Add havelseite

This commit is contained in:
finalnode
2026-05-16 14:16:54 +02:00
parent e53c964609
commit 3f54f53641
79 changed files with 6735 additions and 1 deletions

836
generator/validate.py Normal file
View File

@ -0,0 +1,836 @@
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())