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

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