diff --git a/src/Integrations/Divi/Compose/AttrWriter.php b/src/Integrations/Divi/Compose/AttrWriter.php index 31e4af4..615ef83 100644 --- a/src/Integrations/Divi/Compose/AttrWriter.php +++ b/src/Integrations/Divi/Compose/AttrWriter.php @@ -39,6 +39,9 @@ final class AttrWriter * path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes, * any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss). */ + /** Text-family kinds from laxest to strictest (ruling S33a); url stands apart. */ + private const STRICTNESS = ['code' => 0, 'html' => 1, 'text' => 2]; + private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/'; public function __construct( @@ -92,8 +95,9 @@ final class AttrWriter * Cleans one value written to $path (used by the short form too). $subKey names the * sub-field of a composite path ("linkUrl" of button.innerContent) when $value is its * string alone. $kind ('html'|'code'|'url'|'text') replaces the catalog routing for every - * string of $value: the short form passes its curated field kind (ShortformMap), so a - * short-form url field is always a URL and icon-list-item text always plain text. + * string of $value: the short form passes its curated field kind (ShortformMap), so + * icon-list-item text is always plain text. It may only tighten (ruling S33a): code < html + * < text; url only where the routing says url – a weaker or other-family kind is refused. * * @param 'html'|'code'|'url'|'text'|null $kind * @throws InvalidArgumentException @@ -198,7 +202,17 @@ final class AttrWriter throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.'); } - $kind ??= $this->kind($module, $path, $subName, $key); + $routed = $this->kind($module, $path, $subName, $key); + if ($kind !== null && ! self::tightens($kind, $routed)) { + throw new InvalidArgumentException(sprintf( + 'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").', + $kind, + $path, + $routed + )); + } + + $kind ??= $routed; if (VariableRef::contains($value)) { // The only way a reference is stored: whole, straight from canonical(). return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url'); @@ -211,8 +225,10 @@ final class AttrWriter default => $this->sanitizer->text($value), }; - // Ruling S28: sanitizers strip characters ("", NUL, "%41") and can thereby build a - // marker the raw check above never saw – so the final string is checked again. + // Ruling S32: control characters (the WordPress sanitizers keep NUL & co.) go before the + // check. Ruling S28: sanitizers strip characters ("", NUL, "%41") and can thereby build + // a marker the raw check above never saw – so the final string is checked again. + $clean = VariableRef::withoutControls($clean); if (VariableRef::contains($clean)) { throw VariableRef::notAlone(); } @@ -220,6 +236,21 @@ final class AttrWriter return $clean; } + /** + * Whether the kind override $kind is at least as strict as the routed kind (ruling S33a). + * Strictness within the text family: code (raw for unfiltered_html) < html (kses) < text + * (no markup). url (esc_url_raw) is a family of its own: it neither tightens nor is + * tightened by the others, so only url → url passes. + */ + private static function tightens(string $kind, string $routed): bool + { + if ($kind === 'url' || $routed === 'url') { + return $kind === $routed; + } + + return self::STRICTNESS[$kind] >= self::STRICTNESS[$routed]; + } + /** * Sanitizer for a string (rulings S3/S3a/S23/S24): the catalog kind of the path – or of the * sub-field for a composite path – decides. A string reached through a key below a path diff --git a/src/Integrations/Divi/Compose/Composer.php b/src/Integrations/Divi/Compose/Composer.php new file mode 100644 index 0000000..6c62b9f --- /dev/null +++ b/src/Integrations/Divi/Compose/Composer.php @@ -0,0 +1,548 @@ + – " (spec §5.5). + * Every error is collected with its input path; blocks are only usable when there are none. + * Pure PHP. + * + * Divi saves bypass kses (D7): every client string reaches the attributes only through + * ShortformWriter / AttrWriter::cleanValue() (the single choke point) – short-form fields, + * full-form attrs, admin labels, background_color (ShortformWriter::color(), ruling S13) and + * preset ids. A value that fails there is an error, never stored raw (S13). What Composer + * writes itself is not client text: the builder version (checked in the constructor), block + * names (catalog + StructureRules + Block::create), "fullwidth" and column flexTypes built + * from digits. Design-system names that match several entries are errors listing the ids + * (ruling S13a: colors/variables in Placeholders, presets here). + */ +final class Composer +{ + private const COMMON = ['type', 'label', 'preset', 'attrs']; + private const SECTION = ['section_type', 'background_color', 'rows', 'modules']; + private const ROW = ['columns', 'modules']; + + /** Keys of one lit-divi-update change ("id" names the node, read by the ability). */ + private const UPDATE = ['id', 'set', 'attrs', 'preset', 'label']; + + /** Column fractions Divi's flex layout knows besides multiples of 1/24. */ + private const SPECIAL_WIDTHS = ['1_5', '2_5', '3_5', '4_5', '1_7', '1_9', '1_10', '1_11']; + + /** ET_BUILDER_PRODUCT_VERSION, e.g. "5.2.1" – written into every new block. */ + private const VERSION = '/^[0-9][0-9A-Za-z.+-]*$/D'; + + private const LABEL_PATH = 'module.meta.adminLabel'; + + /** @var array */ + private array $errors = []; + + /** @var list */ + private array $invalidPaths = []; + + /** @throws InvalidArgumentException When $builderVersion is no version number. */ + public function __construct( + private ModuleCatalog $catalog, + private StructureRules $rules, + private ShortformWriter $shortform, + private AttrWriter $attrWriter, + private DesignSystem $design, + private string $builderVersion + ) { + if (preg_match(self::VERSION, $builderVersion) !== 1) { + throw new InvalidArgumentException(sprintf('"%s" is not a Divi builder version.', $builderVersion)); + } + } + + /** + * @param array $specs Node descriptions. + * @param Block|null $parent Block the nodes go into (null = top level of the page). + * @param string $field Input path of $specs for error keys, e.g. "nodes". + * @param string $context Admin label of the surrounding section ("" if none). + * @return array{blocks: list, errors: array, invalid_paths: list} + */ + public function compose(array $specs, ?Block $parent, string $field, string $context = ''): array + { + $this->errors = []; + $this->invalidPaths = []; + $blocks = []; + foreach (array_values($specs) as $position => $spec) { + $block = $this->node($spec, $parent, sprintf('%s[%d]', $field, $position), $context); + if ($block !== null) { + $blocks[] = $block; + } + } + + return ['blocks' => $blocks, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths]; + } + + /** + * Applies short-form fields ("set"), full-form attrs, preset and label to an existing block + * (lit-divi-update); errors like compose(). The block itself is not changed – the caller + * sets the returned attrs. "preset": null and "label": null remove them. + * + * @param array $change id, set, attrs, preset, label + * @return array{attrs: array, errors: array, invalid_paths: list} + */ + public function update(Block $block, array $change, string $field): array + { + $this->errors = []; + $this->invalidPaths = []; + $attrs = $block->attrs(); + if (! $block->isDivi() || $block->isOpaque()) { + $this->errors[$field . '.id'] = 'nur lesbar (globales Modul oder fremder Block)'; + + return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => []]; + } + + $this->unknownKeys($change, self::UPDATE, $field); + if (array_key_exists('set', $change)) { + if (! is_array($change['set']) || $change['set'] === [] || array_is_list($change['set'])) { + $this->errors[$field . '.set'] = 'Objekt mit Kurzform-Feldern erwartet'; + } else { + $result = $this->shortform->apply($block->shortName(), $attrs, $change['set'], $field . '.set'); + $attrs = $result['attrs']; + $this->errors += $result['errors']; + } + } + + $attrs = $this->presetAndAttrs($block->name(), $attrs, $change, $field); + if (array_key_exists('label', $change)) { + $attrs = $change['label'] === null || $change['label'] === '' + ? AttrMap::remove($attrs, self::LABEL_PATH) + : $this->label($block->name(), $attrs, $change['label'], $field . '.label'); + } + + return ['attrs' => $attrs, 'errors' => $this->errors, 'invalid_paths' => $this->invalidPaths]; + } + + /** Admin label for a new module: " – " or the title alone. */ + public function autoLabel(string $name, string $context): string + { + $title = $this->catalog->title($name); + + return $context === '' ? $title : $context . ' – ' . $title; + } + + private function node(mixed $spec, ?Block $parent, string $path, string $context): ?Block + { + if (! is_array($spec) || array_is_list($spec)) { + $this->errors[$path] = 'Objekt mit "type" erwartet'; + + return null; + } + + $type = $this->typeOf($spec, $parent); + if ($type === null) { + $this->errors[$path . '.type'] = 'type fehlt'; + + return null; + } + + $violation = $this->rules->violation($parent, $type); + if ($violation !== null) { + $this->errors[$path . '.type'] = $violation; + + return null; + } + + return match ($type) { + 'divi/section' => $this->section($spec, $path), + 'divi/row' => $this->row($spec, $path, $context), + default => $this->module($type, $spec, $path, $context), + }; + } + + /** @param array $spec */ + private function typeOf(array $spec, ?Block $parent): ?string + { + if (is_string($spec['type'] ?? null) && trim($spec['type']) !== '') { + return ModuleCatalog::normalize($spec['type']); + } + + if (array_key_exists('type', $spec)) { + return null; + } + + $children = $parent === null ? [] : $this->catalog->childrenOf($parent->name()); + + return count($children) === 1 ? $children[0] : null; + } + + /** @param array $spec */ + private function section(array $spec, string $path): Block + { + $this->unknownKeys($spec, array_merge(self::COMMON, self::SECTION), $path); + $type = $spec['section_type'] ?? 'regular'; + $attrs = ['builderVersion' => $this->builderVersion]; + if ($type === 'fullwidth') { + $attrs = AttrMap::set($attrs, 'module.advanced.type', ['desktop' => ['value' => 'fullwidth']]); + } elseif ($type !== 'regular') { + $this->errors[$path . '.section_type'] = 'regular oder fullwidth erwartet (specialty nur im Divi-Builder)'; + } + + if (array_key_exists('background_color', $spec)) { + $attrs = $this->backgroundColor($attrs, $spec['background_color'], $path . '.background_color'); + } + + $attrs = $this->common('divi/section', $attrs, $spec, $path, null); + $label = AttrMap::desktop($attrs, self::LABEL_PATH); + $context = is_string($label) ? $label : ''; + $section = Block::create('divi/section', $attrs); + + $hasRows = array_key_exists('rows', $spec); + $hasModules = array_key_exists('modules', $spec); + if ($type === 'fullwidth') { + if ($hasRows) { + $this->errors[$path . '.rows'] = 'ein Vollbreite-Abschnitt (section_type fullwidth) hat keine Zeilen; ' + . 'fullwidth-Module unter "modules" angeben'; + } + $this->children($section, $spec['modules'] ?? [], $path . '.modules', $context); + } elseif ($hasRows && $hasModules) { + $this->errors[$path] = 'entweder rows oder modules angeben'; + } elseif ($hasModules) { + $this->children($section, [['type' => 'row', 'modules' => $spec['modules']]], $path . '.modules', $context); + } else { + $this->children($section, $spec['rows'] ?? [], $path . '.rows', $context); + } + + return $section; + } + + /** @param array $spec */ + private function row(array $spec, string $path, string $context): Block + { + $this->unknownKeys($spec, array_merge(self::COMMON, self::ROW), $path); + $widths = $this->widths($spec['columns'] ?? '4_4', $path . '.columns'); + $label = $context === '' ? null : $context . ' – Zeile'; + $attrs = $this->common('divi/row', ['builderVersion' => $this->builderVersion], $spec, $path, $label); + $row = Block::create('divi/row', $attrs); + + $modules = $spec['modules'] ?? []; + if (! is_array($modules) || ! array_is_list($modules)) { + $this->errors[$path . '.modules'] = 'Liste erwartet (je Spalte eine Liste von Modulen)'; + $modules = []; + } + + // One column: a flat list of modules is accepted. + $first = $modules[0] ?? null; + if (count($widths) === 1 && is_array($first) && ! array_is_list($first)) { + $modules = [$modules]; + } + + if (count($modules) > count($widths)) { + $this->errors[$path . '.modules'] = sprintf( + '%d Spalten, aber %d Modul-Listen', + count($widths), + count($modules) + ); + } + + foreach ($widths as $position => $width) { + $column = Block::create('divi/column', AttrMap::set( + ['builderVersion' => $this->builderVersion], + 'module.decoration.sizing', + ['desktop' => ['value' => ['flexType' => $width]]] + )); + $row->insertChild($position, $column); + $this->children($column, $modules[$position] ?? [], sprintf('%s.modules[%d]', $path, $position), $context); + } + + return $row; + } + + /** @param array $spec */ + private function module(string $name, array $spec, string $path, string $context): Block + { + $short = ModuleCatalog::shortName($name); + $fields = array_keys(ShortformMap::fields($short)); + $hasItems = $this->catalog->childrenOf($name) !== []; + $allowed = array_merge( + self::COMMON, + $fields, + $hasItems ? ['items'] : [], + $name === 'divi/group' ? ['modules'] : [] + ); + $this->unknownKeys($spec, $allowed, $path); + + // "type" and "label" of a node are its module type and admin label; the short-form fields + // 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))); + if ($name === 'divi/heading' && ! array_key_exists('level', $values)) { + $values['level'] = 'h2'; + } + + $attrs = ['builderVersion' => $this->builderVersion]; + if ($values !== []) { + $result = $this->shortform->apply($short, $attrs, $values, $path); + $attrs = $result['attrs']; + $this->errors += $result['errors']; + } + + $block = Block::create($name, $this->common($name, $attrs, $spec, $path, $this->autoLabel($name, $context))); + if ($hasItems) { + $this->children($block, $spec['items'] ?? [], $path . '.items', $context); + } elseif ($name === 'divi/group') { + $this->children($block, $spec['modules'] ?? [], $path . '.modules', $context); + } + + return $block; + } + + /** + * Preset, full-form attrs and admin label of a new block. The label is $spec's "label" + * when given ("" = none), else $autoLabel. + * + * @param array $attrs + * @param array $spec + * @return array + */ + private function common(string $name, array $attrs, array $spec, string $path, ?string $autoLabel): array + { + $attrs = $this->presetAndAttrs($name, $attrs, $spec, $path); + $label = array_key_exists('label', $spec) && $spec['label'] !== null ? $spec['label'] : $autoLabel; + if ($label === null || $label === '') { + return $attrs; + } + + return $this->label($name, $attrs, $label, $path . '.label'); + } + + /** + * @param array $attrs + * @param array $spec + * @return array + */ + private function presetAndAttrs(string $name, array $attrs, array $spec, string $path): array + { + if (array_key_exists('preset', $spec)) { + $attrs = $this->preset($name, $attrs, $spec['preset'], $path . '.preset'); + } + + if (! array_key_exists('attrs', $spec)) { + return $attrs; + } + + if (! is_array($spec['attrs']) || array_is_list($spec['attrs'])) { + $this->errors[$path . '.attrs'] = 'Objekt "Attributpfad": {Breakpoint: Wert} erwartet'; + + return $attrs; + } + + $result = $this->attrWriter->apply($name, $attrs, $spec['attrs'], $path . '.attrs'); + $this->errors += $result['errors']; + array_push($this->invalidPaths, ...$result['invalid_paths']); + + return $result['attrs']; + } + + /** + * The admin label, cleaned as text at the choke point (ruling S13): a label that fails there, + * or that still carries a reference, is an error and nothing is written. + * + * @param array $attrs + * @return array + */ + private function label(string $name, array $attrs, mixed $label, string $path): array + { + if (! is_string($label)) { + $this->errors[$path] = 'Text erwartet'; + + return $attrs; + } + + try { + $clean = $this->attrWriter->cleanValue($name, self::LABEL_PATH, $label, null, 'text'); + } catch (InvalidArgumentException $e) { + $this->errors[$path] = $e->getMessage(); + + return $attrs; + } + + if (! is_string($clean) || VariableRef::contains($clean)) { + $this->errors[$path] = 'Beschriftung: nur Text, keine Divi-Verweise oder Platzhalter'; + + return $attrs; + } + + return $clean === '' ? $attrs : AttrMap::set($attrs, self::LABEL_PATH, ['desktop' => ['value' => $clean]]); + } + + /** + * @param array $attrs + * @return array + */ + private function preset(string $name, array $attrs, mixed $preset, string $path): array + { + if ($preset === null || $preset === '') { + unset($attrs['modulePreset']); + + return $attrs; + } + + $id = is_string($preset) ? $this->presetId($name, $preset, $path) : null; + if ($id === null) { + if (! isset($this->errors[$path])) { + $names = $this->design->modulePresetNames($name); + $this->errors[$path] = sprintf( + 'unbekanntes Preset; vorhanden für %s: %s', + $name, + $names === [] ? '(keine)' : implode(', ', $names) + ); + } + + return $attrs; + } + + // Design-system data, but still through the choke point: an id text() would change is refused. + try { + $clean = $this->attrWriter->cleanValue($name, 'modulePreset', $id, null, 'text'); + } catch (InvalidArgumentException $e) { + $clean = null; + } + + if ($clean !== $id) { + $this->errors[$path] = sprintf('Preset-ID "%s" ist ungültig', $id); + + return $attrs; + } + + $attrs['modulePreset'] = [$id]; + + return $attrs; + } + + /** + * Module preset id by id, else by name (case-insensitive); a name several presets of the + * module share is an error listing their ids (ruling S13a). Null when unknown or ambiguous. + */ + private function presetId(string $name, string $preset, string $path): ?string + { + $wanted = trim($preset); + $key = mb_strtolower($wanted); + $ids = []; + foreach ($this->design->presets() as $entry) { + if ($entry['kind'] !== 'module' || $entry['module'] !== $name) { + continue; + } + + if ($entry['id'] === $wanted) { + return $entry['id']; + } + + if (mb_strtolower($entry['name']) === $key) { + $ids[] = $entry['id']; + } + } + + if (count($ids) > 1) { + $this->errors[$path] = sprintf( + 'Preset-Name "%s" ist mehrdeutig; bitte die ID angeben: %s.', + $wanted, + implode(', ', $ids) + ); + + return null; + } + + return $ids[0] ?? null; + } + + /** + * Ruling S13: the short-form color check (ShortformWriter::color()), never text(). + * + * @param array $attrs + * @return array + */ + private function backgroundColor(array $attrs, mixed $color, string $path): array + { + try { + $clean = $this->shortform->color('divi/section', 'module.decoration.background', $color, 'color'); + } catch (InvalidArgumentException $e) { + $this->errors[$path] = $e->getMessage(); + + return $attrs; + } + + return AttrMap::set($attrs, 'module.decoration.background', ['desktop' => ['value' => ['color' => $clean]]]); + } + + private function children(Block $parent, mixed $specs, string $path, string $context): void + { + if (! is_array($specs) || ($specs !== [] && ! array_is_list($specs))) { + $this->errors[$path] = 'Liste erwartet'; + + return; + } + + foreach ($specs as $position => $spec) { + $child = $this->node($spec, $parent, sprintf('%s[%d]', $path, $position), $context); + if ($child !== null) { + $parent->insertChild(PHP_INT_MAX, $child); + } + } + } + + /** + * "1_3,2_3" → flexTypes ["8_24", "16_24"]; the fractions must add up to one row. + * + * @return list + */ + private function widths(mixed $columns, string $path): array + { + $parts = is_string($columns) ? array_map('trim', explode(',', $columns)) : []; + $widths = []; + $sum = 0.0; + foreach ($parts as $part) { + $valid = preg_match('/^(\d{1,2})_(\d{1,2})$/D', $part, $match) === 1 + && (int) $match[1] >= 1 + && (int) $match[1] <= (int) $match[2]; + if (! $valid) { + $this->errors[$path] = 'Spaltenaufteilung wie "1_2,1_2" oder "1_3,2_3" erwartet'; + + return ['24_24']; + } + + [$numerator, $denominator] = [(int) $match[1], (int) $match[2]]; + if (24 % $denominator === 0) { + $widths[] = ($numerator * 24 / $denominator) . '_24'; + } elseif (in_array($part, self::SPECIAL_WIDTHS, true)) { + $widths[] = $part; + } else { + $this->errors[$path] = sprintf('Spaltenbreite %s gibt es in Divi nicht', $part); + + return ['24_24']; + } + $sum += $numerator / $denominator; + } + + if ($widths === [] || abs($sum - 1.0) > 0.01) { + $this->errors[$path] = 'die Spaltenbreiten müssen zusammen eine volle Zeile ergeben (z. B. "1_3,2_3")'; + + return ['24_24']; + } + + return $widths; + } + + /** + * @param array $spec + * @param list $allowed + */ + private function unknownKeys(array $spec, array $allowed, string $path): void + { + foreach (array_diff(array_map('strval', array_keys($spec)), $allowed) as $key) { + $this->errors[$path . '.' . $key] = 'unbekanntes Feld; erlaubt: ' . implode(', ', $allowed); + } + } +} diff --git a/src/Integrations/Divi/Compose/ShortformWriter.php b/src/Integrations/Divi/Compose/ShortformWriter.php index 3c7b5b3..8442906 100644 --- a/src/Integrations/Divi/Compose/ShortformWriter.php +++ b/src/Integrations/Divi/Compose/ShortformWriter.php @@ -25,9 +25,6 @@ final class ShortformWriter private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a'; - /** Prefix of every color reference VariableRef::serialize() writes (fixed key order). */ - private const COLOR_REFERENCE = '$variable({"type":"color",'; - /** Curated kind => sanitizer kind of AttrWriter::cleanValue(). */ private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text']; @@ -89,7 +86,7 @@ final class ShortformWriter $value = trim($this->placeholders->resolve($value)); if (VariableRef::contains($value)) { $reference = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text'); - if (! is_string($reference) || ! str_starts_with($reference, self::COLOR_REFERENCE)) { + if (! is_string($reference) || VariableRef::typeOf($reference) !== 'color') { throw new InvalidArgumentException(self::COLOR_ERROR); } @@ -166,14 +163,33 @@ final class ShortformWriter $attrs = AttrMap::set($attrs, $base . $idKey, (string) $id); } - if ($altKey !== null && $withAlt && $image['alt'] !== '') { - $alt = $this->attrs->cleanValue($module, $path, $image['alt'], $altKey, 'text'); - $attrs = AttrMap::set($attrs, $base . $altKey, $alt); + $alt = $altKey !== null && $withAlt ? $this->alt($module, $path, $image['alt'], $altKey) : ''; + if ($alt !== '') { + $attrs = AttrMap::set($attrs, $base . (string) $altKey, $alt); } return $attrs; } + /** + * The attachment's alt text, cleaned as text – or '' (no alt) when it carries a reference, + * whole or rebuilt by cleaning (ruling S33b): library data never becomes dynamic content. + */ + private function alt(string $module, string $path, string $alt, string $altKey): string + { + if ($alt === '' || VariableRef::contains($alt)) { + return ''; + } + + try { + $clean = $this->attrs->cleanValue($module, $path, $alt, $altKey, 'text'); + } catch (InvalidArgumentException) { + return ''; + } + + return is_string($clean) ? $clean : ''; + } + /** * @param array $spec * @throws InvalidArgumentException diff --git a/src/Integrations/Divi/Compose/StructureRules.php b/src/Integrations/Divi/Compose/StructureRules.php new file mode 100644 index 0000000..7de7bc4 --- /dev/null +++ b/src/Integrations/Divi/Compose/StructureRules.php @@ -0,0 +1,89 @@ +catalog->has($child)) { + return sprintf('unbekanntes Modul "%s" (siehe lit-divi-catalog)', $child); + } + + if ($parent === null) { + return $child === 'divi/section' ? null : 'auf oberster Ebene sind nur Abschnitte (section) erlaubt'; + } + + if (! $parent->isDivi() || $parent->isOpaque()) { + return 'der Zielknoten ist nur lesbar (globales Modul oder fremder Block)'; + } + + $parentName = $parent->name(); + if ($parentName === 'divi/section') { + return $this->inSection($parent, $child); + } + + if (in_array($parentName, self::ROWS, true)) { + return 'Spalten entstehen mit der Zeile ("columns"); eine neue Zeile mit der gewünschten Aufteilung ' + . 'einfügen'; + } + + if (in_array($parentName, self::COLUMNS, true) || $parentName === 'divi/group') { + return $this->isPlainModule($child) || $child === 'divi/group' + ? null + : sprintf('%s passt nicht in eine Spalte', $child); + } + + $children = $this->catalog->childrenOf($parentName); + if ($children === []) { + return sprintf('%s kann keine Kind-Module enthalten', $parentName); + } + + return in_array($child, $children, true) + ? null + : sprintf('%s erlaubt nur %s als Kind', $parentName, implode(', ', $children)); + } + + /** A module that goes into a column: category "module", not a fullwidth module. */ + public function isPlainModule(string $name): bool + { + return $this->catalog->category($name) === 'module' && ! str_starts_with($name, self::FULLWIDTH); + } + + private function inSection(Block $section, string $child): ?string + { + $type = AttrMap::desktop($section->attrs(), 'module.advanced.type'); + if ($type === 'specialty') { + return 'Spezialabschnitte (specialty) nur im Divi-Builder umbauen; Module in ihre Spalten einfügen'; + } + + if ($type === 'fullwidth') { + return str_starts_with($child, self::FULLWIDTH) + ? null + : 'in einen Vollbreite-Abschnitt passen nur fullwidth-Module'; + } + + return $child === 'divi/row' ? null : 'ein Abschnitt enthält Zeilen (row)'; + } +} diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 4de4702..c054883 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -115,6 +115,36 @@ final class VariableRef return str_contains($value, self::MARKER); } + /** + * Type ("color" or "content") of $value when it is exactly one reference whose payload + * names that type; null otherwise. Reads the payload, never a string prefix (ruling S33c); + * it does not validate the reference – that is canonical()'s job. + */ + public static function typeOf(string $value): ?string + { + if (preg_match(self::WHOLE, $value, $match) !== 1 || str_contains($match[1], ')$')) { + return null; + } + + try { + $type = self::decode($match[1])['type'] ?? null; + } catch (InvalidArgumentException) { + return null; + } + + return $type === 'color' || $type === 'content' ? $type : null; + } + + /** + * Removes C0 control characters except tab, LF and CR, and DEL (ruling S32). The WordPress + * sanitizers keep them (sanitize_text_field keeps NUL), and "$variable\0(" would otherwise + * hide a marker from the final-string check that a later strip anywhere could rebuild. + */ + public static function withoutControls(string $value): string + { + return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value); + } + /** @param array|stdClass $settings */ public static function serialize(string $type, string $name, array|stdClass $settings): string { @@ -336,7 +366,11 @@ final class VariableRef $clean = is_string($value) ? $sanitizer->text($value) : $value; } - // Ruling S28: sanitizing may rebuild a marker ("$variable(" → "$variable("). + // Ruling S28: sanitizing may rebuild a marker ("$variable(" → "$variable("); control + // characters go first so a NUL-split marker is rebuilt – and caught – here (S32). + if (is_string($clean)) { + $clean = self::withoutControls($clean); + } if (is_string($clean) && (self::contains($clean) || str_contains($clean, ')$'))) { throw self::notAlone(); } diff --git a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php index ca17697..a2a939c 100644 --- a/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php @@ -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("

a\nb\r

", $writer->cleanValue('divi/text', 'content.innerContent', "

a\x0b\nb\r\x08

")); + 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', '

x

', null, 'text')); + self::assertSame('x', $writer->cleanValue('divi/code', 'content.innerContent', 'x', null, 'text')); + self::assertSame('

x

', $writer->cleanValue('divi/code', 'content.innerContent', '

x

', null, 'html')); + self::assertSame('Los', $writer->cleanValue('divi/heading', 'title.innerContent', 'Los', 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); + } + } + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php index 9ab2c23..54f5afb 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposeFixture.php @@ -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)[^>]*?>.*?@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' + ); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php new file mode 100644 index 0000000..eff8f84 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php @@ -0,0 +1,366 @@ +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( + '', + 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' => '

Antwort

', 'open' => true]]], + ['type' => 'text', 'content' => '

x

', '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', [], '', 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' => 'Hero', 'modules' => [['type' => 'heading', 'text' => 'x']]], + ['type' => 'section', 'label' => '$variable(' . 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;}'], + ['type' => 'section', 'background_color' => 'red;}'], + ['type' => 'section', 'background_color' => '{{post:title}}'], + ['type' => 'section', 'background_color' => '$variable({"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' => [ + 'p' => ['id' => 'p', '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', [], '', 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']); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index 633ff2a..70ca3bf 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -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 { diff --git a/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php b/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php new file mode 100644 index 0000000..38f00cb --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php @@ -0,0 +1,66 @@ + 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', [], '', 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')); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index bfedaf0..32efd41 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -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'));