Tabs als fortschreitende Verbesserung (Dokumentschema v7)

Auf Entscheidung von Umut werden Tabs jetzt mit JavaScript umgesetzt.
Die Regel "der Seiteninhalt braucht kein Browser-JavaScript" bleibt
dabei eingehalten: der Server liefert eine Ankerliste und alle Panels
mit eigenen Überschriften, versteckt nichts, und ohne Skript ist
jeder Inhalt lesbar und über die Tastatur erreichbar.

public/assets/tabs.js ergänzt tablist/tab/tabpanel, Auswahl per
Pfeiltasten, Home und Ende sowie aria-selected und wählt bei einem
passenden Sprungziel das zugehörige Panel.

Die strenge Richtlinie bleibt Voreinstellung: öffentliche Seiten
senden weiterhin script-src 'none'. Nur eine Seite mit Tab-Block
sendet script-src 'self' und bindet die eine externe Datei ein;
Inline-Skript ist nie erlaubt.

Geprüft: 109 PHP-Tests / 395 Assertions unter PHP 8.2.12 und 8.4.22,
14 JavaScript-Modelltests, Lint/Format/Container. Das Skript selbst
wurde nicht ausgeführt: Tastaturbedienung und ARIA-Verhalten sind
ohne Browserlauf ungeprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 11:41:36 +02:00
1 parent 8600384f1a
commit c6ec00a906
17 files changed
+279 -26

No files matched your search

+27 -4
View File
@@ -38,23 +38,46 @@ final class PageController extends AbstractController
'navigation' => $this->navigation($path),
'postLists' => $this->postLists($page['document']),
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
'needsTabs' => $this->hasType($page['document'], 'tabs'),
]);
}
return $this->shell($response);
return $this->shell($response, null !== $page && $this->hasType($page['document'], 'tabs'));
}
/** Every public response carries the same headers, whether page, archive or feed. */
private function shell(Response $response): Response
/**
* Every public response carries the same headers. Scripting stays forbidden unless the page
* actually contains a tab group: only then may the browser load the one enhancement script,
* and only from this origin. Inline script is never allowed.
*/
private function shell(Response $response, bool $allowScript = false): Response
{
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
$response->headers->set('Content-Security-Policy', sprintf(
"default-src 'self'; script-src %s; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
$allowScript ? "'self'" : "'none'",
));
return $response;
}
/** True when the stored document contains at least one block of that type. */
private function hasType(array $document, string $type): bool
{
$found = false;
$walk = function (array $blocks) use (&$walk, &$found, $type): void {
foreach ($blocks as $block) {
$found = $found || $type === ($block['type'] ?? null);
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $found;
}
#[Route('/beitraege', name: 'posts', methods: ['GET'])]
#[Route('/kategorie/{slug}', name: 'category', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])]
#[Route('/schlagwort/{slug}', name: 'tag', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])]