837 lines
28 KiB
Python
837 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import difflib
|
|
import json
|
|
import re
|
|
import sys
|
|
from urllib.parse import urlparse
|
|
|
|
import settings_config
|
|
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
|
CONTENT_DIR = settings_config.active_content_dir()
|
|
GENERATOR_DIR = ROOT_DIR / "generator"
|
|
COMPONENTS_FILE = GENERATOR_DIR / "components.json"
|
|
SETTINGS_FILE = settings_config.SETTINGS_JSON_FILE
|
|
SETTINGS_MARKDOWN_FILE = settings_config.active_settings_file() or settings_config.SETTINGS_MARKDOWN_FILE
|
|
|
|
MEDIA_DIR_NAME = "medien"
|
|
LEGACY_MEDIA_DIR_NAME = "assets"
|
|
GALLERY_DIR_NAME = "galerie"
|
|
LEGACY_GALLERY_DIR_NAME = "gallery"
|
|
FOOTER_DIR_NAME = "fusszeile"
|
|
LEGACY_FOOTER_DIR_NAME = "footer"
|
|
FOOTER_FILE_NAME = "fusszeile.md"
|
|
LEGACY_FOOTER_FILE_NAME = "footer.md"
|
|
|
|
IMAGE_EXTENSIONS = {
|
|
".jpg",
|
|
".jpeg",
|
|
".png",
|
|
".gif",
|
|
".webp",
|
|
".svg"
|
|
}
|
|
|
|
VIDEO_EXTENSIONS = {
|
|
".mp4",
|
|
".webm",
|
|
".ogg",
|
|
".mov"
|
|
}
|
|
|
|
|
|
TRANSLATIONS = {
|
|
"de": {
|
|
"json_error": "FEHLER: {path} ist keine gueltige JSON-Datei.",
|
|
"json_position": " Zeile {line}, Spalte {column}: {message}",
|
|
"navigation_height": "FEHLER: generator/settings.json: navigation.height muss small, medium oder large sein.",
|
|
"navigation_align": "FEHLER: Einstellungen: Navigationsausrichtung muss links, mittig oder rechts sein.",
|
|
"mobile_menu_align": "FEHLER: Einstellungen: Mobilmenü muss automatisch, links, mittig oder rechts sein.",
|
|
"mobile_menu_submenus": "FEHLER: Einstellungen: Mobilmenü-Untermenüs müssen offen oder eingeklappt sein.",
|
|
"language": "FEHLER: generator/settings.json: language muss de oder en sein.",
|
|
"validator_language": "FEHLER: generator/settings.json: validator_language muss de oder en sein.",
|
|
"theme": "FEHLER: Einstellungen: theme muss eine bekannte Farbpalette sein: {themes}.",
|
|
"theme_multiple": "WARNUNG: havelseite/einstellungen.md: Mehrere Farbpaletten sind angekreuzt ({selected}). Ich verwende die erste: {first}.",
|
|
"navigation_position": "FEHLER: Einstellungen: Navigation muss sticky/bleibt oben oder static/scrollt mit sein.",
|
|
"navigation_multiple": "WARNUNG: havelseite/einstellungen.md: Navigation hat mehrere Optionen angekreuzt ({selected}). Ich verwende die erste: {first}.",
|
|
"logo_align": "FEHLER: Einstellungen: Wenn das Logo angezeigt wird, muss links oder rechts angekreuzt sein.",
|
|
"logo_align_center": "FEHLER: Einstellungen: Das Logo kann nur links oder rechts stehen.",
|
|
"logo_missing": "FEHLER: Logo nicht gefunden: havelseite/medien/{folder}/{src}",
|
|
"logo_folder_missing": "FEHLER: Logoordner nicht gefunden oder leer: havelseite/medien/{folder}",
|
|
"logo_multiple": "WARNUNG: havelseite/einstellungen.md: Im Logoordner liegen mehrere Bilder ({files}). Ich verwende alphabetisch das erste: {first}.",
|
|
"gallery_layout": "FEHLER: Einstellungen: Galerie muss quadratisch, breit oder kacheln sein.",
|
|
"icon_style": "FEHLER: Einstellungen: Icons muss rund, schlicht oder text sein.",
|
|
"font": "FEHLER: Einstellungen: Schriftgröße muss klein, mittel oder groß sein.",
|
|
"unknown_block": "FEHLER: {path}:{line}: @{annotation} kenne ich nicht.{hint}",
|
|
"did_you_mean": " Meintest du @{suggestion}?",
|
|
"gallery_missing": "FEHLER: {path}:{line}: Galerieordner fehlt: havelseite/medien/galerie/{parameter}.{hint}",
|
|
"image_missing": "FEHLER: {path}:{line}: Bild nicht gefunden: {src}.{hint}",
|
|
"internal_link_missing": "WARNUNG: {path}:{line}: Interner Link zeigt auf keine Seite: [[{label}]].{hint}",
|
|
"hero_missing": "FEHLER: {path}:{line}: Aufmacher-Datei nicht gefunden: {parameter}",
|
|
"event_single_multiple": "WARNUNG: {path}:{line}: @{annotation} sieht nach mehreren Veranstaltungen aus. Nutze dafuer @veranstaltungen.",
|
|
"events_list_single": "WARNUNG: {path}:{line}: @{annotation} ist fuer mehrere Veranstaltungen gedacht. Fuer eine einzelne Veranstaltung nutze @veranstaltung.",
|
|
"person_single_multiple": "WARNUNG: {path}:{line}: @{annotation} sieht nach mehreren Personen aus. Nutze dafuer @personen.",
|
|
"people_list_single": "WARNUNG: {path}:{line}: @{annotation} ist fuer mehrere Personen gedacht. Fuer eine einzelne Person nutze @person.",
|
|
"nested_page": "FEHLER: {path}: Seiten duerfen nur eine Ordnerebene tief liegen. Bitte verschiebe die Datei direkt nach havelseite/ oder in einen Ordner direkt unter havelseite/.",
|
|
"found_header": "Ich habe ein paar Dinge gefunden:\n",
|
|
"found_footer": "\nBitte korrigiere diese Punkte und pruefe danach nochmal.",
|
|
"ok": "Alles gut. Keine offensichtlichen Probleme gefunden."
|
|
},
|
|
"en": {
|
|
"json_error": "ERROR: {path} is not a valid JSON file.",
|
|
"json_position": " Line {line}, column {column}: {message}",
|
|
"navigation_height": "ERROR: generator/settings.json: navigation.height must be small, medium, or large.",
|
|
"navigation_align": "ERROR: Settings: navigation alignment must be left, center, or right.",
|
|
"mobile_menu_align": "ERROR: Settings: mobile menu must be auto, left, center, or right.",
|
|
"mobile_menu_submenus": "ERROR: Settings: mobile submenu behavior must be open or closed.",
|
|
"language": "ERROR: generator/settings.json: language must be de or en.",
|
|
"validator_language": "ERROR: generator/settings.json: validator_language must be de or en.",
|
|
"theme": "ERROR: Settings: theme must be a known color theme: {themes}.",
|
|
"theme_multiple": "WARNING: havelseite/einstellungen.md: Multiple color themes are checked ({selected}). I will use the first one: {first}.",
|
|
"navigation_position": "ERROR: Settings: navigation.position must be sticky or static.",
|
|
"navigation_multiple": "WARNING: havelseite/einstellungen.md: Navigation has multiple checked options ({selected}). I will use the first one: {first}.",
|
|
"logo_align": "ERROR: Settings: if the logo is shown, left or right must be checked.",
|
|
"logo_align_center": "ERROR: Settings: the logo can only be left or right.",
|
|
"logo_missing": "ERROR: Logo not found: havelseite/medien/{folder}/{src}",
|
|
"logo_folder_missing": "ERROR: Logo folder is missing or empty: havelseite/medien/{folder}",
|
|
"logo_multiple": "WARNING: havelseite/einstellungen.md: Multiple images are in the logo folder ({files}). I will use the alphabetically first one: {first}.",
|
|
"gallery_layout": "ERROR: Settings: gallery layout must be square, wide, or masonry.",
|
|
"icon_style": "ERROR: Settings: icons must be round, simple, or text.",
|
|
"font": "ERROR: Settings: font size must be small, medium, or large.",
|
|
"unknown_block": "ERROR: {path}:{line}: I do not know @{annotation}.{hint}",
|
|
"did_you_mean": " Did you mean @{suggestion}?",
|
|
"gallery_missing": "ERROR: {path}:{line}: Gallery folder is missing: havelseite/medien/galerie/{parameter}.{hint}",
|
|
"image_missing": "ERROR: {path}:{line}: Image not found: {src}.{hint}",
|
|
"internal_link_missing": "WARNING: {path}:{line}: Internal link does not point to a known page: [[{label}]].{hint}",
|
|
"hero_missing": "ERROR: {path}:{line}: Hero file not found: {parameter}",
|
|
"event_single_multiple": "WARNING: {path}:{line}: @{annotation} looks like multiple events. Use @events for that.",
|
|
"events_list_single": "WARNING: {path}:{line}: @{annotation} is meant for multiple events. Use @event for a single event.",
|
|
"person_single_multiple": "WARNING: {path}:{line}: @{annotation} looks like multiple people. Use @people for that.",
|
|
"people_list_single": "WARNING: {path}:{line}: @{annotation} is meant for multiple people. Use @person for one person.",
|
|
"nested_page": "ERROR: {path}: Pages may only be one folder deep. Please move this file directly to havelseite/ or into a folder directly below havelseite/.",
|
|
"found_header": "I found a few things:\n",
|
|
"found_footer": "\nPlease fix these points and check again.",
|
|
"ok": "All good. No obvious problems found."
|
|
}
|
|
}
|
|
|
|
|
|
def language_from_settings() -> str:
|
|
try:
|
|
settings = settings_config.load_settings()
|
|
except (json.JSONDecodeError, OSError):
|
|
return "de"
|
|
|
|
language = settings.get(
|
|
"validator_language",
|
|
settings.get("language", "de")
|
|
)
|
|
|
|
if language not in TRANSLATIONS:
|
|
return "de"
|
|
|
|
return language
|
|
|
|
|
|
def tr(language: str, key: str, **values) -> str:
|
|
return TRANSLATIONS[language][key].format(**values)
|
|
|
|
|
|
def load_json(
|
|
file_path: Path,
|
|
exit_on_error: bool = True
|
|
):
|
|
try:
|
|
with file_path.open(encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except json.JSONDecodeError as error:
|
|
if not exit_on_error:
|
|
raise
|
|
|
|
language = "de"
|
|
|
|
print(tr(
|
|
language,
|
|
"json_error",
|
|
path=file_path.relative_to(ROOT_DIR)
|
|
))
|
|
print(tr(
|
|
language,
|
|
"json_position",
|
|
line=error.lineno,
|
|
column=error.colno,
|
|
message=error.msg
|
|
))
|
|
sys.exit(1)
|
|
|
|
|
|
def is_external_url(value: str) -> bool:
|
|
return (
|
|
value.startswith("http://")
|
|
or value.startswith("https://")
|
|
)
|
|
|
|
|
|
def is_footer_file(md_file: Path) -> bool:
|
|
relative = md_file.relative_to(CONTENT_DIR)
|
|
|
|
return (
|
|
len(relative.parts) >= 2
|
|
and relative.parts[0] in {FOOTER_DIR_NAME, LEGACY_FOOTER_DIR_NAME}
|
|
and md_file.name in {FOOTER_FILE_NAME, LEGACY_FOOTER_FILE_NAME}
|
|
)
|
|
|
|
|
|
def title_from_file(md_file: Path) -> str:
|
|
stem = display_name(md_file.stem)
|
|
|
|
if stem == "index":
|
|
if md_file.parent == CONTENT_DIR:
|
|
return "Start"
|
|
|
|
return display_name(md_file.parent.name).replace("-", " ").title()
|
|
|
|
return stem.replace("-", " ").title()
|
|
|
|
|
|
def display_name(name: str) -> str:
|
|
return re.sub(r"^\d+[_-]+", "", name)
|
|
|
|
|
|
def slugify_title(title: str) -> str:
|
|
slug = title.strip().lower()
|
|
slug = slug.replace("ä", "ae")
|
|
slug = slug.replace("ö", "oe")
|
|
slug = slug.replace("ü", "ue")
|
|
slug = slug.replace("ß", "ss")
|
|
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
|
|
|
return slug.strip("-")
|
|
|
|
|
|
def page_keys() -> set[str]:
|
|
keys = set()
|
|
|
|
for md_file in CONTENT_DIR.rglob("*.md"):
|
|
if is_footer_file(md_file):
|
|
continue
|
|
|
|
if md_file == SETTINGS_MARKDOWN_FILE:
|
|
continue
|
|
|
|
keys.add(md_file.stem.lower())
|
|
keys.add(display_name(md_file.stem).lower())
|
|
keys.add(slugify_title(title_from_file(md_file)))
|
|
|
|
return keys
|
|
|
|
|
|
def page_suggestions() -> list[str]:
|
|
suggestions = []
|
|
|
|
for md_file in CONTENT_DIR.rglob("*.md"):
|
|
if is_footer_file(md_file):
|
|
continue
|
|
|
|
if md_file == SETTINGS_MARKDOWN_FILE:
|
|
continue
|
|
|
|
suggestions.append(title_from_file(md_file))
|
|
suggestions.append(display_name(md_file.stem))
|
|
|
|
return sorted(set(suggestions))
|
|
|
|
|
|
def gallery_suggestion(parameter: str) -> str:
|
|
gallery_dir = CONTENT_DIR / MEDIA_DIR_NAME / GALLERY_DIR_NAME
|
|
|
|
if not gallery_dir.exists():
|
|
gallery_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME / LEGACY_GALLERY_DIR_NAME
|
|
|
|
if not gallery_dir.exists():
|
|
return ""
|
|
|
|
candidates = [
|
|
path.name for path in gallery_dir.iterdir()
|
|
if path.is_dir()
|
|
]
|
|
suggestion = difflib.get_close_matches(parameter, candidates, n=1)
|
|
|
|
if not suggestion:
|
|
return ""
|
|
|
|
return f" Meintest du {suggestion[0]}?"
|
|
|
|
|
|
def media_suggestion(src: str) -> str:
|
|
media_dir = CONTENT_DIR / MEDIA_DIR_NAME
|
|
|
|
if not media_dir.exists():
|
|
media_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME
|
|
|
|
if not media_dir.exists():
|
|
return ""
|
|
|
|
file_name = Path(src).name
|
|
candidates = [
|
|
path.name for path in media_dir.rglob("*")
|
|
if path.is_file()
|
|
]
|
|
suggestion = difflib.get_close_matches(file_name, candidates, n=1)
|
|
|
|
if not suggestion:
|
|
return ""
|
|
|
|
matches = [
|
|
path for path in media_dir.rglob(suggestion[0])
|
|
if path.is_file()
|
|
]
|
|
|
|
if not matches:
|
|
return f" Meintest du {suggestion[0]}?"
|
|
|
|
try:
|
|
suggested_path = matches[0].relative_to(CONTENT_DIR).as_posix()
|
|
except ValueError:
|
|
suggested_path = matches[0].name
|
|
|
|
return f" Meintest du {suggested_path}?"
|
|
|
|
|
|
def content_relative_from_src(src: str, source_file: Path) -> Path:
|
|
src = src.strip().strip("/")
|
|
|
|
variants = [src]
|
|
|
|
if src.startswith(f"{LEGACY_MEDIA_DIR_NAME}/"):
|
|
variants.append(
|
|
f"{MEDIA_DIR_NAME}/{src.removeprefix(f'{LEGACY_MEDIA_DIR_NAME}/')}"
|
|
)
|
|
|
|
variants = [
|
|
variant.replace(
|
|
f"{MEDIA_DIR_NAME}/{LEGACY_GALLERY_DIR_NAME}/",
|
|
f"{MEDIA_DIR_NAME}/{GALLERY_DIR_NAME}/"
|
|
)
|
|
for variant in variants
|
|
]
|
|
|
|
candidates = []
|
|
|
|
for variant in dict.fromkeys(variants):
|
|
candidates.extend([
|
|
source_file.parent / variant,
|
|
CONTENT_DIR / variant
|
|
])
|
|
|
|
for candidate in candidates:
|
|
if candidate.exists():
|
|
return candidate
|
|
|
|
assets_dir = CONTENT_DIR / MEDIA_DIR_NAME
|
|
|
|
if not assets_dir.exists():
|
|
assets_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME
|
|
|
|
if assets_dir.exists():
|
|
matches = sorted([
|
|
path for path in assets_dir.rglob(Path(src).name)
|
|
if path.is_file()
|
|
])
|
|
|
|
if matches:
|
|
return matches[0]
|
|
|
|
return CONTENT_DIR / src
|
|
|
|
|
|
def line_number(text: str, offset: int) -> int:
|
|
return text.count("\n", 0, offset) + 1
|
|
|
|
|
|
def annotated_blocks(text: str) -> list[dict]:
|
|
matches = list(re.finditer(r"(?m)^@([^\s:]+)(?::([^\n]+))?", text))
|
|
blocks = []
|
|
|
|
for index, match in enumerate(matches):
|
|
content_start = match.end()
|
|
content_end = (
|
|
matches[index + 1].start()
|
|
if index + 1 < len(matches)
|
|
else len(text)
|
|
)
|
|
blocks.append({
|
|
"annotation": match.group(1).strip(),
|
|
"parameter": (match.group(2) or "").strip(),
|
|
"content": text[content_start:content_end].strip(),
|
|
"line": line_number(text, match.start())
|
|
})
|
|
|
|
return blocks
|
|
|
|
|
|
def validate_settings(
|
|
messages: list[str],
|
|
language: str
|
|
):
|
|
settings = settings_config.load_settings()
|
|
selected_settings = {}
|
|
|
|
if SETTINGS_MARKDOWN_FILE.exists():
|
|
selected_settings = settings_config.selected_by_section(
|
|
SETTINGS_MARKDOWN_FILE.read_text(encoding="utf-8")
|
|
)
|
|
|
|
if settings.get("language", "de") not in {"de", "en"}:
|
|
messages.append(tr(language, "language"))
|
|
|
|
if settings.get("validator_language", "de") not in {"de", "en"}:
|
|
messages.append(tr(language, "validator_language"))
|
|
|
|
if settings.get("theme", "havel") not in settings_config.COLOR_THEMES:
|
|
messages.append(tr(
|
|
language,
|
|
"theme",
|
|
themes=", ".join(settings_config.COLOR_THEMES.keys())
|
|
))
|
|
|
|
selected_themes = selected_settings.get("theme", [])
|
|
|
|
if len(selected_themes) > 1:
|
|
messages.append(tr(
|
|
language,
|
|
"theme_multiple",
|
|
selected=", ".join(selected_themes),
|
|
first=selected_themes[0]
|
|
))
|
|
|
|
navigation_height = (
|
|
settings
|
|
.get("navigation", {})
|
|
.get("height", "medium")
|
|
)
|
|
|
|
if navigation_height not in {"small", "medium", "large"}:
|
|
messages.append(tr(language, "navigation_height"))
|
|
|
|
navigation_position = (
|
|
settings
|
|
.get("navigation", {})
|
|
.get("position", "sticky")
|
|
)
|
|
|
|
if navigation_position not in {"sticky", "static"}:
|
|
messages.append(tr(language, "navigation_position"))
|
|
|
|
navigation_align = (
|
|
settings
|
|
.get("navigation", {})
|
|
.get("align", "center")
|
|
)
|
|
|
|
if navigation_align not in {"left", "center", "right"}:
|
|
messages.append(tr(language, "navigation_align"))
|
|
|
|
mobile_menu_align = (
|
|
settings
|
|
.get("mobile_menu", {})
|
|
.get("align", "auto")
|
|
)
|
|
|
|
if mobile_menu_align not in {"auto", "left", "center", "right"}:
|
|
messages.append(tr(language, "mobile_menu_align"))
|
|
|
|
mobile_menu_submenus = (
|
|
settings
|
|
.get("mobile_menu", {})
|
|
.get("submenus", "open")
|
|
)
|
|
|
|
if mobile_menu_submenus not in {"open", "closed"}:
|
|
messages.append(tr(language, "mobile_menu_submenus"))
|
|
|
|
selected_navigation = selected_settings.get("navigation", [])
|
|
selected_navigation_heights = [
|
|
value for value in selected_navigation
|
|
if settings_config.normalize_choice(
|
|
value,
|
|
settings_config.SIZE_ALIASES
|
|
) in {"small", "medium", "large"}
|
|
]
|
|
selected_navigation_positions = [
|
|
value for value in selected_navigation
|
|
if settings_config.normalize_choice(
|
|
value,
|
|
settings_config.POSITION_ALIASES
|
|
) in {"sticky", "static"}
|
|
]
|
|
selected_navigation_aligns = [
|
|
value for value in selected_navigation
|
|
if settings_config.normalize_choice(
|
|
value,
|
|
settings_config.ALIGN_ALIASES
|
|
) in {"left", "center", "right"}
|
|
]
|
|
|
|
if len(selected_navigation_heights) > 1:
|
|
messages.append(tr(
|
|
language,
|
|
"navigation_multiple",
|
|
selected=", ".join(selected_navigation_heights),
|
|
first=selected_navigation_heights[0]
|
|
))
|
|
|
|
if len(selected_navigation_positions) > 1:
|
|
messages.append(tr(
|
|
language,
|
|
"navigation_multiple",
|
|
selected=", ".join(selected_navigation_positions),
|
|
first=selected_navigation_positions[0]
|
|
))
|
|
|
|
if len(selected_navigation_aligns) > 1:
|
|
messages.append(tr(
|
|
language,
|
|
"navigation_multiple",
|
|
selected=", ".join(selected_navigation_aligns),
|
|
first=selected_navigation_aligns[0]
|
|
))
|
|
|
|
logo = settings.get("logo", {})
|
|
|
|
if logo.get("enabled", False):
|
|
align = logo.get("align", "left")
|
|
selected_logo = selected_settings.get("logo", [])
|
|
selected_logo_aligns = [
|
|
value for value in selected_logo
|
|
if settings_config.normalize_choice(
|
|
value,
|
|
settings_config.ALIGN_ALIASES
|
|
) in {"left", "right"}
|
|
]
|
|
|
|
if not selected_logo_aligns:
|
|
messages.append(tr(language, "logo_align"))
|
|
|
|
elif align not in {"left", "center", "right"}:
|
|
messages.append(tr(language, "logo_align"))
|
|
elif align == "center":
|
|
messages.append(tr(language, "logo_align_center"))
|
|
|
|
src = logo.get("src", "").strip()
|
|
folder = logo.get("folder", "logo").strip().strip("/") or "logo"
|
|
|
|
if src:
|
|
logo_file = CONTENT_DIR / MEDIA_DIR_NAME / folder / src
|
|
|
|
if not logo_file.exists():
|
|
logo_file = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME / folder / src
|
|
|
|
if not logo_file.exists():
|
|
messages.append(tr(
|
|
language,
|
|
"logo_missing",
|
|
folder=folder,
|
|
src=src
|
|
))
|
|
else:
|
|
logo_dir = CONTENT_DIR / MEDIA_DIR_NAME / folder
|
|
|
|
if not logo_dir.exists():
|
|
logo_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME / folder
|
|
|
|
logo_files = sorted([
|
|
file.name for file in logo_dir.iterdir()
|
|
if file.is_file() and file.suffix.lower() in IMAGE_EXTENSIONS
|
|
]) if logo_dir.exists() else []
|
|
|
|
if not logo_files:
|
|
messages.append(tr(
|
|
language,
|
|
"logo_folder_missing",
|
|
folder=folder
|
|
))
|
|
elif len(logo_files) > 1:
|
|
messages.append(tr(
|
|
language,
|
|
"logo_multiple",
|
|
files=", ".join(logo_files),
|
|
first=logo_files[0]
|
|
))
|
|
|
|
gallery_layout = (
|
|
settings
|
|
.get("gallery", {})
|
|
.get("layout", "square")
|
|
)
|
|
|
|
if gallery_layout not in {"square", "wide", "masonry"}:
|
|
messages.append(tr(language, "gallery_layout"))
|
|
|
|
icon_style = (
|
|
settings
|
|
.get("icons", {})
|
|
.get("style", "round")
|
|
)
|
|
|
|
if icon_style not in {"round", "simple", "text"}:
|
|
messages.append(tr(language, "icon_style"))
|
|
|
|
font_size = (
|
|
settings
|
|
.get("typography", {})
|
|
.get("size", "medium")
|
|
)
|
|
|
|
if font_size not in {"small", "medium", "large"}:
|
|
messages.append(tr(language, "font"))
|
|
|
|
|
|
def validate_markdown_files(
|
|
messages: list[str],
|
|
language: str
|
|
):
|
|
components = load_json(COMPONENTS_FILE)
|
|
known_annotations = set(components.keys())
|
|
known_pages = page_keys()
|
|
known_page_suggestions = page_suggestions()
|
|
|
|
for md_file in sorted(CONTENT_DIR.rglob("*.md")):
|
|
if md_file == SETTINGS_MARKDOWN_FILE:
|
|
continue
|
|
|
|
if len(md_file.relative_to(CONTENT_DIR).parts) > 2:
|
|
messages.append(tr(
|
|
language,
|
|
"nested_page",
|
|
path=md_file.relative_to(ROOT_DIR)
|
|
))
|
|
continue
|
|
|
|
text = md_file.read_text(encoding="utf-8")
|
|
display_path = md_file.relative_to(ROOT_DIR)
|
|
|
|
for match in re.finditer(r"(?m)^@([^\s:]+)(?::([^\n]+))?", text):
|
|
annotation = match.group(1).strip()
|
|
parameter = (match.group(2) or "").strip()
|
|
line = line_number(text, match.start())
|
|
|
|
if annotation not in known_annotations:
|
|
suggestion = difflib.get_close_matches(
|
|
annotation,
|
|
known_annotations,
|
|
n=1
|
|
)
|
|
|
|
hint = (
|
|
tr(
|
|
language,
|
|
"did_you_mean",
|
|
suggestion=suggestion[0]
|
|
)
|
|
if suggestion
|
|
else ""
|
|
)
|
|
|
|
messages.append(tr(
|
|
language,
|
|
"unknown_block",
|
|
path=display_path,
|
|
line=line,
|
|
annotation=annotation,
|
|
hint=hint
|
|
))
|
|
|
|
component = components.get(annotation, annotation)
|
|
|
|
if component == "gallery" and parameter:
|
|
gallery_dir = CONTENT_DIR / MEDIA_DIR_NAME / GALLERY_DIR_NAME / parameter
|
|
|
|
if not gallery_dir.exists():
|
|
gallery_dir = (
|
|
CONTENT_DIR
|
|
/ LEGACY_MEDIA_DIR_NAME
|
|
/ LEGACY_GALLERY_DIR_NAME
|
|
/ parameter
|
|
)
|
|
|
|
if not gallery_dir.exists():
|
|
messages.append(tr(
|
|
language,
|
|
"gallery_missing",
|
|
path=display_path,
|
|
line=line,
|
|
parameter=parameter,
|
|
hint=gallery_suggestion(parameter)
|
|
))
|
|
|
|
if component == "hero" and parameter:
|
|
validate_media_parameter(
|
|
messages,
|
|
display_path,
|
|
line,
|
|
parameter,
|
|
md_file,
|
|
language
|
|
)
|
|
|
|
for block in annotated_blocks(text):
|
|
component = components.get(block["annotation"], block["annotation"])
|
|
h2_count = len(re.findall(r"(?m)^##\s+", block["content"]))
|
|
|
|
if component == "event" and h2_count > 1:
|
|
messages.append(tr(
|
|
language,
|
|
"event_single_multiple",
|
|
path=display_path,
|
|
line=block["line"],
|
|
annotation=block["annotation"]
|
|
))
|
|
|
|
if component == "events" and h2_count < 2:
|
|
messages.append(tr(
|
|
language,
|
|
"events_list_single",
|
|
path=display_path,
|
|
line=block["line"],
|
|
annotation=block["annotation"]
|
|
))
|
|
|
|
if component == "person" and h2_count > 1:
|
|
messages.append(tr(
|
|
language,
|
|
"person_single_multiple",
|
|
path=display_path,
|
|
line=block["line"],
|
|
annotation=block["annotation"]
|
|
))
|
|
|
|
if component == "people" and h2_count < 2:
|
|
messages.append(tr(
|
|
language,
|
|
"people_list_single",
|
|
path=display_path,
|
|
line=block["line"],
|
|
annotation=block["annotation"]
|
|
))
|
|
|
|
for match in re.finditer(r"!\[(.*?)\]\((.*?)\)", text):
|
|
src = match.group(2).strip()
|
|
|
|
if is_external_url(src):
|
|
continue
|
|
|
|
file_path = content_relative_from_src(src, md_file)
|
|
|
|
if not file_path.exists():
|
|
messages.append(tr(
|
|
language,
|
|
"image_missing",
|
|
path=display_path,
|
|
line=line_number(text, match.start()),
|
|
src=src,
|
|
hint=media_suggestion(src)
|
|
))
|
|
|
|
for match in re.finditer(r"\[\[(.*?)\]\]", text):
|
|
label = match.group(1).strip()
|
|
key = slugify_title(label)
|
|
|
|
if key not in known_pages:
|
|
suggestion = difflib.get_close_matches(
|
|
label,
|
|
known_page_suggestions,
|
|
n=1
|
|
)
|
|
hint = (
|
|
tr(
|
|
language,
|
|
"did_you_mean",
|
|
suggestion=suggestion[0]
|
|
)
|
|
if suggestion
|
|
else ""
|
|
)
|
|
|
|
messages.append(tr(
|
|
language,
|
|
"internal_link_missing",
|
|
path=display_path,
|
|
line=line_number(text, match.start()),
|
|
label=label,
|
|
hint=hint
|
|
))
|
|
|
|
|
|
def validate_media_parameter(
|
|
messages: list[str],
|
|
display_path: Path,
|
|
line: int,
|
|
parameter: str,
|
|
md_file: Path,
|
|
language: str
|
|
):
|
|
parsed = urlparse(parameter)
|
|
suffix = Path(parsed.path).suffix.lower()
|
|
|
|
if is_external_url(parameter):
|
|
return
|
|
|
|
if suffix in IMAGE_EXTENSIONS or suffix in VIDEO_EXTENSIONS:
|
|
file_path = content_relative_from_src(parameter, md_file)
|
|
|
|
if not file_path.exists():
|
|
messages.append(tr(
|
|
language,
|
|
"hero_missing",
|
|
path=display_path,
|
|
line=line,
|
|
parameter=parameter
|
|
))
|
|
|
|
|
|
def collect_messages() -> tuple[list[str], str]:
|
|
messages: list[str] = []
|
|
settings_config.format_settings_file()
|
|
language = language_from_settings()
|
|
|
|
validate_settings(messages, language)
|
|
validate_markdown_files(messages, language)
|
|
|
|
return messages, language
|
|
|
|
|
|
def main() -> int:
|
|
messages, language = collect_messages()
|
|
|
|
if messages:
|
|
print(tr(language, "found_header"))
|
|
|
|
for message in messages:
|
|
print(f"- {message}")
|
|
|
|
print(tr(language, "found_footer"))
|
|
|
|
return 1 if has_errors(messages) else 0
|
|
|
|
print(tr(language, "ok"))
|
|
|
|
return 0
|
|
|
|
|
|
def has_errors(messages: list[str]) -> bool:
|
|
return any(
|
|
not (
|
|
message.startswith("WARNUNG:")
|
|
or message.startswith("WARNING:")
|
|
)
|
|
for message in messages
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|