Files
KantSites/src/Content/PartialRepository.php
T
kantarciandClaude Opus 5 af3589c95c
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
Seitenbreite wirkt, vier Menueplaetze, Seiten ohne Kopfbereich
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>
2026-09-12 21:15:03 +02:00

224 lines
9.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}