@zlooks.cn/cli 1.0.5 → 1.0.6

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 (28) hide show
  1. package/dist/command/index.js +57 -0
  2. package/dist/command/index.js.map +1 -1
  3. package/dist/theme-authoring.d.ts +36 -0
  4. package/dist/theme-authoring.d.ts.map +1 -0
  5. package/dist/theme-authoring.js +491 -0
  6. package/dist/theme-authoring.js.map +1 -0
  7. package/package.json +9 -3
  8. package/templates/blog-theme/README.md +26 -0
  9. package/templates/blog-theme/_gitignore +5 -0
  10. package/templates/blog-theme/hile-rsc.json +22 -0
  11. package/templates/blog-theme/package.json +64 -0
  12. package/templates/blog-theme/pnpm-workspace.yaml +5 -0
  13. package/templates/blog-theme/src/command/index.ts +11 -0
  14. package/templates/blog-theme/src/identity.ts +7 -0
  15. package/templates/blog-theme/src/plugin/archive-filter.tsx +62 -0
  16. package/templates/blog-theme/src/plugin/blog-frame.tsx +91 -0
  17. package/templates/blog-theme/src/plugin/blog-interactions.tsx +236 -0
  18. package/templates/blog-theme/src/plugin/page.tsx +174 -0
  19. package/templates/blog-theme/src/plugin/styles.d.ts +1 -0
  20. package/templates/blog-theme/src/plugin/theme.css +59 -0
  21. package/templates/blog-theme/src/services/blog-theme.boot.ts +17 -0
  22. package/templates/blog-theme/test/interactions.test.tsx.template +177 -0
  23. package/templates/blog-theme/test/pages.test.tsx.template +125 -0
  24. package/templates/blog-theme/test/shell.test.tsx.template +102 -0
  25. package/templates/blog-theme/test/theme-contract.test.ts.template +43 -0
  26. package/templates/blog-theme/tsconfig.json +16 -0
  27. package/templates/blog-theme/tsconfig.runtime.json +17 -0
  28. package/templates/blog-theme/vitest.config.ts +8 -0
