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'));
}