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);
}
}
}
}
@@ -5,10 +5,13 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Integrations\Divi\Compose\Composer;
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
@@ -19,6 +22,9 @@ use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
*/
final class ComposeFixture
{
/** A valid content reference as an attachment's alt text (media ids 44 and 45). */
public const REFERENCE_ALT = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
public static function sanitizer(bool $unfiltered = false): ValueSanitizer
{
return new class ($unfiltered) implements ValueSanitizer {
@@ -26,12 +32,26 @@ final class ComposeFixture
{
}
/** Like sanitize_text_field: NUL bytes, tags and %XX octets go, then trim. */
/**
* Like sanitize_text_field (_sanitize_text_fields): tags only go when there is a "<"
* (wp_strip_all_tags; PHP's strip_tags also drops NUL then), whitespace runs become one
* space, trim, %XX octets go repeatedly. Without a "<", NUL and other control
* characters stay – as in WordPress (ruling S32: AttrWriter strips them).
*/
public function text(string $value): string
{
$value = strip_tags(str_replace(chr(0), '', $value));
if (str_contains($value, '<')) {
$value = strip_tags((string) preg_replace('@<(script|style)[^>]*?>.*?</\\1>@si', '', $value));
}
return trim((string) preg_replace('/%[a-f0-9]{2}/i', '', $value));
$value = trim((string) preg_replace('/[\r\n\t ]+/', ' ', $value));
$found = false;
while (preg_match('/%[a-f0-9]{2}/i', $value, $match) === 1) {
$value = str_replace($match[0], '', $value);
$found = true;
}
return $found ? trim((string) preg_replace('/ +/', ' ', $value)) : $value;
}
/**
@@ -89,6 +109,10 @@ final class ComposeFixture
return match ($id) {
33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'],
12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''],
// Alt texts carrying a reference, whole or NUL-split (ruling S33b: dropped).
44 => ['url' => 'https://kunde.test/wp-content/uploads/a.jpg', 'alt' => ComposeFixture::REFERENCE_ALT],
45 => ['url' => 'https://kunde.test/wp-content/uploads/b.jpg', 'alt' => '$variable' . chr(0)
. substr(ComposeFixture::REFERENCE_ALT, strlen('$variable'))],
default => null,
};
}
@@ -113,4 +137,29 @@ final class ComposeFixture
{
return new ShortformWriter(self::placeholders(), self::media(), self::attrWriter($unfiltered), $modules);
}
public static function rules(): StructureRules
{
return new StructureRules(ModuleCatalogTest::sampleCatalog());
}
/**
* Builder version 5.2.1. $design replaces the sample design system (one Placeholders instance
* shared by AttrWriter and ShortformWriter, like the live wiring – ruling S31).
*/
public static function composer(bool $unfiltered = false, ?DesignSystem $design = null): Composer
{
$design ??= DesignSystemTest::sample();
$placeholders = new Placeholders($design);
$attrWriter = new AttrWriter(ModuleCatalogTest::sampleCatalog(), $placeholders, self::sanitizer($unfiltered));
return new Composer(
ModuleCatalogTest::sampleCatalog(),
self::rules(),
new ShortformWriter($placeholders, self::media(), $attrWriter),
$attrWriter,
$design,
'5.2.1'
);
}
}
@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Compose\Composer;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use PHPUnit\Framework\TestCase;
final class ComposerTest extends TestCase
{
public function testBuildsASectionFromTheShortForm(): void
{
$result = ComposeFixture::composer()->compose([[
'type' => 'section',
'label' => 'Hero',
'background_color' => '{{color:Nachtblau}}',
'rows' => [[
'columns' => '1_3,2_3',
'modules' => [
[['type' => 'image', 'image_id' => 33]],
[
['type' => 'heading', 'text' => 'Willkommen'],
['type' => 'button', 'text' => 'Termin buchen', 'url' => '/kontakt', 'preset' => 'CTA primär'],
],
],
]],
]], null, 'nodes');
self::assertSame([], $result['errors']);
self::assertStringStartsWith(
'<!-- wp:divi/section {"builderVersion":"5.2.1","module":{"decoration":{"background":{"desktop":{"value":'
. '{"color":"$variable({\u0022type\u0022:\u0022color\u0022,\u0022value\u0022:{\u0022name\u0022:'
. '\u0022gcid-a1b2\u0022,\u0022settings\u0022:{}}})$"}}}},"meta":{"adminLabel":{"desktop":{"value":'
. '"Hero"}}}}} --><!-- wp:divi/row {"builderVersion":"5.2.1","module":{"meta":{"adminLabel":'
. '{"desktop":{"value":"Hero – Zeile"}}}}} --><!-- wp:divi/column {"builderVersion":"5.2.1","module":'
. '{"decoration":{"sizing":{"desktop":{"value":{"flexType":"8_24"}}}}}} --><!-- wp:divi/image '
. '{"builderVersion":"5.2.1","image":{"innerContent":{"desktop":{"value":{"src":'
. '"https://kunde.test/wp-content/uploads/team.jpg","id":"33","alt":"Unser Team"}}}},"module":{"meta":'
. '{"adminLabel":{"desktop":{"value":"Hero – Image"}}}}} --><!-- /wp:divi/image -->',
Serializer::block($result['blocks'][0])
);
$section = $result['blocks'][0];
[$heading, $button] = $section->children()[0]->children()[1]->children();
self::assertSame('16_24', NodeSummary::columnWidth($section->children()[0]->children()[1]));
self::assertSame('h2', $heading->attrs()['title']['decoration']['font']['font']['desktop']['value']['headingLevel']);
self::assertSame('Hero – Heading', NodeSummary::label($heading));
self::assertSame(['p-2'], $button->attrs()['modulePreset']);
// What the composer writes, the parser reads back unchanged.
$markup = Serializer::block($section);
self::assertSame($markup, Serializer::document((new Parser())->parse($markup)));
self::assertFalse((new Parser())->parse($markup)->children()[0]->isOpaque());
}
public function testShortcutsItemsFullwidthAndFullForm(): void
{
$result = ComposeFixture::composer()->compose([
['type' => 'section', 'modules' => [
['type' => 'accordion', 'items' => [['title' => 'Frage', 'content' => '<p>Antwort</p>', 'open' => true]]],
['type' => 'text', 'content' => '<p>x</p>', 'label' => 'Einleitung',
'attrs' => ['module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '20px']]]]],
]],
['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [
['type' => 'fullwidth-header', 'attrs' => ['title.innerContent' => ['desktop' => 'Großer Titel']]],
]],
], null, 'nodes');
self::assertSame([], $result['errors']);
[$regular, $fullwidth] = $result['blocks'];
$column = $regular->children()[0]->children()[0];
self::assertSame('24_24', NodeSummary::columnWidth($column));
[$accordion, $text] = $column->children();
self::assertSame('divi/accordion-item', $accordion->children()[0]->name());
self::assertSame('on', $accordion->children()[0]->attrs()['module']['advanced']['open']['desktop']['value']);
self::assertSame('Einleitung', NodeSummary::label($text));
self::assertSame(['top' => '20px'], $text->attrs()['module']['decoration']['spacing']['desktop']['value']['padding']);
self::assertSame('fullwidth', $fullwidth->attrs()['module']['advanced']['type']['desktop']['value']);
self::assertSame('divi/fullwidth-header', $fullwidth->children()[0]->name());
}
public function testCollectsEveryErrorWithItsInputPath(): void
{
$result = ComposeFixture::composer()->compose([
['type' => 'button', 'text' => 'x'],
['type' => 'section', 'rows' => [
['columns' => '1_2,1_3', 'modules' => []],
['columns' => '1_2,1_2', 'modules' => [[['type' => 'slider-pro']], [['type' => 'accordion-item']]]],
]],
['type' => 'section', 'preset' => 'Gibt es nicht', 'foo' => 1],
['type' => 'section', 'modules' => [['type' => 'heading', 'attrs' => ['title.fnt' => ['desktop' => 'x']]]]],
], null, 'nodes');
self::assertSame('auf oberster Ebene sind nur Abschnitte (section) erlaubt', $result['errors']['nodes[0].type']);
self::assertStringContainsString('volle Zeile', $result['errors']['nodes[1].rows[0].columns']);
self::assertStringContainsString('unbekanntes Modul "divi/slider-pro"', $result['errors']['nodes[1].rows[1].modules[0][0].type']);
self::assertStringContainsString('passt nicht in eine Spalte', $result['errors']['nodes[1].rows[1].modules[1][0].type']);
self::assertStringContainsString('unbekanntes Preset', $result['errors']['nodes[2].preset']);
self::assertStringContainsString('unbekanntes Feld', $result['errors']['nodes[2].foo']);
self::assertSame(['title.fnt'], $result['invalid_paths']);
self::assertArrayHasKey('nodes[3].modules[0].modules[0][0].attrs.title.fnt', $result['errors']);
}
public function testColumnWidths(): void
{
$composer = ComposeFixture::composer();
$widths = static function (string $columns) use ($composer): array {
$result = $composer->compose([['type' => 'section', 'rows' => [['columns' => $columns]]]], null, 'n');
return [
array_map([NodeSummary::class, 'columnWidth'], $result['blocks'][0]->children()[0]->children()),
$result['errors'],
];
};
self::assertSame([['12_24', '12_24'], []], $widths('1_2,1_2'));
self::assertSame([['6_24', '18_24'], []], $widths('1_4, 3_4'));
self::assertSame([['2_5', '3_5'], []], $widths('2_5,3_5'));
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]);
}
public function testUpdateChangesShortFormPresetAndLabel(): void
{
$block = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]);
$result = ComposeFixture::composer()->update($block, [
'set' => ['text' => 'Neu', 'url' => '/neu'],
'preset' => 'Standard',
'label' => 'Hero – CTA',
], 'changes[0]');
self::assertSame([], $result['errors']);
self::assertSame(['text' => 'Neu', 'linkUrl' => '/neu'], $result['attrs']['button']['innerContent']['desktop']['value']);
self::assertSame(['p-1'], $result['attrs']['modulePreset']);
self::assertSame('Hero – CTA', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']);
// update() only computes attributes; the block itself stays as it was.
self::assertArrayNotHasKey('modulePreset', $block->attrs());
$cleared = ComposeFixture::composer()->update(
Block::create('divi/button', $result['attrs']),
['preset' => null, 'label' => null],
'changes[0]'
);
self::assertSame([], $cleared['errors']);
self::assertArrayNotHasKey('modulePreset', $cleared['attrs']);
self::assertArrayNotHasKey('module', $cleared['attrs']);
}
public function testUpdateReportsBadInput(): void
{
$block = Block::create('divi/button', ['builderVersion' => '5.2.1']);
$result = ComposeFixture::composer()->update($block, [
'id' => 's0.r0.c0.m0',
'set' => ['nope'],
'label' => 42,
'colour' => 'x',
], 'changes[1]');
self::assertArrayHasKey('changes[1].set', $result['errors']);
self::assertArrayHasKey('changes[1].label', $result['errors']);
self::assertStringContainsString('unbekanntes Feld', $result['errors']['changes[1].colour']);
self::assertArrayNotHasKey('changes[1].id', $result['errors']);
self::assertSame(['builderVersion' => '5.2.1'], $result['attrs']);
$opaque = Block::parsed('divi/button', [], '<!-- wp:divi/button -->', false, true);
$readOnly = ComposeFixture::composer()->update($opaque, ['label' => 'x'], 'changes[2]');
self::assertStringContainsString('nur lesbar', $readOnly['errors']['changes[2].id']);
}
/** Ruling S13: labels go through the choke point; a label that fails is an error, never stored raw. */
public function testLabelsAreCleanedOrReported(): void
{
$reference = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
$result = ComposeFixture::composer()->compose([
['type' => 'section', 'label' => '<b>Hero</b>', 'modules' => [['type' => 'heading', 'text' => 'x']]],
['type' => 'section', 'label' => '$variable<x>(' . substr($reference, strlen('$variable(')),
'modules' => [['type' => 'heading', 'text' => 'x']]],
['type' => 'section', 'label' => '$variable' . chr(0) . substr($reference, strlen('$variable'))],
['type' => 'section', 'label' => $reference],
['type' => 'section', 'label' => ['Hero']],
['type' => 'section', 'modules' => [['type' => 'heading', 'label' => "Titel\x01\x7f"]]],
], null, 'nodes');
[$clean, $split, $nul, $whole, $list, $control] = $result['blocks'];
self::assertSame('Hero', NodeSummary::label($clean));
self::assertSame('Hero – Heading', NodeSummary::label($clean->children()[0]->children()[0]->children()[0]));
foreach ([1, 2, 3, 4] as $position) {
self::assertArrayHasKey(sprintf('nodes[%d].label', $position), $result['errors']);
}
foreach ([$split, $nul, $whole, $list] as $section) {
self::assertSame('', NodeSummary::label($section));
}
// No raw label reaches the children's auto labels either.
self::assertSame('Heading', NodeSummary::label($split->children()[0]->children()[0]->children()[0]));
self::assertStringNotContainsString('$variable', Serializer::block($split));
// Ruling S32: control characters are stripped from every final string.
self::assertSame('Titel', NodeSummary::label($control->children()[0]->children()[0]->children()[0]));
}
/** Ruling S13: background_color takes the short-form color check, not text(). */
public function testBackgroundColorIsAColor(): void
{
$result = ComposeFixture::composer()->compose([
['type' => 'section', 'background_color' => '#1a1a1a'],
['type' => 'section', 'background_color' => 'red;}</style><script>alert(1)</script>'],
['type' => 'section', 'background_color' => 'red;}'],
['type' => 'section', 'background_color' => '{{post:title}}'],
['type' => 'section', 'background_color' => '$variable<x>({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
['type' => 'section', 'background_color' => ['#fff']],
], null, 'nodes');
self::assertSame('#1a1a1a', $result['blocks'][0]->attrs()['module']['decoration']['background']['desktop']['value']['color']);
foreach ([1, 2, 3, 4, 5] as $position) {
self::assertStringContainsString('Farbe erwartet', $result['errors'][sprintf('nodes[%d].background_color', $position)]);
self::assertArrayNotHasKey('module', $result['blocks'][$position]->attrs());
}
}
/** Ruling S13: a fullwidth section holds fullwidth modules; rows there are an error, not ignored. */
public function testFullwidthSectionWithRowsIsAnError(): void
{
$result = ComposeFixture::composer()->compose([
['type' => 'section', 'section_type' => 'fullwidth', 'rows' => [['columns' => '1_2,1_2']]],
['type' => 'section', 'rows' => [], 'modules' => []],
['type' => 'section', 'section_type' => 'specialty'],
['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [['type' => 'heading', 'text' => 'x']]],
], null, 'nodes');
self::assertStringContainsString('Vollbreite', $result['errors']['nodes[0].rows']);
self::assertSame([], $result['blocks'][0]->children());
self::assertStringContainsString('entweder rows oder modules', $result['errors']['nodes[1]']);
self::assertStringContainsString('specialty', $result['errors']['nodes[2].section_type']);
self::assertStringContainsString('fullwidth-Module', $result['errors']['nodes[3].modules[0].type']);
}
/** Ruling S13a: an ambiguous design-system name is an error listing the ids; ids work directly. */
public function testAmbiguousDesignNamesFailWithTheIds(): void
{
$design = DesignSystem::fromDivi(
[
'gcid-b1' => ['color' => '#00f', 'label' => 'Blau', 'status' => 'active'],
'gcid-b2' => ['color' => '#00a', 'label' => 'blau', 'status' => 'active'],
],
[],
['module' => ['divi/button' => ['default' => 'p-1', 'items' => [
'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'],
'p-3' => ['id' => 'p-3', 'name' => 'standard', 'moduleName' => 'divi/button'],
]]]],
[]
);
$composer = ComposeFixture::composer(false, $design);
$result = $composer->compose([
['type' => 'section', 'background_color' => '{{color:Blau}}', 'modules' => [
['type' => 'button', 'preset' => 'Standard'],
['type' => 'button', 'preset' => 'p-3'],
]],
], null, 'nodes');
self::assertStringContainsString('gcid-b1, gcid-b2', $result['errors']['nodes[0].background_color']);
self::assertStringContainsString('mehrdeutig', $result['errors']['nodes[0].modules[0].modules[0][0].preset']);
self::assertStringContainsString('p-1, p-3', $result['errors']['nodes[0].modules[0].modules[0][0].preset']);
self::assertArrayNotHasKey('nodes[0].modules[0].modules[0][1].preset', $result['errors']);
[$ambiguous, $byId] = $result['blocks'][0]->children()[0]->children()[0]->children();
self::assertArrayNotHasKey('modulePreset', $ambiguous->attrs());
self::assertSame(['p-3'], $byId->attrs()['modulePreset']);
$update = $composer->update(Block::create('divi/button'), ['preset' => 'STANDARD'], 'changes[0]');
self::assertStringContainsString('p-1, p-3', $update['errors']['changes[0].preset']);
}
/** Preset ids come from the design system but still pass the choke point unchanged. */
public function testPresetIdsPassTheChokePoint(): void
{
$design = DesignSystem::fromDivi([], [], ['module' => ['divi/button' => ['default' => '', 'items' => [
'<b>p</b>' => ['id' => '<b>p</b>', 'name' => 'Seltsam', 'moduleName' => 'divi/button'],
]]]], []);
$result = ComposeFixture::composer(false, $design)->update(
Block::create('divi/button'),
['preset' => 'Seltsam'],
'changes[0]'
);
self::assertArrayHasKey('changes[0].preset', $result['errors']);
self::assertArrayNotHasKey('modulePreset', $result['attrs']);
}
public function testBuilderVersionMustBeAVersion(): void
{
$this->expectException(InvalidArgumentException::class);
new Composer(
ModuleCatalogTest::sampleCatalog(),
ComposeFixture::rules(),
ComposeFixture::shortform(),
ComposeFixture::attrWriter(),
DesignSystemTest::sample(),
'5.2.1"},"x":{"'
);
}
public function testAutoLabel(): void
{
$composer = ComposeFixture::composer();
self::assertSame('Hero – Button', $composer->autoLabel('divi/button', 'Hero'));
self::assertSame('Button', $composer->autoLabel('divi/button', ''));
}
public function testComposesIntoAnExistingParent(): void
{
$composer = ComposeFixture::composer();
$column = Block::create('divi/column');
$accordion = Block::create('divi/accordion');
$modules = $composer->compose([['type' => 'button', 'text' => 'Los']], $column, 'nodes', 'Hero');
$items = $composer->compose([['title' => 'Frage']], $accordion, 'nodes');
self::assertSame([], $modules['errors']);
self::assertSame('Hero – Button', NodeSummary::label($modules['blocks'][0]));
self::assertSame([], $items['errors']);
self::assertSame('divi/accordion-item', $items['blocks'][0]->name());
// compose() builds blocks; inserting them is the caller's job.
self::assertSame([], $column->children());
$opaque = Block::parsed('divi/column', [], '<!-- wp:divi/column -->', false, true);
$readOnly = $composer->compose([['type' => 'button']], $opaque, 'nodes');
self::assertStringContainsString('nur lesbar', $readOnly['errors']['nodes[0].type']);
self::assertSame([], $readOnly['blocks']);
}
public function testRejectsNonObjectsAndMissingTypes(): void
{
$result = ComposeFixture::composer()->compose(['section', [], ['label' => 'x']], null, 'nodes');
self::assertArrayHasKey('nodes[0]', $result['errors']);
self::assertArrayHasKey('nodes[1]', $result['errors']);
self::assertSame('type fehlt', $result['errors']['nodes[2].type']);
self::assertSame([], $result['blocks']);
}
public function testTooManyModuleListsForTheColumns(): void
{
$result = ComposeFixture::composer()->compose([['type' => 'section', 'rows' => [[
'columns' => '1_2,1_2',
'modules' => [[], [], [['type' => 'text']]],
]]]], null, 'nodes');
self::assertStringContainsString('2 Spalten, aber 3 Modul-Listen', $result['errors']['nodes[0].rows[0].modules']);
}
}
@@ -215,11 +215,16 @@ final class ShortformWriterTest extends TestCase
{
$rows = self::malicious();
$sanitizer = ComposeFixture::sanitizer();
foreach (['text: tag-split marker', 'text: NUL-split marker', 'text: octet-split marker'] as $row) {
foreach (['text: tag-split marker', 'text: octet-split marker'] as $row) {
self::assertStringNotContainsString('$variable(', $rows[$row][1]);
self::assertStringContainsString('$variable(', $sanitizer->text($rows[$row][1]));
}
// S32: like sanitize_text_field, the fake text() keeps NUL – so the marker the NUL row
// hides is rebuilt only by AttrWriter's control-character stripping (production code).
self::assertStringNotContainsString('$variable(', $sanitizer->text($rows['text: NUL-split marker'][1]));
self::assertStringContainsString(chr(0), $sanitizer->text($rows['text: NUL-split marker'][1]));
foreach (['url: stripped-characters marker', 'url: NUL-split marker', 'url: line-break octet marker'] as $row) {
self::assertStringNotContainsString('$variable(', $rows[$row][1]);
self::assertStringContainsString('$variable(', $sanitizer->url($rows[$row][1]));
@@ -228,6 +233,18 @@ final class ShortformWriterTest extends TestCase
self::assertStringContainsString(chr(0), $rows['text: NUL-split marker'][1]);
}
/** Ruling S33b: an attachment alt carrying a reference (whole or split) is dropped, never canonicalized. */
public function testMediaAltWithAReferenceIsDropped(): void
{
foreach ([44, 45] as $id) {
$result = ComposeFixture::shortform()->apply('image', [], ['image_id' => $id], 'n');
self::assertSame([], $result['errors'], (string) $id);
self::assertSame(['src', 'id'], array_keys($result['attrs']['image']['innerContent']['desktop']['value']));
self::assertStringNotContainsString('variable', (string) json_encode($result['attrs']));
}
}
/** Ruling S21 applies to short-form text too: before/after of a reference are cleaned, not rejected. */
public function testReferenceSettingsInTextAreCleaned(): void
{
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use PHPUnit\Framework\TestCase;
final class StructureRulesTest extends TestCase
{
/** @return array<string, array{?Block, string, ?string}> Parent, child, part of the reason (null = allowed). */
public static function placements(): array
{
$section = static fn (string $type): Block => Block::create(
'divi/section',
['module' => ['advanced' => ['type' => ['desktop' => ['value' => $type]]]]]
);
return [
'section on top' => [null, 'divi/section', null],
'module on top' => [null, 'divi/text', 'oberster Ebene'],
'unknown module' => [Block::create('divi/column'), 'divi/slider-pro', 'unbekanntes Modul'],
'row in a section' => [Block::create('divi/section'), 'divi/row', null],
'module in a section' => [Block::create('divi/section'), 'divi/text', 'Zeilen'],
'fullwidth module in a fullwidth section' => [$section('fullwidth'), 'divi/fullwidth-header', null],
'row in a fullwidth section' => [$section('fullwidth'), 'divi/row', 'fullwidth-Module'],
'anything in a specialty section' => [$section('specialty'), 'divi/row', 'Spezialabschnitte'],
'column in a row' => [Block::create('divi/row'), 'divi/column', 'Spalten entstehen mit der Zeile'],
'module in a column' => [Block::create('divi/column'), 'divi/button', null],
'group in a column' => [Block::create('divi/column'), 'divi/group', null],
'module in a group' => [Block::create('divi/group'), 'divi/text', null],
'fullwidth module in a column' => [Block::create('divi/column'), 'divi/fullwidth-header', 'passt nicht'],
'child module in a column' => [Block::create('divi/column'), 'divi/accordion-item', 'passt nicht'],
'section in a column' => [Block::create('divi/column'), 'divi/section', 'passt nicht'],
'item in its parent' => [Block::create('divi/accordion'), 'divi/accordion-item', null],
'other module in a parent' => [Block::create('divi/accordion'), 'divi/text', 'erlaubt nur divi/accordion-item'],
'child in a plain module' => [Block::create('divi/text'), 'divi/button', 'keine Kind-Module'],
'opaque parent' => [Block::parsed('divi/column', [], '<!-- wp:divi/column -->', false, true), 'divi/text', 'nur lesbar'],
'foreign parent' => [Block::create('core/group'), 'divi/text', 'nur lesbar'],
];
}
/** @dataProvider placements */
public function testWhatGoesWhere(?Block $parent, string $child, ?string $reason): void
{
$violation = ComposeFixture::rules()->violation($parent, $child);
if ($reason === null) {
self::assertNull($violation);
} else {
self::assertIsString($violation);
self::assertStringContainsString($reason, $violation);
}
}
public function testPlainModules(): void
{
$rules = ComposeFixture::rules();
self::assertTrue($rules->isPlainModule('divi/button'));
self::assertFalse($rules->isPlainModule('divi/fullwidth-header'));
self::assertFalse($rules->isPlainModule('divi/accordion-item'));
self::assertFalse($rules->isPlainModule('divi/row'));
}
}
@@ -161,6 +161,15 @@ final class VariableRefTest extends TestCase
'allein im Wert',
],
'octet-split marker in date format' => [self::content('post_date', '{"custom_date_format":"$variable%41(y"}'), 'allein im Wert'],
// S32: text() keeps control characters (like sanitize_text_field); VariableRef strips them.
'NUL-split marker in date format' => [
self::content('post_date', '{"custom_date_format":"$variable' . self::escape('0000') . '(y"}'),
'allein im Wert',
],
'DEL-split marker in date format' => [
self::content('post_date', '{"custom_date_format":"$variable' . self::escape('007f') . '(y"}'),
'allein im Wert',
],
'nested marker in before' => [self::content('post_title', '{"before":"$variable(","after":""}'), 'allein im Wert'],
'broken json' => ['$variable(kaputt)$', 'JSON'],
'trailing text' => [self::content('post_title', '{}') . ' ', 'allein im Wert'],
@@ -395,6 +404,19 @@ final class VariableRefTest extends TestCase
self::ref($map); // @phpstan-ignore-line
}
/** Ruling S33c: the type of a whole reference, read from its payload (not from a string prefix). */
public function testTypeOfAReference(): void
{
self::assertSame('color', VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'));
self::assertSame('content', VariableRef::typeOf(self::content('post_title', '{}')));
self::assertSame('color', VariableRef::typeOf('$variable({"value":{"name":"gcid-a1b2"},"type":"color"})$'));
self::assertNull(VariableRef::typeOf('#1a1a1a'));
self::assertNull(VariableRef::typeOf('$variable(kaputt)$'));
self::assertNull(VariableRef::typeOf('$variable({"type":"evil","value":{"name":"x"}})$'));
self::assertNull(VariableRef::typeOf('#fff $variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'));
self::assertNull(VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$ '));
}
public function testDetectsReferences(): void
{
self::assertTrue(VariableRef::contains('a $variable( b'));