@@ -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
+ }
@@ -0,0 +1 @@
1
+ declare module '*.css';
@@ -0,0 +1,59 @@
1
+ .theme-shell {
2
+ --theme-paper: var(--ant-color-bg-layout);
3
+ --theme-ink: var(--ant-color-text);
4
+ --theme-muted: var(--ant-color-text-secondary);
5
+ --theme-line: var(--ant-color-border);
6
+ --theme-accent: var(--ant-color-primary);
7
+ color: var(--theme-ink);
8
+ background: var(--theme-paper);
9
+ }
10
+
11
+ .theme-shell, .theme-shell * { box-sizing: border-box; }
12
+ .theme-shell a { color: inherit; text-decoration: none; }
13
+ .theme-shell a:focus-visible { outline: 2px solid var(--theme-accent); outline-offset: 3px; }
14
+ .theme-shell { min-height: 100vh; font-family: ui-serif, Georgia, serif; }
15
+ .theme-header { position: sticky; top: 0; z-index: 10; display: flex; gap: 1.25rem; align-items: center; padding: 1rem max(1rem, calc((100vw - 1180px) / 2)); border-bottom: 1px solid var(--theme-line); background: color-mix(in srgb, var(--theme-paper) 92%, transparent); backdrop-filter: blur(12px); }
16
+ .theme-brand { display: flex; gap: .6rem; align-items: center; margin-right: auto; font-size: 1.1rem; }
17
+ .theme-brand img { width: 2rem; height: 2rem; object-fit: contain; }
18
+ .theme-header nav { display: flex; gap: 1rem; align-items: center; }
19
+ .theme-header nav a:hover, .theme-sidebar a:hover { color: var(--theme-accent); }
20
+ .theme-main { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; padding: 3rem 0 5rem; }
21
+ .theme-footer { padding: 2rem; border-top: 1px solid var(--theme-line); color: var(--theme-muted); text-align: center; }
22
+ .theme-hero, .theme-page-heading { margin-bottom: 2.5rem; padding: 4rem 0; border-bottom: 1px solid var(--theme-line); }
23
+ .theme-hero h1, .theme-page-heading h1, .theme-article h1 { margin: .25rem 0; font-size: clamp(2.5rem, 7vw, 5.5rem); line-height: .95; }
24
+ .theme-hero p, .theme-page-heading p, .theme-article header p { color: var(--theme-muted); }
25
+ .theme-layout { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: 4rem; }
26
+ .theme-post-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.25rem; }
27
+ .theme-post-grid article, .theme-friends a, .theme-comment-list article { padding: 1.25rem; border: 1px solid var(--theme-line); background: var(--ant-color-bg-container); overflow-wrap: anywhere; }
28
+ .theme-post-grid img { width: 100%; aspect-ratio: 16/9; object-fit: cover; }
29
+ .theme-post-grid h3 { margin: .5rem 0; font-size: 1.45rem; }
30
+ .theme-post-grid p, .theme-post-grid time { color: var(--theme-muted); }
31
+ .theme-sidebar section { margin-bottom: 2rem; }
32
+ .theme-sidebar a, .theme-post-sidebar a { display: block; padding: .35rem 0; }
33
+ .theme-article { width: min(760px, 100%); margin: 0 auto; }
34
+ .theme-article > header { margin-bottom: 2rem; }
35
+ .theme-cover { width: 100%; max-height: 34rem; object-fit: cover; }
36
+ .theme-prose { font-size: 1.08rem; line-height: 1.8; overflow-wrap: anywhere; }
37
+ .theme-prose img { max-width: 100%; height: auto; }
38
+ .theme-prose pre { overflow-x: auto; padding: 1rem; background: #1d1b18; color: #f7f2e8; }
39
+ .theme-engagement { display: flex; gap: 1rem; align-items: center; margin-top: 1rem; }
40
+ .theme-connections, .theme-pagination { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; margin: 2rem 0; }
41
+ .theme-comments { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid var(--theme-line); }
42
+ .theme-comments form { display: grid; gap: 1rem; margin-bottom: 2rem; }
43
+ .theme-comment-order { display: flex; gap: .5rem; }
44
+ .theme-comment-list { display: grid; gap: 1rem; margin: 1rem 0; }
45
+ .theme-comment-list header { display: flex; justify-content: space-between; gap: 1rem; }
46
+ .theme-comment-list aside { margin-top: 1rem; padding: 1rem; border-left: 3px solid var(--theme-accent); }
47
+ .theme-friends { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }
48
+ .theme-search { position: relative; display: flex; gap: .4rem; width: min(18rem, 100%); }
49
+ .theme-search-results { position: absolute; top: calc(100% + .5rem); right: 0; width: min(24rem, 80vw); padding: .5rem; border: 1px solid var(--theme-line); background: var(--theme-paper); box-shadow: 0 1rem 2rem #0002; }
50
+ .theme-search-results a { display: block; padding: .6rem; }
51
+ .theme-empty { padding: 4rem 1rem; color: var(--theme-muted); text-align: center; }
52
+
53
+ @media (max-width: 820px) {
54
+ .theme-header { flex-wrap: wrap; }
55
+ .theme-header nav { order: 3; width: 100%; overflow-x: auto; }
56
+ .theme-layout { grid-template-columns: 1fr; }
57
+ .theme-post-grid, .theme-friends { grid-template-columns: 1fr; }
58
+ .theme-main { padding-top: 1.5rem; }
59
+ }
@@ -0,0 +1,17 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import type { ServiceRegisterProps } from '@hile/core';
3
+ import { createBlogThemeBootService } from '@zlooks.cn/blog-theme/runtime';
4
+ import { BLOG_THEME } from '../identity.js';
5
+
6
+ const boot: ServiceRegisterProps<unknown> = createBlogThemeBootService({
7
+ definition: BLOG_THEME,
8
+ artifactBuildRoot: fileURLToPath(new URL('../../.hile-rsc', import.meta.url)),
9
+ registryBootstrap: {
10
+ REGISTRY_HOST: process.env.REGISTRY_HOST,
11
+ REGISTRY_PORT: process.env.REGISTRY_PORT,
12
+ },
13
+ advertiseHost: process.env.HILE_ADVERTISE_HOST,
14
+ development: process.env.NODE_ENV === 'development',
15
+ });
16
+
17
+ export default boot;
@@ -0,0 +1,177 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, StrictMode, type ReactNode } from 'react';
3
+ import { createRoot, type Root } from 'react-dom/client';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { ZlooksUiProvider } from '@zlooks.cn/ui';
6
+ import { ArticleEngagement, BlogSearch, CommentSection } from '../src/plugin/blog-interactions.js';
7
+ import ArchiveFilter from '../src/plugin/archive-filter.js';
8
+
9
+ const api = vi.hoisted(() => ({
10
+ searchPosts: vi.fn(), likePost: vi.fn(), unlikePost: vi.fn(), listComments: vi.fn(), createComment: vi.fn(),
11
+ }));
12
+ const navigation = vi.hoisted(() => ({ push: vi.fn() }));
13
+ vi.mock('@zlooks.cn/blog-theme/client', () => ({ createBlogThemeClient: () => api }));
14
+ vi.mock('@hile/rsc/client/navigation', () => ({ RscLink: (props: object) => <a {...props} />, useRscNavigation: () => navigation }));
15
+
16
+ let root: Root;
17
+ let host: HTMLDivElement;
18
+ beforeEach(() => {
19
+ vi.resetAllMocks();
20
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
21
+ host = document.createElement('div');
22
+ document.body.append(host);
23
+ root = createRoot(host);
24
+ });
25
+ afterEach(async () => { await act(() => root.unmount()); host.remove(); });
26
+
27
+ async function render(node: ReactNode) {
28
+ await act(() => root.render(<StrictMode><ZlooksUiProvider theme="light">{node}</ZlooksUiProvider></StrictMode>));
29
+ }
30
+ async function input(selector: string, value: string) {
31
+ const element = host.querySelector<HTMLInputElement | HTMLTextAreaElement>(selector)!;
32
+ const prototype = element.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
33
+ await act(() => {
34
+ Object.getOwnPropertyDescriptor(prototype, 'value')!.set!.call(element, value);
35
+ element.dispatchEvent(new Event('input', { bubbles: true }));
36
+ });
37
+ }
38
+ function button(label: string) {
39
+ return [...host.querySelectorAll('button')].find((item) => item.textContent?.replaceAll(' ', '').includes(label))!;
40
+ }
41
+ function deferred<T>() {
42
+ let resolve!: (value: T) => void;
43
+ let reject!: (cause: Error) => void;
44
+ const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
45
+ return { promise, resolve, reject };
46
+ }
47
+ const comment = { id: 'comment-1', content: '已提交正文', createdAt: '2026-09-01T00:00:00.000Z', user: { displayName: '读者' }, replyContent: null, pendingReview: true };
48
+ const comments = (viewerStatus: 'authenticated' | 'anonymous' | 'unavailable' = 'authenticated') => (
49
+ <CommentSection comments={[]} nextCursor={null} postSlug="post" viewerStatus={viewerStatus} />
50
+ );
51
+
52
+ describe('theme interactions', () => {
53
+ it.each([
54
+ [{}, '/blog/archives'],
55
+ [{ year: 2026 }, '/blog/archives?year=2026'],
56
+ [{ year: 2026, month: 9 }, '/blog/archives?year=2026&month=9'],
57
+ [{ startYear: 2025, startMonth: 1, endYear: 2026, endMonth: 9 }, '/blog/archives?startYear=2025&startMonth=1&endYear=2026&endMonth=9'],
58
+ ] as const)('restores and applies archive filters without preserving the previous page: %j', async (filter, path) => {
59
+ await render(<ArchiveFilter filter={filter} />);
60
+ await act(() => { host.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); });
61
+ expect(navigation.push).toHaveBeenCalledWith(path);
62
+ });
63
+
64
+ it('rejects reversed archive ranges and resets the form on navigation', async () => {
65
+ await render(<ArchiveFilter filter={{ startYear: 2026, startMonth: 1, endYear: 2026, endMonth: 9 }} />);
66
+ await input('input[aria-label="起始月份"]', '2027-01');
67
+ await act(() => { host.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); });
68
+ expect(navigation.push).not.toHaveBeenCalled();
69
+ expect(host.querySelector('[role="alert"]')).not.toBeNull();
70
+ await render(<ArchiveFilter filter={{ year: 2024 }} />);
71
+ expect(host.querySelector<HTMLInputElement>('input[aria-label="年份"]')!.value).toBe('2024');
72
+ expect(host.querySelector('[role="alert"]')).toBeNull();
73
+ });
74
+
75
+ it('rejects synchronous duplicate submits and preserves newly typed text after success', async () => {
76
+ const pending = deferred<typeof comment>();
77
+ api.createComment.mockReturnValue(pending.promise);
78
+ await render(comments());
79
+ await input('textarea', '原始正文');
80
+ const form = host.querySelector('form')!;
81
+ await act(() => {
82
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
83
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
84
+ });
85
+ expect(api.createComment).toHaveBeenCalledTimes(1);
86
+ await input('textarea', '下一条正文');
87
+ await act(() => pending.resolve(comment));
88
+ expect(host.querySelector('textarea')!.value).toBe('下一条正文');
89
+ expect(host.textContent).toContain('待审核');
90
+ });
91
+
92
+ it.each(['anonymous', 'unavailable'] as const)('hides comment input for %s', async (status) => {
93
+ await render(comments(status));
94
+ expect(host.querySelector('textarea')).toBeNull();
95
+ expect(api.createComment).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it('aborts a pending write on unmount without committing a late result', async () => {
99
+ const pending = deferred<typeof comment>();
100
+ api.createComment.mockReturnValue(pending.promise);
101
+ await render(comments());
102
+ await input('textarea', '正文');
103
+ await act(() => host.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })));
104
+ const signal = api.createComment.mock.calls[0]![1].signal as AbortSignal;
105
+ await act(() => root.render(null));
106
+ expect(signal.aborted).toBe(true);
107
+ await act(() => pending.resolve(comment));
108
+ expect(host.textContent).toBe('');
109
+ });
110
+
111
+ it('keeps the committed sort and cursor when a new sort fails', async () => {
112
+ api.listComments.mockRejectedValueOnce(new Error('offline'));
113
+ await render(<CommentSection comments={[comment as never]} nextCursor="old-cursor" postSlug="post" viewerStatus="anonymous" />);
114
+ await act(() => button('最早优先').click());
115
+ expect(button('最新优先').getAttribute('aria-pressed')).toBe('true');
116
+ api.listComments.mockResolvedValueOnce({ items: [], nextCursor: null });
117
+ await act(() => button('加载更多评论').click());
118
+ expect(api.listComments.mock.calls[1]![0]).toMatchObject({ order: 'desc', cursor: 'old-cursor' });
119
+ });
120
+
121
+ it('does not allow comment sorting to cancel an in-flight submission', async () => {
122
+ const pending = deferred<typeof comment>();
123
+ api.createComment.mockReturnValue(pending.promise);
124
+ await render(<CommentSection comments={[comment as never]} nextCursor="cursor" postSlug="post" viewerStatus="authenticated" />);
125
+ await input('textarea', '正文');
126
+ await act(() => host.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })));
127
+ await act(() => button('最早优先').click());
128
+ expect(api.listComments).not.toHaveBeenCalled();
129
+ expect(api.createComment.mock.calls[0]![1].signal.aborted).toBe(false);
130
+ await act(() => pending.resolve(comment));
131
+ });
132
+
133
+ it('keeps ascending comments ordered when a new submission precedes loading older pages', async () => {
134
+ const first = { ...comment, id: 'comment-1', content: '最早评论', pendingReview: false };
135
+ const second = { ...comment, id: 'comment-2', content: '中间评论', createdAt: '2026-09-02T00:00:00.000Z', pendingReview: false };
136
+ const newest = { ...comment, id: 'comment-3', content: '最新评论', createdAt: '2026-09-03T00:00:00.000Z' };
137
+ await render(<CommentSection comments={[first as never]} nextCursor="desc-cursor" postSlug="post" viewerStatus="authenticated" />);
138
+ api.listComments.mockResolvedValueOnce({ items: [first], nextCursor: 'asc-cursor' });
139
+ await act(() => button('最早优先').click());
140
+ api.createComment.mockResolvedValueOnce(newest);
141
+ await input('textarea', newest.content);
142
+ await act(() => host.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })));
143
+ api.listComments.mockResolvedValueOnce({ items: [second, { ...newest, pendingReview: false }], nextCursor: null });
144
+ await act(() => button('加载更多评论').click());
145
+ expect(api.listComments.mock.calls[1]![0]).toMatchObject({ order: 'asc', cursor: 'asc-cursor' });
146
+ expect([...host.querySelectorAll('.theme-comment-list article p')].map((item) => item.textContent)).toEqual(['最早评论', '中间评论', '最新评论']);
147
+ expect(host.querySelector('.theme-comment-list')!.textContent).not.toContain('待审核');
148
+ });
149
+
150
+ it('serializes like/unlike writes even before React commits the loading state', async () => {
151
+ const pending = deferred<{ liked: boolean; likeCount: number }>();
152
+ api.likePost.mockReturnValue(pending.promise);
153
+ await render(<ArticleEngagement engagement={{ viewCount: 3, likeCount: 1, viewerState: 'not-liked' }} postSlug="post" />);
154
+ await act(() => { button('点赞').click(); button('点赞').click(); });
155
+ expect(api.likePost).toHaveBeenCalledTimes(1);
156
+ await act(() => pending.resolve({ liked: true, likeCount: 2 }));
157
+ api.unlikePost.mockResolvedValueOnce({ liked: false, likeCount: 1 });
158
+ await act(() => button('已赞').click());
159
+ expect(api.unlikePost).toHaveBeenCalledTimes(1);
160
+ expect(host.textContent).toContain('点赞 1');
161
+ });
162
+
163
+ it('invalidates stale searches when the query changes and shows a completed empty result', async () => {
164
+ const pending = deferred<{ items: unknown[] }>();
165
+ api.searchPosts.mockReturnValueOnce(pending.promise);
166
+ await render(<BlogSearch />);
167
+ await input('input', '旧查询');
168
+ await act(() => host.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })));
169
+ await input('input', '新查询');
170
+ expect(api.searchPosts.mock.calls[0]![1].signal.aborted).toBe(true);
171
+ await act(() => pending.resolve({ items: [{ id: 'old', slug: 'old', title: '过期结果' }] }));
172
+ expect(host.textContent).not.toContain('过期结果');
173
+ api.searchPosts.mockResolvedValueOnce({ items: [] });
174
+ await act(() => host.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })));
175
+ expect(host.textContent).toContain('未找到相关文章');
176
+ });
177
+ });
@@ -0,0 +1,125 @@
1
+ // @vitest-environment happy-dom
2
+ import { createExecutionContext } from '@hile/context';
3
+ import { BlogThemeDataLoader, type BlogThemeDataSource, type BlogThemeRouteProps } from '@zlooks.cn/blog-theme';
4
+ import { serializeGlobalConfigRscSnapshot, GLOBAL_CONFIG_RSC_PARAM } from '@zlooks.cn/global-config-shared';
5
+ import { serializeZlooksRscShellContext, ZLOOKS_RSC_SHELL_CONTEXT_PARAM } from '@zlooks.cn/ui/shell-context';
6
+ import { renderToStaticMarkup } from 'react-dom/server';
7
+ import type { ReactElement } from 'react';
8
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import { BLOG_THEME } from '../src/identity.js';
10
+ import * as pages from '../src/plugin/page.js';
11
+
12
+ // Only the service acquisition boundary is replaced; the public SDK builds every ViewModel.
13
+ const fixture = vi.hoisted(() => ({ source: undefined as BlogThemeDataSource | undefined }));
14
+ vi.mock('@zlooks.cn/blog-theme/rsc', () => ({
15
+ createServiceBackedBlogThemeDataLoader: () => new BlogThemeDataLoader({
16
+ definition: BLOG_THEME,
17
+ source: () => fixture.source!,
18
+ callOptions: () => ({ context: createExecutionContext({}), timeout: 5000, retries: 0 }),
19
+ }),
20
+ }));
21
+ vi.mock('@hile/rsc/client/navigation', () => ({ RscLink: (props: object) => <a {...props} />, useRscNavigation: () => ({ push: vi.fn() }) }));
22
+
23
+ const instant = '2026-09-01T00:00:00.000Z';
24
+ const category = { id: '018ff2ec-8c3e-7a40-a000-000000000001', slug: 'engineering', name: '工程分类', description: null, sortOrder: 0 };
25
+ const tag = { id: '018ff2ec-8c3e-7a40-a000-000000000002', slug: 'typescript', name: '类型标签' };
26
+ const post = {
27
+ id: '018ff2ec-8c3e-7a40-a000-000000000003', slug: 'article', title: '真实模型文章', summary: '文章摘要',
28
+ category, tags: [tag], coverUrl: null, publishedAt: instant, updatedAt: instant,
29
+ };
30
+ const page = {
31
+ id: '018ff2ec-8c3e-7a40-a000-000000000004', slug: 'about', title: '关于本站', summary: '内容页摘要',
32
+ position: 'navigation' as const, sortOrder: 0, publishedAt: instant, updatedAt: instant,
33
+ };
34
+
35
+ function dataSource(): BlogThemeDataSource {
36
+ return {
37
+ listPosts: vi.fn<BlogThemeDataSource['listPosts']>().mockResolvedValue({ items: [post], nextCursor: null, total: 30 }),
38
+ listPostArchive: vi.fn<BlogThemeDataSource['listPostArchive']>().mockResolvedValue({ items: [post], nextCursor: null, total: 30, months: [{ year: 2026, month: 9, count: 30 }] }),
39
+ readPost: vi.fn<BlogThemeDataSource['readPost']>().mockResolvedValue({ found: true, post: { ...post, content: '## 文章正文' } }),
40
+ openPost: vi.fn<BlogThemeDataSource['openPost']>().mockResolvedValue({ found: true, post: { ...post, content: '## 文章正文' }, engagement: { viewCount: 3, likeCount: 1, viewerState: 'anonymous' } }),
41
+ readPostConnections: vi.fn<BlogThemeDataSource['readPostConnections']>().mockResolvedValue({ found: true, connections: { previousPost: null, nextPost: null, relatedPosts: [] } }),
42
+ listComments: vi.fn<BlogThemeDataSource['listComments']>().mockResolvedValue({ found: true, comments: { items: [], nextCursor: null } }),
43
+ listRecentComments: vi.fn<BlogThemeDataSource['listRecentComments']>().mockResolvedValue([]),
44
+ listCategories: vi.fn<BlogThemeDataSource['listCategories']>().mockResolvedValue([{ ...category, postCount: 30 }]),
45
+ listTags: vi.fn<BlogThemeDataSource['listTags']>().mockResolvedValue([tag]),
46
+ listPages: vi.fn<BlogThemeDataSource['listPages']>().mockImplementation(async ({ position }) => [{ ...page, position: position ?? null }]),
47
+ readPage: vi.fn<BlogThemeDataSource['readPage']>().mockResolvedValue({ found: true, page: { ...page, content: '## 内容页正文' } }),
48
+ listFriendLinks: vi.fn<BlogThemeDataSource['listFriendLinks']>().mockResolvedValue([{ id: '018ff2ec-8c3e-7a40-a000-000000000005', name: '友好站点', url: 'https://friend.example/', description: '友链说明', logoUrl: '/friend.svg', sortOrder: 0 }]),
49
+ };
50
+ }
51
+
52
+ function routeProps(slug?: string, searchParams: Record<string, string> = {}): BlogThemeRouteProps {
53
+ return {
54
+ params: {
55
+ ...(slug ? { slug } : {}),
56
+ [GLOBAL_CONFIG_RSC_PARAM]: serializeGlobalConfigRscSnapshot({ schemaVersion: 2, revision: 1, value: { identity: { name: '测试站点', homeHref: '/' }, linkGroups: [], legal: { registrations: [] } }, updatedAt: instant, updatedBy: 'test-fixture' }),
57
+ [ZLOOKS_RSC_SHELL_CONTEXT_PARAM]: serializeZlooksRscShellContext({ navigation: [], origin: 'https://theme.example/', pathname: '/blog', user: { status: 'unavailable' } }),
58
+ },
59
+ searchParams,
60
+ rsc: { pluginId: 'blog', buildId: 'unit-test-build' },
61
+ };
62
+ }
63
+
64
+ type Page = (props: BlogThemeRouteProps) => Promise<ReactElement>;
65
+ async function html(render: Page, slug?: string, searchParams: Record<string, string> = {}) {
66
+ let tree = await render(routeProps(slug, searchParams));
67
+ // Invalid routes use one asynchronous server-only frame; resolve it before the DOM renderer.
68
+ if (typeof tree.type === 'function' && tree.type.constructor.name === 'AsyncFunction') {
69
+ tree = await (tree.type as (props: unknown) => Promise<ReactElement>)(tree.props);
70
+ }
71
+ const document = new DOMParser().parseFromString(renderToStaticMarkup(tree), 'text/html');
72
+ expect(document.querySelector('main')).not.toBeNull();
73
+ expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toMatch(/^https:\/\/theme\.example\/blog/);
74
+ return document;
75
+ }
76
+
77
+ beforeEach(() => { fixture.source = dataSource(); });
78
+
79
+ describe('all theme routes using public SDK ViewModels', () => {
80
+ it.each([
81
+ [pages.BlogHomePage, undefined, '真实模型文章'],
82
+ [pages.BlogPostPage, 'article', '文章正文'],
83
+ [pages.BlogArchivePage, undefined, '文章归档'],
84
+ [pages.BlogSinglePagePage, 'about', '内容页正文'],
85
+ [pages.BlogCategoryPage, 'engineering', '工程分类'],
86
+ [pages.BlogTagPage, 'typescript', '类型标签'],
87
+ [pages.BlogFriendLinksPage, undefined, '友好站点'],
88
+ ] as const)('renders a complete %s route with shell and SEO', async (render, slug, text) => {
89
+ const document = await html(render, slug);
90
+ expect(document.body.textContent).toContain(text);
91
+ expect(document.querySelectorAll('h1')).toHaveLength(1);
92
+ expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe('index, follow');
93
+ expect(document.querySelector('textarea')).toBeNull();
94
+ });
95
+
96
+ it.each([pages.BlogPostPage, pages.BlogSinglePagePage, pages.BlogCategoryPage, pages.BlogTagPage])('renders invalid parameters with noindex SEO', async (render) => {
97
+ const document = await html(render, 'INVALID');
98
+ expect(document.body.textContent).toContain('不存在');
99
+ expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe('noindex, nofollow');
100
+ expect(fixture.source!.openPost).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it('preserves all archive filters in numbered pagination links', async () => {
104
+ vi.mocked(fixture.source!.listPostArchive).mockResolvedValue({ items: [post], nextCursor: null, total: BLOG_THEME.query.archivePageSize + 1, months: [] });
105
+ const document = await html(pages.BlogArchivePage, undefined, { startYear: '2025', startMonth: '1', endYear: '2026', endMonth: '9' });
106
+ const href = document.querySelector('nav[aria-label="分页"] a[href*="page=2"]')?.getAttribute('href');
107
+ expect(href).toContain('startYear=2025&startMonth=1&endYear=2026&endMonth=9&page=2');
108
+ });
109
+
110
+ it('links sidebar pages and shows an explicit empty friend-link state', async () => {
111
+ const home = await html(pages.BlogHomePage);
112
+ expect(home.querySelector('aside a[href="/blog/pages/about"]')).not.toBeNull();
113
+ vi.mocked(fixture.source!.listFriendLinks).mockResolvedValue([]);
114
+ expect((await html(pages.BlogFriendLinksPage)).body.textContent).toContain('暂无友情链接');
115
+ });
116
+
117
+ it('renders missing content and empty lists without inventing content', async () => {
118
+ vi.mocked(fixture.source!.openPost).mockResolvedValue({ found: false });
119
+ expect((await html(pages.BlogPostPage, 'missing')).body.textContent).toContain('文章不存在');
120
+ vi.mocked(fixture.source!.listPosts).mockResolvedValue({ items: [], total: 0, nextCursor: null });
121
+ const home = await html(pages.BlogHomePage);
122
+ expect(home.body.textContent).toContain('这里还没有文章');
123
+ expect(home.querySelector('nav[aria-label="分页"]')).toBeNull();
124
+ });
125
+ });