fix: Task 6 review round 1 - admin label overlap, heading default level, structure deny-list, row widths (S41)

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 15:26:12 +02:00
co-authored by Claude Opus 5.5
parent 22bb1528db
commit 909ac82594
7 changed files with 294 additions and 11 deletions
@@ -22,6 +22,12 @@ final class AttrMap
'builderVersion', 'modulePreset', 'groupPreset', 'globalModule', 'globalParent', 'localAttrs', 'builderVersion', 'modulePreset', 'groupPreset', 'globalModule', 'globalParent', 'localAttrs',
]; ];
/** Whether two attribute paths reach the same value: equal, or one lies below the other. */
public static function overlaps(string $a, string $b): bool
{
return $a === $b || str_starts_with($a, $b . '.') || str_starts_with($b, $a . '.');
}
/** /**
* @param array<string, mixed> $attrs * @param array<string, mixed> $attrs
* @return array<string, mixed> Path => breakpoint map (or the raw value of an odd leaf). * @return array<string, mixed> Path => breakpoint map (or the raw value of an odd leaf).
@@ -42,6 +42,17 @@ final class AttrWriter
/** Text-family kinds from laxest to strictest (ruling S33a); url stands apart. */ /** Text-family kinds from laxest to strictest (ruling S33a); url stands apart. */
private const STRICTNESS = ['code' => 0, 'html' => 1, 'text' => 2]; private const STRICTNESS = ['code' => 0, 'html' => 1, 'text' => 2];
/**
* Structure attributes (ruling S41), refused for everyone and whatever the catalog says:
* the section type (regular/fullwidth/specialty) and a column's specialty columns decide
* which children are valid – StructureRules would be bypassed. Sections get their type
* through "section_type" when they are inserted.
*/
private const STRUCTURE = [
'divi/section' => ['module.advanced.type'],
'divi/column' => ['module.advanced.specialtyColumns'],
];
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/'; private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/';
public function __construct( public function __construct(
@@ -65,6 +76,12 @@ final class AttrWriter
foreach ($changes as $path => $map) { foreach ($changes as $path => $map) {
$path = (string) $path; $path = (string) $path;
$key = $field . '.' . $path; $key = $field . '.' . $path;
if ($this->isStructure($module, $path)) {
$errors[$key] = 'Struktur des Abschnitts bzw. der Spalte; nur im Divi-Builder änderbar '
. '(Abschnittsart beim Einfügen über section_type)';
continue;
}
if (! $this->catalog->isKnownPath($module, $path)) { if (! $this->catalog->isKnownPath($module, $path)) {
$suggestions = $this->catalog->suggestions($module, $path); $suggestions = $this->catalog->suggestions($module, $path);
$errors[$key] = 'unbekannter Attributpfad' $errors[$key] = 'unbekannter Attributpfad'
@@ -147,6 +164,17 @@ final class AttrWriter
return $normalized; return $normalized;
} }
private function isStructure(string $module, string $path): bool
{
foreach (self::STRUCTURE[$module] ?? [] as $structure) {
if (AttrMap::overlaps($path, $structure)) {
return true;
}
}
return false;
}
/** /**
* Rejects Divi's HTML/attribute/CSS fields for users without unfiltered_html (S7/S26); also * Rejects Divi's HTML/attribute/CSS fields for users without unfiltered_html (S7/S26); also
* for removals, which the short form does without cleanValue(). * for removals, which the short form does without cleanValue().
+92 -8
View File
@@ -46,6 +46,8 @@ final class Composer
private const BACKGROUND = 'module.decoration.background'; private const BACKGROUND = 'module.decoration.background';
private const DEFAULT_LEVEL = 'h2';
/** Advice of the S40 overlap error for new nodes. */ /** Advice of the S40 overlap error for new nodes. */
private const ONE_FORM = 'für diesen Pfad nur Kurzform oder attrs angeben'; private const ONE_FORM = 'für diesen Pfad nur Kurzform oder attrs angeben';
@@ -122,8 +124,13 @@ final class Composer
} }
$set = $change['set'] ?? null; $set = $change['set'] ?? null;
$short = is_array($set) ? $this->shortPaths($block->shortName(), $set, 'set.') : [];
if (array_key_exists('label', $change)) {
// Ruling S41: the admin label is a short-form value too.
$short['label'] = self::LABEL_PATH;
}
$this->overlaps( $this->overlaps(
is_array($set) ? $this->shortPaths($block->shortName(), $set, 'set.') : [], $short,
$change['attrs'] ?? null, $change['attrs'] ?? null,
$field, $field,
'bitte als zwei Änderungen senden' 'bitte als zwei Änderungen senden'
@@ -156,10 +163,7 @@ final class Composer
foreach (array_keys($full) as $path) { foreach (array_keys($full) as $path) {
$path = (string) $path; $path = (string) $path;
foreach ($short as $name => $shortPath) { foreach ($short as $name => $shortPath) {
if ( if (AttrMap::overlaps($path, $shortPath)) {
$path === $shortPath || str_starts_with($path, $shortPath . '.')
|| str_starts_with($shortPath, $path . '.')
) {
$this->errors[$field . '.attrs.' . $path] = sprintf( $this->errors[$field . '.attrs.' . $path] = sprintf(
'derselbe Attributpfad wird auch über %s geändert; %s', 'derselbe Attributpfad wird auch über %s geändert; %s',
$name, $name,
@@ -171,6 +175,47 @@ final class Composer
} }
} }
/** @param array<array-key, mixed> $full "attrs" input. */
private function touches(array $full, string $path): bool
{
foreach (array_keys($full) as $given) {
if (AttrMap::overlaps((string) $given, $path)) {
return true;
}
}
return false;
}
/**
* Ruling S41: the default level of a new heading stays when attrs replaced the desktop font
* value it lives in (attrs replace a breakpoint value whole) – unless attrs name a level.
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
private function defaultLevel(string $short, array $attrs): array
{
$field = ShortformMap::fields($short)['level'] ?? null;
if ($field === null || $field['sub'] === null) {
return $attrs;
}
$value = AttrMap::desktop($attrs, (string) $field['path']);
if ($value !== null && ! is_array($value)) {
return $attrs;
}
if (is_array($value) && array_key_exists((string) $field['sub'], $value)) {
return $attrs;
}
$current = AttrMap::get($attrs, (string) $field['path'] . '.desktop.value');
$merged = array_merge([(string) $field['sub'] => self::DEFAULT_LEVEL], is_array($current) ? $current : []);
return AttrMap::set($attrs, (string) $field['path'] . '.desktop.value', $merged);
}
/** /**
* Paths of the short-form fields a client gave, keyed by their input name ($prefix . field). * Paths of the short-form fields a client gave, keyed by their input name ($prefix . field).
* *
@@ -346,8 +391,9 @@ final class Composer
// of the same name (contact-field type, social network label) go through lit-divi-update "set". // of the same name (contact-field type, social network label) go through lit-divi-update "set".
$values = array_intersect_key($spec, array_flip(array_diff($fields, self::COMMON))); $values = array_intersect_key($spec, array_flip(array_diff($fields, self::COMMON)));
$this->overlaps($this->shortPaths($short, $values, ''), $spec['attrs'] ?? null, $path, self::ONE_FORM); $this->overlaps($this->shortPaths($short, $values, ''), $spec['attrs'] ?? null, $path, self::ONE_FORM);
if ($name === 'divi/heading' && ! array_key_exists('level', $values)) { $defaultLevel = $name === 'divi/heading' && ! array_key_exists('level', $values);
$values['level'] = 'h2'; if ($defaultLevel) {
$values['level'] = self::DEFAULT_LEVEL;
} }
$attrs = ['builderVersion' => $this->builderVersion]; $attrs = ['builderVersion' => $this->builderVersion];
@@ -357,7 +403,12 @@ final class Composer
$this->errors += $result['errors']; $this->errors += $result['errors'];
} }
$block = Block::create($name, $this->common($name, $attrs, $spec, $path, $this->autoLabel($name, $context))); $attrs = $this->common($name, $attrs, $spec, $path, $this->autoLabel($name, $context));
if ($defaultLevel) {
$attrs = $this->defaultLevel($short, $attrs);
}
$block = Block::create($name, $attrs);
if ($hasItems) { if ($hasItems) {
$this->children($block, $spec['items'] ?? [], $path . '.items', $context); $this->children($block, $spec['items'] ?? [], $path . '.items', $context);
} elseif ($name === 'divi/group') { } elseif ($name === 'divi/group') {
@@ -377,6 +428,14 @@ final class Composer
*/ */
private function common(string $name, array $attrs, array $spec, string $path, ?string $autoLabel): array private function common(string $name, array $attrs, array $spec, string $path, ?string $autoLabel): array
{ {
// Ruling S41: "label" and attrs on the admin label is an error; attrs alone replace the auto label.
$full = $spec['attrs'] ?? null;
if (array_key_exists('label', $spec) && $spec['label'] !== null) {
$this->overlaps(['label' => self::LABEL_PATH], $full, $path, self::ONE_FORM);
} elseif (is_array($full) && $this->touches($full, self::LABEL_PATH)) {
$autoLabel = null;
}
$attrs = $this->presetAndAttrs($name, $attrs, $spec, $path); $attrs = $this->presetAndAttrs($name, $attrs, $spec, $path);
$label = array_key_exists('label', $spec) && $spec['label'] !== null ? $spec['label'] : $autoLabel; $label = array_key_exists('label', $spec) && $spec['label'] !== null ? $spec['label'] : $autoLabel;
if ($label === null || $label === '') { if ($label === null || $label === '') {
@@ -574,6 +633,31 @@ final class Composer
return $widths; return $widths;
} }
/**
* Ruling S41: the desktop flexType values of all columns of a row (in order), checked with
* the rules of "columns" – valid Divi widths that fill the row exactly. Null when valid,
* else the German problem.
*
* @param list<mixed> $flexTypes
*/
public function rowWidthsError(array $flexTypes): ?string
{
$saved = $this->errors;
$this->errors = [];
$valid = $flexTypes !== [] && array_filter(
$flexTypes,
static fn (mixed $width): bool => is_string($width) && preg_match('/^\d{1,2}_\d{1,2}$/D', $width) === 1
) === $flexTypes;
if ($valid) {
$this->widths(implode(',', $flexTypes), 'w');
}
$error = $valid ? ($this->errors['w'] ?? null) : 'Spaltenbreiten wie "8_24" erwartet';
$this->errors = $saved;
return $error;
}
/** /**
* Whether the fractions add up to exactly 1: numerators scaled to the least common * Whether the fractions add up to exactly 1: numerators scaled to the least common
* denominator (denominators are divisors of 24 or 5, 7, 9, 10, 11 – at most 27720). * denominator (denominators are divisors of 24 or 5, 7, 9, 10, 11 – at most 27720).
@@ -7,7 +7,9 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult; use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Compose\Composer;
use WP_Error; use WP_Error;
/** /**
@@ -20,6 +22,8 @@ use WP_Error;
*/ */
final class DiviUpdateAbility implements Ability final class DiviUpdateAbility implements Ability
{ {
private const SIZING = 'module.decoration.sizing';
public function __construct(private DiviWriter $writer) public function __construct(private DiviWriter $writer)
{ {
} }
@@ -117,7 +121,7 @@ final class DiviUpdateAbility implements Ability
$invalidPaths = false; $invalidPaths = false;
/** @var array<string, int> $seen id => position of its first change */ /** @var array<string, int> $seen id => position of its first change */
$seen = []; $seen = [];
/** @var list<array{0: Block, 1: array<string, mixed>}> $updates */ /** @var list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates */
$updates = []; $updates = [];
foreach ($changes as $position => $change) { foreach ($changes as $position => $change) {
$field = sprintf('changes[%d]', $position); $field = sprintf('changes[%d]', $position);
@@ -150,7 +154,11 @@ final class DiviUpdateAbility implements Ability
$result = $composer->update($block, $change, $field); $result = $composer->update($block, $change, $field);
$errors += $result['errors']; $errors += $result['errors'];
$invalidPaths = $invalidPaths || $result['invalid_paths'] !== []; $invalidPaths = $invalidPaths || $result['invalid_paths'] !== [];
$updates[] = [$block, $result['attrs']]; $updates[] = [$block, $result['attrs'], $field];
}
if ($errors === []) {
$errors = $this->rowWidths($page, $composer, $updates);
} }
if ($errors !== []) { if ($errors !== []) {
@@ -172,4 +180,55 @@ final class DiviUpdateAbility implements Ability
return $this->writer->save($this->name(), $page, $change, $input); return $this->writer->save($this->name(), $page, $change, $input);
} }
/**
* Ruling S41: a column whose desktop width changes must leave its row filled exactly – the
* rules of "columns" (Composer::rowWidthsError()), checked over the row after all changes.
*
* @param list<array{0: Block, 1: array<string, mixed>, 2: string}> $updates
* @return array<string, string>
*/
private function rowWidths(DiviPage $page, Composer $composer, array $updates): array
{
$new = [];
$rows = [];
foreach ($updates as [$block, $attrs, $field]) {
if ($block->name() !== 'divi/column' || self::width($attrs) === self::width($block->attrs())) {
continue;
}
$new[spl_object_id($block)] = $attrs;
$row = $page->index->parentOf($block);
if ($row instanceof Block) {
$rows[spl_object_id($row)]['row'] = $row;
$rows[spl_object_id($row)]['fields'][] = $field;
}
}
$errors = [];
foreach ($rows as ['row' => $row, 'fields' => $fields]) {
$widths = [];
foreach ($row->children() as $column) {
$widths[] = self::width($new[spl_object_id($column)] ?? $column->attrs());
}
$error = $composer->rowWidthsError($widths);
if ($error !== null) {
foreach ($fields as $field) {
$errors[$field . '.attrs.' . self::SIZING] = $error
. ' (Breiten aller Spalten der Zeile zusammen ändern)';
}
}
}
return $errors;
}
/** @param array<string, mixed> $attrs */
private static function width(array $attrs): mixed
{
$sizing = AttrMap::desktop($attrs, self::SIZING);
return is_array($sizing) ? ($sizing['flexType'] ?? null) : null;
}
} }
@@ -510,5 +510,24 @@ final class AttrWriterTest extends TestCase
self::assertSame($reference, $deep['attrs']['button']['innerContent']['desktop']['value']['text']); self::assertSame($reference, $deep['attrs']['button']['innerContent']['desktop']['value']['text']);
} }
} }
}
/** Ruling S41: structure attributes are refused whatever the catalog says (also for admins, also null). */
public function testStructureAttributesAreRefused(): void
{
foreach ([
['divi/section', 'module.advanced.type', ['desktop' => 'specialty']],
['divi/section', 'module.advanced.type', null],
['divi/section', 'module.advanced', ['desktop' => ['type' => 'fullwidth']]],
['divi/column', 'module.advanced.specialtyColumns', ['desktop' => '2']],
] as [$module, $path, $map]) {
$result = ComposeFixture::attrWriter(true)->apply($module, [], [$path => $map], 'attrs');
self::assertStringContainsString('Struktur', $result['errors']['attrs.' . $path] ?? '', $module . ' ' . $path);
self::assertSame([], $result['invalid_paths'], $path);
self::assertSame([], $result['attrs'], $path);
}
$heading = ComposeFixture::attrWriter()->apply('divi/heading', [], ['module.meta.adminLabel' => ['desktop' => 'x']], 'attrs');
self::assertSame([], $heading['errors'], 'only section type and specialty columns are structure');
}
}
@@ -231,6 +231,72 @@ final class ComposerTest extends TestCase
self::assertSame([], $separate['errors'], 'different paths, and the default heading level, may be combined'); self::assertSame([], $separate['errors'], 'different paths, and the default heading level, may be combined');
} }
/** Ruling S41: an admin label given in attrs is kept (no auto label over it); label + attrs on it is an error. */
public function testAdminLabelInAttrs(): void
{
$kept = ComposeFixture::composer()->compose([[
'type' => 'heading',
'text' => 'x',
'attrs' => ['module.meta.adminLabel' => ['desktop' => 'Mein Label', 'tablet' => 'Tablet']],
]], Block::create('divi/column'), 'nodes', 'Hero');
self::assertSame([], $kept['errors']);
self::assertSame(
['desktop' => ['value' => 'Mein Label'], 'tablet' => ['value' => 'Tablet']],
$kept['blocks'][0]->attrs()['module']['meta']['adminLabel']
);
$both = ComposeFixture::composer()->compose([[
'type' => 'heading',
'text' => 'x',
'label' => 'B',
'attrs' => ['module.meta.adminLabel' => ['desktop' => 'A', 'tablet' => 'T']],
]], Block::create('divi/column'), 'nodes', 'Hero');
self::assertStringContainsString('label', $both['errors']['nodes[0].attrs.module.meta.adminLabel']);
$update = ComposeFixture::composer()->update(Block::create('divi/heading'), [
'label' => 'B',
'attrs' => ['module.meta.adminLabel' => ['tablet' => 'T']],
], 'changes[0]');
self::assertStringContainsString('label', $update['errors']['changes[0].attrs.module.meta.adminLabel']);
}
/** Ruling S41: the default heading level survives attrs on the same font value. */
public function testDefaultHeadingLevelIsMergedIntoAttrs(): void
{
$result = ComposeFixture::composer()->compose([[
'type' => 'heading',
'text' => 'x',
'attrs' => ['title.decoration.font.font' => ['desktop' => ['color' => '#111111']]],
]], Block::create('divi/column'), 'nodes');
self::assertSame([], $result['errors']);
self::assertSame(
['headingLevel' => 'h2', 'color' => '#111111'],
$result['blocks'][0]->attrs()['title']['decoration']['font']['font']['desktop']['value']
);
$explicit = ComposeFixture::composer()->compose([[
'type' => 'heading',
'text' => 'x',
'attrs' => ['title.decoration.font.font' => ['desktop' => ['headingLevel' => 'h3']]],
]], Block::create('divi/column'), 'nodes');
self::assertSame('h3', $explicit['blocks'][0]->attrs()['title']['decoration']['font']['font']['desktop']['value']['headingLevel']);
}
/** Ruling S41: column widths on update follow the rules of "columns". */
public function testRowWidthsAreChecked(): void
{
$composer = ComposeFixture::composer();
self::assertNull($composer->rowWidthsError(['8_24', '16_24']));
self::assertNull($composer->rowWidthsError(['1_5', '1_5', '1_5', '1_5', '1_5']));
self::assertNotNull($composer->rowWidthsError(['12_24', '16_24']));
self::assertNotNull($composer->rowWidthsError(['1_13', '12_13']));
self::assertNotNull($composer->rowWidthsError(['x', '24_24']));
self::assertNotNull($composer->rowWidthsError([]));
}
/** Ruling S13: labels go through the choke point; a label that fails is an error, never stored raw. */ /** Ruling S13: labels go through the choke point; a label that fails is an error, never stored raw. */
public function testLabelsAreCleanedOrReported(): void public function testLabelsAreCleanedOrReported(): void
{ {
@@ -297,6 +297,27 @@ final class DiviWriteAbilitiesTest extends TestCase
self::assertSame([], $this->saved); self::assertSame([], $this->saved);
} }
/** Ruling S41: a column width change must still fill its row exactly. */
public function testUpdateChecksTheRowWidths(): void
{
$sizing = static fn (string $width): array => ['module.decoration.sizing' => ['desktop' => ['flexType' => $width]]];
$broken = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')],
]]);
self::assertSame('lit_validation_failed', $broken->get_error_code());
self::assertArrayHasKey('changes[0].attrs.module.decoration.sizing', self::clientError($broken)['fields']);
self::assertSame([], $this->saved);
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')],
['id' => 's0.r0.c1', 'attrs' => $sizing('12_24')],
]]);
self::assertTrue($result['ok'], (string) json_encode($result));
self::assertCount(1, $this->saved);
self::assertStringContainsString('"flexType":"12_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/image', $this->saved[0]);
}
/** Ruling S7: builder-only paths are refused for users without unfiltered_html – visible to the client. */ /** Ruling S7: builder-only paths are refused for users without unfiltered_html – visible to the client. */
public function testBuilderOnlyPathsAreRefusedForEditors(): void public function testBuilderOnlyPathsAreRefusedForEditors(): void
{ {