diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..2ca674b Binary files /dev/null and b/.DS_Store differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1ff84d1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Havelseiten contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Readme.md b/Readme.md index f0667e0..54f7b18 100644 --- a/Readme.md +++ b/Readme.md @@ -1 +1,540 @@ -# Hallo +# Havelseiten + +Version: `0.7.0` + +Havelseiten ist ein Anti-CMS fuer schnelle, einfache und datenschutzbewusste Websites. + +Keine Datenbank, kein Adminbereich, keine Plugins, keine laufenden Updates, kein teurer Webspace. Inhalte liegen als Markdown-Dateien im Ordner `havelseite/`. Der Generator baut daraus fertige statische HTML-Seiten im Ordner `ausgabe/`. + +Havelseiten ist besonders gedacht fuer Vereine, Initiativen und kleine Projekte, die eine robuste Website brauchen, aber kein Website-System pflegen wollen. + +## Grundidee + +- Markdown rein, fertige Website raus. +- Einstellungen stehen in `havelseite/einstellungen.md`, nicht in JSON/YAML. +- Externe Inhalte wie YouTube oder OpenStreetMap werden datenschutzfreundlich erst nach Zustimmung geladen. +- Die Navigation entsteht automatisch aus Dateien und Ordnern. +- Der Validator prueft vor jedem Build auf typische Fehler. +- Spaeter kann Galatea als UI davorliegen: Inhalte hochladen, Havelseiten baut, GitLab/Pages veroeffentlicht. + +## Was darf ich bearbeiten? + +Diese Dateien und Ordner sind fuer normale Bearbeitung gedacht: + +- `havelseite/` fuer Seiten und Texte +- `havelseite/medien/` fuer Bilder und Dateien +- `havelseite/medien/galerie/` fuer Galerien +- `havelseite/medien/logo/` fuer das Logo +- `havelseite/einstellungen.md` fuer Titel, Design, Logo, Galerie, Icons und Schrift + +Den Ordner `generator/` musst du im normalen Alltag nicht anfassen. + +## Schnellstart + +1. Aendere Titel, Farben, Logo und Navigation in `havelseite/einstellungen.md`. +2. Schreibe deine Startseite in `havelseite/index.md`. +3. Lege weitere Seiten als Markdown-Dateien in `havelseite/` an. +4. Lege Bilder in `havelseite/medien/`. +5. Baue die Website mit `python3 generator/havelseiten.py`. + +## Seite bauen + +```sh +python3 generator/havelseiten.py +``` + +Vor dem Bauen wird `havelseite/einstellungen.md` automatisch formatiert und danach geprueft. Aus `- [] Wald` wird zum Beispiel `- [ ] Wald`. + +Warnungen stoppen den Build nicht. Wenn zum Beispiel zwei Farbpaletten angekreuzt sind, wird gewarnt und die erste angekreuzte Palette verwendet. + +Die fertigen Seiten liegen danach in `ausgabe/`. + +## Vorher pruefen + +```sh +python3 generator/validate.py +``` + +Der Validator sagt in normalen Worten, wenn zum Beispiel ein Bild fehlt, ein Galerieordner falsch geschrieben ist oder ein unbekannter Block wie `@galry` benutzt wurde. + +## Projektstruktur + +```text +havelseite/ + einstellungen.md + index.md + verein.md + regatten/ + kon-tiki.md + fusszeile/ + fusszeile.md + impressum.md + datenschutz.md + medien/ + logo/ + galerie/ + +generator/ + havelseiten.py + validate.py + templates/ + themes/ + +ausgabe/ +``` + +`havelseite/` ist der Inhaltsordner. `generator/` ist die Technik. `ausgabe/` ist das fertige Ergebnis. + +## Eine neue Seite anlegen + +Lege im Ordner `havelseite` eine neue Datei an, zum Beispiel: + +```text +havelseite/aktuelles.md +``` + +Inhalt: + +```md +@aufmacher +# Aktuelles + +Neuigkeiten aus dem Verein. + +@text +# Willkommen + +Hier steht normaler Text. +``` + +Nach dem Bauen entsteht: + +```text +ausgabe/aktuelles.html +``` + +Die Navigation wird automatisch erstellt. + +## Menue sortieren + +Die Reihenfolge steuerst du ueber Zahlen am Anfang von Datei- oder Ordnernamen: + +```text +havelseite/01_start.md +havelseite/02_verein.md +havelseite/03_regatten/ +havelseite/04_kontakt.md +``` + +Die Zahlen sind nur fuer die Sortierung da. Im Menue und in den fertigen Links werden sie ausgeblendet. + +Havelseiten erlaubt fuer Seiten nur eine Ordnerebene: + +```text +havelseite/regatten/kon-tiki.md +``` + +Nicht erlaubt sind Ordner in Ordnern: + +```text +havelseite/regatten/2026/kon-tiki.md +``` + +Der Grund: Die Navigation bleibt dadurch einfach. Eine Datei direkt in `havelseite/` wird ein normaler Menuepunkt. Ein Ordner direkt in `havelseite/` wird ein ausklappbarer Menuepunkt. + +Medienordner duerfen tiefer sein. Diese Regel gilt nur fuer Markdown-Seiten. + +## Einstellungen + +Globale Einstellungen stehen in: + +```text +havelseite/einstellungen.md +``` + +Beispiel: + +```md +# Einstellungen + +@einstellung:seite +Titel: Havelseiten + +@einstellung:sprache +- [x] Deutsch +- [ ] Englisch + +@einstellung:farbpalette +- [ ] Havel +- [ ] Wasser +- [x] 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 +- [x] 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 +- [x] automatisch +- [ ] links +- [ ] mittig +- [ ] rechts + +- [ ] Untermenüs ausgeklappt + +@einstellung:bilder +- [x] Bildunterschriften +- [x] runde Ecken + +@einstellung:icons +- [x] rund +- [ ] schlicht +- [ ] text + +@einstellung:datenschutz +- [ ] externe Inhalte laden + +@einstellung:social +instagram: https://instagram.com +youtube: https://youtube.com +``` + +Wenn in einer Auswahl nichts angekreuzt ist, setzt der Formatter meistens automatisch das Kreuz bei der ersten Option. Einzelne Ja/Nein-Optionen, zum Beispiel `Untermenüs ausgeklappt`, bleiben ohne Kreuz bewusst ausgeschaltet. + +### Logo + +Lege das Logo in `havelseite/medien/logo/`. + +Wenn weder `in der Navigation` noch `in der Fußzeile` angekreuzt ist, wird kein Logo angezeigt. Sobald eine dieser beiden Optionen angekreuzt ist, muss auch `links` oder `rechts` angekreuzt sein. + +Der Formatter ergänzt automatisch eine Auswahl mit den Logo-Dateien: + +```md +@einstellung:logo-datei +- [x] msvb_logo.png +- [ ] zweites-logo.svg +``` + +Wenn nichts angekreuzt ist, wird automatisch die erste Datei angekreuzt. + +`Logoordner: logo` bedeutet: Der Generator schaut in `havelseite/medien/logo/`. + +### Farbpaletten + +Die Farbpaletten liegen als JSON-Dateien in `generator/themes/`. Wenn dort eine neue Datei wie `verein.json` liegt, ergänzt der Formatter die Auswahl in `havelseite/einstellungen.md` automatisch; danach kann sie als `Verein` angekreuzt werden. + +Aktuell gibt es: + +- `Havel` +- `Wasser` +- `Wald` +- `Sonnenuntergang` +- `Dunkel` +- `Küste` +- `Segel` +- `Leuchtturm` + +### Galerie + +Die Galerie bleibt bewusst schlicht. Global einstellbar sind nur die Dinge, die sofort sichtbar sind: `Bildunterschriften` und `runde Ecken`. + +### Icons + +Die Social-Icons sind bewusst ohne externes Iconpaket gebaut. Dadurch gibt es keine Lizenz- oder Ladeprobleme. + +- `rund`: runde kleine Icon-Badges +- `schlicht`: kantigere Icon-Badges +- `text`: reine Textlinks + +Social Links werden zentral aus `@einstellung:social` erzeugt. Schreibe sie deshalb nicht noch einmal in `havelseite/fusszeile/fusszeile.md`, sonst stehen sie doppelt im Footer. + +### Datenschutz + +Wenn `externe Inhalte laden` nicht angekreuzt ist, werden externe Karten und YouTube-Videos nicht automatisch geladen. Stattdessen erscheint ein Hinweis mit einem Button zum Nachladen auf derselben Seite. + +Die Galerie funktioniert ohne externe Skripte und braucht fuer lokale Bilder keine Datenschutzfreigabe. Wenn eine manuelle Galerie externe Bild-URLs verwendet, erscheint ebenfalls ein Button zum Nachladen. + +## Bilder und Galerien + +Normale Bilder legst du unter `havelseite/medien/` ab. + +```md +![Beschreibung des Bildes](medien/mein-bild.jpg) +``` + +Galeriebilder legst du in einen Ordner unter: + +```text +havelseite/medien/galerie/ +``` + +Beispiel: + +```text +havelseite/medien/galerie/Sommerfest/ +``` + +Dann kannst du die Galerie so einbinden: + +```md +@galerie:Sommerfest +# Sommerfest +``` + +Beim Klick auf ein Bild oeffnet sich eine Ansicht, in der man durch die Galerie wischen kann. + +## Bausteine + +Deutsch ist die Hauptschreibweise. Die englische Schreibweise funktioniert zusaetzlich. + +Jeder Baustein beginnt mit `@name`. Der Inhalt darunter gehoert bis zum naechsten `@baustein` zu diesem Bereich. + +Die Namen folgen einer einfachen Regel: Einzelbereiche stehen im Singular, Listenbereiche im Plural. Darum heisst es `@hinweis`, `@veranstaltung` und `@person`, aber `@kacheln`, `@veranstaltungen`, `@fragen`, `@dateien`, `@personen` und `@sponsoren`. Listen verwenden `##`-Ueberschriften fuer einzelne Eintraege. + +| Markdown | Englisch | Wird auf der Website zu | +| --- | --- | --- | +| `@aufmacher` | `@hero` | grosse Medienflaeche mit Text, Bild, Farbe, Video oder YouTube | +| `@text` | `@text` | normaler Inhaltsbereich | +| `@hinweis` | `@banner` | hervorgehobener Hinweis | +| `@kacheln` | `@cards` | Kachelraster | +| `@zwei-spalten` | `@two-columns` | zweispaltiger Bereich | +| `@galerie` | `@gallery` | Bildergalerie mit vergroesserbarer Swipe-Ansicht | +| `@zitat` | `@quote` | Zitatbereich | +| `@zeitstrahl` | `@timeline` | Ablauf oder Zeitstrahl | +| `@fragen` | `@faq` | Fragen-und-Antworten-Bereich | +| `@kontakt` | `@contact` | Kontaktblock | +| `@ort` | `@location` | Adresse mit OpenStreetMap-Link oder Karte | +| `@bild-text` | `@image-text` | Bild links, Text rechts | +| `@veranstaltung` | `@event` | einzelne Veranstaltungsbox | +| `@veranstaltungen` | `@events` | Veranstaltungsliste | +| `@dateien` | `@downloads` | Downloadliste | +| `@person` | `@person` | einzelne Personenbox | +| `@personen` | `@people` | Personen- oder Teamliste | +| `@sponsoren` | `@sponsors` | Sponsor- oder Partnerlogos | + +### Aufmacher + +```md +@aufmacher +# Seitentitel + +Kurzer Einstiegstext. +``` + +Varianten: + +```md +@aufmacher:medien/galerie/Sommerfest/bild.jpg +@aufmacher:medien/film.mp4 +@aufmacher:https://www.youtube.com/watch?v=... +@aufmacher:#1f6f78 +``` + +Ohne Zusatz bleibt der Aufmacherbereich so wie im Standard. + +### Hinweis + +```md +@hinweis +Anmeldung bis zum 1. Juli moeglich. +``` + +### Kacheln + +```md +@kacheln:dreispaltig +# Veranstaltungen + +## Kon-Tiki +Sommerregatta am Beetzsee. + +## Inselcup +Spassregatta. +``` + +Varianten: + +```md +@kacheln +@kacheln:zweispaltig +@kacheln:dreispaltig +``` + +Ohne Zusatz stehen die Kacheln untereinander. + +### Fragen + +```md +@fragen +# FAQ + +## Wie melde ich mich an? +Schreibe uns eine E-Mail. + +## Wo finde ich den Verein? +Die Adresse steht auf der Anfahrtsseite. +``` + +### Veranstaltung + +```md +@veranstaltung +# Sommerfest + +Datum: 12. Juli +Uhrzeit: 15:00 Uhr +Ort: Vereinsgelände +Anmeldung: [Zur Anmeldung](https://example.com) + +![Sommerfest](medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg) + +Gemeinsames Sommerfest mit Kuchen, Grill und kleinen Bootsrunden. +``` + +### Veranstaltungen + +```md +@veranstaltungen +# Termine + +## Sommerfest +Datum: 12. Juli +Uhrzeit: 15:00 Uhr +Ort: Vereinsgelände + +![Sommerfest](medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg) + +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 + +```md +@dateien +# Downloads + +[Ausschreibung](medien/ausschreibung.pdf) +[Anmeldung](https://example.com/anmeldung.pdf) +``` + +### Personen + +```md +@person +# Max Mustermann + +Vorsitzender +max@example.de +``` + +```md +@personen +# Team + +## Max Mustermann +Vorsitzender +max@example.de + +## Erika Beispiel +Jugendwartin +erika@example.de +``` + +### Sponsoren + +```md +@sponsoren +# Partner + +![Sponsorname](medien/logo/sponsor.png) +``` + +### Bild und Text + +```md +@bild-text +![Steg](medien/galerie/Vereinsgelände/csm_db_15er_steg27_01_41d2098b8f.jpg) + +# Unser Steg + +Hier steht der Text neben dem Bild. +``` + +### Ort + +```md +@ort:Am Beetzsee 1, 14770 Brandenburg an der Havel +# Anfahrt + +Hier steht ein kurzer Hinweis zur Anfahrt. +``` + +Wenn externe Inhalte erlaubt sind, wird OpenStreetMap eingebettet. Sonst zeigt der Block einen datenschutzfreundlichen Link zu OpenStreetMap. + +## Interne Links + +Du kannst auf andere Seiten verlinken, indem du den Seitennamen in doppelte eckige Klammern setzt: + +```md +[[Impressum]] +``` + +Der Generator macht daraus automatisch den richtigen Link. + +Normale Markdown-Links funktionieren ebenfalls: + +```md +[Inselcupanmeldung](https://inselcup.msvb.de/) +``` + +## Deployment + +Havelseiten baut lokal in den Ordner `ausgabe/`. + +Die Datei `.gitlab-ci.yml` zeigt einen einfachen GitLab-Pages-Deploy: GitLab installiert die Abhaengigkeiten, fuehrt den Generator aus und kopiert `ausgabe/` nach `public/`. Havelseiten selbst bleibt dadurch einfach; der Service entscheidet, wohin die fertigen Dateien veroeffentlicht werden. + +Geplanter Ausbau: + +- Galatea-UI fuer Upload und Bearbeitung ohne Terminal. +- Einfacher Passwortschutz fuer geschuetzte Bereiche. + +## Lizenz + +Havelseiten steht unter der MIT-Lizenz. Den vollstaendigen Lizenztext findest du in [LICENSE](LICENSE). diff --git a/generator/__pycache__/havelseiten.cpython-311.pyc b/generator/__pycache__/havelseiten.cpython-311.pyc new file mode 100644 index 0000000..7b4510f Binary files /dev/null and b/generator/__pycache__/havelseiten.cpython-311.pyc differ diff --git a/generator/__pycache__/havelseiten.cpython-38.pyc b/generator/__pycache__/havelseiten.cpython-38.pyc new file mode 100644 index 0000000..bfe892e Binary files /dev/null and b/generator/__pycache__/havelseiten.cpython-38.pyc differ diff --git a/generator/__pycache__/settings_config.cpython-311.pyc b/generator/__pycache__/settings_config.cpython-311.pyc new file mode 100644 index 0000000..371457e Binary files /dev/null and b/generator/__pycache__/settings_config.cpython-311.pyc differ diff --git a/generator/__pycache__/settings_config.cpython-38.pyc b/generator/__pycache__/settings_config.cpython-38.pyc new file mode 100644 index 0000000..e283c1b Binary files /dev/null and b/generator/__pycache__/settings_config.cpython-38.pyc differ diff --git a/generator/__pycache__/settings_editor.cpython-311.pyc b/generator/__pycache__/settings_editor.cpython-311.pyc new file mode 100644 index 0000000..50d0762 Binary files /dev/null and b/generator/__pycache__/settings_editor.cpython-311.pyc differ diff --git a/generator/__pycache__/settings_editor.cpython-38.pyc b/generator/__pycache__/settings_editor.cpython-38.pyc new file mode 100644 index 0000000..6962b32 Binary files /dev/null and b/generator/__pycache__/settings_editor.cpython-38.pyc differ diff --git a/generator/__pycache__/validate.cpython-311.pyc b/generator/__pycache__/validate.cpython-311.pyc new file mode 100644 index 0000000..0ea933a Binary files /dev/null and b/generator/__pycache__/validate.cpython-311.pyc differ diff --git a/generator/__pycache__/validate.cpython-38.pyc b/generator/__pycache__/validate.cpython-38.pyc new file mode 100644 index 0000000..6bee729 Binary files /dev/null and b/generator/__pycache__/validate.cpython-38.pyc differ diff --git a/generator/components.json b/generator/components.json new file mode 100644 index 0000000..dec3d84 --- /dev/null +++ b/generator/components.json @@ -0,0 +1,53 @@ +{ + "hero": "hero", + "aufmacher": "hero", + + "text": "text", + + "kacheln": "cards", + "cards": "cards", + + "two-columns": "two-columns", + "zwei-spalten": "two-columns", + + "gallery": "gallery", + "galerie": "gallery", + + "quote": "quote", + "zitat": "quote", + + "banner": "banner", + "hinweis": "banner", + + "timeline": "timeline", + "zeitstrahl": "timeline", + + "fragen": "faq", + "faq": "faq", + + "contact": "contact", + "kontakt": "contact", + + "location": "location", + "ort": "location", + "anfahrt": "location", + + "image-text": "image-text", + "bild-text": "image-text", + + "event": "event", + "veranstaltung": "event", + "events": "events", + "veranstaltungen": "events", + + "dateien": "downloads", + "downloads": "downloads", + + "person": "person", + + "personen": "people", + "people": "people", + + "sponsoren": "sponsors", + "sponsors": "sponsors" +} diff --git a/generator/havelseiten.py b/generator/havelseiten.py new file mode 100644 index 0000000..2ea97c4 --- /dev/null +++ b/generator/havelseiten.py @@ -0,0 +1,1692 @@ +from __future__ import annotations + +from pathlib import Path +import shutil +import os +import json +import re +import markdown +import struct +from urllib.parse import parse_qs, quote_plus, urlparse + +import settings_config +import validate + +from jinja2 import Environment, FileSystemLoader + + +ROOT_DIR = Path(__file__).resolve().parent.parent + +CONTENT_DIR = settings_config.active_content_dir() +OUTPUT_DIR = ROOT_DIR / "ausgabe" + +GENERATOR_DIR = ROOT_DIR / "generator" + +TEMPLATE_DIR = GENERATOR_DIR / "templates" +STATIC_DIR = GENERATOR_DIR / "static" +COMPONENTS_FILE = GENERATOR_DIR / "components.json" +SETTINGS_FILE = GENERATOR_DIR / "settings.json" +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" +} + + +env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR) +) + + +with open(COMPONENTS_FILE, encoding="utf-8") as f: + COMPONENTS = json.load(f) + + +DEFAULT_SETTINGS = settings_config.DEFAULT_SETTINGS + + +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 load_settings() -> dict: + return settings_config.load_settings() + + +SETTINGS = DEFAULT_SETTINGS + + +def run_validation(): + settings_config.format_settings_file() + messages, language = validate.collect_messages() + + if not messages: + print(validate.tr(language, "ok")) + return + + print(validate.tr(language, "found_header")) + + for message in messages: + print(f"- {message}") + + print(validate.tr(language, "found_footer")) + + if validate.has_errors(messages): + raise SystemExit(1) + + +def normalize_component(name: str) -> str: + return COMPONENTS.get(name, name) + + +def parse_annotation(line: str) -> tuple[str, str | None]: + annotation = line.strip()[1:] + + if ":" in annotation: + component, parameter = annotation.split(":", 1) + + return ( + normalize_component(component.strip()), + parameter.strip() + ) + + return ( + normalize_component(annotation.strip()), + None + ) + + +def parse_blocks(md_text: str) -> list[dict]: + blocks = [] + + current_type = "text" + current_parameter = None + current_lines = [] + + for line in md_text.splitlines(): + + if line.strip().startswith("@"): + + if current_lines: + blocks.append({ + "type": current_type, + "parameter": current_parameter, + "content": "\n".join(current_lines).strip() + }) + + current_type, current_parameter = parse_annotation(line) + + current_lines = [] + + else: + current_lines.append(line) + + if current_lines: + blocks.append({ + "type": current_type, + "parameter": current_parameter, + "content": "\n".join(current_lines).strip() + }) + + return [ + block for block in blocks + if block["content"] + ] + + +def render_markdown(md_text: str) -> str: + return markdown.markdown( + md_text, + extensions=["extra"] + ) + + +def render_inline_markdown(md_text: str) -> str: + html = render_markdown(md_text.strip()) + match = re.fullmatch(r"

