Unbekannte Blöcke erhalten statt abweisen (Dokumentschema v4)

Der Lesevalidator wies bisher jeden unbekannten Blocktyp ab, womit der
Fallback im Renderer unerreichbar war: eine Seite mit Bausteinen aus
einer deaktivierten Erweiterung wäre nicht mehr ladbar gewesen. Ab
Schema v4 gilt ein Typ mit Namensraum (acme.slider) als Baustein einer
Erweiterung und bleibt erhalten. Der Kern prüft weiterhin ID, Tiefe,
Blockanzahl und Dokumentgröße, aber bewusst nicht die Props - er kennt
das Schema der Erweiterung nicht.

Damit ungeprüfte Werte nie ausgegeben werden, rendert der Kern aus
einem solchen Block nichts: öffentlich nur ein HTML-Kommentar, in der
Editorvorschau ein Platzhalter. Im Editor bleiben die Blöcke
auswählbar, verschiebbar, duplizierbar und löschbar, aber nicht
bearbeitbar, und es lässt sich nichts in sie einfügen.

Testumgebung: Twig lädt Vorlagen jetzt automatisch neu. Ohne Debug
konnte eine kompilierte Vorlage im Cache den Ausgang eines Testlaufs
bestimmen.

Geprüft: 64 PHP-Tests / 229 Assertions unter PHP 8.2.12 und 8.4.22,
11 JavaScript-Modelltests, Lint/Format/Container. Keine Browser- oder
Lighthouse-Läufe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 10:37:30 +02:00
1 parent a6ffb9bdfd
commit 8e0006f883
13 files changed
+157 -26

No files matched your search

+10 -3
View File
@@ -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;
+16 -8
View File
@@ -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}`;
+4
View File
@@ -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}