feat: lit/divi-replace-text with dry run and per-page tree_rev targets
TextReplacer replaces only in plain-text segments (S8 split), outside script/style bodies, inserts the replacement cleaned per field kind and refuses any result whose tags, entities or references differ from the original plus the inserted text. Unchanged strings are never cleaned. Per page all or nothing with the tree_rev lock; Throwables are reported per page (S10). 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:
@@ -135,6 +135,17 @@ final class AttrWriter
|
||||
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
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
<?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\BlockTree\NodeSummary;
|
||||
|
||||
/**
|
||||
* Replaces visible text in the attributes of one block (lit/divi-replace-text, spec §5.7, D10):
|
||||
* only in content (…innerContent) – plain values and the text keys of object values (text,
|
||||
* title, label, alt, titleText) – never in URLs, IDs, styles or $variable(…)$ references.
|
||||
* Pure PHP.
|
||||
*
|
||||
* Ruling S8: each string is split into tags, entities and references (kept byte for byte) and
|
||||
* plain text; only plain text outside <script>/<style> bodies is searched. 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
|
||||
* hit is returned as it is and never cleaned (Task 1 forward risk: stored references outside
|
||||
* the allow list must survive). A changed string must have exactly the tags, entities and
|
||||
* references of the original plus those of the inserted text – otherwise the replacement glued
|
||||
* something new together across a boundary (a lone "<" closed into a tag, "&" + ";", a
|
||||
* partial reference closed by ")$") and the whole call is refused (InvalidArgumentException).
|
||||
*/
|
||||
final class TextReplacer
|
||||
{
|
||||
/** Ruling S8, verbatim: tags, entities and references are delimiters, the rest is plain text. */
|
||||
public const SPLIT = '/(<[^>]*>|&[#A-Za-z0-9]+;|\$variable\(.+?\)\$)/';
|
||||
|
||||
private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'titleText'];
|
||||
|
||||
/** Elements whose body is code, not text. */
|
||||
private const RAW_OPEN = '/^<(script|style)\b/i';
|
||||
|
||||
private const CONTEXT = 30;
|
||||
|
||||
private string $pattern;
|
||||
|
||||
/** @var array<string, ?string> Cleaned replacement per place (path|key). */
|
||||
private array $cleaned = [];
|
||||
|
||||
/** @throws InvalidArgumentException When problems() finds any. */
|
||||
public function __construct(private string $search, private string $replace, private bool $caseSensitive = true)
|
||||
{
|
||||
$problems = self::problems($search, $replace);
|
||||
if ($problems !== []) {
|
||||
throw new InvalidArgumentException(implode(' ', $problems));
|
||||
}
|
||||
|
||||
$this->pattern = '/' . preg_quote($search, '/') . '/u' . ($caseSensitive ? '' : 'i');
|
||||
}
|
||||
|
||||
/**
|
||||
* Input problems, field => message (empty when fine). The replacement must neither be nor
|
||||
* contain a reference (rulings S2/D9) – also not one a sanitizer would build by stripping
|
||||
* control characters, tags or %XX octets (S28/S32a); the search must not contain the marker.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function problems(string $search, string $replace): array
|
||||
{
|
||||
$problems = [];
|
||||
if (! mb_check_encoding($search, 'UTF-8') || mb_strlen($search) < 2) {
|
||||
$problems['search'] = 'Suchtext: mindestens 2 Zeichen gültiges UTF-8.';
|
||||
} elseif (VariableRef::contains(VariableRef::withoutControls($search))) {
|
||||
$problems['search'] = 'Suchtext darf keinen $variable(…)$-Verweis enthalten; dynamische Inhalte werden '
|
||||
. 'nie ersetzt.';
|
||||
}
|
||||
|
||||
if (! mb_check_encoding($replace, 'UTF-8')) {
|
||||
$problems['replace'] = 'Ersetzung ist kein gültiges UTF-8.';
|
||||
} elseif (VariableRef::contains(self::stripped($replace))) {
|
||||
$problems['replace'] = 'Ersetzung darf keinen $variable(…)$-Verweis enthalten (auch nicht zerlegt); '
|
||||
. 'dynamische Inhalte nur über lit/divi-update einzeln setzen.';
|
||||
}
|
||||
|
||||
return $problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attrs
|
||||
* @param callable(string $path, ?string $key, string $replacement): ?string $clean
|
||||
* The replacement as it may be inserted at that place, or null when the place is no text
|
||||
* field (URL, code) and stays as it is. May throw InvalidArgumentException.
|
||||
* @return array{attrs: array<string, mixed>, count: int,
|
||||
* samples: list<array{path: string, before: string, after: string}>}
|
||||
* @throws InvalidArgumentException When a replacement would change markup or references.
|
||||
*/
|
||||
public function apply(array $attrs, callable $clean): array
|
||||
{
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
foreach (AttrMap::flatten($attrs) as $path => $map) {
|
||||
// A key containing "." would make the dotted path address something else.
|
||||
$content = str_ends_with($path, '.innerContent') && AttrMap::isBreakpointMap($map);
|
||||
if (! $content || AttrMap::get($attrs, $path) !== $map) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($map as $breakpoint => $states) {
|
||||
foreach (is_array($states) ? $states : [] as $state => $value) {
|
||||
$replaced = $this->value($value, $path, $clean, $count, $samples);
|
||||
if ($replaced !== $value) {
|
||||
$attrs = AttrMap::set($attrs, sprintf('%s.%s.%s', $path, $breakpoint, $state), $replaced);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['attrs' => $attrs, 'count' => $count, 'samples' => $samples];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string, ?string, string): ?string $clean
|
||||
* @param list<array{path: string, before: string, after: string}> $samples
|
||||
*/
|
||||
private function value(mixed $value, string $path, callable $clean, int &$count, array &$samples): mixed
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $this->text($value, $path, null, $clean, $count, $samples);
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach (self::TEXT_KEYS as $key) {
|
||||
if (is_string($value[$key] ?? null)) {
|
||||
$value[$key] = $this->text($value[$key], $path, $key, $clean, $count, $samples);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string, ?string, string): ?string $clean
|
||||
* @param list<array{path: string, before: string, after: string}> $samples
|
||||
*/
|
||||
private function text(
|
||||
string $value,
|
||||
string $path,
|
||||
?string $key,
|
||||
callable $clean,
|
||||
int &$count,
|
||||
array &$samples
|
||||
): string {
|
||||
$parts = preg_split(self::SPLIT, $value, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
if (! is_array($parts)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$hits = $this->hits($parts);
|
||||
if ($hits === null || array_sum($hits) === 0) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$place = $path . ($key === null ? '' : '.' . $key);
|
||||
$replacement = $this->replacement($path, $key, $clean);
|
||||
if ($replacement === null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$expected = [];
|
||||
$insert = static fn (): string => $replacement;
|
||||
$replacementDelimiters = self::delimiters($replacement);
|
||||
foreach ($parts as $i => $part) {
|
||||
if ($i % 2 === 1) {
|
||||
$expected[] = $part;
|
||||
} elseif (($hits[$i] ?? 0) > 0) {
|
||||
$parts[$i] = (string) preg_replace_callback($this->pattern, $insert, $part);
|
||||
for ($n = 0; $n < $hits[$i]; $n++) {
|
||||
array_push($expected, ...$replacementDelimiters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$replaced = implode('', $parts);
|
||||
// Every marker of the original must be a whole reference (else the string holds a partial
|
||||
// or nested one Divi might read differently), none may be added, and the structure must
|
||||
// be the original's plus the inserted text's own.
|
||||
$markers = substr_count($value, VariableRef::MARKER);
|
||||
$wholeReferences = $markers === self::referenceCount($parts);
|
||||
$sameMarkers = substr_count($replaced, VariableRef::MARKER) === $markers;
|
||||
if (! $wholeReferences || ! $sameMarkers || self::delimiters($replaced) !== $expected) {
|
||||
throw self::refused($place);
|
||||
}
|
||||
|
||||
$count += array_sum($hits);
|
||||
if (count($samples) < 5) {
|
||||
$samples[] = [
|
||||
'path' => $place,
|
||||
'before' => $this->excerpt(NodeSummary::textOf($value), $this->search),
|
||||
'after' => $this->excerpt(NodeSummary::textOf($replaced), $replacement),
|
||||
];
|
||||
}
|
||||
|
||||
return $replaced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hits per plain part (index => count); plain text inside <script>/<style> has none. Null
|
||||
* when a part cannot be searched (invalid UTF-8): the string stays as it is.
|
||||
*
|
||||
* @param list<string> $parts
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
private function hits(array $parts): ?array
|
||||
{
|
||||
$hits = [];
|
||||
$raw = null;
|
||||
foreach ($parts as $i => $part) {
|
||||
if ($i % 2 === 1) {
|
||||
if ($raw === null && preg_match(self::RAW_OPEN, $part, $match) === 1) {
|
||||
$raw = strtolower($match[1]);
|
||||
} elseif ($raw !== null && preg_match('#^</' . $raw . '\b#i', $part) === 1) {
|
||||
$raw = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($raw !== null || $part === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$found = preg_match_all($this->pattern, $part);
|
||||
if ($found === false) {
|
||||
return null;
|
||||
}
|
||||
$hits[$i] = $found;
|
||||
}
|
||||
|
||||
return $hits;
|
||||
}
|
||||
|
||||
/** @param callable(string, ?string, string): ?string $clean */
|
||||
private function replacement(string $path, ?string $key, callable $clean): ?string
|
||||
{
|
||||
$place = $path . '|' . ($key ?? '');
|
||||
if (! array_key_exists($place, $this->cleaned)) {
|
||||
$cleaned = $clean($path, $key, $this->replace);
|
||||
if ($cleaned !== null && VariableRef::contains($cleaned)) {
|
||||
throw VariableRef::notAlone();
|
||||
}
|
||||
$this->cleaned[$place] = $cleaned;
|
||||
}
|
||||
|
||||
return $this->cleaned[$place];
|
||||
}
|
||||
|
||||
/** @return list<string> Tags, entities and references of $value, in order. */
|
||||
private static function delimiters(string $value): array
|
||||
{
|
||||
$parts = preg_split(self::SPLIT, $value, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
|
||||
return is_array($parts)
|
||||
? array_values(array_filter($parts, static fn (int $i): bool => $i % 2 === 1, ARRAY_FILTER_USE_KEY))
|
||||
: [$value];
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
private static function referenceCount(array $parts): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($parts as $i => $part) {
|
||||
if ($i % 2 === 1 && str_starts_with($part, VariableRef::MARKER)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* $value as a sanitizer could leave it: controls gone, then tags (nested like strip_tags
|
||||
* counts them, an unclosed one up to the end) and %XX octets removed until nothing changes.
|
||||
*/
|
||||
private static function stripped(string $value): string
|
||||
{
|
||||
$value = VariableRef::withoutControls($value);
|
||||
do {
|
||||
$before = $value;
|
||||
$kept = '';
|
||||
$depth = 0;
|
||||
foreach (str_split($value) as $char) {
|
||||
if ($char === '<') {
|
||||
$depth++;
|
||||
} elseif ($depth > 0) {
|
||||
$depth -= $char === '>' ? 1 : 0;
|
||||
} else {
|
||||
$kept .= $char;
|
||||
}
|
||||
}
|
||||
$value = (string) preg_replace('/%[0-9A-Fa-f]{2}/', '', $kept);
|
||||
} while ($value !== $before);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function refused(string $place): InvalidArgumentException
|
||||
{
|
||||
return new InvalidArgumentException(sprintf(
|
||||
'"%s": die Ersetzung würde dort HTML-Tags, Entities oder $variable(…)$-Verweise verändern oder neu '
|
||||
. 'bilden (Text grenzt an ein einzelnes "<", ">", "&" oder einen unvollständigen Verweis); nichts '
|
||||
. 'wurde ersetzt.',
|
||||
$place
|
||||
));
|
||||
}
|
||||
|
||||
private function excerpt(string $text, string $needle): string
|
||||
{
|
||||
$position = $needle === '' ? false
|
||||
: ($this->caseSensitive ? mb_strpos($text, $needle) : mb_stripos($text, $needle));
|
||||
if ($position === false) {
|
||||
return mb_substr($text, 0, 2 * self::CONTEXT);
|
||||
}
|
||||
|
||||
$start = max(0, $position - self::CONTEXT);
|
||||
$length = mb_strlen($needle) + 2 * self::CONTEXT;
|
||||
|
||||
return ($start > 0 ? '…' : '') . mb_substr($text, $start, $length)
|
||||
. ($start + $length < mb_strlen($text) ? '…' : '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user