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
@@ -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');
}