Files
tsv08kulmbach-website/CLAUDE.md
fs 819bdf6173 SEO/GEO-Basics: Descriptions & Titles normalisiert, Outline-Fixes, CLS-Maße
Audit aller 23 indexierbaren Routen (lokaler Crawl) und Fixes:
- Descriptions auf ~130-160 Zeichen; Turnen-Disziplinseiten hatten den
  kurzen Hero-Teaser als Description, jetzt eigene SERP-Texte
- Titles: team-erste gekürzt, sportheimbuchung ohne doppelten Brand
- h1->h3-Sprünge behoben: visually-hidden h2 aus JSON-Section-Titeln
  (sportheimbuchung, partner-werden), sichtbarer choice.title auf
  mitglied-werden; historie bekommt Breadcrumb-Schema
- Neuer Helper img_intrinsic_attrs() liefert width/height aus der
  Bilddatei (PNG via getimagesize, SVG via viewBox) für Partner-Grid-
  und Banner-Slider-Logos — kein Layout-Shift mehr

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:27:19 +02:00

13 KiB
Raw Blame History

TSV 08 Kulmbach — Website

Neubau von tsv08kulmbach.de. Vereinsseite (Fußball + Turnen, gegründet 1908) als sauberer, selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.

Stack & Umgebung

  • Frontend: Natives HTML, Vanilla CSS, Vanilla JS. Kein Framework, keine Build-Pipeline, kein Node.
  • Backend: Vanilla PHP ≥ 8.1, Composer nur für phpmailer/phpmailer. Keine Template-Engine.
  • Hosting-Ziel: Apache Shared Hosting, PHP 8.x, .htaccess, Cronjobs verfügbar.
  • Lokales Dev: php -S localhost:8000 -t public public/index.php (index.php fungiert als Router-Script, weil php -S kein .htaccess kennt).
  • Docroot: public/. Falls der Hoster nur das Repo-Root serviert, greift die Root-.htaccess (Rewrite → public/, Deny für alles andere).

