Der bisherige Editor bot drei Farben, keine Schriftwahl und elf Auswahllisten mit 44 festen Werten. ADR 008 hatte das mit Sicherheit begründet - der Schluss war falsch: die Alternative zu beliebigem CSS-Text ist nicht eine Auswahlliste, sondern ein typisiertes Designmodell. StyleSchema erklärt jede Eigenschaft einmal mit Typ, CSS-Entsprechung und Grenzen. Validator, CSS-Erzeugung und Editorpanel lesen dieselbe Definition, also kann der Editor nichts anbieten, was der Server ablehnt. Rund fünfzig Eigenschaften in sieben Gruppen, je Block, je Gerät und für den Hover-Zustand, mit Vererbung und einzelnem Zurücksetzen. Werte sind kurze Zeichenketten mit verankertem Muster, nie CSS-Text; zusammengesetzte Deklarationen baut der Compiler selbst. Der Server erzeugt daraus pro Seite ein blockgenaues Stylesheet, das über seinen eigenen SHA-256-Hash erlaubt wird statt über unsafe-inline. Theme unter /admin/theme: freie Farbpalette als @schlüssel, Schriften aus Systemschrift oder Google Fonts, Grundtypografie mit modularer Überschriftenskala. Google Fonts auf ausdrückliche Nutzerentscheidung; die Richtlinie öffnet die Hosts nur, wenn wirklich eine eingestellt ist. Alte Tokenwerte aus v2 bis v9 bleiben gültig und im Editor unter "Klassische Werte dieser Seite" bearbeitbar. Geprüft: 154 PHP-Tests / 563 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, Lint/Format/Container. Im Browser wurde nur der Aufbau des Panels bestätigt, kein Gestaltungsvorgang von Hand ausgeführt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
271 lines
16 KiB
JavaScript
271 lines
16 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle } from '../public/assets/editor-model.js';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { designCss } from '../scripts/build-design.mjs';
|
|
|
|
function fixture() { return { schemaVersion: 1, title: 'Page', description: 'Description', blocks: [{ id: 's1', type: 'section', children: [{ id: 'h1', type: 'heading', props: { text: 'Hello', level: 1 } }, { id: 't1', type: 'text', props: { text: 'Body' } }] }, { id: 's2', type: 'section', children: [] }] }; }
|
|
|
|
test('typing coalesces into one undo and redo; saved state tracks content, not stack length', () => {
|
|
const history = new EditorHistory(fixture());
|
|
history.change(doc => doc.title = 'New', 'title');
|
|
history.change(doc => doc.title = 'New title', 'title');
|
|
assert.equal(history.past.length, 1);
|
|
history.undo(); assert.equal(history.document.title, 'Page'); assert.equal(history.dirty, false);
|
|
history.redo(); assert.equal(history.document.title, 'New title'); assert.equal(history.dirty, true);
|
|
history.markSaved(history.document); assert.equal(history.dirty, false);
|
|
history.undo(); assert.equal(history.dirty, true);
|
|
history.redo(); assert.equal(history.dirty, false);
|
|
});
|
|
|
|
test('new edit after undo drops redo and source snapshots remain immutable', () => {
|
|
const original = fixture(); const history = new EditorHistory(original);
|
|
history.change(doc => locate(doc.blocks, 't1').block.props.text = 'changed');
|
|
history.undo(); history.change(doc => doc.title = 'Branch');
|
|
assert.equal(history.future.length, 0); assert.equal(original.title, 'Page');
|
|
assert.equal(locate(original.blocks, 't1').block.props.text, 'Body');
|
|
});
|
|
|
|
test('add into selected container, add after leaf, and duplicate assigns fresh IDs recursively', () => {
|
|
const document = fixture();
|
|
const id = insertBlock(document, 'text', 's2'); assert.equal(locate(document.blocks, id).parent.id, 's2');
|
|
const after = insertBlock(document, 'button', 't1'); assert.equal(document.blocks[0].children[2].id, after);
|
|
const copy = duplicateBlock(document, 's1');
|
|
const copied = locate(document.blocks, copy).block;
|
|
assert.notEqual(copied.children[0].id, 'h1'); assert.equal(copied.children[0].props.level, 2);
|
|
assert.equal(document.blocks[0].children[0].props.level, 1);
|
|
});
|
|
|
|
test('moving prevents self/descendant cycles and supports cross-container moves', () => {
|
|
const document = fixture();
|
|
assert.equal(moveBlock(document, 's1', 'h1', true), false);
|
|
assert.equal(moveBlock(document, 's1', 's1', true), false);
|
|
assert.equal(moveBlock(document, 'h1', 't1', true), false);
|
|
assert.equal(moveBlock(document, 't1', 's2', true), true);
|
|
assert.equal(locate(document.blocks, 't1').parent.id, 's2');
|
|
assert.equal(moveBlock(document, 's2', 's1'), true);
|
|
assert.equal(document.blocks[0].id, 's2');
|
|
});
|
|
|
|
test('history persists exact block document after add, move, delete, undo and JSON reload', () => {
|
|
const history = new EditorHistory(fixture());
|
|
const id = history.change(doc => insertBlock(doc, 'image', 's2'));
|
|
history.change(doc => moveBlock(doc, id, 't1'));
|
|
const saved = structuredClone(history.document);
|
|
history.change(doc => { const target = locate(doc.blocks, id); target.siblings.splice(target.index, 1); });
|
|
history.undo(); assert.deepEqual(history.document, saved);
|
|
const loaded = new EditorHistory(JSON.parse(JSON.stringify(history.document)));
|
|
assert.deepEqual(loaded.document, saved); assert.equal(loaded.dirty, false);
|
|
assert.throws(() => createBlock('unknown'));
|
|
});
|
|
|
|
test('v1 to v2 migration preserves all content and IDs, is idempotent and rejects future versions', () => {
|
|
const original = fixture(); const migrated = upgradeDocument(structuredClone(original));
|
|
assert.deepEqual(migrated, { ...original, schemaVersion: 2 });
|
|
assert.deepEqual(upgradeDocument(structuredClone(migrated)), migrated);
|
|
assert.throws(() => upgradeDocument({ ...original, schemaVersion: 999 }));
|
|
assert.equal(original.schemaVersion, 1);
|
|
});
|
|
|
|
test('responsive inheritance, reset, migration undo, and reload preserve distinct device values', () => {
|
|
const history = new EditorHistory(fixture());
|
|
history.change(doc => setDesign(doc, 'h1', 'desktop', 'fontSize', 'hero'));
|
|
assert.equal(history.document.schemaVersion, 2);
|
|
const heading = () => locate(history.document.blocks, 'h1').block;
|
|
assert.deepEqual(effectiveDesign(heading(), 'mobile', 'fontSize'), { value: 'hero', source: 'desktop' });
|
|
history.undo(); assert.deepEqual(history.document, fixture()); history.redo();
|
|
history.change(doc => setDesign(doc, 'h1', 'tablet', 'fontSize', 'display'));
|
|
history.change(doc => setDesign(doc, 'h1', 'mobile', 'fontSize', 'title'));
|
|
assert.equal(effectiveDesign(heading(), 'desktop', 'fontSize').value, 'hero');
|
|
assert.equal(effectiveDesign(heading(), 'mobile', 'fontSize').value, 'title');
|
|
history.change(doc => setDesign(doc, 'h1', 'mobile', 'fontSize', ''));
|
|
assert.deepEqual(effectiveDesign(heading(), 'mobile', 'fontSize'), { value: 'display', source: 'tablet' });
|
|
history.undo(); assert.equal(effectiveDesign(heading(), 'mobile', 'fontSize').value, 'title');
|
|
assert.deepEqual(JSON.parse(JSON.stringify(history.document)), history.document);
|
|
});
|
|
|
|
test('presets insert after containing section, keep a single H1, unique IDs, and undo atomically', () => {
|
|
const history = new EditorHistory(fixture());
|
|
for (const name of ['intro', 'columns', 'cta']) {
|
|
const id = history.change(doc => insertPreset(doc, name, 'h1'));
|
|
assert.equal(history.document.blocks[1].id, id);
|
|
assert.deepEqual(blockPath(history.document.blocks, 'h1').map(block => block.id), ['s1', 'h1']);
|
|
}
|
|
const nodes = [];
|
|
const visit = blocks => blocks.forEach(block => { nodes.push(block); visit(block.children || []); });
|
|
visit(history.document.blocks);
|
|
assert.equal(new Set(nodes.map(block => block.id)).size, nodes.length);
|
|
assert.equal(nodes.filter(block => block.type === 'heading' && block.props.level === 1).length, 1);
|
|
history.undo(); history.undo(); history.undo(); assert.deepEqual(history.document, fixture());
|
|
});
|
|
|
|
test('committed CSS matches the shared validated token manifest', async () => {
|
|
const tokens = JSON.parse(await readFile(new URL('../public/assets/design-tokens.json', import.meta.url), 'utf8'));
|
|
assert.equal(await readFile(new URL('../public/assets/design.css', import.meta.url), 'utf8'), designCss(tokens));
|
|
});
|
|
|
|
test('v3 media and effects keep originals, gallery restrictions, and atomic undo', () => {
|
|
const history = new EditorHistory(fixture());
|
|
const media = { id: 'a'.repeat(32), alt: 'Landscape', variants: [{src: '/media/a/480.webp', width: 480, height: 320}] };
|
|
const gallery = history.change(doc => insertMedia(doc, 's2', [media, media]));
|
|
assert.equal(history.document.schemaVersion, 3);
|
|
const children = locate(history.document.blocks, gallery).block.children;
|
|
assert.equal(children.length, 2); assert.notEqual(children[0].id, children[1].id);
|
|
assert.equal(moveBlock(history.document, 'h1', gallery, true), false);
|
|
assert.equal(moveBlock(history.document, 'h1', children[0].id), false);
|
|
const text = history.change(doc => insertBlock(doc, 'text', children[0].id));
|
|
assert.notEqual(locate(history.document.blocks, text).parent?.id, gallery);
|
|
history.undo();
|
|
history.change(doc => setEffect(doc, gallery, 'shadow', 'soft'));
|
|
history.change(doc => setDesign(doc, gallery, 'mobile', 'columns', 'one'));
|
|
assert.equal(history.document.schemaVersion, 3);
|
|
history.undo(); history.undo(); history.undo(); assert.deepEqual(history.document, fixture());
|
|
});
|
|
|
|
test('plugin blocks stay movable and intact while the editor refuses to fill or edit them', () => {
|
|
const plugin = { id: 'p1', type: 'acme.slider', props: { slides: [{ headline: 'From the plugin' }] }, children: [{ id: 'pc1', type: 'text', props: { text: 'Nested' } }] };
|
|
const start = { schemaVersion: 4, title: 'Page', description: 'Description', blocks: [{ id: 's1', type: 'section', children: [{ id: 'h1', type: 'heading', props: { text: 'Hello', level: 1 } }] }, structuredClone(plugin)] };
|
|
const history = new EditorHistory(structuredClone(start));
|
|
|
|
assert.equal(isPluginBlock(plugin), true);
|
|
assert.equal(isPluginBlock({ type: 'section' }), false);
|
|
for (const type of ['Acme.Slider', 'acme.', '.slider', 'acme/slider', 'section']) assert.equal(isPluginBlock({ type }), false, type);
|
|
assert.equal(blockLabel(plugin), 'Baustein acme.slider');
|
|
assert.equal(blockIcon(plugin), '❔');
|
|
assert.equal(blockLabel({ type: 'section' }), 'Sektion');
|
|
|
|
// Nothing may be dropped into a block whose layout contract the core does not know.
|
|
assert.equal(canContain(plugin, { type: 'text' }), false);
|
|
assert.equal(isContainer(plugin), false);
|
|
assert.equal(moveBlock(history.document, 'h1', 'p1', true), false);
|
|
assert.throws(() => createBlock('acme.slider'), /Unbekannter Block/);
|
|
|
|
// Moving and duplicating preserve the stored block including its unknown props and children.
|
|
assert.equal(history.change(doc => moveBlock(doc, 'p1', 's1', true)), true);
|
|
assert.equal(locate(history.document.blocks, 'p1').parent.id, 's1');
|
|
const copy = history.change(doc => duplicateBlock(doc, 'p1'));
|
|
const duplicate = locate(history.document.blocks, copy).block;
|
|
assert.notEqual(duplicate.id, 'p1');
|
|
assert.notEqual(duplicate.children[0].id, 'pc1');
|
|
assert.deepEqual(duplicate.props, plugin.props);
|
|
assert.deepEqual(locate(history.document.blocks, 'p1').block.props, plugin.props);
|
|
|
|
// A full undo must restore the plugin block byte for byte.
|
|
history.undo(); history.undo();
|
|
assert.deepEqual(history.document, start);
|
|
assert.equal(upgradeDocument(history.document, 4).schemaVersion, 4);
|
|
assert.deepEqual(JSON.parse(JSON.stringify(history.document)).blocks.find(b => b.id === 'p1'), plugin);
|
|
});
|
|
|
|
test('the post list block carries only its filter, lifts the schema to v5 and undoes atomically', () => {
|
|
const history = new EditorHistory(fixture());
|
|
const id = history.change(doc => insertBlock(doc, 'postlist', 's2'));
|
|
const block = locate(history.document.blocks, id).block;
|
|
|
|
// Content is the filter and the count; the posts themselves are added when the page is served.
|
|
assert.deepEqual(block.props, { count: 3, category: '', tag: '' });
|
|
assert.equal(block.children, undefined);
|
|
assert.equal(history.document.schemaVersion, 5);
|
|
|
|
// Nothing can be nested inside it, and it does not swallow a following block.
|
|
assert.equal(canContain(block, { type: 'text' }), false);
|
|
const text = history.change(doc => insertBlock(doc, 'text', id));
|
|
assert.notEqual(locate(history.document.blocks, text).parent?.id, id);
|
|
|
|
history.undo(); history.undo();
|
|
assert.deepEqual(history.document, fixture());
|
|
assert.equal(history.document.schemaVersion, 1);
|
|
});
|
|
|
|
test('v6 containers accept only their own children and an accordion starts with one entry', () => {
|
|
const history = new EditorHistory(fixture());
|
|
const accordion = history.change(doc => insertBlock(doc, 'accordion', 's2'));
|
|
const block = locate(history.document.blocks, accordion).block;
|
|
|
|
assert.equal(history.document.schemaVersion, 6);
|
|
// An empty accordion would render as nothing, so it comes with one entry.
|
|
assert.equal(block.children.length, 1);
|
|
assert.equal(block.children[0].type, 'accordionitem');
|
|
assert.deepEqual(block.children[0].props, { title: 'Eine Frage', open: false });
|
|
|
|
// Only accordion entries go into an accordion, and an entry goes nowhere else.
|
|
assert.equal(canContain(block, { type: 'text' }), false);
|
|
assert.equal(canContain(block, { type: 'accordionitem' }), true);
|
|
assert.equal(canContain({ type: 'section', children: [] }, { type: 'accordionitem' }), false);
|
|
assert.equal(canContain(block.children[0], { type: 'text' }), true);
|
|
|
|
// A logo strip takes images only, like a gallery.
|
|
const logos = history.change(doc => insertBlock(doc, 'logos', 's2'));
|
|
const strip = locate(history.document.blocks, logos).block;
|
|
assert.equal(canContain(strip, { type: 'image' }), true);
|
|
assert.equal(canContain(strip, { type: 'iconcard' }), false);
|
|
|
|
// Leaf blocks stay leaves and keep their defaults.
|
|
// Two statements on purpose: history.change() replaces the document, so reading
|
|
// history.document.blocks in the same expression would search the previous snapshot.
|
|
const statId = history.change(doc => insertBlock(doc, 'stat', 's2'));
|
|
const stat = locate(history.document.blocks, statId).block;
|
|
assert.equal(stat.children, undefined);
|
|
assert.deepEqual(stat.props, { value: '15+', label: 'Jahre Erfahrung' });
|
|
|
|
history.undo(); history.undo(); history.undo();
|
|
assert.deepEqual(history.document, fixture());
|
|
});
|
|
|
|
test('a new tab group starts with two tabs and accepts nothing but tabs', () => {
|
|
const history = new EditorHistory(fixture());
|
|
const id = history.change(doc => insertBlock(doc, 'tabs', 's2'));
|
|
const group = locate(history.document.blocks, id).block;
|
|
|
|
assert.equal(history.document.schemaVersion, 7);
|
|
// One tab is not a tab group, and the enhancement needs at least two to do anything.
|
|
assert.equal(group.children.length, 2);
|
|
assert.deepEqual(group.children.map(c => c.type), ['tabitem', 'tabitem']);
|
|
assert.notEqual(group.children[0].id, group.children[1].id);
|
|
assert.deepEqual(group.children[0].props, { title: 'Neuer Tab' });
|
|
|
|
assert.equal(canContain(group, { type: 'tabitem' }), true);
|
|
assert.equal(canContain(group, { type: 'text' }), false);
|
|
assert.equal(canContain({ type: 'section', children: [] }, { type: 'tabitem' }), false);
|
|
assert.equal(canContain({ type: 'accordion', children: [] }, { type: 'tabitem' }), false);
|
|
// A tab itself holds ordinary content.
|
|
assert.equal(canContain(group.children[0], { type: 'text' }), true);
|
|
|
|
history.undo();
|
|
assert.deepEqual(history.document, fixture());
|
|
});
|
|
|
|
test('free styles inherit from the wider device, reset individually and leave no skeleton', () => {
|
|
const history = new EditorHistory(fixture());
|
|
history.change(doc => setStyle(doc, 's1', 'desktop', 'fontSize', '42px'));
|
|
history.change(doc => setStyle(doc, 's1', 'desktop', 'padding', '80px', { side: 'top' }));
|
|
history.change(doc => setStyle(doc, 's1', 'desktop', 'color', '#ffffff', { hover: true }));
|
|
history.change(doc => setStyle(doc, 's1', 'mobile', 'fontSize', '28px'));
|
|
const block = () => locate(history.document.blocks, 's1').block;
|
|
|
|
assert.equal(history.document.schemaVersion, 10);
|
|
// Tablet has no own value, so it inherits the desktop one; mobile keeps its own.
|
|
assert.deepEqual(effectiveStyle(block(), 'tablet', 'fontSize'), { value: '42px', source: 'desktop' });
|
|
assert.deepEqual(effectiveStyle(block(), 'mobile', 'fontSize'), { value: '28px', source: 'mobile' });
|
|
assert.deepEqual(effectiveStyle(block(), 'desktop', 'padding', 'top'), { value: '80px', source: 'desktop' });
|
|
assert.equal(hoverStyle(block(), 'desktop', 'color'), '#ffffff');
|
|
|
|
// Clearing one value makes it inherit again and removes the now empty container.
|
|
history.change(doc => setStyle(doc, 's1', 'mobile', 'fontSize', ''));
|
|
assert.equal(block().style.mobile, undefined);
|
|
assert.deepEqual(effectiveStyle(block(), 'mobile', 'fontSize'), { value: '42px', source: 'desktop' });
|
|
|
|
// Copying carries the styling over without sharing objects.
|
|
history.change(doc => copyStyle(doc, 's1', 's2'));
|
|
const copy = locate(history.document.blocks, 's2').block;
|
|
assert.deepEqual(copy.style, block().style);
|
|
assert.notEqual(copy.style, block().style);
|
|
|
|
history.change(doc => clearStyle(doc, 's1'));
|
|
assert.equal(block().style, undefined);
|
|
|
|
// Every step is one history entry, so undoing all of them restores the original document.
|
|
for (let i = 0; i < 7; i++) history.undo();
|
|
assert.deepEqual(history.document, fixture());
|
|
});
|