Forms gained steps, conditions, image choices, number fields with units and a summary step; the DEEG price finder uses all of it. Plugins are manifest plus Twig: blocks, settings, containers, routes, presets and theme packs, switched per installation. The review backlog is done: archive pages render through the normal page pipeline, forms and post lists work inside areas and sections, repeated sections get their own ids, breakpoints and colours come from the palette, backup and update work on MySQL and lock each other out. The task worker no longer shortens a process that has no time limit: set_time_limit() sets the limit instead of extending it, which turned an unlimited CLI run into eighty seconds and killed the full test suite far away from the scheduler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
12 KiB
PHP
289 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Content;
|
||
|
||
use App\Domain\InvalidPage;
|
||
use App\Domain\StaleWrite;
|
||
use Doctrine\DBAL\Connection;
|
||
|
||
/**
|
||
* Header, footer and reusable sections – block documents that are not pages.
|
||
*
|
||
* They use the same block contracts, the same validator and the same editor as a page, so a
|
||
* section behaves everywhere the same way. What differs is the heading rule: a page needs exactly
|
||
* one main heading, while a header, a footer or a section that appears on many pages must not
|
||
* bring one of its own.
|
||
*
|
||
* A page links a section instead of copying it. Copying already exists as the section presets;
|
||
* linking means one change reaches every page that uses it, which is the whole point.
|
||
*/
|
||
final class PartialRepository
|
||
{
|
||
public const KINDS = ['header' => 'Kopfbereich', 'footer' => 'Fußbereich', 'section' => 'Wiederverwendbare Sektion'];
|
||
/** A linked section may itself link one, but not deeper – and never in a circle. */
|
||
public const MAX_NESTING = 3;
|
||
|
||
public function __construct(private Connection $db, private DocumentValidator $validator, private RichText $richText = new RichText())
|
||
{
|
||
}
|
||
|
||
/** @return list<array{id: string, kind: string, slug: string, name: string, document: array, version: int}> */
|
||
public function all(string $locale = 'de'): array
|
||
{
|
||
return array_map($this->present(...), $this->db->fetchAllAssociative(
|
||
'SELECT * FROM partial WHERE website_id = ? AND locale = ? ORDER BY kind, name',
|
||
['default', $locale],
|
||
));
|
||
}
|
||
|
||
public function find(string $id): ?array
|
||
{
|
||
$row = $this->db->fetchAssociative('SELECT * FROM partial WHERE id = ? AND website_id = ?', [$id, 'default']);
|
||
|
||
return false === $row ? null : $this->present($row);
|
||
}
|
||
|
||
public function findByKind(string $kind, string $slug = 'default', string $locale = 'de'): ?array
|
||
{
|
||
$row = $this->db->fetchAssociative(
|
||
'SELECT * FROM partial WHERE website_id = ? AND locale = ? AND kind = ? AND slug = ?',
|
||
['default', $locale, $kind, $slug],
|
||
);
|
||
|
||
return false === $row ? null : $this->present($row);
|
||
}
|
||
|
||
private function present(array $row): array
|
||
{
|
||
return [
|
||
'id' => $row['id'], 'kind' => $row['kind'], 'slug' => $row['slug'], 'name' => $row['name'],
|
||
'document' => json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR),
|
||
'version' => (int) $row['version'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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])) {
|
||
throw new InvalidPage('Unbekannte Art von Bereich.');
|
||
}
|
||
$slug = strtolower(trim($slug));
|
||
if (1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $slug) || strlen($slug) > 80) {
|
||
throw new InvalidPage('Die Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
|
||
}
|
||
$name = trim($name);
|
||
if ('' === $name || mb_strlen($name) > 120) {
|
||
throw new InvalidPage('Bitte gib einen Namen mit höchstens 120 Zeichen an.');
|
||
}
|
||
// 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, true);
|
||
if (null !== $this->findByKind($kind, $slug, $locale)) {
|
||
throw new InvalidPage(sprintf('„%s“ ist als Adresse bereits vergeben.', $slug));
|
||
}
|
||
$id = bin2hex(random_bytes(16));
|
||
$this->db->insert('partial', [
|
||
'id' => $id, 'website_id' => 'default', 'locale' => $locale,
|
||
'kind' => $kind, 'slug' => $slug, 'name' => $name,
|
||
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||
'version' => 1,
|
||
]);
|
||
|
||
return $id;
|
||
}
|
||
|
||
public function save(string $id, array $document, int $expectedVersion): void
|
||
{
|
||
$document = $this->richText->normaliseDocument($document);
|
||
$this->validator->validate($document, false, true);
|
||
$this->db->transactional(function () use ($id, $document, $expectedVersion): void {
|
||
// A section must not end up containing itself, directly or through another section.
|
||
$this->guardAgainstCycles($id, $document);
|
||
$changed = $this->db->executeStatement(
|
||
'UPDATE partial SET document = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||
[json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), $id, $expectedVersion],
|
||
);
|
||
if (1 !== $changed) {
|
||
throw new StaleWrite('Der Bereich wurde inzwischen geändert. Lade die Seite neu, bevor du speicherst.');
|
||
}
|
||
});
|
||
}
|
||
|
||
public function delete(string $id): void
|
||
{
|
||
$partial = $this->find($id);
|
||
if (null === $partial) {
|
||
throw new InvalidPage('Diesen Bereich gibt es nicht mehr.');
|
||
}
|
||
if ('section' !== $partial['kind']) {
|
||
throw new InvalidPage('Kopf- und Fußbereich können nicht gelöscht werden. Leere sie stattdessen.');
|
||
}
|
||
// A page that links a removed section shows a placeholder instead of losing its layout,
|
||
// so removal is allowed – but the editor should know where it is still in use.
|
||
$this->db->executeStatement('DELETE FROM partial WHERE id = ?', [$id]);
|
||
}
|
||
|
||
/**
|
||
* Resolves a linked section for rendering.
|
||
*
|
||
* @return array{name: string, document: array}|null
|
||
*/
|
||
public function resolveSection(string $slug, string $locale = 'de'): ?array
|
||
{
|
||
$partial = $this->findByKind('section', $slug, $locale);
|
||
|
||
return null === $partial ? null : ['name' => $partial['name'], 'document' => $partial['document']];
|
||
}
|
||
|
||
/** @throws InvalidPage when the document would make a section contain itself */
|
||
private function guardAgainstCycles(string $id, array $document, int $depth = 0): void
|
||
{
|
||
$partial = $this->find($id);
|
||
if (null === $partial || 'section' !== $partial['kind']) {
|
||
return;
|
||
}
|
||
foreach ($this->referencedSlugs($document) as $slug) {
|
||
if ($slug === $partial['slug']) {
|
||
throw new InvalidPage('Eine Sektion kann sich nicht selbst enthalten.');
|
||
}
|
||
if ($depth + 1 >= self::MAX_NESTING) {
|
||
throw new InvalidPage(sprintf('Sektionen dürfen höchstens %d Ebenen tief verschachtelt werden.', self::MAX_NESTING));
|
||
}
|
||
$nested = $this->findByKind('section', $slug, $partial['locale'] ?? 'de');
|
||
if (null !== $nested) {
|
||
$this->guardAgainstCycles($id, $nested['document'], $depth + 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** @return list<string> */
|
||
/**
|
||
* Dieselbe Sektion mehrfach auf einer Seite: die zweite und jede weitere Verknüpfung bekommt
|
||
* eine Kopie mit eigenen Kennungen (`id-2`, `id-3`) und ohne Sprungziele, damit die Seite keine
|
||
* doppelten IDs trägt - ein Anker, ein Stil und jede Skriptsuche träfen sonst nur die erste.
|
||
*
|
||
* Gezählt wird in Renderreihenfolge über alle Dokumente einer Seite hinweg (`$seen` teilen sich
|
||
* Kopf, Inhalt und Fuß); verschachtelte Verknüpfungen in einer Sektion zählen mit, bis zur
|
||
* Verschachtelungsgrenze. `$sections` bekommt die Kopien unter `slug~n`, der Verweis im
|
||
* zurückgegebenen Dokument zeigt dorthin.
|
||
*
|
||
* @param array<string, array> $sections die aufgelösten Sektionen, werden um Kopien ergänzt
|
||
* @param array<string, int> $seen Zähler je Slug
|
||
* @param array<string, array> $pristine die Sektionen, wie sie gespeichert sind
|
||
*/
|
||
public function expandRepeatedLinks(array $document, array &$sections, array &$seen, array $pristine, int $depth = 0): array
|
||
{
|
||
$walk = function (array $blocks) use (&$walk, &$sections, &$seen, $pristine, $depth): array {
|
||
foreach ($blocks as $i => $block) {
|
||
$slug = 'partialref' === ($block['type'] ?? null) && is_string($block['props']['partial'] ?? null) ? $block['props']['partial'] : null;
|
||
if (null !== $slug && isset($pristine[$slug])) {
|
||
$n = $seen[$slug] = ($seen[$slug] ?? 0) + 1;
|
||
if (1 === $n) {
|
||
if ($depth < self::MAX_NESTING) {
|
||
$sections[$slug] = $this->expandRepeatedLinks($pristine[$slug], $sections, $seen, $pristine, $depth + 1);
|
||
}
|
||
} else {
|
||
$key = $slug.'~'.$n;
|
||
$copy = $this->renamed($pristine[$slug], '-'.$n);
|
||
$sections[$key] = $depth < self::MAX_NESTING ? $this->expandRepeatedLinks($copy, $sections, $seen, $pristine, $depth + 1) : $copy;
|
||
$blocks[$i]['props']['partial'] = $key;
|
||
}
|
||
}
|
||
if (isset($block['children']) && is_array($block['children'])) {
|
||
$blocks[$i]['children'] = $walk($block['children']);
|
||
}
|
||
}
|
||
|
||
return $blocks;
|
||
};
|
||
$document['blocks'] = $walk($document['blocks'] ?? []);
|
||
|
||
return $document;
|
||
}
|
||
|
||
/** Ein Dokument mit angehängter Kennung an jedem Block, ohne Sprungziele. */
|
||
private function renamed(array $document, string $suffix): array
|
||
{
|
||
$walk = static function (array $blocks) use (&$walk, $suffix): array {
|
||
foreach ($blocks as $i => $block) {
|
||
if (isset($block['id'])) {
|
||
$blocks[$i]['id'] = substr((string) $block['id'], 0, 64 - strlen($suffix)).$suffix;
|
||
}
|
||
unset($blocks[$i]['anchor']);
|
||
if (isset($block['children']) && is_array($block['children'])) {
|
||
$blocks[$i]['children'] = $walk($block['children']);
|
||
}
|
||
}
|
||
|
||
return $blocks;
|
||
};
|
||
$document['blocks'] = $walk($document['blocks'] ?? []);
|
||
|
||
return $document;
|
||
}
|
||
|
||
public function referencedSlugs(array $document): array
|
||
{
|
||
$slugs = [];
|
||
$walk = function (array $blocks) use (&$walk, &$slugs): void {
|
||
foreach ($blocks as $block) {
|
||
if ('partialref' === ($block['type'] ?? null) && is_string($block['props']['partial'] ?? null)) {
|
||
$slugs[] = $block['props']['partial'];
|
||
}
|
||
$walk($block['children'] ?? []);
|
||
}
|
||
};
|
||
$walk($document['blocks'] ?? []);
|
||
|
||
return array_values(array_unique($slugs));
|
||
}
|
||
|
||
/**
|
||
* Which pages and sections link a given section. Shown before deleting, so nobody removes
|
||
* something that is still in use without knowing it.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
public function usedBy(string $slug, string $locale = 'de'): array
|
||
{
|
||
$users = [];
|
||
foreach ($this->db->fetchAllAssociative(
|
||
'SELECT p.path, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.locale = ? AND p.deleted_at IS NULL',
|
||
['default', $locale],
|
||
) as $row) {
|
||
if (in_array($slug, $this->referencedSlugs(json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR)), true)) {
|
||
$users[] = '/'.$row['path'];
|
||
}
|
||
}
|
||
foreach ($this->all($locale) as $partial) {
|
||
if ($partial['slug'] !== $slug && in_array($slug, $this->referencedSlugs($partial['document']), true)) {
|
||
$users[] = $partial['name'];
|
||
}
|
||
}
|
||
|
||
return $users;
|
||
}
|
||
}
|