refactor: ValueCleaner is the named choke point (H1)

The choke point moves out of AttrWriter into ValueCleaner (catalog, the
composition's VariableRef, sanitizer): clean() (was AttrWriter::cleanValue),
kindOf(), guardPath() and the private cleanNode/kind/unknownKind/tightens with
VALUE_KEY, URL_KEY, URL_SEGMENT, STRICTNESS, BUILDER_ONLY (its docblock now sits
on it; STRUCTURE stays in AttrWriter). Code of the pipeline moved unchanged.
- ValueCleaner's docblock is the one canonical description: steps 0-8 with their
  rulings in code order, invariants I1-I3; the step comments in cleanNode carry
  the same numbers. AttrWriter/ShortformWriter/Composer/TextReplacer docblocks
  point there and keep only their own rules.
- ValueCleaner::textReplacementFor($module) holds the D10 rule that was a
  closure in DiviReplaceTextAbility::replace (text/html only, clean() by kind).
- AttrWriter, ShortformWriter and Composer take the ValueCleaner; DiviComposition
  builds one per composition (cleaner()).
Tests: the eight AttrWriterTest methods that only exercised cleanValue/kindOf
moved verbatim to ValueCleanerTest (clean/kindOf, ComposeFixture::cleaner());
mixed tests call the cleaner in place; +1 test for textReplacementFor. Every
provider row and per-test assertion count unchanged.

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:
2026-09-26 18:11:28 +02:00
co-authored by Claude Opus 5.5
parent 203d14a2f9
commit 62ce99e558
13 changed files with 552 additions and 447 deletions
+6 -213
View File
@@ -15,33 +15,14 @@ use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
* ({"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).
* Every value goes through ValueCleaner (the single choke point under the kses bypass, D7 –
* see its pipeline); apply() adds step 0 (S35 control characters before placeholders), the
* guard for removals (S7/S26) and its own rule: structure attributes are refused (S41).
*/
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_-]+$/D';
/** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
private const URL_SEGMENT = '/url$/i';
/**
* Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by
* path shape at any depth: module.advanced.html(.htmlBefore), module.decoration.attributes,
* any free-form custom CSS path of any module (css, css.*, title.css.*, css.advanced.customCss).
*/
/** Text-family kinds from laxest to strictest (ruling S33a); url stands apart. */
private const STRICTNESS = ['code' => 0, 'html' => 1, 'text' => 2];
/**
* Structure attributes (ruling S41), refused for everyone and whatever the catalog says:
* the section type (regular/fullwidth/specialty) and a column's specialty columns decide
@@ -53,13 +34,10 @@ final class AttrWriter
'divi/column' => ['module.advanced.specialtyColumns'],
];
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/';
public function __construct(
private ModuleCatalog $catalog,
private Placeholders $placeholders,
private VariableRef $references,
private ValueSanitizer $sanitizer
private ValueCleaner $cleaner
) {
}
@@ -94,7 +72,7 @@ final class AttrWriter
}
try {
$this->guardPath($path);
$this->cleaner->guardPath($path);
if ($map === null) {
$attrs = AttrMap::remove($attrs, $path);
continue;
@@ -113,40 +91,6 @@ final class AttrWriter
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. $kind ('html'|'code'|'url'|'text') replaces the catalog routing for every
* string of $value: the short form passes its curated field kind (ShortformMap), so
* icon-list-item text is always plain text. It may only tighten (ruling S33a): code < html
* < text; url only where the routing says url – a weaker or other-family kind is refused.
*
* @param 'html'|'code'|'url'|'text'|null $kind
* @throws InvalidArgumentException
*/
public function cleanValue(
string $module,
string $path,
mixed $value,
?string $subKey = null,
?string $kind = null
): mixed {
$this->guardPath($path);
return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind);
}
/**
* The kind cleanValue() routes a string of $path (sub-field $subKey) to. lit/divi-replace-text
* uses it to leave URL and code fields alone (D10).
*
* @return 'html'|'code'|'url'|'text'
*/
public function kindOf(string $module, string $path, ?string $subKey = null): string
{
return $this->kind($module, $path, $subKey, $subKey);
}
/**
* @return array<string, array<string, mixed>> Breakpoint => state => value.
* @throws InvalidArgumentException
@@ -189,157 +133,6 @@ final class AttrWriter
return false;
}
/**
* Rejects Divi's HTML/attribute/CSS fields for users without unfiltered_html (S7/S26); also
* for removals, which the short form does without cleanValue().
*
* @throws InvalidArgumentException
*/
public function guardPath(string $path): void
{
if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) {
throw new InvalidArgumentException(sprintf(
'"%s" (eigenes HTML, CSS oder HTML-Attribute) 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.
* @param 'html'|'code'|'url'|'text'|null $kind Fixed kind instead of the routing (short form).
* @throws InvalidArgumentException
*/
private function cleanNode(
string $module,
string $path,
mixed $value,
?string $subName,
?string $key,
?string $kind = null
): 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, $kind)
: $this->cleanNode($module, $path, $item, $subName, $key, $kind);
}
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.');
}
// Ruling S32/S32a: control characters go on the RAW value, before the marker check and
// before the sanitizer – the WordPress sanitizers keep them ("<" + VT is a literal "<"
// for strip_tags), so any later strip could build a tag or a marker nobody checked.
$value = ControlChars::strip($value);
$routed = $this->kind($module, $path, $subName, $key);
if ($kind !== null && ! self::tightens($kind, $routed)) {
throw new InvalidArgumentException(sprintf(
'Feldart "%s" ist für "%s" nicht zulässig (das Attribut verlangt "%s").',
$kind,
$path,
$routed
));
}
$kind ??= $routed;
if (VariableRef::contains($value)) {
// The only way a reference is stored: whole, straight from canonical().
return $this->references->canonical($value, $this->sanitizer, $kind === 'url');
}
$clean = match ($kind) {
'html' => $this->sanitizer->html($value),
'code' => $this->sanitizer->code($value),
'url' => $this->sanitizer->url($value),
default => $this->sanitizer->text($value),
};
// Ruling S28: sanitizers strip characters ("<x>", "%41") and can thereby build a marker the
// raw check above never saw – so the final string is checked again. After the sanitizer
// the only action is rejection (S32a): nothing transforms the cleaned string any more.
if (VariableRef::contains($clean) || ControlChars::strip($clean) !== $clean) {
throw VariableRef::notAlone();
}
return $clean;
}
/**
* Whether the kind override $kind is at least as strict as the routed kind (ruling S33a).
* Strictness within the text family: code (raw for unfiltered_html) < html (kses) < text
* (no markup). url (esc_url_raw) is a family of its own: it neither tightens nor is
* tightened by the others, so only url → url passes.
*/
private static function tightens(string $kind, string $routed): bool
{
if ($kind === 'url' || $routed === 'url') {
return $kind === $routed;
}
return self::STRICTNESS[$kind] >= self::STRICTNESS[$routed];
}
/**
* Sanitizer for a string (rulings S3/S3a/S23/S24): the catalog kind of the path – or of the
* sub-field for a composite path – decides. A string reached through a key below a path
* with one scalar kind counts as unknown (S24). Unknown falls back to the key name (URL keys
* at any depth), then to the last path segment (…Url), then content.innerContent → rich
* text (S23: fields Divi defines only in JS), 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.
$kind = $this->catalog->pathKind($module, $path, $subName);
if ($key !== null && is_string($this->catalog->kinds($module)[$path] ?? null)) {
$kind = 'unknown';
}
return match ($kind) {
'richtext' => 'html',
'code' => 'code',
'url', 'image' => 'url',
'unknown' => $this->unknownKind($path, $key),
default => 'text',
};
}
/** @return 'html'|'url'|'text' */
private function unknownKind(string $path, ?string $key): string
{
if ($key !== null) {
return preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text';
}
$segments = explode('.', $path);
if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) {
return 'url';
}
return $path === 'content.innerContent' ? 'html' : 'text';
}
/**
* @param array<string, array<string, mixed>> $normalized
* @return array<string, array<string, mixed>>
@@ -349,7 +142,7 @@ final class AttrWriter
{
foreach ($normalized as $breakpoint => $states) {
foreach ($states as $state => $value) {
$normalized[$breakpoint][$state] = $this->cleanValue($module, $path, $value);
$normalized[$breakpoint][$state] = $this->cleaner->clean($module, $path, $value);
}
}
+4 -3
View File
@@ -19,7 +19,7 @@ use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
* 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,
* ShortformWriter / AttrWriter / ValueCleaner::clean() (the single choke point) – short-form fields,
* full-form attrs, admin labels, background_color (ShortformWriter::color(), ruling S13) and
* preset ids. A value that fails there is an error, never stored raw (S13). What Composer
* writes itself is not client text: the builder version (checked in the constructor), block
@@ -69,6 +69,7 @@ final class Composer
private StructureRules $rules,
private ShortformWriter $shortform,
private AttrWriter $attrWriter,
private ValueCleaner $cleaner,
private DesignSystem $design,
private string $builderVersion
) {
@@ -502,7 +503,7 @@ final class Composer
}
try {
$clean = $this->attrWriter->cleanValue($name, self::LABEL_PATH, $label, null, 'text');
$clean = $this->cleaner->clean($name, self::LABEL_PATH, $label, null, 'text');
} catch (InvalidArgumentException $e) {
$this->errors[$path] = $e->getMessage();
@@ -552,7 +553,7 @@ final class Composer
// 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');
$clean = $this->cleaner->clean($name, 'modulePreset', $id, null, 'text');
} catch (InvalidArgumentException $e) {
$clean = null;
}
@@ -13,10 +13,11 @@ use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
* kind, resolves placeholders (not for code) and sets it at "<path>.desktop.value[.<sub>]";
* null removes the field. Pure PHP.
*
* Every string goes through AttrWriter::cleanValue() – the single choke point under the kses
* bypass (D7) – with the curated kind of the field: $variable(…)$ values only whole and
* re-serialized by VariableRef (S1/S2/S25), the final string checked again (S28), URL fields
* through the URL sanitizer (S3), builder-only paths guarded (S7/S26). Nothing is passed through.
* Every string goes through ValueCleaner::clean() – the single choke point under the kses
* bypass (D7), see its pipeline – with the curated kind of the field; apply() adds step 0 (S35)
* and set() the guard for removals (S7/S26). Own rules on top: colors are placeholders, color
* references or plain CSS colors, checked before and after cleaning (S13); an attachment's alt
* text carrying a reference is dropped (S33b). Nothing is passed through.
*/
final class ShortformWriter
{
@@ -25,7 +26,7 @@ final class ShortformWriter
private const COLOR_ERROR = 'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a';
/** Curated kind => sanitizer kind of AttrWriter::cleanValue(). */
/** Curated kind => sanitizer kind of ValueCleaner::clean(). */
private const CLEAN_KIND = ['html' => 'html', 'code' => 'code', 'url' => 'url', 'text' => 'text'];
/**
@@ -35,7 +36,7 @@ final class ShortformWriter
public function __construct(
private Placeholders $placeholders,
private MediaResolver $media,
private AttrWriter $attrs,
private ValueCleaner $cleaner,
private ?array $modules = null
) {
}
@@ -106,7 +107,7 @@ final class ShortformWriter
$value = trim($this->placeholders->resolve($value));
if (VariableRef::contains($value)) {
$reference = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text');
$reference = $this->cleaner->clean($module, $path, $value, $subKey, 'text');
if (! is_string($reference) || VariableRef::typeOf($reference) !== 'color') {
throw new InvalidArgumentException(self::COLOR_ERROR);
}
@@ -118,7 +119,7 @@ final class ShortformWriter
throw new InvalidArgumentException(self::COLOR_ERROR);
}
$clean = $this->attrs->cleanValue($module, $path, $value, $subKey, 'text');
$clean = $this->cleaner->clean($module, $path, $value, $subKey, 'text');
if (! is_string($clean) || preg_match(self::COLOR, $clean) !== 1) {
throw new InvalidArgumentException(self::COLOR_ERROR);
}
@@ -138,7 +139,7 @@ final class ShortformWriter
$path = (string) $spec['path'];
$sub = $spec['sub'] === null ? null : (string) $spec['sub'];
$base = $path . '.desktop.value';
$this->attrs->guardPath($path);
$this->cleaner->guardPath($path);
if ($value === null) {
return AttrMap::remove($attrs, $sub === null ? $path : $base . '.' . $sub);
}
@@ -174,7 +175,7 @@ final class ShortformWriter
[$urlKey, $idKey, $altKey] = $keys;
$base = $path . '.desktop.value.';
$url = $this->attrs->cleanValue($module, $path, $image['url'], $urlKey, 'url');
$url = $this->cleaner->clean($module, $path, $image['url'], $urlKey, 'url');
if ($url === '') {
throw new InvalidArgumentException($notFound);
}
@@ -205,7 +206,7 @@ final class ShortformWriter
}
try {
$clean = $this->attrs->cleanValue($module, $path, $alt, $altKey, 'text');
$clean = $this->cleaner->clean($module, $path, $alt, $altKey, 'text');
} catch (InvalidArgumentException) {
return '';
}
@@ -262,7 +263,7 @@ final class ShortformWriter
$value = $this->placeholders->resolve($value);
}
$clean = $this->attrs->cleanValue($module, $path, $value, $sub, $kind);
$clean = $this->cleaner->clean($module, $path, $value, $sub, $kind);
if ($kind === 'url' && $clean === '' && trim($value) !== '') {
throw new InvalidArgumentException('ungültige URL');
}
@@ -18,8 +18,8 @@ use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
* Rulings S8/S45: each string is tokenized (quote-aware tags, comments, entities, references,
* raw-text bodies such as <script>); only text tokens are searched. A string with an
* unterminated tag, quote, comment or reference and a hit is refused. The inserted text is the
* replacement as the $clean callback returns it for that place (AttrWriter::cleanValue() in the
* ability: sanitized by the field kind, markers rejected – rulings S2/S28). A string with no
* replacement as the $clean callback returns it for that place (ValueCleaner::textReplacementFor()
* in the ability: sanitized by the field kind, markers rejected – rulings S2/S28). A string with no
* effective hit is returned as it is and never cleaned (Task 1 forward risk). A changed string
* must keep every non-text token of the original byte for byte, gain only the inserted text's
* own, add no reference marker, and an independent scan (HtmlContext) must see each replaced
@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
/**
* The single choke point through which every client string reaches Divi post_content. Divi
* saves bypass kses (D7), so this pipeline is the guarantee. Pure PHP.
*
* Pipeline of clean() – in this order, each step on the string named:
* 0. (caller) S35: ControlChars::stripDeep() on the RAW input, before placeholder resolution
* and trim – AttrWriter::apply(), ShortformWriter::apply().
* 1. S7/S26: guardPath() – builder-only paths (HTML, attributes, custom CSS) need
* unfiltered_html; callers also run it for removals (AttrWriter::apply, ShortformWriter::set).
* 2. S13: keys inside values are plain identifiers (VALUE_KEY); per string, the rest:
* 3. UTF-8 check of the raw string.
* 4. S32/S32a: ControlChars::strip() on the RAW string.
* 5. S3/S3a/S23/S24: kind routing by catalog kind, sub-field, key name, path segment
* (kind()); S33a: a kind override may only tighten the routed kind (tightens()).
* 6. S1/S2: a string with the $variable( marker → VariableRef::canonical() (whole value only;
* URL fields only content references) – its result is returned as it is.
* 7. the sanitizer of the kind: html / code / url / text (ValueSanitizer).
* 8. S28: final-string check – a marker or control characters in the cleaned string →
* rejection. Nothing transforms the string after step 7.
*
* Invariants:
* (I1) No client string reaches Block attrs except via ValueCleaner::clean() or
* VariableRef::canonical().
* (I2) After the sanitizer there is only rejection, never another transformation.
* (I3) A stored reference is always the output of VariableRef::serialize().
*
* Callers add their own rule on top: ShortformWriter S13 color and S33b attachment alt,
* Composer admin label and preset id, TextReplacer S45 (through textReplacementFor()),
* DynamicContentRules the same raw-strip/marker/final-check steps for reference settings.
*/
final class ValueCleaner
{
/** 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_-]+$/D';
/** Last path segment naming a URL when neither kind nor key does (ruling S24): redirectUrl. */
private const URL_SEGMENT = '/url$/i';
/** Text-family kinds from laxest to strictest (ruling S33a); url stands apart. */
private const STRICTNESS = ['code' => 0, 'html' => 1, 'text' => 2];
/**
* Raw HTML/CSS fields, and the ones Divi re-serializes whole pages for (rulings S7/S26), by
* 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).
*/
private const BUILDER_ONLY = '/(^|\.)(decoration\.attributes|advanced\.html|advanced\.customCss|css)(\.|$)/';
public function __construct(
private ModuleCatalog $catalog,
private VariableRef $references,
private ValueSanitizer $sanitizer
) {
}
/**
* Cleans one value written to $path (steps 1–8 above). $subKey names the sub-field of a
* composite path ("linkUrl" of button.innerContent) when $value is its string alone. $kind
* ('html'|'code'|'url'|'text') replaces the catalog routing for every string of $value: the
* short form passes its curated field kind (ShortformMap), so icon-list-item text is always
* plain text. It may only tighten (ruling S33a): code < html < text; url only where the
* routing says url – a weaker or other-family kind is refused.
*
* @param 'html'|'code'|'url'|'text'|null $kind
* @throws InvalidArgumentException
*/
public function clean(
string $module,
string $path,
mixed $value,
?string $subKey = null,
?string $kind = null
): mixed {
$this->guardPath($path);
return $this->cleanNode($module, $path, $value, $subKey, $subKey, $kind);
}
/**
* The kind clean() routes a string of $path (sub-field $subKey) to. lit/divi-replace-text
* uses it to leave URL and code fields alone (D10).
*
* @return 'html'|'code'|'url'|'text'
*/
public function kindOf(string $module, string $path, ?string $subKey = null): string
{
return $this->kind($module, $path, $subKey, $subKey);
}
/**
* The $clean callback of TextReplacer::apply() for a block of $module (D10): replace-text
* touches only text and html kinds, and the inserted text goes through clean() with the
* routed kind; URL and code places yield null and stay as they are.
*
* @return callable(string, ?string, string): ?string
*/
public function textReplacementFor(string $module): callable
{
return function (string $path, ?string $key, string $value) use ($module): ?string {
$kind = $this->kindOf($module, $path, $key);
return $kind === 'text' || $kind === 'html'
? (string) $this->clean($module, $path, $value, $key)
: null;
};
}
/**
* Step 1: rejects Divi's HTML/attribute/CSS fields for users without unfiltered_html
* (S7/S26); also for removals, which the callers do without clean().
*
* @throws InvalidArgumentException
*/
public function guardPath(string $path): void
{
if (preg_match(self::BUILDER_ONLY, $path) === 1 && ! $this->sanitizer->allowsUnfilteredHtml()) {
throw new InvalidArgumentException(sprintf(
'"%s" (eigenes HTML, CSS oder HTML-Attribute) ist nur im Divi-Builder änderbar '
. '(oder mit dem Recht unfiltered_html).',
$path
));
}
}
/**
* Steps 2–8 for $value and everything inside it.
*
* @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.
* @param 'html'|'code'|'url'|'text'|null $kind Fixed kind instead of the routing (short form).
* @throws InvalidArgumentException
*/
private function cleanNode(
string $module,
string $path,
mixed $value,
?string $subName,
?string $key,
?string $kind = null
): mixed {
if (is_array($value)) {
$clean = [];
foreach ($value as $childKey => $item) {
// Step 2 (S13).
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, $kind)
: $this->cleanNode($module, $path, $item, $subName, $key, $kind);
}
return $clean;
}
if (! is_string($value)) {
return $value;
}
// Step 3.
if (! mb_check_encoding($value, 'UTF-8')) {
throw new InvalidArgumentException('Wert ist kein gültiges UTF-8.');
}
// Step 4 (S32/S32a): control characters go on the RAW value, before the marker check and
// before the sanitizer – the WordPress sanitizers keep them ("<" + VT is a literal "<"
// for strip_tags), so any later strip could build a tag or a marker nobody checked.
$value = ControlChars::strip($value);
// Step 5 (S3/S3a/S23/S24, S33a).
$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;
// Step 6 (S1/S2): the only way a reference is stored – whole, straight from canonical().
if (VariableRef::contains($value)) {
return $this->references->canonical($value, $this->sanitizer, $kind === 'url');
}
// Step 7.
$clean = match ($kind) {
'html' => $this->sanitizer->html($value),
'code' => $this->sanitizer->code($value),
'url' => $this->sanitizer->url($value),
default => $this->sanitizer->text($value),
};
// Step 8 (S28): sanitizers strip characters ("<x>", "%41") and can thereby build a marker
// the raw check above never saw – so the final string is checked again. After the
// sanitizer the only action is rejection (S32a): nothing transforms the cleaned string.
if (VariableRef::contains($clean) || ControlChars::strip($clean) !== $clean) {
throw VariableRef::notAlone();
}
return $clean;
}
/**
* Whether the kind override $kind is at least as strict as the routed kind (ruling S33a).
* Strictness within the text family: code (raw for unfiltered_html) < html (kses) < text
* (no markup). url (esc_url_raw) is a family of its own: it neither tightens nor is
* tightened by the others, so only url → url passes.
*/
private static function tightens(string $kind, string $routed): bool
{
if ($kind === 'url' || $routed === 'url') {
return $kind === $routed;
}
return self::STRICTNESS[$kind] >= self::STRICTNESS[$routed];
}
/**
* Sanitizer for a string (rulings S3/S3a/S23/S24): the catalog kind of the path – or of the
* sub-field for a composite path – decides. A string reached through a key below a path
* with one scalar kind counts as unknown (S24). Unknown falls back to the key name (URL keys
* at any depth), then to the last path segment (…Url), then content.innerContent → rich
* text (S23: fields Divi defines only in JS), 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.
$kind = $this->catalog->pathKind($module, $path, $subName);
if ($key !== null && is_string($this->catalog->kinds($module)[$path] ?? null)) {
$kind = 'unknown';
}
return match ($kind) {
'richtext' => 'html',
'code' => 'code',
'url', 'image' => 'url',
'unknown' => $this->unknownKind($path, $key),
default => 'text',
};
}
/** @return 'html'|'url'|'text' */
private function unknownKind(string $path, ?string $key): string
{
if ($key !== null) {
return preg_match(self::URL_KEY, $key) === 1 ? 'url' : 'text';
}
$segments = explode('.', $path);
if (preg_match(self::URL_SEGMENT, (string) end($segments)) === 1) {
return 'url';
}
return $path === 'content.innerContent' ? 'html' : 'text';
}
}