Harte Regeln (nicht verhandelbar)

  1. Keine externen Einbindungen. Keine CDNs, keine externen Fonts/Scripts/iframes/Embeds, keine Tracking-/Analytics-Dienste, kein reCAPTCHA. Alles wird self-hosted. Einzige erlaubte ausgehende Verbindungen: All-Inkl-SMTP (Mailversand, serverseitig) und der Instagram-Scraper (CLI/Cron, nie im Request-Pfad). Besucher-Browser kontaktieren ausschließlich unsere Domain. Die CSP (default-src 'self') erzwingt das — nicht aufweichen.
  2. .context/ und .context_db/ sind reine Referenz (alte, unsichere CMS-Seite). Von dort werden nur Assets (Bilder/Fonts/Videos), Texte und Styling-Werte extrahiert. Niemals Code übernehmen, niemals Dateien von dort direkt verlinken. Assets immer nach public/assets/ kopieren/optimieren.
  3. Kein Admin-/Pflege-Backend bauen. Inhalte werden per Chat gepflegt: Claude editiert data/*.json bzw. die Seiten-Dateien. Kein Login, keine Schreib-Endpoints.
  4. Sensible Altdaten nie übernehmen: Aus dem DB-Dump keine Mail-Logs, Formulareinträge, Passwort-Hashes, Tokens oder personenbezogene Daten migrieren.
  5. Secrets nur in config/config.php (gitignored, außerhalb des Webroots, per .htaccess denied). Niemals hardcoden, loggen oder ausgeben. config/config.example.php dokumentiert alle Keys.

Single Source of Truth (vor JEDER Änderung prüfen)

Was Einzige Quelle Niemals
Farben, Fonts, Spacing, Radii, Schatten public/assets/css/tokens.css Hex-Werte/Magic Numbers in anderen CSS-Dateien
Seiten-Slugs & Routing app/routes.php URLs woanders hart verdrahten
Navigation (Labels/Reihenfolge) data/navigation.json Menüpunkte in Templates
Vereinsdaten (Name, Adresse, Kontakt, Social, Geo) data/club.json Adresse/E-Mail irgendwo als Text duplizieren
Startseiten-Inhalte data/home.json Texte in pages/home.php
Mannschaften (Kader, Trainer, Hero, FAQ) data/teams.json Spieler-/Trainernamen in Team-Templates
Seiteninhalte (Texte, Hero, FAQ) data/<slug>.json (z. B. fussball, jugend, turnen, verein, mitmachen, historie, partner-werden, sportheimbuchung) Texte/Hero/FAQ hart in pages/*.php
Ansprechpartner/Personen (geteilte Quelle) data/ansprechpartner.json (per Abteilung auf Team-/Jugend-/Verein-Seiten gezogen) Namen/Funktionen/Kontakte in Templates duplizieren
Banner-Slider (Startseite) data/banners.json Slides in pages/home.php oder der Komponente
Partner/Sponsoren data/partners.json
Instagram-Cache data/instagram.json + public/assets/img/instagram/ maschinenverwaltet von bin/instagram-sync.php — nie von Hand editieren
Matchcenter (Spiele/Ergebnisse/Tabellen) data/matchcenter.json + public/assets/img/crests/ maschinenverwaltet von bin/matchcenter-sync.php — nie von Hand editieren. Team-/Wettbewerbs-IDs in config.php (matchcenter.teams)
Icons public/assets/icons/ (Bootstrap Icons, lokal) via icon()-Helper Inline-SVG-Pfade von Hand ins Markup, Emoji/Unicode-Glyphen, gezeichnete CSS-Icons, Icon-Font/CDN

Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (app/pages/*.php) im $meta-Array.

Strukturierte Daten (JSON-LD): Seiten setzen $meta['schema'] = Array von schema.org-Knoten; app/components/jsonld.php hängt sie an den globalen @graph (Organization #club) an — nie ein <script type="application/ld+json"> von Hand ins Markup. Knoten nur über die Helper in helpers.php bauen: breadcrumb_schema(), faq_schema(), page_schema() (Breadcrumb + optional FAQ), team_schema() (SportsTeam + Breadcrumb + FAQ, verweist via #club auf den Club), sportsevent_nodes() (SportsEvent-Knoten für kommende Spiele, Matchcenter) und job_posting_schema() (JobPosting VOLUNTEER für Ehrenamtsstellen, /mitmachen, verweist via #club). Sichtbare FAQ + FAQPage-Schema aus derselben data/*.json-Quelle, damit Inhalt und Markup nie auseinanderlaufen.

Architektur-Muster

  • Front Controller: public/index.php → Slug-Lookup in app/routes.php → Page-Datei setzt $meta und emittiert Body via component()app/layout.php rendert die Shell.
  • Komponenten: app/components/*.php sind dumme Includes, bekommen Props via component('name', ['key' => $val]). Component-first-Regel: Bevor neues Markup/CSS entsteht, prüfen ob eine Komponente existiert und erweitert werden kann. Wiederkehrende Inhalte auf neuen Seiten → als Komponente extrahieren.
  • Helpers (app/helpers.php): e() (Escaping — IMMER für dynamische Ausgaben), url() (interne Pfade), abs_url() (absolute URLs für Canonical/OG), asset() (Versionierung via filemtime), json_load('name') (liest data/name.json, static cache), component(), config('key') (Werte aus config/config.php), icon('name', 'klasse', 'label?') (Inline-SVG aus public/assets/icons/, siehe Abschnitt Icons), form_token()/form_token_valid() (Time-Trap-Token, siehe Formulare), img_intrinsic_attrs('pfad') (width/height-Attribute aus der Bilddatei für CLS-freie <img>, wenn die Maße nicht schon statisch bekannt sind).
  • CSS: 6 Dateien als <link> in fester Reihenfolge: tokens → reset → base → layout → components → utilities. Kein @import, kein Inline-Style. Komponenten-Styles in components.css mit Banner-Kommentaren (/* === hero === */).
  • JS: Vanilla, defer, progressive enhancement — alles muss ohne JS funktionieren (Formulare = normales POST + Redirect). Ein Feature = eine Datei = eine selbst-initialisierende IIFE.
  • Hero-Regel: Die Startseite hat einen großen Hero (display: true, mit kicker/tagline/Video). Alle Unterseiten-Heros sind einheitlich „Überschrift + Teaser" — nur title + text, dazu compact: true und align: 'bottom-left'. Kein kicker, keine tagline auf Unterseiten (hält es klar und einfach). ctas nur als begründete Ausnahme. Gilt für JSON- wie Inline-Heros.
  • Neue Seite anlegen = genau 3 Schritte: app/pages/<slug>.php + Eintrag in app/routes.php (+ optional data/navigation.json). Sitemap & Canonical folgen automatisch.

