feat: restore sablog browse modes and clean imported HTML
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:
@@ -34,11 +34,12 @@ class ArchiveController extends Controller
|
||||
$start = Carbon::create((int) $year, (int) $month, 1)->startOfMonth();
|
||||
$end = (clone $start)->endOfMonth();
|
||||
|
||||
$posts = Post::query()
|
||||
->whereBetween('published_at', [$start, $end])
|
||||
->published()
|
||||
->orderByDesc('published_at')
|
||||
->paginate((int) blog_setting('per_page', config('blog.per_page')));
|
||||
$posts = blog_paginate(
|
||||
Post::query()
|
||||
->whereBetween('published_at', [$start, $end])
|
||||
->published()
|
||||
->orderByDesc('published_at')
|
||||
);
|
||||
|
||||
return theme_view('list', compact('posts'))
|
||||
->with('archiveTitle', "{$year} 年 {$month} 月");
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Post;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
@@ -14,10 +12,9 @@ class CategoryController extends Controller
|
||||
{
|
||||
$category = Category::query()->where('slug', $slug)->orWhere('id', (int) $slug)->firstOrFail();
|
||||
|
||||
$posts = $category->posts()
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->paginate((int) blog_setting('per_page', config('blog.per_page')));
|
||||
$posts = blog_paginate(
|
||||
$category->posts()->published()->orderedForList()
|
||||
);
|
||||
|
||||
return theme_view('list', compact('category', 'posts'));
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FeedController extends Controller
|
||||
{
|
||||
@@ -54,7 +52,9 @@ class FeedController extends Controller
|
||||
foreach ($disallow as $path) {
|
||||
$lines[] = "Disallow: {$path}";
|
||||
}
|
||||
$lines[] = "Sitemap: ".url($sitemap);
|
||||
$lines[] = 'Sitemap: '.url($sitemap);
|
||||
$lines[] = 'Allow: /llms.txt';
|
||||
$lines[] = 'Allow: /posts/*.md';
|
||||
|
||||
return response(implode("\n", $lines)."\n")->header('Content-Type', 'text/plain; charset=utf-8');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
@@ -11,11 +10,9 @@ class HomeController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$posts = Post::query()
|
||||
->published()
|
||||
->orderByDesc('is_sticky')
|
||||
->latest('published_at')
|
||||
->paginate((int) blog_setting('per_page', config('blog.per_page')));
|
||||
$posts = blog_paginate(
|
||||
Post::query()->published()->orderedForList()
|
||||
);
|
||||
|
||||
return theme_view('index', compact('posts'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Str;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
|
||||
class LlmsController extends Controller
|
||||
{
|
||||
/**
|
||||
* /llms.txt — live index for agents. Not a static file.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$siteName = (string) blog_setting('site_name', config('blog.name'));
|
||||
$siteDescription = (string) blog_setting('site_description', config('blog.description'));
|
||||
|
||||
$posts = Post::query()
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->limit(200)
|
||||
->get();
|
||||
|
||||
$lines = [
|
||||
'# '.$siteName,
|
||||
'',
|
||||
'> '.$siteDescription,
|
||||
'',
|
||||
'## Feeds',
|
||||
'',
|
||||
'- [RSS]('.route('feed.rss').')',
|
||||
'- [Sitemap]('.route('feed.sitemap').')',
|
||||
'- [llms.txt]('.route('feed.llms').')',
|
||||
'',
|
||||
'## Articles',
|
||||
'',
|
||||
];
|
||||
|
||||
foreach ($posts as $post) {
|
||||
if ($this->isRestricted($post)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = Str::limit(trim(preg_replace('/\s+/', ' ', strip_tags($post->excerpt_or_fallback)) ?? ''), 120);
|
||||
$markdownUrl = $this->markdownUrl($post);
|
||||
$htmlUrl = $post->url;
|
||||
$title = $post->title;
|
||||
|
||||
$lines[] = $markdownUrl
|
||||
? '- ['.$title.']('.$markdownUrl.') — '.$summary.' ([html]('.$htmlUrl.'))'
|
||||
: '- ['.$title.']('.$htmlUrl.') — '.$summary;
|
||||
}
|
||||
|
||||
return response(implode("\n", $lines)."\n", 200, [
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* /posts/{slug}.md — full markdown for a published public post.
|
||||
*/
|
||||
public function postMarkdown(string $slug)
|
||||
{
|
||||
$post = Post::query()
|
||||
->where('slug', $slug)
|
||||
->orWhere('id', (int) $slug)
|
||||
->first();
|
||||
|
||||
abort_unless($post && $post->status === 'published' && ! $this->isRestricted($post), 404);
|
||||
|
||||
$body = $post->content_format === 'markdown'
|
||||
? (string) $post->content
|
||||
: $this->htmlToMarkdown((string) $post->content);
|
||||
|
||||
$out = '# '.$post->title."\n\n";
|
||||
if ($post->excerpt) {
|
||||
$out .= '> '.$post->excerpt."\n\n";
|
||||
}
|
||||
$out .= $body."\n";
|
||||
|
||||
return response($out, 200, [
|
||||
'Content-Type' => 'text/markdown; charset=utf-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
private function isRestricted(Post $post): bool
|
||||
{
|
||||
if (filled($post->read_password)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! empty(($post->meta ?? [])['members_only']);
|
||||
}
|
||||
|
||||
private function markdownUrl(Post $post): ?string
|
||||
{
|
||||
$slug = trim((string) $post->slug);
|
||||
|
||||
if ($slug === '') {
|
||||
return route('geo.post.markdown', ['slug' => (string) $post->id]);
|
||||
}
|
||||
|
||||
return route('geo.post.markdown', ['slug' => $slug]);
|
||||
}
|
||||
|
||||
private function htmlToMarkdown(string $html): string
|
||||
{
|
||||
$converter = new HtmlConverter([
|
||||
'strip_tags' => false,
|
||||
'hard_break' => true,
|
||||
]);
|
||||
|
||||
return $converter->convert($html);
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,15 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Blog\Services\PostContentRenderer;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
public function __construct(private PostContentRenderer $renderer)
|
||||
{
|
||||
}
|
||||
public function __construct(private PostContentRenderer $renderer) {}
|
||||
|
||||
public function show(string $id, ?string $page = null)
|
||||
{
|
||||
@@ -21,13 +19,16 @@ class PostController extends Controller
|
||||
->orWhere('slug', $id)
|
||||
->first();
|
||||
|
||||
abort_unless($post && $post->status === 'published', 404);
|
||||
abort_unless($post, 404);
|
||||
|
||||
$canPreview = $this->canPreviewUnpublished();
|
||||
abort_unless($post->status === 'published' || $canPreview, 404);
|
||||
|
||||
$this->ensureReadable($post);
|
||||
|
||||
// sablog 式 /show-{id}-{page}.shtml 的评论分页
|
||||
if ($page !== null) {
|
||||
\Illuminate\Pagination\Paginator::currentPageResolver(fn () => max(1, (int) $page));
|
||||
Paginator::currentPageResolver(fn () => max(1, (int) $page));
|
||||
}
|
||||
|
||||
// 浏览量由 /track-view 端点统计(页面整页缓存下控制器不执行,避免双计)
|
||||
@@ -44,9 +45,17 @@ class PostController extends Controller
|
||||
'comments' => $comments,
|
||||
'contentHtml' => $rendered['html'],
|
||||
'toc' => $rendered['toc'],
|
||||
'isPreview' => $post->status !== 'published' && $canPreview,
|
||||
]);
|
||||
}
|
||||
|
||||
private function canPreviewUnpublished(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user && ($user->isAdmin() || $user->hasRole('editor'));
|
||||
}
|
||||
|
||||
private function ensureReadable(Post $post): void
|
||||
{
|
||||
if (! $post->read_password) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
@@ -13,11 +12,9 @@ class SearchController extends Controller
|
||||
{
|
||||
$keyword = trim(request('q', request('keyword', '')));
|
||||
|
||||
$posts = Post::query()
|
||||
->published()
|
||||
->search($keyword)
|
||||
->orderByDesc('published_at')
|
||||
->paginate((int) blog_setting('per_page', config('blog.per_page')));
|
||||
$posts = blog_paginate(
|
||||
Post::query()->published()->search($keyword)->orderedForList()
|
||||
);
|
||||
|
||||
return theme_view('search', compact('posts', 'keyword'));
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Blog\Controllers;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Tags\Tag;
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$counts = \Illuminate\Support\Facades\DB::table('taggables')
|
||||
$counts = DB::table('taggables')
|
||||
->selectRaw('tag_id, COUNT(*) as total')
|
||||
->where('taggable_type', Post::class)
|
||||
->groupBy('tag_id')
|
||||
@@ -37,11 +37,12 @@ class TagController extends Controller
|
||||
|
||||
abort_unless($tag, 404);
|
||||
|
||||
$posts = Post::query()
|
||||
->whereHas('tags', fn ($q) => $q->where('tags.id', $tag->id))
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->paginate((int) blog_setting('per_page', config('blog.per_page')));
|
||||
$posts = blog_paginate(
|
||||
Post::query()
|
||||
->whereHas('tags', fn ($q) => $q->where('tags.id', $tag->id))
|
||||
->published()
|
||||
->orderedForList()
|
||||
);
|
||||
|
||||
return theme_view('tag', compact('tag', 'posts'));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ declare(strict_types=1);
|
||||
namespace App\Blog\Services;
|
||||
|
||||
use App\Blog\Support\MarkdownMediaEmbed;
|
||||
use App\Blog\Support\SablogHtmlNormalizer;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
@@ -38,7 +39,7 @@ class PostContentRenderer
|
||||
public function render(Post $post): string
|
||||
{
|
||||
$embed = app(MarkdownMediaEmbed::class);
|
||||
$raw = (string) $post->content;
|
||||
$raw = app(SablogHtmlNormalizer::class)->normalize((string) $post->content);
|
||||
|
||||
$content = $post->content_format === 'markdown'
|
||||
? $this->toHtml($embed->expandMarkdown($raw))
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user