diff --git a/config/packages/test/twig.yaml b/config/packages/test/twig.yaml new file mode 100644 index 0000000..e0cb3c2 --- /dev/null +++ b/config/packages/test/twig.yaml @@ -0,0 +1,4 @@ +twig: + # The test kernel runs with debug off, so Twig would otherwise keep a compiled template from an + # earlier run and make the suite pass or fail depending on leftover cache in var/cache/test. + auto_reload: true diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e388fb7..494c9c1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,3 +116,15 @@ Migration `20260912000400` ergänzt `page.path`, `page.parent_id`, `page.type` u Anlegen und Löschen laufen über Formulare, nicht über die JSON-API: `POST /admin/pages` benötigt `ROLE_EDITOR`, `POST /admin/pages/{id}/delete` benötigt `ROLE_ADMIN`, beide mit CSRF-Token. Eine neue Seite entsteht als privater Entwurf mit einem gültigen Startdokument (Schema v3, genau eine H1) und wird erst durch bewusstes Veröffentlichen öffentlich. Löschen entfernt alle Revisionen einer Seite, weist aber Seiten mit Unterseiten ab und prüft die erwartete Version, damit keine Inhalte unbemerkt verschwinden. Die reservierten Adressen `admin`, `media`, `assets` und `favicon` sind für Wurzelseiten gesperrt; zusätzlich hält ein Lookahead in der öffentlichen Route die CMS-Pfade aus dem Auffangmuster heraus. Rückweg: Code-Rollback allein genügt nicht, sobald Unterseiten existieren, weil ältere Software `path` und `parent_id` nicht kennt. Rückweg über das vor der Migration erzeugte Backup (`var/backups/before-hierarchy-*.db`) zusammen mit dem passenden Codestand. + +## ADR 011 – Erhalt unbekannter Blöcke (Dokumentschema v4, 12.09.2026) + +Bisher wies der Lesevalidator jeden unbekannten Blocktyp ab. Eine Seite mit Bausteinen aus einer deaktivierten oder noch nicht installierten Erweiterung wäre damit nicht mehr ladbar gewesen – der Fallback im Renderer war unerreichbar. AGENTS.md und der Entwicklungsplan verlangen für fehlende Blocktypen aber einen kontrollierten Fallback. Dieser Vertrag wird jetzt festgelegt, bevor es Plugins gibt, weil er sonst nachträglich über bestehende Kundeninhalte eingeführt werden müsste. + +Ab Dokumentschema v4 gilt ein Typ mit Namensraum – `^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$`, zum Beispiel `acme.slider` – als Baustein einer Erweiterung. Kernblöcke bleiben ohne Punkt; ein unbekannter Typ ohne Namensraum wird weiterhin abgewiesen. Für Plugin-Blöcke prüft der Kern weiterhin Block-ID, Verschachtelungstiefe, Blockanzahl, die Dokumentgröße von 256 KiB sowie dass `props` und `layout` Objekte sind. Ihre `props` prüft er bewusst **nicht**: er kennt das Schema der Erweiterung nicht. + +Damit ungeprüfte Werte nie in die Ausgabe gelangen, rendert der Kern aus einem solchen Block **nichts**. Öffentlich entsteht nur ein HTML-Kommentar mit ID und Typ; Besucher sehen kein kaputtes Fragment. Nur in der Editorvorschau erscheint ein sichtbarer Platzhalter, damit der Block auffindbar und verschiebbar bleibt. Kinder eines Plugin-Blocks sind erlaubt und werden gespeichert, aber nicht gerendert, weil der Kern ihr Layout nicht kennt. Kern-Designtokens gelten für Plugin-Blöcke nicht, da das Tokenmanifest ihre Typen nicht führt. + +Im Editor bleiben Plugin-Blöcke auswählbar, verschiebbar, duplizierbar und löschbar; das Eigenschaftenpanel zeigt statt Feldern einen Hinweis. In den Baustein hinein lässt sich nichts einfügen (`canContain` ist false), und `createBlock` kann keinen erzeugen – Plugin-Blöcke entstehen ausschließlich durch die jeweilige Erweiterung oder durch Import. + +Rückweg: v1 bis v3 bleiben les- und schreibbar, ein Downgrade gibt es nicht. Ältere Software darf keine v4-Revision laden. Dies ist der Erhaltungsvertrag, noch kein Pluginsystem: Registrierung, Hooks und Ressourcenladen folgen in Phase 4. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index fb34f91..e5d3bce 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,14 @@ # Qualitätsbericht – erster ausführbarer Prototyp +## Ergänzung: Erhalt unbekannter Blöcke, 12.09.2026 + +- **64 PHP-Tests / 229 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **11 JavaScript-Modelltests**. +- Geprüft, dass ein v4-Dokument mit einem Baustein `acme.slider` samt verschachteltem Text unverändert gespeichert, veröffentlicht und wieder geladen wird, und dass aus diesem Block **nichts** in die öffentliche Ausgabe gelangt: weder seine ungeprüften Props noch seine Kindinhalte noch eine darin abgelegte `javascript:`-URL. Die übrige Seite rendert normal. Sechs fehlerhafte Namensraumformen sowie Plugin-Blöcke in den Schemaversionen v1 bis v3 werden weiterhin vor jedem Schreibzugriff abgewiesen. +- Im JavaScript-Modell geprüft: Erkennung des Namensraums, Beschriftung und Symbol ohne Wörterbucheintrag, kein Einfügen in den Baustein, keine Erzeugung über die Elementbibliothek, Verschieben und Duplizieren mit neuen IDs bei unveränderten Props sowie ein vollständiges Undo auf den Ausgangszustand. +- Beim Schreiben dieser Tests wurde ein Fehler in der Testumgebung sichtbar: das Testkernel läuft ohne Debug, wodurch eine im Cache liegende kompilierte Twig-Vorlage den Ausgang des Laufs bestimmen konnte. Twig lädt in der Testumgebung jetzt automatisch neu; die Wirkung wurde gegengeprüft, indem die Vorlage bewusst verändert wurde und der Test daraufhin fehlschlug. +- `composer lint`, `lint:twig`, `lint:yaml`, `lint:container` und die Syntaxprüfung der Editormodule sind erfolgreich. +- **Auch für diesen Schritt wurden auf Wunsch des Nutzers keine Browser-, axe- und Lighthouse-Läufe ausgeführt.** Der Platzhalter in der Editorvorschau und der Hinweis im Eigenschaftenpanel sind nicht visuell geprüft. + ## Ergänzung: Seitenerstellung und Adresshierarchie, 12.09.2026 - **56 PHP-Tests / 212 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **10 JavaScript-Modelltests**. Neu gegenüber dem vorigen Stand sind 15 Tests zu Seitenerstellung, Adresshierarchie, Löschen und Zugriffsrechten; die Liste steht in STATUS. diff --git a/docs/STATUS.md b/docs/STATUS.md index 4c5a250..3a7b9bf 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -25,13 +25,16 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst. - Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar. - Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster. +- Dokumentformat v4: Blocktypen mit Namensraum (`acme.slider`) gelten als Bausteine einer Erweiterung und werden erhalten statt abgewiesen. Öffentlich rendert der Kern aus ihnen nichts außer einem HTML-Kommentar, in der Editorvorschau einen Platzhalter. Im Editor bleiben sie auswählbar, verschiebbar, duplizierbar und löschbar, aber nicht bearbeitbar. v1 bis v3 bleiben unverändert unterstützt. - Login-Throttling nutzt in der Testumgebung einen Array-Cache. Zuvor überdauerten die Zähler den Testlauf und sperrten die Anmeldung nach wenigen Wiederholungen aus, was als Testfehler erschien. - Lokale PHP-/Browser-/Lighthouse-Prüfungen und minimale GitHub-Actions-Konfiguration einschließlich vorbereitetem MySQL-Smokejob. ## Geprüft -- 56 PHP-Tests / 212 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 10 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen. +- 64 PHP-Tests / 229 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 11 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen. - Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator. +- Plugin-Erhalt geprüft: ein v4-Dokument mit `acme.slider` samt verschachteltem Text wird Byte für Byte gespeichert, veröffentlicht und wieder geladen; weder Props noch Kindinhalte noch eine `javascript:`-URL erscheinen in der öffentlichen Ausgabe, die übrige Seite rendert normal. Fehlerhafte Namensräume und Plugin-Blöcke in v1 bis v3 werden weiterhin abgewiesen, ohne zu schreiben. Im JavaScript-Modell zusätzlich: Verschieben, Duplizieren mit neuen IDs, unverändertes Undo, kein Einfügen in den Baustein und keine Erzeugung über die Elementbibliothek. +- Testumgebung: Twig lädt Vorlagen jetzt automatisch neu. Zuvor konnte eine veraltete kompilierte Vorlage im Cache den Ausgang des Testlaufs bestimmen; der Fehler wurde beim Schreiben des Plugin-Tests sichtbar und die Behebung mit einer bewusst veränderten Vorlage gegengeprüft. - Migration `20260912000400` zuerst gegen eine Kopie der lokalen Datenbank geprüft und die erzeugten Anweisungen kontrolliert: vier reine `ALTER TABLE … ADD COLUMN`, kein Tabellenneuaufbau. Danach nach konsistentem Backup unter `var/backups/before-hierarchy-20260912-102402.db` lokal angewendet; Backfill, Indexumstellung und unveränderte Dokumente überprüft. Wiederholtes Seeding und Produktionsbuild erfolgreich. - Composer-/Format-/Twig-/YAML-/Containerprüfungen, Migration, wiederholtes Seeding und Produktionsbuild erfolgreich. - Frische Quellkopie ohne bestehende DB/Secrets/Dev-Abhängigkeiten erfolgreich eingerichtet; gespeicherte Seite als HTTP 200 gerendert. @@ -52,14 +55,14 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - Seiten lassen sich anlegen und löschen, aber noch nicht umbenennen oder verschieben: eine Adressänderung müsste die Pfade aller Unterseiten mitziehen und Weiterleitungen anlegen. Beiträge werden gespeichert und ausgegeben, haben aber noch keine Übersichtsseite, keine Sortierung nach Datum und keine strukturierten Daten. Die Hauptnavigation ist weiterhin fest im Template; verschachtelte Seiten erscheinen dort nicht automatisch. - Für diesen Schritt wurden bewusst keine Browser-, Lighthouse- oder axe-Läufe ausgeführt (Nutzerentscheidung). Oberfläche und Ausgabe der neuen Formulare sind nur über die Funktionstests geprüft, nicht visuell. - SQLite tatsächlich getestet. MySQL-/MariaDB-Kompatibilität und CI erst vorbereitet, nicht nachgewiesen; Anbieter-/Tarifdetails und Livebetrieb offen. -- Plugin-Fallback im Renderer noch nicht durch den strikten Lesevalidator erreichbar; unbekannte Blocktypen werden derzeit abgewiesen, nicht gelöscht. Ausbau in Phase 4. +- Der Erhaltungsvertrag für Plugin-Blöcke steht, ein Pluginsystem nicht: Registrierung, Hooks, Abhängigkeiten und Ressourcenladen folgen in Phase 4. Kinder eines Plugin-Blocks werden gespeichert, aber nicht gerendert. Kern-Designtokens gelten für sie nicht. - Kein Inhaltscache, nur Symfony-Programmcache. Seiten senden `no-store`; Veröffentlichung ist dadurch ohne Cacheinvalidierungsverzögerung sichtbar. - Noch keine repräsentativen Kundeninhalte/Bilder/Blog-/Formularseite, keine Felddaten und keine vollständige manuelle Barrierefreiheitsprüfung. - Die vor der Hostingantwort erzeugte `.venv` bleibt ungenutzt und von Git ausgeschlossen. Die automatische Freigabeprüfung blockierte ihren Löschversuch mit „blocked by policy“; keine Umgehung versucht. Auch `var/fresh-check`, DB, Secrets und Berichte sind lokal ignoriert. ## Nächster konkreter Schritt -Unbekannte Blocktypen im Validator und Renderer erhalten statt abweisen, damit ein deaktiviertes Plugin in Phase 4 keine Seite unlesbar macht. Danach Seiten umbenennen/verschieben samt Pfadaktualisierung und Weiterleitungen, eine aus der Hierarchie erzeugte Navigation sowie Beitragsübersicht und Passwortänderung. Die wichtigsten Editorabläufe anhand einer konkreten UX-Builder-Referenz abgleichen. +Seiten umbenennen/verschieben samt Pfadaktualisierung und Weiterleitungen, eine aus der Hierarchie erzeugte Navigation sowie Beitragsübersicht und Passwortänderung. Die wichtigsten Editorabläufe anhand einer konkreten UX-Builder-Referenz abgleichen. Parallel Pilotwebsite, reale UX-Builder-Referenz und Hostingausstattung konkretisieren. Noch keine Pilotwebsite ausgewählt; `kant-it.de` ist allein durch seine Erwähnung im Plan nicht beauftragt. Keine externe Veröffentlichung erfolgt. diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index 2bf5abc..db7d7d3 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -1,7 +1,14 @@ export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie' }; export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦' }; +// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the +// document, stay selectable and movable, but the editor never edits or fills them. +export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/; +export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type); +export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt'); +export const blockIcon = block => icons[block?.type] ?? '❔'; export const isContainer = block => ['section', 'row', 'column', 'gallery'].includes(block?.type); -export const canContain = (parent, block) => isContainer(parent) && (parent.type !== 'gallery' || block.type === 'image'); +// Nothing may be dropped into a plugin block: the core does not know its layout contract. +export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent) && (parent.type !== 'gallery' || block.type === 'image'); export const clone = value => structuredClone(value); export function locate(blocks, id, parent = null) { @@ -69,7 +76,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' } // Called only inside a history transaction: all other fields and IDs stay untouched. export function upgradeDocument(document, version = 2) { - if (![1, 2, 3].includes(document.schemaVersion) || ![2, 3].includes(version)) throw new Error('Unbekanntes Dokumentschema.'); + if (![1, 2, 3, 4].includes(document.schemaVersion) || ![2, 3, 4].includes(version)) throw new Error('Unbekanntes Dokumentschema.'); document.schemaVersion = Math.max(document.schemaVersion, version); return document; } @@ -86,7 +93,7 @@ export function effectiveDesign(block, device, key) { } export function setDesign(document, id, device, key, value) { - if (![1, 2, 3].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.'); + if (![1, 2, 3, 4].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.'); if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.'); const block = locate(document.blocks, id)?.block; if (!block) return; diff --git a/public/assets/editor.js b/public/assets/editor.js index e658ff3..9c37cfe 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -1,4 +1,4 @@ -import { EditorHistory, labels, icons, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js'; +import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js'; import { openMedia } from './media.js'; const root = document.querySelector('#editor'); @@ -63,7 +63,7 @@ function controls() { function renderTree() { const activeId = $('#structure').contains(document.activeElement) ? document.activeElement.dataset.blockId : null; const query = $('#tree-search').value.trim().toLocaleLowerCase('de'); - const matches = block => `${labels[block.type]} ${block.props?.text || ''} ${block.props?.alt || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches); + const matches = block => `${blockLabel(block)} ${block.props?.text || ''} ${block.props?.alt || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches); const build = blocks => { const list = document.createElement('ul'); list.className = 'structure-list'; for (const block of blocks) { @@ -73,7 +73,7 @@ function renderTree() { const toggle = document.createElement('button'); toggle.className = 'tree-toggle'; toggle.type = 'button'; toggle.textContent = collapsed.has(block.id) && !query ? '▸' : '▾'; toggle.dataset.toggle = block.id; - toggle.setAttribute('aria-label', `${labels[block.type]} ${block.id} ${collapsed.has(block.id) ? 'öffnen' : 'einklappen'}`); + toggle.setAttribute('aria-label', `${blockLabel(block)} ${block.id} ${collapsed.has(block.id) ? 'öffnen' : 'einklappen'}`); toggle.setAttribute('aria-expanded', String(!collapsed.has(block.id) || Boolean(query))); toggle.disabled = Boolean(query); item.append(toggle); } @@ -81,9 +81,9 @@ function renderTree() { button.className = 'tree-item'; button.type = 'button'; button.draggable = true; button.dataset.blockId = block.id; button.setAttribute('aria-pressed', String(block.id === selected)); - button.setAttribute('aria-label', `${labels[block.type]}: ${block.props?.text || block.props?.alt || labels[block.type]}`); - const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = icons[block.type]; - const text = document.createElement('span'); text.textContent = block.props?.text || block.props?.alt || labels[block.type]; + button.setAttribute('aria-label', `${blockLabel(block)}: ${block.props?.text || block.props?.alt || blockLabel(block)}`); + const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = blockIcon(block); + const text = document.createElement('span'); text.textContent = block.props?.text || block.props?.alt || blockLabel(block); button.append(icon, text); item.append(button); if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children)); list.append(item); @@ -115,15 +115,23 @@ function highlight(scroll = false) { function renderProperties() { const block = locate(history.document.blocks, selected)?.block; if (!block) propertyPanel = 'content'; - $('#properties-title').textContent = block ? labels[block.type] : 'Seiteneinstellungen'; + $('#properties-title').textContent = block ? blockLabel(block) : 'Seiteneinstellungen'; const panel = $('#properties'); panel.replaceChildren(); document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel))); $('#breadcrumbs').replaceChildren(); for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)]) { - const button = document.createElement('button'); button.className = 'text-button'; button.textContent = item.type ? labels[item.type] : 'Seite'; + const button = document.createElement('button'); button.className = 'text-button'; button.textContent = item.type ? blockLabel(item) : 'Seite'; button.setAttribute('aria-current', item.id === selected ? 'location' : 'false'); button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button); } + if (block && isPluginBlock(block)) { + // The core has no schema for a plugin block, so it offers structure actions only and never + // touches its stored properties. + const note = document.createElement('p'); note.className = 'panel-note'; + note.textContent = `Der Baustein „${block.type}“ stammt aus einer Erweiterung, die in dieser Installation nicht verfügbar ist. Sein Inhalt bleibt beim Speichern und Veröffentlichen unverändert erhalten. Verschieben, Duplizieren und Löschen sind möglich, Bearbeiten nicht.`; + panel.append(note); + return; + } if (block && propertyPanel === 'design') { renderDesign(block, panel); return; } const field = (label, key, value, options = {}) => { const id = `property-${key}`; diff --git a/public/assets/preview.css b/public/assets/preview.css index 95329bc..36f8c82 100644 --- a/public/assets/preview.css +++ b/public/assets/preview.css @@ -1,2 +1,6 @@ [id].editor-selected{outline:3px solid #437754!important;outline-offset:-3px;scroll-margin-top:14px} [id].editor-hover:not(.editor-selected){outline:2px dashed #64864e;outline-offset:-2px} + +/* Placeholder for a block whose plugin is missing. Editor preview only; the public page renders + nothing for it, so this style never reaches a visitor. */ +.block-unavailable{margin:12px auto;max-width:640px;padding:14px 18px;border:2px dashed #b08a3c;border-radius:8px;background:#fdf6e6;color:#6c5220;font:600 13px/1.6 system-ui,sans-serif} diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index e602e82..85b9dda 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -9,7 +9,7 @@ final class DocumentValidator public function validate(array $document): void { $schemaVersion = $document['schemaVersion'] ?? null; - $this->require(in_array($schemaVersion, [1, 2, 3], true), 'Unsupported document schema.'); + $this->require(in_array($schemaVersion, [1, 2, 3, 4], 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.'); @@ -29,12 +29,17 @@ final class DocumentValidator $this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && !isset($ids[$id]), 'Block IDs must be stable and unique.'); $ids[$id] = true; $type = $block['type'] ?? null; - $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || (3 === $schemaVersion && 'gallery' === $type), 'Unknown block type. Preserve source until its plugin is available.'); + // From schema v4 on, a namespaced type belongs to a plugin. The core does not know its + // property schema, so it keeps the block untouched instead of rejecting or dropping the + // 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) || $plugin, 'Unknown block type. Preserve source until its plugin is available.'); $props = $block['props'] ?? []; $layout = $block['layout'] ?? []; $this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.'); foreach ($layout as $key => $value) { - if ('effects' === $key && 3 === $schemaVersion) { + if ('effects' === $key && $schemaVersion >= 3) { $this->require(is_array($value), 'Effects must be an object.'); $effects = ['shadow' => ['none', 'soft', 'strong'], 'hover' => ['none', 'lift', 'zoom'], 'entrance' => ['none', 'fade', 'rise']]; foreach ($value as $effect => $token) { @@ -69,7 +74,7 @@ final class DocumentValidator } foreach (['caption', 'mediaId', 'sources'] as $extra) { if (isset($props[$extra])) { - $this->require(3 === $schemaVersion, 'Media properties require schema v3.'); + $this->require($schemaVersion >= 3, 'Media properties require schema v3.'); } } if (isset($props['caption'])) { @@ -91,7 +96,8 @@ final class DocumentValidator } $children = $block['children'] ?? []; $this->require(is_array($children) && array_is_list($children), 'Children must be a list.'); - $container = in_array($type, ['section', 'row', 'column', 'gallery'], true); + // A plugin block may nest content; keeping its children is part of preserving the page. + $container = $plugin || in_array($type, ['section', 'row', 'column', 'gallery'], true); $this->require($container || [] === $children, 'Leaf blocks cannot contain children.'); foreach ($children as $child) { if ('gallery' === $type) { diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index 60ce833..544fdca 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -138,7 +138,7 @@ final class EditorController extends AbstractController } $this->validator->validate($document); - return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '']); + return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true]); } if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) { throw new \InvalidArgumentException(); diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index 95e1940..e192597 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -10,7 +10,7 @@ {% set tag = type == 'section' ? 'section' : 'div' %} <{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}"> {% if type == 'section' %}
Dieser Inhaltsblock ist derzeit nicht verfügbar.
+ {# + A block whose plugin is not available. Its stored content stays in the document; the core + renders nothing from its unvalidated props. Visitors see no broken fragment, while the + editor preview shows a placeholder so the block can still be found and moved. + #} + + {% if editing|default(false) %}Baustein „{{ type }}“ ist in dieser Installation nicht verfügbar. Der Inhalt bleibt gespeichert.
{% endif %} {% endif %} diff --git a/templates/page.html.twig b/templates/page.html.twig index 50aaeb3..cf28dce 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -22,7 +22,7 @@