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:
2026-09-26 13:23:59 +02:00
co-authored by Claude Opus 5.5
parent 395e9644ac
commit 1ea2e16c49
10 changed files with 1417 additions and 0 deletions
@@ -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)
));
}
}