+
\ No newline at end of file
diff --git a/generator/templates/downloads.html b/generator/templates/downloads.html
new file mode 100644
index 0000000..1857dd4
--- /dev/null
+++ b/generator/templates/downloads.html
@@ -0,0 +1,13 @@
+
+
+
diff --git a/generator/templates/hero.html b/generator/templates/hero.html
new file mode 100644
index 0000000..acbf300
--- /dev/null
+++ b/generator/templates/hero.html
@@ -0,0 +1,44 @@
+{% set hero_classes = "hero" %}
+{% if hero %}
+ {% set hero_classes = hero_classes ~ " hero-has-media hero-" ~ hero.type %}
+{% endif %}
+
+
+ {% if hero and hero.type == "image" %}
+
+ {% elif hero and hero.type == "video" %}
+
+ {% elif hero and hero.type == "youtube" and external_content_enabled %}
+
+ {% elif hero and hero.type == "youtube" %}
+
+
+ Externes YouTube-Video
+
Dieses Video wird aus Datenschutzgruenden nicht automatisch geladen.
+
+
+
+ {% endif %}
+
+
+ {{ content | safe }}
+
+
diff --git a/generator/templates/image-text.html b/generator/templates/image-text.html
new file mode 100644
index 0000000..7f5fc6b
--- /dev/null
+++ b/generator/templates/image-text.html
@@ -0,0 +1,11 @@
+
+ {% if image %}
+
+
+
+ {% endif %}
+
+
+ {{ content | safe }}
+
+
\ No newline at end of file
diff --git a/generator/templates/location.html b/generator/templates/location.html
new file mode 100644
index 0000000..3c96e18
--- /dev/null
+++ b/generator/templates/location.html
@@ -0,0 +1,26 @@
+
+
+ {{ content | safe }}
+
+ {% if address %}
+
{{ address }}
+ {% endif %}
+
+
+ {% if address %}
+
+ Externe Karte
+
Die Karte wird aus Datenschutzgruenden nicht automatisch geladen.
+
\ No newline at end of file
diff --git a/generator/templates/sponsors.html b/generator/templates/sponsors.html
new file mode 100644
index 0000000..7d18e55
--- /dev/null
+++ b/generator/templates/sponsors.html
@@ -0,0 +1,17 @@
+
+
+ {{ title | safe }}
+
+
+
+ {% for sponsor in sponsors %}
+
+
+
+ {% if sponsor.alt %}
+ {{ sponsor.alt }}
+ {% endif %}
+
+ {% endfor %}
+
+
diff --git a/generator/templates/text.html b/generator/templates/text.html
new file mode 100644
index 0000000..fd8d2f8
--- /dev/null
+++ b/generator/templates/text.html
@@ -0,0 +1,3 @@
+
+ {{ content | safe }}
+
\ No newline at end of file
diff --git a/generator/templates/timeline.html b/generator/templates/timeline.html
new file mode 100644
index 0000000..51917d0
--- /dev/null
+++ b/generator/templates/timeline.html
@@ -0,0 +1,11 @@
+
+
+ {{ title | safe }}
+
+
+ {% for item in items %}
+
+ {{ item | safe }}
+
+ {% endfor %}
+
\ No newline at end of file
diff --git a/generator/templates/two-columns.html b/generator/templates/two-columns.html
new file mode 100644
index 0000000..cfa2326
--- /dev/null
+++ b/generator/templates/two-columns.html
@@ -0,0 +1,13 @@
+
+
+ {{ title | safe }}
+
+
+
+ {{ left | safe }}
+
+
+
+ {{ right | safe }}
+
+
\ No newline at end of file
diff --git a/generator/themes/dunkel.json b/generator/themes/dunkel.json
new file mode 100644
index 0000000..3025e0d
--- /dev/null
+++ b/generator/themes/dunkel.json
@@ -0,0 +1,17 @@
+{
+ "background": "#0f172a",
+ "text": "#e5e7eb",
+ "link": "#38bdf8",
+ "link_hover": "#7dd3fc",
+ "surface": "#111827",
+ "surface_alt": "#1f2937",
+ "muted_text": "#cbd5e1",
+ "border": "#334155",
+ "header_background": "#020617",
+ "header_hover": "#111827",
+ "header_text": "#ffffff",
+ "footer_background": "#020617",
+ "footer_text": "#ffffff",
+ "hero_background": "#1f2937",
+ "accent": "#38bdf8"
+}
diff --git a/generator/themes/havel.json b/generator/themes/havel.json
new file mode 100644
index 0000000..5c49c68
--- /dev/null
+++ b/generator/themes/havel.json
@@ -0,0 +1,17 @@
+{
+ "background": "#ffffff",
+ "text": "#111827",
+ "link": "#0f766e",
+ "link_hover": "#115e59",
+ "surface": "#ffffff",
+ "surface_alt": "#f1f5f9",
+ "muted_text": "#4b5563",
+ "border": "#dddddd",
+ "header_background": "#111827",
+ "header_hover": "#1f2937",
+ "header_text": "#ffffff",
+ "footer_background": "#111827",
+ "footer_text": "#ffffff",
+ "hero_background": "#f1f5f9",
+ "accent": "#111827"
+}
diff --git a/generator/themes/kueste.json b/generator/themes/kueste.json
new file mode 100644
index 0000000..92e1775
--- /dev/null
+++ b/generator/themes/kueste.json
@@ -0,0 +1,17 @@
+{
+ "background": "#fff8ed",
+ "text": "#243447",
+ "link": "#0f8a9d",
+ "link_hover": "#0b6674",
+ "surface": "#ffffff",
+ "surface_alt": "#d8f3ef",
+ "muted_text": "#68717c",
+ "border": "#f3d6ad",
+ "header_background": "#0b5563",
+ "header_hover": "#0f6f7d",
+ "header_text": "#ffffff",
+ "footer_background": "#0b5563",
+ "footer_text": "#ffffff",
+ "hero_background": "#fde6bf",
+ "accent": "#f59e0b"
+}
diff --git a/generator/themes/leuchtturm.json b/generator/themes/leuchtturm.json
new file mode 100644
index 0000000..c8525d1
--- /dev/null
+++ b/generator/themes/leuchtturm.json
@@ -0,0 +1,17 @@
+{
+ "background": "#fff7f1",
+ "text": "#2b1d1d",
+ "link": "#b91c1c",
+ "link_hover": "#7f1d1d",
+ "surface": "#ffffff",
+ "surface_alt": "#ffe3d3",
+ "muted_text": "#665b57",
+ "border": "#fca5a5",
+ "header_background": "#1f2937",
+ "header_hover": "#b91c1c",
+ "header_text": "#ffffff",
+ "footer_background": "#1f2937",
+ "footer_text": "#ffffff",
+ "hero_background": "#fecaca",
+ "accent": "#dc2626"
+}
diff --git a/generator/themes/segel.json b/generator/themes/segel.json
new file mode 100644
index 0000000..5c59ea7
--- /dev/null
+++ b/generator/themes/segel.json
@@ -0,0 +1,17 @@
+{
+ "background": "#f7fbff",
+ "text": "#111c2e",
+ "link": "#2563eb",
+ "link_hover": "#1d4ed8",
+ "surface": "#ffffff",
+ "surface_alt": "#e8eefc",
+ "muted_text": "#536071",
+ "border": "#bfdbfe",
+ "header_background": "#111827",
+ "header_hover": "#1e3a8a",
+ "header_text": "#ffffff",
+ "footer_background": "#111827",
+ "footer_text": "#ffffff",
+ "hero_background": "#dbeafe",
+ "accent": "#f97316"
+}
diff --git a/generator/themes/sonnenuntergang.json b/generator/themes/sonnenuntergang.json
new file mode 100644
index 0000000..44943bb
--- /dev/null
+++ b/generator/themes/sonnenuntergang.json
@@ -0,0 +1,17 @@
+{
+ "background": "#fffaf5",
+ "text": "#1f2937",
+ "link": "#c2410c",
+ "link_hover": "#9a3412",
+ "surface": "#ffffff",
+ "surface_alt": "#ffedd5",
+ "muted_text": "#57534e",
+ "border": "#fed7aa",
+ "header_background": "#7c2d12",
+ "header_hover": "#9a3412",
+ "header_text": "#ffffff",
+ "footer_background": "#7c2d12",
+ "footer_text": "#ffffff",
+ "hero_background": "#ffedd5",
+ "accent": "#c2410c"
+}
diff --git a/generator/themes/wald.json b/generator/themes/wald.json
new file mode 100644
index 0000000..49076f1
--- /dev/null
+++ b/generator/themes/wald.json
@@ -0,0 +1,17 @@
+{
+ "background": "#fbfdf8",
+ "text": "#1f2937",
+ "link": "#15803d",
+ "link_hover": "#166534",
+ "surface": "#ffffff",
+ "surface_alt": "#dcfce7",
+ "muted_text": "#4b5563",
+ "border": "#bbf7d0",
+ "header_background": "#14532d",
+ "header_hover": "#166534",
+ "header_text": "#ffffff",
+ "footer_background": "#14532d",
+ "footer_text": "#ffffff",
+ "hero_background": "#dcfce7",
+ "accent": "#15803d"
+}
diff --git a/generator/themes/wasser.json b/generator/themes/wasser.json
new file mode 100644
index 0000000..ceddb5e
--- /dev/null
+++ b/generator/themes/wasser.json
@@ -0,0 +1,17 @@
+{
+ "background": "#f8fafc",
+ "text": "#0f172a",
+ "link": "#0369a1",
+ "link_hover": "#075985",
+ "surface": "#ffffff",
+ "surface_alt": "#e0f2fe",
+ "muted_text": "#475569",
+ "border": "#bae6fd",
+ "header_background": "#0c4a6e",
+ "header_hover": "#075985",
+ "header_text": "#ffffff",
+ "footer_background": "#0c4a6e",
+ "footer_text": "#ffffff",
+ "hero_background": "#e0f2fe",
+ "accent": "#0369a1"
+}
diff --git a/generator/validate.py b/generator/validate.py
new file mode 100644
index 0000000..c77f747
--- /dev/null
+++ b/generator/validate.py
@@ -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())
diff --git a/havelseite/Bilder/Unser Verein.md b/havelseite/Bilder/Unser Verein.md
new file mode 100644
index 0000000..41c566b
--- /dev/null
+++ b/havelseite/Bilder/Unser Verein.md
@@ -0,0 +1,12 @@
+@galerie:Vereinsgelände
+# Unser Verein
+
+
+
+@bild-text
+
+
+
+# Unsere Jollenwiese
+
+Hier steht der Text rechts neben dem Bild.
diff --git a/havelseite/beispiel-alle-faelle.md b/havelseite/beispiel-alle-faelle.md
new file mode 100644
index 0000000..7652b8c
--- /dev/null
+++ b/havelseite/beispiel-alle-faelle.md
@@ -0,0 +1,195 @@
+@aufmacher
+# Beispielseite fuer alle Faelle
+
+Das ist der Standard-Aufmacher ohne Zusatz.
+
+@aufmacher:#0f766e
+# Aufmacher mit Farbe
+
+Hier wird eine feste Hintergrundfarbe verwendet.
+
+@aufmacher:medien/galerie/Vereinsgelände/csm_db_blick_auf_steganlage7_01_77c4c1781b.jpg
+# Aufmacher mit Bild
+
+Das Bild liegt lokal im Medienordner.
+
+@aufmacher:https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4
+# Aufmacher mit Video
+
+Dieses Beispiel nutzt eine externe Videodatei.
+
+@aufmacher:https://youtu.be/HyxD0Zdj-Cg
+# Aufmacher mit YouTube
+
+YouTube wird erst nach Zustimmung geladen, wenn externe Inhalte nicht automatisch erlaubt sind.
+
+@text
+# Normaler Text
+
+Das ist ein normaler Inhaltsbereich. Hier funktionieren Markdown-Absätze, **fette Schrift**, Listen und Links.
+
+- Erster Punkt
+- Zweiter Punkt
+- Dritter Punkt
+
+Ein normaler externer Link: [Inselcupanmeldung](https://inselcup.msvb.de/)
+
+Ein interner Link: [[Impressum]]
+
+@hinweis
+Dies ist ein kurzer Hinweis, zum Beispiel fuer eine wichtige Anmeldung oder eine Terminänderung.
+
+@kacheln
+# Einspaltige Kacheln
+
+## Kachel eins
+Diese Kachel steht unter den anderen.
+
+## Kachel zwei
+Auch längere Texte funktionieren hier.
+
+@kacheln:zweispaltig
+# Zweispaltige Kacheln
+
+## Training
+Mittwoch ab 17 Uhr.
+
+## Regatta
+Samstag ab 10 Uhr.
+
+@kacheln:dreispaltig
+# Dreispaltige Kacheln
+
+## Verein
+Infos zum Vereinsleben.
+
+## Jugend
+Angebote fuer junge Seglerinnen und Segler.
+
+## Boote
+Hinweise zu Booten und Material.
+
+@zwei-spalten
+# Zwei Spalten
+
+## Linke Spalte
+Hier steht Inhalt in der linken Spalte.
+
+## Rechte Spalte
+Hier steht Inhalt in der rechten Spalte.
+
+@galerie
+# Manuelle Galerie
+
+
+
+
+
+@galerie:Vereinsgelände
+# Automatische Galerie aus einem Ordner
+
+Alle Bilder kommen aus `havelseite/medien/galerie/Vereinsgelände/`.
+
+@zitat
+"Eine gute Vereinsseite muss nicht kompliziert sein."
+
+@zeitstrahl
+# Zeitstrahl
+
+## Januar
+Planung der Saison.
+
+## April
+Boote ins Wasser.
+
+## September
+Regatten und Vereinsleben.
+
+@fragen
+# Fragen und Antworten
+
+## Wie bearbeite ich eine Seite?
+Du änderst einfach die passende Markdown-Datei im Ordner `havelseite/`.
+
+## Wo lege ich Bilder ab?
+Bilder liegen unter `havelseite/medien/`.
+
+@kontakt
+# Kontakt
+
+Musterverein Brandenburg
+Am Beetzsee 1
+14770 Brandenburg an der Havel
+info@example.de
+
+@ort:Am Beetzsee 1, 14770 Brandenburg an der Havel
+# Ort und Anfahrt
+
+Der Ortsblock zeigt eine Adresse und kann OpenStreetMap datenschutzfreundlich nachladen.
+
+@bild-text
+
+
+# Bild und Text
+
+Dieser Bereich stellt ein Bild neben einen Text. Das eignet sich gut fuer Vereinsgelände, Boote, Teams oder Angebote.
+
+@veranstaltung
+# Einzelne Veranstaltung
+
+Datum: 12. Juli
+Uhrzeit: 15:00 Uhr
+Ort: Vereinsgelände
+Anmeldung: [Zur Anmeldung](https://example.com)
+
+
+
+Eine einzelne Veranstaltungsbox fuer einen wichtigen Termin.
+
+@veranstaltungen
+# Mehrere Veranstaltungen
+
+## Sommerfest
+Datum: 12. Juli
+Uhrzeit: 15:00 Uhr
+Ort: Vereinsgelände
+
+
+
+Gemeinsames Sommerfest mit Kuchen, Grill und kleinen Bootsrunden.
+
+## Absegeln
+Datum: 20. September
+Uhrzeit: 10:00 Uhr
+Ort: Vereinsgelände
+
+Gemeinsamer Saisonabschluss auf dem Wasser.
+
+@dateien
+# Dateien und Downloads
+
+[Ausschreibung als externer Link](https://example.com/ausschreibung.pdf)
+[Anmeldung als externer Link](https://example.com/anmeldung.pdf)
+
+@person
+# Einzelne Person
+
+Max Mustermann
+Vorsitzender
+max@example.de
+
+@personen
+# Personen
+
+## Max Mustermann
+Vorsitzender
+max@example.de
+
+## Erika Beispiel
+Jugendwartin
+erika@example.de
+
+@sponsoren
+# Sponsoren und Partner
+
+
diff --git a/havelseite/einstellungen.md b/havelseite/einstellungen.md
new file mode 100644
index 0000000..a15aa3d
--- /dev/null
+++ b/havelseite/einstellungen.md
@@ -0,0 +1,73 @@
+# Einstellungen
+
+@einstellung:seite
+Titel: Havelseiten
+
+@einstellung:sprache
+- [x] Deutsch
+- [ ] Englisch
+
+@einstellung:farbpalette
+- [x] Havel
+- [ ] Wasser
+- [ ] Wald
+- [ ] Sonnenuntergang
+- [ ] Dunkel
+- [ ] Küste
+- [ ] Segel
+- [ ] Leuchtturm
+
+@einstellung:schrift
+- [ ] klein
+- [x] mittel
+- [ ] groß
+
+@einstellung:navigation
+- [ ] klein
+- [ ] mittel
+- [x] groß
+
+- [x] bleibt oben
+- [ ] scrollt mit
+
+- [ ] links
+- [x] mittig
+- [ ] rechts
+
+@einstellung:logo
+- [x] in der Navigation
+- [ ] in der Fußzeile
+
+- [x] links
+- [ ] rechts
+
+Logoordner: logo
+Alternativtext: Märkischer Seglerverein Beetzsee
+Text: Havelseiten
+
+@einstellung:logo-datei
+- [x] msvb_logo.png
+
+@einstellung:mobilmenue
+- [ ] automatisch
+- [ ] links
+- [ ] mittig
+- [x] rechts
+
+- [ ] Untermenüs ausgeklappt
+
+@einstellung:bilder
+- [x] Bildunterschriften
+- [x] runde Ecken
+
+@einstellung:icons
+- [ ] rund
+- [ ] schlicht
+- [x] text
+
+@einstellung:datenschutz
+- [ ] externe Inhalte laden
+
+@einstellung:social
+instagram: https://instagram.com
+youtube: https://youtube.com
diff --git a/havelseite/fusszeile/anfahrt.md b/havelseite/fusszeile/anfahrt.md
new file mode 100644
index 0000000..fd89475
--- /dev/null
+++ b/havelseite/fusszeile/anfahrt.md
@@ -0,0 +1,3 @@
+@ort:Schienenweg 49, 14772 Brandenburg an der Havel, Deutschland
+# Anfahrt
+
diff --git a/havelseite/fusszeile/datenschutz.md b/havelseite/fusszeile/datenschutz.md
new file mode 100644
index 0000000..71efdec
--- /dev/null
+++ b/havelseite/fusszeile/datenschutz.md
@@ -0,0 +1,4 @@
+@text
+# Datenschutz
+
+Hier stehen die Datenschutzhinweise.
\ No newline at end of file
diff --git a/havelseite/fusszeile/fusszeile.md b/havelseite/fusszeile/fusszeile.md
new file mode 100644
index 0000000..2ece8c5
--- /dev/null
+++ b/havelseite/fusszeile/fusszeile.md
@@ -0,0 +1,13 @@
+# Havelseiten
+
+## Kontakt
+Märkischer Segelverein Beetzsee
+Schienenweg 49, 14772 Brandenburg an der Havel, Deutschland
+info@msvb.de
+
+## Rechtliches
+[[Impressum]]
+[[Datenschutz]]
+
+## Anfahrt
+[[Anfahrt]]
diff --git a/havelseite/fusszeile/impressum.md b/havelseite/fusszeile/impressum.md
new file mode 100644
index 0000000..422123e
--- /dev/null
+++ b/havelseite/fusszeile/impressum.md
@@ -0,0 +1,9 @@
+@text
+# Impressum
+
+Angaben gemäß § 5 TMG.
+
+Märkischer Segelverein Beetzsee
+Schienenweg 49
+14772 Brandenburg an der Havel
+
diff --git a/havelseite/hero-tests/bild.md b/havelseite/hero-tests/bild.md
new file mode 100644
index 0000000..f4ad110
--- /dev/null
+++ b/havelseite/hero-tests/bild.md
@@ -0,0 +1,4 @@
+@aufmacher:medien/galerie/Vereinsgelände/csm_db_blick_auf_steganlage7_01_77c4c1781b.jpg
+# Aufmacher Bild
+
+Dieser Aufmacher nutzt ein lokales Bild aus dem Medienordner.
diff --git a/havelseite/hero-tests/farbe.md b/havelseite/hero-tests/farbe.md
new file mode 100644
index 0000000..cfa07ee
--- /dev/null
+++ b/havelseite/hero-tests/farbe.md
@@ -0,0 +1,4 @@
+@aufmacher:#0f766e
+# Aufmacher Farbe
+
+Dieser Aufmacher nutzt einen Farbwert als Hintergrund.
diff --git a/havelseite/hero-tests/standard.md b/havelseite/hero-tests/standard.md
new file mode 100644
index 0000000..561be19
--- /dev/null
+++ b/havelseite/hero-tests/standard.md
@@ -0,0 +1,4 @@
+@aufmacher
+# Aufmacher Standard
+
+Dieser Aufmacher nutzt keinen Parameter und bleibt deshalb bei der normalen hellen Standarddarstellung.
diff --git a/havelseite/hero-tests/video.md b/havelseite/hero-tests/video.md
new file mode 100644
index 0000000..6459b74
--- /dev/null
+++ b/havelseite/hero-tests/video.md
@@ -0,0 +1,4 @@
+@aufmacher:https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4
+# Aufmacher Video
+
+Dieser Aufmacher nutzt eine externe MP4-Datei als Hintergrundvideo.
diff --git a/havelseite/hero-tests/youtube.md b/havelseite/hero-tests/youtube.md
new file mode 100644
index 0000000..cd17631
--- /dev/null
+++ b/havelseite/hero-tests/youtube.md
@@ -0,0 +1,4 @@
+@aufmacher:https://youtu.be/HyxD0Zdj-Cg?si=7yiXVld7sVh4sScE
+# Aufmacher YouTube
+
+Dieser Aufmacher nutzt einen YouTube-Link und wird als eingebettetes Hintergrundvideo ausgegeben.
diff --git a/havelseite/index.md b/havelseite/index.md
new file mode 100644
index 0000000..7244095
--- /dev/null
+++ b/havelseite/index.md
@@ -0,0 +1,19 @@
+@aufmacher:medien/galerie/Kon-Tiki_2026/kontiki2026_1.jpeg
+# MSVB
+
+Märkischer Segelverein Beetzsee
+
+@hinweis
+Anmeldung zur Kon-Tiki-Regatta bis zum 1. Juli möglich.
+
+@veranstaltung
+# Inselcup
+
+Datum: 23.Mai-24.Mai 2026
+Uhrzeit: ab 15:00 Uhr
+Ort: Vereinsgelände
+Anmeldung: [Inselcupanmeldung](https://inselcup.msvb.de/)
+
+Die Spaßregatta im Revier. Für Kuchen, Salata, Grillgut und Getränke ist gesorgt.
+
+
diff --git a/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_1.jpeg b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_1.jpeg
new file mode 100644
index 0000000..b6ebe25
Binary files /dev/null and b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_1.jpeg differ
diff --git a/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_2.jpeg b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_2.jpeg
new file mode 100644
index 0000000..3c94bbe
Binary files /dev/null and b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_2.jpeg differ
diff --git a/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_3.jpeg b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_3.jpeg
new file mode 100644
index 0000000..5bdd4df
Binary files /dev/null and b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_3.jpeg differ
diff --git a/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_4.jpeg b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_4.jpeg
new file mode 100644
index 0000000..215179d
Binary files /dev/null and b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_4.jpeg differ
diff --git a/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_5.jpeg b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_5.jpeg
new file mode 100644
index 0000000..eac29e2
Binary files /dev/null and b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_5.jpeg differ
diff --git a/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_6.jpeg b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_6.jpeg
new file mode 100644
index 0000000..6fb9827
Binary files /dev/null and b/havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_6.jpeg differ
diff --git a/havelseite/medien/galerie/Vereinsgelände/csm_db_15er_steg27_01_41d2098b8f.jpg b/havelseite/medien/galerie/Vereinsgelände/csm_db_15er_steg27_01_41d2098b8f.jpg
new file mode 100644
index 0000000..48ebfd7
Binary files /dev/null and b/havelseite/medien/galerie/Vereinsgelände/csm_db_15er_steg27_01_41d2098b8f.jpg differ
diff --git a/havelseite/medien/galerie/Vereinsgelände/csm_db_15er_steg8_01_f4f2de652b.jpg b/havelseite/medien/galerie/Vereinsgelände/csm_db_15er_steg8_01_f4f2de652b.jpg
new file mode 100644
index 0000000..22ce156
Binary files /dev/null and b/havelseite/medien/galerie/Vereinsgelände/csm_db_15er_steg8_01_f4f2de652b.jpg differ
diff --git a/havelseite/medien/galerie/Vereinsgelände/csm_db_20er_steg7_01_449ea24422.jpg b/havelseite/medien/galerie/Vereinsgelände/csm_db_20er_steg7_01_449ea24422.jpg
new file mode 100644
index 0000000..3deb02c
Binary files /dev/null and b/havelseite/medien/galerie/Vereinsgelände/csm_db_20er_steg7_01_449ea24422.jpg differ
diff --git a/havelseite/medien/galerie/Vereinsgelände/csm_db_anker7_801025207d.jpg b/havelseite/medien/galerie/Vereinsgelände/csm_db_anker7_801025207d.jpg
new file mode 100644
index 0000000..cb978fe
Binary files /dev/null and b/havelseite/medien/galerie/Vereinsgelände/csm_db_anker7_801025207d.jpg differ
diff --git a/havelseite/medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg b/havelseite/medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg
new file mode 100644
index 0000000..7245bb9
Binary files /dev/null and b/havelseite/medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg differ
diff --git a/havelseite/medien/galerie/Vereinsgelände/csm_db_blick_auf_steganlage7_01_77c4c1781b.jpg b/havelseite/medien/galerie/Vereinsgelände/csm_db_blick_auf_steganlage7_01_77c4c1781b.jpg
new file mode 100644
index 0000000..203cc0f
Binary files /dev/null and b/havelseite/medien/galerie/Vereinsgelände/csm_db_blick_auf_steganlage7_01_77c4c1781b.jpg differ
diff --git a/havelseite/medien/galerie/Vereinsgelände/csm_db_hanno_guenther7_01_0814c31197.jpg b/havelseite/medien/galerie/Vereinsgelände/csm_db_hanno_guenther7_01_0814c31197.jpg
new file mode 100644
index 0000000..5ddf679
Binary files /dev/null and b/havelseite/medien/galerie/Vereinsgelände/csm_db_hanno_guenther7_01_0814c31197.jpg differ
diff --git a/havelseite/medien/galerie/Vereinsgelände/csm_db_jollenwiese7_01_a93eaeac9a.jpg b/havelseite/medien/galerie/Vereinsgelände/csm_db_jollenwiese7_01_a93eaeac9a.jpg
new file mode 100644
index 0000000..8e92957
Binary files /dev/null and b/havelseite/medien/galerie/Vereinsgelände/csm_db_jollenwiese7_01_a93eaeac9a.jpg differ
diff --git a/havelseite/medien/logo/msvb_logo.png b/havelseite/medien/logo/msvb_logo.png
new file mode 100644
index 0000000..92edd8b
Binary files /dev/null and b/havelseite/medien/logo/msvb_logo.png differ
diff --git a/havelseite/regatten/kon-tiki.md b/havelseite/regatten/kon-tiki.md
new file mode 100644
index 0000000..e69de29
diff --git a/havelseite/sonstiges.md b/havelseite/sonstiges.md
new file mode 100644
index 0000000..e69de29
diff --git a/havelseite/template-beispiele.md b/havelseite/template-beispiele.md
new file mode 100644
index 0000000..59dd2a0
--- /dev/null
+++ b/havelseite/template-beispiele.md
@@ -0,0 +1,127 @@
+@text
+# Template-Beispiele
+
+Diese Seite zeigt die wichtigsten Markdown-Bausteine, die der Generator versteht.
+
+@hinweis
+Ein Hinweisbanner eignet sich für Termine, Anmeldungen oder wichtige kurze Nachrichten.
+
+@kacheln:dreispaltig
+# Kacheln
+
+## Erste Kachel
+Kurzer Text für eine kompakte Inhaltskachel.
+
+## Zweite Kachel
+Noch ein Beispiel mit eigenem Titel und Beschreibung.
+
+## Dritte Kachel
+Kacheln werden aus `##`-Abschnitten gebildet.
+
+@zwei-spalten
+# Zwei Spalten
+
+## Linke Spalte
+Hier steht der Inhalt der linken Spalte.
+
+## Rechte Spalte
+Hier steht der Inhalt der rechten Spalte.
+
+@zeitstrahl
+# Zeitstrahl
+
+## Frühjahr
+Saisonstart und gemeinsames Aufklaren.
+
+## Sommer
+Regatten, Training und lange Abende am Wasser.
+
+## Herbst
+Absegeln und Vorbereitung auf die Winterpause.
+
+@fragen
+# FAQ
+
+## Wie lege ich eine Seite an?
+Eine neue `.md`-Datei unter `havelseite` wird beim Bauen automatisch zu einer HTML-Seite.
+
+## Wie verlinke ich intern?
+Interne Links funktionieren, indem du den Seitentitel in doppelte eckige Klammern setzt.
+
+@galerie
+# Manuelle Galerie
+
+
+
+
+
+@zitat
+> Ein Zitatblock kann als ruhiger Trenner zwischen Inhaltsbereichen dienen.
+
+@bild-text
+
+
+# Bild und Text
+
+Das erste Markdown-Bild im Block wird links angezeigt, der restliche Text daneben.
+
+@kontakt
+# Kontakt
+
+Musterverein Brandenburg
+Am Beetzsee 1
+info@example.de
+
+@ort:Am Beetzsee 1, 14770 Brandenburg an der Havel
+# Ort und Anfahrt
+
+Hier kann eine Adresse mit OpenStreetMap verknüpft werden.
+
+@veranstaltung
+# Sommerfest
+
+Datum: 12. Juli
+Uhrzeit: 15:00 Uhr
+Ort: Vereinsgelände
+Anmeldung: info@example.de
+
+Gemeinsames Sommerfest mit Kuchen, Grill und kleinen Bootsrunden.
+
+@veranstaltungen
+# Weitere Termine
+
+## Absegeln
+Datum: 20. September
+Uhrzeit: 10:00 Uhr
+Ort: Vereinsgelände
+
+Gemeinsamer Saisonabschluss auf dem Wasser.
+
+## Winterlager
+Datum: 1. November
+Uhrzeit: 9:00 Uhr
+Ort: Bootshaus
+
+Boote gemeinsam einlagern und danach Kaffee trinken.
+
+@dateien
+# Downloads
+
+[Ausschreibung Kon-Tiki](https://example.com/ausschreibung.pdf)
+[Anmeldeformular](https://example.com/anmeldung.pdf)
+
+@personen
+# Team
+
+## Max Mustermann
+Vorsitzender
+max@example.de
+
+## Erika Beispiel
+Jugendwartin
+erika@example.de
+
+@sponsoren
+# Partner und Sponsoren
+
+
diff --git a/havelseite/verein.md b/havelseite/verein.md
new file mode 100644
index 0000000..e69de29
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..963b702
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+markdown
+jinja2
\ No newline at end of file