Keep the public site readable after import (nbsp, highlighter, wrapping code, compact pager) and finish the admin extras already in this tree: sticky levels, preview, page cache, and live llms.txt. Co-authored-by: Cursor <cursoragent@cursor.com>
377 lines
11 KiB
PHP
377 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Blog\Support;
|
|
|
|
use App\Blog\Services\PostContentRenderer;
|
|
use DOMDocument;
|
|
use DOMElement;
|
|
use DOMNode;
|
|
use DOMXPath;
|
|
use League\HTMLToMarkdown\HtmlConverter;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Cleans leftover SaBlog-X HTML so imported posts render without literal
|
|
* and without SyntaxHighlighter markup stretching the page.
|
|
*/
|
|
class SablogHtmlNormalizer
|
|
{
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function languageAliases(): array
|
|
{
|
|
return [
|
|
'js' => 'javascript',
|
|
'javascript' => 'javascript',
|
|
'xml' => 'xml',
|
|
'html' => 'xml',
|
|
'xhtml' => 'xml',
|
|
'css' => 'css',
|
|
'php' => 'php',
|
|
'sql' => 'sql',
|
|
'java' => 'java',
|
|
'py' => 'python',
|
|
'python' => 'python',
|
|
'c#' => 'csharp',
|
|
'cs' => 'csharp',
|
|
'bash' => 'bash',
|
|
'shell' => 'bash',
|
|
'sh' => 'bash',
|
|
'text' => 'text',
|
|
];
|
|
}
|
|
|
|
public function normalize(string $content): string
|
|
{
|
|
if ($content === '') {
|
|
return '';
|
|
}
|
|
|
|
$content = $this->decodeNbsp($content);
|
|
|
|
if (! preg_match('/<\s*(div|p|ol|ul|li|pre|code|span|br|table)\b/i', $content)) {
|
|
return $this->recoverFlattenedShell($content);
|
|
}
|
|
|
|
try {
|
|
$content = $this->transformHtml($content);
|
|
} catch (Throwable) {
|
|
// Keep the decoded text when HTML parsing fails.
|
|
}
|
|
|
|
return $this->recoverFlattenedShell($content);
|
|
}
|
|
|
|
public function toMarkdown(string $html, HtmlConverter $converter, PostContentRenderer $renderer): string
|
|
{
|
|
return $converter->convert($renderer->toMarkdownSafeTokens($this->normalize($html)));
|
|
}
|
|
|
|
public function plainText(string $content): string
|
|
{
|
|
$text = $this->decodeNbsp(strip_tags($content));
|
|
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$text = str_replace("\xC2\xA0", ' ', $text);
|
|
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
|
|
|
return trim($text);
|
|
}
|
|
|
|
public function decodeNbsp(string $content): string
|
|
{
|
|
$content = str_replace(
|
|
['&nbsp;', ' ', ' ', ' ', ' ', "\xC2\xA0"],
|
|
' ',
|
|
$content
|
|
);
|
|
|
|
return str_replace('&nbsp;', ' ', $content);
|
|
}
|
|
|
|
private function transformHtml(string $html): string
|
|
{
|
|
$document = $this->loadDocument($html);
|
|
$xpath = new DOMXPath($document);
|
|
|
|
$this->convertHighlighterBlocks($document, $xpath);
|
|
$this->convertHighlighterLists($document, $xpath);
|
|
$this->convertStackedCodeDivs($document);
|
|
$this->removeEmptyBlocks($xpath);
|
|
|
|
return $this->innerHtml($document);
|
|
}
|
|
|
|
private function convertHighlighterBlocks(DOMDocument $document, DOMXPath $xpath): void
|
|
{
|
|
$nodes = [];
|
|
foreach ($xpath->query('//*[contains(concat(" ", normalize-space(@class), " "), " codeText ")]') ?: [] as $node) {
|
|
if ($node instanceof DOMElement) {
|
|
$nodes[] = $node;
|
|
}
|
|
}
|
|
|
|
foreach ($nodes as $node) {
|
|
if ($node->parentNode === null) {
|
|
continue;
|
|
}
|
|
|
|
$this->replaceWithPre($document, $xpath, $node);
|
|
}
|
|
}
|
|
|
|
private function convertHighlighterLists(DOMDocument $document, DOMXPath $xpath): void
|
|
{
|
|
$lists = [];
|
|
foreach ($xpath->query('//ol[contains(@class, "dp-")]') ?: [] as $node) {
|
|
if (! $node instanceof DOMElement || $node->parentNode === null) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->closestTag($node, 'pre') !== null) {
|
|
continue;
|
|
}
|
|
|
|
$lists[] = $node;
|
|
}
|
|
|
|
foreach ($lists as $list) {
|
|
$this->replaceWithPre($document, $xpath, $list);
|
|
}
|
|
}
|
|
|
|
private function convertStackedCodeDivs(DOMDocument $document): void
|
|
{
|
|
$xpath = new DOMXPath($document);
|
|
$parents = [];
|
|
foreach ($xpath->query('//*') ?: [] as $node) {
|
|
if ($node instanceof DOMElement) {
|
|
$parents[] = $node;
|
|
}
|
|
}
|
|
|
|
foreach ($parents as $parent) {
|
|
if ($parent->parentNode === null) {
|
|
continue;
|
|
}
|
|
|
|
if (in_array(strtolower($parent->nodeName), ['pre', 'code'], true)) {
|
|
continue;
|
|
}
|
|
|
|
$run = [];
|
|
$flush = function () use (&$run, $document): void {
|
|
if (count($run) < 2) {
|
|
$run = [];
|
|
|
|
return;
|
|
}
|
|
|
|
$lines = array_map(fn (DOMElement $div): string => $this->plainText($this->innerHtmlOf($div)), $run);
|
|
if (! $this->looksLikeCodeLines($lines)) {
|
|
$run = [];
|
|
|
|
return;
|
|
}
|
|
|
|
$first = $run[0];
|
|
$pre = $this->makePre($document, implode("\n", $lines), $this->detectLanguage('', implode("\n", $lines)));
|
|
$first->parentNode?->insertBefore($pre, $first);
|
|
foreach ($run as $div) {
|
|
$div->parentNode?->removeChild($div);
|
|
}
|
|
$run = [];
|
|
};
|
|
|
|
foreach (iterator_to_array($parent->childNodes) as $child) {
|
|
if ($child instanceof DOMNode && $child->nodeType === XML_TEXT_NODE && trim((string) $child->textContent) === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($child instanceof DOMElement && strtolower($child->nodeName) === 'div' && $this->isSimpleLineDiv($child)) {
|
|
$run[] = $child;
|
|
continue;
|
|
}
|
|
|
|
$flush();
|
|
}
|
|
|
|
$flush();
|
|
}
|
|
}
|
|
|
|
private function removeEmptyBlocks(DOMXPath $xpath): void
|
|
{
|
|
foreach ($xpath->query('//p|//div') ?: [] as $node) {
|
|
if (! $node instanceof DOMElement || $node->parentNode === null) {
|
|
continue;
|
|
}
|
|
|
|
if ($node->getElementsByTagName('*')->length > 0) {
|
|
continue;
|
|
}
|
|
|
|
$text = trim(str_replace("\xC2\xA0", ' ', $node->textContent));
|
|
if ($text === '') {
|
|
$node->parentNode->removeChild($node);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function replaceWithPre(DOMDocument $document, DOMXPath $xpath, DOMElement $node): void
|
|
{
|
|
$hint = '';
|
|
if (preg_match('/dp-([a-z0-9#+]+)/i', $node->getAttribute('class'), $match)) {
|
|
$hint = $match[1];
|
|
}
|
|
|
|
$lines = [];
|
|
foreach ($xpath->query('.//li', $node) ?: [] as $item) {
|
|
$lines[] = $this->plainText($document->saveHTML($item) ?: '');
|
|
}
|
|
|
|
if ($lines === []) {
|
|
foreach ($xpath->query('.//*[contains(@class, "codeHead")]', $node) ?: [] as $head) {
|
|
$head->parentNode?->removeChild($head);
|
|
}
|
|
$lines = preg_split('/\R/u', $this->plainText($node->textContent)) ?: [];
|
|
}
|
|
|
|
$code = implode("\n", $lines);
|
|
$pre = $this->makePre($document, $code, $this->detectLanguage($hint, $code));
|
|
$node->parentNode?->replaceChild($pre, $node);
|
|
}
|
|
|
|
private function makePre(DOMDocument $document, string $code, string $language): DOMElement
|
|
{
|
|
$pre = $document->createElement('pre');
|
|
$codeNode = $document->createElement('code');
|
|
$codeNode->setAttribute('class', 'language-'.$language);
|
|
$codeNode->appendChild($document->createTextNode($code));
|
|
$pre->appendChild($codeNode);
|
|
|
|
return $pre;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $lines
|
|
*/
|
|
private function looksLikeCodeLines(array $lines): bool
|
|
{
|
|
$codeish = 0;
|
|
foreach ($lines as $line) {
|
|
if ($line === '') {
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('/\\\\$/', $line)
|
|
|| preg_match('/^\s*(docker|npm|npx|pnpm|yarn|git|curl|wget|sudo|ssh|kubectl|composer|php|python|pip|node)\b/i', $line)
|
|
|| preg_match('/^\s+--[\w-]+/', $line)
|
|
|| preg_match('/^\s*\|/', $line)
|
|
|| preg_match('/[{};]$/', $line)
|
|
) {
|
|
$codeish++;
|
|
}
|
|
}
|
|
|
|
return $codeish >= 2;
|
|
}
|
|
|
|
private function isSimpleLineDiv(DOMElement $div): bool
|
|
{
|
|
foreach ($div->childNodes as $child) {
|
|
if ($child instanceof DOMElement && ! in_array(strtolower($child->nodeName), ['br', 'span', 'code', 'b', 'i', 'em', 'strong'], true)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
$text = $this->plainText($div->textContent);
|
|
|
|
return $text !== '';
|
|
}
|
|
|
|
private function detectLanguage(string $hint, string $code): string
|
|
{
|
|
if (preg_match('/^\s*(docker|kubectl|npm|npx|pnpm|yarn|composer|curl|wget|sudo|ssh)\b/m', $code)) {
|
|
return 'bash';
|
|
}
|
|
|
|
$aliases = $this->languageAliases();
|
|
$key = strtolower(trim($hint));
|
|
|
|
return $aliases[$key] ?? ($key !== '' ? $key : 'text');
|
|
}
|
|
|
|
/**
|
|
* Recover a docker/shell command that HtmlConverter flattened into one line.
|
|
*/
|
|
private function recoverFlattenedShell(string $content): string
|
|
{
|
|
if (str_contains($content, '<pre')) {
|
|
return $content;
|
|
}
|
|
|
|
return preg_replace_callback(
|
|
'/(?:docker|sudo docker)\s+run\b(?:[^<\n]*\\\\)+\s*[^<\n]+/i',
|
|
function (array $match): string {
|
|
$code = trim($this->plainText($match[0]));
|
|
$code = preg_replace('/\\\\\s+/', "\\\n", $code) ?? $code;
|
|
|
|
return '<pre><code class="language-bash">'.e($code).'</code></pre>';
|
|
},
|
|
$content
|
|
) ?? $content;
|
|
}
|
|
|
|
private function closestTag(DOMNode $node, string $tag): ?DOMElement
|
|
{
|
|
$current = $node->parentNode;
|
|
while ($current instanceof DOMElement) {
|
|
if (strtolower($current->nodeName) === $tag) {
|
|
return $current;
|
|
}
|
|
$current = $current->parentNode;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function loadDocument(string $html): DOMDocument
|
|
{
|
|
$document = new DOMDocument('1.0', 'UTF-8');
|
|
$previous = libxml_use_internal_errors(true);
|
|
$wrapped = '<div id="ll-root">'.$html.'</div>';
|
|
$document->loadHTML('<?xml encoding="UTF-8">'.$wrapped, LIBXML_NOERROR | LIBXML_HTML_NODEFDTD);
|
|
libxml_clear_errors();
|
|
libxml_use_internal_errors($previous);
|
|
|
|
return $document;
|
|
}
|
|
|
|
private function innerHtml(DOMDocument $document): string
|
|
{
|
|
$root = $document->getElementById('ll-root');
|
|
if (! $root instanceof DOMElement) {
|
|
$root = (new DOMXPath($document))->query('//*[@id="ll-root"]')->item(0);
|
|
}
|
|
|
|
if (! $root instanceof DOMElement) {
|
|
return '';
|
|
}
|
|
|
|
return $this->innerHtmlOf($root);
|
|
}
|
|
|
|
private function innerHtmlOf(DOMElement $element): string
|
|
{
|
|
$html = '';
|
|
foreach ($element->childNodes as $child) {
|
|
$html .= $element->ownerDocument?->saveHTML($child) ?: '';
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
}
|