refactor: split TextReplacer into HtmlTokens and StoredForms (M1)

- HtmlTokens (static, with the S45 docblock): SPLIT, RAW_OPEN, tokenize(),
  structure(), closes(), unterminated() moved unchanged; new RAW_ELEMENTS list.
- StoredForms: of() (was TextReplacer::forms), mentions(), unicodeEscaped() –
  the DB prefilter forms; TextReplacer::mentions() delegates, the ability's
  candidate query uses StoredForms::of().
- TextReplacer keeps problems(), apply() and the splice/safe() verification;
  safe() still runs the tokenizer and HtmlContext as two independent scans, in
  the same order.
HtmlTokensTest asserts RAW_ELEMENTS === HtmlContext::RAW and that RAW_OPEN opens
exactly those elements. TextReplacerTest and HtmlContextTest 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:14:49 +02:00
co-authored by Claude Opus 5.5
parent 570c1e6142
commit 3eeb7eee90
6 changed files with 239 additions and 161 deletions
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
/**
* The forms a search text can take in stored block markup – the database prefilter of
* lit/divi-replace-text and the check for global modules it does not edit. Only a prefilter:
* the exact, safe match happens in the tree (TextReplacer). Pure PHP.
*/
final class StoredForms
{
/**
* The search text as it can be stored in block markup: raw, as serialize_block_attributes()
* escapes it (<, >, &, " and --; the JS builder keeps non-ASCII raw) and with \uXXXX for
* non-ASCII (WordPress' PHP serializer). Case-insensitive adds the lower and upper case
* forms, since the hex digits of \u escapes do not fold.
*
* @return list<string>
*/
public static function of(string $search, bool $caseSensitive): array
{
$variants = $caseSensitive ? [$search] : [$search, mb_strtolower($search), mb_strtoupper($search)];
$forms = [];
foreach ($variants as $variant) {
$escaped = substr(Serializer::attributes(['s' => $variant]), 6, -2);
array_push($forms, $variant, $escaped, self::unicodeEscaped($escaped));
}
return array_values(array_unique($forms));
}
/** Whether block markup contains $search in one of its stored forms. */
public static function mentions(string $markup, string $search, bool $caseSensitive): bool
{
foreach (self::of($search, $caseSensitive) as $form) {
$found = $caseSensitive ? str_contains($markup, $form) : mb_stripos($markup, $form) !== false;
if ($found) {
return true;
}
}
return false;
}
/** Non-ASCII characters as JSON \uXXXX escapes (UTF-16, lower-case hex like json_encode()). */
private static function unicodeEscaped(string $value): string
{
return (string) preg_replace_callback('/[^\x00-\x7F]/u', static function (array $match): string {
$units = unpack('n*', (string) mb_convert_encoding($match[0], 'UTF-16BE', 'UTF-8'));
return implode('', array_map(static fn (int $unit): string => sprintf('\\u%04x', $unit), (array) $units));
}, $value);
}
}