from __future__ import annotations from pathlib import Path import json import re ROOT_DIR = Path(__file__).resolve().parent.parent CONTENT_DIR = ROOT_DIR / "havelseite" LEGACY_CONTENT_DIR = ROOT_DIR / "inhalt" OLD_CONTENT_DIR = ROOT_DIR / "content" GENERATOR_DIR = ROOT_DIR / "generator" SETTINGS_JSON_FILE = GENERATOR_DIR / "settings.json" SETTINGS_MARKDOWN_FILE = CONTENT_DIR / "einstellungen.md" LEGACY_SETTINGS_MARKDOWN_FILE = LEGACY_CONTENT_DIR / "einstellungen.md" OLD_SETTINGS_MARKDOWN_FILE = OLD_CONTENT_DIR / "settings.md" THEMES_DIR = GENERATOR_DIR / "themes" MEDIA_DIR_NAME = "medien" LEGACY_MEDIA_DIR_NAME = "assets" IMAGE_EXTENSIONS = { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg" } def load_color_themes() -> dict: themes = {} if not THEMES_DIR.exists(): return themes for file in sorted(THEMES_DIR.glob("*.json")): with file.open(encoding="utf-8") as f: themes[file.stem] = json.load(f) return themes COLOR_THEMES = load_color_themes() THEME_ORDER = [ "havel", "wasser", "wald", "sonnenuntergang", "dunkel", "kueste", "segel", "leuchtturm" ] THEME_LABELS = { "dunkel": "Dunkel", "havel": "Havel", "kueste": "Küste", "leuchtturm": "Leuchtturm", "segel": "Segel", "sonnenuntergang": "Sonnenuntergang", "wald": "Wald", "wasser": "Wasser" } THEME_ALIASES = { "ocean": "wasser", "forest": "wald", "sunset": "sonnenuntergang", "dark": "dunkel" } DEFAULT_SETTINGS = { "title": "Havelseiten", "language": "de", "validator_language": "de", "theme": "havel", "navigation": { "height": "medium", "position": "sticky", "align": "center" }, "mobile_menu": { "align": "auto", "submenus": "closed" }, "logo": { "enabled": True, "header": True, "footer": False, "align": "left", "folder": "logo", "src": "", "alt": "Märkischer Seglerverein Beetzsee", "text": "Havelseiten" }, "gallery": { "layout": "square", "captions": True, "rounded": True }, "icons": { "style": "round" }, "privacy": { "external_content": False }, "typography": { "size": "medium" }, "social": {}, "colors": {} } SETTING_ALIASES = { "seite": "site", "website": "site", "allgemein": "site", "sprache": "language", "language": "language", "pruefung": "language", "pruefsprache": "language", "prüfung": "language", "design": "theme", "farbpalette": "theme", "theme": "theme", "farben": "colors", "farbe": "colors", "navigation": "navigation", "nav": "navigation", "mobilmenue": "mobile_menu", "mobilmenu": "mobile_menu", "mobile_menu": "mobile_menu", "burger": "mobile_menu", "logo": "logo", "logo_datei": "logo_file", "logodatei": "logo_file", "logo-datei": "logo_file", "logo_file": "logo_file", "galerie": "gallery", "gallery": "gallery", "icons": "icons", "symbole": "icons", "schrift": "typography", "schriftart": "typography", "typography": "typography", "social": "social", "soziales": "social", "soziale_medien": "social", "bilder": "images", "images": "images", "datenschutz": "privacy", "privacy": "privacy", "externe_inhalte": "privacy" } SIZE_ALIASES = { "small": "small", "klein": "small", "medium": "medium", "mittel": "medium", "large": "large", "gross": "large", "groß": "large" } POSITION_ALIASES = { "sticky": "sticky", "bleibt_oben": "sticky", "oben_bleiben": "sticky", "klebt_oben": "sticky", "static": "static", "scrollt_mit": "static", "normal": "static" } ALIGN_ALIASES = { "left": "left", "links": "left", "center": "center", "mitte": "center", "mittig": "center", "right": "right", "rechts": "right" } MOBILE_MENU_ALIASES = { "auto": "auto", "automatisch": "auto", "left": "left", "links": "left", "center": "center", "mitte": "center", "mittig": "center", "right": "right", "rechts": "right" } MOBILE_SUBMENU_ALIASES = { "open": "open", "offen": "open", "aufgeklappt": "open", "ausgeklappt": "open", "untermenues_offen": "open", "untermenus_offen": "open", "untermenues_ausgeklappt": "open", "untermenus_ausgeklappt": "open", "expanded": "open", "closed": "closed", "geschlossen": "closed", "eingeklappt": "closed", "einklappbar": "closed", "untermenues_einklappen": "closed", "untermenus_einklappen": "closed", "untermenues_einklappbar": "closed", "untermenus_einklappbar": "closed", "collapsed": "closed" } GALLERY_LAYOUT_ALIASES = { "square": "square", "quadratisch": "square", "wide": "wide", "breit": "wide", "masonry": "masonry", "kacheln": "masonry" } ICON_STYLE_ALIASES = { "round": "round", "rund": "round", "simple": "simple", "schlicht": "simple", "text": "text" } FONT_ALIASES = { "system": "system", "standard": "system", "modern": "modern", "klassisch": "classic", "classic": "classic", "freundlich": "friendly", "friendly": "friendly" } FONT_SIZE_ALIASES = { "small": "small", "klein": "small", "medium": "medium", "mittel": "medium", "normal": "medium", "large": "large", "gross": "large", "groß": "large" } CHOICE_SETTINGS = { "language", "navigation", "logo_file", "mobile_menu", "icons", "typography" } OPTIONAL_CHECKBOX_OPTIONS = { "untermenues_ausgeklappt", "untermenus_ausgeklappt" } def merge_settings(defaults: dict, custom: dict) -> dict: merged = defaults.copy() for key, value in custom.items(): if ( isinstance(value, dict) and isinstance(merged.get(key), dict) ): merged[key] = merge_settings(merged[key], value) else: merged[key] = value return merged def slug(value: str) -> str: value = value.strip().lower() value = value.replace("ä", "ae") value = value.replace("ö", "oe") value = value.replace("ü", "ue") value = value.replace("ß", "ss") value = re.sub(r"[^a-z0-9]+", "_", value) return value.strip("_") def normalize_choice(value: str, aliases: dict[str, str]) -> str: value_slug = slug(value) return aliases.get(value_slug, value_slug) def active_content_dir() -> Path: if CONTENT_DIR.exists(): return CONTENT_DIR if LEGACY_CONTENT_DIR.exists(): return LEGACY_CONTENT_DIR return OLD_CONTENT_DIR def active_settings_file() -> Path | None: if SETTINGS_MARKDOWN_FILE.exists(): return SETTINGS_MARKDOWN_FILE if LEGACY_SETTINGS_MARKDOWN_FILE.exists(): return LEGACY_SETTINGS_MARKDOWN_FILE if OLD_SETTINGS_MARKDOWN_FILE.exists(): return OLD_SETTINGS_MARKDOWN_FILE return None def parse_bool(value: str) -> bool: return slug(value) in {"true", "yes", "ja", "enabled", "aktiv", "an", "anzeigen"} def selected_options(lines: list[str]) -> list[str]: selected = [] for line in lines: match = re.match(r"^-\s*\[(.*?)\]\s*(.+)$", line.strip()) if match and "x" in match.group(1).lower(): selected.append(match.group(2).strip()) return selected def setting_sections(text: str) -> list[tuple[str, list[str]]]: sections = [] current_name = None current_lines = [] for line in text.splitlines(): match = re.match(r"^@(setting|einstellung):(.+)$", line.strip(), re.I) if match: if current_name: sections.append((current_name, current_lines)) current_name = slug(match.group(2)) current_lines = [] elif current_name: current_lines.append(line) if current_name: sections.append((current_name, current_lines)) return sections def selected_by_section(text: str) -> dict[str, list[str]]: return { SETTING_ALIASES.get(name, name): selected_options(lines) for name, lines in setting_sections(text) } def theme_keys() -> list[str]: known = [key for key in THEME_ORDER if key in COLOR_THEMES] extra = sorted(key for key in COLOR_THEMES if key not in THEME_ORDER) return known + extra def display_theme_name(key: str) -> str: if key in THEME_LABELS: return THEME_LABELS[key] return key.replace("_", " ").title() def normalize_checkbox_lines(text: str) -> str: lines = [] for line in text.splitlines(): match = re.match(r"^(\s*-\s*)\[(.*?)\](.*)$", line) if match: marker = "x" if "x" in match.group(2).lower() else " " lines.append(f"{match.group(1)}[{marker}]{match.group(3)}") else: lines.append(line.rstrip()) return "\n".join(lines).rstrip() + "\n" def refresh_theme_options(text: str) -> str: lines = text.splitlines() formatted = [] index = 0 while index < len(lines): line = lines[index] match = re.match(r"^@(setting|einstellung):(.+)$", line.strip(), re.I) setting_name = None if match: setting_name = SETTING_ALIASES.get(slug(match.group(2)), slug(match.group(2))) if setting_name != "theme": formatted.append(line) index += 1 continue formatted.append(line) index += 1 section_lines = [] while index < len(lines): next_line = lines[index] if re.match(r"^@(setting|einstellung):(.+)$", next_line.strip(), re.I): break section_lines.append(next_line) index += 1 selected_themes = { normalize_theme(option) for option in selected_options(section_lines) } if not selected_themes: selected_themes = {DEFAULT_SETTINGS["theme"]} for key in theme_keys(): marker = "x" if key in selected_themes else " " formatted.append(f"- [{marker}] {display_theme_name(key)}") if index < len(lines): formatted.append("") return "\n".join(formatted).rstrip() + "\n" def logo_folder_from_text(text: str) -> str: for raw_name, lines in setting_sections(text): name = SETTING_ALIASES.get(raw_name, raw_name) if name != "logo": continue values = key_values(lines) for key in ["logoordner", "logo_ordner", "ordner"]: if key in values: return values[key].strip().strip("/") or DEFAULT_SETTINGS["logo"]["folder"] return DEFAULT_SETTINGS["logo"]["folder"] def logo_files(folder: str) -> list[str]: content_dir = active_content_dir() logo_dir = content_dir / MEDIA_DIR_NAME / folder if not logo_dir.exists(): logo_dir = content_dir / LEGACY_MEDIA_DIR_NAME / folder if not logo_dir.exists(): return [] return sorted([ file.name for file in logo_dir.iterdir() if file.is_file() and file.suffix.lower() in IMAGE_EXTENSIONS ]) def logo_file_lines(files: list[str], selected: list[str]) -> list[str]: selected_files = {file for file in selected if file in files} if not selected_files and files: selected_files = {files[0]} return [ f"- [{'x' if file in selected_files else ' '}] {file}" for file in files ] def refresh_logo_file_options(text: str) -> str: files = logo_files(logo_folder_from_text(text)) if not files: return text lines = text.splitlines() formatted = [] index = 0 has_logo_file_section = any( SETTING_ALIASES.get(raw_name, raw_name) == "logo_file" for raw_name, _ in setting_sections(text) ) while index < len(lines): line = lines[index] match = re.match(r"^@(setting|einstellung):(.+)$", line.strip(), re.I) setting_name = None if match: setting_name = SETTING_ALIASES.get(slug(match.group(2)), slug(match.group(2))) if setting_name == "logo_file": formatted.append("@einstellung:logo-datei") index += 1 section_lines = [] while index < len(lines): next_line = lines[index] if re.match(r"^@(setting|einstellung):(.+)$", next_line.strip(), re.I): break section_lines.append(next_line) index += 1 formatted.extend(logo_file_lines(files, selected_options(section_lines))) if index < len(lines) and formatted[-1] != "": formatted.append("") continue formatted.append(line) index += 1 if setting_name == "logo" and not has_logo_file_section: while index < len(lines): next_line = lines[index] if re.match(r"^@(setting|einstellung):(.+)$", next_line.strip(), re.I): break formatted.append(next_line) index += 1 if formatted[-1] != "": formatted.append("") formatted.append("@einstellung:logo-datei") formatted.extend(logo_file_lines(files, [])) if index < len(lines) and formatted[-1] != "": formatted.append("") return "\n".join(formatted).rstrip() + "\n" def checkbox_group_has_choice(lines: list[str]) -> bool: return any( re.match(r"^\s*-\s*\[x\]", line, re.I) for line in lines ) def checkbox_group_is_optional(lines: list[str]) -> bool: options = [ slug(match.group(1)) for line in lines if (match := re.match(r"^\s*-\s*\[(?:.*?)\]\s*(.+)$", line)) ] return bool(options) and all( option in OPTIONAL_CHECKBOX_OPTIONS for option in options ) def default_first_option(text: str) -> str: lines = text.splitlines() formatted = [] section_name = None checkbox_group = [] def flush_group(): nonlocal checkbox_group if not checkbox_group: return should_default = ( section_name in CHOICE_SETTINGS and not checkbox_group_has_choice(checkbox_group) and not checkbox_group_is_optional(checkbox_group) ) for offset, group_line in enumerate(checkbox_group): if should_default and offset == 0: formatted.append(re.sub(r"\[(.*?)\]", "[x]", group_line, count=1)) else: formatted.append(group_line) checkbox_group = [] for line in lines: section_match = re.match(r"^@(setting|einstellung):(.+)$", line.strip(), re.I) checkbox_match = re.match(r"^\s*-\s*\[(.*?)\]", line) if section_match: flush_group() section_name = SETTING_ALIASES.get( slug(section_match.group(2)), slug(section_match.group(2)) ) formatted.append(line) continue if checkbox_match: checkbox_group.append(line) continue flush_group() formatted.append(line) flush_group() return "\n".join(formatted).rstrip() + "\n" def format_settings_markdown(text: str) -> str: text = normalize_checkbox_lines(text) text = refresh_theme_options(text) text = refresh_logo_file_options(text) return default_first_option(text) def format_settings_file() -> bool: settings_file = active_settings_file() if not settings_file: return False original = settings_file.read_text(encoding="utf-8") formatted = format_settings_markdown(original) if formatted == original: return False settings_file.write_text(formatted, encoding="utf-8") return True def key_values(lines: list[str]) -> dict: values = {} for line in lines: match = re.match(r"^([A-Za-zÄÖÜäöüß _-]+):\s*(.+)$", line.strip()) if match: values[slug(match.group(1))] = match.group(2).strip() return values def normalize_theme(value: str) -> str: theme = slug(value) return THEME_ALIASES.get(theme, theme) def selected_slugs(selected: list[str]) -> set[str]: return {slug(value) for value in selected} def selected_first(selected: list[str], aliases: dict[str, str]) -> str | None: for value in selected: normalized = normalize_choice(value, aliases) if normalized in aliases.values(): return normalized return None def parse_settings_markdown(text: str) -> dict: settings = {} for raw_name, lines in setting_sections(text): name = SETTING_ALIASES.get(raw_name, raw_name) values = key_values(lines) selected = selected_options(lines) selected_names = selected_slugs(selected) if name == "site": if "title" in values: settings["title"] = values["title"] if "titel" in values: settings["title"] = values["titel"] if "language" in values: settings["language"] = normalize_choice(values["language"], {"deutsch": "de", "english": "en", "englisch": "en"}) if "sprache" in values: settings["language"] = normalize_choice(values["sprache"], {"deutsch": "de", "english": "en", "englisch": "en"}) if "validator_language" in values: settings["validator_language"] = normalize_choice(values["validator_language"], {"deutsch": "de", "english": "en", "englisch": "en"}) if "pruefsprache" in values: settings["validator_language"] = normalize_choice(values["pruefsprache"], {"deutsch": "de", "english": "en", "englisch": "en"}) elif name == "language": language = selected_first( selected, {"deutsch": "de", "english": "en", "englisch": "en"} ) if language: settings["language"] = language settings["validator_language"] = language elif name == "theme": if selected: settings["theme"] = normalize_theme(selected[0]) elif name == "colors": settings["colors"] = values elif name == "navigation": navigation = {} height = selected_first(selected, SIZE_ALIASES) position = selected_first(selected, POSITION_ALIASES) align = selected_first(selected, ALIGN_ALIASES) if height: navigation["height"] = height if position: navigation["position"] = position if align: navigation["align"] = align if "height" in values: navigation["height"] = normalize_choice(values["height"], SIZE_ALIASES) if "hoehe" in values: navigation["height"] = normalize_choice(values["hoehe"], SIZE_ALIASES) if "position" in values: navigation["position"] = normalize_choice(values["position"], POSITION_ALIASES) if "verhalten" in values: navigation["position"] = normalize_choice(values["verhalten"], POSITION_ALIASES) if "ausrichtung" in values: navigation["align"] = normalize_choice(values["ausrichtung"], ALIGN_ALIASES) legacy_position = normalize_choice(selected[0], POSITION_ALIASES) if selected else None if legacy_position in {"sticky", "static"} and not position and not height: navigation["position"] = legacy_position settings["navigation"] = navigation elif name == "logo": logo = {} header_selected = any( value in selected_names for value in {"in_der_navigation", "navigation", "header", "oben"} ) footer_selected = any( value in selected_names for value in {"in_der_fusszeile", "im_footer", "footer", "fusszeile", "unten"} ) logo["enabled"] = header_selected or footer_selected logo["header"] = header_selected logo["footer"] = footer_selected allowed_aligns = {"left", "right"} first_allowed = next( ( normalize_choice(value, ALIGN_ALIASES) for value in selected if normalize_choice(value, ALIGN_ALIASES) in allowed_aligns ), None ) if first_allowed: logo["align"] = first_allowed for key in ["src", "alt", "text"]: if key in values: logo[key] = values[key] if "datei" in values: logo["src"] = values["datei"] if "logoordner" in values: logo["folder"] = values["logoordner"] if "logo_ordner" in values: logo["folder"] = values["logo_ordner"] if "ordner" in values: logo["folder"] = values["ordner"] if "alternativtext" in values: logo["alt"] = values["alternativtext"] if "ausrichtung" in values: logo["align"] = normalize_choice(values["ausrichtung"], ALIGN_ALIASES) if "header" in values: logo["header"] = parse_bool(values["header"]) if "navigation" in values: logo["header"] = parse_bool(values["navigation"]) if "footer" in values: logo["footer"] = parse_bool(values["footer"]) if "fusszeile" in values: logo["footer"] = parse_bool(values["fusszeile"]) settings["logo"] = logo elif name == "logo_file": if selected: settings.setdefault("logo", {}) settings["logo"]["src"] = selected[0] elif name == "mobile_menu": mobile_menu = {} align = selected_first(selected, MOBILE_MENU_ALIASES) if align: mobile_menu["align"] = align mobile_menu["submenus"] = "open" if any( slug(value) in OPTIONAL_CHECKBOX_OPTIONS for value in selected ) else "closed" if "ausrichtung" in values: mobile_menu["align"] = normalize_choice( values["ausrichtung"], MOBILE_MENU_ALIASES ) if "untermenues" in values: mobile_menu["submenus"] = normalize_choice( values["untermenues"], MOBILE_SUBMENU_ALIASES ) if "untermenus" in values: mobile_menu["submenus"] = normalize_choice( values["untermenus"], MOBILE_SUBMENU_ALIASES ) if "submenus" in values: mobile_menu["submenus"] = normalize_choice( values["submenus"], MOBILE_SUBMENU_ALIASES ) settings["mobile_menu"] = mobile_menu elif name == "gallery": gallery = {} if selected: gallery["layout"] = normalize_choice(selected[0], GALLERY_LAYOUT_ALIASES) if "layout" in values: gallery["layout"] = normalize_choice(values["layout"], GALLERY_LAYOUT_ALIASES) if "darstellung" in values: gallery["layout"] = normalize_choice(values["darstellung"], GALLERY_LAYOUT_ALIASES) if "bildunterschriften" in values: gallery["captions"] = parse_bool(values["bildunterschriften"]) settings["gallery"] = gallery elif name == "images": gallery = {} if "bildunterschriften" in selected_names: gallery["captions"] = True if "keine_bildunterschriften" in selected_names: gallery["captions"] = False if "runde_ecken" in selected_names: gallery["rounded"] = True if "eckige_bilder" in selected_names or "keine_runden_ecken" in selected_names: gallery["rounded"] = False if "bildunterschriften" in values: gallery["captions"] = parse_bool(values["bildunterschriften"]) if "runde_ecken" in values: gallery["rounded"] = parse_bool(values["runde_ecken"]) settings["gallery"] = gallery elif name == "icons": icons = {} if selected: icons["style"] = normalize_choice(selected[0], ICON_STYLE_ALIASES) if "stil" in values: icons["style"] = normalize_choice(values["stil"], ICON_STYLE_ALIASES) if "style" in values: icons["style"] = normalize_choice(values["style"], ICON_STYLE_ALIASES) settings["icons"] = icons elif name == "privacy": privacy = {} if "externe_inhalte_laden" in selected_names: privacy["external_content"] = True if "externe_inhalte_nicht_laden" in selected_names: privacy["external_content"] = False if "external_content" in values: privacy["external_content"] = parse_bool(values["external_content"]) if "externe_inhalte" in values: privacy["external_content"] = parse_bool(values["externe_inhalte"]) settings["privacy"] = privacy elif name == "typography": typography = {} size = selected_first(selected, FONT_SIZE_ALIASES) if size: typography["size"] = size elif selected: typography["font"] = normalize_choice(selected[0], FONT_ALIASES) if "groesse" in values: typography["size"] = normalize_choice(values["groesse"], FONT_SIZE_ALIASES) if "größe" in values: typography["size"] = normalize_choice(values["größe"], FONT_SIZE_ALIASES) if "size" in values: typography["size"] = normalize_choice(values["size"], FONT_SIZE_ALIASES) if "schriftart" in values: typography["font"] = normalize_choice(values["schriftart"], FONT_ALIASES) if "font" in values: typography["font"] = normalize_choice(values["font"], FONT_ALIASES) settings["typography"] = typography elif name == "social": settings["social"] = values return settings def load_json_settings() -> dict: if not SETTINGS_JSON_FILE.exists(): return {} with SETTINGS_JSON_FILE.open(encoding="utf-8") as f: return json.load(f) def load_markdown_settings() -> dict: settings_file = active_settings_file() if not settings_file: return {} return parse_settings_markdown( settings_file.read_text(encoding="utf-8") ) def apply_theme(settings: dict) -> dict: theme = normalize_theme(settings.get("theme", "havel")) fallback = next(iter(COLOR_THEMES.values()), {}) theme_colors = COLOR_THEMES.get(theme, fallback) custom_colors = settings.get("colors", {}) settings["theme"] = theme settings["colors"] = { **theme_colors, **custom_colors } return settings def apply_typography(settings: dict) -> dict: font = settings.get("typography", {}).get("font", "system") size = settings.get("typography", {}).get("size", "medium") stacks = { "system": { "body": "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", "heading": "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" }, "modern": { "body": "'Avenir Next', Avenir, 'Segoe UI', system-ui, sans-serif", "heading": "'Avenir Next', Avenir, 'Segoe UI', system-ui, sans-serif" }, "classic": { "body": "Georgia, 'Times New Roman', serif", "heading": "Georgia, 'Times New Roman', serif" }, "friendly": { "body": "'Trebuchet MS', 'Arial Rounded MT Bold', Verdana, system-ui, sans-serif", "heading": "'Trebuchet MS', 'Arial Rounded MT Bold', Verdana, system-ui, sans-serif" } } settings["typography"] = { **settings.get("typography", {}), "size": size, **stacks.get(font, stacks["system"]) } return settings def load_settings() -> dict: if active_settings_file(): settings = merge_settings( DEFAULT_SETTINGS, load_markdown_settings() ) else: settings = merge_settings( DEFAULT_SETTINGS, load_json_settings() ) return apply_typography(apply_theme(settings))