get(['id', 'name', 'slug']) ->sortByDesc(fn (Tool $tool) => mb_strlen($tool->name)) ->values(); if ($tools->isEmpty()) { return $html; } $document = new DOMDocument(); libxml_use_internal_errors(true); $document->loadHTML( '
'.$html.'
', LIBXML_NOERROR | LIBXML_NOWARNING ); libxml_clear_errors(); $xpath = new DOMXPath($document); $textNodes = $xpath->query( '//text()[not(ancestor::a) and not(ancestor::code) and not(ancestor::pre) '. 'and not(ancestor::script) and not(ancestor::style)]' ); $alreadyLinked = []; foreach ($textNodes as $node) { /** @var DOMText $node */ foreach ($tools as $tool) { if (isset($alreadyLinked[$tool->id])) { continue; } $pattern = '/\b'.preg_quote($tool->name, '/').'\b/ui'; if (! preg_match($pattern, $node->wholeText, $match, PREG_OFFSET_CAPTURE)) { continue; } $this->wrapMatchInLink($document, $node, $match[0], $tool->slug); $alreadyLinked[$tool->id] = true; // The node has been split and detached from the document; // move on to the next original text node. break; } } $wrapper = $document->getElementsByTagName('div')->item(0); $result = ''; foreach (iterator_to_array($wrapper->childNodes) as $child) { $result .= $document->saveHTML($child); } return $result; } /** * @param array{0: string, 1: int} $match [matched text, byte offset] from PREG_OFFSET_CAPTURE */ private function wrapMatchInLink(DOMDocument $document, DOMText $node, array $match, string $slug): void { [$matchedText, $offset] = $match; // PREG_OFFSET_CAPTURE offsets are byte offsets, so slice with // substr() (byte-based), not mb_substr() -- the offsets are still // valid UTF-8 boundaries since the /u match itself respects them. $before = substr($node->wholeText, 0, $offset); $after = substr($node->wholeText, $offset + strlen($matchedText)); $link = $document->createElement('a'); $link->setAttribute('href', "/tools/{$slug}"); $link->appendChild($document->createTextNode($matchedText)); $parent = $node->parentNode; if ($before !== '') { $parent->insertBefore($document->createTextNode($before), $node); } $parent->insertBefore($link, $node); if ($after !== '') { $parent->insertBefore($document->createTextNode($after), $node); } $parent->removeChild($node); } }