Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog

Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-16 07:32:35 +02:00
1 parent 66ff68abf8
commit c8a8fa7460
74 files changed
+5352 -243

No files matched your search

+62 -5
View File
@@ -8,7 +8,7 @@ import { preview, rowPreview } from './library-previews.js';
import { decorateContainers } from './preview-containers.js';
import { initMotion } from './motion.js';
import { initSliders } from './slider.js';
import { VISIBILITY, setVisibility } from './editor-model.js';
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS } from './editor-model.js';
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
@@ -243,7 +243,7 @@ function renderProperties() {
const button = document.createElement('button'); button.type = 'button'; button.className = 'crumb'; button.textContent = item.type ? blockLabel(item) : 'Seite';
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
}
if (block && isPluginBlock(block)) {
if (block && isPluginBlock(block) && !pluginDefinition(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';
@@ -337,6 +337,8 @@ function renderContent(block, panel) {
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
return;
}
const plugin = pluginDefinition(block);
if (plugin) { renderPluginFields(block, panel, plugin, field); return; }
if (block.type === 'text') {
// Formatted text with a toolbar and an HTML view. The document moves to v12 on the first
// formatted save; until then it keeps plain text semantics and stays escaped when rendered.
@@ -665,7 +667,7 @@ function renderLegacyTokens(block, panel) {
// Token based values from documents written before schema v10. Shown only while a block
// still carries them, so older pages stay editable without cluttering the new panel.
const hint = document.createElement('p'); hint.className = 'design-scope';
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 650 px'}`;
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 767 px'}`;
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = 'Oben die Vorschaugröße wählen. Ohne eigenen Wert gilt die Einstellung des größeren Geräts oder des Themes.';
panel.append(hint, help);
for (const [key, token] of Object.entries(designTokens)) {
@@ -714,6 +716,35 @@ function renderLegacyEffects(block, panel) {
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit „Bewegung testen“ lässt sich das in der Vorschau prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note);
}
/**
* Die Felder eines Plugin-Bausteins, aus dem Schema seines Manifests erzeugt. Dieselbe
* Feldfabrik wie für Kernbausteine; nur ein Bild braucht die Mediathek.
*/
function renderPluginFields(block, panel, plugin, field) {
if (plugin.description) { const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = plugin.description; panel.append(hint); }
for (const [key, schema] of Object.entries(plugin.props || {})) {
const value = block.props?.[key] ?? schema.default;
const options = { help: schema.help || undefined };
if (schema.kind === 'textarea') field(schema.label, key, value, { ...options, multiline: true, maxLength: schema.maxLength });
else if (schema.kind === 'number') field(schema.label, key, value, { ...options, number: true, min: schema.min, max: schema.max });
else if (schema.kind === 'boolean') field(schema.label, key, value === true, { ...options, checkbox: true });
else if (schema.kind === 'select') field(schema.label, key, value, { ...options, choices: schema.choices });
else if (schema.kind === 'media') {
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = schema.label; wrap.append(label);
const preview = createMediaPreview({ mediaId: value || '' }); wrap.append(preview.element);
const set = id => { history.change(doc => { const current = locate(doc.blocks, block.id)?.block; if (current) (current.props ??= {})[key] = id; }); renderProperties(); changed(); };
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Bild wählen' : 'Bild aus Mediathek wählen';
pick.addEventListener('click', () => openMedia({ multiple: false, onChoose: items => { if (items[0]?.id) set(items[0].id); } }));
wrap.append(pick);
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
if (schema.help) { const help = document.createElement('p'); help.className = 'field-help'; help.textContent = schema.help; wrap.append(help); }
panel.append(wrap);
}
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength });
}
}
function chooseMedia(multiple) {
const target = selected;
openMedia({ multiple, onChoose: items => {
@@ -1012,6 +1043,9 @@ async function load() {
if (!designSchema) await loadDesignSchema();
history = new EditorHistory(data.document);
({ version, revisionId, publishedRevisionId, csrfToken } = data);
// Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten
// bekommt dafür eine eigene Gruppe.
registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets();
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
} catch (error) { notify(error.message, true); }
finally { busy = false; controls(); }
@@ -1024,7 +1058,13 @@ async function save(publish = false) {
try {
const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot });
({ version, revisionId, publishedRevisionId, csrfToken } = data);
history.markSaved(snapshot);
// Der Server bereinigt Rich Text beim Speichern. Weicht seine Fassung ab, uebernimmt der
// Editor sie - als gespeicherter Stand, ohne den Verlauf zu verlieren.
if (!publish && data.document && JSON.stringify(data.document) !== JSON.stringify(snapshot) && JSON.stringify(history.document) === JSON.stringify(snapshot)) {
history.adopt(data.document);
history.markSaved(data.document);
renderTree(); renderProperties(); schedulePreview();
} else history.markSaved(snapshot);
notify();
} catch (error) { notify(error.message, true); }
finally { busy = false; controls(); }
@@ -1101,6 +1141,23 @@ function markInsertTarget() {
// editor describes in its own hint was not the structure the buttons followed.
// ---------------------------------------------------------------------------------------------
/** Die Seitenvorlagen der Plugins, hinter den eingebauten, mit demselben Knopf. */
function buildPluginPresets() {
const presets = $('#presets');
presets.querySelectorAll('[data-plugin-preset]').forEach(button => button.remove());
for (const preset of Object.values(PLUGIN_PRESETS)) {
const button = document.createElement('button'); button.type = 'button'; button.dataset.preset = preset.id; button.dataset.pluginPreset = '';
button.title = preset.description ? `${preset.description} (${preset.plugin})` : preset.plugin;
const icon = document.createElement('span'); icon.setAttribute('aria-hidden', 'true'); icon.textContent = '⬡';
button.append(icon, document.createTextNode(` ${preset.label}`));
presets.append(button);
}
controls();
}
/** Die Zeichnung für einen Plugin-Baustein im Werkzeugkasten: ein Steckstück. */
const PLUGIN_PREVIEW = '<svg viewBox="0 0 64 40" aria-hidden="true"><path d="M20 8h-8v24h40V8h-8v4a4 4 0 0 1-8 0V8h-8v4a4 4 0 0 1-8 0Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>';
function buildLibrary() {
const library = $('#library');
library.replaceChildren();
@@ -1128,7 +1185,7 @@ function buildLibrary() {
const picture = document.createElement('span');
picture.className = 'library-preview';
// Drawn, not written: the shapes come from this editor and carry no untrusted text.
picture.innerHTML = columns ? rowPreview(columns) : preview(type);
picture.innerHTML = columns ? rowPreview(columns) : (preview(type) || PLUGIN_PREVIEW);
const text = document.createElement('span');
text.className = 'library-name';
text.textContent = name;