feat: restore sablog browse modes and clean imported HTML
Build and Deploy / verify (push) Successful in 40s
Build and Deploy / deploy (push) Successful in 36s

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>
This commit is contained in:
gouki
2026-09-18 21:33:02 +00:00
co-authored by Cursor
parent 469160dc3f
commit ae47447062
98 changed files with 8618 additions and 901 deletions
+376
View File
@@ -0,0 +1,376 @@
<?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 &nbsp;
* 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(
['&amp;nbsp;', '&nbsp;', '&#160;', '&#xA0;', '&#xa0;', "\xC2\xA0"],
' ',
$content
);
return str_replace('&amp;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;
}
}