Formulare

  • Felder ausschließlich über component('form-field', ['field' => …]) (app/components/form-field.php) — nie Feld-Markup von Hand. Die Komponente liefert Label, Pflichtfeld-Stern, Hilfetext und das Fehler-Element #<id>-error (per aria-describedby verknüpft) in einheitlichem Markup.
  • Jedes neue Formular braucht: (1) Status-Region role="status" aria-live="polite" tabindex="-1" data-form-status als erstes Form-Kind, (2) Hinweiszeile „Pflichtfelder sind mit * markiert“, (3) Honeypot-Feld website (visually-hidden) + Hidden-Field ft mit form_token(), (4) serverseitige Action unter app/actions/ mit Whitelist-Validierung + PRG-Redirect.
  • form.js übernimmt automatisch Inline-Feldfehler (Browser-Validierungsmeldung, aria-invalid, Fokus aufs erste ungültige Feld) — Konvention dafür ist die #<id>-error-ID aus der Komponente.
  • A11y-Vorgaben: Feldränder mit --clr-glass-border-strong (≥3:1 Kontrast, WCAG 1.4.11), Checkbox min. 24px (WCAG 2.5.8), Status-Region nie per display:none verstecken (fliegt aus dem Accessibility-Tree).
  • Versand via PHPMailer über All-Inkl-SMTP (w01ca75d.kasserver.com:587, STARTTLS, Postfach noreply@tsv08kulmbach.de), Config aus config/config.php. Verbindung/Login ohne Mailversand testen: php bin/smtp-test.php.
  • Spam-Schutz ohne externe Dienste — mehrschichtig in jeder app/actions/*-Action: (1) Honeypot-Feld website, (2) HMAC-signierter Timestamp / Time-Trap (ft-Token via form_token()/form_token_valid(), app_secret), (3) serverseitige Whitelist-Validierung, (4) Rate-Limiting via rate_limit_ok() mit Zählern unter storage/ratelimit/: pro IP+Route (5/10 min), globaler Tages-Cap (100/Tag) und Token-Replay-Sperre (3×/Token), (5) Link-Count-Check im Freitext. Abgewiesene Versuche → log_spam() nach storage/logs/spam.log. Immer POST-Redirect-GET; form.js macht optional fetch.
  • Logs unter storage/logs/: mail.log (Mailversand-Fehler), spam.log (abgewiesene Form-Versuche), instagram.log / matchcenter.log (Sync-Läufe), php-errors.log. Fehler nie an Besucher leaken.

Design (aus der alten Seite extrahiert, verifiziert)

  • Dunkles sportliches Theme: BG #222 mit Textur, weiße Schrift, Akzent-Rot #e20612 (das ist der echte Markenwert aus der alten DB — nicht #ff6532).
  • Headings: Coolvetica, uppercase, line-height ~0.85. Body: Abel. Beide self-hosted als woff2.
  • Coolvetica nie kleiner als --fs-650 (28px) — der schmale Condensed-Schnitt wird in Uppercase darunter unleserlich (mehrfach bestätigt: Footer-Headings, Timeline-Nav). Für kleinere Auszeichnungen Abel verwenden, ggf. mit letter-spacing + uppercase.
  • Rot nie für Fließtext auf dunklem Grund (Kontrast!) — nur als Button-/Akzent-Fläche mit weißem Text.
  • Alle Werte als CSS Custom Properties in tokens.css; Breakpoints: 1249 (Burger) / 1024 / 992 / 768 / 600 / 500.

Icons

  • Bootstrap Icons (MIT), lokal & self-hosted unter public/assets/icons/<name>.svg — die erlaubte Icon-Quelle. Kein Icon-Font, kein CDN, keine gezeichneten CSS-/Unicode-Icons.
  • Ausgabe nur über den Helper: icon('pause-fill') (dekorativ, aria-hidden), icon('pause-fill', 'meine-klasse') (mit Klasse), icon('envelope', '', 'E-Mail') (semantisch, role="img" + aria-label). Inline-SVG, currentColor erbt die Schriftfarbe.
  • Größe = font-size des Elements (Icon ist 1em), Farbe = currentColor. Basisklasse .icon in utilities.css.
  • Nur tatsächlich genutzte Icons werden abgelegt (kein Komplett-Set). Neues Icon: php bin/icons-add.php <name> (Name = Dateiname auf icons.getbootstrap.com ohne .svg).

Checkliste für jede neue Seite/Komponente

  • Semantisches HTML (Landmarks, Headings-Hierarchie ohne Sprünge)
  • Alt-Texte für Bilder; dekorative Bilder alt=""
  • Tastatur-bedienbar, :focus-visible sichtbar, sinnvolle Tab-Reihenfolge
  • Kontrast ≥ 4.5:1 (Fließtext) / 3:1 (große Schrift, UI)
  • prefers-reduced-motion respektiert (globaler Kill-Switch in reset.css)
  • $meta gesetzt: title, description (+ og_image falls abweichend)
  • Bilder: srcset/sizes, width/height, loading="lazy" (above-the-fold: eager + fetchpriority). Responsive widths-Varianten mit php bin/img-resize.php erzeugen (nicht von Hand skalieren)
  • Keine Duplikate: Inhalte/Werte aus den Single-Source-Dateien beziehen
  • php -l sauber; Seite lokal geprüft

Instagram-Feed (Juicer-Ersatz)

bin/instagram-sync.php (nur CLI) scraped das öffentliche Profil @tsv08kulmbach, schreibt data/instagram.json atomar und lädt Bilder nach public/assets/img/instagram/. Bei Fehlern bleibt der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert die Komponente eine „Folge uns"-CTA-Karte). Cron: 2×/Tag. Besucher laden Instagram-Inhalte ausschließlich von unserer Domain.

Matchcenter (BFV-Widget-Ersatz)

bin/matchcenter-sync.php (nur CLI) holt Spielplan, Ergebnisse und Tabelle der drei Aktivenmannschaften aus der öffentlichen BFV-Widget-JSON-API (widget-prod.bfv.de, reverse-engineert aus dem alten <BFVWidget.HTML5…>-Embed — kein HTML-Scraping), lädt die Vereinswappen lokal nach public/assets/img/crests/ und schreibt data/matchcenter.json atomar. Endpunkte: …/api/service/widget/v1/team/{teamId}/matches, …/api/service/widget/v1/competition/{compoundId}/table, Wappen app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId}. Browser-User-Agent + Referer nötig (sonst HTTP 418). Eigene-Verein-Erkennung exakt über permanentId. Bei Fehlern bleibt der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert matchcenter-empty). Cron 2×/Tag + am Wochenende ½-stündlich nachmittags (siehe config.example.php). Der „Nächste Spiele"-Slider teilt sich den Antrieb assets/js/carousel.js (generisch, [data-carousel]) mit dem Banner-Slider. Besucher laden alle Matchcenter-Inhalte ausschließlich von unserer Domain.