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
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Blog\Support;
use Illuminate\Http\Request;
/**
* Homepage / list browse mode, matching old SaBlog 标准 | 列表.
*/
class HomeViewMode
{
public const LIST = 'list';
public const FULL = 'full';
public const COOKIE = 'll_home_view';
/**
* @return array<string, string>
*/
public static function options(): array
{
return [
self::LIST => '列表',
self::FULL => '标准',
];
}
public static function isValid(mixed $value): bool
{
return is_string($value) && in_array($value, [self::LIST, self::FULL], true);
}
public static function resolve(?Request $request = null): string
{
$request ??= request();
foreach ([
$request->query('view'),
$request->cookie(self::COOKIE),
blog_setting('home_view_mode', config('blog.home_view_mode', self::LIST)),
] as $value) {
if (self::isValid($value)) {
return $value;
}
}
return self::LIST;
}
public static function current(): string
{
$request = request();
$mode = self::resolve($request);
if (self::isValid($request->query('view'))) {
cookie()->queue(cookie(self::COOKIE, (string) $request->query('view'), 60 * 24 * 30));
}
return $mode;
}
public static function url(string $mode): string
{
return request()->fullUrlWithQuery(['view' => $mode]);
}
}
+49 -7
View File
@@ -45,7 +45,7 @@ class PageCacheMiddleware
'body' => $response->getContent(),
'type' => $response->headers->get('Content-Type') ?: 'text/html; charset=utf-8',
], $ttl);
self::rememberKey($key);
self::rememberKey($key, $request->fullUrl());
}
return $response;
@@ -53,8 +53,8 @@ class PageCacheMiddleware
public static function flush(): void
{
foreach ((array) Cache::get(self::TAG.'_keys', []) as $key) {
Cache::forget($key);
foreach (array_keys((array) Cache::get(self::TAG.'_keys', [])) as $key) {
Cache::forget((string) $key);
}
Cache::forget(self::TAG.'_keys');
@@ -88,16 +88,50 @@ class PageCacheMiddleware
Cache::forget(self::TAG.'_misses');
}
/**
* @return list<array{key: string, url: string, cached_at: string}>
*/
public static function entries(): array
{
$entries = [];
foreach ((array) Cache::get(self::TAG.'_keys', []) as $key => $meta) {
$entries[] = [
'key' => (string) $key,
'url' => is_array($meta) ? (string) ($meta['url'] ?? $key) : (string) $key,
'cached_at' => is_array($meta) ? (string) ($meta['cached_at'] ?? '') : '',
];
}
usort($entries, fn (array $left, array $right): int => strcmp($right['cached_at'], $left['cached_at']));
return $entries;
}
public static function forget(string $key): void
{
Cache::forget($key);
$keys = (array) Cache::get(self::TAG.'_keys', []);
unset($keys[$key]);
Cache::put(self::TAG.'_keys', $keys, now()->addDay());
}
private static function increment(string $metric): void
{
$key = self::TAG.'_'.$metric;
Cache::put($key, (int) Cache::get($key, 0) + 1, now()->addDay());
}
private static function rememberKey(string $key): void
private static function rememberKey(string $key, ?string $url = null): void
{
$keys = (array) Cache::get(self::TAG.'_keys', []);
$keys[$key] = true;
$previous = $keys[$key] ?? [];
$keys[$key] = [
'url' => $url ?: (is_array($previous) ? (string) ($previous['url'] ?? '') : ''),
'cached_at' => now()->toDateTimeString(),
];
Cache::put(self::TAG.'_keys', $keys, now()->addDay());
}
@@ -117,16 +151,24 @@ class PageCacheMiddleware
return false;
}
// 动态端点
// 动态端点、订阅和 agent 索引不进整页缓存,新文章要马上能看到
if (str_starts_with($path, 'track-view')) {
return false;
}
if (in_array($path, ['rss.xml', 'rss.php', 'sitemap.xml', 'robots.txt', 'llms.txt'], true)) {
return false;
}
if (str_ends_with($path, '.md')) {
return false;
}
return true;
}
private function key(Request $request): string
{
return self::TAG.':'.md5($request->fullUrl());
return self::TAG.':'.md5($request->fullUrl().'|view:'.HomeViewMode::resolve($request));
}
}
+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;
}
}