Seitenbreite wirkt, vier Menueplaetze, Seiten ohne Kopfbereich
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Drei Fragen, drei unterschiedliche Antworten.

Die globale Seitenbreite war einstellbar und wirkte nur in eine Richtung:
site.css schrieb width:min(1180px,...), also schluckte die 1180 jeden
groesseren Wert aus dem Designbereich. Schmaler ging, breiter nicht, ohne
jede Meldung. Die Zahl kommt jetzt aus dem Theme. Das ist der dritte Fehler
dieser Art an einem Tag - nach der Rahmenstaerke und der Bildrundung -, und
alle drei hatten dieselbe Ursache: CSS, das nie gegen eine Erwartung geprueft
wurde.

Menueplaetze und Menueart waren vermischt. Wo ein Menue steht, bestimmt der
Block und war immer frei. Welche Eintraege es zeigt, bestimmt der Platz -
davon gab es zwei, jetzt vier (Haupt, zweite, Fusszeile, Rechtliches). Wie es
aussieht, war gar nicht einstellbar: jetzt waagerecht, untereinander oder
aufklappbar als Burger. Der Burger ist ein details-Element, denn ein Menue,
das ein Skript zum Oeffnen braucht, geht auf einer Seite ohne Skript nicht auf.

"Seiten ohne Menue" war nicht moeglich - der Kopfbereich war ein Dokument fuer
alle Seiten. Jetzt waehlt jede Seite: Standard, ein anderer Bereich, oder
keiner. Dafuer musste die Regel "es gibt genau einen Kopf- und Fussbereich"
fallen; sie war richtig, solange niemand waehlen konnte, und wurde falsch,
sobald man waehlen kann. Ein bestehender Test, der diese Regel zusicherte, ist
deshalb umgeschrieben und prueft jetzt die neue.

Gespeichert wird die Wahl, nicht das Ergebnis: leer heisst Standard, none
heisst keiner, sonst die Adresse. Ein Verweis auf einen geloeschten Bereich
faellt auf den Standard zurueck, statt die Seite stillschweigend ohne
Navigation auszuliefern.

Neu im Ausbauplan: ein einzelnes install.php, das das Releasearchiv
herunterlaedt und entpackt, damit niemand tausende Dateien per FTP hochladen
muss. Mit den Regeln, die darueber entscheiden, ob das eine Hilfe oder eine
Einladung ist: Adresse und SHA-256 stehen in der Datei und nicht im Formular,
HTTPS mit Zertifikatspruefung, Schutz gegen ZIP-Slip, Selbstloeschung danach.

336 PHP-Tests / 1354 Assertions, 17 JS-Modelltests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 21:15:03 +02:00
1 parent 67b85751cf
commit af3589c95c
22 files changed
+357 -26

No files matched your search

