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
+15 -10
View File
@@ -11,6 +11,7 @@ use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Compose\Warnings;
@@ -20,9 +21,9 @@ use LIT\MCP\Integrations\Divi\Schema\DiviModules;
/**
* Wires the pure Compose classes with the live catalog, design system, Divi's dynamic content
* registry and the WordPress sanitizer/media lookup – once per request and entry. Ruling S31:
* ONE VariableRef (carrying the live dynamic content map of the entry, S22) and ONE Placeholders
* instance are shared by AttrWriter and ShortformWriter, and the same AttrWriter serves
* ShortformWriter and Composer.
* ONE VariableRef (carrying the live dynamic content map of the entry, S22), ONE Placeholders
* and ONE ValueCleaner instance are shared by AttrWriter, ShortformWriter and Composer, and the
* same AttrWriter serves Composer and the abilities.
*
* forPost() binds the composition to the entry being written (DiviWriter::composition() does it
* for the abilities); unbound, VariableRef's default map applies. Not final so abilities can be
@@ -33,6 +34,7 @@ class DiviComposition
private ?int $postId = null;
private ?VariableRef $references = null;
private ?Placeholders $placeholders = null;
private ?ValueCleaner $cleaner = null;
private ?AttrWriter $attrWriter = null;
private ?Composer $composer = null;
@@ -52,6 +54,7 @@ class DiviComposition
$bound->postId = $postId;
$bound->references = null;
$bound->placeholders = null;
$bound->cleaner = null;
$bound->attrWriter = null;
$bound->composer = null;
@@ -73,14 +76,15 @@ class DiviComposition
return $this->placeholders ??= new Placeholders($this->design->read());
}
/** The one choke point of this composition (ValueCleaner, rulings S1–S3, S7, S13, S28, S32). */
public function cleaner(): ValueCleaner
{
return $this->cleaner ??= new ValueCleaner($this->modules->catalog(), $this->references(), $this->sanitizer);
}
public function attrWriter(): AttrWriter
{
return $this->attrWriter ??= new AttrWriter(
$this->modules->catalog(),
$this->placeholders(),
$this->references(),
$this->sanitizer
);
return $this->attrWriter ??= new AttrWriter($this->modules->catalog(), $this->placeholders(), $this->cleaner());
}
public function composer(): Composer
@@ -88,8 +92,9 @@ class DiviComposition
return $this->composer ??= new Composer(
$this->modules->catalog(),
$this->rules(),
new ShortformWriter($this->placeholders(), $this->media, $this->attrWriter()),
new ShortformWriter($this->placeholders(), $this->media, $this->cleaner()),
$this->attrWriter(),
$this->cleaner(),
$this->design->read(),
$this->modules->builderVersion()
);
@@ -29,8 +29,8 @@ use WP_Error;
* (DiviWriter::composition). Should the entry's map refuse something (an option Divi offers
* only for other types), write() returns the errors and lit/content-create deletes the draft.
*
* Every client string goes through Composer (AttrWriter / ShortformWriter) – the single choke
* point that makes the kses bypass safe (spec D7).
* Every client string goes through Composer (AttrWriter / ShortformWriter → ValueCleaner) – the
* single choke point that makes the kses bypass safe (spec D7).
*/
final class DiviContentComposer implements ContentComposer
{
@@ -30,7 +30,7 @@ use WP_Error;
* it is written; DiviWriter::save() stores it with its own restore-point revision and change
* log. The batch reports every page (spec §8.2, ruling S10): any Throwable of one page is logged
* and reported there, the others go on. Inserted text is cleaned by the field kind through
* AttrWriter::cleanValue() (S2/S28); URL and code fields are never touched.
* ValueCleaner::textReplacementFor() (S2/S28); URL and code fields are never touched.
*/
final class DiviReplaceTextAbility implements Ability
{
@@ -379,7 +379,7 @@ final class DiviReplaceTextAbility implements Ability
/**
* Every hit of $page, computed without changing it. The replacement is cleaned per place by
* the entry's AttrWriter (field kind; markers rejected); URL and code fields are skipped.
* the entry's ValueCleaner (field kind; markers rejected); URL and code fields are skipped.
*
* @return array{count: int, samples: list<array<string, string>>,
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
@@ -387,7 +387,7 @@ final class DiviReplaceTextAbility implements Ability
*/
private function replace(DiviPage $page, TextReplacer $replacer): array
{
$attrWriter = $this->writer->composition($page)->attrWriter();
$cleaner = $this->writer->composition($page)->cleaner();
$count = 0;
$samples = [];
$blocks = [];
@@ -404,16 +404,8 @@ final class DiviReplaceTextAbility implements Ability
continue;
}
$module = $block->name();
$clean = static function (string $path, ?string $key, string $value) use ($attrWriter, $module): ?string {
$kind = $attrWriter->kindOf($module, $path, $key);
return $kind === 'text' || $kind === 'html'
? (string) $attrWriter->cleanValue($module, $path, $value, $key)
: null;
};
try {
$result = $replacer->apply($block->attrs(), $clean);
$result = $replacer->apply($block->attrs(), $cleaner->textReplacementFor($block->name()));
} catch (InvalidArgumentException $e) {
throw new InvalidArgumentException(sprintf('Knoten %s: %s', $entry['id'], $e->getMessage()), 0, $e);
}
@@ -9,7 +9,7 @@ use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
/**
* WordPress sanitizing of Divi values (spec §5.6/§10). Because DiviSaver bypasses kses for the
* page as a whole (kses would re-serialize every untouched block, D7), every value this plugin
* writes goes through here instead – always via AttrWriter::cleanValue(), which also checks
* writes goes through here instead – always via ValueCleaner::clean(), which also checks
* $variable(…)$ references before and after these sanitizers.
*
* Rich text and code depend on unfiltered_html like in core (ruling S4): administrators keep