Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren
Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb
der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine
Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort
die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl
in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im
Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen.
h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der
--fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase
unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar.
Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit
Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses
Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente
gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts.
Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten-
Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()-
Rampen für Karten-Überschriften (bento__heading, bento__stat-value,
group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name
wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich.
Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und
11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser,
folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen
drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest.
Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf
vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em.
Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von
tokens.css.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
< ? php
declare ( strict_types = 1 );
/**
* Deploy - Selbsttest — NUR CLI . Prüft alles , was nach dem Hochladen stimmen muss ,
* bevor die Seite Besucher sieht : Konfiguration , Secrets , Schreibrechte ,
* Composer - Abhängigkeiten , PHP - Version , Datenstände .
*
* Prüft NICHT den SMTP - Login ( Netzwerkzugriff ) — dafür : php bin / smtp - test . php
*
* Aufruf : php bin / preflight . php ( Exit 0 = startklar , 1 = Fehler offen )
*/
if ( PHP_SAPI !== 'cli' ) {
exit ( 1 );
}
require dirname ( __DIR__ ) . '/app/bootstrap.php' ;
$errors = [];
$warnings = [];
// --- Konfiguration & Secrets (dieselbe Prüfung, die auch die Formular-Actions macht) ---
foreach ( config_problems () as $problem ) {
$errors [] = $problem ;
}
if (( string ) config ( 'env' ) !== 'production' ) {
$errors [] = " env steht auf ' " . ( string ) config ( 'env' ) . " ' — in Produktion 'production' (sonst zeigt die Seite PHP-Fehler). " ;
}
$baseUrl = ( string ) config ( 'base_url' );
if ( ! str_starts_with ( $baseUrl , 'https://' )) {
$errors [] = " base_url ist nicht https ( { $baseUrl } ) — Canonical, OG und Sitemap wären falsch. " ;
}
if ( str_ends_with ( $baseUrl , '/' )) {
$warnings [] = 'base_url endet auf "/" — abs_url() erzeugt dann doppelte Slashes.' ;
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.
Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.
News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.
Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
(Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.
Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
// base_url MUSS der Host sein, den public/.htaccess erzwingt. Läuft es
// auseinander, zeigen Canonical, OG und Sitemap auf Adressen, die der Server
// sofort per 301 weiterleitet — und gedruckte QR-Codes bekommen einen
// unnötigen Umweg eingebacken. Genau dieser Fehler stand schon einmal drin
// (base_url ohne www, .htaccess mit).
$htaccess = PUBLIC_PATH . '/.htaccess' ;
if ( is_file ( $htaccess )
&& preg_match ( '~RewriteRule \^ https://([a-z0-9.-]+)%\{REQUEST_URI\}~i' , ( string ) file_get_contents ( $htaccess ), $forced ) === 1 ) {
$forcedHost = strtolower ( $forced [ 1 ]);
$configHost = strtolower (( string ) parse_url ( $baseUrl , PHP_URL_HOST ));
if ( $configHost !== '' && $configHost !== $forcedHost && ! str_contains ( $configHost , 'localhost' )) {
$errors [] = " base_url nutzt ' { $configHost } ', public/.htaccess leitet aber alles auf ' { $forcedHost } ' um — Canonical, OG und Sitemap zeigten auf weitergeleitete Adressen. " ;
}
}
Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren
Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb
der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine
Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort
die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl
in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im
Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen.
h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der
--fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase
unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar.
Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit
Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses
Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente
gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts.
Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten-
Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()-
Rampen für Karten-Überschriften (bento__heading, bento__stat-value,
group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name
wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich.
Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und
11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser,
folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen
drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest.
Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf
vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em.
Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von
tokens.css.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
// --- Abhängigkeiten ---
if ( ! is_file ( ROOT_PATH . '/vendor/autoload.php' )) {
$errors [] = 'vendor/ fehlt — auf dem Server "composer install --no-dev --optimize-autoloader" laufen lassen.' ;
} elseif ( ! class_exists ( PHPMailer\PHPMailer\PHPMailer :: class )) {
$errors [] = 'PHPMailer ist nicht ladbar — vendor/ unvollständig?' ;
}
if ( version_compare ( PHP_VERSION , '8.1' , '<' )) {
$errors [] = 'PHP ' . PHP_VERSION . ' ist zu alt — mindestens 8.1 (der Code nutzt never-Return-Types und enums-Syntax).' ;
}
// --- Schreibrechte (Logs, Rate-Limit-Zähler, Sync-Caches) ---
foreach ([ 'logs' , 'ratelimit' , 'cache' ] as $dir ) {
$path = STORAGE_PATH . '/' . $dir ;
if ( ! is_dir ( $path )) {
$errors [] = " storage/ { $dir } / fehlt. " ;
} elseif ( ! is_writable ( $path )) {
$errors [] = " storage/ { $dir } / ist nicht beschreibbar (Rate-Limiting und Logging fielen sonst still aus). " ;
}
}
foreach ([ 'img/instagram' , 'img/crests' ] as $dir ) {
$path = PUBLIC_PATH . '/assets/' . $dir ;
if ( ! is_dir ( $path ) || ! is_writable ( $path )) {
$warnings [] = " public/assets/ { $dir } / fehlt oder ist nicht beschreibbar — der Sync kann dort keine Bilder ablegen. " ;
}
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.
Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.
News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.
Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
(Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.
Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
// --- Dateiablage /dateien (Files Gallery) ---
// Die App muss in ihr Upload- und Storage-Verzeichnis schreiben können, sonst bricht
// schon der erste Aufruf ab (sie legt Cache-Verzeichnisse selbst an).
foreach ([ 'uploads' , 'system' ] as $dir ) {
$path = STORAGE_PATH . '/dateien/' . $dir ;
if ( ! is_dir ( $path )) {
$errors [] = " storage/dateien/ { $dir } / fehlt — anlegen und beschreibbar machen. " ;
} elseif ( ! is_writable ( $path )) {
$errors [] = " storage/dateien/ { $dir } / ist nicht beschreibbar — Uploads und Thumbnails scheitern. " ;
}
}
// Ohne echten Hash ist der Zugang gesperrt (fail closed) — das soll auffallen, bevor
// jemand vor dem Login-Formular steht und sich wundert.
if ( config ( 'dateien' ) === null ) {
$errors [] = " Der Block 'dateien' fehlt in config/config.php — die Dateiablage hätte keine Zugänge (siehe config.example.php). " ;
} else {
foreach ([ 'verein' , 'admin' ] as $user ) {
$hash = ( string ) config ( " dateien.users. { $user } .password_hash " , '' );
if ( $hash === '' || $hash === 'CHANGE_ME' ) {
$errors [] = " dateien.users. { $user } .password_hash fehlt — dieser Zugang ist gesperrt. " ;
} elseif ( password_get_info ( $hash )[ 'algoName' ] === 'unknown' ) {
$warnings [] = " dateien.users. { $user } .password_hash sieht wie Klartext aus — besser hashen: php -r \" echo password_hash('…', PASSWORD_DEFAULT); \" " ;
}
}
if ( in_array (( string ) config ( 'dateien.license_key' , '' ), [ '' , 'CHANGE_ME' ], true )) {
$warnings [] = 'dateien.license_key fehlt — die Dateiablage läuft, zeigt aber den Hinweis der unlizenzierten Version.' ;
}
}
// Der Patch und die self-hosted Assets entscheiden darüber, ob die App überhaupt
// mit Login startet und ob sie ohne CDN auskommt. Details: bin/filesgallery-update.php
$galleryApp = PUBLIC_PATH . '/dateien/index.php' ;
if ( ! is_file ( $galleryApp )) {
$errors [] = 'public/dateien/index.php fehlt — die Dateiablage ist nicht ausgeliefert.' ;
} else {
$source = ( string ) file_get_contents ( $galleryApp );
if ( ! str_contains ( $source , " \$ localconfigpath = __DIR__ . '/_filesconfig.php' " )) {
$errors [] = 'public/dateien/index.php ist ungepatcht — die App würde OHNE Login starten. php bin/filesgallery-update.php' ;
}
// Wer Einstellungen direkt in die vendorte Datei schreibt, verliert sie beim nächsten
// Update — und Secrets hätten dort ohnehin nichts zu suchen (Regel 5). Die App warnt
// selbst davor (index.php:33), also fangen wir es hier ab.
if ( preg_match ( '/^\s*\'(license_key|username|password)\' => \'.+\',/m' , $source , $edited ) === 1 ) {
$errors [] = " public/dateien/index.php wurde direkt editiert (' { $edited [ 1 ] } ') — Wert nach config/config.php (dateien.*) verschieben und die Zeile leeren, sonst ist er nach dem nächsten Update weg. " ;
}
$version = preg_match ( '/public static \$version = \'([0-9][^\']*)\'/' , $source , $m ) === 1 ? $m [ 1 ] : '?' ;
if ( ! is_file ( PUBLIC_PATH . " /assets/filesgallery/files.photo.gallery@ { $version } /css/files.css " )) {
$errors [] = " Assets der Dateiablage fehlen (Version { $version } ) — sonst lädt sie vom CDN und die CSP blockt alles: php bin/filesgallery-update.php --assets " ;
}
}
// Es gilt immer die kleinere von zwei Grenzen: unsere aus _filesconfig.php und die von
// PHP. Steht PHP niedriger, wird jedes größere Video abgewiesen, ohne dass man es
// unserer Konfiguration ansehen könnte — deshalb hier vergleichen, nicht nur ausgeben.
$phpUpload = ini_parse_quantity (( string ) ini_get ( 'upload_max_filesize' ));
$phpPost = ini_parse_quantity (( string ) ini_get ( 'post_max_size' ));
// Bewusst gelesen und nicht ausgeführt: _filesconfig.php setzt display_errors und
// Session-Parameter — Nebenwirkungen, die in diesem Skript nichts zu suchen haben.
$ourMax = 0 ;
$galleryConfigFile = PUBLIC_PATH . '/dateien/_filesconfig.php' ;
if ( is_file ( $galleryConfigFile )
&& preg_match ( " /'upload_max_filesize' \ s*=> \ s*( \ d+)/ " , ( string ) file_get_contents ( $galleryConfigFile ), $um ) === 1 ) {
$ourMax = ( int ) $um [ 1 ];
}
$mb = static fn ( int | float $bytes ) : string => round ( $bytes / 1048576 ) . ' MB' ;
echo 'INFO Uploadgrenze: ' . ( $ourMax > 0 ? $mb ( $ourMax ) : 'unbekannt' )
. " laut _filesconfig.php, diese PHP-CLI erlaubt { $mb ( $phpUpload ) } (post_max_size { $mb ( $phpPost ) } ) \n " ;
if ( $ourMax > 0 && $phpUpload < $ourMax ) {
// Achtung: das hier ist die CLI-Konfiguration. Für Uploads zählt die des Webservers
// (FPM + .user.ini), die abweichen darf — deshalb Warnung und nicht Fehler.
$warnings [] = " Diese PHP-CLI deckelt Uploads auf { $mb ( $phpUpload ) } , unsere Config erlaubt { $mb ( $ourMax ) } . Für Uploads zählt aber die Webserver-Konfiguration: public/dateien/.user.ini setzt { $mb ( $ourMax ) } . Verbindlich prüfen mit einem echten großen Upload über /dateien (oder phpinfo() im Browser). " ;
}
if ( $phpPost <= $phpUpload ) {
$warnings [] = " post_max_size ( { $mb ( $phpPost ) } ) ist nicht größer als upload_max_filesize ( { $mb ( $phpUpload ) } ) — Uploads am oberen Rand scheitern dann ohne Fehlermeldung. " ;
}
Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren
Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb
der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine
Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort
die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl
in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im
Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen.
h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der
--fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase
unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar.
Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit
Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses
Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente
gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts.
Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten-
Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()-
Rampen für Karten-Überschriften (bento__heading, bento__stat-value,
group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name
wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich.
Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und
11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser,
folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen
drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest.
Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf
vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em.
Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von
tokens.css.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
// --- Datenstände der Cron-Skripte (fehlen nur beim Erstlauf) ---
foreach ([ 'instagram' => 'bin/instagram-sync.php' , 'matchcenter' => 'bin/matchcenter-sync.php' ] as $name => $script ) {
$file = DATA_PATH . '/' . $name . '.json' ;
if ( ! is_file ( $file )) {
$warnings [] = " data/ { $name } .json fehlt — einmal \" php { $script } \" von Hand anstoßen. " ;
} elseif ( filemtime ( $file ) < time () - 3 * 86400 ) {
$warnings [] = " data/ { $name } .json ist älter als 3 Tage — läuft der Cron für { $script } ? " ;
}
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.
Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.
News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.
Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
(Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.
Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
// --- Termin-Quellen: events.json vs. veranstaltungen.json ---
// Ein Fest gehört in genau EINE der beiden Dateien (siehe CLAUDE.md): ohne
// eigene Seite in events.json, mit eigener Seite in veranstaltungen.json.
// Steht es in beiden, taucht es doppelt in der Termine-Liste auf und /termine
// baut zwei Event-Knoten für dasselbe Fest — beides fällt im Alltag kaum auf,
// deshalb prüft es die Maschine.
$eventSlugs = array_filter ( array_column ( json_load ( 'events' )[ 'events' ] ? ? [], 'slug' ));
$veranstaltungRows = json_load ( 'veranstaltungen' )[ 'veranstaltungen' ] ? ? [];
$veranstaltungSlugs = array_column ( $veranstaltungRows , 'slug' );
$doppelt = array_intersect ( $eventSlugs , $veranstaltungSlugs );
if ( $doppelt !== []) {
$errors [] = 'Diese Slugs stehen in data/events.json UND data/veranstaltungen.json: '
. implode ( ', ' , $doppelt ) . ' — der Termin erscheint doppelt. Eintrag in events.json löschen.' ;
}
if ( count ( $veranstaltungSlugs ) !== count ( array_unique ( $veranstaltungSlugs ))) {
$errors [] = 'data/veranstaltungen.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.' ;
}
foreach ( $veranstaltungRows as $i => $row ) {
if ( empty ( $row [ 'slug' ]) || empty ( $row [ 'title' ]) || empty ( $row [ 'date_start' ])) {
$errors [] = " data/veranstaltungen.json, Eintrag # { $i } : slug, title und date_start sind Pflicht. " ;
}
}
// --- Formular-Empfänger auf eigener Domain ---
// app/pages/datenschutz.php sagt zu, dass wir "keinen zusätzlichen
// E-Mail-Dienstleister" einsetzen. Das stimmt nur, solange jeder Empfänger ein
// Postfach auf unserer Domain bei All-Inkl ist. Zeigt club.json auf einen
// fremden Anbieter (die Turnabteilung lief früher auf Gmail), muss die
// Datenschutzerklärung mit — und das fällt von allein nie auf.
$clubMails = array_filter (
json_load ( 'club' ),
static fn ( $v , string $k ) : bool => str_starts_with ( $k , 'email' ) && is_string ( $v ) && $v !== '' ,
ARRAY_FILTER_USE_BOTH
);
foreach ( $clubMails as $key => $address ) {
if ( ! str_ends_with ( strtolower (( string ) $address ), '@tsv08kulmbach.de' )) {
$warnings [] = " club.json → { $key } ( { $address } ) liegt nicht auf tsv08kulmbach.de. "
. ' Formular-Mails an diese Adresse verlassen die All-Inkl-Auftragsverarbeitung —'
. ' dann muss app/pages/datenschutz.php den Anbieter nennen.' ;
}
}
// --- News (data/news.json) ---
// Meldungen mit `artikel` haben eine eigene Seite /news/<slug>. Die Fehler, die
// dabei still ins Nichts laufen, prüft die Maschine: ein Artikel ohne Slug ist
// nicht erreichbar, ein doppelter Slug verdeckt den zweiten Artikel, ein
// unbekannter Blocktyp verschluckt seinen Inhalt beim Rendern.
$newsRows = json_load ( 'news' )[ 'news' ] ? ? [];
$newsSlugs = array_values ( array_filter ( array_column ( $newsRows , 'slug' )));
$newsBlockTypen = [ 'text' , 'zwischentitel' , 'bild' , 'zitat' ];
if ( count ( $newsSlugs ) !== count ( array_unique ( $newsSlugs ))) {
$errors [] = 'data/news.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.' ;
}
// Kollision mit einem exakten Routen-Slug: der Prefix-Match für 'news/' läuft in
// public/index.php VOR dem routes.php-Lookup. Ein künftiger Eintrag wie
// 'news/archiv' wäre also von der Detailseite verdeckt, und die Ursache dafür
// sieht man an der weißen Seite nicht.
$routeSlugs = array_keys ( require APP_PATH . '/routes.php' );
$verdeckt = array_intersect ( array_map ( static fn ( string $s ) : string => 'news/' . $s , $newsSlugs ), $routeSlugs );
if ( $verdeckt !== []) {
$errors [] = 'Diese News-Slugs verdecken einen Routen-Eintrag: ' . implode ( ', ' , $verdeckt )
. ' — der Prefix-Match greift vor routes.php. Slug der Meldung ändern.' ;
}
$gleichnamig = array_intersect ( $newsSlugs , $veranstaltungSlugs );
if ( $gleichnamig !== []) {
$warnings [] = 'Diese Slugs stehen in data/news.json UND data/veranstaltungen.json: '
. implode ( ', ' , $gleichnamig ) . ' — die URLs kollidieren nicht (verschiedene Präfixe), aber es sind'
. ' wahrscheinlich zwei Seiten für dieselbe Sache.' ;
}
foreach ( $newsRows as $i => $row ) {
if ( empty ( $row [ 'title' ]) || empty ( $row [ 'date' ])) {
$errors [] = " data/news.json, Eintrag # { $i } : title und date sind Pflicht. " ;
}
if ( ! empty ( $row [ 'date' ]) && ! preg_match ( '/^\d{4}-\d{2}-\d{2}$/' , ( string ) $row [ 'date' ])) {
// Der Feed sortiert Datumsangaben als Strings und german_date() fällt
// still auf den Rohwert zurück — ein abweichendes Format sortiert also
// falsch, ohne dass etwas kaputt aussieht.
$errors [] = " data/news.json, Eintrag # { $i } : date muss YYYY-MM-DD sein. " ;
}
if ( empty ( $row [ 'artikel' ])) {
continue ; // Kurzmeldung ohne Detailseite — ein Slug ist hier erlaubt, aber nicht nötig.
}
if ( empty ( $row [ 'slug' ])) {
$errors [] = " data/news.json, Eintrag # { $i } : mit artikel[] braucht die Meldung einen slug, sonst ist die Detailseite nicht erreichbar. " ;
} elseif ( ! preg_match ( '/^[a-z0-9-]+$/' , ( string ) $row [ 'slug' ])) {
$errors [] = " data/news.json, Eintrag # { $i } : slug darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten. " ;
}
foreach ( $row [ 'artikel' ] as $b => $block ) {
$typ = ( string ) ( $block [ 'typ' ] ? ? '' );
if ( ! in_array ( $typ , $newsBlockTypen , true )) {
$errors [] = " data/news.json, Eintrag # { $i } , Block # { $b } : unbekannter typ ' { $typ } ' — erlaubt sind "
. implode ( ', ' , $newsBlockTypen ) . '. Der Block würde beim Rendern verschluckt.' ;
continue ;
}
if ( $typ === 'bild' && empty ( $block [ 'alt' ])) {
$errors [] = " data/news.json, Eintrag # { $i } , Block # { $b } : Bild ohne alt-Text. " ;
}
}
}
// --- Cron-Endpoint (public/cron.php) ---
// Nur relevant, wenn der Tarif keine Cronjobs hat und der Takt von außen kommt.
// Ohne brauchbaren Schlüssel ist der Endpoint zu (403) und nichts synct — das fällt
// sonst wochenlang nicht auf, weil die Seite mit dem alten Cache normal weiterläuft.
$cronKey = ( string ) config ( 'cron.key' , '' );
$cronInterval = ( int ) config ( 'cron.min_interval' , 240 );
if ( $cronKey === '' || $cronKey === 'CHANGE_ME' ) {
$warnings [] = 'cron.key ist nicht gesetzt — public/cron.php weist jeden Aufruf ab. Nur in Ordnung, wenn der Takt über echte Shell-Cronjobs läuft.' ;
} else {
if ( strlen ( $cronKey ) < 24 ) {
$errors [] = 'cron.key ist kürzer als 24 Zeichen — public/cron.php verweigert den Dienst. Erzeugen: php -r \'echo bin2hex(random_bytes(24)), "\n";\'' ;
}
if ( $cronKey === ( string ) config ( 'app_secret' )) {
$errors [] = 'cron.key ist identisch mit app_secret — der Cron-Schlüssel liegt bei einem externen Dienst und darf nicht die Formular-Token aufschließen.' ;
}
if ( $cronInterval >= 1800 ) {
$warnings [] = " cron.min_interval ist { $cronInterval } s — bei halbstündlichem Takt am Spieltag würde jeder zweite Aufruf verworfen. " ;
}
}
Veröffentlichen friert auch die Bilder ein
Der Snapshot fror die Daten ein, verwies aber weiter auf Ordner, die die
Sync-Skripte verwalten: matchcenter-sync räumt Wappen weg, die der BFV nicht mehr
führt (eine Saison), instagram-sync ältere Posts (Wochen). Am 31.07.2026
nachgewiesen — ein Lauf löschte 18 Wappen und 3 Bilder. Eine archivierte Ausgabe
wäre also mit der Zeit von selbst zerfallen, und "gedruckt = fixiert" war damit
nur die halbe Wahrheit.
bin/stadionzeitung-publish.php kopiert jetzt alles, worauf der Snapshot zeigt
(img/crests, img/instagram, img/news), als fix-* in den Ausgaben-Ordner und
schreibt die Pfade um. Varianten-Sets {base, widths} werden über alle Breiten
kopiert und nur umgeschrieben, wenn alle da sind — ein halbes srcset wäre
schlimmer als der alte Pfad; genau daran fiel img/news/neue-website beim ersten
Versuch auf. --entwurf löscht die fix-*-Dateien wieder, sie gehören zum Snapshot.
Flache Dateinamen statt Unterordner, weil die Aufräum-Logik in
stadionzeitung-add.php nur Dateien entfernt und ein Unterordner als Waise bliebe.
bin/preflight.php warnt, wenn eine veröffentlichte Ausgabe noch auf die
flüchtigen Ordner zeigt — sonst verfiele sie still.
Geprüft am Hutschdorf-Heft: 39 Dateien eingefroren, Snapshot verweist nur noch
auf den Ausgaben-Ordner, 88 Bilder in Viewer und Druckfassung erreichbar, keins
fehlt, preflight-Warnung verschwindet. Der Testlauf wurde danach zurückgenommen:
der lokale Matchcenter-Stand ist einen Tag alt, eingefroren wird auf dem Server
nach dem letzten Sync vor dem Druck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:47:44 +02:00
// --- Veröffentlichte Stadionzeitungen: Bilder wirklich eingefroren? ---
// Der Snapshot friert die Daten ein. Zeigt er noch auf img/crests oder img/instagram,
// hängt die Ausgabe weiter am Sync: der räumt Wappen weg, die der BFV nicht mehr führt
// (eine Saison), und Instagram-Bilder älterer Posts (Wochen). Am 31.07.2026 löschte ein
// Lauf 18 Wappen und 3 Bilder. Danach zeigte eine gedruckte Ausgabe Lücken.
// bin/stadionzeitung-publish.php kopiert sie deshalb in den Ausgaben-Ordner.
// Reparatur einer Alt-Ausgabe: `--entwurf` und neu veröffentlichen.
$szFile = DATA_PATH . '/stadionzeitung.json' ;
if ( is_file ( $szFile )) {
$szData = json_decode (( string ) file_get_contents ( $szFile ), true ) ? : [];
foreach ( $szData [ 'issues' ] ? ? [] as $ausgabe ) {
if (( $ausgabe [ 'status' ] ? ? 'veroeffentlicht' ) !== 'veroeffentlicht' || ! isset ( $ausgabe [ 'snapshot' ])) {
continue ;
}
$rohSnap = ( string ) json_encode ( $ausgabe [ 'snapshot' ]);
$fluechtig = [];
foreach ([ 'img/crests/' , 'img/instagram/' ] as $ordner ) {
// JSON escapt Schrägstriche, deshalb beide Schreibweisen prüfen.
if ( str_contains ( $rohSnap , $ordner ) || str_contains ( $rohSnap , str_replace ( '/' , '\\/' , $ordner ))) {
$fluechtig [] = rtrim ( $ordner , '/' );
}
}
if ( $fluechtig !== []) {
$warnings [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ' ist veröffentlicht, ihr Snapshot zeigt aber noch auf "
. implode ( ' und ' , $fluechtig ) . ' — diese Ordner verwalten die Sync-Skripte, die Bilder verschwinden dort mit der Zeit.'
. ' Einfrieren: php bin/stadionzeitung-publish.php ' . $ausgabe [ 'slug' ] . ' --entwurf && php bin/stadionzeitung-publish.php ' . $ausgabe [ 'slug' ];
}
}
}
Sportfest-Ausgabe der Stadionzeitung zum Heimspiel gegen ATS Wartenfels
Neue Live-Seiten, geschaltet vom chat-gepflegten Feld `veranstaltung` (Slug aus
data/veranstaltungen.json) — Programm und Turnierpläne kommen damit aus derselben
Quelle wie die Website, nicht als Kopie ins Heft:
- Programm-Seite (programm[], rendert zugleich auf /veranstaltungen/sportfest-2026)
- vier Turnierplan-Seiten (turniere[]) mit leerer Ergebnisspalte und leerer
Abschlusstabelle zum Eintragen am Platz
- Gegnerseite je Mannschaft mit Heimspiel am Fest statt nur der tragenden:
stadionzeitung_gegner_fixtures() ist die einzige Stelle, die das entscheidet,
Seitenplanung und Snapshot lesen beide dort. `gegner` ist deshalb eine Map je
Team-Key; die Vorschau überspringt jedes Spiel mit eigener Gegnerseite
- Danke-Seite mit Partner-Logo-Wand aus data/partners.json und QR auf
/partner-werden, dazu das Anzeigen-Feld `platzierung` für Motive mit festem
Platz auf einer Live-Seite (Emons über der Logo-Wand). Vorher klebte der Dank
als leere Fläche unten an einem Anzeigen-Bild
- Cover einer Sonderausgabe: das Fest als Titelgeschichte statt einer der drei
Begegnungen, dazu das Hauptplakat schräg gelegt an Stelle des Spielerfotos
Ausgabe 2026-09-04-ats-wartenfels als ENTWURF angelegt, 48 Seiten (12 Bögen),
Tabellen auf gerader Seite, längster Anzeigenblock zwei. Veröffentlicht wird auf
dem Server, dort liegt der frische Matchcenter-Stand.
Nebenbei behoben: bin/anzeige-add.php räumte beim Anlegen alle Motive weg, deren
Slug mit demselben Text beginnt (`emons` löschte `emons-partnerdank` und
`emons-stuebinger`). Muster jetzt auf die Breiten-Endung eingeschränkt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KubsajzuV6N6QsSd3xKNjC
2026-09-03 10:57:52 +02:00
// --- Stadionzeitung ↔ Veranstaltung (Sportfest-Ausgabe) ---
// Eine Ausgabe mit `veranstaltung`-Feld zieht Programm, Turnierpläne und die
// Gegnerseiten je Heimspiel aus data/veranstaltungen.json. Ein Tippfehler im Slug
// oder im Turnier-Key rendert dort eine LEERE A5-Seite — ohne Fehlermeldung, weil
// die Komponenten bewusst still zurückkehren. Deshalb hier laut.
if ( is_file ( $szFile )) {
$szData = $szData ? ? ( json_decode (( string ) file_get_contents ( $szFile ), true ) ? : []);
$vBySlug = array_column ( $veranstaltungRows , null , 'slug' );
foreach ( $szData [ 'issues' ] ? ? [] as $ausgabe ) {
$vSlug = ( string ) ( $ausgabe [ 'veranstaltung' ] ? ? '' );
if ( $vSlug === '' ) {
continue ;
}
if ( ! isset ( $vBySlug [ $vSlug ])) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': veranstaltung ' { $vSlug } ' fehlt in data/veranstaltungen.json. " ;
continue ;
}
$turniere = $vBySlug [ $vSlug ][ 'turniere' ] ? ? [];
foreach ( $turniere as $i => $turnier ) {
foreach ([ 'key' , 'title' , 'date' , 'teams' , 'spiele' ] as $feld ) {
if ( empty ( $turnier [ $feld ])) {
$warnings [] = " data/veranstaltungen.json, ' { $vSlug } ' → turniere[ { $i } ]: Feld ' { $feld } ' fehlt oder ist leer. " ;
}
}
}
$keys = array_column ( $turniere , 'key' );
foreach ( $ausgabe [ 'images' ] ? ? [] as $seite ) {
if (( $seite [ 'type' ] ? ? '' ) === 'live' && ( $seite [ 'page' ] ? ? '' ) === 'turnier'
&& ! in_array (( string ) ( $seite [ 'turnier' ] ? ? '' ), $keys , true )) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': Turnierseite ' " . ( $seite [ 'turnier' ] ? ? '' ) . " ' hat keinen Eintrag in turniere[] von ' { $vSlug } '. " ;
}
}
}
}
2026-07-31 16:50:43 +02:00
// --- Inline-Styles (die CSP verwirft sie stillschweigend) ---
// Diese Prüfung existiert wegen eines echten Ausfalls am 31.07.2026: der
// Banner-Slider setzte sein Hintergrundbild per style="--banner-bg: url(…)". Auf
// echtem Apache greift die CSP aus public/.htaccess (default-src 'self', KEIN
// style-src), der Browser verwarf das Attribut, die Slides blieben schwarz. Lokal
// war das unsichtbar, weil `php -S` keine .htaccess liest — der Fehler konnte also
// erst in Produktion auffallen. Genau davor schützt diese Wache.
// Hinweis: per JavaScript gesetzte Styles (element.style.…) sind NICHT betroffen,
// die CSP prüft nur vom HTML-Parser gelesene style-Attribute und <style>-Blöcke.
/* Zeilenweise und ohne Tag - Kontext geprüft : ein Muster , das beim öffnenden Tag
ansetzt und mit [ ^> ] * weitersucht , bricht sofort ab — PHP - Templates enthalten
über das schließende PHP - Tag ständig eine spitze Klammer . Ein style - Attribut in
einer PHP - Datei ist praktisch immer echtes Markup , diese Prüfung genügt also .
Dieser Kommentar ist bewusst ein Blockkommentar : die Zeichenfolge des schließenden
PHP - Tags beendet den PHP - Modus AUCH in einem //-Kommentar. Genau daran ist diese
Prüfung beim Bauen einmal gescheitert ( preflight gab ab dort seinen eigenen
Quelltext aus , und php - l meldete nichts , weil das gültiges PHP mit HTML ist ) . */
$inlineStyles = [];
$phpDateien = new RecursiveIteratorIterator ( new RecursiveDirectoryIterator ( ROOT_PATH . '/app' ));
foreach ( $phpDateien as $datei ) {
if ( ! $datei -> isFile () || $datei -> getExtension () !== 'php' ) {
continue ;
}
foreach ( file ( $datei -> getPathname ()) ? : [] as $nr => $zeile ) {
$roh = ltrim ( $zeile );
// Kommentarzeilen überspringen — dieser Abschnitt selbst nennt style=" ja auch.
if ( $roh === '' || str_starts_with ( $roh , '*' ) || str_starts_with ( $roh , '//' ) || str_starts_with ( $roh , '/*' )) {
continue ;
}
if ( preg_match ( '/\sstyle\s*=\s*"/i' , $zeile ) === 1 ) {
$kurz = str_replace ( ROOT_PATH . '/' , '' , $datei -> getPathname ());
$inlineStyles [] = $kurz . ':' . ( $nr + 1 );
break ;
}
}
}
if ( $inlineStyles !== []) {
$errors [] = 'Inline-Style im Markup — die CSP (kein style-src) verwirft ihn, die Regel greift NUR in Produktion: '
. implode ( ', ' , $inlineStyles )
. '. Bilder als <img> ausgeben, Farben als Token in tokens.css plus Klasse in components.css.' ;
}
Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren
Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb
der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine
Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort
die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl
in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im
Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen.
h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der
--fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase
unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar.
Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit
Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses
Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente
gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts.
Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten-
Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()-
Rampen für Karten-Überschriften (bento__heading, bento__stat-value,
group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name
wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich.
Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und
11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser,
folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen
drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest.
Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf
vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em.
Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von
tokens.css.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
// --- Ausgabe ---
foreach ( $warnings as $w ) {
echo " WARNUNG { $w } \n " ;
}
foreach ( $errors as $e ) {
echo " FEHLER { $e } \n " ;
}
if ( $errors === []) {
echo $warnings === [] ? " Alles startklar. \n " : " Keine Fehler — nur die Warnungen oben prüfen. \n " ;
echo " Nächster Schritt: php bin/smtp-test.php (Login testen) und je eine Testmail über die drei Formulare. \n " ;
exit ( 0 );
}
echo " \n " . count ( $errors ) . " Fehler offen — noch nicht live gehen. \n " ;
exit ( 1 );