– " (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 / ValueCleaner::clean() (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 (ColumnWidths). Design-system names that match several entries are errors * listing the ids (ruling S13a: colors/variables in Placeholders, presets in * DesignSystem::modulePresetId()). */ 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']; /** 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'; 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'; /** @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 ValueCleaner $cleaner, 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']; } } $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( $short, $change['attrs'] ?? null, $field, 'bitte als zwei Änderungen senden' ); $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]; } /** * Rulings S38/S40: one change or new node may not reach the same attribute path through the * short form and "attrs" – composite values are replaced whole per breakpoint, so combining * them would silently lose sub-keys. Paths overlap when equal or when one lies below the other. * * @param array $short Input name of the short-form value ("set.text", "text") => its path. * @param mixed $full The "attrs" input. * @param string $advice How to resolve it, for the error message. */ private function overlaps(array $short, mixed $full, string $field, string $advice): void { if ($short === [] || ! is_array($full)) { return; } foreach (array_keys($full) as $path) { $path = (string) $path; foreach ($short as $name => $shortPath) { if (AttrMap::overlaps($path, $shortPath)) { $this->errors[$field . '.attrs.' . $path] = sprintf( 'derselbe Attributpfad wird auch über %s geändert; %s', $name, $advice ); break; } } } } /** @param array $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 $attrs * @return array */ 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). * * @param array $values Field => value. * @return array */ private function shortPaths(string $type, array $values, string $prefix): array { $paths = []; foreach ($this->shortform->paths($type, array_map('strval', array_keys($values))) as $name => $path) { $paths[$prefix . $name] = $path; } return $paths; } /** Admin label for a new module: " – " or the title alone. */ private 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'] = $this->typeFieldCollision($spec, $parent) ?? $violation; return null; } return match ($type) { 'divi/section' => $this->section($spec, $path), 'divi/row' => $this->row($spec, $path, $context, $parent !== null && $parent->name() !== 'divi/section'), default => $this->module($type, $spec, $path, $context), }; } /** * Trial feedback: an item whose only possible type has a short-form field named "type" (the * contact field's field type) – a `type` that is no allowed child was almost certainly meant as * that field. Explains the collision instead of "unbekanntes Modul divi/". * * @param array $spec */ private function typeFieldCollision(array $spec, ?Block $parent): ?string { $children = $parent === null ? [] : $this->catalog->childrenOf($parent->name()); if (count($children) !== 1 || ! is_string($spec['type'] ?? null)) { return null; } $child = ModuleCatalog::shortName($children[0]); $field = ShortformMap::fields($child)['type'] ?? null; if ($field === null) { return null; } // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). $value = json_encode( $spec['type'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ); return sprintf( '`type` ist hier der Knotentyp (%1$s, darf fehlen); das Feldtyp-Feld über attrs setzen: ' . '{"attrs": {"%2$s": {"desktop": %3$s}}} – oder type weglassen und danach mit lit-divi-update ' . '{"set": {"type": %3$s}} setzen.', $child, (string) $field['path'], (string) $value ); } /** @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'); $this->overlaps( ['background_color' => self::BACKGROUND], $spec['attrs'] ?? null, $path, self::ONE_FORM ); } $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 * @param bool $nested In a column (Divi 5 rows nest): own auto label (ledger T3). */ private function row(array $spec, string $path, string $context, bool $nested): Block { $this->unknownKeys($spec, array_merge(self::COMMON, self::ROW), $path); $parsed = ColumnWidths::parse($spec['columns'] ?? '4_4'); if ($parsed['error'] !== null) { $this->errors[$path . '.columns'] = $parsed['error']; } $widths = $parsed['widths']; $label = $context === '' ? null : $context . ($nested ? ' – Zeile (innen)' : ' – 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))); $this->overlaps($this->shortPaths($short, $values, ''), $spec['attrs'] ?? null, $path, self::ONE_FORM); $defaultLevel = $name === 'divi/heading' && ! array_key_exists('level', $values); if ($defaultLevel) { $values['level'] = self::DEFAULT_LEVEL; } $attrs = ['builderVersion' => $this->builderVersion]; if ($values !== []) { $result = $this->shortform->apply($short, $attrs, $values, $path); $attrs = $result['attrs']; $this->errors += $result['errors']; } $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') { $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 { // 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 === '') { 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'); // An unknown path is the more useful message on its key (it decides invalid_attr_path). foreach ($result['invalid_paths'] as $invalid) { $key = $path . '.attrs.' . $invalid; if (isset($result['errors'][$key])) { $this->errors[$key] = $result['errors'][$key]; } } $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->cleaner->clean($name, self::LABEL_PATH, $label, null, ValueKind::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; } try { $id = is_string($preset) ? $this->design->modulePresetId($name, $preset) : null; } catch (InvalidArgumentException $e) { // Ruling S13a: a name several presets share (DesignSystem lists their ids). $this->errors[$path] = $e->getMessage(); return $attrs; } if ($id === null) { $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->cleaner->clean($name, 'modulePreset', $id, null, ValueKind::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; } /** * 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', self::BACKGROUND, $color, 'color'); } catch (InvalidArgumentException $e) { $this->errors[$path] = $e->getMessage(); return $attrs; } return AttrMap::set($attrs, self::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); } } } /** * @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); } } }