Add havelseite

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

BIN
.DS_Store vendored Normal file

Binary file not shown.

21
LICENSE Normal file
View 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
View File

@ -1 +1,540 @@
# Hallo # Havelseiten
Version: `0.7.0`
Havelseiten ist ein Anti-CMS fuer schnelle, einfache und datenschutzbewusste Websites.
Keine Datenbank, kein Adminbereich, keine Plugins, keine laufenden Updates, kein teurer Webspace. Inhalte liegen als Markdown-Dateien im Ordner `havelseite/`. Der Generator baut daraus fertige statische HTML-Seiten im Ordner `ausgabe/`.
Havelseiten ist besonders gedacht fuer Vereine, Initiativen und kleine Projekte, die eine robuste Website brauchen, aber kein Website-System pflegen wollen.
## Grundidee
- Markdown rein, fertige Website raus.
- Einstellungen stehen in `havelseite/einstellungen.md`, nicht in JSON/YAML.
- Externe Inhalte wie YouTube oder OpenStreetMap werden datenschutzfreundlich erst nach Zustimmung geladen.
- Die Navigation entsteht automatisch aus Dateien und Ordnern.
- Der Validator prueft vor jedem Build auf typische Fehler.
- Spaeter kann Galatea als UI davorliegen: Inhalte hochladen, Havelseiten baut, GitLab/Pages veroeffentlicht.
## Was darf ich bearbeiten?
Diese Dateien und Ordner sind fuer normale Bearbeitung gedacht:
- `havelseite/` fuer Seiten und Texte
- `havelseite/medien/` fuer Bilder und Dateien
- `havelseite/medien/galerie/` fuer Galerien
- `havelseite/medien/logo/` fuer das Logo
- `havelseite/einstellungen.md` fuer Titel, Design, Logo, Galerie, Icons und Schrift
Den Ordner `generator/` musst du im normalen Alltag nicht anfassen.
## Schnellstart
1. Aendere Titel, Farben, Logo und Navigation in `havelseite/einstellungen.md`.
2. Schreibe deine Startseite in `havelseite/index.md`.
3. Lege weitere Seiten als Markdown-Dateien in `havelseite/` an.
4. Lege Bilder in `havelseite/medien/`.
5. Baue die Website mit `python3 generator/havelseiten.py`.
## Seite bauen
```sh
python3 generator/havelseiten.py
```
Vor dem Bauen wird `havelseite/einstellungen.md` automatisch formatiert und danach geprueft. Aus `- [] Wald` wird zum Beispiel `- [ ] Wald`.
Warnungen stoppen den Build nicht. Wenn zum Beispiel zwei Farbpaletten angekreuzt sind, wird gewarnt und die erste angekreuzte Palette verwendet.
Die fertigen Seiten liegen danach in `ausgabe/`.
## Vorher pruefen
```sh
python3 generator/validate.py
```
Der Validator sagt in normalen Worten, wenn zum Beispiel ein Bild fehlt, ein Galerieordner falsch geschrieben ist oder ein unbekannter Block wie `@galry` benutzt wurde.
## Projektstruktur
```text
havelseite/
einstellungen.md
index.md
verein.md
regatten/
kon-tiki.md
fusszeile/
fusszeile.md
impressum.md
datenschutz.md
medien/
logo/
galerie/
generator/
havelseiten.py
validate.py
templates/
themes/
ausgabe/
```
`havelseite/` ist der Inhaltsordner. `generator/` ist die Technik. `ausgabe/` ist das fertige Ergebnis.
## Eine neue Seite anlegen
Lege im Ordner `havelseite` eine neue Datei an, zum Beispiel:
```text
havelseite/aktuelles.md
```
Inhalt:
```md
@aufmacher
# Aktuelles
Neuigkeiten aus dem Verein.
@text
# Willkommen
Hier steht normaler Text.
```
Nach dem Bauen entsteht:
```text
ausgabe/aktuelles.html
```
Die Navigation wird automatisch erstellt.
## Menue sortieren
Die Reihenfolge steuerst du ueber Zahlen am Anfang von Datei- oder Ordnernamen:
```text
havelseite/01_start.md
havelseite/02_verein.md
havelseite/03_regatten/
havelseite/04_kontakt.md
```
Die Zahlen sind nur fuer die Sortierung da. Im Menue und in den fertigen Links werden sie ausgeblendet.
Havelseiten erlaubt fuer Seiten nur eine Ordnerebene:
```text
havelseite/regatten/kon-tiki.md
```
Nicht erlaubt sind Ordner in Ordnern:
```text
havelseite/regatten/2026/kon-tiki.md
```
Der Grund: Die Navigation bleibt dadurch einfach. Eine Datei direkt in `havelseite/` wird ein normaler Menuepunkt. Ein Ordner direkt in `havelseite/` wird ein ausklappbarer Menuepunkt.
Medienordner duerfen tiefer sein. Diese Regel gilt nur fuer Markdown-Seiten.
## Einstellungen
Globale Einstellungen stehen in:
```text
havelseite/einstellungen.md
```
Beispiel:
```md
# Einstellungen
@einstellung:seite
Titel: Havelseiten
@einstellung:sprache
- [x] Deutsch
- [ ] Englisch
@einstellung:farbpalette
- [ ] Havel
- [ ] Wasser
- [x] Wald
- [ ] Sonnenuntergang
- [ ] Dunkel
- [ ] Küste
- [ ] Segel
- [ ] Leuchtturm
@einstellung:schrift
- [ ] klein
- [x] mittel
- [ ] groß
@einstellung:navigation
- [ ] klein
- [ ] mittel
- [x] groß
- [x] bleibt oben
- [ ] scrollt mit
- [ ] links
- [x] mittig
- [ ] rechts
@einstellung:logo
- [x] in der Navigation
- [x] in der Fußzeile
- [x] links
- [ ] rechts
Logoordner: logo
Alternativtext: Märkischer Seglerverein Beetzsee
Text: Havelseiten
@einstellung:logo-datei
- [x] msvb_logo.png
@einstellung:mobilmenue
- [x] automatisch
- [ ] links
- [ ] mittig
- [ ] rechts
- [ ] Untermenüs ausgeklappt
@einstellung:bilder
- [x] Bildunterschriften
- [x] runde Ecken
@einstellung:icons
- [x] rund
- [ ] schlicht
- [ ] text
@einstellung:datenschutz
- [ ] externe Inhalte laden
@einstellung:social
instagram: https://instagram.com
youtube: https://youtube.com
```
Wenn in einer Auswahl nichts angekreuzt ist, setzt der Formatter meistens automatisch das Kreuz bei der ersten Option. Einzelne Ja/Nein-Optionen, zum Beispiel `Untermenüs ausgeklappt`, bleiben ohne Kreuz bewusst ausgeschaltet.
### Logo
Lege das Logo in `havelseite/medien/logo/`.
Wenn weder `in der Navigation` noch `in der Fußzeile` angekreuzt ist, wird kein Logo angezeigt. Sobald eine dieser beiden Optionen angekreuzt ist, muss auch `links` oder `rechts` angekreuzt sein.
Der Formatter ergänzt automatisch eine Auswahl mit den Logo-Dateien:
```md
@einstellung:logo-datei
- [x] msvb_logo.png
- [ ] zweites-logo.svg
```
Wenn nichts angekreuzt ist, wird automatisch die erste Datei angekreuzt.
`Logoordner: logo` bedeutet: Der Generator schaut in `havelseite/medien/logo/`.
### Farbpaletten
Die Farbpaletten liegen als JSON-Dateien in `generator/themes/`. Wenn dort eine neue Datei wie `verein.json` liegt, ergänzt der Formatter die Auswahl in `havelseite/einstellungen.md` automatisch; danach kann sie als `Verein` angekreuzt werden.
Aktuell gibt es:
- `Havel`
- `Wasser`
- `Wald`
- `Sonnenuntergang`
- `Dunkel`
- `Küste`
- `Segel`
- `Leuchtturm`
### Galerie
Die Galerie bleibt bewusst schlicht. Global einstellbar sind nur die Dinge, die sofort sichtbar sind: `Bildunterschriften` und `runde Ecken`.
### Icons
Die Social-Icons sind bewusst ohne externes Iconpaket gebaut. Dadurch gibt es keine Lizenz- oder Ladeprobleme.
- `rund`: runde kleine Icon-Badges
- `schlicht`: kantigere Icon-Badges
- `text`: reine Textlinks
Social Links werden zentral aus `@einstellung:social` erzeugt. Schreibe sie deshalb nicht noch einmal in `havelseite/fusszeile/fusszeile.md`, sonst stehen sie doppelt im Footer.
### Datenschutz
Wenn `externe Inhalte laden` nicht angekreuzt ist, werden externe Karten und YouTube-Videos nicht automatisch geladen. Stattdessen erscheint ein Hinweis mit einem Button zum Nachladen auf derselben Seite.
Die Galerie funktioniert ohne externe Skripte und braucht fuer lokale Bilder keine Datenschutzfreigabe. Wenn eine manuelle Galerie externe Bild-URLs verwendet, erscheint ebenfalls ein Button zum Nachladen.
## Bilder und Galerien
Normale Bilder legst du unter `havelseite/medien/` ab.
```md
![Beschreibung des Bildes](medien/mein-bild.jpg)
```
Galeriebilder legst du in einen Ordner unter:
```text
havelseite/medien/galerie/
```
Beispiel:
```text
havelseite/medien/galerie/Sommerfest/
```
Dann kannst du die Galerie so einbinden:
```md
@galerie:Sommerfest
# Sommerfest
```
Beim Klick auf ein Bild oeffnet sich eine Ansicht, in der man durch die Galerie wischen kann.
## Bausteine
Deutsch ist die Hauptschreibweise. Die englische Schreibweise funktioniert zusaetzlich.
Jeder Baustein beginnt mit `@name`. Der Inhalt darunter gehoert bis zum naechsten `@baustein` zu diesem Bereich.
Die Namen folgen einer einfachen Regel: Einzelbereiche stehen im Singular, Listenbereiche im Plural. Darum heisst es `@hinweis`, `@veranstaltung` und `@person`, aber `@kacheln`, `@veranstaltungen`, `@fragen`, `@dateien`, `@personen` und `@sponsoren`. Listen verwenden `##`-Ueberschriften fuer einzelne Eintraege.
| Markdown | Englisch | Wird auf der Website zu |
| --- | --- | --- |
| `@aufmacher` | `@hero` | grosse Medienflaeche mit Text, Bild, Farbe, Video oder YouTube |
| `@text` | `@text` | normaler Inhaltsbereich |
| `@hinweis` | `@banner` | hervorgehobener Hinweis |
| `@kacheln` | `@cards` | Kachelraster |
| `@zwei-spalten` | `@two-columns` | zweispaltiger Bereich |
| `@galerie` | `@gallery` | Bildergalerie mit vergroesserbarer Swipe-Ansicht |
| `@zitat` | `@quote` | Zitatbereich |
| `@zeitstrahl` | `@timeline` | Ablauf oder Zeitstrahl |
| `@fragen` | `@faq` | Fragen-und-Antworten-Bereich |
| `@kontakt` | `@contact` | Kontaktblock |
| `@ort` | `@location` | Adresse mit OpenStreetMap-Link oder Karte |
| `@bild-text` | `@image-text` | Bild links, Text rechts |
| `@veranstaltung` | `@event` | einzelne Veranstaltungsbox |
| `@veranstaltungen` | `@events` | Veranstaltungsliste |
| `@dateien` | `@downloads` | Downloadliste |
| `@person` | `@person` | einzelne Personenbox |
| `@personen` | `@people` | Personen- oder Teamliste |
| `@sponsoren` | `@sponsors` | Sponsor- oder Partnerlogos |
### Aufmacher
```md
@aufmacher
# Seitentitel
Kurzer Einstiegstext.
```
Varianten:
```md
@aufmacher:medien/galerie/Sommerfest/bild.jpg
@aufmacher:medien/film.mp4
@aufmacher:https://www.youtube.com/watch?v=...
@aufmacher:#1f6f78
```
Ohne Zusatz bleibt der Aufmacherbereich so wie im Standard.
### Hinweis
```md
@hinweis
Anmeldung bis zum 1. Juli moeglich.
```
### Kacheln
```md
@kacheln:dreispaltig
# Veranstaltungen
## Kon-Tiki
Sommerregatta am Beetzsee.
## Inselcup
Spassregatta.
```
Varianten:
```md
@kacheln
@kacheln:zweispaltig
@kacheln:dreispaltig
```
Ohne Zusatz stehen die Kacheln untereinander.
### Fragen
```md
@fragen
# FAQ
## Wie melde ich mich an?
Schreibe uns eine E-Mail.
## Wo finde ich den Verein?
Die Adresse steht auf der Anfahrtsseite.
```
### Veranstaltung
```md
@veranstaltung
# Sommerfest
Datum: 12. Juli
Uhrzeit: 15:00 Uhr
Ort: Vereinsgelände
Anmeldung: [Zur Anmeldung](https://example.com)
![Sommerfest](medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg)
Gemeinsames Sommerfest mit Kuchen, Grill und kleinen Bootsrunden.
```
### Veranstaltungen
```md
@veranstaltungen
# Termine
## Sommerfest
Datum: 12. Juli
Uhrzeit: 15:00 Uhr
Ort: Vereinsgelände
![Sommerfest](medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg)
Gemeinsames Sommerfest mit Kuchen, Grill und kleinen Bootsrunden.
## Absegeln
Datum: 20. September
Uhrzeit: 10:00 Uhr
Ort: Vereinsgelände
Gemeinsamer Saisonabschluss auf dem Wasser.
```
### Dateien
```md
@dateien
# Downloads
[Ausschreibung](medien/ausschreibung.pdf)
[Anmeldung](https://example.com/anmeldung.pdf)
```
### Personen
```md
@person
# Max Mustermann
Vorsitzender
max@example.de
```
```md
@personen
# Team
## Max Mustermann
Vorsitzender
max@example.de
## Erika Beispiel
Jugendwartin
erika@example.de
```
### Sponsoren
```md
@sponsoren
# Partner
![Sponsorname](medien/logo/sponsor.png)
```
### Bild und Text
```md
@bild-text
![Steg](medien/galerie/Vereinsgelände/csm_db_15er_steg27_01_41d2098b8f.jpg)
# Unser Steg
Hier steht der Text neben dem Bild.
```
### Ort
```md
@ort:Am Beetzsee 1, 14770 Brandenburg an der Havel
# Anfahrt
Hier steht ein kurzer Hinweis zur Anfahrt.
```
Wenn externe Inhalte erlaubt sind, wird OpenStreetMap eingebettet. Sonst zeigt der Block einen datenschutzfreundlichen Link zu OpenStreetMap.
## Interne Links
Du kannst auf andere Seiten verlinken, indem du den Seitennamen in doppelte eckige Klammern setzt:
```md
[[Impressum]]
```
Der Generator macht daraus automatisch den richtigen Link.
Normale Markdown-Links funktionieren ebenfalls:
```md
[Inselcupanmeldung](https://inselcup.msvb.de/)
```
## Deployment
Havelseiten baut lokal in den Ordner `ausgabe/`.
Die Datei `.gitlab-ci.yml` zeigt einen einfachen GitLab-Pages-Deploy: GitLab installiert die Abhaengigkeiten, fuehrt den Generator aus und kopiert `ausgabe/` nach `public/`. Havelseiten selbst bleibt dadurch einfach; der Service entscheidet, wohin die fertigen Dateien veroeffentlicht werden.
Geplanter Ausbau:
- Galatea-UI fuer Upload und Bearbeitung ohne Terminal.
- Einfacher Passwortschutz fuer geschuetzte Bereiche.
## Lizenz
Havelseiten steht unter der MIT-Lizenz. Den vollstaendigen Lizenztext findest du in [LICENSE](LICENSE).

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

