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())