(.*)

", html, re.S) + + return match.group(1) if match else html + + +def split_h2_blocks(md_text: str): + lines = md_text.splitlines() + + title_lines = [] + blocks = [] + current = [] + + for line in lines: + + if line.startswith("## "): + + if current: + blocks.append( + "\n".join(current).strip() + ) + + current = [line] + + else: + + if current: + current.append(line) + + else: + title_lines.append(line) + + if current: + blocks.append( + "\n".join(current).strip() + ) + + return ( + "\n".join(title_lines).strip(), + blocks + ) + + +def extract_images(md_text: str) -> list[dict]: + pattern = r"!\[(.*?)\]\((.*?)\)" + + images = [] + + for alt, src in re.findall(pattern, md_text): + + images.append({ + "alt": alt.strip(), + "src": src.strip() + }) + + return images + + +def remove_markdown_images(md_text: str) -> str: + return re.sub( + r"!\[(.*?)\]\((.*?)\)", + "", + md_text + ).strip() + + +def remove_first_image(md_text: str): + + pattern = r"!\[(.*?)\]\((.*?)\)" + + match = re.search(pattern, md_text) + + if not match: + return None, md_text + + image = { + "alt": match.group(1).strip(), + "src": match.group(2).strip() + } + + remaining = ( + md_text[:match.start()] + + md_text[match.end():] + ) + + return image, remaining.strip() + + +def extract_links(md_text: str) -> list[dict]: + pattern = r"\[(.*?)\]\((.*?)\)" + + links = [] + + for label, href in re.findall(pattern, md_text): + links.append({ + "label": label.strip(), + "href": href.strip() + }) + + return links + + +def key_value_lines(md_text: str) -> tuple[dict, str]: + values = {} + remaining_lines = [] + + for line in md_text.splitlines(): + match = re.match(r"^([A-Za-zÄÖÜäöüß _-]+):\s*(.+)$", line) + + if match: + key = slugify_title(match.group(1)).replace("-", "_") + values[key] = match.group(2).strip() + else: + remaining_lines.append(line) + + return values, "\n".join(remaining_lines).strip() + + +def href_from_output( + current_output_file: Path, + target_output_file: Path +) -> str: + + current_dir = current_output_file.parent + + relative_href = os.path.relpath( + target_output_file, + start=current_dir + ) + + return relative_href.replace("\\", "/") + + +def content_path_from_src(src: str) -> Path: + return CONTENT_DIR / src + + +def output_path_from_src(src: str) -> Path: + return OUTPUT_DIR / src + + +def jpeg_dimensions(file_path: Path) -> tuple[int, int] | None: + with file_path.open("rb") as f: + if f.read(2) != b"\xff\xd8": + return None + + while True: + marker_start = f.read(1) + + if not marker_start: + return None + + if marker_start != b"\xff": + continue + + marker = f.read(1) + + while marker == b"\xff": + marker = f.read(1) + + if marker in { + b"\xc0", + b"\xc1", + b"\xc2", + b"\xc3", + b"\xc5", + b"\xc6", + b"\xc7", + b"\xc9", + b"\xca", + b"\xcb", + b"\xcd", + b"\xce", + b"\xcf" + }: + f.read(3) + height, width = struct.unpack(">HH", f.read(4)) + return width, height + + segment_length_data = f.read(2) + + if len(segment_length_data) != 2: + return None + + segment_length = struct.unpack( + ">H", + segment_length_data + )[0] + + f.seek(segment_length - 2, os.SEEK_CUR) + + +def image_dimensions(file_path: Path) -> tuple[int, int] | None: + try: + with file_path.open("rb") as f: + header = f.read(24) + + if header.startswith(b"\x89PNG\r\n\x1a\n"): + width, height = struct.unpack(">II", header[16:24]) + return width, height + + if header[:6] in {b"GIF87a", b"GIF89a"}: + width, height = struct.unpack(" dict: + return { + "width": 1600, + "height": 1000 + } + + +def image_size_for_content_relative(src: str) -> dict: + src = src.split("?", 1)[0].split("#", 1)[0] + + if is_external_url(src): + return fallback_image_size() + + file_path = CONTENT_DIR / src + + if not file_path.exists(): + return fallback_image_size() + + dimensions = image_dimensions(file_path) + + if not dimensions: + return fallback_image_size() + + width, height = dimensions + + return { + "width": width, + "height": height + } + + +def is_external_url(src: str) -> bool: + return ( + src.startswith("http://") + or src.startswith("https://") + ) + + +def media_src_variants(src: str) -> list[str]: + 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 + ] + + return list(dict.fromkeys(variants)) + + +def find_asset_by_name(file_name: str) -> Path | None: + media_dirs = [ + CONTENT_DIR / MEDIA_DIR_NAME, + CONTENT_DIR / LEGACY_MEDIA_DIR_NAME + ] + + existing_dirs = [ + media_dir for media_dir in media_dirs + if media_dir.exists() + ] + + if not existing_dirs: + return None + + matches = [] + + for media_dir in existing_dirs: + matches.extend([ + path for path in media_dir.rglob(file_name) + if path.is_file() + ]) + + matches = sorted(matches) + + if not matches: + return None + + return matches[0] + + +def content_relative_from_src( + src: str, + source_file: Path | None = None +) -> str: + src = src.strip().strip("/") + + candidates = [] + + for variant in media_src_variants(src): + + if source_file: + candidates.append(source_file.parent / variant) + + candidates.append(CONTENT_DIR / variant) + + for candidate in candidates: + candidate = candidate.resolve() + + if not candidate.exists(): + continue + + try: + return candidate.relative_to(CONTENT_DIR).as_posix() + except ValueError: + pass + + fallback = find_asset_by_name(Path(src).name) + + if fallback: + return fallback.relative_to(CONTENT_DIR).as_posix() + + return src + + +def image_src_for_page( + src: str, + current_output_file: Path, + source_file: Path | None = None +) -> str: + + content_relative = content_relative_from_src( + src, + source_file + ) + + target_output_file = output_path_from_src( + content_relative + ) + + return href_from_output( + current_output_file, + target_output_file + ) + + +def collect_gallery_images( + folder_name: str, + current_output_file: Path +) -> list[dict]: + + source_folder = CONTENT_DIR / MEDIA_DIR_NAME / GALLERY_DIR_NAME / folder_name + + if not source_folder.exists(): + source_folder = ( + CONTENT_DIR + / LEGACY_MEDIA_DIR_NAME + / LEGACY_GALLERY_DIR_NAME + / folder_name + ) + + if not source_folder.exists(): + return [] + + images = [] + + for file in sorted(source_folder.iterdir()): + + if ( + file.is_file() + and file.suffix.lower() in IMAGE_EXTENSIONS + ): + + content_relative = file.relative_to( + CONTENT_DIR + ).as_posix() + + image_src = image_src_for_page( + content_relative, + current_output_file + ) + + images.append({ + "alt": "", + "src": image_src, + **image_size_for_content_relative( + content_relative + ) + }) + + return images + + +def resolve_manual_image_paths( + images: list[dict], + current_output_file: Path, + source_file: Path +) -> list[dict]: + + resolved_images = [] + + for image in images: + + src = image["src"] + + if is_external_url(src): + + resolved_src = src + size = fallback_image_size() + + else: + content_relative = content_relative_from_src( + src, + source_file + ) + + resolved_src = image_src_for_page( + content_relative, + current_output_file, + None + ) + + size = image_size_for_content_relative( + content_relative + ) + + resolved_images.append({ + "alt": image["alt"], + "src": resolved_src, + **size + }) + + return resolved_images + + +def card_layout(parameter: str) -> str: + normalized = slugify_title(parameter or "") + + return { + "einspaltig": "one", + "eine_spalte": "one", + "zweispaltig": "two", + "zwei_spalten": "two", + "dreispaltig": "three", + "drei_spalten": "three" + }.get(normalized, "one") + + +def render_cards(md_text: str, parameter: str = "") -> str: + + title, cards = split_h2_blocks(md_text) + + template = env.get_template("cards.html") + + return template.render( + layout=card_layout(parameter), + title=render_markdown(title), + cards=[ + render_markdown(card) + for card in cards + ] + ) + + +def render_two_columns(md_text: str) -> str: + + title, columns = split_h2_blocks(md_text) + + left = columns[0] if len(columns) > 0 else "" + right = columns[1] if len(columns) > 1 else "" + + template = env.get_template("two-columns.html") + + return template.render( + title=render_markdown(title), + left=render_markdown(left), + right=render_markdown(right) + ) + + +def render_timeline(md_text: str) -> str: + + title, items = split_h2_blocks(md_text) + + template = env.get_template("timeline.html") + + return template.render( + title=render_markdown(title), + items=[ + render_markdown(item) + for item in items + ] + ) + + +def render_faq(md_text: str) -> str: + + title, items = split_h2_blocks(md_text) + + template = env.get_template("faq.html") + + return template.render( + title=render_markdown(title), + items=[ + render_markdown(item) + for item in items + ] + ) + + +def render_gallery( + md_text: str, + current_output_file: Path, + source_file: Path, + parameter: str | None +) -> str: + + if parameter: + + images = collect_gallery_images( + parameter, + current_output_file + ) + + title_text = md_text.strip() + + else: + + manual_images = extract_images(md_text) + + images = resolve_manual_image_paths( + manual_images, + current_output_file, + source_file + ) + + title_text = remove_markdown_images(md_text) + + template = env.get_template("gallery.html") + external_content_enabled = SETTINGS.get( + "privacy", + {} + ).get("external_content", False) + has_external_images = any( + is_external_url(image["src"]) + for image in images + ) + + return template.render( + title=render_markdown(title_text), + images=images, + gallery=SETTINGS.get("gallery", {}), + needs_external_consent=has_external_images and not external_content_enabled + ) + + +def render_image_text( + md_text: str, + current_output_file: Path, + source_file: Path +) -> str: + + image, remaining_text = remove_first_image(md_text) + + if image: + + image["src"] = image_src_for_page( + image["src"], + current_output_file, + source_file + ) + + template = env.get_template("image-text.html") + + return template.render( + image=image, + content=render_markdown(remaining_text) + ) + + +def event_parts( + md_text: str, + current_output_file: Path, + source_file: Path +) -> dict: + values, remaining_text = key_value_lines(md_text) + image, remaining_text = remove_first_image(remaining_text) + + if image: + image["src"] = image_src_for_page( + image["src"], + current_output_file, + source_file + ) + + return { + "facts": { + key: render_inline_markdown(value) + for key, value in values.items() + }, + "content": render_markdown(remaining_text), + "image": image + } + + +def render_event( + md_text: str, + current_output_file: Path, + source_file: Path +) -> str: + event = event_parts(md_text, current_output_file, source_file) + template = env.get_template("event.html") + + return template.render( + values=event["facts"], + content=event["content"], + image=event["image"] + ) + + +def render_events( + md_text: str, + current_output_file: Path, + source_file: Path +) -> str: + title, event_blocks = split_h2_blocks(md_text) + events = [] + + for event_block in event_blocks: + events.append(event_parts( + event_block, + current_output_file, + source_file + )) + + template = env.get_template("events.html") + + return template.render( + title=render_markdown(title), + events=events + ) + + +def render_downloads( + md_text: str, + current_output_file: Path, + source_file: Path +) -> str: + title_text = remove_markdown_images(md_text) + links = [] + + for link in extract_links(md_text): + href = link["href"] + + if not is_external_url(href): + href = image_src_for_page( + href, + current_output_file, + source_file + ) + + links.append({ + "label": link["label"], + "href": href + }) + + template = env.get_template("downloads.html") + + return template.render( + title=render_markdown(re.sub( + r"\[(.*?)\]\((.*?)\)", + "", + title_text + ).strip()), + links=links + ) + + +def render_people(md_text: str) -> str: + title, people = split_h2_blocks(md_text) + template = env.get_template("people.html") + + return template.render( + title=render_markdown(title), + people=[ + render_markdown(person) + for person in people + ] + ) + + +def render_person(md_text: str) -> str: + template = env.get_template("person.html") + + return template.render( + content=render_markdown(md_text) + ) + + +def render_sponsors( + md_text: str, + current_output_file: Path, + source_file: Path +) -> str: + images = resolve_manual_image_paths( + extract_images(md_text), + current_output_file, + source_file + ) + title_text = remove_markdown_images(md_text) + template = env.get_template("sponsors.html") + + return template.render( + title=render_markdown(title_text), + sponsors=images + ) + + +def youtube_video_id(url: str) -> str | None: + parsed = urlparse(url) + host = parsed.netloc.lower().replace("www.", "") + path_parts = [ + part for part in parsed.path.split("/") + if part + ] + + if host == "youtu.be" and path_parts: + return path_parts[0] + + if host.endswith("youtube.com"): + if parsed.path == "/watch": + return parse_qs(parsed.query).get("v", [None])[0] + + if len(path_parts) >= 2 and path_parts[0] in { + "embed", + "shorts" + }: + return path_parts[1] + + return None + + +def youtube_embed_url(url: str) -> str | None: + video_id = youtube_video_id(url) + + if not video_id: + return None + + return ( + "https://www.youtube.com/embed/" + f"{video_id}" + "?autoplay=1" + "&mute=1" + "&loop=1" + f"&playlist={video_id}" + "&controls=0" + "&modestbranding=1" + "&playsinline=1" + ) + + +def hero_media_from_parameter( + parameter: str | None, + current_output_file: Path, + source_file: Path +) -> dict | None: + if not parameter: + return None + + parameter = parameter.strip() + suffix = Path(urlparse(parameter).path).suffix.lower() + + if is_external_url(parameter): + embed_url = youtube_embed_url(parameter) + + if embed_url: + return { + "type": "youtube", + "src": embed_url, + "external_url": parameter, + "style": "" + } + + if suffix in IMAGE_EXTENSIONS: + return { + "type": "image", + "src": parameter, + "style": "" + } + + if suffix in VIDEO_EXTENSIONS: + return { + "type": "video", + "src": parameter, + "style": "" + } + + if suffix in IMAGE_EXTENSIONS: + return { + "type": "image", + "src": image_src_for_page( + parameter, + current_output_file, + source_file + ), + "style": "" + } + + if suffix in VIDEO_EXTENSIONS: + return { + "type": "video", + "src": image_src_for_page( + parameter, + current_output_file, + source_file + ), + "style": "" + } + + return { + "type": "color", + "src": "", + "style": f"background: {parameter};" + } + + +def render_hero( + md_text: str, + current_output_file: Path, + source_file: Path, + parameter: str | None +) -> str: + template = env.get_template("hero.html") + + return template.render( + content=render_markdown(md_text), + external_content_enabled=SETTINGS.get( + "privacy", + {} + ).get("external_content", False), + hero=hero_media_from_parameter( + parameter, + current_output_file, + source_file + ) + ) + + +def osm_search_url(address: str) -> str: + return ( + "https://www.openstreetmap.org/search?query=" + f"{quote_plus(address)}" + ) + + +def render_location( + md_text: str, + parameter: str | None +) -> str: + address = (parameter or "").strip() + content = md_text.strip() + + if not address: + values, remaining_text = key_value_lines(md_text) + address = values.get("Adresse", values.get("adresse", "")).strip() + content = remaining_text + + map_url = osm_search_url(address) if address else "" + template = env.get_template("location.html") + + return template.render( + address=address, + content=render_markdown(content), + map_url=map_url, + external_content_enabled=SETTINGS.get( + "privacy", + {} + ).get("external_content", False) + ) + + +def render_footer( + md_text: str, + logo: dict | None = None, + social_links: list[dict] | None = None +) -> str: + + title, columns = split_h2_blocks(md_text) + columns = [ + column for column in columns + if not re.match( + r"(?im)^##\s*(social|soziales|social media)\s*$", + column.strip() + ) + ] + + template = env.get_template("footer.html") + + return template.render( + title=render_markdown(title), + logo=logo, + social_links=social_links or [], + columns=[ + render_markdown(column) + for column in columns + ] + ) + + +def render_block( + block: dict, + current_output_file: Path, + source_file: Path +) -> str: + + block_type = block["type"] + content = block["content"] + parameter = block["parameter"] + + if block_type == "cards": + return render_cards(content, parameter) + + if block_type == "two-columns": + return render_two_columns(content) + + if block_type == "timeline": + return render_timeline(content) + + if block_type == "faq": + return render_faq(content) + + if block_type == "gallery": + return render_gallery( + content, + current_output_file, + source_file, + parameter + ) + + if block_type == "image-text": + return render_image_text( + content, + current_output_file, + source_file + ) + + if block_type == "event": + return render_event( + content, + current_output_file, + source_file + ) + + if block_type == "events": + return render_events( + content, + current_output_file, + source_file + ) + + if block_type == "downloads": + return render_downloads( + content, + current_output_file, + source_file + ) + + if block_type == "person": + return render_person(content) + + if block_type == "people": + return render_people(content) + + if block_type == "sponsors": + return render_sponsors( + content, + current_output_file, + source_file + ) + + if block_type == "location": + return render_location( + content, + parameter + ) + + if block_type == "hero": + return render_hero( + content, + current_output_file, + source_file, + parameter + ) + + template_name = f"{block_type}.html" + + if not (TEMPLATE_DIR / template_name).exists(): + template_name = "text.html" + + template = env.get_template(template_name) + + return template.render( + content=render_markdown(content) + ) + + +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 output_path_from_file(md_file: Path) -> Path: + + relative = md_file.relative_to(CONTENT_DIR) + relative_parts = [ + display_name(part) + for part in relative.parts + ] + relative = Path(*relative_parts) + + if display_name(md_file.stem) == "index": + return OUTPUT_DIR / relative.parent / "index.html" + + return OUTPUT_DIR / relative.with_suffix(".html") + + +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 is_settings_file(md_file: Path) -> bool: + return md_file == SETTINGS_MARKDOWN_FILE + + +def static_path_from_output(output_file: Path) -> str: + + return href_from_output( + output_file, + OUTPUT_DIR / "static" / "style.css" + ) + + +def logo_for_page( + settings: dict, + current_output_file: Path +) -> dict: + logo = settings.get("logo", {}).copy() + + logo_src = logo.get("src", "").strip().strip("/") + logo_folder = logo.get("folder", "logo").strip().strip("/") or "logo" + + if not logo_src: + logo_dir = CONTENT_DIR / MEDIA_DIR_NAME / logo_folder + + if not logo_dir.exists(): + logo_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME / logo_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 logo_files: + logo_src = f"{MEDIA_DIR_NAME}/{logo_folder}/{logo_files[0]}" + + if logo_src: + + if "/" not in logo_src: + logo_src = f"{MEDIA_DIR_NAME}/{logo_folder}/{logo_src}" + + logo["url"] = image_src_for_page( + logo_src, + current_output_file + ) + else: + logo["url"] = "" + + logo["home_url"] = href_from_output( + current_output_file, + OUTPUT_DIR / "index.html" + ) + + return logo + + +def social_for_page(settings: dict) -> list[dict]: + social = settings.get("social", {}) + icon_style = settings.get("icons", {}).get("style", "round") + labels = { + "instagram": "Instagram", + "youtube": "YouTube", + "facebook": "Facebook", + "x": "X", + "twitter": "Twitter", + "linkedin": "LinkedIn" + } + icons = { + "instagram": "IG", + "youtube": "YT", + "facebook": "FB", + "x": "X", + "twitter": "TW", + "linkedin": "IN" + } + + return [ + { + "key": key, + "label": labels.get(key, key.title()), + "icon": ( + labels.get(key, key.title()) + if icon_style == "text" + else icons.get(key, key[:2].upper()) + ), + "url": url + } + for key, url in social.items() + if url + ] + + +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 + ) + + slug = slug.strip("-") + + return slug + + +def build_page_index() -> dict: + + page_index = {} + + for md_file in CONTENT_DIR.rglob("*.md"): + + if is_footer_file(md_file): + continue + + if is_settings_file(md_file): + continue + + key_from_filename = md_file.stem.lower() + + key_from_title = slugify_title( + title_from_file(md_file) + ) + + target = output_path_from_file(md_file) + + page_index[key_from_filename] = target + page_index[key_from_title] = target + + return page_index + + +def resolve_wiki_links( + text: str, + current_output_file: Path, + page_index: dict +) -> str: + + pattern = r"\[\[(.*?)\]\]" + + def replacer(match): + + label = match.group(1).strip() + + key = slugify_title(label) + + if key not in page_index: + return label + + target_output = page_index[key] + + href = href_from_output( + current_output_file, + target_output + ) + + return f"[{label}]({href})" + + return re.sub(pattern, replacer, text) + + +def build_navigation_raw() -> list[dict]: + + navigation = [] + + for md_file in sorted(CONTENT_DIR.glob("*.md")): + + if md_file.name == FOOTER_FILE_NAME: + continue + + if is_settings_file(md_file): + continue + + navigation.append({ + "type": "link", + "title": title_from_file(md_file), + "target": output_path_from_file(md_file) + }) + + for folder in sorted([ + p for p in CONTENT_DIR.iterdir() + if p.is_dir() + ]): + + if folder.name == FOOTER_DIR_NAME: + continue + + if folder.name in {MEDIA_DIR_NAME, LEGACY_MEDIA_DIR_NAME}: + continue + + children = [] + + for md_file in sorted(folder.glob("*.md")): + + children.append({ + "title": title_from_file(md_file), + "target": output_path_from_file(md_file) + }) + + if children: + + navigation.append({ + "type": "dropdown", + "title": display_name(folder.name).replace( + "-", + " " + ).title(), + "children": children + }) + + return navigation + + +def navigation_for_page( + navigation_raw: list[dict], + current_output_file: Path +) -> list[dict]: + + navigation = [] + + for item in navigation_raw: + + if item["type"] == "link": + + navigation.append({ + "type": "link", + "title": item["title"], + "url": href_from_output( + current_output_file, + item["target"] + ) + }) + + elif item["type"] == "dropdown": + + children = [] + + for child in item["children"]: + + children.append({ + "title": child["title"], + "url": href_from_output( + current_output_file, + child["target"] + ) + }) + + navigation.append({ + "type": "dropdown", + "title": item["title"], + "children": children + }) + + return navigation + + +def render_footer_for_page( + current_output_file: Path, + page_index: dict +) -> str: + + footer_file = CONTENT_DIR / FOOTER_DIR_NAME / FOOTER_FILE_NAME + + if not footer_file.exists(): + footer_file = CONTENT_DIR / FOOTER_DIR_NAME / LEGACY_FOOTER_FILE_NAME + + if not footer_file.exists(): + footer_file = CONTENT_DIR / LEGACY_FOOTER_DIR_NAME / LEGACY_FOOTER_FILE_NAME + + if not footer_file.exists(): + return "" + + footer_md = footer_file.read_text( + encoding="utf-8" + ) + + footer_md = resolve_wiki_links( + footer_md, + current_output_file, + page_index + ) + + return render_footer( + footer_md, + logo_for_page( + SETTINGS, + current_output_file + ), + social_for_page(SETTINGS) + ) + + +def build_page( + input_file: Path, + output_file: Path, + navigation_raw: list[dict], + page_index: dict +): + + md_text = input_file.read_text( + encoding="utf-8" + ) + + md_text = resolve_wiki_links( + md_text, + output_file, + page_index + ) + + blocks = parse_blocks(md_text) + + has_gallery = any( + block["type"] == "gallery" + for block in blocks + ) + + body = "\n".join([ + render_block( + block, + output_file, + input_file + ) + for block in blocks + ]) + + footer = render_footer_for_page( + output_file, + page_index + ) + + html = env.get_template( + "base.html" + ).render( + body=body, + footer=footer, + navigation=navigation_for_page( + navigation_raw, + output_file + ), + static_path=static_path_from_output(output_file), + has_gallery=has_gallery, + settings=SETTINGS, + logo=logo_for_page( + SETTINGS, + output_file + ) + ) + + output_file.parent.mkdir( + parents=True, + exist_ok=True + ) + + output_file.write_text( + html, + encoding="utf-8" + ) + + +def copy_static(): + + target = OUTPUT_DIR / "static" + + if target.exists(): + shutil.rmtree(target) + + shutil.copytree( + STATIC_DIR, + target + ) + + +def copy_content_media(): + + source = CONTENT_DIR / MEDIA_DIR_NAME + target = OUTPUT_DIR / MEDIA_DIR_NAME + + if not source.exists(): + source = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME + + if not source.exists(): + return + + if target.exists(): + shutil.rmtree(target) + + shutil.copytree( + source, + target + ) + + +def build_site(): + global SETTINGS + + run_validation() + SETTINGS = load_settings() + + if OUTPUT_DIR.exists(): + shutil.rmtree(OUTPUT_DIR) + + OUTPUT_DIR.mkdir(exist_ok=True) + + copy_static() + copy_content_media() + + navigation_raw = build_navigation_raw() + + page_index = build_page_index() + + for md_file in CONTENT_DIR.rglob("*.md"): + + if is_footer_file(md_file): + continue + + if is_settings_file(md_file): + continue + + output_file = output_path_from_file( + md_file + ) + + build_page( + md_file, + output_file, + navigation_raw, + page_index + ) + + print( + f"gebaut: " + f"{md_file.relative_to(ROOT_DIR)} " + f"→ " + f"{output_file.relative_to(ROOT_DIR)}" + ) + + +if __name__ == "__main__": + build_site() diff --git a/generator/settings.json b/generator/settings.json new file mode 100644 index 0000000..419b4a5 --- /dev/null +++ b/generator/settings.json @@ -0,0 +1,32 @@ +{ + "title": "Havelseiten", + "language": "de", + "validator_language": "de", + "navigation": { + "height": "large" + }, + "logo": { + "enabled": true, + "placement": "header", + "src": "msvb_logo.png", + "alt": "Märkischer Seglerverein Beetzsee", + "text": "Havelseiten" + }, + "colors": { + "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/settings_config.py b/generator/settings_config.py new file mode 100644 index 0000000..8327cef --- /dev/null +++ b/generator/settings_config.py @@ -0,0 +1,1022 @@ +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)) diff --git a/generator/static/external-content.js b/generator/static/external-content.js new file mode 100644 index 0000000..7ec4563 --- /dev/null +++ b/generator/static/external-content.js @@ -0,0 +1,325 @@ +const CONSENT_PREFIX = "havelseiten:external:"; + +function storageGet(key) { + try { + return localStorage.getItem(key); + } catch (error) { + return null; + } +} + +function storageSet(key, value) { + try { + localStorage.setItem(key, value); + } catch (error) { + // If storage is unavailable, the current in-page load still works. + } +} + +function consentKey(button) { + if (button.dataset.consentKey) { + return `${CONSENT_PREFIX}${button.dataset.consentKey}`; + } + + if (button.dataset.mapAddress) { + return `${CONSENT_PREFIX}map:${button.dataset.mapAddress}`; + } + + if (button.dataset.embedSrc) { + return `${CONSENT_PREFIX}embed:${button.dataset.embedSrc}`; + } + + return ""; +} + +function mapEmbedUrl(lat, lon) { + const latitude = Number(lat); + const longitude = Number(lon); + const bbox = [ + longitude - 0.01, + latitude - 0.006, + longitude + 0.01, + latitude + 0.006 + ].join(","); + + return ( + "https://www.openstreetmap.org/export/embed.html" + + `?bbox=${encodeURIComponent(bbox)}` + + "&layer=mapnik" + + `&marker=${encodeURIComponent(`${latitude},${longitude}`)}` + ); +} + +async function geocodeAddress(address) { + const url = ( + "https://nominatim.openstreetmap.org/search" + + `?format=json&limit=1&q=${encodeURIComponent(address)}` + ); + const response = await fetch(url, { + headers: { + Accept: "application/json" + } + }); + const results = await response.json(); + + if (!results.length) { + throw new Error("Adresse nicht gefunden"); + } + + return results[0]; +} + +async function loadEmbed(button, remember = true) { + const iframe = document.createElement("iframe"); + const key = consentKey(button); + + button.disabled = true; + button.textContent = "Wird geladen..."; + + if (button.dataset.mapAddress) { + const cachedMapUrl = storageGet(`${key}:src`); + + if (cachedMapUrl) { + iframe.src = cachedMapUrl; + } else { + try { + const place = await geocodeAddress(button.dataset.mapAddress); + iframe.src = mapEmbedUrl(place.lat, place.lon); + storageSet(`${key}:src`, iframe.src); + } catch (error) { + button.disabled = false; + button.textContent = "Karte konnte nicht geladen werden"; + return; + } + } + } else { + iframe.src = button.dataset.embedSrc; + } + + iframe.className = button.dataset.embedClass || ""; + iframe.title = button.dataset.embedTitle || "Externer Inhalt"; + iframe.loading = "lazy"; + iframe.allow = button.dataset.embedAllow || ""; + iframe.allowFullscreen = true; + + if (remember && key) { + storageSet(key, "1"); + } + + button.closest(".external-placeholder, .location-placeholder").replaceWith(iframe); +} + +function galleryItems() { + return [...document.querySelectorAll(".gallery-grid a")].map((link) => ({ + alt: link.querySelector("img")?.alt || "", + height: link.dataset.pswpHeight, + src: link.href, + width: link.dataset.pswpWidth + })); +} + +function createLightbox() { + const lightbox = document.createElement("div"); + lightbox.className = "site-lightbox"; + lightbox.hidden = true; + lightbox.innerHTML = ` + + +
+ +
+
+ + `; + document.body.append(lightbox); + + return lightbox; +} + +function initGallery() { + const items = galleryItems(); + + if (!items.length) { + return; + } + + const lightbox = createLightbox(); + const image = lightbox.querySelector("img"); + const caption = lightbox.querySelector("figcaption"); + let currentIndex = 0; + let touchStartX = 0; + + function show(index) { + currentIndex = (index + items.length) % items.length; + const item = items[currentIndex]; + image.src = item.src; + image.alt = item.alt; + caption.textContent = item.alt; + caption.hidden = !item.alt; + lightbox.hidden = false; + document.body.classList.add("lightbox-open"); + } + + function close() { + lightbox.hidden = true; + image.removeAttribute("src"); + document.body.classList.remove("lightbox-open"); + } + + function next() { + show(currentIndex + 1); + } + + function prev() { + show(currentIndex - 1); + } + + document.querySelectorAll(".gallery-grid a").forEach((link, index) => { + link.addEventListener("click", (event) => { + event.preventDefault(); + show(index); + }); + }); + + lightbox.querySelector(".site-lightbox-close").addEventListener("click", close); + lightbox.querySelector(".site-lightbox-next").addEventListener("click", next); + lightbox.querySelector(".site-lightbox-prev").addEventListener("click", prev); + + lightbox.addEventListener("click", (event) => { + if (event.target === lightbox) { + close(); + } + }); + + lightbox.addEventListener("touchstart", (event) => { + touchStartX = event.changedTouches[0].clientX; + }, { passive: true }); + + lightbox.addEventListener("touchend", (event) => { + const delta = event.changedTouches[0].clientX - touchStartX; + + if (Math.abs(delta) < 40) { + return; + } + + if (delta < 0) { + next(); + } else { + prev(); + } + }, { passive: true }); + + document.addEventListener("keydown", (event) => { + if (lightbox.hidden) { + return; + } + + if (event.key === "Escape") { + close(); + } else if (event.key === "ArrowRight") { + next(); + } else if (event.key === "ArrowLeft") { + prev(); + } + }); +} + +function initSubmenus() { + const mobileQuery = window.matchMedia("(max-width: 800px)"); + + document.querySelectorAll(".dropdown").forEach((dropdown) => { + const button = dropdown.querySelector(".dropdown-label"); + + if (!button) { + return; + } + + const startsOpen = ( + document.body.classList.contains("mobile-submenus-open") + && mobileQuery.matches + ); + + dropdown.classList.toggle("is-open", startsOpen); + button.setAttribute("aria-expanded", startsOpen ? "true" : "false"); + button.addEventListener("click", (event) => { + if (!mobileQuery.matches) { + return; + } + + event.preventDefault(); + const isOpen = dropdown.classList.toggle("is-open"); + button.setAttribute("aria-expanded", isOpen ? "true" : "false"); + }); + + dropdown.addEventListener("mouseenter", () => { + if (mobileQuery.matches) { + return; + } + + dropdown.classList.add("is-open"); + button.setAttribute("aria-expanded", "true"); + }); + + dropdown.addEventListener("mouseleave", () => { + if (mobileQuery.matches) { + return; + } + + dropdown.classList.remove("is-open"); + button.setAttribute("aria-expanded", "false"); + }); + + dropdown.addEventListener("focusin", () => { + if (mobileQuery.matches) { + return; + } + + dropdown.classList.add("is-open"); + button.setAttribute("aria-expanded", "true"); + }); + + dropdown.addEventListener("focusout", (event) => { + if ( + mobileQuery.matches + || dropdown.contains(event.relatedTarget) + ) { + return; + } + + dropdown.classList.remove("is-open"); + button.setAttribute("aria-expanded", "false"); + }); + }); +} + +document.querySelectorAll(".external-load-button").forEach((button) => { + const key = consentKey(button); + + if (button.dataset.autoLoad === "true" || (key && storageGet(key) === "1")) { + loadEmbed(button, false); + } +}); + +document.addEventListener("click", (event) => { + const externalButton = event.target.closest(".external-load-button"); + + if (externalButton) { + loadEmbed(externalButton); + } + + const galleryButton = event.target.closest(".gallery-load-button"); + + if (galleryButton) { + const section = galleryButton.closest(".gallery-section"); + const grid = section?.querySelector(".gallery-grid"); + + if (grid) { + grid.hidden = false; + } + + galleryButton.closest(".gallery-placeholder")?.remove(); + } +}); + +initSubmenus(); +initGallery(); diff --git a/generator/static/style.css b/generator/static/style.css new file mode 100644 index 0000000..8e9c14f --- /dev/null +++ b/generator/static/style.css @@ -0,0 +1,1199 @@ +html, +body { + min-height: 100%; +} + +body { + font-family: var(--font-body, system-ui, sans-serif); + margin: 0; + line-height: 1.65; + color: var(--color-text, #111827); + background: var(--color-background, #ffffff); + + min-height: 100vh; + display: flex; + flex-direction: column; +} + +h1, +h2, +h3 { + font-family: var(--font-heading, var(--font-body, system-ui, sans-serif)); + line-height: 1.15; + margin-top: 0; +} + +h1 { + margin-bottom: 1.1rem; +} + +h2, +h3 { + margin-bottom: 0.8rem; +} + +p { + line-height: 1.68; + margin-top: 0; +} + +.font-size-small { + font-size: 0.95rem; +} + +.font-size-medium { + font-size: 1rem; +} + +.font-size-large { + font-size: 1.12rem; +} + +.font-modern { + letter-spacing: 0.01em; +} + +.font-modern h1, +.font-modern h2, +.font-modern h3 { + font-weight: 850; +} + +.font-classic h1, +.font-classic h2, +.font-classic h3, +.font-friendly h1, +.font-friendly h2, +.font-friendly h3 { + font-weight: 700; +} + +.nav-height-small { + --nav-height: 56px; +} + +.nav-height-medium { + --nav-height: 72px; +} + +.nav-height-large { + --nav-height: 96px; +} + +a { + color: var(--color-link, #0f766e); +} + +a:hover { + color: var(--color-link-hover, #115e59); +} + +.site-main { + flex: 1; +} + +.site-header { + background: var(--color-header-background, #111827); + position: sticky; + top: 0; + z-index: 100; +} + +.nav-position-static .site-header { + position: static; +} + +.nav-wrap { + box-sizing: border-box; + margin: 0 auto; + max-width: none; + padding: 0 2rem; + width: 100%; + min-height: 72px; + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; +} + +.site-logo { + align-items: center; + color: var(--color-header-text, white); + display: inline-flex; + font-weight: 800; + gap: 0.65rem; + letter-spacing: 0.02em; + line-height: 1; + text-decoration: none; + white-space: nowrap; +} + +.site-logo:hover { + color: var(--color-header-text, white); +} + +.site-logo img { + display: block; + object-fit: contain; +} + +.site-logo-nav { + flex: 0 0 auto; +} + +.site-logo-nav img { + height: 54px; + max-width: 170px; +} + +.site-logo-nav span { + font-size: 1.05rem; +} + +.logo-align-right .site-logo-nav { + order: 2; +} + +.nav-align-left .main-nav { + justify-content: flex-start; +} + +.nav-align-center .main-nav { + justify-content: center; +} + +.nav-align-right .main-nav { + justify-content: flex-end; +} + +.logo-align-left .main-nav { + margin-left: 1rem; +} + +.logo-align-right .main-nav { + margin-right: 1rem; + order: 1; +} + +.nav-height-small .nav-wrap { + min-height: 56px; +} + +.nav-height-medium .nav-wrap { + min-height: 72px; +} + +.nav-height-large .nav-wrap { + min-height: 96px; +} + +.nav-height-small .site-logo-nav img { + height: 38px; + max-width: 140px; +} + +.nav-height-medium .site-logo-nav img { + height: 54px; + max-width: 170px; +} + +.nav-height-large .site-logo-nav img { + height: 78px; + max-width: 220px; +} + +.nav-height-small .main-nav a, +.nav-height-small .dropdown-label { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.nav-height-large .main-nav a, +.nav-height-large .dropdown-label { + padding-top: 1.55rem; + padding-bottom: 1.55rem; +} + +.nav-toggle { + display: none; +} + +.burger { + display: none; + color: var(--color-header-text, white); + font-size: 2rem; + cursor: pointer; + line-height: 1; +} + +.main-nav { + display: flex; + flex: 1 1 auto; + justify-content: center; + align-items: center; + gap: 0.5rem; +} + +.main-nav a, +.dropdown-label { + appearance: none; + background: transparent; + border: 0; + box-sizing: border-box; + color: var(--color-header-text, white); + text-decoration: none; + font: inherit; + font-weight: 600; + padding: 1.25rem 1rem; + display: block; + cursor: pointer; +} + +.dropdown.is-open > .dropdown-label, +.main-nav a:focus-visible, +.dropdown-label:focus-visible { + background: var(--color-header-hover, #1f2937); + color: var(--color-header-text, white); +} + +.nav-item { + position: relative; +} + +.submenu { + display: none; + position: absolute; + top: 100%; + left: 0; + min-width: 220px; + background: var(--color-header-hover, #1f2937); + box-shadow: 0 10px 20px rgba(0,0,0,0.18); +} + +.submenu a { + padding: 0.8rem 1rem; + white-space: nowrap; +} + +.dropdown.is-open .submenu { + display: block; +} + +section { + padding: clamp(3rem, 6vw, 6rem) clamp(1.25rem, 10vw, 8rem); +} + +.hero { + box-sizing: border-box; + min-height: clamp(360px, 48vh, 560px); + display: flex; + flex-direction: column; + justify-content: center; + background: var(--color-hero-background, #f1f5f9); + position: relative; + overflow: hidden; + padding-block: clamp(4rem, 8vw, 6rem); +} + +.hero h1 { + font-size: clamp(2.25rem, 5vw, 3.45rem); + line-height: 1.04; + margin-bottom: 0.9rem; +} + +.hero-content { + max-width: 680px; + position: relative; + z-index: 2; +} + +.hero-content p { + font-size: clamp(1.02rem, 1.7vw, 1.22rem); + line-height: 1.58; + max-width: 58ch; +} + +.hero-has-media { + color: white; + background: var(--color-accent, #111827); +} + +.hero-has-media::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + rgba(0, 0, 0, 0.72), + rgba(0, 0, 0, 0.54) 46%, + rgba(0, 0, 0, 0.34) + ); + pointer-events: none; + z-index: 1; +} + +.hero-color { + color: white; +} + +.hero-media { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 0; +} + +.hero-youtube-frame { + width: 120%; + height: 120%; + left: -10%; + top: -10%; + border: 0; + pointer-events: none; +} + +.external-placeholder { + align-items: center; + background: rgba(17, 24, 39, 0.72); + display: flex; + justify-content: center; + padding: 2rem; + pointer-events: auto; + text-align: center; + z-index: 3; +} + +.external-load-button, +.gallery-load-button { + border: 0; + border-radius: 999px; + background: var(--color-footer-text, white); + color: var(--color-footer-background, #111827); + cursor: pointer; + font: inherit; + font-weight: 800; + padding: 0.75rem 1rem; +} + +.text-section { + max-width: 70ch; +} + +.text-section, +.card, +.event-content, +.contact-box, +.person-card, +.footer-column { + line-height: 1.65; +} + +.cards { + display: flex; + flex-direction: column; + gap: 1.35rem; +} + +.cards-layout-two .cards, +.cards-layout-three .cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.cards-layout-three .cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.card { + padding: 1.65rem; + border: 1px solid color-mix(in srgb, var(--color-border, #ddd) 70%, transparent); + border-radius: 0.9rem; + background: var(--color-surface, white); + box-shadow: 0 10px 24px rgba(17, 24, 39, 0.06); +} + +.card h2, +.card h3 { + margin-bottom: 0.65rem; +} + +.two-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3rem; +} + +.two-columns-title { + grid-column: 1 / -1; +} + +.timeline { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.timeline-title { + margin-bottom: 1rem; +} + +.timeline-item { + padding-left: 1.5rem; + border-left: 4px solid var(--color-accent, #111827); +} + +.faq { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.faq-title { + margin-bottom: 1rem; +} + +.faq-item { + padding: 1rem 1.5rem; + background: var(--color-surface-alt, #f1f5f9); + border-radius: 1rem; +} + +.banner { + background: var(--color-accent, #111827); + color: white; + text-align: center; + padding: 2rem 10vw; + font-size: 1.25rem; + font-weight: 600; +} + +.banner a { + color: white; + text-decoration: underline; +} + +.quote-section { + background: var(--color-surface-alt, #f9fafb); +} + +.quote-box { + max-width: 800px; + margin: 0 auto; + font-size: 1.6rem; + font-style: italic; + border-left: 6px solid var(--color-accent, #111827); + padding-left: 2rem; +} + +.quote-box blockquote { + margin: 0; +} + +.contact-section { + background: var(--color-surface-alt, #f1f5f9); +} + +.contact-box { + max-width: 700px; + margin: 0 auto; + padding: 2rem; + background: var(--color-surface, white); + border: 1px solid var(--color-border, #ddd); + border-radius: 1rem; +} + +.contact-box p { + margin: 0.5rem 0; +} + +.location-section { + background: var(--color-surface-alt, #f1f5f9); +} + +.location-content, +.location-placeholder { + max-width: 900px; + margin: 0 auto 1.5rem; +} + +.location-address { + font-weight: 800; +} + +.location-map, +.location-placeholder { + border: 1px solid var(--color-border, #ddd); + border-radius: 1rem; +} + +.location-map { + display: block; + min-height: 360px; + width: 100%; +} + +.location-placeholder { + background: var(--color-surface, white); + padding: 2rem; +} + +.gallery-title { + margin-inline: auto; + margin-bottom: 2rem; + max-width: 1120px; +} + +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1.25rem; +} + +.gallery-layout-square .gallery-grid { + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} + +.gallery-layout-wide .gallery-grid { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.gallery-link { + color: inherit; + display: block; + text-decoration: none; +} + +.gallery-item { + margin: 0; +} + +.gallery-item img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + display: block; + border-radius: 0; + transition: + box-shadow 180ms ease, + filter 180ms ease, + transform 180ms ease; +} + +.images-rounded .content-section img, +.images-rounded .image-text-image, +.images-rounded .sponsor-logo img, +.images-rounded .gallery-item img { + border-radius: 0.75rem; +} + +.images-square .content-section img, +.images-square .image-text-image, +.images-square .sponsor-logo img, +.images-square .gallery-item img { + border-radius: 0; +} + +.gallery-layout-square .gallery-item img { + aspect-ratio: 1 / 1; +} + +.gallery-layout-wide .gallery-item img { + aspect-ratio: 16 / 9; +} + +.gallery-layout-masonry .gallery-grid { + align-items: stretch; + display: grid; + grid-auto-flow: dense; + grid-auto-rows: 120px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.gallery-layout-masonry .gallery-link { + min-height: 0; +} + +.gallery-layout-masonry .gallery-item img { + aspect-ratio: auto; + height: 100%; +} + +.gallery-layout-masonry .gallery-link:nth-child(3n + 1) { + grid-row: span 2; +} + +.gallery-layout-masonry .gallery-link:nth-child(5n + 2) { + grid-column: span 2; +} + +.gallery-layout-masonry .gallery-link:nth-child(7n + 4) { + grid-row: span 2; + grid-column: span 2; +} + +.lightbox-open { + overflow: hidden; +} + +.site-lightbox { + align-items: center; + background: rgba(17, 24, 39, 0.92); + display: flex; + justify-content: center; + inset: 0; + padding: 2rem; + position: fixed; + z-index: 1000; +} + +.site-lightbox[hidden] { + display: none; +} + +.site-lightbox figure { + margin: 0; + text-align: center; +} + +.site-lightbox img { + max-height: 82vh; + max-width: 100%; + object-fit: contain; +} + +.site-lightbox figcaption { + color: white; + margin-top: 0.8rem; +} + +.site-lightbox button { + align-items: center; + border: 0; + border-radius: 999px; + background: white; + color: #111827; + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: 2rem; + height: 3rem; + justify-content: center; + line-height: 1; + padding: 0; + position: absolute; + z-index: 2; + width: 3rem; +} + +.site-lightbox-close { + position: absolute; + right: 1rem; + top: 1rem; +} + +.site-lightbox-prev, +.site-lightbox-next { + top: 50%; + transform: translateY(-50%); +} + +.site-lightbox-prev { + left: 1rem; +} + +.site-lightbox-next { + right: 1rem; +} + +.gallery-link:hover img, +.gallery-link:focus-visible img { + box-shadow: 0 12px 30px rgba(17, 24, 39, 0.18); + filter: brightness(0.96); + transform: translateY(-2px); +} + +.gallery-item figcaption { + margin-top: 0.55rem; + font-size: 0.9rem; + color: var(--color-muted-text, #4b5563); +} + +.image-text { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3rem; + align-items: center; +} + +.image-text-image img { + width: 100%; + display: block; + border-radius: 1rem; +} + +.event-list { + display: grid; + gap: 1.4rem; +} + +.event-box { + background: var(--color-surface, white); + border: 1px solid color-mix(in srgb, var(--color-border, #ddd) 72%, transparent); + border-left: 0.45rem solid var(--color-accent, #111827); + border-radius: 0.95rem; + box-shadow: 0 12px 28px rgba(17, 24, 39, 0.06); + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 300px); + gap: clamp(1rem, 2.4vw, 1.75rem); + align-items: start; + padding: clamp(1.2rem, 3vw, 1.75rem); +} + +.event-box.event-has-image { + grid-template-columns: minmax(0, 1fr) minmax(190px, 260px) minmax(180px, 260px); +} + +.event-content h1, +.event-content h2, +.event-content h3 { + margin-bottom: 0.55rem; +} + +.event-content p:last-child { + margin-bottom: 0; +} + +.event-facts { + margin: 0; + display: grid; + gap: 0.85rem; + padding: 1rem; + background: var(--color-surface-alt, #f1f5f9); + border: 1px solid color-mix(in srgb, var(--color-border, #ddd) 65%, transparent); + border-radius: 0.75rem; +} + +.event-facts div + div { + margin-top: 0; +} + +.event-facts dt { + font-size: 0.76rem; + font-weight: 700; + color: var(--color-muted-text, #4b5563); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.event-facts dd { + margin: 0.1rem 0 0; + font-size: 1rem; + font-weight: 750; +} + +.event-image { + margin: 0; +} + +.event-image img { + aspect-ratio: 4 / 3; + border-radius: 0.75rem; + display: block; + height: 100%; + max-height: 220px; + object-fit: cover; + width: 100%; +} + +.download-list { + display: grid; + gap: 0.8rem; + max-width: 780px; +} + +.download-item { + display: flex; + justify-content: space-between; + padding: 1rem 1.15rem; + background: var(--color-surface-alt, #f1f5f9); + border-radius: 0.6rem; + color: var(--color-text, #111827); + font-weight: 650; + text-decoration: none; +} + +.download-item::after { + content: "↓"; +} + +.download-item:hover { + color: var(--color-link-hover, #115e59); +} + +.people-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.person-section { + max-width: 760px; +} + +.person-card { + padding: 1.25rem; + background: var(--color-surface, white); + border: 1px solid var(--color-border, #ddd); + border-radius: 0.75rem; +} + +.person-card-single { + max-width: 520px; +} + +.sponsor-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + align-items: center; +} + +.sponsor-logo { + margin: 0; + padding: 1.25rem; + background: var(--color-surface, white); + border: 1px solid var(--color-border, #ddd); + border-radius: 0.75rem; + text-align: center; +} + +.sponsor-logo img { + max-width: 100%; + max-height: 90px; + object-fit: contain; +} + +.sponsor-logo figcaption { + margin-top: 0.65rem; + color: var(--color-muted-text, #4b5563); + font-size: 0.9rem; +} + +.site-footer { + background: var(--color-footer-background, #111827); + color: var(--color-footer-text, white); + padding: clamp(3rem, 6vw, 5rem) clamp(1.25rem, 10vw, 8rem); +} + +.footer-inner { + max-width: 1100px; + margin: 0 auto; +} + +.footer-brand { + margin-bottom: 2rem; +} + +.footer-brand h1, +.footer-brand h2, +.footer-brand h3, +.footer-brand p { + margin-top: 0; +} + +.footer-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: clamp(2rem, 5vw, 4rem); +} + +.footer-logo-column { + align-self: start; +} + +.footer-logo { + color: var(--color-footer-text, white); + justify-content: flex-start; +} + +.footer-logo:hover { + color: var(--color-footer-text, white); +} + +.footer-logo img { + height: 78px; + max-width: 180px; +} + +.footer-logo span { + font-size: 1.15rem; +} + +.social-links { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.social-links a { + align-items: center; + border: 1px solid currentColor; + border-radius: 999px; + color: var(--color-footer-text, white); + display: inline-flex; + font-size: 0.82rem; + font-weight: 800; + height: 2.25rem; + justify-content: center; + text-decoration: none; + width: 2.25rem; +} + +.social-links a:hover { + background: var(--color-footer-text, white); + color: var(--color-footer-background, #111827); +} + +.icons-simple .social-links a { + border-radius: 0.35rem; +} + +.icons-text .social-links { + display: block; +} + +.icons-text .social-links a { + border: 0; + border-radius: 0; + display: block; + height: auto; + margin-top: 0.35rem; + width: auto; +} + +.icons-text .social-links a:hover { + background: transparent; + color: var(--color-footer-text, white); + text-decoration: underline; +} + +.footer-column h2, +.footer-column h3 { + font-size: 1rem; + letter-spacing: 0.02em; + margin-top: 0; + margin-bottom: 1rem; +} + +.footer-column p { + margin: 0.35rem 0; +} + +.footer-column a { + color: var(--color-footer-text, white); + text-decoration: none; +} + +.footer-column a:hover { + text-decoration: underline; +} + +.footer-credit { + border-top: 1px solid rgba(255, 255, 255, 0.2); + color: color-mix(in srgb, var(--color-footer-text, white) 76%, transparent); + font-size: 0.86rem; + margin: clamp(2rem, 5vw, 3.5rem) 0 0; + padding-top: 1.25rem; +} + +@media (max-width: 800px) { + .logo-placement-header { + --mobile-menu-offset: 150px; + } + + .logo-placement-footer, + body:not(.logo-placement-header) { + --mobile-menu-offset: var(--nav-height, 72px); + } + + .nav-wrap { + flex-wrap: wrap; + justify-content: center; + } + + .site-logo-nav { + margin: 0; + order: 1; + position: static; + transform: none; + padding: 0.8rem 0; + width: auto; + justify-content: flex-start; + } + + .burger { + display: block; + padding: 1rem; + margin: 0 auto; + text-align: center; + order: 2; + } + + .logo-align-left.mobile-menu-auto .burger { + margin-left: auto; + margin-right: 0; + } + + .logo-align-right.mobile-menu-auto .site-logo-nav { + order: 2; + } + + .logo-align-right.mobile-menu-auto .burger { + margin-left: 0; + margin-right: auto; + order: 1; + } + + .mobile-menu-left .burger { + margin-left: 0; + margin-right: auto; + order: 1; + } + + .mobile-menu-center .burger { + margin-left: auto; + margin-right: auto; + order: 2; + } + + .mobile-menu-right .burger { + margin-left: auto; + margin-right: 0; + order: 2; + } + + .main-nav { + flex: 1 0 100%; + margin: 0; + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + box-sizing: border-box; + max-height: calc(100vh - var(--mobile-menu-offset, var(--nav-height, 72px))); + max-height: calc(100dvh - var(--mobile-menu-offset, var(--nav-height, 72px))); + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + background: var(--color-header-background, #111827); + flex-direction: column; + align-items: stretch; + gap: 0; + order: 3; + padding-bottom: max(2rem, env(safe-area-inset-bottom)); + } + + .nav-toggle:checked ~ .main-nav { + display: flex; + } + + .main-nav a, + .dropdown-label { + box-sizing: border-box; + padding: 0.9rem 2rem; + text-align: center; + width: 100%; + } + + .submenu { + position: static; + box-shadow: none; + background: var(--color-header-hover, #1f2937); + padding: 0.35rem 0 0.65rem; + } + + .dropdown-label { + align-items: center; + display: flex; + justify-content: center; + } + + .dropdown-label::after { + content: "›"; + font-size: 1.25rem; + margin-left: 0.65rem; + transform: rotate(90deg); + transition: transform 160ms ease; + } + + .dropdown.is-open .dropdown-label::after { + transform: rotate(-90deg); + } + + .submenu a { + padding-left: 3rem; + padding-right: 3rem; + } + + .hero h1 { + font-size: clamp(2rem, 10vw, 2.55rem); + } + + .hero { + min-height: clamp(300px, 52vh, 440px); + padding-block: clamp(3rem, 12vw, 4.5rem); + } + + .hero-content { + max-width: 34rem; + } + + .hero-content p { + font-size: 1rem; + line-height: 1.58; + } + + .hero-has-media::after { + background: linear-gradient( + rgba(0, 0, 0, 0.68), + rgba(0, 0, 0, 0.52) + ); + } + + section { + padding-block: clamp(2.5rem, 10vw, 4rem); + } + + .two-columns { + grid-template-columns: 1fr; + } + + .cards-layout-two .cards, + .cards-layout-three .cards { + grid-template-columns: 1fr; + } + + .image-text { + grid-template-columns: 1fr; + } + + .event-box { + grid-template-columns: 1fr; + } + + .event-box.event-has-image { + grid-template-columns: 1fr; + } + + .gallery-grid { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + } + + .footer-grid { + grid-template-columns: 1fr; + gap: 2rem; + } +} diff --git a/generator/templates/banner.html b/generator/templates/banner.html new file mode 100644 index 0000000..d3e6b25 --- /dev/null +++ b/generator/templates/banner.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/generator/templates/base.html b/generator/templates/base.html new file mode 100644 index 0000000..d298766 --- /dev/null +++ b/generator/templates/base.html @@ -0,0 +1,75 @@ + + + + + {{ settings.title }} + + + + + + +
+ {{ body | safe }} +
+ + {{ footer | safe }} + + + + diff --git a/generator/templates/cards.html b/generator/templates/cards.html new file mode 100644 index 0000000..94d37cb --- /dev/null +++ b/generator/templates/cards.html @@ -0,0 +1,11 @@ +
+ {{ title | safe }} + +
+ {% for card in cards %} +
+ {{ card | safe }} +
+ {% endfor %} +
+
diff --git a/generator/templates/contact.html b/generator/templates/contact.html new file mode 100644 index 0000000..13cc29f --- /dev/null +++ b/generator/templates/contact.html @@ -0,0 +1,5 @@ +
+
+ {{ content | safe }} +
+
\ 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 @@ +
+
+ {{ title | safe }} +
+ +
+ {% for link in links %} + + {{ link.label }} + + {% endfor %} +
+
diff --git a/generator/templates/event.html b/generator/templates/event.html new file mode 100644 index 0000000..b2a69d6 --- /dev/null +++ b/generator/templates/event.html @@ -0,0 +1,22 @@ +
+
+
+ {{ content | safe }} +
+ +
+ {% for key, value in values.items() %} +
+
{{ key.replace("_", " ").title() }}
+
{{ value | safe }}
+
+ {% endfor %} +
+ + {% if image %} +
+ {{ image.alt }} +
+ {% endif %} +
+
diff --git a/generator/templates/events.html b/generator/templates/events.html new file mode 100644 index 0000000..15f6893 --- /dev/null +++ b/generator/templates/events.html @@ -0,0 +1,28 @@ +
+ {{ title | safe }} + +
+ {% for event in events %} +
+
+ {{ event.content | safe }} +
+ +
+ {% for key, value in event.facts.items() %} +
+
{{ key.replace("_", " ").title() }}
+
{{ value | safe }}
+
+ {% endfor %} +
+ + {% if event.image %} +
+ {{ event.image.alt }} +
+ {% endif %} +
+ {% endfor %} +
+
diff --git a/generator/templates/faq.html b/generator/templates/faq.html new file mode 100644 index 0000000..f0995dc --- /dev/null +++ b/generator/templates/faq.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/footer.html b/generator/templates/footer.html new file mode 100644 index 0000000..bd359e9 --- /dev/null +++ b/generator/templates/footer.html @@ -0,0 +1,44 @@ + diff --git a/generator/templates/gallery.html b/generator/templates/gallery.html new file mode 100644 index 0000000..5a01b3e --- /dev/null +++ b/generator/templates/gallery.html @@ -0,0 +1,42 @@ + 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 %} +
+ {{ image.alt }} +
+ {% 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.

+ +
+ {% endif %} +
diff --git a/generator/templates/people.html b/generator/templates/people.html new file mode 100644 index 0000000..85c6412 --- /dev/null +++ b/generator/templates/people.html @@ -0,0 +1,13 @@ +
+
+ {{ title | safe }} +
+ +
+ {% for person in people %} +
+ {{ person | safe }} +
+ {% endfor %} +
+
diff --git a/generator/templates/person.html b/generator/templates/person.html new file mode 100644 index 0000000..b37409e --- /dev/null +++ b/generator/templates/person.html @@ -0,0 +1,5 @@ +
+
+ {{ content | safe }} +
+
diff --git a/generator/templates/quote.html b/generator/templates/quote.html new file mode 100644 index 0000000..7721b4e --- /dev/null +++ b/generator/templates/quote.html @@ -0,0 +1,5 @@ +
+
+ {{ content | safe }} +
+
\ 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 }} +
+ + +
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 + +![Jollenwiese](../medien/galerie/Vereinsgelände/csm_db_jollenwiese7_01_a93eaeac9a.jpg) + +# 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 + +![Steganlage](medien/galerie/Vereinsgelände/csm_db_blick_auf_steganlage7_01_77c4c1781b.jpg) +![Anker](medien/galerie/Vereinsgelände/csm_db_anker7_801025207d.jpg) +![Jollenwiese](medien/galerie/Vereinsgelände/csm_db_jollenwiese7_01_a93eaeac9a.jpg) + +@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 +![Steg](medien/galerie/Vereinsgelände/csm_db_15er_steg27_01_41d2098b8f.jpg) + +# 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) + +![Sommerfest](medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg) + +Eine einzelne Veranstaltungsbox fuer einen wichtigen Termin. + +@veranstaltungen +# Mehrere Veranstaltungen + +## Sommerfest +Datum: 12. Juli +Uhrzeit: 15:00 Uhr +Ort: Vereinsgelände + +![Sommerfest](medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg) + +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 + +![Märkischer Seglerverein Beetzsee](medien/logo/msvb_logo.png) 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 + +![Steganlage](medien/galerie/Vereinsgelände/csm_db_blick_auf_steganlage7_01_77c4c1781b.jpg) +![Grillhütte](medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg) +![Jollenwiese](medien/galerie/Vereinsgelände/csm_db_jollenwiese7_01_a93eaeac9a.jpg) + +@zitat +> Ein Zitatblock kann als ruhiger Trenner zwischen Inhaltsbereichen dienen. + +@bild-text +![Steg](medien/galerie/Vereinsgelände/csm_db_15er_steg27_01_41d2098b8f.jpg) + +# 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 + +![Märkischer Seglerverein Beetzsee](medien/logo/msvb_logo.png) 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