112 lines
3.4 KiB
PHP
112 lines
3.4 KiB
PHP
<?php
|
|||
|
|
|
||
|
|
namespace App\Support;
|
||
|
|
|
||
|
|
use App\Models\Tool;
|
||
|
|
use DOMDocument;
|
||
|
|
use DOMText;
|
||
|
|
use DOMXPath;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Auto-links the first mention of each approved tool's name in rendered
|
||
|
|
* post HTML to that tool's page, so "Top 10" style posts (and any post
|
||
|
|
* that happens to name-drop a tool we list) link back into the directory
|
||
|
|
* without editors having to hand-add the links.
|
||
|
|
*/
|
||
|
|
class ToolLinker
|
||
|
|
{
|
||
|
|
public function link(string $html): string
|
||
|
|
{
|
||
|
|
if (trim($html) === '') {
|
||
|
|
return $html;
|
||
|
|
}
|
||
|
|
|
||
|
|
$tools = Tool::approved()->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(
|
||
|
|
'<?xml encoding="utf-8"?><div>'.$html.'</div>',
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|