Files
tsv08kulmbach-website/app/helpers.php
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

422 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Config-Wert holen: config('smtp.host') oder config() für das ganze Array.
*/
function config(?string $key = null, mixed $default = null): mixed
{
$value = $GLOBALS['__config'];
if ($key === null) {
return $value;
}
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
}
$value = $value[$part];
}
return $value;
}
/**
* HTML-Escaping — für JEDE dynamische Ausgabe verwenden.
*/
function e(string|int|float|null $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
/**
* Interner Link aus Slug: url('fussball') → '/fussball', url('') → '/'.
*/
function url(string $slug = ''): string
{
return '/' . trim($slug, '/');
}
/**
* Absolute URL für Canonical, OG und Sitemap.
*/
function abs_url(string $slug = ''): string
{
return rtrim((string) config('base_url'), '/') . url($slug);
}
/**
* Asset-Pfad mit Cache-Busting über filemtime: asset('css/tokens.css').
*/
function asset(string $path): string
{
$path = ltrim($path, '/');
$file = PUBLIC_PATH . '/assets/' . $path;
$version = is_file($file) ? (string) filemtime($file) : '0';
return '/assets/' . $path . '?v=' . $version;
}
/**
* Bootstrap-Icon als Inline-SVG ausgeben (lokal aus public/assets/icons/<name>.svg).
* Standard: dekorativ (aria-hidden). Mit $label wird es als beschriftetes Bild
* (role="img") ausgegeben. Größe/Farbe steuert CSS über die Klasse .icon.
* Quelle: Bootstrap Icons (MIT) — neue Icons via bin/icons-add.php hinzufügen.
*/
function icon(string $name, string $class = '', ?string $label = null): string
{
static $cache = [];
if (!array_key_exists($name, $cache)) {
$file = PUBLIC_PATH . '/assets/icons/' . basename($name) . '.svg';
$cache[$name] = is_file($file) ? trim((string) file_get_contents($file)) : '';
}
if ($cache[$name] === '') {
return '';
}
$attrs = 'class="icon' . ($class !== '' ? ' ' . e($class) : '') . '"';
$attrs .= $label !== null && $label !== ''
? ' role="img" aria-label="' . e($label) . '"'
: ' aria-hidden="true" focusable="false"';
return preg_replace('/<svg\b/', '<svg ' . $attrs, $cache[$name], 1);
}
/**
* JSON-Datendatei laden (data/<name>.json) mit Request-weitem Cache.
* Wirft bei kaputtem JSON — Datenfehler sollen laut scheitern, nicht leise.
*/
function json_load(string $name): array
{
static $cache = [];
if (!array_key_exists($name, $cache)) {
$file = DATA_PATH . '/' . $name . '.json';
if (!is_file($file)) {
return [];
}
$cache[$name] = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
}
return $cache[$name];
}
/**
* Intrinsische Bildmaße als ' width="…" height="…"' für CLS-freie <img>-Tags.
* $rel = Pfad relativ zu public/assets/. PNG/JPG via getimagesize, SVG via
* viewBox (Fallback: width/height-Attribute, Einheiten werden ignoriert —
* fürs Seitenverhältnis reicht die Zahl). Liefert '' wenn nicht bestimmbar.
*/
function img_intrinsic_attrs(string $rel): string
{
static $cache = [];
if (!isset($cache[$rel])) {
$file = PUBLIC_PATH . '/assets/' . ltrim($rel, '/');
$w = $h = 0;
if (is_file($file)) {
if (str_ends_with(strtolower($file), '.svg')) {
$svg = (string) file_get_contents($file);
if (preg_match('/viewBox="\s*[\d.-]+[\s,]+[\d.-]+[\s,]+([\d.]+)[\s,]+([\d.]+)/', $svg, $m)) {
[$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
} elseif (preg_match('/<svg\b[^>]*\bwidth="([\d.]+)[a-z%]*"[^>]*\bheight="([\d.]+)[a-z%]*"/s', $svg, $m)) {
[$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
}
} else {
[$w, $h] = (getimagesize($file) ?: [0, 0]);
}
}
$cache[$rel] = ($w > 0 && $h > 0) ? ' width="' . $w . '" height="' . $h . '"' : '';
}
return $cache[$rel];
}
/**
* Komponente rendern: component('hero', ['title' => …]).
* Props werden als lokale Variablen extrahiert; Komponenten sind dumme Includes.
*/
function component(string $name, array $props = []): void
{
extract($props, EXTR_SKIP);
require APP_PATH . '/components/' . $name . '.php';
}
/**
* Page-Datei ausführen: sie setzt $meta und emittiert ihren Body.
* Rückgabe: [$meta, $html].
*/
function render_page(string $file): array
{
$meta = [];
ob_start();
require $file;
return [$meta, (string) ob_get_clean()];
}
/**
* Signierten Zeitstempel für die Formular-Time-Trap erzeugen.
*/
function form_token(): string
{
$ts = (string) time();
return $ts . '.' . hash_hmac('sha256', $ts, (string) config('app_secret'));
}
/**
* Time-Trap prüfen: Signatur gültig, älter als $min Sekunden, jünger als $max.
* Obergrenze großzügig (24h), damit langsame oder lange offene Formulare nicht
* grundlos abgewiesen werden; die Untergrenze fängt Sofort-Submits von Bots ab.
*/
function form_token_valid(string $token, int $min = 3, int $max = 86400): bool
{
$parts = explode('.', $token);
if (count($parts) !== 2) {
return false;
}
[$ts, $sig] = $parts;
if (!hash_equals(hash_hmac('sha256', $ts, (string) config('app_secret')), $sig)) {
return false;
}
$age = time() - (int) $ts;
return $age >= $min && $age <= $max;
}
/**
* Client-IP für Rate-Limiting/Logging. Bewusst nur REMOTE_ADDR — X-Forwarded-For
* ist ohne vertrauenswürdigen Proxy spoofbar und wird daher nicht ausgewertet.
*/
function client_ip(): string
{
return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
}
/**
* Dateibasiertes Rate-Limit mit gleitendem Fenster (shared-hosting-sicher, kein
* APCu/Redis nötig). Gibt true zurück und verbucht einen Treffer, solange in den
* letzten $window Sekunden weniger als $max Treffer für $key gezählt wurden; sonst
* false ohne Eintrag. Atomar via flock. Bei Datei-/IO-Fehler wird NICHT geblockt
* (Verfügbarkeit vor Schutz). Verwaiste Zähler werden gelegentlich aufgeräumt.
*/
function rate_limit_ok(string $key, int $max, int $window): bool
{
$dir = STORAGE_PATH . '/ratelimit';
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return true;
}
// Probabilistische GC: Zähler-Dateien, die seit >1 Tag nicht angefasst wurden, löschen.
if (random_int(1, 100) === 1) {
foreach (glob($dir . '/*.json') ?: [] as $stale) {
if ((int) @filemtime($stale) < time() - 86400) {
@unlink($stale);
}
}
}
$file = $dir . '/' . hash('sha256', $key) . '.json';
$fh = @fopen($file, 'c+');
if ($fh === false) {
return true;
}
try {
flock($fh, LOCK_EX);
$raw = (string) stream_get_contents($fh);
$hits = $raw !== '' ? (array) (json_decode($raw, true) ?: []) : [];
$now = time();
$hits = array_values(array_filter($hits, static fn ($t): bool => (int) $t > $now - $window));
if (count($hits) >= $max) {
return false;
}
$hits[] = $now;
rewind($fh);
ftruncate($fh, 0);
fwrite($fh, (string) json_encode($hits));
return true;
} finally {
flock($fh, LOCK_UN);
fclose($fh);
}
}
/**
* Abgewiesenen Formular-Versuch protokollieren (storage/logs/spam.log) — reine
* Beobachtbarkeit zum Tunen der Schwellen. Datensparsam: nur ein gekürzter,
* gesalzener IP-Hash, keine Klartext-IP/PII. $reason z. B. honeypot|token|ratelimit|links|daily-cap|replay.
*/
function log_spam(string $route, string $reason): void
{
$ipHash = substr(hash_hmac('sha256', client_ip(), (string) config('app_secret')), 0, 12);
error_log('[' . date('c') . "] {$route} {$reason} ip={$ipHash}\n", 3, STORAGE_PATH . '/logs/spam.log');
}
/**
* BreadcrumbList-Knoten: $items = [['name'=>…, 'slug'=>…], …] (Reihenfolge = Pfad).
*/
function breadcrumb_schema(array $items): array
{
$list = [];
foreach (array_values($items) as $i => $item) {
$list[] = [
'@type' => 'ListItem',
'position' => $i + 1,
'name' => $item['name'],
'item' => abs_url($item['slug']),
];
}
return ['@type' => 'BreadcrumbList', 'itemListElement' => $list];
}
/**
* FAQPage-Knoten aus [['q'=>…, 'a'=>…], …]. Leere Paare werden übersprungen;
* ohne Fragen wird ein leerer Array zurückgegeben (Aufrufer filtert das raus).
*/
function faq_schema(array $faq): array
{
$questions = [];
foreach ($faq as $item) {
if (empty($item['q']) || empty($item['a'])) {
continue;
}
$questions[] = [
'@type' => 'Question',
'name' => $item['q'],
'acceptedAnswer' => ['@type' => 'Answer', 'text' => $item['a']],
];
}
return $questions === [] ? [] : ['@type' => 'FAQPage', 'mainEntity' => $questions];
}
/**
* Seiten-Schema-Knoten zusammenstellen (für $meta['schema']):
* Breadcrumb + optional FAQPage. Generisch für Übersichts-/Jugendseiten.
*/
function page_schema(array $breadcrumb, array $faq = []): array
{
$nodes = [];
if ($breadcrumb !== []) {
$nodes[] = breadcrumb_schema($breadcrumb);
}
if ($faq !== [] && ($faqNode = faq_schema($faq)) !== []) {
$nodes[] = $faqNode;
}
return $nodes;
}
/**
* WebSite-Knoten (für die Startseite): definiert die Site als Entität und
* verweist via publisher auf den Club (#club). Keine SearchAction — es gibt
* keine Site-Suche.
*/
function website_schema(): array
{
$club = json_load('club');
return [
'@type' => 'WebSite',
'@id' => abs_url() . '#website',
'url' => abs_url(),
'name' => $club['name'],
'inLanguage' => 'de-DE',
'publisher' => ['@id' => abs_url() . '#club'],
];
}
/**
* Schema-Knoten für eine Mannschafts-Seite: SportsTeam (verweist auf den Club
* via #club) + Breadcrump (Start → Fußball → Team) + optional FAQPage.
* $team: Eintrag aus data/teams.json; $slug: voller Seiten-Slug.
*/
function team_schema(array $team, string $slug): array
{
$teamNode = [
'@type' => 'SportsTeam',
'name' => $team['name'],
'sport' => 'Fußball',
'url' => abs_url($slug),
'memberOf' => ['@id' => abs_url() . '#club'],
];
if (!empty($team['hero']['text'])) {
$teamNode['description'] = $team['hero']['text'];
}
$nodes = [$teamNode];
$nodes = array_merge($nodes, page_schema(
[
['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'],
['name' => $team['name'], 'slug' => $slug],
],
$team['faq'] ?? []
));
return $nodes;
}
/**
* SportsEvent-Knoten für anstehende Spiele (Matchcenter). $upcoming: Einträge aus
* data/matchcenter.json → upcoming[] (home, away, kickoff, …). startDate nur, wenn
* der Anstoß als ISO-Zeit vorliegt. Aufrufer hängt das Ergebnis an $meta['schema'].
*/
function sportsevent_nodes(array $upcoming): array
{
$nodes = [];
foreach ($upcoming as $m) {
if (empty($m['home']) || empty($m['away'])) {
continue;
}
$node = [
'@type' => 'SportsEvent',
'name' => $m['home'] . ' ' . $m['away'],
'sport' => 'Fußball',
'homeTeam' => ['@type' => 'SportsTeam', 'name' => $m['home']],
'awayTeam' => ['@type' => 'SportsTeam', 'name' => $m['away']],
'eventStatus' => 'https://schema.org/EventScheduled',
];
if (!empty($m['kickoff'])) {
$node['startDate'] = $m['kickoff'];
}
$nodes[] = $node;
}
return $nodes;
}
/**
* JobPosting-Knoten für eine Ehrenamtsstelle (Seite /mitmachen). $job: Eintrag aus
* data/mitmachen.json → positions[]; $pageSlug: Seiten-Slug für die Anker-URL.
* employmentType VOLUNTEER; hiringOrganization verweist via #club auf den Org-Knoten;
* jobLocation = Vereinsadresse aus club.json (Single Source). validThrough bewusst
* optional — ein abgelaufenes Datum entfernt die Anzeige aktiv aus den Ergebnissen,
* deshalb nur bei echt befristeten Stellen setzen. Ohne title → leerer Array (Aufrufer filtert).
*/
function job_posting_schema(array $job, string $pageSlug): array
{
if (empty($job['title'])) {
return [];
}
$club = json_load('club');
$node = [
'@type' => 'JobPosting',
'title' => $job['title'],
'description' => $job['description'] ?? ($job['summary'] ?? $job['title']),
'employmentType' => 'VOLUNTEER',
'hiringOrganization' => ['@id' => abs_url() . '#club'],
'jobLocation' => [
'@type' => 'Place',
'address' => [
'@type' => 'PostalAddress',
'streetAddress' => $club['address']['street'],
'postalCode' => $club['address']['zip'],
'addressLocality' => $club['address']['city'],
'addressCountry' => $club['address']['country'],
],
],
];
if (!empty($job['id'])) {
$node['identifier'] = [
'@type' => 'PropertyValue',
'name' => $club['name'],
'value' => $job['id'],
];
$node['url'] = abs_url($pageSlug) . '#' . $job['id'];
}
if (!empty($job['posted'])) {
$node['datePosted'] = $job['posted'];
}
if (!empty($job['valid_through'])) {
$node['validThrough'] = $job['valid_through'];
}
return $node;
}