.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('/.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]; } /** * 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. */ function form_token_valid(string $token, int $min = 3, int $max = 7200): 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; } /** * 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; } /** * 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; }