Add havelseite
21
LICENSE
Normal file
@ -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.
|
||||||
541
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
|
||||||
|

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

|
||||||
|
|
||||||
|
Gemeinsames Sommerfest mit Kuchen, Grill und kleinen Bootsrunden.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Veranstaltungen
|
||||||
|
|
||||||
|
```md
|
||||||
|
@veranstaltungen
|
||||||
|
# Termine
|
||||||
|
|
||||||
|
## Sommerfest
|
||||||
|
Datum: 12. Juli
|
||||||
|
Uhrzeit: 15:00 Uhr
|
||||||
|
Ort: Vereinsgelände
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Gemeinsames Sommerfest mit Kuchen, Grill und kleinen Bootsrunden.
|
||||||
|
|
||||||
|
## Absegeln
|
||||||
|
Datum: 20. September
|
||||||
|
Uhrzeit: 10:00 Uhr
|
||||||
|
Ort: Vereinsgelände
|
||||||
|
|
||||||
|
Gemeinsamer Saisonabschluss auf dem Wasser.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dateien
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
### Bild und Text
|
||||||
|
|
||||||
|
```md
|
||||||
|
@bild-text
|
||||||
|

|
||||||
|
|
||||||
|
# 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).
|
||||||
|
|||||||
BIN
generator/__pycache__/havelseiten.cpython-311.pyc
Normal file
BIN
generator/__pycache__/havelseiten.cpython-38.pyc
Normal file
BIN
generator/__pycache__/settings_config.cpython-311.pyc
Normal file
BIN
generator/__pycache__/settings_config.cpython-38.pyc
Normal file
BIN
generator/__pycache__/settings_editor.cpython-311.pyc
Normal file
BIN
generator/__pycache__/settings_editor.cpython-38.pyc
Normal file
BIN
generator/__pycache__/validate.cpython-311.pyc
Normal file
BIN
generator/__pycache__/validate.cpython-38.pyc
Normal file
53
generator/components.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
1692
generator/havelseiten.py
Normal file
32
generator/settings.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1022
generator/settings_config.py
Normal file
325
generator/static/external-content.js
Normal file
@ -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 = `
|
||||||
|
<button class="site-lightbox-close" type="button" aria-label="Schliessen">×</button>
|
||||||
|
<button class="site-lightbox-prev" type="button" aria-label="Vorheriges Bild">‹</button>
|
||||||
|
<figure>
|
||||||
|
<img alt="">
|
||||||
|
<figcaption></figcaption>
|
||||||
|
</figure>
|
||||||
|
<button class="site-lightbox-next" type="button" aria-label="Naechstes Bild">›</button>
|
||||||
|
`;
|
||||||
|
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();
|
||||||
1199
generator/static/style.css
Normal file
3
generator/templates/banner.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<section class="banner">
|
||||||
|
{{ content | safe }}
|
||||||
|
</section>
|
||||||
75
generator/templates/base.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{ settings.language | default("de") }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{ settings.title }}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--color-background: {{ settings.colors.background | e }};
|
||||||
|
--color-text: {{ settings.colors.text | e }};
|
||||||
|
--color-link: {{ settings.colors.link | e }};
|
||||||
|
--color-link-hover: {{ settings.colors.link_hover | e }};
|
||||||
|
--color-surface: {{ settings.colors.surface | e }};
|
||||||
|
--color-surface-alt: {{ settings.colors.surface_alt | e }};
|
||||||
|
--color-muted-text: {{ settings.colors.muted_text | e }};
|
||||||
|
--color-border: {{ settings.colors.border | e }};
|
||||||
|
--color-header-background: {{ settings.colors.header_background | e }};
|
||||||
|
--color-header-hover: {{ settings.colors.header_hover | e }};
|
||||||
|
--color-header-text: {{ settings.colors.header_text | e }};
|
||||||
|
--color-footer-background: {{ settings.colors.footer_background | e }};
|
||||||
|
--color-footer-text: {{ settings.colors.footer_text | e }};
|
||||||
|
--color-hero-background: {{ settings.colors.hero_background | e }};
|
||||||
|
--color-accent: {{ settings.colors.accent | e }};
|
||||||
|
--font-body: {{ settings.typography.body | default("system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif") | e }};
|
||||||
|
--font-heading: {{ settings.typography.heading | default(settings.typography.body | default("system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif")) | e }};
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="{{ static_path }}">
|
||||||
|
</head>
|
||||||
|
<body class="nav-height-{{ settings.navigation.height | default("medium") }} nav-position-{{ settings.navigation.position | default("sticky") }} nav-align-{{ settings.navigation.align | default("center") }} mobile-menu-{{ settings.mobile_menu.align | default("auto") }} mobile-submenus-{{ settings.mobile_menu.submenus | default("closed") }} font-{{ settings.typography.font | default("system") }} font-size-{{ settings.typography.size | default("medium") }} icons-{{ settings.icons.style | default("round") }} images-{{ "rounded" if settings.gallery.rounded | default(true) else "square" }}{% if logo.enabled %} logo-placement-{{ "header" if logo.header else "footer" }} logo-align-{{ logo.align | default("left") }}{% endif %}">
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="nav-wrap">
|
||||||
|
{% if logo.enabled and logo.header %}
|
||||||
|
<a class="site-logo site-logo-nav" href="{{ navigation[0].url if navigation }}">
|
||||||
|
{% if logo.url %}
|
||||||
|
<img src="{{ logo.url }}" alt="{{ logo.alt }}">
|
||||||
|
{% else %}
|
||||||
|
<span>{{ logo.text }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||||
|
<label for="nav-toggle" class="burger">☰</label>
|
||||||
|
|
||||||
|
<nav class="main-nav">
|
||||||
|
{% for item in navigation %}
|
||||||
|
{% if item.type == "link" %}
|
||||||
|
<div class="nav-item">
|
||||||
|
<a href="{{ item.url }}">{{ item.title }}</a>
|
||||||
|
</div>
|
||||||
|
{% elif item.type == "dropdown" %}
|
||||||
|
<div class="nav-item dropdown">
|
||||||
|
<button class="dropdown-label" type="button" aria-expanded="{{ "true" if settings.mobile_menu.submenus | default("open") == "open" else "false" }}">{{ item.title }}</button>
|
||||||
|
|
||||||
|
<div class="submenu">
|
||||||
|
{% for child in item.children %}
|
||||||
|
<a href="{{ child.url }}">{{ child.title }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="site-main">
|
||||||
|
{{ body | safe }}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{ footer | safe }}
|
||||||
|
|
||||||
|
<script type="module" src="{{ static_path | replace('style.css', 'external-content.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
generator/templates/cards.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<section class="cards-section cards-layout-{{ layout }}">
|
||||||
|
{{ title | safe }}
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
{% for card in cards %}
|
||||||
|
<article class="card">
|
||||||
|
{{ card | safe }}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
5
generator/templates/contact.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<section class="contact-section">
|
||||||
|
<div class="contact-box">
|
||||||
|
{{ content | safe }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
13
generator/templates/downloads.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<section class="downloads-section">
|
||||||
|
<div class="downloads-title">
|
||||||
|
{{ title | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-list">
|
||||||
|
{% for link in links %}
|
||||||
|
<a class="download-item" href="{{ link.href }}">
|
||||||
|
<span>{{ link.label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
22
generator/templates/event.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<section class="event-section">
|
||||||
|
<article class="event-box{% if image %} event-has-image{% endif %}">
|
||||||
|
<div class="event-content">
|
||||||
|
{{ content | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="event-facts">
|
||||||
|
{% for key, value in values.items() %}
|
||||||
|
<div>
|
||||||
|
<dt>{{ key.replace("_", " ").title() }}</dt>
|
||||||
|
<dd>{{ value | safe }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if image %}
|
||||||
|
<figure class="event-image">
|
||||||
|
<img src="{{ image.src }}" alt="{{ image.alt }}">
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
28
generator/templates/events.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<section class="event-section">
|
||||||
|
{{ title | safe }}
|
||||||
|
|
||||||
|
<div class="event-list">
|
||||||
|
{% for event in events %}
|
||||||
|
<article class="event-box{% if event.image %} event-has-image{% endif %}">
|
||||||
|
<div class="event-content">
|
||||||
|
{{ event.content | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="event-facts">
|
||||||
|
{% for key, value in event.facts.items() %}
|
||||||
|
<div>
|
||||||
|
<dt>{{ key.replace("_", " ").title() }}</dt>
|
||||||
|
<dd>{{ value | safe }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if event.image %}
|
||||||
|
<figure class="event-image">
|
||||||
|
<img src="{{ event.image.src }}" alt="{{ event.image.alt }}">
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
11
generator/templates/faq.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<section class="faq">
|
||||||
|
<div class="faq-title">
|
||||||
|
{{ title | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for item in items %}
|
||||||
|
<article class="faq-item">
|
||||||
|
{{ item | safe }}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
44
generator/templates/footer.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<footer class="site-footer">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div class="footer-brand">
|
||||||
|
{{ title | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-grid">
|
||||||
|
{% if logo and logo.enabled and logo.footer %}
|
||||||
|
<div class="footer-column footer-logo-column">
|
||||||
|
<a class="site-logo footer-logo" href="{{ logo.home_url if logo.home_url }}">
|
||||||
|
{% if logo.url %}
|
||||||
|
<img src="{{ logo.url }}" alt="{{ logo.alt }}">
|
||||||
|
{% else %}
|
||||||
|
<span>{{ logo.text }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for column in columns %}
|
||||||
|
<div class="footer-column">
|
||||||
|
{{ column | safe }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if social_links %}
|
||||||
|
<div class="footer-column footer-social-column">
|
||||||
|
<h2>Social</h2>
|
||||||
|
<div class="social-links">
|
||||||
|
{% for social in social_links %}
|
||||||
|
<a href="{{ social.url }}" aria-label="{{ social.label }}">
|
||||||
|
<span>{{ social.icon }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="footer-credit">
|
||||||
|
Erstellt mit Havelseiten von Binnenrevier
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
42
generator/templates/gallery.html
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<section class="gallery-section gallery-layout-{{ gallery.layout | default("square") }}">
|
||||||
|
<div class="gallery-title">
|
||||||
|
{{ title | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if needs_external_consent %}
|
||||||
|
<div class="gallery-placeholder external-placeholder">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="gallery-load-button"
|
||||||
|
>
|
||||||
|
Externe Galerie-Bilder laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="gallery-grid"{% if needs_external_consent %} hidden{% endif %}>
|
||||||
|
{% for image in images %}
|
||||||
|
<a
|
||||||
|
href="{{ image.src }}"
|
||||||
|
class="gallery-link"
|
||||||
|
data-pswp-width="{{ image.width }}"
|
||||||
|
data-pswp-height="{{ image.height }}"
|
||||||
|
aria-label="Bild öffnen"
|
||||||
|
>
|
||||||
|
<figure class="gallery-item">
|
||||||
|
<img
|
||||||
|
src="{{ image.src }}"
|
||||||
|
alt="{{ image.alt }}"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
|
||||||
|
{% if image.alt and gallery.captions | default(true) %}
|
||||||
|
<figcaption>
|
||||||
|
{{ image.alt }}
|
||||||
|
</figcaption>
|
||||||
|
{% endif %}
|
||||||
|
</figure>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
44
generator/templates/hero.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{% set hero_classes = "hero" %}
|
||||||
|
{% if hero %}
|
||||||
|
{% set hero_classes = hero_classes ~ " hero-has-media hero-" ~ hero.type %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="{{ hero_classes }}"
|
||||||
|
{% if hero and hero.style %}style="{{ hero.style | e }}"{% endif %}
|
||||||
|
>
|
||||||
|
{% if hero and hero.type == "image" %}
|
||||||
|
<img class="hero-media" src="{{ hero.src | e }}" alt="">
|
||||||
|
{% elif hero and hero.type == "video" %}
|
||||||
|
<video class="hero-media" src="{{ hero.src | e }}" autoplay muted loop playsinline></video>
|
||||||
|
{% elif hero and hero.type == "youtube" and external_content_enabled %}
|
||||||
|
<iframe
|
||||||
|
class="hero-media hero-youtube-frame"
|
||||||
|
src="{{ hero.src | e }}"
|
||||||
|
title="YouTube-Hero"
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
{% elif hero and hero.type == "youtube" %}
|
||||||
|
<div class="hero-media external-placeholder">
|
||||||
|
<div>
|
||||||
|
<strong>Externes YouTube-Video</strong>
|
||||||
|
<p>Dieses Video wird aus Datenschutzgruenden nicht automatisch geladen.</p>
|
||||||
|
<button
|
||||||
|
class="external-load-button"
|
||||||
|
type="button"
|
||||||
|
data-embed-src="{{ hero.src | e }}"
|
||||||
|
data-embed-class="hero-media hero-youtube-frame"
|
||||||
|
data-embed-title="YouTube-Hero"
|
||||||
|
data-embed-allow="autoplay; encrypted-media; picture-in-picture"
|
||||||
|
>
|
||||||
|
Video laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="hero-content">
|
||||||
|
{{ content | safe }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
11
generator/templates/image-text.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<section class="image-text">
|
||||||
|
{% if image %}
|
||||||
|
<div class="image-text-image">
|
||||||
|
<img src="{{ image.src }}" alt="{{ image.alt }}">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="image-text-content">
|
||||||
|
{{ content | safe }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
26
generator/templates/location.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<section class="location-section">
|
||||||
|
<div class="location-content">
|
||||||
|
{{ content | safe }}
|
||||||
|
|
||||||
|
{% if address %}
|
||||||
|
<p class="location-address">{{ address }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if address %}
|
||||||
|
<div class="location-placeholder">
|
||||||
|
<strong>Externe Karte</strong>
|
||||||
|
<p>Die Karte wird aus Datenschutzgruenden nicht automatisch geladen.</p>
|
||||||
|
<button
|
||||||
|
class="external-load-button"
|
||||||
|
type="button"
|
||||||
|
data-map-address="{{ address | e }}"
|
||||||
|
data-embed-class="location-map"
|
||||||
|
data-embed-title="Karte"
|
||||||
|
{% if external_content_enabled %}data-auto-load="true"{% endif %}
|
||||||
|
>
|
||||||
|
Karte laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
13
generator/templates/people.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<section class="people-section">
|
||||||
|
<div class="people-title">
|
||||||
|
{{ title | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="people-grid">
|
||||||
|
{% for person in people %}
|
||||||
|
<article class="person-card">
|
||||||
|
{{ person | safe }}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
5
generator/templates/person.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<section class="person-section">
|
||||||
|
<article class="person-card person-card-single">
|
||||||
|
{{ content | safe }}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
5
generator/templates/quote.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<section class="quote-section">
|
||||||
|
<div class="quote-box">
|
||||||
|
{{ content | safe }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
17
generator/templates/sponsors.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<section class="sponsors-section">
|
||||||
|
<div class="sponsors-title">
|
||||||
|
{{ title | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sponsor-grid">
|
||||||
|
{% for sponsor in sponsors %}
|
||||||
|
<figure class="sponsor-logo">
|
||||||
|
<img src="{{ sponsor.src }}" alt="{{ sponsor.alt }}">
|
||||||
|
|
||||||
|
{% if sponsor.alt %}
|
||||||
|
<figcaption>{{ sponsor.alt }}</figcaption>
|
||||||
|
{% endif %}
|
||||||
|
</figure>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
3
generator/templates/text.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<section class="text-section">
|
||||||
|
{{ content | safe }}
|
||||||
|
</section>
|
||||||
11
generator/templates/timeline.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<section class="timeline">
|
||||||
|
<div class="timeline-title">
|
||||||
|
{{ title | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for item in items %}
|
||||||
|
<article class="timeline-item">
|
||||||
|
{{ item | safe }}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
13
generator/templates/two-columns.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<section class="two-columns">
|
||||||
|
<div class="two-columns-title">
|
||||||
|
{{ title | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
{{ left | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
{{ right | safe }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
17
generator/themes/dunkel.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
17
generator/themes/havel.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
17
generator/themes/kueste.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
17
generator/themes/leuchtturm.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
17
generator/themes/segel.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
17
generator/themes/sonnenuntergang.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
17
generator/themes/wald.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
17
generator/themes/wasser.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
836
generator/validate.py
Normal file
@ -0,0 +1,836 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import difflib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import settings_config
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
CONTENT_DIR = settings_config.active_content_dir()
|
||||||
|
GENERATOR_DIR = ROOT_DIR / "generator"
|
||||||
|
COMPONENTS_FILE = GENERATOR_DIR / "components.json"
|
||||||
|
SETTINGS_FILE = settings_config.SETTINGS_JSON_FILE
|
||||||
|
SETTINGS_MARKDOWN_FILE = settings_config.active_settings_file() or settings_config.SETTINGS_MARKDOWN_FILE
|
||||||
|
|
||||||
|
MEDIA_DIR_NAME = "medien"
|
||||||
|
LEGACY_MEDIA_DIR_NAME = "assets"
|
||||||
|
GALLERY_DIR_NAME = "galerie"
|
||||||
|
LEGACY_GALLERY_DIR_NAME = "gallery"
|
||||||
|
FOOTER_DIR_NAME = "fusszeile"
|
||||||
|
LEGACY_FOOTER_DIR_NAME = "footer"
|
||||||
|
FOOTER_FILE_NAME = "fusszeile.md"
|
||||||
|
LEGACY_FOOTER_FILE_NAME = "footer.md"
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = {
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".png",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".svg"
|
||||||
|
}
|
||||||
|
|
||||||
|
VIDEO_EXTENSIONS = {
|
||||||
|
".mp4",
|
||||||
|
".webm",
|
||||||
|
".ogg",
|
||||||
|
".mov"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TRANSLATIONS = {
|
||||||
|
"de": {
|
||||||
|
"json_error": "FEHLER: {path} ist keine gueltige JSON-Datei.",
|
||||||
|
"json_position": " Zeile {line}, Spalte {column}: {message}",
|
||||||
|
"navigation_height": "FEHLER: generator/settings.json: navigation.height muss small, medium oder large sein.",
|
||||||
|
"navigation_align": "FEHLER: Einstellungen: Navigationsausrichtung muss links, mittig oder rechts sein.",
|
||||||
|
"mobile_menu_align": "FEHLER: Einstellungen: Mobilmenü muss automatisch, links, mittig oder rechts sein.",
|
||||||
|
"mobile_menu_submenus": "FEHLER: Einstellungen: Mobilmenü-Untermenüs müssen offen oder eingeklappt sein.",
|
||||||
|
"language": "FEHLER: generator/settings.json: language muss de oder en sein.",
|
||||||
|
"validator_language": "FEHLER: generator/settings.json: validator_language muss de oder en sein.",
|
||||||
|
"theme": "FEHLER: Einstellungen: theme muss eine bekannte Farbpalette sein: {themes}.",
|
||||||
|
"theme_multiple": "WARNUNG: havelseite/einstellungen.md: Mehrere Farbpaletten sind angekreuzt ({selected}). Ich verwende die erste: {first}.",
|
||||||
|
"navigation_position": "FEHLER: Einstellungen: Navigation muss sticky/bleibt oben oder static/scrollt mit sein.",
|
||||||
|
"navigation_multiple": "WARNUNG: havelseite/einstellungen.md: Navigation hat mehrere Optionen angekreuzt ({selected}). Ich verwende die erste: {first}.",
|
||||||
|
"logo_align": "FEHLER: Einstellungen: Wenn das Logo angezeigt wird, muss links oder rechts angekreuzt sein.",
|
||||||
|
"logo_align_center": "FEHLER: Einstellungen: Das Logo kann nur links oder rechts stehen.",
|
||||||
|
"logo_missing": "FEHLER: Logo nicht gefunden: havelseite/medien/{folder}/{src}",
|
||||||
|
"logo_folder_missing": "FEHLER: Logoordner nicht gefunden oder leer: havelseite/medien/{folder}",
|
||||||
|
"logo_multiple": "WARNUNG: havelseite/einstellungen.md: Im Logoordner liegen mehrere Bilder ({files}). Ich verwende alphabetisch das erste: {first}.",
|
||||||
|
"gallery_layout": "FEHLER: Einstellungen: Galerie muss quadratisch, breit oder kacheln sein.",
|
||||||
|
"icon_style": "FEHLER: Einstellungen: Icons muss rund, schlicht oder text sein.",
|
||||||
|
"font": "FEHLER: Einstellungen: Schriftgröße muss klein, mittel oder groß sein.",
|
||||||
|
"unknown_block": "FEHLER: {path}:{line}: @{annotation} kenne ich nicht.{hint}",
|
||||||
|
"did_you_mean": " Meintest du @{suggestion}?",
|
||||||
|
"gallery_missing": "FEHLER: {path}:{line}: Galerieordner fehlt: havelseite/medien/galerie/{parameter}.{hint}",
|
||||||
|
"image_missing": "FEHLER: {path}:{line}: Bild nicht gefunden: {src}.{hint}",
|
||||||
|
"internal_link_missing": "WARNUNG: {path}:{line}: Interner Link zeigt auf keine Seite: [[{label}]].{hint}",
|
||||||
|
"hero_missing": "FEHLER: {path}:{line}: Aufmacher-Datei nicht gefunden: {parameter}",
|
||||||
|
"event_single_multiple": "WARNUNG: {path}:{line}: @{annotation} sieht nach mehreren Veranstaltungen aus. Nutze dafuer @veranstaltungen.",
|
||||||
|
"events_list_single": "WARNUNG: {path}:{line}: @{annotation} ist fuer mehrere Veranstaltungen gedacht. Fuer eine einzelne Veranstaltung nutze @veranstaltung.",
|
||||||
|
"person_single_multiple": "WARNUNG: {path}:{line}: @{annotation} sieht nach mehreren Personen aus. Nutze dafuer @personen.",
|
||||||
|
"people_list_single": "WARNUNG: {path}:{line}: @{annotation} ist fuer mehrere Personen gedacht. Fuer eine einzelne Person nutze @person.",
|
||||||
|
"nested_page": "FEHLER: {path}: Seiten duerfen nur eine Ordnerebene tief liegen. Bitte verschiebe die Datei direkt nach havelseite/ oder in einen Ordner direkt unter havelseite/.",
|
||||||
|
"found_header": "Ich habe ein paar Dinge gefunden:\n",
|
||||||
|
"found_footer": "\nBitte korrigiere diese Punkte und pruefe danach nochmal.",
|
||||||
|
"ok": "Alles gut. Keine offensichtlichen Probleme gefunden."
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"json_error": "ERROR: {path} is not a valid JSON file.",
|
||||||
|
"json_position": " Line {line}, column {column}: {message}",
|
||||||
|
"navigation_height": "ERROR: generator/settings.json: navigation.height must be small, medium, or large.",
|
||||||
|
"navigation_align": "ERROR: Settings: navigation alignment must be left, center, or right.",
|
||||||
|
"mobile_menu_align": "ERROR: Settings: mobile menu must be auto, left, center, or right.",
|
||||||
|
"mobile_menu_submenus": "ERROR: Settings: mobile submenu behavior must be open or closed.",
|
||||||
|
"language": "ERROR: generator/settings.json: language must be de or en.",
|
||||||
|
"validator_language": "ERROR: generator/settings.json: validator_language must be de or en.",
|
||||||
|
"theme": "ERROR: Settings: theme must be a known color theme: {themes}.",
|
||||||
|
"theme_multiple": "WARNING: havelseite/einstellungen.md: Multiple color themes are checked ({selected}). I will use the first one: {first}.",
|
||||||
|
"navigation_position": "ERROR: Settings: navigation.position must be sticky or static.",
|
||||||
|
"navigation_multiple": "WARNING: havelseite/einstellungen.md: Navigation has multiple checked options ({selected}). I will use the first one: {first}.",
|
||||||
|
"logo_align": "ERROR: Settings: if the logo is shown, left or right must be checked.",
|
||||||
|
"logo_align_center": "ERROR: Settings: the logo can only be left or right.",
|
||||||
|
"logo_missing": "ERROR: Logo not found: havelseite/medien/{folder}/{src}",
|
||||||
|
"logo_folder_missing": "ERROR: Logo folder is missing or empty: havelseite/medien/{folder}",
|
||||||
|
"logo_multiple": "WARNING: havelseite/einstellungen.md: Multiple images are in the logo folder ({files}). I will use the alphabetically first one: {first}.",
|
||||||
|
"gallery_layout": "ERROR: Settings: gallery layout must be square, wide, or masonry.",
|
||||||
|
"icon_style": "ERROR: Settings: icons must be round, simple, or text.",
|
||||||
|
"font": "ERROR: Settings: font size must be small, medium, or large.",
|
||||||
|
"unknown_block": "ERROR: {path}:{line}: I do not know @{annotation}.{hint}",
|
||||||
|
"did_you_mean": " Did you mean @{suggestion}?",
|
||||||
|
"gallery_missing": "ERROR: {path}:{line}: Gallery folder is missing: havelseite/medien/galerie/{parameter}.{hint}",
|
||||||
|
"image_missing": "ERROR: {path}:{line}: Image not found: {src}.{hint}",
|
||||||
|
"internal_link_missing": "WARNING: {path}:{line}: Internal link does not point to a known page: [[{label}]].{hint}",
|
||||||
|
"hero_missing": "ERROR: {path}:{line}: Hero file not found: {parameter}",
|
||||||
|
"event_single_multiple": "WARNING: {path}:{line}: @{annotation} looks like multiple events. Use @events for that.",
|
||||||
|
"events_list_single": "WARNING: {path}:{line}: @{annotation} is meant for multiple events. Use @event for a single event.",
|
||||||
|
"person_single_multiple": "WARNING: {path}:{line}: @{annotation} looks like multiple people. Use @people for that.",
|
||||||
|
"people_list_single": "WARNING: {path}:{line}: @{annotation} is meant for multiple people. Use @person for one person.",
|
||||||
|
"nested_page": "ERROR: {path}: Pages may only be one folder deep. Please move this file directly to havelseite/ or into a folder directly below havelseite/.",
|
||||||
|
"found_header": "I found a few things:\n",
|
||||||
|
"found_footer": "\nPlease fix these points and check again.",
|
||||||
|
"ok": "All good. No obvious problems found."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def language_from_settings() -> str:
|
||||||
|
try:
|
||||||
|
settings = settings_config.load_settings()
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return "de"
|
||||||
|
|
||||||
|
language = settings.get(
|
||||||
|
"validator_language",
|
||||||
|
settings.get("language", "de")
|
||||||
|
)
|
||||||
|
|
||||||
|
if language not in TRANSLATIONS:
|
||||||
|
return "de"
|
||||||
|
|
||||||
|
return language
|
||||||
|
|
||||||
|
|
||||||
|
def tr(language: str, key: str, **values) -> str:
|
||||||
|
return TRANSLATIONS[language][key].format(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(
|
||||||
|
file_path: Path,
|
||||||
|
exit_on_error: bool = True
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
with file_path.open(encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
if not exit_on_error:
|
||||||
|
raise
|
||||||
|
|
||||||
|
language = "de"
|
||||||
|
|
||||||
|
print(tr(
|
||||||
|
language,
|
||||||
|
"json_error",
|
||||||
|
path=file_path.relative_to(ROOT_DIR)
|
||||||
|
))
|
||||||
|
print(tr(
|
||||||
|
language,
|
||||||
|
"json_position",
|
||||||
|
line=error.lineno,
|
||||||
|
column=error.colno,
|
||||||
|
message=error.msg
|
||||||
|
))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def is_external_url(value: str) -> bool:
|
||||||
|
return (
|
||||||
|
value.startswith("http://")
|
||||||
|
or value.startswith("https://")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_footer_file(md_file: Path) -> bool:
|
||||||
|
relative = md_file.relative_to(CONTENT_DIR)
|
||||||
|
|
||||||
|
return (
|
||||||
|
len(relative.parts) >= 2
|
||||||
|
and relative.parts[0] in {FOOTER_DIR_NAME, LEGACY_FOOTER_DIR_NAME}
|
||||||
|
and md_file.name in {FOOTER_FILE_NAME, LEGACY_FOOTER_FILE_NAME}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def title_from_file(md_file: Path) -> str:
|
||||||
|
stem = display_name(md_file.stem)
|
||||||
|
|
||||||
|
if stem == "index":
|
||||||
|
if md_file.parent == CONTENT_DIR:
|
||||||
|
return "Start"
|
||||||
|
|
||||||
|
return display_name(md_file.parent.name).replace("-", " ").title()
|
||||||
|
|
||||||
|
return stem.replace("-", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
def display_name(name: str) -> str:
|
||||||
|
return re.sub(r"^\d+[_-]+", "", name)
|
||||||
|
|
||||||
|
|
||||||
|
def slugify_title(title: str) -> str:
|
||||||
|
slug = title.strip().lower()
|
||||||
|
slug = slug.replace("ä", "ae")
|
||||||
|
slug = slug.replace("ö", "oe")
|
||||||
|
slug = slug.replace("ü", "ue")
|
||||||
|
slug = slug.replace("ß", "ss")
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
||||||
|
|
||||||
|
return slug.strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def page_keys() -> set[str]:
|
||||||
|
keys = set()
|
||||||
|
|
||||||
|
for md_file in CONTENT_DIR.rglob("*.md"):
|
||||||
|
if is_footer_file(md_file):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if md_file == SETTINGS_MARKDOWN_FILE:
|
||||||
|
continue
|
||||||
|
|
||||||
|
keys.add(md_file.stem.lower())
|
||||||
|
keys.add(display_name(md_file.stem).lower())
|
||||||
|
keys.add(slugify_title(title_from_file(md_file)))
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def page_suggestions() -> list[str]:
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
for md_file in CONTENT_DIR.rglob("*.md"):
|
||||||
|
if is_footer_file(md_file):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if md_file == SETTINGS_MARKDOWN_FILE:
|
||||||
|
continue
|
||||||
|
|
||||||
|
suggestions.append(title_from_file(md_file))
|
||||||
|
suggestions.append(display_name(md_file.stem))
|
||||||
|
|
||||||
|
return sorted(set(suggestions))
|
||||||
|
|
||||||
|
|
||||||
|
def gallery_suggestion(parameter: str) -> str:
|
||||||
|
gallery_dir = CONTENT_DIR / MEDIA_DIR_NAME / GALLERY_DIR_NAME
|
||||||
|
|
||||||
|
if not gallery_dir.exists():
|
||||||
|
gallery_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME / LEGACY_GALLERY_DIR_NAME
|
||||||
|
|
||||||
|
if not gallery_dir.exists():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
path.name for path in gallery_dir.iterdir()
|
||||||
|
if path.is_dir()
|
||||||
|
]
|
||||||
|
suggestion = difflib.get_close_matches(parameter, candidates, n=1)
|
||||||
|
|
||||||
|
if not suggestion:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return f" Meintest du {suggestion[0]}?"
|
||||||
|
|
||||||
|
|
||||||
|
def media_suggestion(src: str) -> str:
|
||||||
|
media_dir = CONTENT_DIR / MEDIA_DIR_NAME
|
||||||
|
|
||||||
|
if not media_dir.exists():
|
||||||
|
media_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME
|
||||||
|
|
||||||
|
if not media_dir.exists():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
file_name = Path(src).name
|
||||||
|
candidates = [
|
||||||
|
path.name for path in media_dir.rglob("*")
|
||||||
|
if path.is_file()
|
||||||
|
]
|
||||||
|
suggestion = difflib.get_close_matches(file_name, candidates, n=1)
|
||||||
|
|
||||||
|
if not suggestion:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
matches = [
|
||||||
|
path for path in media_dir.rglob(suggestion[0])
|
||||||
|
if path.is_file()
|
||||||
|
]
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return f" Meintest du {suggestion[0]}?"
|
||||||
|
|
||||||
|
try:
|
||||||
|
suggested_path = matches[0].relative_to(CONTENT_DIR).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
suggested_path = matches[0].name
|
||||||
|
|
||||||
|
return f" Meintest du {suggested_path}?"
|
||||||
|
|
||||||
|
|
||||||
|
def content_relative_from_src(src: str, source_file: Path) -> Path:
|
||||||
|
src = src.strip().strip("/")
|
||||||
|
|
||||||
|
variants = [src]
|
||||||
|
|
||||||
|
if src.startswith(f"{LEGACY_MEDIA_DIR_NAME}/"):
|
||||||
|
variants.append(
|
||||||
|
f"{MEDIA_DIR_NAME}/{src.removeprefix(f'{LEGACY_MEDIA_DIR_NAME}/')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
variants = [
|
||||||
|
variant.replace(
|
||||||
|
f"{MEDIA_DIR_NAME}/{LEGACY_GALLERY_DIR_NAME}/",
|
||||||
|
f"{MEDIA_DIR_NAME}/{GALLERY_DIR_NAME}/"
|
||||||
|
)
|
||||||
|
for variant in variants
|
||||||
|
]
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
for variant in dict.fromkeys(variants):
|
||||||
|
candidates.extend([
|
||||||
|
source_file.parent / variant,
|
||||||
|
CONTENT_DIR / variant
|
||||||
|
])
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
assets_dir = CONTENT_DIR / MEDIA_DIR_NAME
|
||||||
|
|
||||||
|
if not assets_dir.exists():
|
||||||
|
assets_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME
|
||||||
|
|
||||||
|
if assets_dir.exists():
|
||||||
|
matches = sorted([
|
||||||
|
path for path in assets_dir.rglob(Path(src).name)
|
||||||
|
if path.is_file()
|
||||||
|
])
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
return CONTENT_DIR / src
|
||||||
|
|
||||||
|
|
||||||
|
def line_number(text: str, offset: int) -> int:
|
||||||
|
return text.count("\n", 0, offset) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def annotated_blocks(text: str) -> list[dict]:
|
||||||
|
matches = list(re.finditer(r"(?m)^@([^\s:]+)(?::([^\n]+))?", text))
|
||||||
|
blocks = []
|
||||||
|
|
||||||
|
for index, match in enumerate(matches):
|
||||||
|
content_start = match.end()
|
||||||
|
content_end = (
|
||||||
|
matches[index + 1].start()
|
||||||
|
if index + 1 < len(matches)
|
||||||
|
else len(text)
|
||||||
|
)
|
||||||
|
blocks.append({
|
||||||
|
"annotation": match.group(1).strip(),
|
||||||
|
"parameter": (match.group(2) or "").strip(),
|
||||||
|
"content": text[content_start:content_end].strip(),
|
||||||
|
"line": line_number(text, match.start())
|
||||||
|
})
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def validate_settings(
|
||||||
|
messages: list[str],
|
||||||
|
language: str
|
||||||
|
):
|
||||||
|
settings = settings_config.load_settings()
|
||||||
|
selected_settings = {}
|
||||||
|
|
||||||
|
if SETTINGS_MARKDOWN_FILE.exists():
|
||||||
|
selected_settings = settings_config.selected_by_section(
|
||||||
|
SETTINGS_MARKDOWN_FILE.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.get("language", "de") not in {"de", "en"}:
|
||||||
|
messages.append(tr(language, "language"))
|
||||||
|
|
||||||
|
if settings.get("validator_language", "de") not in {"de", "en"}:
|
||||||
|
messages.append(tr(language, "validator_language"))
|
||||||
|
|
||||||
|
if settings.get("theme", "havel") not in settings_config.COLOR_THEMES:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"theme",
|
||||||
|
themes=", ".join(settings_config.COLOR_THEMES.keys())
|
||||||
|
))
|
||||||
|
|
||||||
|
selected_themes = selected_settings.get("theme", [])
|
||||||
|
|
||||||
|
if len(selected_themes) > 1:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"theme_multiple",
|
||||||
|
selected=", ".join(selected_themes),
|
||||||
|
first=selected_themes[0]
|
||||||
|
))
|
||||||
|
|
||||||
|
navigation_height = (
|
||||||
|
settings
|
||||||
|
.get("navigation", {})
|
||||||
|
.get("height", "medium")
|
||||||
|
)
|
||||||
|
|
||||||
|
if navigation_height not in {"small", "medium", "large"}:
|
||||||
|
messages.append(tr(language, "navigation_height"))
|
||||||
|
|
||||||
|
navigation_position = (
|
||||||
|
settings
|
||||||
|
.get("navigation", {})
|
||||||
|
.get("position", "sticky")
|
||||||
|
)
|
||||||
|
|
||||||
|
if navigation_position not in {"sticky", "static"}:
|
||||||
|
messages.append(tr(language, "navigation_position"))
|
||||||
|
|
||||||
|
navigation_align = (
|
||||||
|
settings
|
||||||
|
.get("navigation", {})
|
||||||
|
.get("align", "center")
|
||||||
|
)
|
||||||
|
|
||||||
|
if navigation_align not in {"left", "center", "right"}:
|
||||||
|
messages.append(tr(language, "navigation_align"))
|
||||||
|
|
||||||
|
mobile_menu_align = (
|
||||||
|
settings
|
||||||
|
.get("mobile_menu", {})
|
||||||
|
.get("align", "auto")
|
||||||
|
)
|
||||||
|
|
||||||
|
if mobile_menu_align not in {"auto", "left", "center", "right"}:
|
||||||
|
messages.append(tr(language, "mobile_menu_align"))
|
||||||
|
|
||||||
|
mobile_menu_submenus = (
|
||||||
|
settings
|
||||||
|
.get("mobile_menu", {})
|
||||||
|
.get("submenus", "open")
|
||||||
|
)
|
||||||
|
|
||||||
|
if mobile_menu_submenus not in {"open", "closed"}:
|
||||||
|
messages.append(tr(language, "mobile_menu_submenus"))
|
||||||
|
|
||||||
|
selected_navigation = selected_settings.get("navigation", [])
|
||||||
|
selected_navigation_heights = [
|
||||||
|
value for value in selected_navigation
|
||||||
|
if settings_config.normalize_choice(
|
||||||
|
value,
|
||||||
|
settings_config.SIZE_ALIASES
|
||||||
|
) in {"small", "medium", "large"}
|
||||||
|
]
|
||||||
|
selected_navigation_positions = [
|
||||||
|
value for value in selected_navigation
|
||||||
|
if settings_config.normalize_choice(
|
||||||
|
value,
|
||||||
|
settings_config.POSITION_ALIASES
|
||||||
|
) in {"sticky", "static"}
|
||||||
|
]
|
||||||
|
selected_navigation_aligns = [
|
||||||
|
value for value in selected_navigation
|
||||||
|
if settings_config.normalize_choice(
|
||||||
|
value,
|
||||||
|
settings_config.ALIGN_ALIASES
|
||||||
|
) in {"left", "center", "right"}
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(selected_navigation_heights) > 1:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"navigation_multiple",
|
||||||
|
selected=", ".join(selected_navigation_heights),
|
||||||
|
first=selected_navigation_heights[0]
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(selected_navigation_positions) > 1:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"navigation_multiple",
|
||||||
|
selected=", ".join(selected_navigation_positions),
|
||||||
|
first=selected_navigation_positions[0]
|
||||||
|
))
|
||||||
|
|
||||||
|
if len(selected_navigation_aligns) > 1:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"navigation_multiple",
|
||||||
|
selected=", ".join(selected_navigation_aligns),
|
||||||
|
first=selected_navigation_aligns[0]
|
||||||
|
))
|
||||||
|
|
||||||
|
logo = settings.get("logo", {})
|
||||||
|
|
||||||
|
if logo.get("enabled", False):
|
||||||
|
align = logo.get("align", "left")
|
||||||
|
selected_logo = selected_settings.get("logo", [])
|
||||||
|
selected_logo_aligns = [
|
||||||
|
value for value in selected_logo
|
||||||
|
if settings_config.normalize_choice(
|
||||||
|
value,
|
||||||
|
settings_config.ALIGN_ALIASES
|
||||||
|
) in {"left", "right"}
|
||||||
|
]
|
||||||
|
|
||||||
|
if not selected_logo_aligns:
|
||||||
|
messages.append(tr(language, "logo_align"))
|
||||||
|
|
||||||
|
elif align not in {"left", "center", "right"}:
|
||||||
|
messages.append(tr(language, "logo_align"))
|
||||||
|
elif align == "center":
|
||||||
|
messages.append(tr(language, "logo_align_center"))
|
||||||
|
|
||||||
|
src = logo.get("src", "").strip()
|
||||||
|
folder = logo.get("folder", "logo").strip().strip("/") or "logo"
|
||||||
|
|
||||||
|
if src:
|
||||||
|
logo_file = CONTENT_DIR / MEDIA_DIR_NAME / folder / src
|
||||||
|
|
||||||
|
if not logo_file.exists():
|
||||||
|
logo_file = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME / folder / src
|
||||||
|
|
||||||
|
if not logo_file.exists():
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"logo_missing",
|
||||||
|
folder=folder,
|
||||||
|
src=src
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
logo_dir = CONTENT_DIR / MEDIA_DIR_NAME / folder
|
||||||
|
|
||||||
|
if not logo_dir.exists():
|
||||||
|
logo_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME / folder
|
||||||
|
|
||||||
|
logo_files = sorted([
|
||||||
|
file.name for file in logo_dir.iterdir()
|
||||||
|
if file.is_file() and file.suffix.lower() in IMAGE_EXTENSIONS
|
||||||
|
]) if logo_dir.exists() else []
|
||||||
|
|
||||||
|
if not logo_files:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"logo_folder_missing",
|
||||||
|
folder=folder
|
||||||
|
))
|
||||||
|
elif len(logo_files) > 1:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"logo_multiple",
|
||||||
|
files=", ".join(logo_files),
|
||||||
|
first=logo_files[0]
|
||||||
|
))
|
||||||
|
|
||||||
|
gallery_layout = (
|
||||||
|
settings
|
||||||
|
.get("gallery", {})
|
||||||
|
.get("layout", "square")
|
||||||
|
)
|
||||||
|
|
||||||
|
if gallery_layout not in {"square", "wide", "masonry"}:
|
||||||
|
messages.append(tr(language, "gallery_layout"))
|
||||||
|
|
||||||
|
icon_style = (
|
||||||
|
settings
|
||||||
|
.get("icons", {})
|
||||||
|
.get("style", "round")
|
||||||
|
)
|
||||||
|
|
||||||
|
if icon_style not in {"round", "simple", "text"}:
|
||||||
|
messages.append(tr(language, "icon_style"))
|
||||||
|
|
||||||
|
font_size = (
|
||||||
|
settings
|
||||||
|
.get("typography", {})
|
||||||
|
.get("size", "medium")
|
||||||
|
)
|
||||||
|
|
||||||
|
if font_size not in {"small", "medium", "large"}:
|
||||||
|
messages.append(tr(language, "font"))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_markdown_files(
|
||||||
|
messages: list[str],
|
||||||
|
language: str
|
||||||
|
):
|
||||||
|
components = load_json(COMPONENTS_FILE)
|
||||||
|
known_annotations = set(components.keys())
|
||||||
|
known_pages = page_keys()
|
||||||
|
known_page_suggestions = page_suggestions()
|
||||||
|
|
||||||
|
for md_file in sorted(CONTENT_DIR.rglob("*.md")):
|
||||||
|
if md_file == SETTINGS_MARKDOWN_FILE:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(md_file.relative_to(CONTENT_DIR).parts) > 2:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"nested_page",
|
||||||
|
path=md_file.relative_to(ROOT_DIR)
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = md_file.read_text(encoding="utf-8")
|
||||||
|
display_path = md_file.relative_to(ROOT_DIR)
|
||||||
|
|
||||||
|
for match in re.finditer(r"(?m)^@([^\s:]+)(?::([^\n]+))?", text):
|
||||||
|
annotation = match.group(1).strip()
|
||||||
|
parameter = (match.group(2) or "").strip()
|
||||||
|
line = line_number(text, match.start())
|
||||||
|
|
||||||
|
if annotation not in known_annotations:
|
||||||
|
suggestion = difflib.get_close_matches(
|
||||||
|
annotation,
|
||||||
|
known_annotations,
|
||||||
|
n=1
|
||||||
|
)
|
||||||
|
|
||||||
|
hint = (
|
||||||
|
tr(
|
||||||
|
language,
|
||||||
|
"did_you_mean",
|
||||||
|
suggestion=suggestion[0]
|
||||||
|
)
|
||||||
|
if suggestion
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"unknown_block",
|
||||||
|
path=display_path,
|
||||||
|
line=line,
|
||||||
|
annotation=annotation,
|
||||||
|
hint=hint
|
||||||
|
))
|
||||||
|
|
||||||
|
component = components.get(annotation, annotation)
|
||||||
|
|
||||||
|
if component == "gallery" and parameter:
|
||||||
|
gallery_dir = CONTENT_DIR / MEDIA_DIR_NAME / GALLERY_DIR_NAME / parameter
|
||||||
|
|
||||||
|
if not gallery_dir.exists():
|
||||||
|
gallery_dir = (
|
||||||
|
CONTENT_DIR
|
||||||
|
/ LEGACY_MEDIA_DIR_NAME
|
||||||
|
/ LEGACY_GALLERY_DIR_NAME
|
||||||
|
/ parameter
|
||||||
|
)
|
||||||
|
|
||||||
|
if not gallery_dir.exists():
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"gallery_missing",
|
||||||
|
path=display_path,
|
||||||
|
line=line,
|
||||||
|
parameter=parameter,
|
||||||
|
hint=gallery_suggestion(parameter)
|
||||||
|
))
|
||||||
|
|
||||||
|
if component == "hero" and parameter:
|
||||||
|
validate_media_parameter(
|
||||||
|
messages,
|
||||||
|
display_path,
|
||||||
|
line,
|
||||||
|
parameter,
|
||||||
|
md_file,
|
||||||
|
language
|
||||||
|
)
|
||||||
|
|
||||||
|
for block in annotated_blocks(text):
|
||||||
|
component = components.get(block["annotation"], block["annotation"])
|
||||||
|
h2_count = len(re.findall(r"(?m)^##\s+", block["content"]))
|
||||||
|
|
||||||
|
if component == "event" and h2_count > 1:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"event_single_multiple",
|
||||||
|
path=display_path,
|
||||||
|
line=block["line"],
|
||||||
|
annotation=block["annotation"]
|
||||||
|
))
|
||||||
|
|
||||||
|
if component == "events" and h2_count < 2:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"events_list_single",
|
||||||
|
path=display_path,
|
||||||
|
line=block["line"],
|
||||||
|
annotation=block["annotation"]
|
||||||
|
))
|
||||||
|
|
||||||
|
if component == "person" and h2_count > 1:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"person_single_multiple",
|
||||||
|
path=display_path,
|
||||||
|
line=block["line"],
|
||||||
|
annotation=block["annotation"]
|
||||||
|
))
|
||||||
|
|
||||||
|
if component == "people" and h2_count < 2:
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"people_list_single",
|
||||||
|
path=display_path,
|
||||||
|
line=block["line"],
|
||||||
|
annotation=block["annotation"]
|
||||||
|
))
|
||||||
|
|
||||||
|
for match in re.finditer(r"!\[(.*?)\]\((.*?)\)", text):
|
||||||
|
src = match.group(2).strip()
|
||||||
|
|
||||||
|
if is_external_url(src):
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = content_relative_from_src(src, md_file)
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"image_missing",
|
||||||
|
path=display_path,
|
||||||
|
line=line_number(text, match.start()),
|
||||||
|
src=src,
|
||||||
|
hint=media_suggestion(src)
|
||||||
|
))
|
||||||
|
|
||||||
|
for match in re.finditer(r"\[\[(.*?)\]\]", text):
|
||||||
|
label = match.group(1).strip()
|
||||||
|
key = slugify_title(label)
|
||||||
|
|
||||||
|
if key not in known_pages:
|
||||||
|
suggestion = difflib.get_close_matches(
|
||||||
|
label,
|
||||||
|
known_page_suggestions,
|
||||||
|
n=1
|
||||||
|
)
|
||||||
|
hint = (
|
||||||
|
tr(
|
||||||
|
language,
|
||||||
|
"did_you_mean",
|
||||||
|
suggestion=suggestion[0]
|
||||||
|
)
|
||||||
|
if suggestion
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"internal_link_missing",
|
||||||
|
path=display_path,
|
||||||
|
line=line_number(text, match.start()),
|
||||||
|
label=label,
|
||||||
|
hint=hint
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_media_parameter(
|
||||||
|
messages: list[str],
|
||||||
|
display_path: Path,
|
||||||
|
line: int,
|
||||||
|
parameter: str,
|
||||||
|
md_file: Path,
|
||||||
|
language: str
|
||||||
|
):
|
||||||
|
parsed = urlparse(parameter)
|
||||||
|
suffix = Path(parsed.path).suffix.lower()
|
||||||
|
|
||||||
|
if is_external_url(parameter):
|
||||||
|
return
|
||||||
|
|
||||||
|
if suffix in IMAGE_EXTENSIONS or suffix in VIDEO_EXTENSIONS:
|
||||||
|
file_path = content_relative_from_src(parameter, md_file)
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
messages.append(tr(
|
||||||
|
language,
|
||||||
|
"hero_missing",
|
||||||
|
path=display_path,
|
||||||
|
line=line,
|
||||||
|
parameter=parameter
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def collect_messages() -> tuple[list[str], str]:
|
||||||
|
messages: list[str] = []
|
||||||
|
settings_config.format_settings_file()
|
||||||
|
language = language_from_settings()
|
||||||
|
|
||||||
|
validate_settings(messages, language)
|
||||||
|
validate_markdown_files(messages, language)
|
||||||
|
|
||||||
|
return messages, language
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
messages, language = collect_messages()
|
||||||
|
|
||||||
|
if messages:
|
||||||
|
print(tr(language, "found_header"))
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
print(f"- {message}")
|
||||||
|
|
||||||
|
print(tr(language, "found_footer"))
|
||||||
|
|
||||||
|
return 1 if has_errors(messages) else 0
|
||||||
|
|
||||||
|
print(tr(language, "ok"))
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def has_errors(messages: list[str]) -> bool:
|
||||||
|
return any(
|
||||||
|
not (
|
||||||
|
message.startswith("WARNUNG:")
|
||||||
|
or message.startswith("WARNING:")
|
||||||
|
)
|
||||||
|
for message in messages
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
12
havelseite/Bilder/Unser Verein.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
@galerie:Vereinsgelände
|
||||||
|
# Unser Verein
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@bild-text
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# Unsere Jollenwiese
|
||||||
|
|
||||||
|
Hier steht der Text rechts neben dem Bild.
|
||||||
195
havelseite/beispiel-alle-faelle.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
@aufmacher
|
||||||
|
# Beispielseite fuer alle Faelle
|
||||||
|
|
||||||
|
Das ist der Standard-Aufmacher ohne Zusatz.
|
||||||
|
|
||||||
|
@aufmacher:#0f766e
|
||||||
|
# Aufmacher mit Farbe
|
||||||
|
|
||||||
|
Hier wird eine feste Hintergrundfarbe verwendet.
|
||||||
|
|
||||||
|
@aufmacher:medien/galerie/Vereinsgelände/csm_db_blick_auf_steganlage7_01_77c4c1781b.jpg
|
||||||
|
# Aufmacher mit Bild
|
||||||
|
|
||||||
|
Das Bild liegt lokal im Medienordner.
|
||||||
|
|
||||||
|
@aufmacher:https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4
|
||||||
|
# Aufmacher mit Video
|
||||||
|
|
||||||
|
Dieses Beispiel nutzt eine externe Videodatei.
|
||||||
|
|
||||||
|
@aufmacher:https://youtu.be/HyxD0Zdj-Cg
|
||||||
|
# Aufmacher mit YouTube
|
||||||
|
|
||||||
|
YouTube wird erst nach Zustimmung geladen, wenn externe Inhalte nicht automatisch erlaubt sind.
|
||||||
|
|
||||||
|
@text
|
||||||
|
# Normaler Text
|
||||||
|
|
||||||
|
Das ist ein normaler Inhaltsbereich. Hier funktionieren Markdown-Absätze, **fette Schrift**, Listen und Links.
|
||||||
|
|
||||||
|
- Erster Punkt
|
||||||
|
- Zweiter Punkt
|
||||||
|
- Dritter Punkt
|
||||||
|
|
||||||
|
Ein normaler externer Link: [Inselcupanmeldung](https://inselcup.msvb.de/)
|
||||||
|
|
||||||
|
Ein interner Link: [[Impressum]]
|
||||||
|
|
||||||
|
@hinweis
|
||||||
|
Dies ist ein kurzer Hinweis, zum Beispiel fuer eine wichtige Anmeldung oder eine Terminänderung.
|
||||||
|
|
||||||
|
@kacheln
|
||||||
|
# Einspaltige Kacheln
|
||||||
|
|
||||||
|
## Kachel eins
|
||||||
|
Diese Kachel steht unter den anderen.
|
||||||
|
|
||||||
|
## Kachel zwei
|
||||||
|
Auch längere Texte funktionieren hier.
|
||||||
|
|
||||||
|
@kacheln:zweispaltig
|
||||||
|
# Zweispaltige Kacheln
|
||||||
|
|
||||||
|
## Training
|
||||||
|
Mittwoch ab 17 Uhr.
|
||||||
|
|
||||||
|
## Regatta
|
||||||
|
Samstag ab 10 Uhr.
|
||||||
|
|
||||||
|
@kacheln:dreispaltig
|
||||||
|
# Dreispaltige Kacheln
|
||||||
|
|
||||||
|
## Verein
|
||||||
|
Infos zum Vereinsleben.
|
||||||
|
|
||||||
|
## Jugend
|
||||||
|
Angebote fuer junge Seglerinnen und Segler.
|
||||||
|
|
||||||
|
## Boote
|
||||||
|
Hinweise zu Booten und Material.
|
||||||
|
|
||||||
|
@zwei-spalten
|
||||||
|
# Zwei Spalten
|
||||||
|
|
||||||
|
## Linke Spalte
|
||||||
|
Hier steht Inhalt in der linken Spalte.
|
||||||
|
|
||||||
|
## Rechte Spalte
|
||||||
|
Hier steht Inhalt in der rechten Spalte.
|
||||||
|
|
||||||
|
@galerie
|
||||||
|
# Manuelle Galerie
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
@galerie:Vereinsgelände
|
||||||
|
# Automatische Galerie aus einem Ordner
|
||||||
|
|
||||||
|
Alle Bilder kommen aus `havelseite/medien/galerie/Vereinsgelände/`.
|
||||||
|
|
||||||
|
@zitat
|
||||||
|
"Eine gute Vereinsseite muss nicht kompliziert sein."
|
||||||
|
|
||||||
|
@zeitstrahl
|
||||||
|
# Zeitstrahl
|
||||||
|
|
||||||
|
## Januar
|
||||||
|
Planung der Saison.
|
||||||
|
|
||||||
|
## April
|
||||||
|
Boote ins Wasser.
|
||||||
|
|
||||||
|
## September
|
||||||
|
Regatten und Vereinsleben.
|
||||||
|
|
||||||
|
@fragen
|
||||||
|
# Fragen und Antworten
|
||||||
|
|
||||||
|
## Wie bearbeite ich eine Seite?
|
||||||
|
Du änderst einfach die passende Markdown-Datei im Ordner `havelseite/`.
|
||||||
|
|
||||||
|
## Wo lege ich Bilder ab?
|
||||||
|
Bilder liegen unter `havelseite/medien/`.
|
||||||
|
|
||||||
|
@kontakt
|
||||||
|
# Kontakt
|
||||||
|
|
||||||
|
Musterverein Brandenburg
|
||||||
|
Am Beetzsee 1
|
||||||
|
14770 Brandenburg an der Havel
|
||||||
|
info@example.de
|
||||||
|
|
||||||
|
@ort:Am Beetzsee 1, 14770 Brandenburg an der Havel
|
||||||
|
# Ort und Anfahrt
|
||||||
|
|
||||||
|
Der Ortsblock zeigt eine Adresse und kann OpenStreetMap datenschutzfreundlich nachladen.
|
||||||
|
|
||||||
|
@bild-text
|
||||||
|

|
||||||
|
|
||||||
|
# Bild und Text
|
||||||
|
|
||||||
|
Dieser Bereich stellt ein Bild neben einen Text. Das eignet sich gut fuer Vereinsgelände, Boote, Teams oder Angebote.
|
||||||
|
|
||||||
|
@veranstaltung
|
||||||
|
# Einzelne Veranstaltung
|
||||||
|
|
||||||
|
Datum: 12. Juli
|
||||||
|
Uhrzeit: 15:00 Uhr
|
||||||
|
Ort: Vereinsgelände
|
||||||
|
Anmeldung: [Zur Anmeldung](https://example.com)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Eine einzelne Veranstaltungsbox fuer einen wichtigen Termin.
|
||||||
|
|
||||||
|
@veranstaltungen
|
||||||
|
# Mehrere Veranstaltungen
|
||||||
|
|
||||||
|
## Sommerfest
|
||||||
|
Datum: 12. Juli
|
||||||
|
Uhrzeit: 15:00 Uhr
|
||||||
|
Ort: Vereinsgelände
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Gemeinsames Sommerfest mit Kuchen, Grill und kleinen Bootsrunden.
|
||||||
|
|
||||||
|
## Absegeln
|
||||||
|
Datum: 20. September
|
||||||
|
Uhrzeit: 10:00 Uhr
|
||||||
|
Ort: Vereinsgelände
|
||||||
|
|
||||||
|
Gemeinsamer Saisonabschluss auf dem Wasser.
|
||||||
|
|
||||||
|
@dateien
|
||||||
|
# Dateien und Downloads
|
||||||
|
|
||||||
|
[Ausschreibung als externer Link](https://example.com/ausschreibung.pdf)
|
||||||
|
[Anmeldung als externer Link](https://example.com/anmeldung.pdf)
|
||||||
|
|
||||||
|
@person
|
||||||
|
# Einzelne Person
|
||||||
|
|
||||||
|
Max Mustermann
|
||||||
|
Vorsitzender
|
||||||
|
max@example.de
|
||||||
|
|
||||||
|
@personen
|
||||||
|
# Personen
|
||||||
|
|
||||||
|
## Max Mustermann
|
||||||
|
Vorsitzender
|
||||||
|
max@example.de
|
||||||
|
|
||||||
|
## Erika Beispiel
|
||||||
|
Jugendwartin
|
||||||
|
erika@example.de
|
||||||
|
|
||||||
|
@sponsoren
|
||||||
|
# Sponsoren und Partner
|
||||||
|
|
||||||
|

|
||||||
73
havelseite/einstellungen.md
Normal file
@ -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
|
||||||
3
havelseite/fusszeile/anfahrt.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@ort:Schienenweg 49, 14772 Brandenburg an der Havel, Deutschland
|
||||||
|
# Anfahrt
|
||||||
|
|
||||||
4
havelseite/fusszeile/datenschutz.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@text
|
||||||
|
# Datenschutz
|
||||||
|
|
||||||
|
Hier stehen die Datenschutzhinweise.
|
||||||
13
havelseite/fusszeile/fusszeile.md
Normal file
@ -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]]
|
||||||
9
havelseite/fusszeile/impressum.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
@text
|
||||||
|
# Impressum
|
||||||
|
|
||||||
|
Angaben gemäß § 5 TMG.
|
||||||
|
|
||||||
|
Märkischer Segelverein Beetzsee
|
||||||
|
Schienenweg 49
|
||||||
|
14772 Brandenburg an der Havel
|
||||||
|
|
||||||
4
havelseite/hero-tests/bild.md
Normal file
@ -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.
|
||||||
4
havelseite/hero-tests/farbe.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@aufmacher:#0f766e
|
||||||
|
# Aufmacher Farbe
|
||||||
|
|
||||||
|
Dieser Aufmacher nutzt einen Farbwert als Hintergrund.
|
||||||
4
havelseite/hero-tests/standard.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@aufmacher
|
||||||
|
# Aufmacher Standard
|
||||||
|
|
||||||
|
Dieser Aufmacher nutzt keinen Parameter und bleibt deshalb bei der normalen hellen Standarddarstellung.
|
||||||
4
havelseite/hero-tests/video.md
Normal file
@ -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.
|
||||||
4
havelseite/hero-tests/youtube.md
Normal file
@ -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.
|
||||||
19
havelseite/index.md
Normal file
@ -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.
|
||||||
|
|
||||||
|
|
||||||
BIN
havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_1.jpeg
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_2.jpeg
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_3.jpeg
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_4.jpeg
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_5.jpeg
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
havelseite/medien/galerie/Kon-Tiki_2026/kontiki2026_6.jpeg
Normal file
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 141 KiB |
BIN
havelseite/medien/logo/msvb_logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
0
havelseite/regatten/kon-tiki.md
Normal file
0
havelseite/sonstiges.md
Normal file
127
havelseite/template-beispiele.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
@text
|
||||||
|
# Template-Beispiele
|
||||||
|
|
||||||
|
Diese Seite zeigt die wichtigsten Markdown-Bausteine, die der Generator versteht.
|
||||||
|
|
||||||
|
@hinweis
|
||||||
|
Ein Hinweisbanner eignet sich für Termine, Anmeldungen oder wichtige kurze Nachrichten.
|
||||||
|
|
||||||
|
@kacheln:dreispaltig
|
||||||
|
# Kacheln
|
||||||
|
|
||||||
|
## Erste Kachel
|
||||||
|
Kurzer Text für eine kompakte Inhaltskachel.
|
||||||
|
|
||||||
|
## Zweite Kachel
|
||||||
|
Noch ein Beispiel mit eigenem Titel und Beschreibung.
|
||||||
|
|
||||||
|
## Dritte Kachel
|
||||||
|
Kacheln werden aus `##`-Abschnitten gebildet.
|
||||||
|
|
||||||
|
@zwei-spalten
|
||||||
|
# Zwei Spalten
|
||||||
|
|
||||||
|
## Linke Spalte
|
||||||
|
Hier steht der Inhalt der linken Spalte.
|
||||||
|
|
||||||
|
## Rechte Spalte
|
||||||
|
Hier steht der Inhalt der rechten Spalte.
|
||||||
|
|
||||||
|
@zeitstrahl
|
||||||
|
# Zeitstrahl
|
||||||
|
|
||||||
|
## Frühjahr
|
||||||
|
Saisonstart und gemeinsames Aufklaren.
|
||||||
|
|
||||||
|
## Sommer
|
||||||
|
Regatten, Training und lange Abende am Wasser.
|
||||||
|
|
||||||
|
## Herbst
|
||||||
|
Absegeln und Vorbereitung auf die Winterpause.
|
||||||
|
|
||||||
|
@fragen
|
||||||
|
# FAQ
|
||||||
|
|
||||||
|
## Wie lege ich eine Seite an?
|
||||||
|
Eine neue `.md`-Datei unter `havelseite` wird beim Bauen automatisch zu einer HTML-Seite.
|
||||||
|
|
||||||
|
## Wie verlinke ich intern?
|
||||||
|
Interne Links funktionieren, indem du den Seitentitel in doppelte eckige Klammern setzt.
|
||||||
|
|
||||||
|
@galerie
|
||||||
|
# Manuelle Galerie
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
@zitat
|
||||||
|
> Ein Zitatblock kann als ruhiger Trenner zwischen Inhaltsbereichen dienen.
|
||||||
|
|
||||||
|
@bild-text
|
||||||
|

|
||||||
|
|
||||||
|
# Bild und Text
|
||||||
|
|
||||||
|
Das erste Markdown-Bild im Block wird links angezeigt, der restliche Text daneben.
|
||||||
|
|
||||||
|
@kontakt
|
||||||
|
# Kontakt
|
||||||
|
|
||||||
|
Musterverein Brandenburg
|
||||||
|
Am Beetzsee 1
|
||||||
|
info@example.de
|
||||||
|
|
||||||
|
@ort:Am Beetzsee 1, 14770 Brandenburg an der Havel
|
||||||
|
# Ort und Anfahrt
|
||||||
|
|
||||||
|
Hier kann eine Adresse mit OpenStreetMap verknüpft werden.
|
||||||
|
|
||||||
|
@veranstaltung
|
||||||
|
# Sommerfest
|
||||||
|
|
||||||
|
Datum: 12. Juli
|
||||||
|
Uhrzeit: 15:00 Uhr
|
||||||
|
Ort: Vereinsgelände
|
||||||
|
Anmeldung: info@example.de
|
||||||
|
|
||||||
|
Gemeinsames Sommerfest mit Kuchen, Grill und kleinen Bootsrunden.
|
||||||
|
|
||||||
|
@veranstaltungen
|
||||||
|
# Weitere Termine
|
||||||
|
|
||||||
|
## Absegeln
|
||||||
|
Datum: 20. September
|
||||||
|
Uhrzeit: 10:00 Uhr
|
||||||
|
Ort: Vereinsgelände
|
||||||
|
|
||||||
|
Gemeinsamer Saisonabschluss auf dem Wasser.
|
||||||
|
|
||||||
|
## Winterlager
|
||||||
|
Datum: 1. November
|
||||||
|
Uhrzeit: 9:00 Uhr
|
||||||
|
Ort: Bootshaus
|
||||||
|
|
||||||
|
Boote gemeinsam einlagern und danach Kaffee trinken.
|
||||||
|
|
||||||
|
@dateien
|
||||||
|
# Downloads
|
||||||
|
|
||||||
|
[Ausschreibung Kon-Tiki](https://example.com/ausschreibung.pdf)
|
||||||
|
[Anmeldeformular](https://example.com/anmeldung.pdf)
|
||||||
|
|
||||||
|
@personen
|
||||||
|
# Team
|
||||||
|
|
||||||
|
## Max Mustermann
|
||||||
|
Vorsitzender
|
||||||
|
max@example.de
|
||||||
|
|
||||||
|
## Erika Beispiel
|
||||||
|
Jugendwartin
|
||||||
|
erika@example.de
|
||||||
|
|
||||||
|
@sponsoren
|
||||||
|
# Partner und Sponsoren
|
||||||
|
|
||||||
|

|
||||||
0
havelseite/verein.md
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
markdown
|
||||||
|
jinja2
|
||||||