Beitragsnavigation als Baustein (Dokumentschema v18)
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

Umut: "Beitragsseite gefaellt mir nicht, sollte wie bei kant-it.de sein
oder besser. Falls noetig Plugin bauen."

Die Originalseite hat Galerie, Vor/Zurueck zwischen Projekten und einen
Abschlussaufruf. Galerie und Aufruf gab es laengst - sie waren nur nicht
benutzt. Die Navigation gab es nicht.

Kein Plugin: ein Plugin ist hier ein Bausteintyp, dessen Eigenschaften der
Kern aufbewahrt, aber nicht rendert - es koennte also nichts ausgeben,
solange es keine Laufzeit dafuer gibt. Und die Nachbarn eines Beitrags
stehen in derselben Tabelle wie die Beitragsliste, die der Kern schon
fuellt. Also ein Baustein: postnav.

Gespeichert sind zwei Beschriftungen, mehr nicht - welcher Beitrag vorher
und nachher kommt, entscheidet der Renderer. Gesucht wird innerhalb
derselben Kategorie, und am Rand wird nicht umgebrochen.

Ausserdem: die uebernommenen Projekttexte begannen mit dem wiederholten
Titel (20 Beitraege), und schmale Abschnitte standen links statt mittig -
max-width ohne margin:auto ist eine halbe Einstellung.

Geprueft: 396 Tests unter PHP 8.2 und 8.4, 18 JS-Tests, Editortest,
Browserpruefung, axe ohne Verstoss auf der Beitragsseite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-13 10:56:18 +02:00
1 parent 5a5933f8cd
commit 1b22265a54
14 files changed
+148 -14

No files matched your search

+10 -2
View File
@@ -27,6 +27,8 @@ final class DocumentValidator
public const V14_TYPES = ['embed'];
/** The sizes an icon can have; each one has a class in site.css. */
public const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
/** Schema v18: die Navigation zwischen Beiträgen, vom Renderer gefüllt. */
public const V18_TYPES = ['postnav'];
/** Schema v15: a single icon, a progress bar and a slider that works without a script. */
public const V15_TYPES = ['icon', 'progress', 'slider'];
@@ -38,7 +40,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, 16, 17], 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, 17, 18], 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.');
@@ -66,7 +68,7 @@ final class DocumentValidator
// page: a deactivated plugin must never make stored content unreadable. Nothing from
// props is rendered for such a block, so unvalidated values never reach the output.
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
// else it would produce a details element without its group.
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
@@ -124,6 +126,12 @@ final class DocumentValidator
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
$headings += 1 === $props['level'] ? 1 : 0;
}
if ('postnav' === $type) {
// Nur die Beschriftungen sind Inhalt. Welche Beiträge es sind, entscheidet der
// Renderer beim Ausliefern - gespeicherter Inhalt kann keinen Beitrag behaupten.
$this->text($props['previousLabel'] ?? null, 60);
$this->text($props['nextLabel'] ?? null, 60);
}
if ('postlist' === $type) {
// The list is filled by the renderer, never by stored content: the block only says
// how many posts to show and which term to limit them to.
+39
View File
@@ -153,6 +153,45 @@ final class PostRepository
/**
* @return array<string, list<array{slug: string, name: string, href: string}>>
*/
/**
* Der vorherige und der nächste Beitrag, in der Reihenfolge des Archivs.
*
* Gesucht wird innerhalb derselben Kategorie, wenn der Beitrag eine hat: von einem Projekt
* führt man zum nächsten Projekt und nicht zur nächsten Pressemitteilung. Hat er keine, gilt
* die ganze Liste. Am Rand wird nicht umgebrochen - der erste Beitrag hat keinen Vorgänger,
* und ein Ring aus zwei Beiträgen wäre eine Schleife, keine Navigation.
*
* @return array{previous: ?array, next: ?array}
*/
public function neighbours(string $pageId, string $locale = 'de'): array
{
$termId = (string) $this->db->fetchOne(
'SELECT t.id FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? AND t.kind = ? ORDER BY t.name LIMIT 1',
[$pageId, 'category'],
);
// Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale)['posts'];
// present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse.
// Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist.
$own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId]));
$index = null;
foreach ($all as $position => $post) {
if ($post['href'] === $own) {
$index = $position;
break;
}
}
if (null === $index) {
return ['previous' => null, 'next' => null];
}
return [
// „Vorheriges" ist im Archiv der neuere Beitrag, weil dort das Neueste oben steht.
'previous' => $all[$index - 1] ?? null,
'next' => $all[$index + 1] ?? null,
];
}
public function termsFor(string $pageId): array
{
$terms = ['category' => [], 'tag' => []];