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>
224 lines
9.1 KiB
PHP
224 lines
9.1 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);
|
||
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);
|
||
$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> */
|
||
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;
|
||
}
|
||
}
|