@zlooks.cn/cli 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/command/index.js +59 -2
  2. package/dist/command/index.js.map +1 -1
  3. package/dist/operations.d.ts +2 -0
  4. package/dist/operations.d.ts.map +1 -1
  5. package/dist/operations.js +8 -10
  6. package/dist/operations.js.map +1 -1
  7. package/dist/prompt.d.ts +4 -0
  8. package/dist/prompt.d.ts.map +1 -1
  9. package/dist/prompt.js +7 -1
  10. package/dist/prompt.js.map +1 -1
  11. package/dist/service-catalog.d.ts +1 -1
  12. package/dist/service-catalog.d.ts.map +1 -1
  13. package/dist/service-catalog.js +1 -0
  14. package/dist/service-catalog.js.map +1 -1
  15. package/dist/theme-authoring.d.ts +36 -0
  16. package/dist/theme-authoring.d.ts.map +1 -0
  17. package/dist/theme-authoring.js +491 -0
  18. package/dist/theme-authoring.js.map +1 -0
  19. package/package.json +10 -4
  20. package/templates/blog-theme/README.md +26 -0
  21. package/templates/blog-theme/_gitignore +5 -0
  22. package/templates/blog-theme/hile-rsc.json +22 -0
  23. package/templates/blog-theme/package.json +64 -0
  24. package/templates/blog-theme/pnpm-workspace.yaml +5 -0
  25. package/templates/blog-theme/src/command/index.ts +11 -0
  26. package/templates/blog-theme/src/identity.ts +7 -0
  27. package/templates/blog-theme/src/plugin/archive-filter.tsx +62 -0
  28. package/templates/blog-theme/src/plugin/blog-frame.tsx +91 -0
  29. package/templates/blog-theme/src/plugin/blog-interactions.tsx +236 -0
  30. package/templates/blog-theme/src/plugin/page.tsx +174 -0
  31. package/templates/blog-theme/src/plugin/styles.d.ts +1 -0
  32. package/templates/blog-theme/src/plugin/theme.css +59 -0
  33. package/templates/blog-theme/src/services/blog-theme.boot.ts +17 -0
  34. package/templates/blog-theme/test/interactions.test.tsx.template +177 -0
  35. package/templates/blog-theme/test/pages.test.tsx.template +125 -0
  36. package/templates/blog-theme/test/shell.test.tsx.template +102 -0
  37. package/templates/blog-theme/test/theme-contract.test.ts.template +43 -0
  38. package/templates/blog-theme/tsconfig.json +16 -0
  39. package/templates/blog-theme/tsconfig.runtime.json +17 -0
  40. package/templates/blog-theme/vitest.config.ts +8 -0
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "__PACKAGE_NAME__",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=24.0.0"
8
+ },
9
+ "bin": {
10
+ "__PACKAGE_NAME__": "dist/command/index.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ ".hile-rsc"
15
+ ],
16
+ "zlooks": {
17
+ "kind": "theme",
18
+ "themeId": "__THEME_ID__",
19
+ "description": __DESCRIPTION_JSON__,
20
+ "dependsOn": [],
21
+ "nodeConditions": [
22
+ "react-server"
23
+ ]
24
+ },
25
+ "scripts": {
26
+ "build:rsc": "pnpm run clean:rsc && hile-rsc build",
27
+ "verify:rsc": "hile-rsc verify",
28
+ "build:runtime": "pnpm run clean:runtime && tsc -p tsconfig.runtime.json && fix-esm-import-path --preserve-import-type ./dist",
29
+ "build": "pnpm run build:rsc && pnpm run build:runtime",
30
+ "clean:runtime": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
31
+ "clean:rsc": "node -e \"require('node:fs').rmSync('.hile-rsc', { recursive: true, force: true })\"",
32
+ "dev": "NODE_OPTIONS=--conditions=react-server hile start --dev --env-file ~/.zlooks.cn/.env",
33
+ "start": "NODE_OPTIONS=--conditions=react-server hile start --env-file ~/.zlooks.cn/.env",
34
+ "test": "vitest run",
35
+ "typecheck": "tsc -p tsconfig.runtime.json --noEmit && tsc -p tsconfig.json --noEmit"
36
+ },
37
+ "dependencies": {
38
+ "@hile/rsc": "^1.0.18",
39
+ "@zlooks.cn/blog-schema": "^__BLOG_SCHEMA_VERSION__",
40
+ "@zlooks.cn/blog-theme": "^__BLOG_THEME_VERSION__",
41
+ "@zlooks.cn/global-config-shared": "^__GLOBAL_CONFIG_VERSION__",
42
+ "@zlooks.cn/service-command": "^__SERVICE_COMMAND_VERSION__",
43
+ "@zlooks.cn/ui": "^__UI_VERSION__",
44
+ "antd": "6.6.1",
45
+ "react": "^19.2.8",
46
+ "react-dom": "^19.2.8",
47
+ "react-markdown": "^10.1.0",
48
+ "react-server-dom-webpack": "^19.2.8",
49
+ "remark-gfm": "^4.0.1"
50
+ },
51
+ "devDependencies": {
52
+ "@hile/cli": "^4.0.5",
53
+ "@hile/context": "^4.0.2",
54
+ "@hile/core": "^4.0.1",
55
+ "@hile/rsc-build": "^1.0.15",
56
+ "@types/node": "^26.2.0",
57
+ "@types/react": "^19.2.18",
58
+ "@types/react-dom": "^19.2.4",
59
+ "fix-esm-import-path": "^1.10.3",
60
+ "happy-dom": "^20.14.0",
61
+ "typescript": "^7.0.2",
62
+ "vitest": "^4.1.11"
63
+ }
64
+ }
@@ -0,0 +1,5 @@
1
+ packages:
2
+ - .
3
+
4
+ allowBuilds:
5
+ esbuild: true
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url';
3
+ import { runServiceCommand } from '@zlooks.cn/service-command';
4
+
5
+ await runServiceCommand({
6
+ packageRoot: fileURLToPath(new URL('../../', import.meta.url)),
7
+ requiresReactServer: true,
8
+ }).catch((error: unknown) => {
9
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
10
+ process.exitCode = 1;
11
+ });
@@ -0,0 +1,7 @@
1
+ import { defineBlogTheme } from '@zlooks.cn/blog-theme';
2
+
3
+ export const BLOG_THEME = defineBlogTheme({
4
+ id: '__THEME_ID__',
5
+ displayName: __DISPLAY_NAME_JSON__,
6
+ description: __DESCRIPTION_JSON__,
7
+ });
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { RscLink, useRscNavigation } from '@hile/rsc/client/navigation';
4
+ import { blogPostArchiveInputSchema } from '@zlooks.cn/blog-schema';
5
+ import type { BlogArchiveFilter } from '@zlooks.cn/blog-theme';
6
+ import { ZlooksButton } from '@zlooks.cn/ui/button';
7
+ import Input from 'antd/es/input/Input.js';
8
+ import Select from 'antd/es/select/index.js';
9
+ import { useState, type FormEvent } from 'react';
10
+
11
+ type Mode = 'all' | 'year' | 'month' | 'range';
12
+
13
+ export default function ArchiveFilter({ filter }: { readonly filter: BlogArchiveFilter }) {
14
+ return <FilterForm key={JSON.stringify(filter)} filter={filter} />;
15
+ }
16
+
17
+ function FilterForm({ filter }: { readonly filter: BlogArchiveFilter }) {
18
+ const navigation = useRscNavigation();
19
+ const [mode, setMode] = useState<Mode>(filter.startYear ? 'range' : filter.month ? 'month' : filter.year ? 'year' : 'all');
20
+ const [year, setYear] = useState(String(filter.year ?? ''));
21
+ const [month, setMonth] = useState(monthValue(filter.year, filter.month));
22
+ const [start, setStart] = useState(monthValue(filter.startYear, filter.startMonth));
23
+ const [end, setEnd] = useState(monthValue(filter.endYear, filter.endMonth));
24
+ const [error, setError] = useState('');
25
+
26
+ function submit(event: FormEvent) {
27
+ event.preventDefault();
28
+ const selected = mode === 'all' ? {} : mode === 'year' ? { year: Number(year) }
29
+ : mode === 'month' ? parseMonth(month)
30
+ : { startYear: parseMonth(start).year, startMonth: parseMonth(start).month, endYear: parseMonth(end).year, endMonth: parseMonth(end).month };
31
+ const parsed = blogPostArchiveInputSchema.safeParse({ ...selected, limit: 1, monthLimit: 1 });
32
+ if (!parsed.success) { setError('请选择有效时间,起始月份不能晚于结束月份。'); return; }
33
+ setError('');
34
+ const query = new URLSearchParams(Object.entries(selected).map(([key, value]) => [key, String(value)]));
35
+ navigation.push(query.size ? `/blog/archives?${query}` : '/blog/archives');
36
+ }
37
+
38
+ return (
39
+ <form aria-label="归档时间筛选" className="theme-archive-filter" onSubmit={submit}>
40
+ <Select<Mode> aria-label="筛选方式" value={mode} onChange={(value) => { setMode(value); setError(''); }} options={[
41
+ { value: 'all', label: '全部' }, { value: 'year', label: '年份' }, { value: 'month', label: '月份' }, { value: 'range', label: '月份范围' },
42
+ ]} />
43
+ {mode === 'year' ? <Input aria-label="年份" type="number" min={1970} max={9999} required value={year} onChange={(event) => setYear(event.target.value)} /> : null}
44
+ {mode === 'month' ? <Input aria-label="月份" type="month" min="1970-01" max="9999-12" required value={month} onChange={(event) => setMonth(event.target.value)} /> : null}
45
+ {mode === 'range' ? <>
46
+ <Input aria-label="起始月份" type="month" min="1970-01" max="9999-12" required value={start} onChange={(event) => setStart(event.target.value)} />
47
+ <Input aria-label="结束月份" type="month" min={start || '1970-01'} max="9999-12" required value={end} onChange={(event) => setEnd(event.target.value)} />
48
+ </> : null}
49
+ <ZlooksButton htmlType="submit">应用筛选</ZlooksButton>
50
+ <RscLink href="/blog/archives">清除筛选</RscLink>
51
+ {error ? <p role="alert">{error}</p> : null}
52
+ </form>
53
+ );
54
+ }
55
+
56
+ function parseMonth(value: string) {
57
+ const [year, month] = value.split('-').map(Number);
58
+ return { year: year ?? NaN, month: month ?? NaN };
59
+ }
60
+ function monthValue(year?: number, month?: number): string {
61
+ return year && month ? `${year}-${String(month).padStart(2, '0')}` : '';
62
+ }
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ import { RscLink, useRscNavigation } from '@hile/rsc/client/navigation';
4
+ import type { BlogPageSummary } from '@zlooks.cn/blog-schema';
5
+ import type { SitePresentationSnapshot } from '@zlooks.cn/global-config-shared';
6
+ import { ZlooksUiProvider, useZlooksTheme, type ZlooksThemeMode } from '@zlooks.cn/ui';
7
+ import { ZlooksShellProvider, useZlooksNavigation, useZlooksPathname } from '@zlooks.cn/ui/shell-provider';
8
+ import { ZlooksUserAccess } from '@zlooks.cn/ui/shell';
9
+ import { useRequest } from '@zlooks.cn/ui/request';
10
+ import type { ZlooksRscShellContext } from '@zlooks.cn/ui/shell-context';
11
+ import { useSitePresentation, ZlooksSiteConfigProvider } from '@zlooks.cn/ui/site-config';
12
+ import type { ReactNode } from 'react';
13
+ import AntFlex from 'antd/es/flex/index.js';
14
+ import AntSegmented from 'antd/es/segmented/index.js';
15
+ import { BlogSearch } from './blog-interactions.js';
16
+ import './theme.css';
17
+
18
+ const Flex = (AntFlex as unknown as { default?: typeof AntFlex }).default ?? AntFlex;
19
+ const Segmented = (AntSegmented as unknown as { default?: typeof AntSegmented }).default ?? AntSegmented;
20
+
21
+ export default function BlogFrame({
22
+ children,
23
+ navigationPages,
24
+ shell,
25
+ site,
26
+ }: {
27
+ readonly children: ReactNode;
28
+ readonly navigationPages: readonly BlogPageSummary[];
29
+ readonly shell: ZlooksRscShellContext;
30
+ readonly site: SitePresentationSnapshot;
31
+ }) {
32
+ return (
33
+ <ZlooksUiProvider>
34
+ <ZlooksShellProvider initialContext={shell}>
35
+ <ZlooksSiteConfigProvider initialConfig={site}>
36
+ <ThemeLayout navigationPages={navigationPages}>{children}</ThemeLayout>
37
+ </ZlooksSiteConfigProvider>
38
+ </ZlooksShellProvider>
39
+ </ZlooksUiProvider>
40
+ );
41
+ }
42
+
43
+ function ThemeLayout({ children, navigationPages }: {
44
+ readonly children: ReactNode;
45
+ readonly navigationPages: readonly BlogPageSummary[];
46
+ }) {
47
+ const site = useSitePresentation();
48
+ const navigation = useZlooksNavigation();
49
+ const pathname = useZlooksPathname();
50
+ const rscNavigation = useRscNavigation();
51
+ const request = useRequest();
52
+ const { mode, setMode } = useZlooksTheme();
53
+ const links = [...navigation, { key: 'blog', label: '博客', href: '/blog' }, { key: 'archives', label: '归档', href: '/blog/archives' },
54
+ ...navigationPages.map((page) => ({ key: page.id, label: page.title, href: `/blog/pages/${page.slug}` })),
55
+ { key: 'friends', label: '友链', href: '/blog/friends' },
56
+ ].filter((item, index, items) => items.findIndex((other) => other.href === item.href) === index);
57
+ const activeHref = links.filter(({ href }) => pathname === href || (href !== '/' && pathname.startsWith(`${href}/`)))
58
+ .sort((a, b) => b.href.length - a.href.length)[0]?.href;
59
+
60
+ async function logout(href: string) {
61
+ await request(href, { method: 'POST', credentials: 'same-origin' }, { errorMessage: '退出登录失败' });
62
+ rscNavigation.refresh();
63
+ }
64
+ return (
65
+ <div className="theme-shell">
66
+ <Flex component="header" className="theme-header" align="center" gap="middle" wrap>
67
+ <RscLink className="theme-brand" href={site.identity.homeHref}>
68
+ {site.identity.logoUrl ? <img alt="" src={site.identity.logoUrl} /> : null}
69
+ <strong>{site.identity.name}</strong>
70
+ </RscLink>
71
+ <Flex component="nav" aria-label="主导航" gap="middle" wrap>
72
+ {links.map((item) => <RscLink aria-current={item.href === activeHref ? 'page' : undefined} href={item.href} key={item.href}>{item.label}</RscLink>)}
73
+ </Flex>
74
+ <BlogSearch />
75
+ <div role="group" aria-label="主题">
76
+ <Segmented<ZlooksThemeMode> value={mode} onChange={setMode} options={[
77
+ { label: '浅色', value: 'light' }, { label: '深色', value: 'dark' }, { label: '自动', value: 'default' },
78
+ ]} />
79
+ </div>
80
+ <ZlooksUserAccess onLogout={logout} onNavigate={rscNavigation.push} variant="avatar" />
81
+ </Flex>
82
+ <main className="theme-main">{children}</main>
83
+ <Flex component="footer" className="theme-footer" vertical align="center" gap="small">
84
+ {site.legal.registrations.map((registration) => registration.verificationUrl
85
+ ? <a key={registration.id} href={registration.verificationUrl} target="_blank" rel="noopener noreferrer">{registration.number}</a>
86
+ : <span key={registration.id}>{registration.number}</span>)}
87
+ <span>{site.legal.copyright ?? '由 Zlooks 驱动'}</span>
88
+ </Flex>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,236 @@
1
+ 'use client';
2
+
3
+ import { RscLink } from '@hile/rsc/client/navigation';
4
+ import type { BlogComment, BlogPostEngagement, BlogPostSummary } from '@zlooks.cn/blog-schema';
5
+ import { createBlogThemeClient } from '@zlooks.cn/blog-theme/client';
6
+ import { ZlooksButton as Button } from '@zlooks.cn/ui/button';
7
+ import { useRequest } from '@zlooks.cn/ui/request';
8
+ import Input from 'antd/es/input/Input.js';
9
+ import TextArea from 'antd/es/input/TextArea.js';
10
+ import { useEffect, useRef, useState, type FormEvent } from 'react';
11
+ import ReactMarkdown from 'react-markdown';
12
+ import remarkGfm from 'remark-gfm';
13
+
14
+ export function MarkdownContent({ children }: { readonly children: string }) {
15
+ return <ReactMarkdown skipHtml remarkPlugins={[remarkGfm]} components={{ h1: ({ children }) => <h2>{children}</h2> }}>{children}</ReactMarkdown>;
16
+ }
17
+
18
+ export function BlogSearch() {
19
+ const request = useRequest();
20
+ const client = createBlogThemeClient(request);
21
+ const controller = useRef<AbortController | undefined>(undefined);
22
+ const [query, setQuery] = useState('');
23
+ const [results, setResults] = useState<readonly BlogPostSummary[]>([]);
24
+ const [loading, setLoading] = useState(false);
25
+ const [searched, setSearched] = useState(false);
26
+ useEffect(() => () => controller.current?.abort(), []);
27
+
28
+ async function search(event: FormEvent) {
29
+ event.preventDefault();
30
+ const normalized = query.trim();
31
+ if (normalized.length < 2 || normalized.length > 100) return;
32
+ controller.current?.abort();
33
+ const current = new AbortController();
34
+ controller.current = current;
35
+ setLoading(true);
36
+ setSearched(false);
37
+ try {
38
+ const response = await client.searchPosts(
39
+ { query: normalized, limit: 10 },
40
+ { signal: current.signal, errorMessage: '文章搜索失败' },
41
+ );
42
+ if (!current.signal.aborted) {
43
+ setResults(response.items);
44
+ setSearched(true);
45
+ }
46
+ } catch {
47
+ // useRequest owns bounded user-facing error presentation.
48
+ } finally {
49
+ if (controller.current === current) controller.current = undefined;
50
+ if (!current.signal.aborted) setLoading(false);
51
+ }
52
+ }
53
+
54
+ return (
55
+ <form className="theme-search" onSubmit={search} role="search">
56
+ <Input
57
+ aria-label="搜索文章"
58
+ maxLength={100}
59
+ onChange={(event) => {
60
+ controller.current?.abort();
61
+ controller.current = undefined;
62
+ setLoading(false);
63
+ setSearched(false);
64
+ setResults([]);
65
+ setQuery(event.target.value);
66
+ }}
67
+ placeholder="搜索文章"
68
+ value={query}
69
+ />
70
+ <Button className="theme-action" htmlType="submit" loading={loading} disabled={query.trim().length < 2}>搜索</Button>
71
+ {results.length > 0 ? (
72
+ <div className="theme-search-results">
73
+ {results.map((post) => <RscLink href={`/blog/posts/${post.slug}`} key={post.id}>{post.title}</RscLink>)}
74
+ </div>
75
+ ) : null}
76
+ {searched && results.length === 0 ? <p role="status">未找到相关文章</p> : null}
77
+ </form>
78
+ );
79
+ }
80
+
81
+ export function ArticleEngagement({ engagement, postSlug }: {
82
+ readonly engagement: BlogPostEngagement;
83
+ readonly postSlug: string;
84
+ }) {
85
+ const request = useRequest();
86
+ const client = createBlogThemeClient(request);
87
+ const controller = useRef<AbortController | undefined>(undefined);
88
+ const [liked, setLiked] = useState(engagement.viewerState === 'liked');
89
+ const [likeCount, setLikeCount] = useState(engagement.likeCount);
90
+ const [loading, setLoading] = useState(false);
91
+ const canLike = engagement.viewerState === 'liked' || engagement.viewerState === 'not-liked';
92
+ useEffect(() => () => controller.current?.abort(), []);
93
+
94
+ async function toggleLike() {
95
+ if (!canLike || controller.current) return;
96
+ const current = new AbortController();
97
+ controller.current = current;
98
+ setLoading(true);
99
+ try {
100
+ const response = liked
101
+ ? await client.unlikePost({ slug: postSlug }, { signal: current.signal, errorMessage: '取消点赞失败' })
102
+ : await client.likePost({ slug: postSlug }, { signal: current.signal, errorMessage: '点赞失败' });
103
+ if (!current.signal.aborted) {
104
+ setLiked(response.liked);
105
+ setLikeCount(response.likeCount);
106
+ }
107
+ } catch {
108
+ // useRequest owns bounded user-facing error presentation.
109
+ } finally {
110
+ if (controller.current === current) controller.current = undefined;
111
+ if (!current.signal.aborted) setLoading(false);
112
+ }
113
+ }
114
+
115
+ return (
116
+ <div className="theme-engagement">
117
+ <span>{engagement.viewCount} 次浏览</span>
118
+ <Button type={liked ? 'primary' : 'default'} aria-pressed={liked} disabled={!canLike} loading={loading} onClick={() => void toggleLike()}>
119
+ {liked ? '已赞' : '点赞'} {likeCount}
120
+ </Button>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ export function CommentSection({ comments, nextCursor: initialCursor, postSlug, viewerStatus }: {
126
+ readonly comments: readonly BlogComment[];
127
+ readonly nextCursor: string | null;
128
+ readonly postSlug: string;
129
+ readonly viewerStatus: 'authenticated' | 'anonymous' | 'unavailable';
130
+ }) {
131
+ const request = useRequest();
132
+ const client = createBlogThemeClient(request);
133
+ const controller = useRef<AbortController | undefined>(undefined);
134
+ const [items, setItems] = useState<readonly BlogComment[]>(comments);
135
+ const [cursor, setCursor] = useState(initialCursor);
136
+ const [order, setOrder] = useState<'desc' | 'asc'>('desc');
137
+ const [content, setContent] = useState('');
138
+ const [submitted, setSubmitted] = useState(false);
139
+ const [loading, setLoading] = useState(false);
140
+ useEffect(() => () => controller.current?.abort(), []);
141
+
142
+ async function load(nextOrder: 'desc' | 'asc', nextCursor?: string) {
143
+ // A read must never cancel an accepted write, and cursors belong to the committed order.
144
+ if (controller.current) return;
145
+ const current = new AbortController();
146
+ controller.current = current;
147
+ setLoading(true);
148
+ try {
149
+ const response = await client.listComments(
150
+ { postSlug, limit: 20, order: nextOrder, ...(nextCursor ? { cursor: nextCursor } : {}) },
151
+ { signal: current.signal, errorMessage: '评论加载失败' },
152
+ );
153
+ if (!current.signal.aborted) {
154
+ setItems((existing) => mergeComments(nextCursor ? existing : [], response.items, nextOrder));
155
+ setCursor(response.nextCursor);
156
+ setOrder(nextOrder);
157
+ }
158
+ } catch {
159
+ // useRequest owns bounded user-facing error presentation.
160
+ } finally {
161
+ if (controller.current === current) controller.current = undefined;
162
+ if (!current.signal.aborted) setLoading(false);
163
+ }
164
+ }
165
+
166
+ async function submit(event: FormEvent) {
167
+ event.preventDefault();
168
+ const normalized = content.trim();
169
+ if (viewerStatus !== 'authenticated' || !normalized || normalized.length > 2000 || controller.current) return;
170
+ const submittedSnapshot = content;
171
+ const current = new AbortController();
172
+ controller.current = current;
173
+ setLoading(true);
174
+ try {
175
+ const comment = await client.createComment(
176
+ { postSlug, content: normalized },
177
+ { signal: current.signal, errorMessage: '评论提交失败' },
178
+ );
179
+ if (!current.signal.aborted) {
180
+ setItems((existing) => mergeComments(existing, [comment], order));
181
+ setContent((latest) => latest === submittedSnapshot ? '' : latest);
182
+ setSubmitted(true);
183
+ }
184
+ } catch {
185
+ // useRequest owns bounded user-facing error presentation.
186
+ } finally {
187
+ if (controller.current === current) controller.current = undefined;
188
+ if (!current.signal.aborted) setLoading(false);
189
+ }
190
+ }
191
+
192
+ return (
193
+ <section className="theme-comments" id="comments">
194
+ <header><h2>评论</h2></header>
195
+ {viewerStatus === 'authenticated' ? (
196
+ <form onSubmit={submit}>
197
+ <TextArea aria-label="评论内容" maxLength={2000} onChange={(event) => { setContent(event.target.value); setSubmitted(false); }} rows={5} value={content} />
198
+ <Button type="primary" disabled={!content.trim()} htmlType="submit" loading={loading}>提交评论</Button>
199
+ {submitted ? <p role="status">评论已提交,审核通过后将公开展示。</p> : null}
200
+ </form>
201
+ ) : <p>{viewerStatus === 'anonymous' ? '登录后才能发表评论。' : '账户服务暂不可用。'}</p>}
202
+ {items.length > 0 ? (
203
+ <>
204
+ <div aria-label="评论排序" className="theme-comment-order" role="group">
205
+ <Button aria-pressed={order === 'desc'} type={order === 'desc' ? 'primary' : 'default'} disabled={loading} onClick={() => void load('desc')}>最新优先</Button>
206
+ <Button aria-pressed={order === 'asc'} type={order === 'asc' ? 'primary' : 'default'} disabled={loading} onClick={() => void load('asc')}>最早优先</Button>
207
+ </div>
208
+ <div className="theme-comment-list">
209
+ {items.map((comment) => (
210
+ <article key={comment.id}>
211
+ <header><strong>{comment.user.displayName ?? '读者'}</strong><time dateTime={comment.createdAt}>{formatDate(comment.createdAt)}</time></header>
212
+ {comment.pendingReview ? <span>待审核</span> : null}
213
+ <MarkdownContent>{comment.content}</MarkdownContent>
214
+ {comment.replyContent ? <aside><strong>站长回复</strong><MarkdownContent>{comment.replyContent}</MarkdownContent></aside> : null}
215
+ </article>
216
+ ))}
217
+ </div>
218
+ {cursor ? <Button block className="theme-action theme-action-block" loading={loading} onClick={() => void load(order, cursor)}>加载更多评论</Button> : null}
219
+ </>
220
+ ) : <p>暂无评论。</p>}
221
+ </section>
222
+ );
223
+ }
224
+
225
+ function mergeComments(current: readonly BlogComment[], incoming: readonly BlogComment[], order: 'asc' | 'desc'): readonly BlogComment[] {
226
+ const byId = new Map([...current, ...incoming].map((comment) => [comment.id, comment]));
227
+ return [...byId.values()].sort((left, right) => {
228
+ const comparison = Date.parse(left.createdAt) - Date.parse(right.createdAt)
229
+ || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
230
+ return order === 'asc' ? comparison : -comparison;
231
+ });
232
+ }
233
+
234
+ function formatDate(value: string): string {
235
+ return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeZone: 'Asia/Shanghai' }).format(new Date(value));
236
+ }
@@ -0,0 +1,174 @@
1
+ import { RscLink } from '@hile/rsc/client/navigation';
2
+ import type { BlogPostSummary } from '@zlooks.cn/blog-schema';
3
+ import type { BlogThemeDataLoader, BlogThemeFrameModel, BlogThemeRouteProps } from '@zlooks.cn/blog-theme';
4
+ import { createServiceBackedBlogThemeDataLoader } from '@zlooks.cn/blog-theme/rsc';
5
+ import { ZlooksSeo, type ZlooksSeoModel } from '@zlooks.cn/ui/seo';
6
+ import type { ReactNode } from 'react';
7
+ import { BLOG_THEME } from '../identity.js';
8
+ import BlogFrame from './blog-frame.js';
9
+ import ArchiveFilter from './archive-filter.js';
10
+ import { ArticleEngagement, CommentSection, MarkdownContent } from './blog-interactions.js';
11
+
12
+ const data = createServiceBackedBlogThemeDataLoader({ definition: BLOG_THEME });
13
+
14
+ export async function BlogHomePage(props: BlogThemeRouteProps) {
15
+ const model = await data.home(props);
16
+ return (
17
+ <Frame model={model.frame} seo={model.seo}>
18
+ <section className="theme-hero"><p>{__DISPLAY_NAME_JSON__}</p><h1>{model.frame.site.value.identity.name}</h1></section>
19
+ <div className="theme-layout">
20
+ <section>
21
+ <h2>最新文章</h2>
22
+ <PostGrid posts={model.posts.items} />
23
+ <Pagination current={model.page} pageSize={model.pageSize} path="/blog" total={model.posts.total} />
24
+ </section>
25
+ <aside className="theme-sidebar">
26
+ {model.sidebarPages.map((page) => <section key={page.id}><h2><RscLink href={`/blog/pages/${page.slug}`}>{page.title}</RscLink></h2><p>{page.summary}</p></section>)}
27
+ <section><h2>最新发布</h2>{model.latestPosts.map((post) => <RscLink href={`/blog/posts/${post.slug}`} key={post.id}>{post.title}</RscLink>)}</section>
28
+ <section><h2>分类</h2>{model.categories.map((category) => <RscLink href={`/blog/categories/${category.slug}`} key={category.id}>{category.name} · {category.postCount}</RscLink>)}</section>
29
+ <section><h2>标签</h2>{model.tags.map((tag) => <RscLink href={`/blog/tags/${tag.slug}`} key={tag.id}>#{tag.name}</RscLink>)}</section>
30
+ <section><h2>归档</h2>{model.archive.months.map((month) => <RscLink href={`/blog/archives?year=${month.year}&month=${month.month}`} key={`${month.year}-${month.month}`}>{month.year}-{month.month} · {month.count}</RscLink>)}</section>
31
+ <section><h2>最新评论</h2>{model.recentComments.map(({ comment, post }) => <RscLink href={`/blog/posts/${post.slug}#comments`} key={comment.id}>{comment.user.displayName ?? '读者'}:{comment.content}</RscLink>)}</section>
32
+ </aside>
33
+ </div>
34
+ </Frame>
35
+ );
36
+ }
37
+
38
+ export async function BlogPostPage(props: BlogThemeRouteProps) {
39
+ const model = await data.post(props);
40
+ if (model.kind === 'not-found') return renderMissing(model, props, 'post', '文章不存在');
41
+ return (
42
+ <Frame model={model.frame} seo={model.seo}>
43
+ <article className="theme-article">
44
+ <header>
45
+ <RscLink href={`/blog/categories/${model.post.category.slug}`}>{model.post.category.name}</RscLink>
46
+ <h1>{model.post.title}</h1>
47
+ {model.post.summary ? <p>{model.post.summary}</p> : null}
48
+ <time dateTime={model.post.publishedAt}>{formatDate(model.post.publishedAt)}</time>
49
+ <ArticleEngagement key={`${model.post.slug}:${JSON.stringify(model.engagement)}`} engagement={model.engagement} postSlug={model.post.slug} />
50
+ </header>
51
+ {model.post.coverUrl ? <img alt="" className="theme-cover" src={model.post.coverUrl} /> : null}
52
+ <div className="theme-prose"><MarkdownContent>{model.post.content}</MarkdownContent></div>
53
+ <nav aria-label="文章标签">{model.post.tags.map((tag) => <RscLink href={`/blog/tags/${tag.slug}`} key={tag.id}>#{tag.name}</RscLink>)}</nav>
54
+ <nav className="theme-connections" aria-label="相邻文章">
55
+ {model.connections.previousPost ? <RscLink href={`/blog/posts/${model.connections.previousPost.slug}`}>← {model.connections.previousPost.title}</RscLink> : <span />}
56
+ {model.connections.nextPost ? <RscLink href={`/blog/posts/${model.connections.nextPost.slug}`}>{model.connections.nextPost.title} →</RscLink> : null}
57
+ </nav>
58
+ {model.connections.relatedPosts.length ? <section><h2>相关文章</h2><PostGrid posts={model.connections.relatedPosts} /></section> : null}
59
+ <CommentSection key={`${model.post.slug}:${model.viewerStatus}:${JSON.stringify(model.comments)}`} comments={model.comments} nextCursor={model.nextCommentCursor} postSlug={model.post.slug} viewerStatus={model.viewerStatus} />
60
+ </article>
61
+ <aside className="theme-post-sidebar">
62
+ <h2>最新文章</h2>{model.sidebar.latestPosts.map((post) => <RscLink href={`/blog/posts/${post.slug}`} key={post.id}>{post.title}</RscLink>)}
63
+ <h2>分类</h2>{model.sidebar.categories.map((category) => <RscLink href={`/blog/categories/${category.slug}`} key={category.id}>{category.name}</RscLink>)}
64
+ <h2>最新评论</h2>{model.sidebar.recentComments.map(({ comment, post }) => <RscLink href={`/blog/posts/${post.slug}#comments`} key={comment.id}>{comment.content}</RscLink>)}
65
+ </aside>
66
+ </Frame>
67
+ );
68
+ }
69
+
70
+ export async function BlogArchivePage(props: BlogThemeRouteProps) {
71
+ const model = await data.archive(props);
72
+ return (
73
+ <Frame model={model.frame} seo={model.seo}>
74
+ <section><header className="theme-page-heading"><h1>文章归档</h1><p>共 {model.archive.total} 篇文章</p></header>
75
+ <ArchiveFilter filter={model.filter} />
76
+ <PostGrid posts={model.archive.items} />
77
+ <Pagination current={model.page} pageSize={model.pageSize} path={archivePath(model.filter)} total={model.archive.total} />
78
+ </section>
79
+ </Frame>
80
+ );
81
+ }
82
+
83
+ export async function BlogSinglePagePage(props: BlogThemeRouteProps) {
84
+ const model = await data.page(props);
85
+ if (!('frame' in model)) return <DeferredFrame props={props} resource="page"><Missing title="页面不存在" /></DeferredFrame>;
86
+ return (
87
+ <Frame model={model.frame} seo={model.seo}>
88
+ {model.kind === 'page' ? <article className="theme-article"><header><h1>{model.page.title}</h1><p>{model.page.summary}</p></header><div className="theme-prose"><MarkdownContent>{model.page.content}</MarkdownContent></div></article> : <Missing title="页面不存在" />}
89
+ </Frame>
90
+ );
91
+ }
92
+
93
+ export async function BlogCategoryPage(props: BlogThemeRouteProps) {
94
+ return renderTaxonomy(await data.category(props), props, 'category');
95
+ }
96
+
97
+ export async function BlogTagPage(props: BlogThemeRouteProps) {
98
+ return renderTaxonomy(await data.tag(props), props, 'tag');
99
+ }
100
+
101
+ export async function BlogFriendLinksPage(props: BlogThemeRouteProps) {
102
+ const model = await data.friends(props);
103
+ return (
104
+ <Frame model={model.frame} seo={model.seo}>
105
+ <section><header className="theme-page-heading"><h1>友情链接</h1><p>值得持续访问的站点。</p></header>
106
+ <div className="theme-friends">{model.links.map((link) => <a href={link.url} key={link.id} rel="noopener noreferrer" target="_blank">{link.logoUrl ? <img alt="" src={link.logoUrl} width={48} height={48} /> : null}<strong>{link.name}</strong>{link.description ? <p>{link.description}</p> : null}</a>)}</div>
107
+ {model.links.length === 0 ? <Missing title="暂无友情链接" /> : null}
108
+ </section>
109
+ </Frame>
110
+ );
111
+ }
112
+
113
+ function renderTaxonomy(
114
+ model: Awaited<ReturnType<BlogThemeDataLoader['category']>>,
115
+ props: BlogThemeRouteProps,
116
+ kind: 'category' | 'tag',
117
+ ) {
118
+ if (model.kind === 'not-found') return renderMissing(model, props, kind, `${kind === 'category' ? '分类' : '标签'}不存在`);
119
+ return (
120
+ <Frame model={model.frame} seo={model.seo}>
121
+ <section><header className="theme-page-heading"><h1>{kind === 'tag' ? '# ' : ''}{model.taxonomy.name}</h1><p>共 {model.total} 篇文章</p></header>
122
+ <PostGrid posts={model.posts} />
123
+ <Pagination current={model.page} pageSize={model.pageSize} path={model.path} total={model.total} />
124
+ </section>
125
+ </Frame>
126
+ );
127
+ }
128
+
129
+ function PostGrid({ posts }: { readonly posts: readonly BlogPostSummary[] }) {
130
+ if (posts.length === 0) return <Missing title="这里还没有文章" />;
131
+ return <div className="theme-post-grid">{posts.map((post) => <article key={post.id}>{post.coverUrl ? <img alt="" src={post.coverUrl} /> : null}<RscLink href={`/blog/categories/${post.category.slug}`}>{post.category.name}</RscLink><h3><RscLink href={`/blog/posts/${post.slug}`}>{post.title}</RscLink></h3>{post.summary ? <p>{post.summary}</p> : null}<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time></article>)}</div>;
132
+ }
133
+
134
+ function Pagination({ current, pageSize, path, total }: { readonly current: number; readonly pageSize: number; readonly path: string; readonly total: number }) {
135
+ const pages = Math.max(1, Math.ceil(total / pageSize));
136
+ if (pages <= 1) return null;
137
+ const separator = path.includes('?') ? '&' : '?';
138
+ const visiblePages = [...new Set([1, ...Array.from({ length: 5 }, (_, index) => current + index - 2), pages])].filter((page) => page >= 1 && page <= pages);
139
+ return <nav className="theme-pagination" aria-label="分页">{current > 1 ? <RscLink href={`${path}${separator}page=${current - 1}`}>上一页</RscLink> : null}{visiblePages.map((page, index) => <span key={page}>{index > 0 && page - visiblePages[index - 1]! > 1 ? <span aria-hidden="true">… </span> : null}<RscLink aria-current={current === page ? 'page' : undefined} href={`${path}${separator}page=${page}`}>{page}</RscLink></span>)}{current < pages ? <RscLink href={`${path}${separator}page=${current + 1}`}>下一页</RscLink> : null}</nav>;
140
+ }
141
+
142
+ function Frame({ children, model, seo }: { readonly children: ReactNode; readonly model: BlogThemeFrameModel; readonly seo: ZlooksSeoModel }) {
143
+ return <><ZlooksSeo seo={seo} /><BlogFrame navigationPages={model.navigationPages} shell={model.shell} site={model.site}>{children}</BlogFrame></>;
144
+ }
145
+
146
+ function renderMissing(model: object, props: BlogThemeRouteProps, resource: 'post' | 'category' | 'tag', title: string) {
147
+ return hasFrameAndSeo(model)
148
+ ? <Frame model={model.frame} seo={model.seo}><Missing title={title} /></Frame>
149
+ : <DeferredFrame props={props} resource={resource}><Missing title={title} /></DeferredFrame>;
150
+ }
151
+
152
+ function hasFrameAndSeo(value: object): value is { readonly frame: BlogThemeFrameModel; readonly seo: ZlooksSeoModel } {
153
+ return 'frame' in value && 'seo' in value;
154
+ }
155
+
156
+ async function DeferredFrame({ children, props, resource }: { readonly children: ReactNode; readonly props: BlogThemeRouteProps; readonly resource: 'post' | 'page' | 'category' | 'tag' }) {
157
+ const model = await data.notFound(props, resource);
158
+ return <Frame model={model.frame} seo={model.seo}>{children}</Frame>;
159
+ }
160
+
161
+ function Missing({ title }: { readonly title: string }) { return <p className="theme-empty">{title}</p>; }
162
+ function formatDate(value: string) { return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeZone: 'Asia/Shanghai' }).format(new Date(value)); }
163
+ function archivePath(filter: {
164
+ readonly year?: number;
165
+ readonly month?: number;
166
+ readonly startYear?: number;
167
+ readonly startMonth?: number;
168
+ readonly endYear?: number;
169
+ readonly endMonth?: number;
170
+ }) {
171
+ const query = new URLSearchParams();
172
+ for (const [key, value] of Object.entries(filter)) if (value !== undefined) query.set(key, String(value));
173
+ return query.size ? `/blog/archives?${query}` : '/blog/archives';
174
+ }