fix: Task 3 review round 1 - strip control chars before the sanitizer (S32a), S34 minors

Control characters now go on the raw value in AttrWriter::cleanNode and
VariableRef::setting; after the sanitizer only rejection remains ('<' + VT/FF
no longer turns into a tag). VALUE_KEY anchored with D, exact column-width
fractions, nested rows in columns, specialty columns refused, fullwidth by
catalog category, preset ambiguity in DesignSystem::modulePresetId().

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:26:18 +02:00
co-authored by Claude Opus 5.5
parent 4b72c2bb42
commit 8688a02402
13 changed files with 278 additions and 104 deletions
@@ -128,6 +128,10 @@ final class ComposerTest extends TestCase
self::assertSame(['24_24'], $widths('1_7,5_7')[0]);
self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_7,5_7')[1]);
self::assertArrayHasKey('n[0].rows[0].columns', $widths('abc')[1]);
// Ruling S34: exact fractions – 1/11 + 11/12 is 133/132, not "about one".
self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_11,11_12')[1]);
self::assertSame([['1_5', '1_5', '1_5', '1_5', '1_5'], []], $widths('1_5,1_5,1_5,1_5,1_5'));
self::assertSame([['8_24', '8_24', '8_24'], []], $widths('1_3,1_3,1_3'));
}
public function testUpdateChangesShortFormPresetAndLabel(): void
@@ -191,9 +195,14 @@ final class ComposerTest extends TestCase
['type' => 'section', 'label' => $reference],
['type' => 'section', 'label' => ['Hero']],
['type' => 'section', 'modules' => [['type' => 'heading', 'label' => "Titel\x01\x7f"]]],
['type' => 'section', 'label' => '<' . chr(11) . 'img src=x onerror=alert(1)>Hero'],
['type' => 'section', 'label' => '<' . chr(12) . 'script>alert(1)</script>Hero'],
], null, 'nodes');
[$clean, $split, $nul, $whole, $list, $control] = $result['blocks'];
[$clean, $split, $nul, $whole, $list, $control, $vt, $ff] = $result['blocks'];
// Ruling S32a: controls go before the sanitizer, so no tag is built after it.
self::assertSame('Hero', NodeSummary::label($vt));
self::assertSame('Hero', NodeSummary::label($ff));
self::assertSame('Hero', NodeSummary::label($clean));
self::assertSame('Hero – Heading', NodeSummary::label($clean->children()[0]->children()[0]->children()[0]));
@@ -344,6 +353,24 @@ final class ComposerTest extends TestCase
self::assertSame([], $readOnly['blocks']);
}
/** Ruling S34: Divi 5 rows are nestable – a row may go into a column. */
public function testNestedRowInAColumn(): void
{
$result = ComposeFixture::composer()->compose([['type' => 'section', 'label' => 'Hero', 'rows' => [[
'columns' => '1_2,1_2',
'modules' => [
[['type' => 'row', 'columns' => '1_2,1_2', 'modules' => [[['type' => 'text', 'content' => '<p>a</p>']], []]]],
[['type' => 'text', 'content' => '<p>b</p>']],
],
]]]], null, 'nodes');
self::assertSame([], $result['errors']);
$nested = $result['blocks'][0]->children()[0]->children()[0]->children()[0];
self::assertSame('divi/row', $nested->name());
self::assertSame(['12_24', '12_24'], array_map([NodeSummary::class, 'columnWidth'], $nested->children()));
self::assertSame('divi/text', $nested->children()[0]->children()[0]->name());
}
public function testRejectsNonObjectsAndMissingTypes(): void
{
$result = ComposeFixture::composer()->compose(['section', [], ['label' => 'x']], null, 'nodes');