); 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 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 */ 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 $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, count: int, * samples: list} * @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 $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 $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 $tokens * @return list|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 $tokens * @param list $hits * @param list $inserted * @return array{string, list, list} */ 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 $tokens * @param list $hits * @param list $insertedRanges * @param list $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) ? '…' : ''); } }