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:
1 parent
a6ffb9bdfd
commit
8e0006f883
13 files changed
+157
-26
No files matched your search
+38
-1
@@ -155,7 +155,16 @@ final class CmsTest extends TestCase
|
||||
yield 'invalid heading' => [$bad];
|
||||
$bad = $base;
|
||||
$bad['blocks'][0]['children'][0]['type'] = 'unknown-plugin';
|
||||
yield 'missing plugin cannot be silently resaved' => [$bad];
|
||||
yield 'unnamespaced unknown type stays rejected' => [$bad];
|
||||
foreach (['Acme.Slider', 'acme.', '.slider', 'acme..slider', 'acme.slider.extra', 'acme/slider'] as $type) {
|
||||
$bad = $base;
|
||||
$bad['schemaVersion'] = 4;
|
||||
$bad['blocks'][0]['children'][0]['type'] = $type;
|
||||
yield 'malformed plugin type '.$type => [$bad];
|
||||
}
|
||||
$bad = $base;
|
||||
$bad['blocks'][0]['children'][0]['type'] = 'acme.slider';
|
||||
yield 'plugin block needs schema v4' => [$bad];
|
||||
$bad = $base;
|
||||
$bad['blocks'][0]['children'][] = ['id' => 'image', 'type' => 'image', 'props' => ['src' => '/image.png', 'alt' => '', 'decorative' => false, 'width' => 100, 'height' => 100]];
|
||||
yield 'missing meaningful alt' => [$bad];
|
||||
@@ -337,6 +346,34 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('name="_username"', $this->request('/admin/login')->getContent());
|
||||
}
|
||||
|
||||
public function testPluginBlocksSurviveSavePublishAndReloadWithoutRenderingTheirProps(): void
|
||||
{
|
||||
$document = DemoContent::starter('Mit Erweiterung');
|
||||
$document['schemaVersion'] = 4;
|
||||
$document['blocks'][] = [
|
||||
'id' => 'plugin-block', 'type' => 'acme.slider',
|
||||
'props' => ['slides' => [['src' => 'javascript:alert(1)', 'headline' => 'MARKER_FROM_PLUGIN_PROPS']], 'autoplay' => true],
|
||||
'children' => [['id' => 'plugin-child', 'type' => 'text', 'props' => ['text' => 'MARKER_INSIDE_PLUGIN']]],
|
||||
];
|
||||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||||
// Stored byte for byte: a page must never lose content because a plugin is absent.
|
||||
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true));
|
||||
$this->pages->publish('page-1', $revision, 1);
|
||||
self::assertSame($document, $this->pages->findPublished('home')['document']);
|
||||
self::assertSame($document, $this->pages->findForEditor('page-1')['document']);
|
||||
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertSame(200, $this->request('/')->getStatusCode());
|
||||
// The core knows no schema for these props, so none of them may reach the output.
|
||||
self::assertStringNotContainsString('MARKER_FROM_PLUGIN_PROPS', $html);
|
||||
self::assertStringNotContainsString('MARKER_INSIDE_PLUGIN', $html);
|
||||
self::assertStringNotContainsString('javascript:', $html);
|
||||
self::assertStringNotContainsString('block-unavailable', $html);
|
||||
self::assertStringContainsString('<!-- Block plugin-block (acme.slider)', $html);
|
||||
// The rest of the page still renders.
|
||||
self::assertStringContainsString('>Mit Erweiterung</h1>', $html);
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET'): Response
|
||||
{
|
||||
$previous = [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 } from '../public/assets/editor-model.js';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer } from '../public/assets/editor-model.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { designCss } from '../scripts/build-design.mjs';
|
||||
|
||||
@@ -121,3 +121,38 @@ test('v3 media and effects keep originals, gallery restrictions, and atomic undo
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user