A site can have further languages (Settings → Languages). A page is built once; each language adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111, document schema v27, migration 20261009000100 for page_translation). - Editor: DE | EN switch, a list of all texts with original, state (missing, translated, original changed) and filter; structure stays with the main language. Undo, drafts, publishing and revisions cover all languages at once. - Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale, sitemap; links to translated pages follow the language; a language switch block. - Header, footer and linked sections translate the same way; menu entries carry labels per language; fixed template texts come from a small dictionary. - Forms: labels, help, options, button and success message per language; messages and the step script speak the language, the submission and the email stay in the main language. - Posts, archives, category names, consent text and the 404 page follow the language. - Single-language sites see none of it and never touch the new table. The forms administration becomes an overview with one row per form and its own edit page; fields are one line each with details collapsed. Co-Authored-By: Claude Opus 5.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 = Translations::normalise($this->richText->normaliseDocument($document), $this->richText);
|
||
$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 = Translations::normalise($this->richText->normaliseDocument($document), $this->richText);
|
||
$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;
|
||
}
|
||
}
|