+7 -2
View File
@@ -38,7 +38,7 @@ final class DocumentValidator
public function validate(array $document, bool $requireHeading = true): void
{
$schemaVersion = $document['schemaVersion'] ?? null;
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], true), 'Unsupported document schema.');
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], true), 'Unsupported document schema.');
$this->text($document['title'] ?? null, 160);
$this->text($document['description'] ?? null, 320);
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
@@ -184,8 +184,13 @@ final class DocumentValidator
$this->text($props['title'] ?? null, 160);
}
if ('menu' === $type) {
// The entries live in the menu administration; the block only says which slot to show.
// The entries live in the menu administration; the block only says which slot to show
// and how to lay it out.
$this->require(isset(MenuRepository::SLOTS[$props['slot'] ?? '']), 'Unknown menu slot.');
if (isset($props['layout'])) {
$this->require($schemaVersion >= 16, 'Die Menüart braucht Schema v16.');
$this->require(in_array($props['layout'], ['horizontal', 'vertical', 'burger'], true), 'Unbekannte Menüart.');
}
}
if ('partialref' === $type) {
// Only the address of the section is content. Its blocks live in the partial, so one
+11 -1
View File
@@ -14,7 +14,17 @@ use Doctrine\DBAL\Connection;
*/
final class MenuRepository
{
public const SLOTS = ['header' => 'Hauptnavigation', 'footer' => 'Fußzeile'];
/**
* The places a menu can be maintained for. They are not positions on the page - a menu block
* can stand anywhere - but separate sets of entries: a header needs other links than a
* footer, and legal links are their own short list.
*/
public const SLOTS = [
'header' => 'Hauptnavigation',
'secondary' => 'Zweite Navigation',
'footer' => 'Fußzeile',
'legal' => 'Rechtliches',
];
public const MAX_ITEMS = 60;
public const MAX_DEPTH = 2;
+22 -2
View File
@@ -357,7 +357,7 @@ final class PageRepository
public function findPublished(string $path, string $locale = 'de'): ?array
{
$page = $this->db->fetchAssociative(
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.og_media_id, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL',
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.og_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.path = ? AND p.locale = ? AND p.website_id = ? AND p.deleted_at IS NULL',
[$path, $locale, 'default'],
);
if (false === $page) {
@@ -369,6 +369,26 @@ final class PageRepository
return $page;
}
/**
* Which header and footer a page wants: null for the standard one, 'none' for no area at all,
* or the address of another one.
*/
public function saveAreas(string $pageId, ?string $header, ?string $footer, int $expectedVersion): void
{
foreach ([$header, $footer] as $choice) {
if (null !== $choice && 'none' !== $choice && 1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $choice)) {
throw new InvalidPage('Die Adresse eines Bereichs darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
}
}
$changed = $this->db->executeStatement(
'UPDATE page SET header_area = ?, footer_area = ?, version = version + 1 WHERE id = ? AND version = ?',
[$header, $footer, $pageId, $expectedVersion],
);
if (1 !== $changed) {
throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Seite neu.');
}
}
/** A published page by ID, used to serve its own script file. */
public function findPublishedById(string $id, string $locale = 'de'): ?array
{
@@ -386,7 +406,7 @@ final class PageRepository
public function listForEditor(): array
{
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, p.publish_at, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.og_media_id, p.publish_at, p.header_area, p.footer_area, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
}
public function findForEditor(string $id): ?array
+23 -4
View File
@@ -62,6 +62,24 @@ final class PartialRepository
];
}
/**
* The header or footer areas a page can choose from, by address.
*
* @return array<string, string> address => name
*/
public function choices(string $kind, string $locale = 'de'): array
{
$choices = [];
foreach ($this->db->fetchAllAssociative(
'SELECT slug, name FROM partial WHERE website_id = ? AND locale = ? AND kind = ? ORDER BY slug = ? DESC, name',
['default', $locale, $kind, 'default'],
) as $row) {
$choices[$row['slug']] = $row['name'];
}
return $choices;
}
public function create(string $kind, string $slug, string $name, array $document, string $locale = 'de'): string
{
if (!isset(self::KINDS[$kind])) {
@@ -75,9 +93,10 @@ final class PartialRepository
if ('' === $name || mb_strlen($name) > 120) {
throw new InvalidPage('Bitte gib einen Namen mit höchstens 120 Zeichen an.');
}
// Header and footer exist once; several would be ambiguous at render time.
if ('section' !== $kind && null !== $this->findByKind($kind, 'default', $locale)) {
throw new InvalidPage(sprintf('Es gibt bereits einen %s.', self::KINDS[$kind]));
// Several headers and footers are allowed since pages may choose one: the first one
// keeps the address "default" and is what a page without an own choice gets.
if ('section' !== $kind && null === $this->findByKind($kind, 'default', $locale)) {
$slug = 'default';
}
$document = $this->richText->normaliseDocument($document);
$this->validator->validate($document, false);
@@ -87,7 +106,7 @@ final class PartialRepository
$id = bin2hex(random_bytes(16));
$this->db->insert('partial', [
'id' => $id, 'website_id' => 'default', 'locale' => $locale,
'kind' => $kind, 'slug' => 'section' === $kind ? $slug : 'default', 'name' => $name,
'kind' => $kind, 'slug' => $slug, 'name' => $name,
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'version' => 1,
]);
+26
View File
@@ -45,6 +45,8 @@ final class EditorController extends AbstractController
'pages' => $pages,
'trash' => $this->pages->trash(),
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
// Which header and footer areas a page can choose from.
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
]);
}
@@ -148,6 +150,30 @@ final class EditorController extends AbstractController
return $this->back('Die Beitragsdaten wurden gespeichert.', 'success');
}
/** Which header and footer this page uses, including none at all. */
#[Route('/pages/{id}/bereiche', name: 'admin_page_areas', methods: ['POST'])]
public function areas(Request $request, string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
// An empty field means "the standard area"; that is stored as null, not as an address.
$choice = static fn (string $value): ?string => '' === $value ? null : $value;
try {
$this->pages->saveAreas(
$id,
$choice($request->request->getString('headerArea')),
$choice($request->request->getString('footerArea')),
$request->request->getInt('expectedVersion', -1),
);
} catch (InvalidPage|StaleWrite $e) {
return $this->back($e->getMessage());
}
return $this->back('Kopf- und Fußbereich dieser Seite wurden gespeichert.', 'success');
}
#[Route('/pages/{id}/move', name: 'admin_move', methods: ['POST'])]
public function move(Request $request, string $id): Response
{
+14 -3
View File
@@ -41,7 +41,8 @@ final class PageController extends AbstractController
$page = $this->pages->findPublished($path);
$styles = '';
$googleFonts = null;
$areas = $this->areas();
// The page decides which header and footer it gets - including none.
$areas = $this->areas($page);
if (null !== $page) {
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
@@ -374,11 +375,21 @@ final class PageController extends AbstractController
*
* @return array<string, array>
*/
private function areas(): array
private function areas(?array $page = null): array
{
$areas = [];
foreach (['header', 'footer'] as $kind) {
$partial = $this->partials->findByKind($kind);
// A page may ask for another area or for none at all: a landing page without a
// navigation is a normal wish, and it must not need a second header document that
// happens to be empty.
$choice = $page[$kind.'_area'] ?? null;
if ('none' === $choice) {
continue;
}
$partial = $this->partials->findByKind($kind, is_string($choice) && '' !== $choice ? $choice : 'default');
// A page pointing at an area that no longer exists falls back to the standard one
// rather than losing its header without saying so.
$partial ??= $this->partials->findByKind($kind);
if (null !== $partial) {
$areas[$kind] = $partial['document'];
}