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
+6 -5
View File
@@ -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} 月");
+3 -6
View File
@@ -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'));
}
+3 -3
View File
@@ -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');
}
+3 -6
View File
@@ -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'));
}
+120
View File
@@ -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);
}
}
+15 -6
View File
@@ -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) {
+3 -6
View File
@@ -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'));
}
+8 -7
View File
@@ -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'));
}
+2 -1
View File
@@ -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))
+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;
}
}
+3 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Blog\Support\SablogHtmlNormalizer;
use App\Models\Post;
use Illuminate\Console\Command;
use League\HTMLToMarkdown\HtmlConverter;
@@ -36,6 +37,7 @@ class ConvertContent extends Command
]);
$renderer = app(\App\Blog\Services\PostContentRenderer::class);
$normalizer = app(SablogHtmlNormalizer::class);
$posts = $query->get();
if ($posts->isEmpty()) {
@@ -49,7 +51,7 @@ class ConvertContent extends Command
foreach ($posts as $post) {
// [attach=xx] 是 markdown 保留字符,先换成令牌,渲染时再解析
$markdown = $converter->convert($renderer->toMarkdownSafeTokens($post->content));
$markdown = $normalizer->toMarkdown((string) $post->content, $converter, $renderer);
if ($this->option('dry-run')) {
$this->newLine();
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Blog\Support\SablogHtmlNormalizer;
use App\Models\Post;
use Illuminate\Console\Command;
class FixImportedContent extends Command
{
protected $signature = 'sablog:fix-content
{--all : Repair every post}
{id? : Single post ID}
{--dry-run : Preview only}';
protected $description = 'Clean leftover SaBlog HTML: literal &nbsp;, highlighter blocks, stacked docker divs';
public function handle(SablogHtmlNormalizer $normalizer): int
{
$query = Post::query();
if ($id = $this->argument('id')) {
$query->where('id', $id);
} elseif (! $this->option('all')) {
$this->error('Pass a post ID or use --all');
return self::FAILURE;
}
$posts = $query->get();
if ($posts->isEmpty()) {
$this->info('No posts to repair');
return self::SUCCESS;
}
$changed = 0;
foreach ($posts as $post) {
$cleaned = $normalizer->normalize((string) $post->content);
if ($cleaned === $post->content) {
continue;
}
if ($this->option('dry-run')) {
$this->line("[{$post->id}] {$post->title}");
continue;
}
$post->content = $cleaned;
$post->save();
$changed++;
}
$this->info($this->option('dry-run')
? 'Preview only, nothing written'
: "Repaired {$changed} posts");
return self::SUCCESS;
}
}
+8 -7
View File
@@ -2,19 +2,18 @@
declare(strict_types=1);
namespace App\Console\Commands;
use App\Blog\Services\AttachmentImporter;
use App\Models\Category;
use App\Models\Comment;
use App\Models\Link;
use App\Blog\Services\PostContentRenderer;
use App\Blog\Support\SablogHtmlNormalizer;
use App\Models\Post;
use App\Models\Setting;
use App\Models\User;
use App\Support\SlugGenerator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use League\HTMLToMarkdown\HtmlConverter;
use PDO;
use Spatie\Tags\Tag;
@@ -271,6 +270,7 @@ class SablogImport extends Command
'keywords' => $row['keywords'] ?: null,
'status' => $status,
'is_sticky' => (bool) $row['stick'],
'sticky_level' => ((bool) $row['stick']) ? 1 : 0,
'close_comment' => (bool) $row['closecomment'],
'read_password' => $row['readpassword'] ?: null,
'views' => (int) $row['views'],
@@ -362,15 +362,16 @@ class SablogImport extends Command
{
$this->info('转换文章内容为 Markdown ...');
$converter = new \League\HTMLToMarkdown\HtmlConverter([
$converter = new HtmlConverter([
'strip_tags' => false,
'hard_break' => true,
]);
$renderer = app(\App\Blog\Services\PostContentRenderer::class);
$renderer = app(PostContentRenderer::class);
$normalizer = app(SablogHtmlNormalizer::class);
$count = 0;
foreach (Post::query()->where('content_format', 'html')->cursor() as $post) {
$post->content = $converter->convert($renderer->toMarkdownSafeTokens($post->content));
$post->content = $normalizer->toMarkdown((string) $post->content, $converter, $renderer);
$post->content_format = 'markdown';
$post->save();
$count++;
+8
View File
@@ -42,6 +42,7 @@ class BlogSettings extends Page
// 外观
'active_theme' => Setting::get('active_theme', config('themes.default')),
'per_page' => Setting::get('per_page', config('blog.per_page')),
'home_view_mode' => Setting::get('home_view_mode', config('blog.home_view_mode', 'list')),
'list_shownum' => Setting::get('list_shownum', '50'),
'listtime' => Setting::get('listtime', 'Y-m-d'),
'normaltime' => Setting::get('normaltime', 'Y-m-d H:i'),
@@ -102,6 +103,13 @@ class BlogSettings extends Page
->columnSpanFull()
->options(collect(app(\App\Blog\Support\ThemeManager::class)->all())->pluck('title', 'name')),
TextInput::make('per_page')->label('首页每页文章数')->numeric(),
Select::make('home_view_mode')
->label('首页浏览模式')
->options([
'list' => '列表(标题 + 摘要)',
'full' => '标准(全文)',
])
->helperText('前台右上角仍可临时切换,和老 sablog 的「标准 | 列表」一样'),
TextInput::make('list_shownum')->label('列表每页数量')->numeric()->helperText('分类/标签/搜索等列表页'),
TextInput::make('listtime')->label('列表时间格式')->placeholder('Y-m-d'),
TextInput::make('normaltime')->label('详情时间格式')->placeholder('Y-m-d H:i'),
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use Filament\Pages\Page;
use Illuminate\Support\Facades\File;
class Languages extends Page
{
protected static \UnitEnum|string|null $navigationGroup = '管理';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-language';
protected static ?string $navigationLabel = '语言';
protected static ?string $title = '语言';
protected static ?string $slug = 'languages';
protected static ?int $navigationSort = 21;
protected string $view = 'filament.pages.languages';
/**
* @return array{
* locale: string,
* fallback: string,
* faker: string,
* app_files: list<string>,
* filament_locales: list<string>,
* content_i18n: bool
* }
*/
public function getInfoProperty(): array
{
return [
'locale' => app()->getLocale(),
'fallback' => (string) config('app.fallback_locale'),
'faker' => (string) config('app.faker_locale'),
'app_files' => $this->appLanguageFiles(),
'filament_locales' => $this->filamentLocales(),
'content_i18n' => false,
];
}
/**
* @return list<string>
*/
private function appLanguageFiles(): array
{
$found = [];
foreach (File::glob(lang_path('*.json')) as $file) {
$found[] = basename((string) $file, '.json');
}
foreach (File::directories(lang_path()) as $directory) {
$name = basename($directory);
if ($name === 'vendor') {
continue;
}
$found[] = $name;
}
$found = array_values(array_unique($found));
sort($found);
return $found;
}
/**
* @return list<string>
*/
private function filamentLocales(): array
{
$path = lang_path('vendor/filament');
if (! File::isDirectory($path)) {
return [];
}
$locales = array_map(
static fn (string $directory): string => basename($directory),
File::directories($path)
);
sort($locales);
return $locales;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Blog\Support\PageCacheMiddleware;
use App\Models\Setting;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Cache;
class PageCache extends Page
{
protected static \UnitEnum|string|null $navigationGroup = '管理';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-bolt';
protected static ?string $navigationLabel = '页面缓存';
protected static ?string $title = '页面缓存';
protected static ?string $slug = 'page-cache';
protected static ?int $navigationSort = 20;
protected string $view = 'filament.pages.page-cache';
/**
* @return array{hits: int, misses: int, count: int, rate: float, ttl: int}
*/
public function getStatsProperty(): array
{
return [
...PageCacheMiddleware::stats(),
'ttl' => (int) config('blog.page_cache_ttl', 300),
];
}
/**
* @return list<array{key: string, url: string, cached_at: string}>
*/
public function getEntriesProperty(): array
{
return PageCacheMiddleware::entries();
}
public function flushPageCache(): void
{
PageCacheMiddleware::flush();
Notification::make()->title('前台页面缓存已清空')->success()->send();
}
public function flushAllCache(): void
{
PageCacheMiddleware::flush();
Setting::flushCache();
Cache::flush();
Notification::make()->title('全部缓存已清空')->success()->send();
}
public function resetStats(): void
{
PageCacheMiddleware::resetStats();
Notification::make()->title('命中统计已重置')->success()->send();
}
public function forgetEntry(string $key): void
{
PageCacheMiddleware::forget($key);
Notification::make()->title('已删除这条缓存')->success()->send();
}
protected function getHeaderActions(): array
{
return [
Action::make('resetStats')
->label('重置统计')
->color('gray')
->action(fn () => $this->resetStats()),
Action::make('flushPageCache')
->label('清空前台缓存')
->color('warning')
->action(fn () => $this->flushPageCache()),
Action::make('flushAllCache')
->label('清空全部缓存')
->color('danger')
->requiresConfirmation()
->action(fn () => $this->flushAllCache()),
];
}
}
@@ -46,7 +46,8 @@ class CategoryForm
->label('封面图')
->module('articles')
->mediaType('image')
->relationship('coverMedia'),
->relationship('coverMedia')
->extraFieldWrapperAttributes(['class' => 'll-media-picker-preview']),
]),
]);
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Filament\Resources\Posts\Pages;
use App\Filament\Resources\Posts\PostResource;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
@@ -16,6 +16,12 @@ class EditPost extends EditRecord
protected function getHeaderActions(): array
{
return [
Action::make('preview')
->label('预览')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (): string => $this->record->url)
->openUrlInNewTab(),
DeleteAction::make(),
];
}
@@ -5,10 +5,11 @@ declare(strict_types=1);
namespace App\Filament\Resources\Posts\RelationManagers;
use App\Models\Comment;
use Filament\Forms\Components\TextInput;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\Action;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -22,7 +23,7 @@ class CommentsRelationManager extends RelationManager
protected static ?string $pluralModelLabel = '评论';
public function form(\Filament\Schemas\Schema $schema): \Filament\Schemas\Schema
public function form(Schema $schema): Schema
{
return $schema
->components([
+270 -104
View File
@@ -5,129 +5,295 @@ declare(strict_types=1);
namespace App\Filament\Resources\Posts\Schemas;
use App\Blog\Support\MarkdownMediaEmbed;
use App\Models\Post;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use ReflectionClass;
use Xpier\FilamentMediaLibrary\Components\MediaPicker;
use Xpier\FilamentMediaLibrary\Models\MediaLibrary;
class PostForm
{
public const SIDEBAR_KEY = 'post_form_sidebar';
public const ACCESS_KEY = 'post_form_access';
public static function configure(Schema $schema): Schema
{
return $schema
->columns(self::pcColumns(3))
->components([
\Filament\Schemas\Components\Section::make('内容')
->columns(2)
->schema([
TextInput::make('title')
->label('标题')
->required()
->columnSpanFull(),
TextInput::make('slug')
->label('Slug(留空自动生成)'),
Select::make('category_id')
->label('分类')
->relationship('category', 'name'),
Select::make('content_format')
->label('内容格式')
->options([
'markdown' => 'Markdown',
'html' => 'HTML',
])
->default('markdown')
->live(),
Select::make('status')
->label('状态')
->options([
'published' => '已发布',
'draft' => '草稿',
'private' => '私密',
])
->default('published'),
MarkdownEditor::make('content')
->label('正文')
->required()
->helperText('Markdown 插入图片用 ![说明](media:ID),文件用 [文件名](media:ID)。不要把 CDN / 对象存储地址写进正文。')
->fileAttachments(false)
->columnSpanFull(),
MediaPicker::make('insert_media_id')
->label('从媒体库插入正文')
->module('articles')
->mediaType(null)
->dehydrated(false)
->helperText('选好媒体后点「插入到正文」。Markdown 写入 media:ID 引用,渲染时再换成真实地址。')
->columnSpanFull(),
Actions::make([
Action::make('insertSelectedMedia')
->label('插入到正文')
->action(function (Get $get, Set $set): void {
$mediaId = $get('insert_media_id');
$media = is_numeric($mediaId)
? MediaLibrary::query()->find((int) $mediaId)
: null;
if (! $media instanceof MediaLibrary) {
Notification::make()->title('请先从媒体库选择文件')->warning()->send();
return;
}
$snippet = app(MarkdownMediaEmbed::class)->snippet(
$media,
(string) ($get('content_format') ?: 'markdown')
);
$set('content', rtrim((string) $get('content'))."\n\n".$snippet."\n");
$set('insert_media_id', null);
Notification::make()->title('已写入正文引用')->success()->send();
}),
])->columnSpanFull(),
]),
\Filament\Schemas\Components\Section::make('封面与摘要')
->columns(2)
->schema([
MediaPicker::make('cover_media_id')
->label('封面图')
->module('articles')
->mediaType('image')
->relationship('coverMedia')
->columnSpanFull(),
Textarea::make('excerpt')
->label('摘要(留空自动截取)')
->rows(3),
TextInput::make('keywords')
->label('关键词(逗号分隔)'),
]),
\Filament\Schemas\Components\Section::make('发布设置')
->columns(3)
->schema([
DateTimePicker::make('published_at')
->label('发布时间')
->default(now()),
Toggle::make('is_sticky')
->label('置顶'),
Toggle::make('close_comment')
->label('关闭评论'),
TextInput::make('read_password')
->label('阅读密码(留空公开)'),
TextInput::make('views')
->label('浏览量')
->numeric()
->disabled(),
TextInput::make('comment_count')
->label('评论数')
->numeric()
->disabled(),
]),
self::contentSection(),
self::sidebarGroup(),
]);
}
/**
* Append sections or fields into the right-hand sidebar (2:1 layout).
*
* @param array<Component> $components
*/
public static function appendToSidebar(Schema $schema, array $components): void
{
$sidebar = self::findKeyedComponent($schema->getComponents(), self::SIDEBAR_KEY);
if (! $sidebar instanceof Component) {
$schema->components([
...$schema->getComponents(),
...$components,
]);
return;
}
$existing = $sidebar->getDefaultChildComponents();
if ($existing instanceof Schema) {
$existing->components([
...$existing->getComponents(),
...$components,
]);
return;
}
$sidebar->schema([
...(is_array($existing) ? $existing : []),
...$components,
]);
}
/**
* Append fields into the access group (password / members / price).
*
* @param array<Component> $fields
*/
public static function appendToAccessSection(Schema $schema, array $fields): void
{
$sidebar = self::findKeyedComponent($schema->getComponents(), self::SIDEBAR_KEY);
$access = $sidebar instanceof Component
? self::findKeyedComponent(self::childList($sidebar), self::ACCESS_KEY)
: null;
if (! $access instanceof Component) {
self::appendToSidebar($schema, [
Section::make('访问设置')
->key(self::ACCESS_KEY)
->schema($fields),
]);
return;
}
$existing = $access->getDefaultChildComponents();
if ($existing instanceof Schema) {
$existing->components([
...$existing->getComponents(),
...$fields,
]);
return;
}
$access->schema([
...(is_array($existing) ? $existing : []),
...$fields,
]);
}
private static function contentSection(): Section
{
return Section::make('正文')
->columnSpan(self::pcColumns(2))
->columns(self::pcColumns(3))
->schema([
TextInput::make('title')
->label('标题')
->required()
->columnSpanFull(),
TextInput::make('slug')
->label('Slug(留空自动生成)')
->columnSpanFull(),
Select::make('category_id')
->label('分类')
->relationship('category', 'name'),
ToggleButtons::make('content_format')
->label('内容格式')
->options([
'markdown' => 'Markdown',
'html' => 'HTML',
])
->default('markdown')
->inline()
->grouped()
->live(),
ToggleButtons::make('status')
->label('状态')
->options([
'published' => '已发布',
'draft' => '草稿',
'private' => '私密',
])
->default('published')
->inline()
->grouped(),
MediaPicker::make('cover_media_id')
->label('封面图')
->module('articles')
->mediaType('image')
->relationship('coverMedia')
->extraFieldWrapperAttributes(['class' => 'll-media-picker-preview'])
->columnSpanFull(),
MarkdownEditor::make('content')
->label('正文')
->required()
->minHeight('32rem')
->helperText('Markdown 插入图片用 ![说明](media:ID),文件用 [文件名](media:ID)。不要把 CDN / 对象存储地址写进正文。')
->fileAttachments(false)
->columnSpanFull(),
MediaPicker::make('insert_media_id')
->label('从媒体库插入正文')
->module('articles')
->mediaType(null)
->dehydrated(false)
->extraFieldWrapperAttributes(['class' => 'll-media-picker-preview'])
->helperText('选好媒体后点「插入到正文」。Markdown 写入 media:ID 引用,渲染时再换成真实地址。')
->columnSpanFull(),
Actions::make([
Action::make('insertSelectedMedia')
->label('插入到正文')
->action(function (Get $get, Set $set): void {
$mediaId = $get('insert_media_id');
$media = is_numeric($mediaId)
? MediaLibrary::query()->find((int) $mediaId)
: null;
if (! $media instanceof MediaLibrary) {
Notification::make()->title('请先从媒体库选择文件')->warning()->send();
return;
}
$snippet = app(MarkdownMediaEmbed::class)->snippet(
$media,
(string) ($get('content_format') ?: 'markdown')
);
$set('content', rtrim((string) $get('content'))."\n\n".$snippet."\n");
$set('insert_media_id', null);
Notification::make()->title('已写入正文引用')->success()->send();
}),
])->columnSpanFull(),
]);
}
private static function sidebarGroup(): Group
{
return Group::make([
Section::make('发布设置')
->schema([
DateTimePicker::make('published_at')
->label('发布时间')
->default(now()),
ToggleButtons::make('sticky_level')
->label('置顶等级')
->options(Post::stickyLevelOptions())
->default(Post::STICKY_NONE)
->inline()
->grouped()
->helperText('轻置顶压过普通文章,强置顶压过轻置顶,置顶头条永远排在最上面。'),
ToggleButtons::make('close_comment')
->label('评论状态')
->boolean('关闭评论', '开放评论')
->default(false)
->inline()
->grouped()
->dehydrateStateUsing(fn (mixed $state): bool => (bool) $state),
Textarea::make('excerpt')
->label('摘要(留空自动截取)')
->rows(4),
TextInput::make('keywords')
->label('关键词(逗号分隔)'),
]),
Section::make('访问设置')
->key(self::ACCESS_KEY)
->schema([
TextInput::make('read_password')
->label('阅读密码(留空公开)'),
]),
])
->key(self::SIDEBAR_KEY)
->columnSpan(self::pcColumns(1));
}
/**
* Admin writing is almost always on a PC. Filament's columns(3) only
* applies from the lg breakpoint, so we pin every breakpoint.
*
* @return array<string, int>
*/
private static function pcColumns(int $count): array
{
return [
'default' => $count,
'sm' => $count,
'md' => $count,
'lg' => $count,
'xl' => $count,
'2xl' => $count,
];
}
/**
* @param array<int, mixed> $components
*/
private static function findKeyedComponent(array $components, string $key): ?Component
{
foreach ($components as $component) {
if (! $component instanceof Component) {
continue;
}
if (self::rawKey($component) === $key) {
return $component;
}
}
return null;
}
private static function rawKey(Component $component): ?string
{
$property = (new ReflectionClass($component))->getProperty('key');
$key = $property->getValue($component);
return is_string($key) ? $key : null;
}
/**
* @return array<int, mixed>
*/
private static function childList(Component $component): array
{
$children = $component->getDefaultChildComponents();
if ($children instanceof Schema) {
return $children->getComponents();
}
return is_array($children) ? $children : [];
}
}
@@ -2,18 +2,18 @@
declare(strict_types=1);
namespace App\Filament\Resources\Posts\Tables;
use App\Models\Post;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Select;
use Filament\Tables\Columns\IconColumn;
use Filament\Support\Enums\Width;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class PostsTable
@@ -46,9 +46,17 @@ class PostsTable
'draft' => 'warning',
default => 'gray',
}),
IconColumn::make('is_sticky')
TextColumn::make('sticky_level')
->label('置顶')
->boolean(),
->badge()
->sortable()
->formatStateUsing(fn ($state) => Post::stickyLevelOptions()[(int) $state] ?? '不置顶')
->color(fn ($state) => match ((int) $state) {
Post::STICKY_HEADLINE => 'danger',
Post::STICKY_STRONG => 'warning',
Post::STICKY_LIGHT => 'success',
default => 'gray',
}),
TextColumn::make('views')
->label('阅读')
->numeric()
@@ -79,12 +87,17 @@ class PostsTable
'markdown' => 'Markdown',
'html' => 'HTML',
]),
TernaryFilter::make('is_sticky')
->label('置顶'),
], \Filament\Tables\Enums\FiltersLayout::AboveContent)
SelectFilter::make('sticky_level')
->label('置顶')
->options(Post::stickyLevelOptions()),
], FiltersLayout::AboveContent)
->filtersFormColumns(3)
->filtersFormWidth(\Filament\Support\Enums\Width::ThreeExtraLarge)
->filtersFormWidth(Width::ThreeExtraLarge)
->recordActions([
Action::make('preview')
->label('预览')
->url(fn (Post $record): string => $record->url)
->openUrlInNewTab(),
ViewAction::make(),
EditAction::make(),
])
+4 -2
View File
@@ -2,9 +2,10 @@
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Blog\Support\PageCacheMiddleware;
use App\Filament\Pages\PageCache;
use App\Models\Comment;
use App\Models\Post;
use App\Models\User;
@@ -17,7 +18,7 @@ class BlogStats extends BaseWidget
protected function getStats(): array
{
$cache = \App\Blog\Support\PageCacheMiddleware::stats();
$cache = PageCacheMiddleware::stats();
return [
Stat::make('文章', Post::published()->count())
@@ -30,6 +31,7 @@ class BlogStats extends BaseWidget
->icon('heroicon-o-users'),
Stat::make('页面缓存', $cache['count'].' 条')
->description('命中率 '.$cache['rate'].'%(命中 '.$cache['hits'].' / 未命中 '.$cache['misses'].',按日滚动)')
->url(PageCache::getUrl())
->icon('heroicon-o-bolt'),
];
}
+61 -7
View File
@@ -2,25 +2,25 @@
declare(strict_types=1);
namespace App\Models;
use App\Support\MediaDisk;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\MediaLibrary\HasMedia;
use Xpier\FilamentMediaLibrary\Models\MediaLibrary;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\Tags\HasTags;
use Xpier\FilamentMediaLibrary\Models\MediaLibrary;
class Post extends Model implements HasMedia
{
use HasFactory;
use InteractsWithMedia;
use HasTags;
use InteractsWithMedia;
protected $fillable = [
'category_id',
@@ -34,6 +34,7 @@ class Post extends Model implements HasMedia
'cover_media_id',
'status',
'is_sticky',
'sticky_level',
'close_comment',
'read_password',
'views',
@@ -45,10 +46,63 @@ class Post extends Model implements HasMedia
protected $casts = [
'meta' => 'array',
'is_sticky' => 'boolean',
'sticky_level' => 'integer',
'close_comment' => 'boolean',
'published_at' => 'datetime',
];
public const STICKY_NONE = 0;
public const STICKY_LIGHT = 1;
public const STICKY_STRONG = 2;
public const STICKY_HEADLINE = 3;
/**
* @return array<int, string>
*/
public static function stickyLevelOptions(): array
{
return [
self::STICKY_NONE => '不置顶',
self::STICKY_LIGHT => '轻置顶',
self::STICKY_STRONG => '强置顶',
self::STICKY_HEADLINE => '置顶头条',
];
}
public function stickyLabel(): ?string
{
$level = (int) $this->sticky_level;
return $level > 0 ? (self::stickyLevelOptions()[$level] ?? '置顶') : null;
}
public function isPinned(): bool
{
return (int) $this->sticky_level > 0 || $this->is_sticky;
}
protected static function booted(): void
{
static::saving(function (Post $post): void {
$level = (int) ($post->sticky_level ?? 0);
$post->sticky_level = max(self::STICKY_NONE, min(self::STICKY_HEADLINE, $level));
if ($post->is_sticky && $post->sticky_level < self::STICKY_LIGHT) {
$post->sticky_level = self::STICKY_LIGHT;
}
$post->is_sticky = $post->sticky_level > self::STICKY_NONE;
});
}
public function scopeOrderedForList(Builder $query): Builder
{
return $query->orderByDesc('sticky_level')->latest('published_at');
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
@@ -107,8 +161,8 @@ class Post extends Model implements HasMedia
public function registerMediaCollections(): void
{
$this->addMediaCollection('attachments')->useDisk(\App\Support\MediaDisk::name());
$this->addMediaCollection('cover')->useDisk(\App\Support\MediaDisk::name())
$this->addMediaCollection('attachments')->useDisk(MediaDisk::name());
$this->addMediaCollection('cover')->useDisk(MediaDisk::name())
->singleFile()
->registerMediaConversions(function () {
$this->addMediaConversion('card')->width(1200)->height(630)->sharpen(1);
@@ -153,13 +207,13 @@ class Post extends Model implements HasMedia
return '';
}
$text = strip_tags($this->content);
$text = (new \App\Blog\Support\SablogHtmlNormalizer)->plainText((string) $this->content);
// 付费块不进入摘要,避免付费内容泄漏到列表页/SEO meta
$text = preg_replace('/\[paid\].*?\[\/paid\]/s', '', $text) ?? $text;
$text = preg_replace('/!\[([^\]]*)\]\((?:media|attach):\d+\)/i', '$1', $text) ?? $text;
$text = preg_replace('/\[([^\]]*)\]\((?:media|attach):\d+\)/i', '$1', $text) ?? $text;
$text = preg_replace('/\[[^\]]*\]/', '', $text) ?? $text;
return mb_substr($text, 0, 200);
return mb_substr(trim($text), 0, 200);
}
}
+39 -4
View File
@@ -4,10 +4,16 @@ declare(strict_types=1);
namespace App\Providers;
use App\Blog\Support\HomeViewMode;
use App\Blog\Support\PageCacheMiddleware;
use App\Blog\Support\PluginManager;
use App\Models\MarketPackage;
use Filament\Facades\Filament;
use Filament\View\PanelsRenderHook;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Plugins\Neatstudio\Payment\Models\Payment;
class AppServiceProvider extends ServiceProvider
{
@@ -21,16 +27,16 @@ class AppServiceProvider extends ServiceProvider
private function registerMarketPurchaseListener(): void
{
$manager = app(\App\Blog\Support\PluginManager::class);
$manager = app(PluginManager::class);
if (! $manager->isEnabled('neatstudio.payment')) {
return;
}
$manager->addAction('payment.paid', function (\Plugins\Neatstudio\Payment\Models\Payment $payment) {
$manager->addAction('payment.paid', function (Payment $payment) {
$payable = $payment->payable;
if ($payable instanceof \App\Models\MarketPackage) {
if ($payable instanceof MarketPackage) {
$payable->markPaid($payment);
}
}, 10);
@@ -54,7 +60,20 @@ class AppServiceProvider extends ServiceProvider
$this->registerMarketPurchaseListener();
// 前台页面缓存(匿名 GET,缓存含 SEO 注入后的最终 HTML)
app('router')->pushMiddlewareToGroup('web', \App\Blog\Support\PageCacheMiddleware::class);
app('router')->pushMiddlewareToGroup('web', PageCacheMiddleware::class);
View::composer([
'index',
'list',
'tag',
'search',
'partials.post-card',
'partials.view-mode-switcher',
], function ($view): void {
if (! $view->offsetExists('homeViewMode')) {
$view->with('homeViewMode', HomeViewMode::current());
}
});
// 后台紧凑化:侧边栏 + 表格行 + 筛选区(Filament 无官方全局紧凑开关,用 CSS 收紧)
Filament::serving(function () {
@@ -96,6 +115,22 @@ class AppServiceProvider extends ServiceProvider
/* 文章编辑框加大(MarkdownEditor 基于 tiptap) */
.fi-fo-markdown-editor .tiptap, .fi-fo-markdown-editor .ProseMirror { min-height: 26rem !important; }
/* 封面/媒体预览:禁止按栏宽 100% 拉伸,否则大图会把编辑页撑烂 */
.ll-media-picker-preview img,
img.h-32.max-w-full.rounded-md.object-contain {
max-width: 220px !important;
max-height: 124px !important;
width: auto !important;
height: auto !important;
object-fit: contain !important;
display: block !important;
}
.ll-media-picker-preview .relative,
.relative.rounded-lg:has(> img.h-32.max-w-full) {
width: fit-content;
max-width: 15rem;
}
/* 筛选条件区:整体一行横排,重置紧跟应用按钮,控件紧凑 */
.fi-ta-filters-above-content-ctn { padding: 0.4rem 0.9rem !important; }
.fi-ta-filters-heading { display: none !important; }
+12
View File
@@ -33,6 +33,18 @@ if (! function_exists('theme')) {
}
}
if (! function_exists('blog_paginate')) {
/**
* Front-end pager: fewer page chips so the sidebar column does not wrap under.
*/
function blog_paginate(\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $query, ?int $perPage = null): \Illuminate\Contracts\Pagination\LengthAwarePaginator
{
$perPage = max(1, (int) ($perPage ?? blog_setting('per_page', config('blog.per_page'))));
return $query->paginate($perPage)->onEachSide(1)->withQueryString();
}
}
if (! function_exists('blog_setting')) {
/**
* 读取博客设置(Setting 表,带缓存)。