feat: Divi placeholders, strict $variable references and full-form attribute writer
VariableRef parses, validates and re-serializes every $variable(...)$ value (S1/S2/S12); AttrWriter routes strings by catalog kind incl. per-subName composite kinds and key-name URL fallback (S3/S3a), rejects odd value keys (S13) and Divi HTML/attribute fields without unfiltered_html (S7); ambiguous design-system labels fail with their ids (S13a); R2 finding recorded (S5). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ Getroffen bei der Planung von Etappe 2a/2b, 2026-09-26. Kosten, falls falsch, je
|
||||
- D6: `lit/divi-dynamic-sources` und die Warnung „ähnliche Abschnitte“ kommen mit Etappe 3 (Theme Builder, `lit/library-find-similar`). – Kosten: bis dahin keine Quellenliste; `{{field:…}}` nimmt jeden Meta-Schlüssel.
|
||||
- D7 (Ruling R1): Divi-Speichern ohne kses für Benutzer ohne `unfiltered_html` (sonst re-serialisiert WordPress jeden Block) ist nur vertretbar, weil keine client-gelieferte Zeichenkette ungeprüft in `post_content` gelangt. Klartext-Werte werden je Feldtyp bereinigt (Text: `sanitize_text_field`; Rich-Text: `wp_kses_post`, abhängig von `unfiltered_html`; URLs: `esc_url_raw`, geroutet über die Attribut-Metadaten des Katalogs, ersatzweise über den Schlüsselnamen). Werte mit `$variable(...)$` – ob aus Platzhaltern erzeugt oder unverändert aus `lit/divi-node-get` zurückgegeben – werden geparst, streng geprüft (zulässiger Typ, existierende gcid-/gvid-/Dynamic-Content-Quellennamen, whitelisted Settings-Schlüssel, String-Settings bereinigt) und vom Plugin neu serialisiert; alles andere wird mit `validation_failed` abgelehnt. Code-Modul ungefiltert nur mit `unfiltered_html`. – Kosten: jeder neue Schreibpfad muss bereinigen, streng validieren und neu serialisieren (Review-Pflicht).
|
||||
- R2 (Ruling): Ob Divi das Dynamic-Content-`before`/`after` bei der Ausgabe selbst escaped, wird in Etappe 2b im Divi-Quellcode verifiziert; die Bereinigung aus D7 gilt unabhängig vom Ergebnis (Verteidigung in der Tiefe). – Kosten: keine (Verifikation, keine Abhängigkeit der Sicherheit vom Ergebnis).
|
||||
- R2 – Befund (Etappe 2b, Divi 5.2.1, Ruling S5): (1) Dynamic-Content-`before`/`after` wird bei der Ausgabe nur dann mit `wp_kses` gefiltert, wenn der **Autor des Beitrags** kein `unfiltered_html` hat (`DynamicContentElements::get_wrapper_element`); auf Seiten eines Administrators gelangt es ungefiltert ins HTML – ein Redakteur könnte dort Skript platzieren. (2) `enable_html: on` gibt Meta-Werte mit `wp_kses_post` statt `esc_html` aus; Divi entfernt `enable_html` beim Speichern nur für Benutzer ohne `unfiltered_html` (`Security::sanitize_dynamic_content_fields`, `wp_insert_post_data` Priorität 10). (3) `custom_meta_<key>` wird ohne `is_protected_meta`-Prüfung ausgegeben (`DynamicContentOptionPostMetaKey`). (4) Farbverweis-Namen gehen unescaped in `var(--<name>)` (`StyleLibrary\Utils::resolve_dynamic_variable`). (5) Divis eigene `wp_insert_post_data`-Filter sind kein kses und laufen auch bei unserem Speichern ohne kses: `AttributeSecurity` (Priorität 5, immer, sobald `"decoration":{` und `"attributes":{` vorkommen) und `HtmlSecurity` (Priorität 6, bei `htmlBefore`/`htmlAfter`/`elementType`) parsen und re-serialisieren die **ganze Seite**; `DynamicContentFixes` (Priorität 10). Folgen: die Plugin-Prüfung aus D7 ist die eigentliche Garantie (VariableRef: nur ganze, geprüfte, neu serialisierte Verweise; `before`/`after` ohne unzulässiges HTML, kein `enable_html`, keine Meta-Schlüssel mit `_`); Divis Filter bleiben aktiv (Verteidigung in der Tiefe) und werden nie entfernt; Schreiben auf `*.decoration.attributes` und `*.advanced.html` nur mit `unfiltered_html` („nur im Divi-Builder“, Ruling S7), und auf Seiten mit diesen Feldern gilt die Byte-Invarianz nicht (Divi normalisiert beim Speichern). – Kosten: keine (Dokumentation; die Sicherheit hängt nicht von Divis Ausgabe ab).
|
||||
- D8: Spalten neuer Zeilen über `module.decoration.sizing.desktop.value.flexType` (`"1_3,2_3"` → `8_24`, `16_24`; Fünftel und 1/7, 1/9, 1/10, 1/11 nativ), wie Divi 5.2 (Flex-Layout ist Standard). – Kosten: falls der Builder anders speichert, nur Composer anpassen.
|
||||
- D9: Platzhalter stehen allein im Wert; Überschriften der Kurzform ohne `level` werden `h2`. – Kosten: eingebettete dynamische Texte nur über die Vollform.
|
||||
- D10: `lit/divi-replace-text` je Inhaltstyp; Probelauf liefert `tree_rev` je Seite, Ausführung nur auf diesen `targets` mit `confirm: true`; ersetzt nur sichtbaren Text (`…innerContent`, Schlüssel text/title/label/alt/titleText), nie URLs oder Stile. – Kosten: Text mit HTML-Entitäten muss so gesucht werden, wie er gespeichert ist.
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<?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\Schema\ModuleCatalog;
|
||||
|
||||
/**
|
||||
* Applies full-form changes (spec §5.4): attribute path => breakpoint map. Paths are checked
|
||||
* against the catalog (invalid_attr_path with suggestions), placeholders resolved, strings
|
||||
* sanitized. A breakpoint may be given short ({"desktop": "#fff"}) or raw
|
||||
* ({"desktop": {"value": "#fff", "hover": "#000"}}); top-level "hover"/"sticky" mean desktop.
|
||||
* Given breakpoints/states replace the existing ones, others stay; null removes. Pure PHP.
|
||||
*
|
||||
* cleanValue() is the single choke point through which every client string reaches Divi
|
||||
* post_content, which is saved without kses (D7): strings are routed by the catalog's value
|
||||
* kind (ruling S3/S3a), $variable(…)$ values are re-serialized by VariableRef (S1/S2), keys
|
||||
* inside values are plain identifiers (S13), and Divi's own HTML/attribute fields need
|
||||
* unfiltered_html (S7).
|
||||
*/
|
||||
final class AttrWriter
|
||||
{
|
||||
private const STATES = ['value', 'hover', 'sticky'];
|
||||
|
||||
/** Keys whose string values are URLs when the catalog names no kind (ruling S3). */
|
||||
private const URL_KEY = '/(url|src|link|href|image)$/i';
|
||||
|
||||
/** Keys inside values: plain identifiers (ruling S13); list indexes are ints. */
|
||||
private const VALUE_KEY = '/^[A-Za-z0-9_-]+$/';
|
||||
|
||||
/** Paths Divi re-serializes whole pages for, and that carry raw HTML (ruling S7). */
|
||||
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html)$/';
|
||||
|
||||
public function __construct(
|
||||
private ModuleCatalog $catalog,
|
||||
private Placeholders $placeholders,
|
||||
private ValueSanitizer $sanitizer
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attrs Current attributes of the block.
|
||||
* @param array<array-key, mixed> $changes Path => breakpoint map, or null to remove the path.
|
||||
* @return array{attrs: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
|
||||
*/
|
||||
public function apply(string $module, array $attrs, array $changes, string $field): array
|
||||
{
|
||||
$errors = [];
|
||||
$invalid = [];
|
||||
foreach ($changes as $path => $map) {
|
||||
$path = (string) $path;
|
||||
$key = $field . '.' . $path;
|
||||
if (! $this->catalog->isKnownPath($module, $path)) {
|
||||
$suggestions = $this->catalog->suggestions($module, $path);
|
||||
$errors[$key] = 'unbekannter Attributpfad'
|
||||
. ($suggestions === [] ? '' : '; ähnlich: ' . implode(', ', $suggestions));
|
||||
$invalid[] = $path;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->guardPath($path);
|
||||
if ($map === null) {
|
||||
$attrs = AttrMap::remove($attrs, $path);
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = $this->clean($module, $path, $this->placeholders->resolveDeep(self::normalize($map)));
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[$key] = $e->getMessage();
|
||||
continue;
|
||||
}
|
||||
|
||||
$merged = self::merge(AttrMap::get($attrs, $path), $normalized);
|
||||
$attrs = $merged === [] ? AttrMap::remove($attrs, $path) : AttrMap::set($attrs, $path, $merged);
|
||||
}
|
||||
|
||||
return ['attrs' => $attrs, 'errors' => $errors, 'invalid_paths' => $invalid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans one value written to $path (used by the short form too). $subKey names the
|
||||
* sub-field of a composite path ("linkUrl" of button.innerContent) when $value is its
|
||||
* string alone.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function cleanValue(string $module, string $path, mixed $value, ?string $subKey = null): mixed
|
||||
{
|
||||
$this->guardPath($path);
|
||||
|
||||
return $this->cleanNode($module, $path, $value, $subKey, $subKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>> Breakpoint => state => value.
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public static function normalize(mixed $map): array
|
||||
{
|
||||
$shape = 'erwartet {"desktop": Wert, "tablet": Wert, "phone": Wert, "hover": Wert} '
|
||||
. 'oder {"desktop": {"value": …, "hover": …}}';
|
||||
if (! is_array($map) || $map === [] || array_is_list($map)) {
|
||||
throw new InvalidArgumentException($shape);
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($map as $key => $value) {
|
||||
$key = (string) $key;
|
||||
if ($key === 'hover' || $key === 'sticky') {
|
||||
$normalized['desktop'][$key] = $value;
|
||||
} elseif (in_array($key, AttrMap::BREAKPOINTS, true)) {
|
||||
$isRaw = is_array($value) && $value !== [] && ! array_is_list($value)
|
||||
&& array_diff(array_map('strval', array_keys($value)), self::STATES) === [];
|
||||
foreach ($isRaw ? $value : ['value' => $value] as $state => $stateValue) {
|
||||
$normalized[$key][(string) $state] = $stateValue;
|
||||
}
|
||||
} else {
|
||||
throw new InvalidArgumentException(sprintf('unbekannter Breakpoint "%s"; %s', $key, $shape));
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException For Divi's HTML/attribute fields without unfiltered_html (S7). */
|
||||
private function guardPath(string $path): void
|
||||
{
|
||||
if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'"%s" (eigene HTML-Attribute und -Elemente) ist nur im Divi-Builder änderbar '
|
||||
. '(oder mit dem Recht unfiltered_html).',
|
||||
$path
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $subName First key below the state value: selects the sub-kind of a composite path.
|
||||
* @param string|null $key Nearest key above $value: the URL fallback when the catalog names no kind.
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function cleanNode(string $module, string $path, mixed $value, ?string $subName, ?string $key): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$clean = [];
|
||||
foreach ($value as $childKey => $item) {
|
||||
if (is_string($childKey) && preg_match(self::VALUE_KEY, $childKey) !== 1) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Ungültiger Schlüssel "%s" im Wert (erlaubt: Buchstaben, Ziffern, "_" und "-").',
|
||||
$childKey
|
||||
));
|
||||
}
|
||||
|
||||
$clean[$childKey] = is_string($childKey)
|
||||
? $this->cleanNode($module, $path, $item, $subName ?? $childKey, $childKey)
|
||||
: $this->cleanNode($module, $path, $item, $subName, $key);
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! mb_check_encoding($value, 'UTF-8')) {
|
||||
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
|
||||
}
|
||||
|
||||
$kind = $this->kind($module, $path, $subName, $key);
|
||||
if (VariableRef::contains($value)) {
|
||||
return $this->placeholders->references()->canonical($value, $this->sanitizer, $kind === 'url');
|
||||
}
|
||||
|
||||
return match ($kind) {
|
||||
'html' => $this->sanitizer->html($value),
|
||||
'code' => $this->sanitizer->code($value),
|
||||
'url' => $this->sanitizer->url($value),
|
||||
default => $this->sanitizer->text($value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizer for a string (ruling S3/S3a): the catalog kind of the path – or of the
|
||||
* sub-field for a composite path – decides; an unresolved kind falls back to the key name
|
||||
* (URL keys at any depth), else text – never laxer than text.
|
||||
*
|
||||
* @return 'html'|'code'|'url'|'text'
|
||||
*/
|
||||
private function kind(string $module, string $path, ?string $subName, ?string $key): string
|
||||
{
|
||||
// "unknown" also for a composite path whose sub-kinds do not name $subName.
|
||||
return match ($this->catalog->pathKind($module, $path, $subName)) {
|
||||
'richtext' => 'html',
|
||||
'code' => 'code',
|
||||
'url', 'image' => 'url',
|
||||
'unknown' => $key !== null && preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text',
|
||||
default => 'text',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $normalized
|
||||
* @return array<string, array<string, mixed>>
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function clean(string $module, string $path, array $normalized): array
|
||||
{
|
||||
foreach ($normalized as $breakpoint => $states) {
|
||||
foreach ($states as $state => $value) {
|
||||
$normalized[$breakpoint][$state] = $this->cleanValue($module, $path, $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $changes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function merge(mixed $existing, array $changes): array
|
||||
{
|
||||
$map = is_array($existing) && AttrMap::isBreakpointMap($existing) ? $existing : [];
|
||||
foreach ($changes as $breakpoint => $states) {
|
||||
$current = is_array($map[$breakpoint] ?? null) ? $map[$breakpoint] : [];
|
||||
foreach ($states as $state => $value) {
|
||||
if ($value === null) {
|
||||
unset($current[$state]);
|
||||
} else {
|
||||
$current[$state] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($current === []) {
|
||||
unset($map[$breakpoint]);
|
||||
} else {
|
||||
$map[$breakpoint] = $current;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
/**
|
||||
* Looks up media library images for short-form fields like image_id (spec §6.3: Divi image
|
||||
* modules reference images by attachment ID plus URL). WpMediaResolver is the WordPress side.
|
||||
*/
|
||||
interface MediaResolver
|
||||
{
|
||||
/** @return array{url: string, alt: string}|null Null when $id is no image the user may read. */
|
||||
public function image(int $id): ?array;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||
|
||||
/**
|
||||
* Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
|
||||
* {{color:primary|<Name>|gcid-…}}, {{var:<Name>|gvid-…}}, {{font:heading|body}},
|
||||
* {{post:title|…}}, {{field:<meta_key>}}. A placeholder must be the whole value (D9); unknown
|
||||
* names fail with the list of existing ones, labels two entries share fail with their IDs
|
||||
* (ruling S13a). The result is still checked by VariableRef wherever it is written. Pure PHP.
|
||||
*/
|
||||
final class Placeholders
|
||||
{
|
||||
/** {{post:<key>}} => Divi dynamic content option. */
|
||||
public const POST = [
|
||||
'title' => 'post_title',
|
||||
'excerpt' => 'post_excerpt',
|
||||
'date' => 'post_date',
|
||||
'modified' => 'post_modified_date',
|
||||
'author' => 'post_author',
|
||||
'url' => 'post_link_url',
|
||||
'featured_image' => 'post_featured_image',
|
||||
'categories' => 'post_categories',
|
||||
'tags' => 'post_tags',
|
||||
];
|
||||
|
||||
/** Dynamic content that is text and takes before/after settings. */
|
||||
private const TEXT_SETTINGS = [
|
||||
'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags',
|
||||
];
|
||||
|
||||
private const PATTERN = '/^\{\{\s*(color|var|font|post|field)\s*:\s*([^{}]+?)\s*\}\}$/';
|
||||
|
||||
private VariableRef $references;
|
||||
|
||||
public function __construct(private DesignSystem $design)
|
||||
{
|
||||
$this->references = new VariableRef($design);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $value has the shape of one Divi variable reference ($variable({…})$), e.g. from an
|
||||
* earlier read. A shape check only – never a reason to store a value unchecked: that is
|
||||
* VariableRef::canonical() (ruling S1).
|
||||
*/
|
||||
public static function isVariable(string $value): bool
|
||||
{
|
||||
return preg_match('/^\$variable\((\{.*\})\)\$$/s', $value, $match) === 1
|
||||
&& ! str_contains($match[1], ')$')
|
||||
&& is_array(json_decode($match[1], true));
|
||||
}
|
||||
|
||||
/** The strict checker of $variable(…)$ values against this design system (ruling S1). */
|
||||
public function references(): VariableRef
|
||||
{
|
||||
return $this->references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves $value when it is a placeholder; other strings are returned unchanged.
|
||||
*
|
||||
* @throws InvalidArgumentException With a German message for the client.
|
||||
*/
|
||||
public function resolve(string $value): string
|
||||
{
|
||||
if (preg_match(self::PATTERN, trim($value), $match) !== 1) {
|
||||
if (str_contains($value, '{{')) {
|
||||
throw new InvalidArgumentException(
|
||||
'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, '
|
||||
. 'post, field).'
|
||||
);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
[, $kind, $name] = $match;
|
||||
|
||||
return match ($kind) {
|
||||
'color' => $this->color($name),
|
||||
'var' => $this->variable($name),
|
||||
'font' => $this->font($name),
|
||||
'post' => $this->post($name),
|
||||
default => $this->field($name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies resolve() to every string inside $value (full-form values are nested).
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function resolveDeep(mixed $value): mixed
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $this->resolve($value);
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return array_map([$this, 'resolveDeep'], $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function color(string $name): string
|
||||
{
|
||||
$colors = $this->design->colors();
|
||||
$ids = array_column($colors, 'id');
|
||||
$alias = DesignSystem::COLOR_ALIASES[mb_strtolower($name)] ?? null;
|
||||
$id = match (true) {
|
||||
in_array($name, $ids, true) => $name,
|
||||
$alias !== null && in_array($alias, $ids, true) => $alias,
|
||||
default => self::byLabel($colors, $name, 'globale Farbe'),
|
||||
};
|
||||
|
||||
if ($id === null) {
|
||||
$known = array_merge(
|
||||
array_keys(array_intersect(DesignSystem::COLOR_ALIASES, $ids)),
|
||||
array_column($colors, 'label')
|
||||
);
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Unbekannte globale Farbe "%s". Vorhanden: %s.',
|
||||
$name,
|
||||
implode(', ', array_unique($known))
|
||||
));
|
||||
}
|
||||
|
||||
return VariableRef::serialize('color', $id, []);
|
||||
}
|
||||
|
||||
private function variable(string $name): string
|
||||
{
|
||||
$variables = $this->design->variables();
|
||||
$id = in_array($name, array_column($variables, 'id'), true)
|
||||
? $name
|
||||
: self::byLabel($variables, $name, 'globale Variable');
|
||||
if ($id === null) {
|
||||
$labels = array_column($variables, 'label');
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Unbekannte globale Variable "%s". Vorhanden: %s.',
|
||||
$name,
|
||||
$labels === [] ? '(keine)' : implode(', ', $labels)
|
||||
));
|
||||
}
|
||||
|
||||
$type = 'content';
|
||||
foreach ($variables as $variable) {
|
||||
if ($variable['id'] === $id && $variable['type'] === 'colors') {
|
||||
$type = 'color';
|
||||
}
|
||||
}
|
||||
|
||||
return VariableRef::serialize($type, $id, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* ID of the one entry labelled $name (case-insensitive); null when none is.
|
||||
*
|
||||
* @param list<array{id: string, label: string}> $entries
|
||||
* @throws InvalidArgumentException When several entries share the label (ruling S13a).
|
||||
*/
|
||||
private static function byLabel(array $entries, string $name, string $what): ?string
|
||||
{
|
||||
$key = mb_strtolower($name);
|
||||
$ids = [];
|
||||
foreach ($entries as $entry) {
|
||||
if (mb_strtolower($entry['label']) === $key) {
|
||||
$ids[] = $entry['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($ids) > 1) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Name der %s "%s" ist mehrdeutig; bitte die ID angeben: %s.',
|
||||
$what,
|
||||
$name,
|
||||
implode(', ', $ids)
|
||||
));
|
||||
}
|
||||
|
||||
return $ids[0] ?? null;
|
||||
}
|
||||
|
||||
private function font(string $name): string
|
||||
{
|
||||
$id = DesignSystem::FONT_VARIABLES[strtolower($name)] ?? null;
|
||||
if ($id === null) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf('Unbekannte Theme-Schrift "%s". Vorhanden: heading, body.', $name)
|
||||
);
|
||||
}
|
||||
|
||||
return VariableRef::serialize('content', $id, []);
|
||||
}
|
||||
|
||||
private function post(string $name): string
|
||||
{
|
||||
$option = self::POST[strtolower($name)] ?? null;
|
||||
if ($option === null) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Unbekannter Beitragswert "%s". Vorhanden: %s.',
|
||||
$name,
|
||||
implode(', ', array_keys(self::POST))
|
||||
));
|
||||
}
|
||||
|
||||
$settings = in_array($option, self::TEXT_SETTINGS, true) ? ['before' => '', 'after' => ''] : [];
|
||||
|
||||
return VariableRef::serialize('content', $option, $settings);
|
||||
}
|
||||
|
||||
private function field(string $name): string
|
||||
{
|
||||
if (preg_match(VariableRef::META_KEY, $name) !== 1) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Ungültiger Feldschlüssel "%s" (erlaubt: Buchstaben, Ziffern, "_" und "-", nicht mit "_" am Anfang).',
|
||||
$name
|
||||
));
|
||||
}
|
||||
|
||||
return VariableRef::serialize('content', 'custom_meta_' . $name, ['before' => '', 'after' => '']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
/**
|
||||
* Cleans values before they go into Divi attributes (spec §5.6/§10: wp_kses_post for rich
|
||||
* text, sanitize_text_field for simple fields, esc_url_raw for URLs). Divi saves bypass kses
|
||||
* (D7), so these methods are the security guarantee. The pure Compose classes only know this
|
||||
* interface; WpValueSanitizer is the WordPress implementation.
|
||||
*/
|
||||
interface ValueSanitizer
|
||||
{
|
||||
/** Single-line text (labels, titles, button text, colors, sizes, every unknown kind). */
|
||||
public function text(string $value): string;
|
||||
|
||||
/** Rich text of text modules. */
|
||||
public function html(string $value): string;
|
||||
|
||||
/** Code module content: unfiltered only for users with unfiltered_html. */
|
||||
public function code(string $value): string;
|
||||
|
||||
/** Link or media URL; '' when not acceptable. */
|
||||
public function url(string $value): string;
|
||||
|
||||
/**
|
||||
* Whether the current user may write unfiltered HTML (unfiltered_html). Divi's own HTML
|
||||
* and attribute fields (*.advanced.html, *.decoration.attributes) need it (ruling S7).
|
||||
*/
|
||||
public function allowsUnfilteredHtml(): bool;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Json;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Divi's $variable({…})$ references (global colors, global variables, theme fonts, dynamic
|
||||
* content). Divi saves bypass kses (D7), and Divi resolves references anywhere in a string and
|
||||
* escapes before/after only for authors without unfiltered_html – so no client-supplied
|
||||
* reference is ever stored as sent (ruling S1). canonical() parses one, checks it strictly
|
||||
* against the live design system and re-serializes it; everything else fails with a German
|
||||
* message (the abilities report validation_failed). A reference must be the whole value (D9,
|
||||
* ruling S2). Pure PHP.
|
||||
*/
|
||||
final class VariableRef
|
||||
{
|
||||
public const MARKER = '$variable(';
|
||||
|
||||
/** custom_meta_<key>: no leading "_" – Divi renders protected meta publicly (ruling S12). */
|
||||
public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/';
|
||||
|
||||
private const WHOLE = '/^\$variable\((.*)\)\$$/s';
|
||||
|
||||
private const META_PREFIX = 'custom_meta_';
|
||||
|
||||
private const COLOR_SETTINGS = ['hue', 'saturation', 'lightness', 'opacity'];
|
||||
|
||||
private const HTML_SETTINGS = ['before', 'after'];
|
||||
|
||||
private const TEXT_SETTINGS = ['date_format', 'custom_date_format'];
|
||||
|
||||
private const NUMBER = '/^-?\d+(\.\d+)?$/';
|
||||
|
||||
public function __construct(private DesignSystem $design)
|
||||
{
|
||||
}
|
||||
|
||||
/** Whether $value contains a reference anywhere (such a value may only be a whole reference). */
|
||||
public static function contains(string $value): bool
|
||||
{
|
||||
return str_contains($value, self::MARKER);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|stdClass $settings */
|
||||
public static function serialize(string $type, string $name, array|stdClass $settings): string
|
||||
{
|
||||
if ($settings === []) {
|
||||
$settings = new stdClass();
|
||||
}
|
||||
|
||||
$payload = ['type' => $type, 'value' => ['name' => $name, 'settings' => $settings]];
|
||||
|
||||
return '$variable(' . Json::encode($payload) . ')$';
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's own serialization of the single reference $value.
|
||||
*
|
||||
* @param bool $url Whether $value goes into a URL field: then only content references
|
||||
* without before/after are allowed.
|
||||
* @throws InvalidArgumentException With a German message for the client.
|
||||
*/
|
||||
public function canonical(string $value, ValueSanitizer $sanitizer, bool $url = false): string
|
||||
{
|
||||
if (preg_match(self::WHOLE, $value, $match) !== 1 || str_contains($match[1], ')$')) {
|
||||
throw self::notAlone();
|
||||
}
|
||||
|
||||
$payload = self::decode($match[1]);
|
||||
self::onlyKeys($payload, ['type', 'value'], 'Divi-Verweis');
|
||||
$type = $payload['type'] ?? null;
|
||||
if ($type !== 'color' && $type !== 'content') {
|
||||
throw new InvalidArgumentException('Unzulässiger Typ im Divi-Verweis; erlaubt sind "color" und "content".');
|
||||
}
|
||||
|
||||
$inner = $payload['value'] ?? null;
|
||||
if (! is_array($inner) || ! is_string($inner['name'] ?? null)) {
|
||||
throw new InvalidArgumentException('Divi-Verweis ohne "value.name".');
|
||||
}
|
||||
self::onlyKeys($inner, ['name', 'settings'], 'value');
|
||||
|
||||
$name = $inner['name'];
|
||||
$settings = $inner['settings'] ?? [];
|
||||
if (! is_array($settings) || ($settings !== [] && array_is_list($settings))) {
|
||||
throw new InvalidArgumentException('"settings" im Divi-Verweis muss ein Objekt sein.');
|
||||
}
|
||||
|
||||
if ($url && $type !== 'content') {
|
||||
throw new InvalidArgumentException('In URL-Feldern sind nur Inhaltsverweise (type "content") erlaubt.');
|
||||
}
|
||||
|
||||
$this->checkName($type, $name);
|
||||
|
||||
return self::serialize($type, $name, self::settings($type, $settings, $sanitizer, $url));
|
||||
}
|
||||
|
||||
public static function notAlone(): InvalidArgumentException
|
||||
{
|
||||
return new InvalidArgumentException(
|
||||
'Divi-Verweise ($variable(…)$) und Platzhalter: Verweise nur allein im Wert (D9), nicht eingebettet '
|
||||
. 'in anderen Text.'
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<array-key, mixed> */
|
||||
private static function decode(string $json): array
|
||||
{
|
||||
try {
|
||||
$payload = json_decode($json, true, 16, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
throw new InvalidArgumentException('Divi-Verweis enthält kein gültiges JSON.');
|
||||
}
|
||||
|
||||
if (! is_array($payload) || array_is_list($payload)) {
|
||||
throw new InvalidArgumentException('Divi-Verweis enthält kein JSON-Objekt.');
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $data
|
||||
* @param list<string> $allowed
|
||||
*/
|
||||
private static function onlyKeys(array $data, array $allowed, string $where): void
|
||||
{
|
||||
$extra = array_diff(array_map('strval', array_keys($data)), $allowed);
|
||||
if ($extra !== []) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Unzulässige Schlüssel in %s: %s (erlaubt: %s).',
|
||||
$where,
|
||||
implode(', ', $extra),
|
||||
implode(', ', $allowed)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function checkName(string $type, string $name): void
|
||||
{
|
||||
$allowed = $type === 'color' ? $this->colorNames() : $this->contentNames();
|
||||
$meta = $type === 'content' && str_starts_with($name, self::META_PREFIX)
|
||||
&& preg_match(self::META_KEY, substr($name, strlen(self::META_PREFIX))) === 1;
|
||||
if (! $meta && ! in_array($name, $allowed, true)) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Unbekannter Name "%s" für Typ "%s" im Divi-Verweis (nur vorhandene globale Farben/Variablen, '
|
||||
. 'Theme-Schriften, Beitragswerte und custom_meta_<Schlüssel> ohne führendes "_").',
|
||||
$name,
|
||||
$type
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> Global color IDs plus color-type global variables. */
|
||||
private function colorNames(): array
|
||||
{
|
||||
$names = array_column($this->design->colors(), 'id');
|
||||
foreach ($this->design->variables() as $variable) {
|
||||
if ($variable['type'] === 'colors') {
|
||||
$names[] = $variable['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function contentNames(): array
|
||||
{
|
||||
$names = array_merge(array_values(DesignSystem::FONT_VARIABLES), array_values(Placeholders::POST));
|
||||
foreach ($this->design->variables() as $variable) {
|
||||
if ($variable['type'] !== 'colors') {
|
||||
$names[] = $variable['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $settings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function settings(string $type, array $settings, ValueSanitizer $sanitizer, bool $url): array
|
||||
{
|
||||
$clean = [];
|
||||
foreach ($settings as $key => $value) {
|
||||
$key = (string) $key;
|
||||
$clean[$key] = $type === 'color'
|
||||
? self::colorSetting($key, $value)
|
||||
: self::contentSetting($key, $value, $sanitizer, $url);
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
private static function colorSetting(string $key, mixed $value): int|float|string
|
||||
{
|
||||
if (! in_array($key, self::COLOR_SETTINGS, true)) {
|
||||
throw self::unknownSetting($key, self::COLOR_SETTINGS);
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value) || (is_string($value) && preg_match(self::NUMBER, $value) === 1)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis muss eine Zahl sein.', $key));
|
||||
}
|
||||
|
||||
private static function contentSetting(string $key, mixed $value, ValueSanitizer $sanitizer, bool $url): string
|
||||
{
|
||||
$isHtml = in_array($key, self::HTML_SETTINGS, true);
|
||||
if (! $isHtml && ! in_array($key, self::TEXT_SETTINGS, true)) {
|
||||
throw self::unknownSetting($key, array_merge(self::HTML_SETTINGS, self::TEXT_SETTINGS));
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
throw new InvalidArgumentException(sprintf('Einstellung "%s" im Divi-Verweis muss Text sein.', $key));
|
||||
}
|
||||
|
||||
if (self::contains($value)) {
|
||||
throw self::notAlone();
|
||||
}
|
||||
|
||||
if ($isHtml && $url && $value !== '') {
|
||||
throw new InvalidArgumentException(sprintf('In URL-Feldern ist "%s" im Divi-Verweis nicht erlaubt.', $key));
|
||||
}
|
||||
|
||||
// Rejected rather than silently cleaned: the client learns what was not accepted.
|
||||
if (($isHtml ? $sanitizer->html($value) : $sanitizer->text($value)) !== $value) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Einstellung "%s" im Divi-Verweis enthält unzulässiges %s.',
|
||||
$key,
|
||||
$isHtml ? 'HTML' : 'Markup'
|
||||
));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @param list<string> $allowed */
|
||||
private static function unknownSetting(string $key, array $allowed): InvalidArgumentException
|
||||
{
|
||||
$hint = $key === 'enable_html' ? ' (HTML-Ausgabe von Feldern nur im Divi-Builder)' : '';
|
||||
|
||||
return new InvalidArgumentException(sprintf(
|
||||
'Unzulässige Einstellung "%s" im Divi-Verweis%s; erlaubt: %s.',
|
||||
$key,
|
||||
$hint,
|
||||
implode(', ', $allowed)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AttrWriterTest extends TestCase
|
||||
{
|
||||
private const HEADING = [
|
||||
'title' => [
|
||||
'innerContent' => ['desktop' => ['value' => 'Alt']],
|
||||
'decoration' => ['font' => ['font' => [
|
||||
'desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111']],
|
||||
'phone' => ['value' => ['size' => '22px']],
|
||||
]]],
|
||||
],
|
||||
'builderVersion' => '5.2.1',
|
||||
];
|
||||
|
||||
private const TEXT = [
|
||||
'content' => ['innerContent' => ['desktop' => ['value' => '<p>Alt</p>']]],
|
||||
'builderVersion' => '5.2.1',
|
||||
];
|
||||
|
||||
public function testMergesShortAndRawBreakpointsAndKeepsTheRest(): void
|
||||
{
|
||||
$result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
|
||||
'title.decoration.font.font' => [
|
||||
'desktop' => ['value' => ['headingLevel' => 'h3'], 'hover' => ['color' => '{{color:primary}}']],
|
||||
'tablet' => ['size' => '26px'],
|
||||
],
|
||||
'title.innerContent' => ['desktop' => '<b>Neu</b>'],
|
||||
], 'attrs');
|
||||
|
||||
self::assertSame([], $result['errors']);
|
||||
self::assertSame(
|
||||
[
|
||||
'desktop' => [
|
||||
'value' => ['headingLevel' => 'h3'],
|
||||
'hover' => ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'],
|
||||
],
|
||||
'phone' => ['value' => ['size' => '22px']],
|
||||
'tablet' => ['value' => ['size' => '26px']],
|
||||
],
|
||||
$result['attrs']['title']['decoration']['font']['font']
|
||||
);
|
||||
// Heading title is kind "text" in the catalog (ruling S3): tags are stripped.
|
||||
self::assertSame('Neu', $result['attrs']['title']['innerContent']['desktop']['value']);
|
||||
self::assertSame('5.2.1', $result['attrs']['builderVersion']);
|
||||
}
|
||||
|
||||
public function testNullRemovesPathsAndStatesAndTopLevelHoverMeansDesktop(): void
|
||||
{
|
||||
$writer = ComposeFixture::attrWriter();
|
||||
|
||||
$removed = $writer->apply('divi/heading', self::HEADING, ['title.decoration.font.font' => null], 'attrs');
|
||||
self::assertArrayNotHasKey('decoration', $removed['attrs']['title']);
|
||||
|
||||
$phoneGone = $writer->apply('divi/heading', self::HEADING, [
|
||||
'title.decoration.font.font' => ['phone' => ['value' => null], 'hover' => ['color' => '#000000']],
|
||||
], 'attrs');
|
||||
self::assertSame(
|
||||
['desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111'], 'hover' => ['color' => '#000000']]],
|
||||
$phoneGone['attrs']['title']['decoration']['font']['font']
|
||||
);
|
||||
}
|
||||
|
||||
public function testUnknownPathsGetSuggestions(): void
|
||||
{
|
||||
$result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
|
||||
'title.decoration.font' => ['desktop' => ['color' => '#fff']],
|
||||
'title.innerContent' => ['mobile' => 'x'],
|
||||
], 'changes[0].attrs');
|
||||
|
||||
self::assertSame(['title.decoration.font'], $result['invalid_paths']);
|
||||
self::assertStringContainsString(
|
||||
'title.decoration.font.font',
|
||||
$result['errors']['changes[0].attrs.title.decoration.font']
|
||||
);
|
||||
self::assertStringContainsString('unbekannter Breakpoint "mobile"', $result['errors']['changes[0].attrs.title.innerContent']);
|
||||
self::assertSame(self::HEADING, $result['attrs']);
|
||||
}
|
||||
|
||||
public function testSanitizesByPlace(): void
|
||||
{
|
||||
$writer = ComposeFixture::attrWriter();
|
||||
|
||||
self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
|
||||
self::assertSame('<p>x</p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p><script>y</script>'));
|
||||
self::assertSame('<script>ok()</script>', $writer->cleanValue('divi/code', 'content.innerContent', '<script>ok()</script>'));
|
||||
self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '<b>10px</b>'));
|
||||
self::assertSame(
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||||
$writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
|
||||
);
|
||||
self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x']));
|
||||
}
|
||||
|
||||
/** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */
|
||||
public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void
|
||||
{
|
||||
$writer = ComposeFixture::attrWriter();
|
||||
|
||||
// Unknown kind, URL keys deep inside the value.
|
||||
self::assertSame(
|
||||
['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]],
|
||||
$writer->cleanValue('divi/text', 'module.decoration.background', [
|
||||
'color' => '<i>#fff</i>',
|
||||
'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']],
|
||||
])
|
||||
);
|
||||
self::assertSame(
|
||||
['url' => '', 'target' => 'on'],
|
||||
$writer->cleanValue('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => '<b>on</b>'])
|
||||
);
|
||||
self::assertSame(['href' => '', 'src' => '', 'image' => ''], $writer->cleanValue('divi/text', 'module.advanced.link', [
|
||||
'href' => 'javascript:a()', 'src' => 'javascript:b()', 'image' => 'javascript:c()',
|
||||
]));
|
||||
// Rich text keeps markup, text kind strips it, unknown innerContent is text too.
|
||||
self::assertSame('<p>Hallo <b>Welt</b></p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>Hallo <b>Welt</b></p>'));
|
||||
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '<h1>Titel</h1>'));
|
||||
self::assertSame('Los', $writer->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>', 'text'));
|
||||
// Composite path with per-subName kinds (buttonOne.innerContent: text → text, linkUrl → url).
|
||||
self::assertSame(
|
||||
['text' => 'Mehr', 'linkUrl' => ''],
|
||||
$writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', [
|
||||
'text' => '<b>Mehr</b>',
|
||||
'linkUrl' => 'javascript:alert(1)',
|
||||
])
|
||||
);
|
||||
self::assertSame('', $writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', 'javascript:x', 'linkUrl'));
|
||||
// An explicit sub-kind wins over the key name; the image module's alt is text, src a URL.
|
||||
self::assertSame(
|
||||
['useIcon' => 'on', 'src' => ''],
|
||||
$writer->cleanValue('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x'])
|
||||
);
|
||||
self::assertSame(
|
||||
['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'],
|
||||
$writer->cleanValue('divi/image', 'image.innerContent', ['src' => 'https://kunde.test/a.jpg', 'alt' => '<b>Team</b>'])
|
||||
);
|
||||
// Numbers and booleans are no strings: unchanged.
|
||||
self::assertSame(['size' => 12, 'on' => true], $writer->cleanValue('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling S6: each malicious reference fails and nothing is stored.
|
||||
*
|
||||
* @return array<string, array{string, string, array<string, mixed>, array<string, mixed>}>
|
||||
*/
|
||||
public static function malicious(): array
|
||||
{
|
||||
$cases = [];
|
||||
foreach (VariableRefTest::malicious() as $name => [$value]) {
|
||||
$cases[$name] = ['divi/text', 'content.innerContent', ['desktop' => $value], self::TEXT];
|
||||
}
|
||||
|
||||
$cases['embedded reference in rich text'] = [
|
||||
'divi/text',
|
||||
'content.innerContent',
|
||||
['desktop' => '<p>Hallo $variable({"type":"content","value":{"name":"post_title","settings":{}}})$</p>'],
|
||||
self::TEXT,
|
||||
];
|
||||
$cases['embedded reference deep in a value'] = [
|
||||
'divi/text',
|
||||
'module.decoration.background',
|
||||
['desktop' => ['image' => ['url' => 'https://a.test/$variable({"type":"content","value":{"name":"post_title","settings":{}}})$']]],
|
||||
self::TEXT,
|
||||
];
|
||||
$cases['javascript via a reference in a URL field'] = [
|
||||
'divi/fullwidth-header',
|
||||
'buttonOne.innerContent',
|
||||
['desktop' => [
|
||||
'text' => 'Los',
|
||||
'linkUrl' => '$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$',
|
||||
]],
|
||||
[],
|
||||
];
|
||||
$cases['javascript via a reference in a URL key'] = [
|
||||
'divi/text',
|
||||
'module.advanced.link',
|
||||
['desktop' => ['url' => '$variable({"type":"content","value":{"name":"custom_meta_ziel","settings":{"before":"javascript:","after":""}}})$']],
|
||||
self::TEXT,
|
||||
];
|
||||
|
||||
return $cases;
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider malicious
|
||||
* @param array<string, mixed> $map
|
||||
* @param array<string, mixed> $attrs
|
||||
*/
|
||||
public function testRejectsMaliciousReferencesAndStoresNothing(string $module, string $path, array $map, array $attrs): void
|
||||
{
|
||||
$result = ComposeFixture::attrWriter(true)->apply($module, $attrs, [$path => $map], 'attrs');
|
||||
|
||||
self::assertArrayHasKey('attrs.' . $path, $result['errors']);
|
||||
self::assertSame($attrs, $result['attrs']);
|
||||
}
|
||||
|
||||
public function testEmbeddedReferenceNamesTheRule(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Verweise nur allein im Wert');
|
||||
|
||||
ComposeFixture::attrWriter()->cleanValue('divi/text', 'content.innerContent', 'a $variable({}) b');
|
||||
}
|
||||
|
||||
/** Ruling S7: Divi's own HTML/attribute fields only for users with unfiltered_html. */
|
||||
public function testHtmlAndAttributeFieldsNeedUnfilteredHtml(): void
|
||||
{
|
||||
$changes = [
|
||||
'module.decoration.attributes' => ['desktop' => ['attributes' => [['name' => 'data-x', 'value' => '1']]]],
|
||||
'module.advanced.html' => ['desktop' => ['elementType' => 'section']],
|
||||
];
|
||||
|
||||
$editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs');
|
||||
self::assertSame(self::TEXT, $editor['attrs']);
|
||||
self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.attributes']);
|
||||
self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.advanced.html']);
|
||||
|
||||
$removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.advanced.html' => null], 'attrs');
|
||||
self::assertArrayHasKey('attrs.module.advanced.html', $removal['errors']);
|
||||
|
||||
$admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs');
|
||||
self::assertSame([], $admin['errors']);
|
||||
self::assertSame('section', $admin['attrs']['module']['advanced']['html']['desktop']['value']['elementType']);
|
||||
self::assertSame('data-x', $admin['attrs']['module']['decoration']['attributes']['desktop']['value']['attributes'][0]['name']);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('nur im Divi-Builder');
|
||||
ComposeFixture::attrWriter()->cleanValue('divi/button', 'module.decoration.attributes', ['attributes' => []]);
|
||||
}
|
||||
|
||||
/** Ruling S13: keys inside value maps are plain identifiers. */
|
||||
public function testRejectsOddKeysInsideValues(): void
|
||||
{
|
||||
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
|
||||
'module.decoration.spacing' => ['desktop' => ['<img src=x onerror=1>' => '1px']],
|
||||
], 'attrs');
|
||||
|
||||
self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']);
|
||||
self::assertSame(self::TEXT, $result['attrs']);
|
||||
}
|
||||
|
||||
public function testRejectsInvalidUtf8(): void
|
||||
{
|
||||
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
|
||||
'content.innerContent' => ['desktop' => "<p>\xC3\x28</p>"],
|
||||
], 'attrs');
|
||||
|
||||
self::assertStringContainsString('UTF-8', $result['errors']['attrs.content.innerContent']);
|
||||
}
|
||||
|
||||
public function testReferencesFromAnEarlierReadAreReserialized(): void
|
||||
{
|
||||
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
|
||||
'module.decoration.background' => ['desktop' => [
|
||||
'color' => '$variable({"value":{"name":"gcid-a1b2","settings":[]},"type":"color"})$',
|
||||
]],
|
||||
], 'attrs');
|
||||
|
||||
self::assertSame([], $result['errors']);
|
||||
self::assertSame(
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||||
$result['attrs']['module']['decoration']['background']['desktop']['value']['color']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
|
||||
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
|
||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
||||
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
|
||||
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
|
||||
|
||||
/**
|
||||
* Compose classes wired with the real Divi 5.2.1 sample catalog, the sample design system and
|
||||
* predictable fakes for sanitizing and media (no WordPress). $unfiltered switches the fake
|
||||
* sanitizer between a user with and without unfiltered_html (ruling S7).
|
||||
*/
|
||||
final class ComposeFixture
|
||||
{
|
||||
public static function sanitizer(bool $unfiltered = false): ValueSanitizer
|
||||
{
|
||||
return new class ($unfiltered) implements ValueSanitizer {
|
||||
public function __construct(private bool $unfiltered)
|
||||
{
|
||||
}
|
||||
|
||||
public function text(string $value): string
|
||||
{
|
||||
return trim(strip_tags($value));
|
||||
}
|
||||
|
||||
public function html(string $value): string
|
||||
{
|
||||
return (string) preg_replace('#<script\b[^>]*>.*?</script>#is', '', $value);
|
||||
}
|
||||
|
||||
public function code(string $value): string
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function url(string $value): string
|
||||
{
|
||||
return str_starts_with(strtolower(trim($value)), 'javascript:') ? '' : trim($value);
|
||||
}
|
||||
|
||||
public function allowsUnfilteredHtml(): bool
|
||||
{
|
||||
return $this->unfiltered;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static function media(): MediaResolver
|
||||
{
|
||||
return new class implements MediaResolver {
|
||||
public function image(int $id): ?array
|
||||
{
|
||||
return match ($id) {
|
||||
33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'],
|
||||
12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''],
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static function placeholders(): Placeholders
|
||||
{
|
||||
return new Placeholders(DesignSystemTest::sample());
|
||||
}
|
||||
|
||||
public static function attrWriter(bool $unfiltered = false): AttrWriter
|
||||
{
|
||||
return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer($unfiltered));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PlaceholdersTest extends TestCase
|
||||
{
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function placeholders(): array
|
||||
{
|
||||
return [
|
||||
'color alias' => ['{{color:primary}}', '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'],
|
||||
'color label' => ['{{ color : Nachtblau }}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
|
||||
'color id' => ['{{color:gcid-a1b2}}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
|
||||
'variable' => ['{{var:Telefon}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'],
|
||||
'variable id' => ['{{var:gvid-s1}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'],
|
||||
'font' => ['{{font:heading}}', '$variable({"type":"content","value":{"name":"--et_global_heading_font","settings":{}}})$'],
|
||||
'post title' => [
|
||||
'{{post:title}}',
|
||||
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
|
||||
],
|
||||
'featured image' => ['{{post:featured_image}}', '$variable({"type":"content","value":{"name":"post_featured_image","settings":{}}})$'],
|
||||
'custom field' => [
|
||||
'{{field:telefon_mobil}}',
|
||||
'$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$',
|
||||
],
|
||||
'plain text' => ['Hallo Welt', 'Hallo Welt'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider placeholders */
|
||||
public function testResolvesToDiviVariables(string $input, string $expected): void
|
||||
{
|
||||
$resolved = ComposeFixture::placeholders()->resolve($input);
|
||||
|
||||
self::assertSame($expected, $resolved);
|
||||
self::assertSame($expected !== $input, Placeholders::isVariable($resolved));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function invalid(): array
|
||||
{
|
||||
return [
|
||||
'unknown color' => ['{{color:Lila}}', 'Vorhanden: primary, body, Primärfarbe, Fließtextfarbe, Nachtblau.'],
|
||||
'unknown color id' => ['{{color:gcid-nope}}', 'Unbekannte globale Farbe'],
|
||||
'unknown variable' => ['{{var:Fax}}', 'Vorhanden: Radius, Telefon'],
|
||||
'unknown font' => ['{{font:mono}}', 'heading, body'],
|
||||
'unknown post value' => ['{{post:views}}', 'title, excerpt'],
|
||||
'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'],
|
||||
'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'],
|
||||
'embedded' => ['Seite: {{post:title}}', 'allein im Wert'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider invalid */
|
||||
public function testRejectsUnknownNamesWithTheExistingOnes(string $input, string $message): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage($message);
|
||||
|
||||
ComposeFixture::placeholders()->resolve($input);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function ambiguous(): array
|
||||
{
|
||||
return [
|
||||
'color label' => ['{{color:blau}}', 'gcid-b1, gcid-b2'],
|
||||
'variable label' => ['{{var:Info}}', 'gvid-i1, gvid-i2'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling S13a: a label two entries share never silently picks the first one; IDs work directly.
|
||||
*
|
||||
* @dataProvider ambiguous
|
||||
*/
|
||||
public function testAmbiguousLabelsFailWithTheIds(string $input, string $ids): void
|
||||
{
|
||||
$placeholders = new Placeholders(new DesignSystem(
|
||||
[
|
||||
['id' => 'gcid-b1', 'label' => 'Blau', 'value' => '#00f'],
|
||||
['id' => 'gcid-b2', 'label' => 'blau', 'value' => '#00a'],
|
||||
],
|
||||
[
|
||||
['id' => 'gvid-i1', 'type' => 'strings', 'label' => 'Info', 'value' => 'a'],
|
||||
['id' => 'gvid-i2', 'type' => 'strings', 'label' => 'Info', 'value' => 'b'],
|
||||
],
|
||||
[],
|
||||
['heading' => '', 'body' => ''],
|
||||
[]
|
||||
));
|
||||
|
||||
try {
|
||||
$placeholders->resolve($input);
|
||||
self::fail('Ambiguous label accepted.');
|
||||
} catch (InvalidArgumentException $e) {
|
||||
self::assertStringContainsString('mehrdeutig', $e->getMessage());
|
||||
self::assertStringContainsString($ids, $e->getMessage());
|
||||
}
|
||||
|
||||
self::assertStringContainsString('"gcid-b2"', $placeholders->resolve('{{color:gcid-b2}}'));
|
||||
self::assertStringContainsString('"gvid-i2"', $placeholders->resolve('{{var:gvid-i2}}'));
|
||||
}
|
||||
|
||||
public function testColorTypeVariablesBecomeColorReferences(): void
|
||||
{
|
||||
$placeholders = new Placeholders(new DesignSystem(
|
||||
[],
|
||||
[['id' => 'gvid-c1', 'type' => 'colors', 'label' => 'Akzent', 'value' => '#f00']],
|
||||
[],
|
||||
['heading' => '', 'body' => ''],
|
||||
[]
|
||||
));
|
||||
|
||||
self::assertSame(
|
||||
'$variable({"type":"color","value":{"name":"gvid-c1","settings":{}}})$',
|
||||
$placeholders->resolve('{{var:Akzent}}')
|
||||
);
|
||||
}
|
||||
|
||||
public function testResolvesDeepValues(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', 'size' => '20px'],
|
||||
ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px'])
|
||||
);
|
||||
self::assertFalse(Placeholders::isVariable('$variable(kaputt)$'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/** Ruling S1/S2/S12: every $variable(…)$ value is parsed, strictly checked and re-serialized. */
|
||||
final class VariableRefTest extends TestCase
|
||||
{
|
||||
private static function ref(): VariableRef
|
||||
{
|
||||
return new VariableRef(DesignSystemTest::sample());
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function valid(): array
|
||||
{
|
||||
return [
|
||||
'reordered keys' => [
|
||||
'$variable({"value":{"settings":{},"name":"gcid-a1b2"},"type":"color"})$',
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||||
],
|
||||
'empty list settings, no settings' => [
|
||||
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":[]}})$',
|
||||
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$',
|
||||
],
|
||||
'missing settings' => [
|
||||
'$variable({"type":"content","value":{"name":"gvid-r1"}})$',
|
||||
'$variable({"type":"content","value":{"name":"gvid-r1","settings":{}}})$',
|
||||
],
|
||||
'color settings numeric' => [
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$',
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$',
|
||||
],
|
||||
'post date with format and html before' => [
|
||||
'$variable({"type":"content","value":{"name":"post_date","settings":{"before":"<b>Am</b> ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}}})$',
|
||||
'$variable({"type":"content","value":{"name":"post_date","settings":{"before":"<b>Am</b> ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}}})$',
|
||||
],
|
||||
'custom meta' => [
|
||||
'$variable({"type":"content","value":{"name":"custom_meta_telefon-mobil","settings":{"before":"","after":""}}})$',
|
||||
'$variable({"type":"content","value":{"name":"custom_meta_telefon-mobil","settings":{"before":"","after":""}}})$',
|
||||
],
|
||||
'theme font' => [
|
||||
'$variable({"type":"content","value":{"name":"--et_global_body_font","settings":{}}})$',
|
||||
'$variable({"type":"content","value":{"name":"--et_global_body_font","settings":{}}})$',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider valid */
|
||||
public function testReserializesValidReferences(string $input, string $expected): void
|
||||
{
|
||||
self::assertSame($expected, self::ref()->canonical($input, ComposeFixture::sanitizer()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-crafted attacks (preflight §4 "Malicious $variable tests").
|
||||
*
|
||||
* @return array<string, array{string, string}> Value => part of the German error.
|
||||
*/
|
||||
public static function malicious(): array
|
||||
{
|
||||
$content = static fn (string $name, string $settings): string =>
|
||||
'$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$';
|
||||
|
||||
return [
|
||||
'script in before' => [$content('post_title', '{"before":"<script>alert(1)</script>","after":""}'), 'before'],
|
||||
'script in after' => [$content('post_title', '{"before":"","after":"<img src=x onerror=alert(1)><script>x()</script>"}'), 'after'],
|
||||
'escaped script in before' => [
|
||||
$content('post_title', '{"before":"<script>alert(1)</script>","after":""}'),
|
||||
'before',
|
||||
],
|
||||
'tags in date format' => [$content('post_date', '{"date_format":"<b>d</b>"}'), 'date_format'],
|
||||
'type evil' => ['$variable({"type":"evil","value":{"name":"gcid-a1b2","settings":{}}})$', 'Typ'],
|
||||
'unknown gcid' => ['$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$', 'gcid-nope'],
|
||||
'unknown gvid' => [$content('gvid-nope', '{}'), 'gvid-nope'],
|
||||
'content gvid as color' => ['$variable({"type":"color","value":{"name":"gvid-s1","settings":{}}})$', 'gvid-s1'],
|
||||
'color gcid as content' => [$content('gcid-a1b2', '{}'), 'gcid-a1b2'],
|
||||
'name with paren' => ['$variable({"type":"color","value":{"name":"gcid-a1b2)","settings":{}}})$', 'Name'],
|
||||
'name with style breakout' => [
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2;}</style><script>alert(1)</script>","settings":{}}})$',
|
||||
'Name',
|
||||
],
|
||||
'unknown settings key' => [$content('post_title', '{"before":"","foo":"1"}'), 'foo'],
|
||||
'enable_html' => [$content('custom_meta_telefon', '{"before":"","after":"","enable_html":"on"}'), 'enable_html'],
|
||||
'color setting not numeric' => [
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"1;}</style>"}}})$',
|
||||
'opacity',
|
||||
],
|
||||
'protected meta key' => [$content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'],
|
||||
'unknown dynamic content' => [$content('post_meta_key', '{}'), 'post_meta_key'],
|
||||
'extra top-level key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}},"x":1})$', 'x'],
|
||||
'extra value key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{},"x":1}})$', 'x'],
|
||||
'greedy smuggling' => ['$variable({"a":"})$<script>alert(1)</script>"})$', 'allein im Wert'],
|
||||
'nested reference in before' => [
|
||||
$content('post_title', '{"before":"$variable(x)$","after":""}'),
|
||||
'allein im Wert',
|
||||
],
|
||||
'broken json' => ['$variable(kaputt)$', 'JSON'],
|
||||
'trailing text' => [$content('post_title', '{}') . ' ', 'allein im Wert'],
|
||||
'embedded in text' => ['<p>Hi ' . $content('post_title', '{"before":"","after":""}') . '</p>', 'allein im Wert'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider malicious */
|
||||
public function testRejectsMaliciousReferences(string $input, string $message): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage($message);
|
||||
|
||||
self::ref()->canonical($input, ComposeFixture::sanitizer());
|
||||
}
|
||||
|
||||
public function testUrlFieldsTakeContentReferencesWithoutBeforeOrAfter(): void
|
||||
{
|
||||
$ref = self::ref();
|
||||
$link = '$variable({"type":"content","value":{"name":"post_link_url","settings":{}}})$';
|
||||
|
||||
self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true));
|
||||
|
||||
foreach (
|
||||
[
|
||||
'$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$',
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||||
] as $value
|
||||
) {
|
||||
try {
|
||||
$ref->canonical($value, ComposeFixture::sanitizer(), true);
|
||||
self::fail('Accepted in a URL field: ' . $value);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
self::assertStringContainsString('URL', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testDetectsReferences(): void
|
||||
{
|
||||
self::assertTrue(VariableRef::contains('a $variable( b'));
|
||||
self::assertFalse(VariableRef::contains('Preis: 5 $'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user