fix: Task 8 review round 1 - quote-aware tokenizer, context check, skipped global nodes (S45)
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:
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An independent second scan of an HTML fragment (ruling S45): a small state machine after the
|
||||||
|
* HTML tokenizer rules, written separately from TextReplacer's regex tokenizer so a mistake in
|
||||||
|
* one is caught by the other. It lists the byte spans that are NOT text: tags (with quoted
|
||||||
|
* attribute values, where ">" does not end the tag), comments, bogus comments and the bodies of
|
||||||
|
* raw-text elements (script, style, …). Where it is unsure it errs towards "not text": an
|
||||||
|
* unterminated construct runs to the end, a script body containing "<!--" (the escaped/double-
|
||||||
|
* escaped script states) makes the rest of the string markup. Linear time, pure PHP.
|
||||||
|
*/
|
||||||
|
final class HtmlContext
|
||||||
|
{
|
||||||
|
/** Elements whose body the browser does not parse as text/markup. */
|
||||||
|
public const RAW = ['script', 'style', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript', 'plaintext'];
|
||||||
|
|
||||||
|
/** @return list<array{int, int}> [start, end) byte spans that are markup or raw bodies. */
|
||||||
|
public static function markupSpans(string $html): array
|
||||||
|
{
|
||||||
|
$spans = [];
|
||||||
|
$length = strlen($html);
|
||||||
|
$i = 0;
|
||||||
|
while ($i < $length) {
|
||||||
|
$lt = strpos($html, '<', $i);
|
||||||
|
if ($lt === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = self::markupEnd($html, $lt);
|
||||||
|
if ($end === $lt) {
|
||||||
|
$i = $lt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$end ??= $length;
|
||||||
|
$name = self::rawName($html, $lt, $end);
|
||||||
|
if ($name !== null && $end < $length) {
|
||||||
|
$end = self::rawEnd($html, $name, $end);
|
||||||
|
}
|
||||||
|
$spans[] = [$lt, $end];
|
||||||
|
$i = $end;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether [start, end) overlaps no markup span. */
|
||||||
|
public static function isText(array $spans, int $start, int $end): bool
|
||||||
|
{
|
||||||
|
foreach ($spans as [$from, $to]) {
|
||||||
|
if ($start < $to && $from < $end) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether offset $position lies between spans (not strictly inside one). */
|
||||||
|
public static function isBoundary(array $spans, int $position): bool
|
||||||
|
{
|
||||||
|
foreach ($spans as [$from, $to]) {
|
||||||
|
if ($from < $position && $position < $to) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End (exclusive) of the markup starting with the "<" at $lt; $lt itself when that "<" is
|
||||||
|
* plain text, null when the markup is not terminated.
|
||||||
|
*/
|
||||||
|
private static function markupEnd(string $html, int $lt): ?int
|
||||||
|
{
|
||||||
|
$next = $html[$lt + 1] ?? '';
|
||||||
|
if (substr($html, $lt, 4) === '<!--') {
|
||||||
|
return self::commentEnd($html, $lt + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($next === '!' || $next === '?') {
|
||||||
|
return self::after($html, '>', $lt + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($next === '/') {
|
||||||
|
$third = $html[$lt + 2] ?? '';
|
||||||
|
if (ctype_alpha($third)) {
|
||||||
|
return self::tagEnd($html, $lt + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $third === '>' ? $lt + 3 : self::after($html, '>', $lt + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctype_alpha($next) ? self::tagEnd($html, $lt + 2) : $lt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After "<!--": "-->" or "--!>", and the abrupt "<!-->" / "<!--->". */
|
||||||
|
private static function commentEnd(string $html, int $from): ?int
|
||||||
|
{
|
||||||
|
if (($html[$from] ?? '') === '>') {
|
||||||
|
return $from + 1;
|
||||||
|
}
|
||||||
|
if (substr($html, $from, 2) === '->') {
|
||||||
|
return $from + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$close = strpos($html, '-->', $from);
|
||||||
|
$bang = strpos($html, '--!>', $from);
|
||||||
|
if ($bang !== false && ($close === false || $bang < $close)) {
|
||||||
|
return $bang + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $close === false ? null : $close + 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inside a tag: ">" ends it unless it lies in an attribute value quoted right after "="
|
||||||
|
* (whitespace allowed around "=").
|
||||||
|
*/
|
||||||
|
private static function tagEnd(string $html, int $from): ?int
|
||||||
|
{
|
||||||
|
$length = strlen($html);
|
||||||
|
for ($p = $from; $p < $length; $p++) {
|
||||||
|
$char = $html[$p];
|
||||||
|
if ($char === '>') {
|
||||||
|
return $p + 1;
|
||||||
|
}
|
||||||
|
if ($char !== '=') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$p++;
|
||||||
|
while ($p < $length && strpos(" \t\n\r\f", $html[$p]) !== false) {
|
||||||
|
$p++;
|
||||||
|
}
|
||||||
|
$quote = $html[$p] ?? '';
|
||||||
|
if ($quote === '"' || $quote === "'") {
|
||||||
|
$close = strpos($html, $quote, $p + 1);
|
||||||
|
if ($close === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$p = $close;
|
||||||
|
} else {
|
||||||
|
$p--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function after(string $html, string $needle, int $from): ?int
|
||||||
|
{
|
||||||
|
$found = strpos($html, $needle, $from);
|
||||||
|
|
||||||
|
return $found === false ? null : $found + strlen($needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name of a raw-text element opened by the start tag [$lt, $end), else null. */
|
||||||
|
private static function rawName(string $html, int $lt, int $end): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/^<([A-Za-z][A-Za-z0-9]*)/', substr($html, $lt, min(20, $end - $lt)), $match) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = strtolower($match[1]);
|
||||||
|
|
||||||
|
return in_array($name, self::RAW, true) ? $name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the raw body after $from ends: at its "</name" followed by whitespace, "/" or ">".
|
||||||
|
* plaintext never ends; a script body with "<!--" is treated as running to the end.
|
||||||
|
*/
|
||||||
|
private static function rawEnd(string $html, string $name, int $from): int
|
||||||
|
{
|
||||||
|
$length = strlen($html);
|
||||||
|
if ($name === 'plaintext') {
|
||||||
|
return $length;
|
||||||
|
}
|
||||||
|
|
||||||
|
$p = $from;
|
||||||
|
while (($found = stripos($html, '</' . $name, $p)) !== false) {
|
||||||
|
$follow = $html[$found + 2 + strlen($name)] ?? '';
|
||||||
|
if ($follow !== '' && strpos(" \t\n\r\f/>", $follow) !== false) {
|
||||||
|
if ($name === 'script' && str_contains(substr($html, $from, $found - $from), '<!--')) {
|
||||||
|
return $length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
$p = $found + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Compose;
|
|||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces visible text in the attributes of one block (lit/divi-replace-text, spec §5.7, D10):
|
* Replaces visible text in the attributes of one block (lit/divi-replace-text, spec §5.7, D10):
|
||||||
@@ -14,25 +15,32 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
|||||||
* title, label, alt, titleText) – never in URLs, IDs, styles or $variable(…)$ references.
|
* title, label, alt, titleText) – never in URLs, IDs, styles or $variable(…)$ references.
|
||||||
* Pure PHP.
|
* Pure PHP.
|
||||||
*
|
*
|
||||||
* Ruling S8: each string is split into tags, entities and references (kept byte for byte) and
|
* Rulings S8/S45: each string is tokenized (quote-aware tags, comments, entities, references,
|
||||||
* plain text; only plain text outside <script>/<style> bodies is searched. The inserted text is
|
* raw-text bodies such as <script>); only text tokens are searched. A string with an
|
||||||
* the replacement as the $clean callback returns it for that place (AttrWriter::cleanValue() in
|
* unterminated tag, quote, comment or reference and a hit is refused. The inserted text is the
|
||||||
* the ability: sanitized by the field kind, markers rejected – rulings S2/S28). A string with no
|
* replacement as the $clean callback returns it for that place (AttrWriter::cleanValue() in the
|
||||||
* hit is returned as it is and never cleaned (Task 1 forward risk: stored references outside
|
* ability: sanitized by the field kind, markers rejected – rulings S2/S28). A string with no
|
||||||
* the allow list must survive). A changed string must have exactly the tags, entities and
|
* effective hit is returned as it is and never cleaned (Task 1 forward risk). A changed string
|
||||||
* references of the original plus those of the inserted text – otherwise the replacement glued
|
* must keep every non-text token of the original byte for byte, gain only the inserted text's
|
||||||
* something new together across a boundary (a lone "<" closed into a tag, "&" + ";", a
|
* own, add no reference marker, and an independent scan (HtmlContext) must see each replaced
|
||||||
* partial reference closed by ")$") and the whole call is refused (InvalidArgumentException).
|
* 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
|
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\(.+?\)\$)/';
|
* Rulings S8/S45: comments, tags (quote-aware: a ">" inside a quoted attribute value does not
|
||||||
|
* end the tag; an unquoted "<" does, so an unterminated tag cannot make PCRE rescan the
|
||||||
|
* rest from every "<"), entities and references are tokens, the rest is text. Possessive
|
||||||
|
* quantifiers keep it linear.
|
||||||
|
*/
|
||||||
|
public const SPLIT = '/((?s:<!--.*?-->)|<(?:[^<>"\']++|"[^"]*+"|\'[^\']*+\')*+>|&[#A-Za-z0-9]++;'
|
||||||
|
. '|\$variable\(.+?\)\$)/';
|
||||||
|
|
||||||
private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'titleText'];
|
private const TEXT_KEYS = ['text', 'title', 'label', 'alt', 'titleText'];
|
||||||
|
|
||||||
/** Elements whose body is code, not text. */
|
/** Raw-text elements (HtmlContext::RAW): their body is code, never text. */
|
||||||
private const RAW_OPEN = '/^<(script|style)\b/i';
|
private const RAW_OPEN = '/^<(script|style|xmp|iframe|noembed|noframes|noscript|plaintext)\b/i';
|
||||||
|
|
||||||
private const CONTEXT = 30;
|
private const CONTEXT = 30;
|
||||||
|
|
||||||
@@ -64,7 +72,9 @@ final class TextReplacer
|
|||||||
$problems = [];
|
$problems = [];
|
||||||
if (! mb_check_encoding($search, 'UTF-8') || mb_strlen($search) < 2) {
|
if (! mb_check_encoding($search, 'UTF-8') || mb_strlen($search) < 2) {
|
||||||
$problems['search'] = 'Suchtext: mindestens 2 Zeichen gültiges UTF-8.';
|
$problems['search'] = 'Suchtext: mindestens 2 Zeichen gültiges UTF-8.';
|
||||||
} elseif (VariableRef::contains(VariableRef::withoutControls($search))) {
|
} elseif (VariableRef::withoutControls($search) !== $search) {
|
||||||
|
$problems['search'] = 'Suchtext darf keine Steuerzeichen enthalten.';
|
||||||
|
} elseif (VariableRef::contains($search)) {
|
||||||
$problems['search'] = 'Suchtext darf keinen $variable(…)$-Verweis enthalten; dynamische Inhalte werden '
|
$problems['search'] = 'Suchtext darf keinen $variable(…)$-Verweis enthalten; dynamische Inhalte werden '
|
||||||
. 'nie ersetzt.';
|
. 'nie ersetzt.';
|
||||||
}
|
}
|
||||||
@@ -79,6 +89,49 @@ final class TextReplacer
|
|||||||
return $problems;
|
return $problems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 forms(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 (e.g. of a global module this tool does not edit) contains the search text. */
|
||||||
|
public function mentions(string $markup): bool
|
||||||
|
{
|
||||||
|
foreach (self::forms($this->search, $this->caseSensitive) as $form) {
|
||||||
|
$found = $this->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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $attrs
|
* @param array<string, mixed> $attrs
|
||||||
* @param callable(string $path, ?string $key, string $replacement): ?string $clean
|
* @param callable(string $path, ?string $key, string $replacement): ?string $clean
|
||||||
@@ -145,48 +198,45 @@ final class TextReplacer
|
|||||||
int &$count,
|
int &$count,
|
||||||
array &$samples
|
array &$samples
|
||||||
): string {
|
): 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);
|
$place = $path . ($key === null ? '' : '.' . $key);
|
||||||
|
$tokens = self::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);
|
$replacement = $this->replacement($path, $key, $clean);
|
||||||
if ($replacement === null) {
|
if ($replacement === null) {
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
$expected = [];
|
// Only hits that change something (search == replacement, or a cleaned replacement equal
|
||||||
$insert = static fn (): string => $replacement;
|
// to the match, change nothing and are no hits).
|
||||||
$replacementDelimiters = self::delimiters($replacement);
|
$hits = array_values(array_filter($hits, static fn (array $hit): bool => $hit[2] !== $replacement));
|
||||||
foreach ($parts as $i => $part) {
|
if ($hits === []) {
|
||||||
if ($i % 2 === 1) {
|
return $value;
|
||||||
$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);
|
$inserted = self::tokenize($replacement);
|
||||||
// Every marker of the original must be a whole reference (else the string holds a partial
|
if ($inserted === null) {
|
||||||
// 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);
|
throw self::refused($place);
|
||||||
}
|
}
|
||||||
|
|
||||||
$count += array_sum($hits);
|
[$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) {
|
if (count($samples) < 5) {
|
||||||
$samples[] = [
|
$samples[] = [
|
||||||
'path' => $place,
|
'path' => $place,
|
||||||
@@ -199,40 +249,222 @@ final class TextReplacer
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hits per plain part (index => count); plain text inside <script>/<style> has none. Null
|
* Hits in text tokens: [byte offset in the value, byte length, matched text]. Null when a
|
||||||
* when a part cannot be searched (invalid UTF-8): the string stays as it is.
|
* token cannot be searched (invalid UTF-8): the string stays as it is.
|
||||||
*
|
*
|
||||||
* @param list<string> $parts
|
* @param list<array{string, string, int}> $tokens
|
||||||
* @return array<int, int>|null
|
* @return list<array{int, int, string}>|null
|
||||||
*/
|
*/
|
||||||
private function hits(array $parts): ?array
|
private function hits(array $tokens): ?array
|
||||||
{
|
{
|
||||||
$hits = [];
|
$hits = [];
|
||||||
$raw = null;
|
foreach ($tokens as [$type, $text, $offset]) {
|
||||||
foreach ($parts as $i => $part) {
|
if ($type !== 'text') {
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($raw !== null || $part === '') {
|
if (preg_match_all($this->pattern, $text, $matches, PREG_OFFSET_CAPTURE) === false) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$found = preg_match_all($this->pattern, $part);
|
|
||||||
if ($found === false) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$hits[$i] = $found;
|
foreach ($matches[0] as [$match, $at]) {
|
||||||
|
$hits[] = [$offset + (int) $at, strlen((string) $match), (string) $match];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $hits;
|
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 = self::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 = self::tokenize($replaced);
|
||||||
|
$sameMarkers = $markers === $references && substr_count($replaced, VariableRef::MARKER) === $markers;
|
||||||
|
if (! $sameMarkers || $after === null || self::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 list<array{string, string, int}> $tokens
|
||||||
|
* @return list<array{string, string}> The non-text tokens (type and bytes), in order.
|
||||||
|
*/
|
||||||
|
private static function structure(array $tokens): array
|
||||||
|
{
|
||||||
|
$structure = [];
|
||||||
|
foreach ($tokens as [$type, $text]) {
|
||||||
|
if ($type !== 'text') {
|
||||||
|
$structure[] = [$type, $text];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $structure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S45 tokenizer: [type, bytes, offset] with type "token" (tag – quote-aware, ">" in a
|
||||||
|
* quoted value does not end it –, comment, entity, reference), "raw" (body of script, style
|
||||||
|
* and the other raw-text elements) or "text". Null when the value has an unterminated tag,
|
||||||
|
* quote, comment or reference – then nobody can say where its text is.
|
||||||
|
*
|
||||||
|
* @return list<array{string, string, int}>|null
|
||||||
|
*/
|
||||||
|
private static function tokenize(string $value): ?array
|
||||||
|
{
|
||||||
|
if (self::unterminated($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = preg_split(self::SPLIT, $value, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_OFFSET_CAPTURE);
|
||||||
|
if (! is_array($parts)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = [];
|
||||||
|
$raw = null;
|
||||||
|
foreach ($parts as $i => [$text, $offset]) {
|
||||||
|
$text = (string) $text;
|
||||||
|
if ($i % 2 === 1) {
|
||||||
|
if ($raw === null && preg_match(self::RAW_OPEN, $text, $match) === 1) {
|
||||||
|
$raw = strtolower($match[1]);
|
||||||
|
} elseif ($raw !== null && self::closes($raw, $text)) {
|
||||||
|
$raw = null;
|
||||||
|
}
|
||||||
|
$tokens[] = ['token', $text, (int) $offset];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($text === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A "<" that starts markup but matched no token: an unterminated tag or quote.
|
||||||
|
if ($raw === null && preg_match('#<[A-Za-z/!?]#', $text) === 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$tokens[] = [$raw === null ? 'text' : 'raw', $text, (int) $offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether token $text closes raw-text element $raw ("</script-x>" does not; plaintext never ends). */
|
||||||
|
private static function closes(string $raw, string $text): bool
|
||||||
|
{
|
||||||
|
return $raw !== 'plaintext' && preg_match('#^</' . $raw . '[\s/>]#i', $text) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap checks that keep the tokenizer linear: a comment opened after the last "-->", or a
|
||||||
|
* reference marker after the last ")$" of its line, can never be closed – PCRE would retry
|
||||||
|
* it from every later opener.
|
||||||
|
*/
|
||||||
|
private static function unterminated(string $value): bool
|
||||||
|
{
|
||||||
|
$open = strrpos($value, '<!--');
|
||||||
|
if ($open !== false) {
|
||||||
|
$close = strrpos($value, '-->');
|
||||||
|
if ($close === false || $close < $open + 2) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_contains($value, VariableRef::MARKER)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (explode("\n", $value) as $line) {
|
||||||
|
$marker = strrpos($line, VariableRef::MARKER);
|
||||||
|
$end = $marker === false ? false : strrpos($line, ')$');
|
||||||
|
if ($marker !== false && ($end === false || $end < $marker)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** @param callable(string, ?string, string): ?string $clean */
|
/** @param callable(string, ?string, string): ?string $clean */
|
||||||
private function replacement(string $path, ?string $key, callable $clean): ?string
|
private function replacement(string $path, ?string $key, callable $clean): ?string
|
||||||
{
|
{
|
||||||
@@ -248,29 +480,6 @@ final class TextReplacer
|
|||||||
return $this->cleaned[$place];
|
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
|
* $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.
|
* counts them, an unclosed one up to the end) and %XX octets removed until nothing changes.
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
|
|
||||||
private const SAMPLES = 5;
|
private const SAMPLES = 5;
|
||||||
|
|
||||||
|
public const GLOBAL_REASON = 'globales Modul – in der Bibliothek bearbeiten';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ContentSchema $schema,
|
private ContentSchema $schema,
|
||||||
private DiviPageLoader $loader,
|
private DiviPageLoader $loader,
|
||||||
@@ -63,7 +65,9 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
. 'dynamischen Inhalte) auf den Divi-Seiten eines Inhaltstyps. Standard ist dry_run: true – liefert die '
|
. 'dynamischen Inhalte) auf den Divi-Seiten eines Inhaltstyps. Standard ist dry_run: true – liefert die '
|
||||||
. 'Treffer je Seite mit tree_rev. Zum Ersetzen dry_run: false, targets = [{post_id, tree_rev}] aus dem '
|
. 'Treffer je Seite mit tree_rev. Zum Ersetzen dry_run: false, targets = [{post_id, tree_rev}] aus dem '
|
||||||
. 'Probelauf und confirm: true nach Rückfrage beim Nutzer. Jede Seite wird einzeln gespeichert und erhält '
|
. 'Probelauf und confirm: true nach Rückfrage beim Nutzer. Jede Seite wird einzeln gespeichert und erhält '
|
||||||
. 'eine eigene Revision; results meldet jede Seite.';
|
. 'eine eigene Revision; results meldet jede Seite. In reinen Textfeldern (Titel, Button-Texte) werden '
|
||||||
|
. 'Leerzeichen am Anfang und Ende der Ersetzung entfernt. Globale Module werden nicht geändert, sondern '
|
||||||
|
. 'in skipped_nodes genannt (in der Divi-Bibliothek bearbeiten).';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function inputSchema(): array
|
public function inputSchema(): array
|
||||||
@@ -104,6 +108,7 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
'dry_run' => ['type' => 'boolean'],
|
'dry_run' => ['type' => 'boolean'],
|
||||||
'matches' => ['type' => 'array', 'items' => ['type' => 'object']],
|
'matches' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||||
'skipped' => ['type' => 'array', 'items' => ['type' => 'object']],
|
'skipped' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||||
|
'skipped_nodes' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||||
'next_cursor' => ['type' => ['string', 'null']],
|
'next_cursor' => ['type' => ['string', 'null']],
|
||||||
'results' => ['type' => 'array', 'items' => ['type' => 'object']],
|
'results' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||||
], ['dry_run']);
|
], ['dry_run']);
|
||||||
@@ -169,6 +174,7 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
$page = Cursor::page($candidates, $limit, $offset);
|
$page = Cursor::page($candidates, $limit, $offset);
|
||||||
$matches = [];
|
$matches = [];
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
|
$skippedNodes = [];
|
||||||
$total = 0;
|
$total = 0;
|
||||||
foreach ($page['items'] as $postId) {
|
foreach ($page['items'] as $postId) {
|
||||||
$postId = (int) $postId;
|
$postId = (int) $postId;
|
||||||
@@ -190,7 +196,10 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($found['count'] > 0) {
|
foreach ($found['skipped_nodes'] as $node) {
|
||||||
|
$skippedNodes[] = ['post_id' => $postId] + $node;
|
||||||
|
}
|
||||||
|
if ($found['count'] > 0 || $found['skipped_nodes'] !== []) {
|
||||||
$total += $found['count'];
|
$total += $found['count'];
|
||||||
$matches[] = [
|
$matches[] = [
|
||||||
'post_id' => $postId,
|
'post_id' => $postId,
|
||||||
@@ -199,19 +208,25 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
'tree_rev' => $divi->treeRev(),
|
'tree_rev' => $divi->treeRev(),
|
||||||
'count' => $found['count'],
|
'count' => $found['count'],
|
||||||
'samples' => $found['samples'],
|
'samples' => $found['samples'],
|
||||||
|
'skipped_nodes' => count($found['skipped_nodes']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ToolResult::ok(sprintf(
|
return ToolResult::ok(sprintf(
|
||||||
'Probelauf: %d Treffer auf %d Seiten – nichts geändert.%s',
|
'Probelauf: %d Treffer auf %d Seiten – nichts geändert.%s%s',
|
||||||
$total,
|
$total,
|
||||||
count($matches),
|
count($matches),
|
||||||
|
$skippedNodes === [] ? '' : sprintf(
|
||||||
|
' %d Knoten (globale Module) werden nicht geändert, siehe skipped_nodes.',
|
||||||
|
count($skippedNodes)
|
||||||
|
),
|
||||||
$page['next_cursor'] === null ? '' : ' Weitere Seiten mit next_cursor prüfen.'
|
$page['next_cursor'] === null ? '' : ' Weitere Seiten mit next_cursor prüfen.'
|
||||||
))
|
))
|
||||||
->with('dry_run', true)
|
->with('dry_run', true)
|
||||||
->with('matches', $matches)
|
->with('matches', $matches)
|
||||||
->with('skipped', $skipped)
|
->with('skipped', $skipped)
|
||||||
|
->with('skipped_nodes', $skippedNodes)
|
||||||
->with('next_cursor', $page['next_cursor'])
|
->with('next_cursor', $page['next_cursor'])
|
||||||
->toArray();
|
->toArray();
|
||||||
}
|
}
|
||||||
@@ -263,7 +278,12 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
count($results),
|
count($results),
|
||||||
array_sum(array_column($replaced, 'count'))
|
array_sum(array_column($replaced, 'count'))
|
||||||
));
|
));
|
||||||
|
$skippedNodes = [];
|
||||||
foreach ($results as $entry) {
|
foreach ($results as $entry) {
|
||||||
|
foreach ($entry['skipped_nodes'] ?? [] as $node) {
|
||||||
|
$skippedNodes[] = ['post_id' => $entry['post_id']] + $node;
|
||||||
|
$tool->warning(sprintf('Eintrag %d, Knoten %s: %s', $entry['post_id'], $node['id'], $node['reason']));
|
||||||
|
}
|
||||||
if (! $entry['ok']) {
|
if (! $entry['ok']) {
|
||||||
$tool->warning(sprintf('Eintrag %d: %s', $entry['post_id'], $entry['error']));
|
$tool->warning(sprintf('Eintrag %d: %s', $entry['post_id'], $entry['error']));
|
||||||
continue;
|
continue;
|
||||||
@@ -277,7 +297,10 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $tool->with('dry_run', false)->with('results', $results)->toArray();
|
return $tool->with('dry_run', false)
|
||||||
|
->with('results', $results)
|
||||||
|
->with('skipped_nodes', $skippedNodes)
|
||||||
|
->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -330,6 +353,7 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
return $base + ['ok' => false, 'error' => $e->getMessage() . ' Diese Seite wurde nicht geändert.'];
|
return $base + ['ok' => false, 'error' => $e->getMessage() . ' Diese Seite wurde nicht geändert.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$base['skipped_nodes'] = $found['skipped_nodes'];
|
||||||
if ($found['count'] === 0) {
|
if ($found['count'] === 0) {
|
||||||
return $base + ['ok' => true, 'count' => 0, 'tree_rev' => $page->treeRev()];
|
return $base + ['ok' => true, 'count' => 0, 'tree_rev' => $page->treeRev()];
|
||||||
}
|
}
|
||||||
@@ -357,7 +381,7 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
* the entry's AttrWriter (field kind; markers rejected); URL and code fields are skipped.
|
* the entry's AttrWriter (field kind; markers rejected); URL and code fields are skipped.
|
||||||
*
|
*
|
||||||
* @return array{count: int, samples: list<array<string, string>>,
|
* @return array{count: int, samples: list<array<string, string>>,
|
||||||
* blocks: list<array{Block, array<string, mixed>}>}
|
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
||||||
* @throws InvalidArgumentException When a place would be changed unsafely – the page fails.
|
* @throws InvalidArgumentException When a place would be changed unsafely – the page fails.
|
||||||
*/
|
*/
|
||||||
private function replace(DiviPage $page, TextReplacer $replacer): array
|
private function replace(DiviPage $page, TextReplacer $replacer): array
|
||||||
@@ -366,9 +390,16 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
$count = 0;
|
$count = 0;
|
||||||
$samples = [];
|
$samples = [];
|
||||||
$blocks = [];
|
$blocks = [];
|
||||||
|
$skipped = [];
|
||||||
foreach ($page->index->entries() as $entry) {
|
foreach ($page->index->entries() as $entry) {
|
||||||
$block = $entry['block'];
|
$block = $entry['block'];
|
||||||
if (! $block->isDivi() || $block->isOpaque()) {
|
if ($block->isDivi() && $block->isOpaque()) {
|
||||||
|
if ($replacer->mentions(Serializer::block($block))) {
|
||||||
|
$skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! $block->isDivi()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,30 +428,22 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks];
|
return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks, 'skipped_nodes' => $skipped];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IDs of Divi entries whose content contains the search text as stored: raw, as
|
* IDs of Divi entries whose content contains the search text in one of its stored forms
|
||||||
* serialize_block_attributes() escapes it (<, >, &, " and --), and additionally with \uXXXX
|
* (TextReplacer::forms()). The exact match happens in the tree; this is only a prefilter.
|
||||||
* for non-ASCII (WordPress' PHP serializer). Case-insensitive adds the lower and upper case
|
|
||||||
* forms (LIKE is case-insensitive for letters, not for the hex digits of \u escapes). The
|
|
||||||
* exact match happens in the tree; this is only a prefilter.
|
|
||||||
*
|
*
|
||||||
* @return list<int>
|
* @return list<int>
|
||||||
*/
|
*/
|
||||||
private function candidates(string $postType, string $search, bool $caseSensitive, int $limit, int $offset): array
|
private function candidates(string $postType, string $search, bool $caseSensitive, int $limit, int $offset): array
|
||||||
{
|
{
|
||||||
$db = $this->db();
|
$db = $this->db();
|
||||||
$variants = $caseSensitive ? [$search] : [$search, mb_strtolower($search), mb_strtoupper($search)];
|
$forms = TextReplacer::forms($search, $caseSensitive);
|
||||||
$forms = [];
|
|
||||||
foreach ($variants as $variant) {
|
|
||||||
$escaped = substr(Serializer::attributes(['s' => $variant]), 6, -2);
|
|
||||||
array_push($forms, $variant, $escaped, self::unicodeEscaped($escaped));
|
|
||||||
}
|
|
||||||
$likes = array_map(
|
$likes = array_map(
|
||||||
static fn (string $form): string => '%' . $db->esc_like($form) . '%',
|
static fn (string $form): string => '%' . $db->esc_like($form) . '%',
|
||||||
array_values(array_unique($forms))
|
$forms
|
||||||
);
|
);
|
||||||
|
|
||||||
$sql = "SELECT ID FROM {$db->posts} WHERE post_type = %s"
|
$sql = "SELECT ID FROM {$db->posts} WHERE post_type = %s"
|
||||||
@@ -443,14 +466,12 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
return array_map('intval', is_array($ids) ? $ids : []);
|
return array_map('intval', is_array($ids) ? $ids : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Non-ASCII characters as JSON \uXXXX escapes (UTF-16, lower-case hex like json_encode()). */
|
/** Why an opaque node is not changed (global modules are edited in the Divi library, Etappe 3). */
|
||||||
private static function unicodeEscaped(string $value): string
|
private static function opaqueReason(Block $block): string
|
||||||
{
|
{
|
||||||
return (string) preg_replace_callback('/[^\x00-\x7F]/u', static function (array $match): string {
|
return $block->isGlobal()
|
||||||
$units = unpack('n*', (string) mb_convert_encoding($match[0], 'UTF-16BE', 'UTF-8'));
|
? self::GLOBAL_REASON
|
||||||
|
: 'Knoten nicht lesbar (Attribute kein JSON-Objekt) – im Divi-Builder bearbeiten';
|
||||||
return implode('', array_map(static fn (int $unit): string => sprintf('\\u%04x', $unit), (array) $units));
|
|
||||||
}, $value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The client message of an error (its JSON "message"), without the JSON around it. */
|
/** The client message of an error (its JSON "message"), without the JSON around it. */
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\HtmlContext;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class HtmlContextTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* HTML with the probe marked as [[…]]: whether the browser reads it as text.
|
||||||
|
*
|
||||||
|
* @return array<string, array{string, bool}>
|
||||||
|
*/
|
||||||
|
public static function probes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'plain text' => ['a [[B]] c', true],
|
||||||
|
'between tags' => ['<p>[[B]]</p>', true],
|
||||||
|
'double-quoted ">" keeps the tag' => ['<a title="A > [[B]]" href="/x">T</a>', false],
|
||||||
|
'single-quoted ">" keeps the tag' => ["<img alt='A > [[B]]' src=x>", false],
|
||||||
|
'quoted value after spaces around =' => ['<a title = "A > [[B]]">', false],
|
||||||
|
'after a quoted ">"' => ['<a title="A > B">[[T]]</a>', true],
|
||||||
|
'quote in an attribute name is no value' => ['<a b"c>[[T]]', true],
|
||||||
|
'unquoted value' => ['<a href=[[x]]>', false],
|
||||||
|
'end tag' => ['</a [[x]]>', false],
|
||||||
|
'comment' => ['<!-- a > [[B]] -->', false],
|
||||||
|
'after a comment' => ['<!-- a -->[[B]]', true],
|
||||||
|
'comment closed by --!>' => ['<!-- a --!>[[B]]', true],
|
||||||
|
'abrupt comment' => ['<!-->[[B]]', true],
|
||||||
|
'unterminated comment' => ['<!-- a [[B]]', false],
|
||||||
|
'bogus comment' => ['<!DOCTYPE [[x]]>', false],
|
||||||
|
'processing instruction' => ['<?x [[B]] >', false],
|
||||||
|
'unterminated tag' => ['<a title="x [[B]]', false],
|
||||||
|
'unterminated quote' => ['<a title="x>[[B]]</a>', false],
|
||||||
|
'lone "<"' => ['a < [[B]]', true],
|
||||||
|
'"<" + digit' => ['a <3 [[B]]', true],
|
||||||
|
'script body' => ['<script>var x = "[[B]]";</script>', false],
|
||||||
|
'after the script' => ['<script>x</script>[[B]]', true],
|
||||||
|
'script end needs a delimiter' => ['<script>x</script-x>[[B]]</script>', false],
|
||||||
|
'script end in upper case' => ['<SCRIPT>x</Script >[[B]]', true],
|
||||||
|
'double-escaped script' => ['<script><!--<script></script>[[B]]</script>', false],
|
||||||
|
'style body' => ['<style>.a{} [[B]]</style>', false],
|
||||||
|
'iframe body' => ['<iframe>[[B]]</iframe>', false],
|
||||||
|
'plaintext' => ['<plaintext></plaintext>[[B]]', false],
|
||||||
|
'textarea is text' => ['<textarea>[[B]]</textarea>', true],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider probes */
|
||||||
|
public function testKnowsWhatIsText(string $marked, bool $text): void
|
||||||
|
{
|
||||||
|
$start = (int) strpos($marked, '[[');
|
||||||
|
$html = str_replace(['[[', ']]'], '', $marked);
|
||||||
|
$end = (int) strpos($marked, ']]') - 2;
|
||||||
|
|
||||||
|
$spans = HtmlContext::markupSpans($html);
|
||||||
|
|
||||||
|
self::assertSame($text, HtmlContext::isText($spans, $start, $end), $marked);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBoundaries(): void
|
||||||
|
{
|
||||||
|
$spans = HtmlContext::markupSpans('<p>ab</p>');
|
||||||
|
|
||||||
|
self::assertSame([[0, 3], [5, 9]], $spans);
|
||||||
|
self::assertTrue(HtmlContext::isBoundary($spans, 3));
|
||||||
|
self::assertTrue(HtmlContext::isBoundary($spans, 5));
|
||||||
|
self::assertFalse(HtmlContext::isBoundary($spans, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLongInputsStayLinear(): void
|
||||||
|
{
|
||||||
|
$start = microtime(true);
|
||||||
|
foreach (['<', '<a ', '<a title="', '<!--', '<script>', '</'] as $unit) {
|
||||||
|
HtmlContext::markupSpans(str_repeat($unit, 100000));
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertLessThan(2.0, microtime(true) - $start);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,6 +195,134 @@ final class TextReplacerTest extends TestCase
|
|||||||
(new TextReplacer($search, $replace))->apply(self::text($value), self::same());
|
(new TextReplacer($search, $replace))->apply(self::text($value), self::same());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S45: a ">" inside a quoted attribute value does not end the tag, so nothing after it
|
||||||
|
* in the tag is text. With the old "<[^>]*>" split these tests fail (the rest of the tag
|
||||||
|
* was searched), which is how a reverted tokenizer shows.
|
||||||
|
*/
|
||||||
|
public function testQuotedGreaterThanDoesNotEndTheTag(): void
|
||||||
|
{
|
||||||
|
$double = '<a title="A > Kontakt" href="/kontakt">Kontakt</a> und Kontakt';
|
||||||
|
$single = "<img alt='x > Kontakt' src='/kontakt.jpg'> Kontakt";
|
||||||
|
|
||||||
|
$result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($double), self::same());
|
||||||
|
$image = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($single), self::same());
|
||||||
|
|
||||||
|
self::assertSame('<a title="A > Kontakt" href="/kontakt">Anfrage</a> und Anfrage', self::valueOf($result['attrs']));
|
||||||
|
self::assertSame(2, $result['count']);
|
||||||
|
self::assertSame("<img alt='x > Kontakt' src='/kontakt.jpg'> Anfrage", self::valueOf($image['attrs']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCommentsAreNoText(): void
|
||||||
|
{
|
||||||
|
$value = "<!-- Kontakt > Kontakt\n<b> --><p>Kontakt</p><!---->Kontakt";
|
||||||
|
|
||||||
|
$result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same());
|
||||||
|
|
||||||
|
self::assertSame("<!-- Kontakt > Kontakt\n<b> --><p>Anfrage</p><!---->Anfrage", self::valueOf($result['attrs']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** More raw-text elements; a closing tag must really close ("</script-x>" does not). */
|
||||||
|
public function testRawTextBodiesOfAllKinds(): void
|
||||||
|
{
|
||||||
|
$value = '<iframe>Kontakt</iframe><noscript>Kontakt</noscript><script>a</script-x> Kontakt</script>'
|
||||||
|
. '<textarea>Kontakt</textarea>';
|
||||||
|
|
||||||
|
$result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same());
|
||||||
|
|
||||||
|
self::assertSame(1, $result['count']);
|
||||||
|
self::assertStringEndsWith('<textarea>Anfrage</textarea>', (string) self::valueOf($result['attrs']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search == replacement (or a cleaned replacement equal to the match) is no change and no hit. */
|
||||||
|
public function testNoEffectiveChangeIsNoHit(): void
|
||||||
|
{
|
||||||
|
$same = (new TextReplacer('Müller', 'Müller'))->apply(self::ATTRS, self::same());
|
||||||
|
$cleanedAway = (new TextReplacer('Müller', '<b>Müller</b>'))->apply(
|
||||||
|
self::ATTRS,
|
||||||
|
static fn (string $p, ?string $k, string $v): string => strip_tags($v)
|
||||||
|
);
|
||||||
|
$partly = (new TextReplacer('müller', 'Müller', false))->apply(self::ATTRS, self::same());
|
||||||
|
|
||||||
|
self::assertSame(0, $same['count']);
|
||||||
|
self::assertSame(self::ATTRS, $same['attrs']);
|
||||||
|
self::assertSame(0, $cleanedAway['count']);
|
||||||
|
self::assertSame(self::ATTRS, $cleanedAway['attrs']);
|
||||||
|
self::assertSame(1, $partly['count'], 'only MÜLLER changes');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S45 malicious rows: either refused (exception) or no change at all.
|
||||||
|
*
|
||||||
|
* @return array<string, array{string, string, string}>
|
||||||
|
*/
|
||||||
|
public static function s45(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'double-quoted ">" then handler' => ['<a title="A > B" href="/kontakt">Link</a>', ' B', '" onmouseover="alert(1)'],
|
||||||
|
'single-quoted ">" then handler' => ["<img alt='A > B' src='/b.jpg'>", ' B', "' onerror='alert(1)"],
|
||||||
|
'href rewrite after quoted ">"' => ['<a title="x > y" href="/kontakt">Link</a>', 'kontakt', 'javascript:alert(1)'],
|
||||||
|
'comment with ">"' => ['<!-- a > B --><p>x</p>', ' B', '--><img src=x onerror=alert(1)>'],
|
||||||
|
'comment closed by --!>' => ['<!-- B --!><p>x</p>', ' B', 'x'],
|
||||||
|
'unterminated comment' => ['<p>x B</p><!-- B', ' B', 'y'],
|
||||||
|
'unterminated tag' => ['<p>ok B</p><a title="x B', ' B', 'y'],
|
||||||
|
'unbalanced quote' => ['<a title="x>B B</a>', ' B', 'y'],
|
||||||
|
'unquoted "<" in a tag' => ['<a <b>x B</b>', ' B', 'y'],
|
||||||
|
'lone "<" before text' => ['a < B', ' B', 'img src=x onerror=alert(1)>'],
|
||||||
|
'"<" + letter in text' => ['a<B c', ' c', 'y'],
|
||||||
|
'raw body' => ['<iframe>x B</iframe>', ' B', '<img src=x onerror=alert(1)>'],
|
||||||
|
'fake script end' => ['<script>a</script-x> B</script>', ' B', 'y'],
|
||||||
|
'double-escaped script' => ['<script><!--<script></script> B--></script>', ' B', 'y'],
|
||||||
|
'unterminated reference' => ['$variable({"type":"content"} B', ' B', ')$'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider s45 */
|
||||||
|
public function testS45RowsAreRefusedOrUnchanged(string $value, string $search, string $replace): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = (new TextReplacer($search, $replace))->apply(self::text($value), self::same());
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('content.innerContent', $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame(0, $result['count']);
|
||||||
|
self::assertSame($value, self::valueOf($result['attrs']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{string}> */
|
||||||
|
public static function longInputs(): array
|
||||||
|
{
|
||||||
|
$n = 200000;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'many "<"' => [str_repeat('<', $n) . ' Kontakt'],
|
||||||
|
'many "<a"' => [str_repeat('<a ', intdiv($n, 3)) . ' Kontakt'],
|
||||||
|
'many open quotes' => [str_repeat('<a title="', intdiv($n, 10)) . ' Kontakt'],
|
||||||
|
'many comment openers' => [str_repeat('<!--', intdiv($n, 4)) . ' Kontakt'],
|
||||||
|
'comments then a closer' => [str_repeat('<!--', intdiv($n, 4)) . ' --> Kontakt'],
|
||||||
|
'many markers' => [str_repeat('$variable(', intdiv($n, 10)) . ' Kontakt'],
|
||||||
|
'many markers then a closer' => [str_repeat('$variable(', intdiv($n, 10)) . ')$ Kontakt'],
|
||||||
|
'many entities' => [str_repeat('&', intdiv($n, 4)) . ' Kontakt'],
|
||||||
|
'long text' => [str_repeat('Kontakt ', intdiv($n, 8))],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider longInputs */
|
||||||
|
public function testLongInputsStayLinear(string $value): void
|
||||||
|
{
|
||||||
|
$start = microtime(true);
|
||||||
|
try {
|
||||||
|
(new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same());
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
// refused is fine
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertLessThan(2.0, microtime(true) - $start);
|
||||||
|
}
|
||||||
|
|
||||||
public function testRefusesAMarkerFromTheCallback(): void
|
public function testRefusesAMarkerFromTheCallback(): void
|
||||||
{
|
{
|
||||||
$this->expectException(InvalidArgumentException::class);
|
$this->expectException(InvalidArgumentException::class);
|
||||||
@@ -218,6 +346,8 @@ final class TextReplacerTest extends TestCase
|
|||||||
'NUL-split marker (S32a)' => ['Müller', "\$vari\0able(", 'replace'],
|
'NUL-split marker (S32a)' => ['Müller', "\$vari\0able(", 'replace'],
|
||||||
'VT-split marker' => ['Müller', "\$variable\x0B(", 'replace'],
|
'VT-split marker' => ['Müller', "\$variable\x0B(", 'replace'],
|
||||||
'octet-split marker' => ['Müller', '$v%41ariable(', 'replace'],
|
'octet-split marker' => ['Müller', '$v%41ariable(', 'replace'],
|
||||||
|
'NUL in search' => ["Mül\0ler", 'x', 'search'],
|
||||||
|
'VT in search' => ["Mül\x0Bler", 'x', 'search'],
|
||||||
'nested octet-split marker' => ['Müller', '$v%4%411ariable(', 'replace'],
|
'nested octet-split marker' => ['Müller', '$v%4%411ariable(', 'replace'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,6 +394,141 @@ final class DiviReplaceTextAbilityTest extends TestCase
|
|||||||
self::assertSame([], $dry['matches'], 'the dry run shows no hit either');
|
self::assertSame([], $dry['matches'], 'the dry run shows no hit either');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S45 end to end (the live stored XSS): a ">" inside a quoted attribute, comments,
|
||||||
|
* unterminated tags and "<" in text – no stored change, error or no-op.
|
||||||
|
*
|
||||||
|
* @return array<string, array{string, string, string}>
|
||||||
|
*/
|
||||||
|
public static function s45(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'double quotes' => ['<p><a title="A > B" href="/kontakt">Kontakt</a></p>', ' B', '" onmouseover="alert(1)'],
|
||||||
|
'single quotes' => ["<p><img alt='A > B' src='/b.jpg'></p>", ' B', "' onerror='alert(1)"],
|
||||||
|
'href after a quoted ">"' => ['<p><a title="x > y" href="/kontakt">Link</a></p>', 'kontakt', 'javascript:alert(1)'],
|
||||||
|
'comment' => ['<!-- a > B --><p>x</p>', ' B', '--><img src=x onerror=alert(1)>'],
|
||||||
|
'unterminated tag' => ['<p>ok B</p><a title="x B', ' B', 'y'],
|
||||||
|
'unbalanced quote' => ['<a title="x>B B</a>', ' B', 'y'],
|
||||||
|
'lone "<" in text' => ['a < B', ' B', 'img src=x onerror=alert(1)>'],
|
||||||
|
'"<" + letter in text' => ['a<B c', ' c', 'y'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @dataProvider s45 */
|
||||||
|
public function testS45RowsStoreNothing(string $html, string $search, string $replace): void
|
||||||
|
{
|
||||||
|
foreach ([false, true] as $unfiltered) {
|
||||||
|
$this->saved = [];
|
||||||
|
$ability = $this->ability(self::page([self::text($html)]), $unfiltered);
|
||||||
|
|
||||||
|
$dry = $ability->execute(['search' => $search, 'replace' => $replace]);
|
||||||
|
$result = $this->replaceFor($ability, ['search' => $search, 'replace' => $replace]);
|
||||||
|
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
$entry = $result['results'][0];
|
||||||
|
self::assertTrue(! $entry['ok'] || $entry['count'] === 0, (string) json_encode($entry));
|
||||||
|
self::assertSame([], array_filter($dry['matches'], static fn (array $match): bool => $match['count'] > 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Global modules are never changed – but reported, also when they hold the only hits. */
|
||||||
|
public function testGlobalModulesAreReportedNotChanged(): void
|
||||||
|
{
|
||||||
|
$global = ['divi/text', ['globalModule' => '123', 'content' => ['innerContent' => ['desktop' => ['value' => '<p>Müller</p>']]]]];
|
||||||
|
$ability = $this->ability(self::page([$global, self::text('<p>Meier</p>')]));
|
||||||
|
|
||||||
|
$dry = $ability->execute(['search' => 'müller', 'replace' => 'Schmidt', 'case_sensitive' => false]);
|
||||||
|
$result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Schmidt']);
|
||||||
|
|
||||||
|
$node = ['post_id' => 7, 'id' => 's0.r0.c0.g0', 'reason' => 'globales Modul – in der Bibliothek bearbeiten'];
|
||||||
|
self::assertSame([$node], $dry['skipped_nodes']);
|
||||||
|
self::assertSame(7, $dry['matches'][0]['post_id'], 'the page does not vanish');
|
||||||
|
self::assertSame(0, $dry['matches'][0]['count']);
|
||||||
|
self::assertSame(1, $dry['matches'][0]['skipped_nodes']);
|
||||||
|
self::assertStringContainsString('globale Module', $dry['summary']);
|
||||||
|
self::assertSame([$node], $result['skipped_nodes']);
|
||||||
|
self::assertSame(0, $result['results'][0]['count']);
|
||||||
|
self::assertStringContainsString('Knoten s0.r0.c0.g0', implode("\n", $result['warnings']));
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No effective change (search == replacement, cleaned replacement == match): nothing saved. */
|
||||||
|
public function testNoEffectiveChangeSavesNothing(): void
|
||||||
|
{
|
||||||
|
$ability = $this->ability(self::page([self::heading('Müller'), self::text('<p>Müller</p>')]));
|
||||||
|
|
||||||
|
$same = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Müller']);
|
||||||
|
$caseOnly = $this->replaceFor($ability, ['search' => 'müller', 'replace' => 'Müller', 'case_sensitive' => false]);
|
||||||
|
$dry = $ability->execute(['search' => 'Müller', 'replace' => 'Müller']);
|
||||||
|
|
||||||
|
self::assertSame(0, $same['results'][0]['count']);
|
||||||
|
self::assertSame(0, $caseOnly['results'][0]['count']);
|
||||||
|
self::assertArrayNotHasKey('revision_id', $same['results'][0]);
|
||||||
|
self::assertSame([], $dry['matches']);
|
||||||
|
self::assertSame([], $this->saved);
|
||||||
|
self::assertSame([], $same['changed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDescriptionMentionsTrimming(): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString('Leerzeichen am Anfang und Ende', $this->ability()->description());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testControlCharactersAndEmptySearch(): void
|
||||||
|
{
|
||||||
|
$ability = $this->ability();
|
||||||
|
|
||||||
|
foreach (['', "Ter\0min", "Ter\x0Bmin"] as $search) {
|
||||||
|
$error = $ability->execute(['search' => $search, 'replace' => 'X']);
|
||||||
|
self::assertSame('lit_validation_failed', $error->get_error_code(), json_encode($search));
|
||||||
|
self::assertArrayHasKey('search', self::clientError($error)['fields']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->replaceFor($ability, ['search' => 'Termin', 'replace' => "R\0\x0Bück\x7Fruf"]);
|
||||||
|
|
||||||
|
self::assertTrue($result['results'][0]['ok'], (string) json_encode($result));
|
||||||
|
self::assertSame(['text' => 'Rückruf buchen', 'linkUrl' => '/kontakt', 'linkTarget' => 'off'], $this->savedValue('s0.r0.c1.m2', 'button.innerContent'));
|
||||||
|
self::assertSame([], $this->queries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCursorAndLimit(): void
|
||||||
|
{
|
||||||
|
$ability = $this->ability(null, false, [7, 8, 9]);
|
||||||
|
|
||||||
|
$first = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'limit' => 2]);
|
||||||
|
$second = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'limit' => 2, 'cursor' => $first['next_cursor']]);
|
||||||
|
$invalid = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'cursor' => 'kaputt']);
|
||||||
|
$capped = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'limit' => 5000]);
|
||||||
|
|
||||||
|
self::assertCount(2, $first['matches']);
|
||||||
|
self::assertStringContainsString('LIMIT 3 OFFSET 0', $this->queries[0]);
|
||||||
|
self::assertStringContainsString('LIMIT 3 OFFSET 2', $this->queries[1]);
|
||||||
|
self::assertIsString($second['next_cursor'], 'the fake query returns three rows again');
|
||||||
|
self::assertSame('lit_validation_failed', $invalid->get_error_code());
|
||||||
|
self::assertStringContainsString('LIMIT 101 OFFSET 0', $this->queries[2]);
|
||||||
|
self::assertCount(3, $capped['matches']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Byte identity on a page whose other blocks use dynamic content names outside the allow map. */
|
||||||
|
public function testByteIdentityNextToUnknownDynamicContent(): void
|
||||||
|
{
|
||||||
|
$odd = self::text('<p>' . '$variable({"type":"content","value":{"name":"acf_field_x","settings":{"before":"<b>"}}})$' . '</p>');
|
||||||
|
$odd[1]['module'] = ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$']]]]];
|
||||||
|
$hit = self::heading('Willkommen bei Müller');
|
||||||
|
$ability = $this->ability(self::page([$odd, $hit, $odd]));
|
||||||
|
$original = (string) $this->post->post_content;
|
||||||
|
|
||||||
|
$result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Schmidt']);
|
||||||
|
|
||||||
|
self::assertTrue($result['results'][0]['ok'], (string) json_encode($result));
|
||||||
|
$changed = $hit;
|
||||||
|
$changed[1]['title']['innerContent']['desktop']['value'] = 'Willkommen bei Schmidt';
|
||||||
|
self::assertSame(
|
||||||
|
str_replace(Serializer::attributes($hit[1]), Serializer::attributes($changed[1]), $original),
|
||||||
|
$this->saved[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Ruling S10: an unexpected error on one page is logged, reported neutrally, and the batch goes on. */
|
/** Ruling S10: an unexpected error on one page is logged, reported neutrally, and the batch goes on. */
|
||||||
public function testAnUnexpectedErrorOnOnePageDoesNotStopTheBatch(): void
|
public function testAnUnexpectedErrorOnOnePageDoesNotStopTheBatch(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user