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
+92 -8
View File
@@ -46,6 +46,8 @@ final class Composer
private const BACKGROUND = 'module.decoration.background';
private const DEFAULT_LEVEL = 'h2';
/** Advice of the S40 overlap error for new nodes. */
private const ONE_FORM = 'für diesen Pfad nur Kurzform oder attrs angeben';
@@ -122,8 +124,13 @@ final class Composer
}
$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(
is_array($set) ? $this->shortPaths($block->shortName(), $set, 'set.') : [],
$short,
$change['attrs'] ?? null,
$field,
'bitte als zwei Änderungen senden'
@@ -156,10 +163,7 @@ final class Composer
foreach (array_keys($full) as $path) {
$path = (string) $path;
foreach ($short as $name => $shortPath) {
if (
$path === $shortPath || str_starts_with($path, $shortPath . '.')
|| str_starts_with($shortPath, $path . '.')
) {
if (AttrMap::overlaps($path, $shortPath)) {
$this->errors[$field . '.attrs.' . $path] = sprintf(
'derselbe Attributpfad wird auch über %s geändert; %s',
$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).
*
@@ -346,8 +391,9 @@ final class Composer
// 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)));
$this->overlaps($this->shortPaths($short, $values, ''), $spec['attrs'] ?? null, $path, self::ONE_FORM);
if ($name === 'divi/heading' && ! array_key_exists('level', $values)) {
$values['level'] = 'h2';
$defaultLevel = $name === 'divi/heading' && ! array_key_exists('level', $values);
if ($defaultLevel) {
$values['level'] = self::DEFAULT_LEVEL;
}
$attrs = ['builderVersion' => $this->builderVersion];
@@ -357,7 +403,12 @@ final class Composer
$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) {
$this->children($block, $spec['items'] ?? [], $path . '.items', $context);
} 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
{
// 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);
$label = array_key_exists('label', $spec) && $spec['label'] !== null ? $spec['label'] : $autoLabel;
if ($label === null || $label === '') {
@@ -574,6 +633,31 @@ final class Composer
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
* denominator (denominators are divisors of 24 or 5, 7, 9, 10, 11 – at most 27720).