Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
387 lines
15 KiB
PHP
387 lines
15 KiB
PHP
<?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;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* Rulings S8/S45: each string is tokenized by HtmlTokens (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 (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
|
||
* hit and both ends of each insertion in text context – otherwise the whole call is refused
|
||
* (InvalidArgumentException, the page is not changed).
|
||
*/
|
||
final class TextReplacer
|
||
{
|
||
private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'titleText'];
|
||
|
||
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 (ControlChars::strip($search) !== $search) {
|
||
$problems['search'] = 'Suchtext darf keine Steuerzeichen enthalten.';
|
||
} elseif (VariableRef::contains($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;
|
||
}
|
||
|
||
/** Whether block markup (e.g. of a global module this tool does not edit) contains the search text. */
|
||
public function mentions(string $markup): bool
|
||
{
|
||
return StoredForms::mentions($markup, $this->search, $this->caseSensitive);
|
||
}
|
||
|
||
/**
|
||
* @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 {
|
||
$place = $path . ($key === null ? '' : '.' . $key);
|
||
$tokens = HtmlTokens::tokenize($value);
|
||
if ($tokens === null) {
|
||
// Unterminated markup, quotes, comments or references: where does the text end? Never guess.
|
||
if (preg_match($this->pattern, $value) === 1) {
|
||
throw self::refused($place);
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
$hits = $this->hits($tokens);
|
||
if ($hits === null || $hits === []) {
|
||
return $value;
|
||
}
|
||
|
||
$replacement = $this->replacement($path, $key, $clean);
|
||
if ($replacement === null) {
|
||
return $value;
|
||
}
|
||
|
||
// Only hits that change something (search == replacement, or a cleaned replacement equal
|
||
// to the match, change nothing and are no hits).
|
||
$hits = array_values(array_filter($hits, static fn (array $hit): bool => $hit[2] !== $replacement));
|
||
if ($hits === []) {
|
||
return $value;
|
||
}
|
||
|
||
$inserted = HtmlTokens::tokenize($replacement);
|
||
if ($inserted === null) {
|
||
throw self::refused($place);
|
||
}
|
||
|
||
[$replaced, $insertedRanges, $expected] = self::splice($value, $tokens, $hits, $replacement, $inserted);
|
||
if (! self::safe($value, $tokens, $hits, $replaced, $insertedRanges, $expected)) {
|
||
throw self::refused($place);
|
||
}
|
||
|
||
$count += count($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 in text tokens: [byte offset in the value, byte length, matched text]. Null when a
|
||
* token cannot be searched (invalid UTF-8): the string stays as it is.
|
||
*
|
||
* @param list<array{string, string, int}> $tokens
|
||
* @return list<array{int, int, string}>|null
|
||
*/
|
||
private function hits(array $tokens): ?array
|
||
{
|
||
$hits = [];
|
||
foreach ($tokens as [$type, $text, $offset]) {
|
||
if ($type !== 'text') {
|
||
continue;
|
||
}
|
||
|
||
if (preg_match_all($this->pattern, $text, $matches, PREG_OFFSET_CAPTURE) === false) {
|
||
return null;
|
||
}
|
||
foreach ($matches[0] as [$match, $at]) {
|
||
$hits[] = [$offset + (int) $at, strlen((string) $match), (string) $match];
|
||
}
|
||
}
|
||
|
||
return $hits;
|
||
}
|
||
|
||
/**
|
||
* The value with every hit replaced, the byte ranges of the inserted text in it, and the
|
||
* non-text tokens it must have: the original ones with the inserted text's own at each hit.
|
||
*
|
||
* @param list<array{string, string, int}> $tokens
|
||
* @param list<array{int, int, string}> $hits
|
||
* @param list<array{string, string, int}> $inserted
|
||
* @return array{string, list<array{int, int}>, list<array{string, string}>}
|
||
*/
|
||
private static function splice(
|
||
string $value,
|
||
array $tokens,
|
||
array $hits,
|
||
string $replacement,
|
||
array $inserted
|
||
): array {
|
||
$own = HtmlTokens::structure($inserted);
|
||
$replaced = '';
|
||
$ranges = [];
|
||
$expected = [];
|
||
$position = 0;
|
||
$next = 0;
|
||
foreach ($tokens as [$type, $text, $offset]) {
|
||
if ($type !== 'text') {
|
||
$expected[] = [$type, $text];
|
||
continue;
|
||
}
|
||
|
||
$end = $offset + strlen($text);
|
||
while (isset($hits[$next]) && $hits[$next][0] < $end) {
|
||
[$at, $length] = $hits[$next];
|
||
$replaced .= substr($value, $position, $at - $position);
|
||
$ranges[] = [strlen($replaced), strlen($replaced) + strlen($replacement)];
|
||
$replaced .= $replacement;
|
||
array_push($expected, ...$own);
|
||
$position = $at + $length;
|
||
$next++;
|
||
}
|
||
}
|
||
$replaced .= substr($value, $position);
|
||
|
||
return [$replaced, $ranges, $expected];
|
||
}
|
||
|
||
/**
|
||
* Rulings S8/S45: the changed value is safe when (1) every reference marker of the original
|
||
* is a whole reference and none is added, (2) its full non-text tokens (tags byte for byte,
|
||
* comments, entities, references, raw bodies) are exactly the expected ones, and (3) an
|
||
* independent scan (HtmlContext) puts every replaced hit of the original and both ends of
|
||
* every inserted text in text context.
|
||
*
|
||
* @param list<array{string, string, int}> $tokens
|
||
* @param list<array{int, int, string}> $hits
|
||
* @param list<array{int, int}> $insertedRanges
|
||
* @param list<array{string, string}> $expected
|
||
*/
|
||
private static function safe(
|
||
string $value,
|
||
array $tokens,
|
||
array $hits,
|
||
string $replaced,
|
||
array $insertedRanges,
|
||
array $expected
|
||
): bool {
|
||
$markers = substr_count($value, VariableRef::MARKER);
|
||
$references = count(array_filter(
|
||
$tokens,
|
||
static fn (array $token): bool => $token[0] === 'token' && str_starts_with($token[1], VariableRef::MARKER)
|
||
));
|
||
$after = HtmlTokens::tokenize($replaced);
|
||
$sameMarkers = $markers === $references && substr_count($replaced, VariableRef::MARKER) === $markers;
|
||
if (! $sameMarkers || $after === null || HtmlTokens::structure($after) !== $expected) {
|
||
return false;
|
||
}
|
||
|
||
$before = HtmlContext::markupSpans($value);
|
||
foreach ($hits as [$at, $length]) {
|
||
if (! HtmlContext::isText($before, $at, $at + $length)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
$spans = HtmlContext::markupSpans($replaced);
|
||
foreach ($insertedRanges as [$start, $end]) {
|
||
if (! HtmlContext::isBoundary($spans, $start) || ! HtmlContext::isBoundary($spans, $end)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/** @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];
|
||
}
|
||
|
||
/**
|
||
* $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 = ControlChars::strip($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) ? '…' : '');
|
||
}
|
||
}
|