feat: Divi composer – short/full form to blocks with structure rules, presets and labels
Labels, background_color and preset ids go through the choke point (S13); ambiguous preset names fail with their ids (S13a); fullwidth + rows is an error. Carry-ins: control characters stripped before the marker check (S32), kind override may only tighten, reference alt texts dropped, color check via VariableRef::typeOf (S33). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -39,6 +39,9 @@ final class AttrWriter
|
|||||||
* path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes,
|
* 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).
|
* 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)(\.|$)/';
|
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/';
|
||||||
|
|
||||||
public function __construct(
|
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
|
* 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
|
* 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 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
|
* string of $value: the short form passes its curated field kind (ShortformMap), so
|
||||||
* short-form url field is always a URL and icon-list-item text always plain text.
|
* 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
|
* @param 'html'|'code'|'url'|'text'|null $kind
|
||||||
* @throws InvalidArgumentException
|
* @throws InvalidArgumentException
|
||||||
@@ -198,7 +202,17 @@ final class AttrWriter
|
|||||||
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
|
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)) {
|
if (VariableRef::contains($value)) {
|
||||||
// The only way a reference is stored: whole, straight from canonical().
|
// The only way a reference is stored: whole, straight from canonical().
|
||||||
return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url');
|
return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url');
|
||||||
@@ -211,8 +225,10 @@ final class AttrWriter
|
|||||||
default => $this->sanitizer->text($value),
|
default => $this->sanitizer->text($value),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ruling S28: sanitizers strip characters ("<x>", NUL, "%41") and can thereby build a
|
// Ruling S32: control characters (the WordPress sanitizers keep NUL & co.) go before the
|
||||||
// marker the raw check above never saw – so the final string is checked again.
|
// check. Ruling S28: sanitizers strip characters ("<x>", 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)) {
|
if (VariableRef::contains($clean)) {
|
||||||
throw VariableRef::notAlone();
|
throw VariableRef::notAlone();
|
||||||
}
|
}
|
||||||
@@ -220,6 +236,21 @@ final class AttrWriter
|
|||||||
return $clean;
|
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
|
* 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
|
* sub-field for a composite path – decides. A string reached through a key below a path
|
||||||
|
|||||||
@@ -0,0 +1,548 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds new Divi blocks from node descriptions (spec §5.4): short form, full form ("attrs")
|
||||||
|
* or both; sections with rows ("columns": "1_3,2_3") and modules; parent modules with
|
||||||
|
* "items"; presets by name; automatic admin labels "<Abschnitt> – <Modul>" (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<string, string> */
|
||||||
|
private array $errors = [];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
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<array-key, mixed> $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<Block>, errors: array<string, string>, invalid_paths: list<string>}
|
||||||
|
*/
|
||||||
|
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<array-key, mixed> $change id, set, attrs, preset, label
|
||||||
|
* @return array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
|
||||||
|
*/
|
||||||
|
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: "<Abschnitt> – <Modultitel>" 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<array-key, mixed> $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<array-key, mixed> $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<array-key, mixed> $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<array-key, mixed> $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<string, mixed> $attrs
|
||||||
|
* @param array<array-key, mixed> $spec
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $attrs
|
||||||
|
* @param array<array-key, mixed> $spec
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $attrs
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<array-key, mixed> $spec
|
||||||
|
* @param list<string> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,9 +25,6 @@ final class ShortformWriter
|
|||||||
|
|
||||||
private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a';
|
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(). */
|
/** Curated kind => sanitizer kind of AttrWriter::cleanValue(). */
|
||||||
private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text'];
|
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));
|
$value = trim($this->placeholders->resolve($value));
|
||||||
if (VariableRef::contains($value)) {
|
if (VariableRef::contains($value)) {
|
||||||
$reference = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text');
|
$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);
|
throw new InvalidArgumentException(self::COLOR_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,14 +163,33 @@ final class ShortformWriter
|
|||||||
$attrs = AttrMap::set($attrs, $base . $idKey, (string) $id);
|
$attrs = AttrMap::set($attrs, $base . $idKey, (string) $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($altKey !== null && $withAlt && $image['alt'] !== '') {
|
$alt = $altKey !== null && $withAlt ? $this->alt($module, $path, $image['alt'], $altKey) : '';
|
||||||
$alt = $this->attrs->cleanValue($module, $path, $image['alt'], $altKey, 'text');
|
if ($alt !== '') {
|
||||||
$attrs = AttrMap::set($attrs, $base . $altKey, $alt);
|
$attrs = AttrMap::set($attrs, $base . (string) $altKey, $alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attrs;
|
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<string, mixed> $spec
|
* @param array<string, mixed> $spec
|
||||||
* @throws InvalidArgumentException
|
* @throws InvalidArgumentException
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which node may sit where (spec §5.4 "Struktur"): page → sections; regular section → rows;
|
||||||
|
* fullwidth section → fullwidth modules; column/group → modules; parent module → its child
|
||||||
|
* modules (catalog). Columns only come with their row; specialty sections and read-only
|
||||||
|
* nodes (opaque, non-Divi) are not rebuilt. Pure PHP.
|
||||||
|
*/
|
||||||
|
final class StructureRules
|
||||||
|
{
|
||||||
|
private const COLUMNS = ['divi/column', 'divi/column-inner'];
|
||||||
|
private const ROWS = ['divi/row', 'divi/row-inner'];
|
||||||
|
private const FULLWIDTH = 'divi/fullwidth-';
|
||||||
|
|
||||||
|
public function __construct(private ModuleCatalog $catalog)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null when $child may be placed into $parent (null = top level of the page), else the German reason. */
|
||||||
|
public function violation(?Block $parent, string $child): ?string
|
||||||
|
{
|
||||||
|
if (! $this->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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,36 @@ final class VariableRef
|
|||||||
return str_contains($value, self::MARKER);
|
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<string, mixed>|stdClass $settings */
|
/** @param array<string, mixed>|stdClass $settings */
|
||||||
public static function serialize(string $type, string $name, array|stdClass $settings): string
|
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;
|
$clean = is_string($value) ? $sanitizer->text($value) : $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ruling S28: sanitizing may rebuild a marker ("$variable<x>(" → "$variable(").
|
// Ruling S28: sanitizing may rebuild a marker ("$variable<x>(" → "$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, ')$'))) {
|
if (is_string($clean) && (self::contains($clean) || str_contains($clean, ')$'))) {
|
||||||
throw self::notAlone();
|
throw self::notAlone();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,4 +375,63 @@ final class AttrWriterTest extends TestCase
|
|||||||
$result['attrs']['module']['decoration']['background']['desktop']['value']['color']
|
$result['attrs']['module']['decoration']['background']['desktop']['value']['color']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */
|
||||||
|
public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void
|
||||||
|
{
|
||||||
|
$writer = ComposeFixture::attrWriter();
|
||||||
|
|
||||||
|
self::assertSame("a\tb\n", $writer->cleanValue('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f"));
|
||||||
|
self::assertSame("<p>a\nb\r</p>", $writer->cleanValue('divi/text', 'content.innerContent', "<p>a\x0b\nb\r\x08</p>"));
|
||||||
|
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', "Ti\x00tel\x7f"));
|
||||||
|
|
||||||
|
$tail = '({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$';
|
||||||
|
$targets = [
|
||||||
|
'text' => ['divi/heading', 'title.innerContent'],
|
||||||
|
'html' => ['divi/text', 'content.innerContent'],
|
||||||
|
'code' => ['divi/code', 'content.innerContent'],
|
||||||
|
];
|
||||||
|
foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) {
|
||||||
|
$value = '$variable' . $control . $tail;
|
||||||
|
// The fake keeps them like sanitize_text_field does: the production code must catch it.
|
||||||
|
self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text($value));
|
||||||
|
foreach ($targets as $kind => [$module, $path]) {
|
||||||
|
try {
|
||||||
|
$writer->cleanValue($module, $path, $value);
|
||||||
|
self::fail(sprintf('%s: control %d not caught', $kind, ord($control)));
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('allein im Wert', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */
|
||||||
|
public function testKindOverrideMayOnlyTighten(): void
|
||||||
|
{
|
||||||
|
$writer = ComposeFixture::attrWriter();
|
||||||
|
|
||||||
|
self::assertSame('x', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p>', null, 'text'));
|
||||||
|
self::assertSame('x', $writer->cleanValue('divi/code', 'content.innerContent', '<b>x</b>', null, 'text'));
|
||||||
|
self::assertSame('<p>x</p>', $writer->cleanValue('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, 'html'));
|
||||||
|
self::assertSame('Los', $writer->cleanValue('divi/heading', 'title.innerContent', '<b>Los</b>', null, 'text'));
|
||||||
|
self::assertSame('/a', $writer->cleanValue('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url'));
|
||||||
|
|
||||||
|
$weaker = [
|
||||||
|
'html over text' => ['divi/heading', 'title.innerContent', null, 'html'],
|
||||||
|
'code over text' => ['divi/heading', 'title.innerContent', null, 'code'],
|
||||||
|
'code over html' => ['divi/text', 'content.innerContent', null, 'code'],
|
||||||
|
'url over text' => ['divi/heading', 'title.innerContent', null, 'url'],
|
||||||
|
'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'],
|
||||||
|
'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'],
|
||||||
|
];
|
||||||
|
foreach ($weaker as $name => [$module, $path, $sub, $kind]) {
|
||||||
|
try {
|
||||||
|
$writer->cleanValue($module, $path, 'x', $sub, $kind);
|
||||||
|
self::fail($name . ' was accepted');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Feldart', $e->getMessage(), $name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||||
|
|
||||||
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
|
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\MediaResolver;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
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\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\Design\DesignSystemTest;
|
||||||
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
|
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
|
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
|
public static function sanitizer(bool $unfiltered = false): ValueSanitizer
|
||||||
{
|
{
|
||||||
return new class ($unfiltered) implements 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
|
public function text(string $value): string
|
||||||
{
|
{
|
||||||
$value = strip_tags(str_replace(chr(0), '', $value));
|
if (str_contains($value, '<')) {
|
||||||
|
$value = strip_tags((string) preg_replace('@<(script|style)[^>]*?>.*?</\\1>@si', '', $value));
|
||||||
|
}
|
||||||
|
|
||||||
return trim((string) preg_replace('/%[a-f0-9]{2}/i', '', $value));
|
$value = trim((string) preg_replace('/[\r\n\t ]+/', ' ', $value));
|
||||||
|
$found = false;
|
||||||
|
while (preg_match('/%[a-f0-9]{2}/i', $value, $match) === 1) {
|
||||||
|
$value = str_replace($match[0], '', $value);
|
||||||
|
$found = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $found ? trim((string) preg_replace('/ +/', ' ', $value)) : $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,6 +109,10 @@ final class ComposeFixture
|
|||||||
return match ($id) {
|
return match ($id) {
|
||||||
33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'],
|
33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'],
|
||||||
12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''],
|
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,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -113,4 +137,29 @@ final class ComposeFixture
|
|||||||
{
|
{
|
||||||
return new ShortformWriter(self::placeholders(), self::media(), self::attrWriter($unfiltered), $modules);
|
return new ShortformWriter(self::placeholders(), self::media(), self::attrWriter($unfiltered), $modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function rules(): StructureRules
|
||||||
|
{
|
||||||
|
return new StructureRules(ModuleCatalogTest::sampleCatalog());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder version 5.2.1. $design replaces the sample design system (one Placeholders instance
|
||||||
|
* shared by AttrWriter and ShortformWriter, like the live wiring – ruling S31).
|
||||||
|
*/
|
||||||
|
public static function composer(bool $unfiltered = false, ?DesignSystem $design = null): Composer
|
||||||
|
{
|
||||||
|
$design ??= DesignSystemTest::sample();
|
||||||
|
$placeholders = new Placeholders($design);
|
||||||
|
$attrWriter = new AttrWriter(ModuleCatalogTest::sampleCatalog(), $placeholders, self::sanitizer($unfiltered));
|
||||||
|
|
||||||
|
return new Composer(
|
||||||
|
ModuleCatalogTest::sampleCatalog(),
|
||||||
|
self::rules(),
|
||||||
|
new ShortformWriter($placeholders, self::media(), $attrWriter),
|
||||||
|
$attrWriter,
|
||||||
|
$design,
|
||||||
|
'5.2.1'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\Composer;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||||
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
|
||||||
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ComposerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testBuildsASectionFromTheShortForm(): void
|
||||||
|
{
|
||||||
|
$result = ComposeFixture::composer()->compose([[
|
||||||
|
'type' => 'section',
|
||||||
|
'label' => 'Hero',
|
||||||
|
'background_color' => '{{color:Nachtblau}}',
|
||||||
|
'rows' => [[
|
||||||
|
'columns' => '1_3,2_3',
|
||||||
|
'modules' => [
|
||||||
|
[['type' => 'image', 'image_id' => 33]],
|
||||||
|
[
|
||||||
|
['type' => 'heading', 'text' => 'Willkommen'],
|
||||||
|
['type' => 'button', 'text' => 'Termin buchen', 'url' => '/kontakt', 'preset' => 'CTA primär'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
]], null, 'nodes');
|
||||||
|
|
||||||
|
self::assertSame([], $result['errors']);
|
||||||
|
self::assertStringStartsWith(
|
||||||
|
'<!-- wp:divi/section {"builderVersion":"5.2.1","module":{"decoration":{"background":{"desktop":{"value":'
|
||||||
|
. '{"color":"$variable({\u0022type\u0022:\u0022color\u0022,\u0022value\u0022:{\u0022name\u0022:'
|
||||||
|
. '\u0022gcid-a1b2\u0022,\u0022settings\u0022:{}}})$"}}}},"meta":{"adminLabel":{"desktop":{"value":'
|
||||||
|
. '"Hero"}}}}} --><!-- wp:divi/row {"builderVersion":"5.2.1","module":{"meta":{"adminLabel":'
|
||||||
|
. '{"desktop":{"value":"Hero – Zeile"}}}}} --><!-- wp:divi/column {"builderVersion":"5.2.1","module":'
|
||||||
|
. '{"decoration":{"sizing":{"desktop":{"value":{"flexType":"8_24"}}}}}} --><!-- wp:divi/image '
|
||||||
|
. '{"builderVersion":"5.2.1","image":{"innerContent":{"desktop":{"value":{"src":'
|
||||||
|
. '"https://kunde.test/wp-content/uploads/team.jpg","id":"33","alt":"Unser Team"}}}},"module":{"meta":'
|
||||||
|
. '{"adminLabel":{"desktop":{"value":"Hero – Image"}}}}} --><!-- /wp:divi/image -->',
|
||||||
|
Serializer::block($result['blocks'][0])
|
||||||
|
);
|
||||||
|
|
||||||
|
$section = $result['blocks'][0];
|
||||||
|
[$heading, $button] = $section->children()[0]->children()[1]->children();
|
||||||
|
self::assertSame('16_24', NodeSummary::columnWidth($section->children()[0]->children()[1]));
|
||||||
|
self::assertSame('h2', $heading->attrs()['title']['decoration']['font']['font']['desktop']['value']['headingLevel']);
|
||||||
|
self::assertSame('Hero – Heading', NodeSummary::label($heading));
|
||||||
|
self::assertSame(['p-2'], $button->attrs()['modulePreset']);
|
||||||
|
|
||||||
|
// What the composer writes, the parser reads back unchanged.
|
||||||
|
$markup = Serializer::block($section);
|
||||||
|
self::assertSame($markup, Serializer::document((new Parser())->parse($markup)));
|
||||||
|
self::assertFalse((new Parser())->parse($markup)->children()[0]->isOpaque());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShortcutsItemsFullwidthAndFullForm(): void
|
||||||
|
{
|
||||||
|
$result = ComposeFixture::composer()->compose([
|
||||||
|
['type' => 'section', 'modules' => [
|
||||||
|
['type' => 'accordion', 'items' => [['title' => 'Frage', 'content' => '<p>Antwort</p>', 'open' => true]]],
|
||||||
|
['type' => 'text', 'content' => '<p>x</p>', 'label' => 'Einleitung',
|
||||||
|
'attrs' => ['module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '20px']]]]],
|
||||||
|
]],
|
||||||
|
['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [
|
||||||
|
['type' => 'fullwidth-header', 'attrs' => ['title.innerContent' => ['desktop' => 'Großer Titel']]],
|
||||||
|
]],
|
||||||
|
], null, 'nodes');
|
||||||
|
|
||||||
|
self::assertSame([], $result['errors']);
|
||||||
|
[$regular, $fullwidth] = $result['blocks'];
|
||||||
|
$column = $regular->children()[0]->children()[0];
|
||||||
|
self::assertSame('24_24', NodeSummary::columnWidth($column));
|
||||||
|
[$accordion, $text] = $column->children();
|
||||||
|
self::assertSame('divi/accordion-item', $accordion->children()[0]->name());
|
||||||
|
self::assertSame('on', $accordion->children()[0]->attrs()['module']['advanced']['open']['desktop']['value']);
|
||||||
|
self::assertSame('Einleitung', NodeSummary::label($text));
|
||||||
|
self::assertSame(['top' => '20px'], $text->attrs()['module']['decoration']['spacing']['desktop']['value']['padding']);
|
||||||
|
self::assertSame('fullwidth', $fullwidth->attrs()['module']['advanced']['type']['desktop']['value']);
|
||||||
|
self::assertSame('divi/fullwidth-header', $fullwidth->children()[0]->name());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCollectsEveryErrorWithItsInputPath(): void
|
||||||
|
{
|
||||||
|
$result = ComposeFixture::composer()->compose([
|
||||||
|
['type' => 'button', 'text' => 'x'],
|
||||||
|
['type' => 'section', 'rows' => [
|
||||||
|
['columns' => '1_2,1_3', 'modules' => []],
|
||||||
|
['columns' => '1_2,1_2', 'modules' => [[['type' => 'slider-pro']], [['type' => 'accordion-item']]]],
|
||||||
|
]],
|
||||||
|
['type' => 'section', 'preset' => 'Gibt es nicht', 'foo' => 1],
|
||||||
|
['type' => 'section', 'modules' => [['type' => 'heading', 'attrs' => ['title.fnt' => ['desktop' => 'x']]]]],
|
||||||
|
], null, 'nodes');
|
||||||
|
|
||||||
|
self::assertSame('auf oberster Ebene sind nur Abschnitte (section) erlaubt', $result['errors']['nodes[0].type']);
|
||||||
|
self::assertStringContainsString('volle Zeile', $result['errors']['nodes[1].rows[0].columns']);
|
||||||
|
self::assertStringContainsString('unbekanntes Modul "divi/slider-pro"', $result['errors']['nodes[1].rows[1].modules[0][0].type']);
|
||||||
|
self::assertStringContainsString('passt nicht in eine Spalte', $result['errors']['nodes[1].rows[1].modules[1][0].type']);
|
||||||
|
self::assertStringContainsString('unbekanntes Preset', $result['errors']['nodes[2].preset']);
|
||||||
|
self::assertStringContainsString('unbekanntes Feld', $result['errors']['nodes[2].foo']);
|
||||||
|
self::assertSame(['title.fnt'], $result['invalid_paths']);
|
||||||
|
self::assertArrayHasKey('nodes[3].modules[0].modules[0][0].attrs.title.fnt', $result['errors']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testColumnWidths(): void
|
||||||
|
{
|
||||||
|
$composer = ComposeFixture::composer();
|
||||||
|
$widths = static function (string $columns) use ($composer): array {
|
||||||
|
$result = $composer->compose([['type' => 'section', 'rows' => [['columns' => $columns]]]], null, 'n');
|
||||||
|
|
||||||
|
return [
|
||||||
|
array_map([NodeSummary::class, 'columnWidth'], $result['blocks'][0]->children()[0]->children()),
|
||||||
|
$result['errors'],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
self::assertSame([['12_24', '12_24'], []], $widths('1_2,1_2'));
|
||||||
|
self::assertSame([['6_24', '18_24'], []], $widths('1_4, 3_4'));
|
||||||
|
self::assertSame([['2_5', '3_5'], []], $widths('2_5,3_5'));
|
||||||
|
self::assertSame(['24_24'], $widths('1_7,5_7')[0]);
|
||||||
|
self::assertArrayHasKey('n[0].rows[0].columns', $widths('1_7,5_7')[1]);
|
||||||
|
self::assertArrayHasKey('n[0].rows[0].columns', $widths('abc')[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateChangesShortFormPresetAndLabel(): void
|
||||||
|
{
|
||||||
|
$block = Block::create('divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Alt']]]]]);
|
||||||
|
|
||||||
|
$result = ComposeFixture::composer()->update($block, [
|
||||||
|
'set' => ['text' => 'Neu', 'url' => '/neu'],
|
||||||
|
'preset' => 'Standard',
|
||||||
|
'label' => 'Hero – CTA',
|
||||||
|
], 'changes[0]');
|
||||||
|
|
||||||
|
self::assertSame([], $result['errors']);
|
||||||
|
self::assertSame(['text' => 'Neu', 'linkUrl' => '/neu'], $result['attrs']['button']['innerContent']['desktop']['value']);
|
||||||
|
self::assertSame(['p-1'], $result['attrs']['modulePreset']);
|
||||||
|
self::assertSame('Hero – CTA', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']);
|
||||||
|
// update() only computes attributes; the block itself stays as it was.
|
||||||
|
self::assertArrayNotHasKey('modulePreset', $block->attrs());
|
||||||
|
|
||||||
|
$cleared = ComposeFixture::composer()->update(
|
||||||
|
Block::create('divi/button', $result['attrs']),
|
||||||
|
['preset' => null, 'label' => null],
|
||||||
|
'changes[0]'
|
||||||
|
);
|
||||||
|
self::assertSame([], $cleared['errors']);
|
||||||
|
self::assertArrayNotHasKey('modulePreset', $cleared['attrs']);
|
||||||
|
self::assertArrayNotHasKey('module', $cleared['attrs']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateReportsBadInput(): void
|
||||||
|
{
|
||||||
|
$block = Block::create('divi/button', ['builderVersion' => '5.2.1']);
|
||||||
|
|
||||||
|
$result = ComposeFixture::composer()->update($block, [
|
||||||
|
'id' => 's0.r0.c0.m0',
|
||||||
|
'set' => ['nope'],
|
||||||
|
'label' => 42,
|
||||||
|
'colour' => 'x',
|
||||||
|
], 'changes[1]');
|
||||||
|
|
||||||
|
self::assertArrayHasKey('changes[1].set', $result['errors']);
|
||||||
|
self::assertArrayHasKey('changes[1].label', $result['errors']);
|
||||||
|
self::assertStringContainsString('unbekanntes Feld', $result['errors']['changes[1].colour']);
|
||||||
|
self::assertArrayNotHasKey('changes[1].id', $result['errors']);
|
||||||
|
self::assertSame(['builderVersion' => '5.2.1'], $result['attrs']);
|
||||||
|
|
||||||
|
$opaque = Block::parsed('divi/button', [], '<!-- wp:divi/button -->', false, true);
|
||||||
|
$readOnly = ComposeFixture::composer()->update($opaque, ['label' => 'x'], 'changes[2]');
|
||||||
|
self::assertStringContainsString('nur lesbar', $readOnly['errors']['changes[2].id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S13: labels go through the choke point; a label that fails is an error, never stored raw. */
|
||||||
|
public function testLabelsAreCleanedOrReported(): void
|
||||||
|
{
|
||||||
|
$reference = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
|
||||||
|
$result = ComposeFixture::composer()->compose([
|
||||||
|
['type' => 'section', 'label' => '<b>Hero</b>', 'modules' => [['type' => 'heading', 'text' => 'x']]],
|
||||||
|
['type' => 'section', 'label' => '$variable<x>(' . substr($reference, strlen('$variable(')),
|
||||||
|
'modules' => [['type' => 'heading', 'text' => 'x']]],
|
||||||
|
['type' => 'section', 'label' => '$variable' . chr(0) . substr($reference, strlen('$variable'))],
|
||||||
|
['type' => 'section', 'label' => $reference],
|
||||||
|
['type' => 'section', 'label' => ['Hero']],
|
||||||
|
['type' => 'section', 'modules' => [['type' => 'heading', 'label' => "Titel\x01\x7f"]]],
|
||||||
|
], null, 'nodes');
|
||||||
|
|
||||||
|
[$clean, $split, $nul, $whole, $list, $control] = $result['blocks'];
|
||||||
|
self::assertSame('Hero', NodeSummary::label($clean));
|
||||||
|
self::assertSame('Hero – Heading', NodeSummary::label($clean->children()[0]->children()[0]->children()[0]));
|
||||||
|
|
||||||
|
foreach ([1, 2, 3, 4] as $position) {
|
||||||
|
self::assertArrayHasKey(sprintf('nodes[%d].label', $position), $result['errors']);
|
||||||
|
}
|
||||||
|
foreach ([$split, $nul, $whole, $list] as $section) {
|
||||||
|
self::assertSame('', NodeSummary::label($section));
|
||||||
|
}
|
||||||
|
// No raw label reaches the children's auto labels either.
|
||||||
|
self::assertSame('Heading', NodeSummary::label($split->children()[0]->children()[0]->children()[0]));
|
||||||
|
self::assertStringNotContainsString('$variable', Serializer::block($split));
|
||||||
|
|
||||||
|
// Ruling S32: control characters are stripped from every final string.
|
||||||
|
self::assertSame('Titel', NodeSummary::label($control->children()[0]->children()[0]->children()[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S13: background_color takes the short-form color check, not text(). */
|
||||||
|
public function testBackgroundColorIsAColor(): void
|
||||||
|
{
|
||||||
|
$result = ComposeFixture::composer()->compose([
|
||||||
|
['type' => 'section', 'background_color' => '#1a1a1a'],
|
||||||
|
['type' => 'section', 'background_color' => 'red;}</style><script>alert(1)</script>'],
|
||||||
|
['type' => 'section', 'background_color' => 'red;}'],
|
||||||
|
['type' => 'section', 'background_color' => '{{post:title}}'],
|
||||||
|
['type' => 'section', 'background_color' => '$variable<x>({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
|
||||||
|
['type' => 'section', 'background_color' => ['#fff']],
|
||||||
|
], null, 'nodes');
|
||||||
|
|
||||||
|
self::assertSame('#1a1a1a', $result['blocks'][0]->attrs()['module']['decoration']['background']['desktop']['value']['color']);
|
||||||
|
foreach ([1, 2, 3, 4, 5] as $position) {
|
||||||
|
self::assertStringContainsString('Farbe erwartet', $result['errors'][sprintf('nodes[%d].background_color', $position)]);
|
||||||
|
self::assertArrayNotHasKey('module', $result['blocks'][$position]->attrs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S13: a fullwidth section holds fullwidth modules; rows there are an error, not ignored. */
|
||||||
|
public function testFullwidthSectionWithRowsIsAnError(): void
|
||||||
|
{
|
||||||
|
$result = ComposeFixture::composer()->compose([
|
||||||
|
['type' => 'section', 'section_type' => 'fullwidth', 'rows' => [['columns' => '1_2,1_2']]],
|
||||||
|
['type' => 'section', 'rows' => [], 'modules' => []],
|
||||||
|
['type' => 'section', 'section_type' => 'specialty'],
|
||||||
|
['type' => 'section', 'section_type' => 'fullwidth', 'modules' => [['type' => 'heading', 'text' => 'x']]],
|
||||||
|
], null, 'nodes');
|
||||||
|
|
||||||
|
self::assertStringContainsString('Vollbreite', $result['errors']['nodes[0].rows']);
|
||||||
|
self::assertSame([], $result['blocks'][0]->children());
|
||||||
|
self::assertStringContainsString('entweder rows oder modules', $result['errors']['nodes[1]']);
|
||||||
|
self::assertStringContainsString('specialty', $result['errors']['nodes[2].section_type']);
|
||||||
|
self::assertStringContainsString('fullwidth-Module', $result['errors']['nodes[3].modules[0].type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling S13a: an ambiguous design-system name is an error listing the ids; ids work directly. */
|
||||||
|
public function testAmbiguousDesignNamesFailWithTheIds(): void
|
||||||
|
{
|
||||||
|
$design = DesignSystem::fromDivi(
|
||||||
|
[
|
||||||
|
'gcid-b1' => ['color' => '#00f', 'label' => 'Blau', 'status' => 'active'],
|
||||||
|
'gcid-b2' => ['color' => '#00a', 'label' => 'blau', 'status' => 'active'],
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
['module' => ['divi/button' => ['default' => 'p-1', 'items' => [
|
||||||
|
'p-1' => ['id' => 'p-1', 'name' => 'Standard', 'moduleName' => 'divi/button'],
|
||||||
|
'p-3' => ['id' => 'p-3', 'name' => 'standard', 'moduleName' => 'divi/button'],
|
||||||
|
]]]],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
$composer = ComposeFixture::composer(false, $design);
|
||||||
|
|
||||||
|
$result = $composer->compose([
|
||||||
|
['type' => 'section', 'background_color' => '{{color:Blau}}', 'modules' => [
|
||||||
|
['type' => 'button', 'preset' => 'Standard'],
|
||||||
|
['type' => 'button', 'preset' => 'p-3'],
|
||||||
|
]],
|
||||||
|
], null, 'nodes');
|
||||||
|
|
||||||
|
self::assertStringContainsString('gcid-b1, gcid-b2', $result['errors']['nodes[0].background_color']);
|
||||||
|
self::assertStringContainsString('mehrdeutig', $result['errors']['nodes[0].modules[0].modules[0][0].preset']);
|
||||||
|
self::assertStringContainsString('p-1, p-3', $result['errors']['nodes[0].modules[0].modules[0][0].preset']);
|
||||||
|
self::assertArrayNotHasKey('nodes[0].modules[0].modules[0][1].preset', $result['errors']);
|
||||||
|
[$ambiguous, $byId] = $result['blocks'][0]->children()[0]->children()[0]->children();
|
||||||
|
self::assertArrayNotHasKey('modulePreset', $ambiguous->attrs());
|
||||||
|
self::assertSame(['p-3'], $byId->attrs()['modulePreset']);
|
||||||
|
|
||||||
|
$update = $composer->update(Block::create('divi/button'), ['preset' => 'STANDARD'], 'changes[0]');
|
||||||
|
self::assertStringContainsString('p-1, p-3', $update['errors']['changes[0].preset']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preset ids come from the design system but still pass the choke point unchanged. */
|
||||||
|
public function testPresetIdsPassTheChokePoint(): void
|
||||||
|
{
|
||||||
|
$design = DesignSystem::fromDivi([], [], ['module' => ['divi/button' => ['default' => '', 'items' => [
|
||||||
|
'<b>p</b>' => ['id' => '<b>p</b>', 'name' => 'Seltsam', 'moduleName' => 'divi/button'],
|
||||||
|
]]]], []);
|
||||||
|
|
||||||
|
$result = ComposeFixture::composer(false, $design)->update(
|
||||||
|
Block::create('divi/button'),
|
||||||
|
['preset' => 'Seltsam'],
|
||||||
|
'changes[0]'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertArrayHasKey('changes[0].preset', $result['errors']);
|
||||||
|
self::assertArrayNotHasKey('modulePreset', $result['attrs']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuilderVersionMustBeAVersion(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new Composer(
|
||||||
|
ModuleCatalogTest::sampleCatalog(),
|
||||||
|
ComposeFixture::rules(),
|
||||||
|
ComposeFixture::shortform(),
|
||||||
|
ComposeFixture::attrWriter(),
|
||||||
|
DesignSystemTest::sample(),
|
||||||
|
'5.2.1"},"x":{"'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAutoLabel(): void
|
||||||
|
{
|
||||||
|
$composer = ComposeFixture::composer();
|
||||||
|
|
||||||
|
self::assertSame('Hero – Button', $composer->autoLabel('divi/button', 'Hero'));
|
||||||
|
self::assertSame('Button', $composer->autoLabel('divi/button', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testComposesIntoAnExistingParent(): void
|
||||||
|
{
|
||||||
|
$composer = ComposeFixture::composer();
|
||||||
|
$column = Block::create('divi/column');
|
||||||
|
$accordion = Block::create('divi/accordion');
|
||||||
|
|
||||||
|
$modules = $composer->compose([['type' => 'button', 'text' => 'Los']], $column, 'nodes', 'Hero');
|
||||||
|
$items = $composer->compose([['title' => 'Frage']], $accordion, 'nodes');
|
||||||
|
|
||||||
|
self::assertSame([], $modules['errors']);
|
||||||
|
self::assertSame('Hero – Button', NodeSummary::label($modules['blocks'][0]));
|
||||||
|
self::assertSame([], $items['errors']);
|
||||||
|
self::assertSame('divi/accordion-item', $items['blocks'][0]->name());
|
||||||
|
// compose() builds blocks; inserting them is the caller's job.
|
||||||
|
self::assertSame([], $column->children());
|
||||||
|
|
||||||
|
$opaque = Block::parsed('divi/column', [], '<!-- wp:divi/column -->', false, true);
|
||||||
|
$readOnly = $composer->compose([['type' => 'button']], $opaque, 'nodes');
|
||||||
|
self::assertStringContainsString('nur lesbar', $readOnly['errors']['nodes[0].type']);
|
||||||
|
self::assertSame([], $readOnly['blocks']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsNonObjectsAndMissingTypes(): void
|
||||||
|
{
|
||||||
|
$result = ComposeFixture::composer()->compose(['section', [], ['label' => 'x']], null, 'nodes');
|
||||||
|
|
||||||
|
self::assertArrayHasKey('nodes[0]', $result['errors']);
|
||||||
|
self::assertArrayHasKey('nodes[1]', $result['errors']);
|
||||||
|
self::assertSame('type fehlt', $result['errors']['nodes[2].type']);
|
||||||
|
self::assertSame([], $result['blocks']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTooManyModuleListsForTheColumns(): void
|
||||||
|
{
|
||||||
|
$result = ComposeFixture::composer()->compose([['type' => 'section', 'rows' => [[
|
||||||
|
'columns' => '1_2,1_2',
|
||||||
|
'modules' => [[], [], [['type' => 'text']]],
|
||||||
|
]]]], null, 'nodes');
|
||||||
|
|
||||||
|
self::assertStringContainsString('2 Spalten, aber 3 Modul-Listen', $result['errors']['nodes[0].rows[0].modules']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,11 +215,16 @@ final class ShortformWriterTest extends TestCase
|
|||||||
{
|
{
|
||||||
$rows = self::malicious();
|
$rows = self::malicious();
|
||||||
$sanitizer = ComposeFixture::sanitizer();
|
$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::assertStringNotContainsString('$variable(', $rows[$row][1]);
|
||||||
self::assertStringContainsString('$variable(', $sanitizer->text($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) {
|
foreach (['url: stripped-characters marker', 'url: NUL-split marker', 'url: line-break octet marker'] as $row) {
|
||||||
self::assertStringNotContainsString('$variable(', $rows[$row][1]);
|
self::assertStringNotContainsString('$variable(', $rows[$row][1]);
|
||||||
self::assertStringContainsString('$variable(', $sanitizer->url($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]);
|
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. */
|
/** Ruling S21 applies to short-form text too: before/after of a reference are cleaned, not rejected. */
|
||||||
public function testReferenceSettingsInTextAreCleaned(): void
|
public function testReferenceSettingsInTextAreCleaned(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class StructureRulesTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @return array<string, array{?Block, string, ?string}> Parent, child, part of the reason (null = allowed). */
|
||||||
|
public static function placements(): array
|
||||||
|
{
|
||||||
|
$section = static fn (string $type): Block => Block::create(
|
||||||
|
'divi/section',
|
||||||
|
['module' => ['advanced' => ['type' => ['desktop' => ['value' => $type]]]]]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'section on top' => [null, 'divi/section', null],
|
||||||
|
'module on top' => [null, 'divi/text', 'oberster Ebene'],
|
||||||
|
'unknown module' => [Block::create('divi/column'), 'divi/slider-pro', 'unbekanntes Modul'],
|
||||||
|
'row in a section' => [Block::create('divi/section'), 'divi/row', null],
|
||||||
|
'module in a section' => [Block::create('divi/section'), 'divi/text', 'Zeilen'],
|
||||||
|
'fullwidth module in a fullwidth section' => [$section('fullwidth'), 'divi/fullwidth-header', null],
|
||||||
|
'row in a fullwidth section' => [$section('fullwidth'), 'divi/row', 'fullwidth-Module'],
|
||||||
|
'anything in a specialty section' => [$section('specialty'), 'divi/row', 'Spezialabschnitte'],
|
||||||
|
'column in a row' => [Block::create('divi/row'), 'divi/column', 'Spalten entstehen mit der Zeile'],
|
||||||
|
'module in a column' => [Block::create('divi/column'), 'divi/button', null],
|
||||||
|
'group in a column' => [Block::create('divi/column'), 'divi/group', null],
|
||||||
|
'module in a group' => [Block::create('divi/group'), 'divi/text', null],
|
||||||
|
'fullwidth module in a column' => [Block::create('divi/column'), 'divi/fullwidth-header', 'passt nicht'],
|
||||||
|
'child module in a column' => [Block::create('divi/column'), 'divi/accordion-item', 'passt nicht'],
|
||||||
|
'section in a column' => [Block::create('divi/column'), 'divi/section', 'passt nicht'],
|
||||||
|
'item in its parent' => [Block::create('divi/accordion'), 'divi/accordion-item', null],
|
||||||
|
'other module in a parent' => [Block::create('divi/accordion'), 'divi/text', 'erlaubt nur divi/accordion-item'],
|
||||||
|
'child in a plain module' => [Block::create('divi/text'), 'divi/button', 'keine Kind-Module'],
|
||||||
|
'opaque parent' => [Block::parsed('divi/column', [], '<!-- wp:divi/column -->', false, true), 'divi/text', 'nur lesbar'],
|
||||||
|
'foreign parent' => [Block::create('core/group'), 'divi/text', 'nur lesbar'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider placements */
|
||||||
|
public function testWhatGoesWhere(?Block $parent, string $child, ?string $reason): void
|
||||||
|
{
|
||||||
|
$violation = ComposeFixture::rules()->violation($parent, $child);
|
||||||
|
|
||||||
|
if ($reason === null) {
|
||||||
|
self::assertNull($violation);
|
||||||
|
} else {
|
||||||
|
self::assertIsString($violation);
|
||||||
|
self::assertStringContainsString($reason, $violation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlainModules(): void
|
||||||
|
{
|
||||||
|
$rules = ComposeFixture::rules();
|
||||||
|
|
||||||
|
self::assertTrue($rules->isPlainModule('divi/button'));
|
||||||
|
self::assertFalse($rules->isPlainModule('divi/fullwidth-header'));
|
||||||
|
self::assertFalse($rules->isPlainModule('divi/accordion-item'));
|
||||||
|
self::assertFalse($rules->isPlainModule('divi/row'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,15 @@ final class VariableRefTest extends TestCase
|
|||||||
'allein im Wert',
|
'allein im Wert',
|
||||||
],
|
],
|
||||||
'octet-split marker in date format' => [self::content('post_date', '{"custom_date_format":"$variable%41(y"}'), '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'],
|
'nested marker in before' => [self::content('post_title', '{"before":"$variable(","after":""}'), 'allein im Wert'],
|
||||||
'broken json' => ['$variable(kaputt)$', 'JSON'],
|
'broken json' => ['$variable(kaputt)$', 'JSON'],
|
||||||
'trailing text' => [self::content('post_title', '{}') . ' ', 'allein im Wert'],
|
'trailing text' => [self::content('post_title', '{}') . ' ', 'allein im Wert'],
|
||||||
@@ -395,6 +404,19 @@ final class VariableRefTest extends TestCase
|
|||||||
self::ref($map); // @phpstan-ignore-line
|
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
|
public function testDetectsReferences(): void
|
||||||
{
|
{
|
||||||
self::assertTrue(VariableRef::contains('a $variable( b'));
|
self::assertTrue(VariableRef::contains('a $variable( b'));
|
||||||
|
|||||||
Reference in New Issue
Block a user