53
generator/components.json Normal file
View 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

File diff suppressed because it is too large Load Diff

32
generator/settings.json Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
<section class="banner">
{{ content | safe }}
</section>

View 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>

View 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>

View File

@ -0,0 +1,5 @@
<section class="contact-section">
<div class="contact-box">
{{ content | safe }}
</div>
</section>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -0,0 +1,5 @@
<section class="person-section">
<article class="person-card person-card-single">
{{ content | safe }}
</article>
</section>

View File

@ -0,0 +1,5 @@
<section class="quote-section">
<div class="quote-box">
{{ content | safe }}
</div>
</section>

View 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>

View File

@ -0,0 +1,3 @@
<section class="text-section">
{{ content | safe }}
</section>

View 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>

View 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>

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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
View File

@ -0,0 +1,836 @@
from __future__ import annotations
from pathlib import Path
import difflib
import json
import re
import sys
from urllib.parse import urlparse
import settings_config
ROOT_DIR = Path(__file__).resolve().parent.parent
CONTENT_DIR = settings_config.active_content_dir()
GENERATOR_DIR = ROOT_DIR / "generator"
COMPONENTS_FILE = GENERATOR_DIR / "components.json"
SETTINGS_FILE = settings_config.SETTINGS_JSON_FILE
SETTINGS_MARKDOWN_FILE = settings_config.active_settings_file() or settings_config.SETTINGS_MARKDOWN_FILE
MEDIA_DIR_NAME = "medien"
LEGACY_MEDIA_DIR_NAME = "assets"
GALLERY_DIR_NAME = "galerie"
LEGACY_GALLERY_DIR_NAME = "gallery"
FOOTER_DIR_NAME = "fusszeile"
LEGACY_FOOTER_DIR_NAME = "footer"
FOOTER_FILE_NAME = "fusszeile.md"
LEGACY_FOOTER_FILE_NAME = "footer.md"
IMAGE_EXTENSIONS = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".svg"
}
VIDEO_EXTENSIONS = {
".mp4",
".webm",
".ogg",
".mov"
}
TRANSLATIONS = {
"de": {
"json_error": "FEHLER: {path} ist keine gueltige JSON-Datei.",
"json_position": " Zeile {line}, Spalte {column}: {message}",
"navigation_height": "FEHLER: generator/settings.json: navigation.height muss small, medium oder large sein.",
"navigation_align": "FEHLER: Einstellungen: Navigationsausrichtung muss links, mittig oder rechts sein.",
"mobile_menu_align": "FEHLER: Einstellungen: Mobilmenü muss automatisch, links, mittig oder rechts sein.",
"mobile_menu_submenus": "FEHLER: Einstellungen: Mobilmenü-Untermenüs müssen offen oder eingeklappt sein.",
"language": "FEHLER: generator/settings.json: language muss de oder en sein.",
"validator_language": "FEHLER: generator/settings.json: validator_language muss de oder en sein.",
"theme": "FEHLER: Einstellungen: theme muss eine bekannte Farbpalette sein: {themes}.",
"theme_multiple": "WARNUNG: havelseite/einstellungen.md: Mehrere Farbpaletten sind angekreuzt ({selected}). Ich verwende die erste: {first}.",
"navigation_position": "FEHLER: Einstellungen: Navigation muss sticky/bleibt oben oder static/scrollt mit sein.",
"navigation_multiple": "WARNUNG: havelseite/einstellungen.md: Navigation hat mehrere Optionen angekreuzt ({selected}). Ich verwende die erste: {first}.",
"logo_align": "FEHLER: Einstellungen: Wenn das Logo angezeigt wird, muss links oder rechts angekreuzt sein.",
"logo_align_center": "FEHLER: Einstellungen: Das Logo kann nur links oder rechts stehen.",
"logo_missing": "FEHLER: Logo nicht gefunden: havelseite/medien/{folder}/{src}",
"logo_folder_missing": "FEHLER: Logoordner nicht gefunden oder leer: havelseite/medien/{folder}",
"logo_multiple": "WARNUNG: havelseite/einstellungen.md: Im Logoordner liegen mehrere Bilder ({files}). Ich verwende alphabetisch das erste: {first}.",
"gallery_layout": "FEHLER: Einstellungen: Galerie muss quadratisch, breit oder kacheln sein.",
"icon_style": "FEHLER: Einstellungen: Icons muss rund, schlicht oder text sein.",
"font": "FEHLER: Einstellungen: Schriftgröße muss klein, mittel oder groß sein.",
"unknown_block": "FEHLER: {path}:{line}: @{annotation} kenne ich nicht.{hint}",
"did_you_mean": " Meintest du @{suggestion}?",
"gallery_missing": "FEHLER: {path}:{line}: Galerieordner fehlt: havelseite/medien/galerie/{parameter}.{hint}",
"image_missing": "FEHLER: {path}:{line}: Bild nicht gefunden: {src}.{hint}",
"internal_link_missing": "WARNUNG: {path}:{line}: Interner Link zeigt auf keine Seite: [[{label}]].{hint}",
"hero_missing": "FEHLER: {path}:{line}: Aufmacher-Datei nicht gefunden: {parameter}",
"event_single_multiple": "WARNUNG: {path}:{line}: @{annotation} sieht nach mehreren Veranstaltungen aus. Nutze dafuer @veranstaltungen.",
"events_list_single": "WARNUNG: {path}:{line}: @{annotation} ist fuer mehrere Veranstaltungen gedacht. Fuer eine einzelne Veranstaltung nutze @veranstaltung.",
"person_single_multiple": "WARNUNG: {path}:{line}: @{annotation} sieht nach mehreren Personen aus. Nutze dafuer @personen.",
"people_list_single": "WARNUNG: {path}:{line}: @{annotation} ist fuer mehrere Personen gedacht. Fuer eine einzelne Person nutze @person.",
"nested_page": "FEHLER: {path}: Seiten duerfen nur eine Ordnerebene tief liegen. Bitte verschiebe die Datei direkt nach havelseite/ oder in einen Ordner direkt unter havelseite/.",
"found_header": "Ich habe ein paar Dinge gefunden:\n",
"found_footer": "\nBitte korrigiere diese Punkte und pruefe danach nochmal.",
"ok": "Alles gut. Keine offensichtlichen Probleme gefunden."
},
"en": {
"json_error": "ERROR: {path} is not a valid JSON file.",
"json_position": " Line {line}, column {column}: {message}",
"navigation_height": "ERROR: generator/settings.json: navigation.height must be small, medium, or large.",
"navigation_align": "ERROR: Settings: navigation alignment must be left, center, or right.",
"mobile_menu_align": "ERROR: Settings: mobile menu must be auto, left, center, or right.",
"mobile_menu_submenus": "ERROR: Settings: mobile submenu behavior must be open or closed.",
"language": "ERROR: generator/settings.json: language must be de or en.",
"validator_language": "ERROR: generator/settings.json: validator_language must be de or en.",
"theme": "ERROR: Settings: theme must be a known color theme: {themes}.",
"theme_multiple": "WARNING: havelseite/einstellungen.md: Multiple color themes are checked ({selected}). I will use the first one: {first}.",
"navigation_position": "ERROR: Settings: navigation.position must be sticky or static.",
"navigation_multiple": "WARNING: havelseite/einstellungen.md: Navigation has multiple checked options ({selected}). I will use the first one: {first}.",
"logo_align": "ERROR: Settings: if the logo is shown, left or right must be checked.",
"logo_align_center": "ERROR: Settings: the logo can only be left or right.",
"logo_missing": "ERROR: Logo not found: havelseite/medien/{folder}/{src}",
"logo_folder_missing": "ERROR: Logo folder is missing or empty: havelseite/medien/{folder}",
"logo_multiple": "WARNING: havelseite/einstellungen.md: Multiple images are in the logo folder ({files}). I will use the alphabetically first one: {first}.",
"gallery_layout": "ERROR: Settings: gallery layout must be square, wide, or masonry.",
"icon_style": "ERROR: Settings: icons must be round, simple, or text.",
"font": "ERROR: Settings: font size must be small, medium, or large.",
"unknown_block": "ERROR: {path}:{line}: I do not know @{annotation}.{hint}",
"did_you_mean": " Did you mean @{suggestion}?",
"gallery_missing": "ERROR: {path}:{line}: Gallery folder is missing: havelseite/medien/galerie/{parameter}.{hint}",
"image_missing": "ERROR: {path}:{line}: Image not found: {src}.{hint}",
"internal_link_missing": "WARNING: {path}:{line}: Internal link does not point to a known page: [[{label}]].{hint}",
"hero_missing": "ERROR: {path}:{line}: Hero file not found: {parameter}",
"event_single_multiple": "WARNING: {path}:{line}: @{annotation} looks like multiple events. Use @events for that.",
"events_list_single": "WARNING: {path}:{line}: @{annotation} is meant for multiple events. Use @event for a single event.",
"person_single_multiple": "WARNING: {path}:{line}: @{annotation} looks like multiple people. Use @people for that.",
"people_list_single": "WARNING: {path}:{line}: @{annotation} is meant for multiple people. Use @person for one person.",
"nested_page": "ERROR: {path}: Pages may only be one folder deep. Please move this file directly to havelseite/ or into a folder directly below havelseite/.",
"found_header": "I found a few things:\n",
"found_footer": "\nPlease fix these points and check again.",
"ok": "All good. No obvious problems found."
}
}
def language_from_settings() -> str:
try:
settings = settings_config.load_settings()
except (json.JSONDecodeError, OSError):
return "de"
language = settings.get(
"validator_language",
settings.get("language", "de")
)
if language not in TRANSLATIONS:
return "de"
return language
def tr(language: str, key: str, **values) -> str:
return TRANSLATIONS[language][key].format(**values)
def load_json(
file_path: Path,
exit_on_error: bool = True
):
try:
with file_path.open(encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as error:
if not exit_on_error:
raise
language = "de"
print(tr(
language,
"json_error",
path=file_path.relative_to(ROOT_DIR)
))
print(tr(
language,
"json_position",
line=error.lineno,
column=error.colno,
message=error.msg
))
sys.exit(1)
def is_external_url(value: str) -> bool:
return (
value.startswith("http://")
or value.startswith("https://")
)
def is_footer_file(md_file: Path) -> bool:
relative = md_file.relative_to(CONTENT_DIR)
return (
len(relative.parts) >= 2
and relative.parts[0] in {FOOTER_DIR_NAME, LEGACY_FOOTER_DIR_NAME}
and md_file.name in {FOOTER_FILE_NAME, LEGACY_FOOTER_FILE_NAME}
)
def title_from_file(md_file: Path) -> str:
stem = display_name(md_file.stem)
if stem == "index":
if md_file.parent == CONTENT_DIR:
return "Start"
return display_name(md_file.parent.name).replace("-", " ").title()
return stem.replace("-", " ").title()
def display_name(name: str) -> str:
return re.sub(r"^\d+[_-]+", "", name)
def slugify_title(title: str) -> str:
slug = title.strip().lower()
slug = slug.replace("ä", "ae")
slug = slug.replace("ö", "oe")
slug = slug.replace("ü", "ue")
slug = slug.replace("ß", "ss")
slug = re.sub(r"[^a-z0-9]+", "-", slug)
return slug.strip("-")
def page_keys() -> set[str]:
keys = set()
for md_file in CONTENT_DIR.rglob("*.md"):
if is_footer_file(md_file):
continue
if md_file == SETTINGS_MARKDOWN_FILE:
continue
keys.add(md_file.stem.lower())
keys.add(display_name(md_file.stem).lower())
keys.add(slugify_title(title_from_file(md_file)))
return keys
def page_suggestions() -> list[str]:
suggestions = []
for md_file in CONTENT_DIR.rglob("*.md"):
if is_footer_file(md_file):
continue
if md_file == SETTINGS_MARKDOWN_FILE:
continue
suggestions.append(title_from_file(md_file))
suggestions.append(display_name(md_file.stem))
return sorted(set(suggestions))
def gallery_suggestion(parameter: str) -> str:
gallery_dir = CONTENT_DIR / MEDIA_DIR_NAME / GALLERY_DIR_NAME
if not gallery_dir.exists():
gallery_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME / LEGACY_GALLERY_DIR_NAME
if not gallery_dir.exists():
return ""
candidates = [
path.name for path in gallery_dir.iterdir()
if path.is_dir()
]
suggestion = difflib.get_close_matches(parameter, candidates, n=1)
if not suggestion:
return ""
return f" Meintest du {suggestion[0]}?"
def media_suggestion(src: str) -> str:
media_dir = CONTENT_DIR / MEDIA_DIR_NAME
if not media_dir.exists():
media_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME
if not media_dir.exists():
return ""
file_name = Path(src).name
candidates = [
path.name for path in media_dir.rglob("*")
if path.is_file()
]
suggestion = difflib.get_close_matches(file_name, candidates, n=1)
if not suggestion:
return ""
matches = [
path for path in media_dir.rglob(suggestion[0])
if path.is_file()
]
if not matches:
return f" Meintest du {suggestion[0]}?"
try:
suggested_path = matches[0].relative_to(CONTENT_DIR).as_posix()
except ValueError:
suggested_path = matches[0].name
return f" Meintest du {suggested_path}?"
def content_relative_from_src(src: str, source_file: Path) -> Path:
src = src.strip().strip("/")
variants = [src]
if src.startswith(f"{LEGACY_MEDIA_DIR_NAME}/"):
variants.append(
f"{MEDIA_DIR_NAME}/{src.removeprefix(f'{LEGACY_MEDIA_DIR_NAME}/')}"
)
variants = [
variant.replace(
f"{MEDIA_DIR_NAME}/{LEGACY_GALLERY_DIR_NAME}/",
f"{MEDIA_DIR_NAME}/{GALLERY_DIR_NAME}/"
)
for variant in variants
]
candidates = []
for variant in dict.fromkeys(variants):
candidates.extend([
source_file.parent / variant,
CONTENT_DIR / variant
])
for candidate in candidates:
if candidate.exists():
return candidate
assets_dir = CONTENT_DIR / MEDIA_DIR_NAME
if not assets_dir.exists():
assets_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME
if assets_dir.exists():
matches = sorted([
path for path in assets_dir.rglob(Path(src).name)
if path.is_file()
])
if matches:
return matches[0]
return CONTENT_DIR / src
def line_number(text: str, offset: int) -> int:
return text.count("\n", 0, offset) + 1
def annotated_blocks(text: str) -> list[dict]:
matches = list(re.finditer(r"(?m)^@([^\s:]+)(?::([^\n]+))?", text))
blocks = []
for index, match in enumerate(matches):
content_start = match.end()
content_end = (
matches[index + 1].start()
if index + 1 < len(matches)
else len(text)
)
blocks.append({
"annotation": match.group(1).strip(),
"parameter": (match.group(2) or "").strip(),
"content": text[content_start:content_end].strip(),
"line": line_number(text, match.start())
})
return blocks
def validate_settings(
messages: list[str],
language: str
):
settings = settings_config.load_settings()
selected_settings = {}
if SETTINGS_MARKDOWN_FILE.exists():
selected_settings = settings_config.selected_by_section(
SETTINGS_MARKDOWN_FILE.read_text(encoding="utf-8")
)
if settings.get("language", "de") not in {"de", "en"}:
messages.append(tr(language, "language"))
if settings.get("validator_language", "de") not in {"de", "en"}:
messages.append(tr(language, "validator_language"))
if settings.get("theme", "havel") not in settings_config.COLOR_THEMES:
messages.append(tr(
language,
"theme",
themes=", ".join(settings_config.COLOR_THEMES.keys())
))
selected_themes = selected_settings.get("theme", [])
if len(selected_themes) > 1:
messages.append(tr(
language,
"theme_multiple",
selected=", ".join(selected_themes),
first=selected_themes[0]
))
navigation_height = (
settings
.get("navigation", {})
.get("height", "medium")
)
if navigation_height not in {"small", "medium", "large"}:
messages.append(tr(language, "navigation_height"))
navigation_position = (
settings
.get("navigation", {})
.get("position", "sticky")
)
if navigation_position not in {"sticky", "static"}:
messages.append(tr(language, "navigation_position"))
navigation_align = (
settings
.get("navigation", {})
.get("align", "center")
)
if navigation_align not in {"left", "center", "right"}:
messages.append(tr(language, "navigation_align"))
mobile_menu_align = (
settings
.get("mobile_menu", {})
.get("align", "auto")
)
if mobile_menu_align not in {"auto", "left", "center", "right"}:
messages.append(tr(language, "mobile_menu_align"))
mobile_menu_submenus = (
settings
.get("mobile_menu", {})
.get("submenus", "open")
)
if mobile_menu_submenus not in {"open", "closed"}:
messages.append(tr(language, "mobile_menu_submenus"))
selected_navigation = selected_settings.get("navigation", [])
selected_navigation_heights = [
value for value in selected_navigation
if settings_config.normalize_choice(
value,
settings_config.SIZE_ALIASES
) in {"small", "medium", "large"}
]
selected_navigation_positions = [
value for value in selected_navigation
if settings_config.normalize_choice(
value,
settings_config.POSITION_ALIASES
) in {"sticky", "static"}
]
selected_navigation_aligns = [
value for value in selected_navigation
if settings_config.normalize_choice(
value,
settings_config.ALIGN_ALIASES
) in {"left", "center", "right"}
]
if len(selected_navigation_heights) > 1:
messages.append(tr(
language,
"navigation_multiple",
selected=", ".join(selected_navigation_heights),
first=selected_navigation_heights[0]
))
if len(selected_navigation_positions) > 1:
messages.append(tr(
language,
"navigation_multiple",
selected=", ".join(selected_navigation_positions),
first=selected_navigation_positions[0]
))
if len(selected_navigation_aligns) > 1:
messages.append(tr(
language,
"navigation_multiple",
selected=", ".join(selected_navigation_aligns),
first=selected_navigation_aligns[0]
))
logo = settings.get("logo", {})
if logo.get("enabled", False):
align = logo.get("align", "left")
selected_logo = selected_settings.get("logo", [])
selected_logo_aligns = [
value for value in selected_logo
if settings_config.normalize_choice(
value,
settings_config.ALIGN_ALIASES
) in {"left", "right"}
]
if not selected_logo_aligns:
messages.append(tr(language, "logo_align"))
elif align not in {"left", "center", "right"}:
messages.append(tr(language, "logo_align"))
elif align == "center":
messages.append(tr(language, "logo_align_center"))
src = logo.get("src", "").strip()
folder = logo.get("folder", "logo").strip().strip("/") or "logo"
if src:
logo_file = CONTENT_DIR / MEDIA_DIR_NAME / folder / src
if not logo_file.exists():
logo_file = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME / folder / src
if not logo_file.exists():
messages.append(tr(
language,
"logo_missing",
folder=folder,
src=src
))
else:
logo_dir = CONTENT_DIR / MEDIA_DIR_NAME / folder
if not logo_dir.exists():
logo_dir = CONTENT_DIR / LEGACY_MEDIA_DIR_NAME / folder
logo_files = sorted([
file.name for file in logo_dir.iterdir()
if file.is_file() and file.suffix.lower() in IMAGE_EXTENSIONS
]) if logo_dir.exists() else []
if not logo_files:
messages.append(tr(
language,
"logo_folder_missing",
folder=folder
))
elif len(logo_files) > 1:
messages.append(tr(
language,
"logo_multiple",
files=", ".join(logo_files),
first=logo_files[0]
))
gallery_layout = (
settings
.get("gallery", {})
.get("layout", "square")
)
if gallery_layout not in {"square", "wide", "masonry"}:
messages.append(tr(language, "gallery_layout"))
icon_style = (
settings
.get("icons", {})
.get("style", "round")
)
if icon_style not in {"round", "simple", "text"}:
messages.append(tr(language, "icon_style"))
font_size = (
settings
.get("typography", {})
.get("size", "medium")
)
if font_size not in {"small", "medium", "large"}:
messages.append(tr(language, "font"))
def validate_markdown_files(
messages: list[str],
language: str
):
components = load_json(COMPONENTS_FILE)
known_annotations = set(components.keys())
known_pages = page_keys()
known_page_suggestions = page_suggestions()
for md_file in sorted(CONTENT_DIR.rglob("*.md")):
if md_file == SETTINGS_MARKDOWN_FILE:
continue
if len(md_file.relative_to(CONTENT_DIR).parts) > 2:
messages.append(tr(
language,
"nested_page",
path=md_file.relative_to(ROOT_DIR)
))
continue
text = md_file.read_text(encoding="utf-8")
display_path = md_file.relative_to(ROOT_DIR)
for match in re.finditer(r"(?m)^@([^\s:]+)(?::([^\n]+))?", text):
annotation = match.group(1).strip()
parameter = (match.group(2) or "").strip()
line = line_number(text, match.start())
if annotation not in known_annotations:
suggestion = difflib.get_close_matches(
annotation,
known_annotations,
n=1
)
hint = (
tr(
language,
"did_you_mean",
suggestion=suggestion[0]
)
if suggestion
else ""
)
messages.append(tr(
language,
"unknown_block",
path=display_path,
line=line,
annotation=annotation,
hint=hint
))
component = components.get(annotation, annotation)
if component == "gallery" and parameter:
gallery_dir = CONTENT_DIR / MEDIA_DIR_NAME / GALLERY_DIR_NAME / parameter
if not gallery_dir.exists():
gallery_dir = (
CONTENT_DIR
/ LEGACY_MEDIA_DIR_NAME
/ LEGACY_GALLERY_DIR_NAME
/ parameter
)
if not gallery_dir.exists():
messages.append(tr(
language,
"gallery_missing",
path=display_path,
line=line,
parameter=parameter,
hint=gallery_suggestion(parameter)
))
if component == "hero" and parameter:
validate_media_parameter(
messages,
display_path,
line,
parameter,
md_file,
language
)
for block in annotated_blocks(text):
component = components.get(block["annotation"], block["annotation"])
h2_count = len(re.findall(r"(?m)^##\s+", block["content"]))
if component == "event" and h2_count > 1:
messages.append(tr(
language,
"event_single_multiple",
path=display_path,
line=block["line"],
annotation=block["annotation"]
))
if component == "events" and h2_count < 2:
messages.append(tr(
language,
"events_list_single",
path=display_path,
line=block["line"],
annotation=block["annotation"]
))
if component == "person" and h2_count > 1:
messages.append(tr(
language,
"person_single_multiple",
path=display_path,
line=block["line"],
annotation=block["annotation"]
))
if component == "people" and h2_count < 2:
messages.append(tr(
language,
"people_list_single",
path=display_path,
line=block["line"],
annotation=block["annotation"]
))
for match in re.finditer(r"!\[(.*?)\]\((.*?)\)", text):
src = match.group(2).strip()
if is_external_url(src):
continue
file_path = content_relative_from_src(src, md_file)
if not file_path.exists():
messages.append(tr(
language,
"image_missing",
path=display_path,
line=line_number(text, match.start()),
src=src,
hint=media_suggestion(src)
))
for match in re.finditer(r"\[\[(.*?)\]\]", text):
label = match.group(1).strip()
key = slugify_title(label)
if key not in known_pages:
suggestion = difflib.get_close_matches(
label,
known_page_suggestions,
n=1
)
hint = (
tr(
language,
"did_you_mean",
suggestion=suggestion[0]
)
if suggestion
else ""
)
messages.append(tr(
language,
"internal_link_missing",
path=display_path,
line=line_number(text, match.start()),
label=label,
hint=hint
))
def validate_media_parameter(
messages: list[str],
display_path: Path,
line: int,
parameter: str,
md_file: Path,
language: str
):
parsed = urlparse(parameter)
suffix = Path(parsed.path).suffix.lower()
if is_external_url(parameter):
return
if suffix in IMAGE_EXTENSIONS or suffix in VIDEO_EXTENSIONS:
file_path = content_relative_from_src(parameter, md_file)
if not file_path.exists():
messages.append(tr(
language,
"hero_missing",
path=display_path,
line=line,
parameter=parameter
))
def collect_messages() -> tuple[list[str], str]:
messages: list[str] = []
settings_config.format_settings_file()
language = language_from_settings()
validate_settings(messages, language)
validate_markdown_files(messages, language)
return messages, language
def main() -> int:
messages, language = collect_messages()
if messages:
print(tr(language, "found_header"))
for message in messages:
print(f"- {message}")
print(tr(language, "found_footer"))
return 1 if has_errors(messages) else 0
print(tr(language, "ok"))
return 0
def has_errors(messages: list[str]) -> bool:
return any(
not (
message.startswith("WARNUNG:")
or message.startswith("WARNING:")
)
for message in messages
)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,12 @@
@galerie:Vereinsgelände
# Unser Verein
@bild-text
![Jollenwiese](../medien/galerie/Vereinsgelände/csm_db_jollenwiese7_01_a93eaeac9a.jpg)
# Unsere Jollenwiese
Hier steht der Text rechts neben dem Bild.

View 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
![Steganlage](medien/galerie/Vereinsgelände/csm_db_blick_auf_steganlage7_01_77c4c1781b.jpg)
![Anker](medien/galerie/Vereinsgelände/csm_db_anker7_801025207d.jpg)
![Jollenwiese](medien/galerie/Vereinsgelände/csm_db_jollenwiese7_01_a93eaeac9a.jpg)
@galerie:Vereinsgelände
# Automatische Galerie aus einem Ordner
Alle Bilder kommen aus `havelseite/medien/galerie/Vereinsgelände/`.
@zitat
"Eine gute Vereinsseite muss nicht kompliziert sein."
@zeitstrahl
# Zeitstrahl
## Januar
Planung der Saison.
## April
Boote ins Wasser.
## September
Regatten und Vereinsleben.
@fragen
# Fragen und Antworten
## Wie bearbeite ich eine Seite?
Du änderst einfach die passende Markdown-Datei im Ordner `havelseite/`.
## Wo lege ich Bilder ab?
Bilder liegen unter `havelseite/medien/`.
@kontakt
# Kontakt
Musterverein Brandenburg
Am Beetzsee 1
14770 Brandenburg an der Havel
info@example.de
@ort:Am Beetzsee 1, 14770 Brandenburg an der Havel
# Ort und Anfahrt
Der Ortsblock zeigt eine Adresse und kann OpenStreetMap datenschutzfreundlich nachladen.
@bild-text
![Steg](medien/galerie/Vereinsgelände/csm_db_15er_steg27_01_41d2098b8f.jpg)
# Bild und Text
Dieser Bereich stellt ein Bild neben einen Text. Das eignet sich gut fuer Vereinsgelände, Boote, Teams oder Angebote.
@veranstaltung
# Einzelne Veranstaltung
Datum: 12. Juli
Uhrzeit: 15:00 Uhr
Ort: Vereinsgelände
Anmeldung: [Zur Anmeldung](https://example.com)
![Sommerfest](medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg)
Eine einzelne Veranstaltungsbox fuer einen wichtigen Termin.
@veranstaltungen
# Mehrere Veranstaltungen
## Sommerfest
Datum: 12. Juli
Uhrzeit: 15:00 Uhr
Ort: Vereinsgelände
![Sommerfest](medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg)
Gemeinsames Sommerfest mit Kuchen, Grill und kleinen Bootsrunden.
## Absegeln
Datum: 20. September
Uhrzeit: 10:00 Uhr
Ort: Vereinsgelände
Gemeinsamer Saisonabschluss auf dem Wasser.
@dateien
# Dateien und Downloads
[Ausschreibung als externer Link](https://example.com/ausschreibung.pdf)
[Anmeldung als externer Link](https://example.com/anmeldung.pdf)
@person
# Einzelne Person
Max Mustermann
Vorsitzender
max@example.de
@personen
# Personen
## Max Mustermann
Vorsitzender
max@example.de
## Erika Beispiel
Jugendwartin
erika@example.de
@sponsoren
# Sponsoren und Partner
![Märkischer Seglerverein Beetzsee](medien/logo/msvb_logo.png)

View 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

View File

@ -0,0 +1,3 @@
@ort:Schienenweg 49, 14772 Brandenburg an der Havel, Deutschland
# Anfahrt

View File

@ -0,0 +1,4 @@
@text
# Datenschutz
Hier stehen die Datenschutzhinweise.

View 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]]

View File

@ -0,0 +1,9 @@
@text
# Impressum
Angaben gemäß § 5 TMG.
Märkischer Segelverein Beetzsee
Schienenweg 49
14772 Brandenburg an der Havel

View 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.

View File

@ -0,0 +1,4 @@
@aufmacher:#0f766e
# Aufmacher Farbe
Dieser Aufmacher nutzt einen Farbwert als Hintergrund.

View File

@ -0,0 +1,4 @@
@aufmacher
# Aufmacher Standard
Dieser Aufmacher nutzt keinen Parameter und bleibt deshalb bei der normalen hellen Standarddarstellung.

View 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.

View 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
View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

0
havelseite/sonstiges.md Normal file
View File

View 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
![Steganlage](medien/galerie/Vereinsgelände/csm_db_blick_auf_steganlage7_01_77c4c1781b.jpg)
![Grillhütte](medien/galerie/Vereinsgelände/csm_db_blick_auf_grillhuette7_01_b8c05a4d5b.jpg)
![Jollenwiese](medien/galerie/Vereinsgelände/csm_db_jollenwiese7_01_a93eaeac9a.jpg)
@zitat
> Ein Zitatblock kann als ruhiger Trenner zwischen Inhaltsbereichen dienen.
@bild-text
![Steg](medien/galerie/Vereinsgelände/csm_db_15er_steg27_01_41d2098b8f.jpg)
# Bild und Text
Das erste Markdown-Bild im Block wird links angezeigt, der restliche Text daneben.
@kontakt
# Kontakt
Musterverein Brandenburg
Am Beetzsee 1
info@example.de
@ort:Am Beetzsee 1, 14770 Brandenburg an der Havel
# Ort und Anfahrt
Hier kann eine Adresse mit OpenStreetMap verknüpft werden.
@veranstaltung
# Sommerfest
Datum: 12. Juli
Uhrzeit: 15:00 Uhr
Ort: Vereinsgelände
Anmeldung: info@example.de
Gemeinsames Sommerfest mit Kuchen, Grill und kleinen Bootsrunden.
@veranstaltungen
# Weitere Termine
## Absegeln
Datum: 20. September
Uhrzeit: 10:00 Uhr
Ort: Vereinsgelände
Gemeinsamer Saisonabschluss auf dem Wasser.
## Winterlager
Datum: 1. November
Uhrzeit: 9:00 Uhr
Ort: Bootshaus
Boote gemeinsam einlagern und danach Kaffee trinken.
@dateien
# Downloads
[Ausschreibung Kon-Tiki](https://example.com/ausschreibung.pdf)
[Anmeldeformular](https://example.com/anmeldung.pdf)
@personen
# Team
## Max Mustermann
Vorsitzender
max@example.de
## Erika Beispiel
Jugendwartin
erika@example.de
@sponsoren
# Partner und Sponsoren
![Märkischer Seglerverein Beetzsee](medien/logo/msvb_logo.png)

0
havelseite/verein.md Normal file
View File

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
markdown
jinja2