feat: Divi composer – short/full form to blocks with structure rules, presets and labels

Labels, background_color and preset ids go through the choke point (S13);
ambiguous preset names fail with their ids (S13a); fullwidth + rows is an
error. Carry-ins: control characters stripped before the marker check (S32),
kind override may only tighten, reference alt texts dropped, color check via
VariableRef::typeOf (S33).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 14:15:33 +02:00
co-authored by Claude Opus 5.5
parent f86cd5b34a
commit 4b72c2bb42
11 changed files with 1314 additions and 17 deletions
@@ -375,4 +375,63 @@ final class AttrWriterTest extends TestCase
$result['attrs']['module']['decoration']['background']['desktop']['value']['color']
);
}
/** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */
public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void
{
$writer = ComposeFixture::attrWriter();
self::assertSame("a\tb\n", $writer->cleanValue('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f"));
self::assertSame("<p>a\nb\r</p>", $writer->cleanValue('divi/text', 'content.innerContent', "<p>a\x0b\nb\r\x08</p>"));
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', "Ti\x00tel\x7f"));
$tail = '({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$';
$targets = [
'text' => ['divi/heading', 'title.innerContent'],
'html' => ['divi/text', 'content.innerContent'],
'code' => ['divi/code', 'content.innerContent'],
];
foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) {
$value = '$variable' . $control . $tail;
// The fake keeps them like sanitize_text_field does: the production code must catch it.
self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text($value));
foreach ($targets as $kind => [$module, $path]) {
try {
$writer->cleanValue($module, $path, $value);
self::fail(sprintf('%s: control %d not caught', $kind, ord($control)));
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('allein im Wert', $e->getMessage());
}
}
}
}
/** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */
public function testKindOverrideMayOnlyTighten(): void
{
$writer = ComposeFixture::attrWriter();
self::assertSame('x', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p>', null, 'text'));
self::assertSame('x', $writer->cleanValue('divi/code', 'content.innerContent', '<b>x</b>', null, 'text'));
self::assertSame('<p>x</p>', $writer->cleanValue('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, 'html'));
self::assertSame('Los', $writer->cleanValue('divi/heading', 'title.innerContent', '<b>Los</b>', null, 'text'));
self::assertSame('/a', $writer->cleanValue('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url'));
$weaker = [
'html over text' => ['divi/heading', 'title.innerContent', null, 'html'],
'code over text' => ['divi/heading', 'title.innerContent', null, 'code'],
'code over html' => ['divi/text', 'content.innerContent', null, 'code'],
'url over text' => ['divi/heading', 'title.innerContent', null, 'url'],
'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'],
'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'],
];
foreach ($weaker as $name => [$module, $path, $sub, $kind]) {
try {
$writer->cleanValue($module, $path, 'x', $sub, $kind);
self::fail($name . ' was accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Feldart', $e->getMessage(), $name);
}
}
}
}