@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.
- package/dist/command/index.js +59 -2
- package/dist/command/index.js.map +1 -1
- package/dist/operations.d.ts +2 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +8 -10
- package/dist/operations.js.map +1 -1
- package/dist/prompt.d.ts +4 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +7 -1
- package/dist/prompt.js.map +1 -1
- package/dist/service-catalog.d.ts +1 -1
- package/dist/service-catalog.d.ts.map +1 -1
- package/dist/service-catalog.js +1 -0
- package/dist/service-catalog.js.map +1 -1
- package/dist/theme-authoring.d.ts +36 -0
- package/dist/theme-authoring.d.ts.map +1 -0
- package/dist/theme-authoring.js +491 -0
- package/dist/theme-authoring.js.map +1 -0
- package/package.json +10 -4
- package/templates/blog-theme/README.md +26 -0
- package/templates/blog-theme/_gitignore +5 -0
- package/templates/blog-theme/hile-rsc.json +22 -0
- package/templates/blog-theme/package.json +64 -0
- package/templates/blog-theme/pnpm-workspace.yaml +5 -0
- package/templates/blog-theme/src/command/index.ts +11 -0
- package/templates/blog-theme/src/identity.ts +7 -0
- package/templates/blog-theme/src/plugin/archive-filter.tsx +62 -0
- package/templates/blog-theme/src/plugin/blog-frame.tsx +91 -0
- package/templates/blog-theme/src/plugin/blog-interactions.tsx +236 -0
- package/templates/blog-theme/src/plugin/page.tsx +174 -0
- package/templates/blog-theme/src/plugin/styles.d.ts +1 -0
- package/templates/blog-theme/src/plugin/theme.css +59 -0
- package/templates/blog-theme/src/services/blog-theme.boot.ts +17 -0
- package/templates/blog-theme/test/interactions.test.tsx.template +177 -0
- package/templates/blog-theme/test/pages.test.tsx.template +125 -0
- package/templates/blog-theme/test/shell.test.tsx.template +102 -0
- package/templates/blog-theme/test/theme-contract.test.ts.template +43 -0
- package/templates/blog-theme/tsconfig.json +16 -0
- package/templates/blog-theme/tsconfig.runtime.json +17 -0
- package/templates/blog-theme/vitest.config.ts +8 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from 'react';
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import type { ZlooksRscShellContext } from '@zlooks.cn/ui/shell-context';
|
|
6
|
+
import BlogFrame from '../src/plugin/blog-frame.js';
|
|
7
|
+
|
|
8
|
+
const navigation = vi.hoisted(() => ({ push: vi.fn(), refresh: vi.fn() }));
|
|
9
|
+
vi.mock('@hile/rsc/client/navigation', () => ({ RscLink: (props: object) => <a {...props} />, useRscNavigation: () => navigation }));
|
|
10
|
+
let host: HTMLDivElement;
|
|
11
|
+
let root: Root;
|
|
12
|
+
const fetchMock = vi.fn();
|
|
13
|
+
const site = {
|
|
14
|
+
schemaVersion: 2 as const, revision: 1, updatedAt: '2026-09-01T00:00:00.000Z', updatedBy: 'test',
|
|
15
|
+
value: { identity: { name: '测试站点', homeHref: '/' }, linkGroups: [], legal: {
|
|
16
|
+
copyright: '测试版权', registrations: [{ id: 'icp', kind: 'icp' as const, number: '测试备案', verificationUrl: 'https://beian.miit.gov.cn/' }],
|
|
17
|
+
} },
|
|
18
|
+
};
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.resetAllMocks();
|
|
21
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
22
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
23
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
|
|
24
|
+
});
|
|
25
|
+
afterEach(async () => { await act(() => root.unmount()); host.remove(); vi.unstubAllGlobals(); });
|
|
26
|
+
|
|
27
|
+
async function render(user: ZlooksRscShellContext['user']) {
|
|
28
|
+
await act(() => root.render(<BlogFrame navigationPages={[]} shell={{
|
|
29
|
+
user, origin: 'https://theme.example', pathname: '/blog',
|
|
30
|
+
navigation: [{ key: 'blog:home', label: '博客', href: '/blog', order: 1 }, { key: 'other', label: '其他功能', href: '/other', order: 2 }],
|
|
31
|
+
}} site={site}><p>页面内容</p></BlogFrame>));
|
|
32
|
+
}
|
|
33
|
+
function button(label: string) {
|
|
34
|
+
const found = [...document.querySelectorAll('button')].find((item) => item.getAttribute('aria-label') === label || item.textContent?.trim() === label);
|
|
35
|
+
expect(found, label).toBeDefined();
|
|
36
|
+
return found!;
|
|
37
|
+
}
|
|
38
|
+
const authenticated = { status: 'authenticated' as const, user: { label: '测试读者', avatarText: '读' }, accountHref: '/account', logoutHref: '/api/user/logout' };
|
|
39
|
+
|
|
40
|
+
describe('shared UI shell', () => {
|
|
41
|
+
it('uses Host navigation and site legal data without an initialization fetch', async () => {
|
|
42
|
+
await render({ status: 'anonymous', loginMethods: [] });
|
|
43
|
+
expect(host.querySelector('nav a[href="/other"]')?.textContent).toBe('其他功能');
|
|
44
|
+
expect(host.querySelectorAll('nav a[href="/blog"]')).toHaveLength(1);
|
|
45
|
+
expect(host.querySelector('nav a[aria-current="page"]')?.getAttribute('href')).toBe('/blog');
|
|
46
|
+
expect(host.querySelector('footer')?.textContent).toContain('测试版权');
|
|
47
|
+
expect(host.querySelector('footer a')?.textContent).toBe('测试备案');
|
|
48
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('opens real shared login methods and navigates through the Host port', async () => {
|
|
52
|
+
await render({ status: 'anonymous', loginMethods: [{ id: 'password', label: '密码登录', href: '/auth.password' }] });
|
|
53
|
+
await act(() => button('登录').click());
|
|
54
|
+
const link = document.querySelector<HTMLAnchorElement>('a[href="/auth.password"]');
|
|
55
|
+
expect(link).not.toBeNull();
|
|
56
|
+
await act(() => link!.click());
|
|
57
|
+
expect(navigation.push).toHaveBeenCalledWith('/auth.password');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('disables account access when the upstream is unavailable', async () => {
|
|
61
|
+
await render({ status: 'unavailable' });
|
|
62
|
+
expect(button('账户服务暂不可用').disabled).toBe(true);
|
|
63
|
+
expect(document.querySelector('[aria-label="登录"]')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('switches light, dark and system preference through the shared hook', async () => {
|
|
67
|
+
await render({ status: 'unavailable' });
|
|
68
|
+
for (const [label, mode] of [['浅色', 'light'], ['深色', 'dark'], ['自动', 'default']] as const) {
|
|
69
|
+
const control = [...host.querySelectorAll('label')].find((item) => item.textContent === label);
|
|
70
|
+
expect(control).toBeDefined();
|
|
71
|
+
await act(() => control!.click());
|
|
72
|
+
expect(window.localStorage.getItem('zlooks.theme')).toBe(mode);
|
|
73
|
+
expect(control!.querySelector<HTMLInputElement>('input')?.checked).toBe(true);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('opens the account menu and confirms logout before requesting and refreshing', async () => {
|
|
78
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ code: 0, data: {} }), { status: 200 }));
|
|
79
|
+
await render(authenticated);
|
|
80
|
+
await act(() => button('打开测试读者的账户信息').click());
|
|
81
|
+
expect(document.querySelector('a[href="/account"]')).not.toBeNull();
|
|
82
|
+
await act(() => button('退出登录').click());
|
|
83
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
84
|
+
const dialog = [...document.querySelectorAll('[role="dialog"]')].find((item) => item.textContent?.includes('确认退出登录'));
|
|
85
|
+
expect(dialog?.textContent).toContain('确认退出登录');
|
|
86
|
+
await act(() => [...dialog!.querySelectorAll('button')].find((item) => item.textContent === '退出登录')!.click());
|
|
87
|
+
expect(fetchMock).toHaveBeenCalledWith('/api/user/logout', expect.objectContaining({ method: 'POST', credentials: 'same-origin' }));
|
|
88
|
+
expect(navigation.refresh).toHaveBeenCalledTimes(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('keeps logout confirmation open and does not refresh when the server rejects it', async () => {
|
|
92
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ code: 1, error: '退出暂不可用' }), { status: 503 }));
|
|
93
|
+
await render(authenticated);
|
|
94
|
+
await act(() => button('打开测试读者的账户信息').click());
|
|
95
|
+
await act(() => button('退出登录').click());
|
|
96
|
+
const dialog = [...document.querySelectorAll('[role="dialog"]')].find((item) => item.textContent?.includes('确认退出登录'))!;
|
|
97
|
+
await act(() => [...dialog.querySelectorAll('button')].find((item) => item.textContent === '退出登录')!.click());
|
|
98
|
+
expect(navigation.refresh).not.toHaveBeenCalled();
|
|
99
|
+
expect(document.body.textContent).toContain('退出暂不可用');
|
|
100
|
+
expect(dialog.isConnected).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const root = resolve(import.meta.dirname, '..');
|
|
6
|
+
|
|
7
|
+
describe('theme contract', () => {
|
|
8
|
+
it('keeps one SDK-owned boot composition root', async () => {
|
|
9
|
+
const services = await readdir(resolve(root, 'src/services'), { recursive: true });
|
|
10
|
+
expect(services.filter((path) => path.endsWith('.ts'))).toEqual(['blog-theme.boot.ts']);
|
|
11
|
+
const boot = await readFile(resolve(root, 'src/services/blog-theme.boot.ts'), 'utf8');
|
|
12
|
+
expect(boot).toContain('@zlooks.cn/blog-theme/runtime');
|
|
13
|
+
expect(boot).toContain('createBlogThemeBootService');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('declares every Blog route', async () => {
|
|
17
|
+
const config = JSON.parse(await readFile(resolve(root, 'hile-rsc.json'), 'utf8')) as {
|
|
18
|
+
readonly pluginId: string;
|
|
19
|
+
readonly routes: readonly { readonly path: string }[];
|
|
20
|
+
};
|
|
21
|
+
expect(config.pluginId).toBe('blog');
|
|
22
|
+
expect(config.routes.map(({ path }) => path)).toEqual([
|
|
23
|
+
'/', '/posts/[slug]', '/archives', '/pages/[slug]', '/categories/[slug]', '/tags/[slug]', '/friends',
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('uses SDK-owned server data and client interactions', async () => {
|
|
28
|
+
const page = await readFile(resolve(root, 'src/plugin/page.tsx'), 'utf8');
|
|
29
|
+
const interactions = await readFile(resolve(root, 'src/plugin/blog-interactions.tsx'), 'utf8');
|
|
30
|
+
expect(page).toContain('createServiceBackedBlogThemeDataLoader');
|
|
31
|
+
expect(page).not.toMatch(/@zlooks\.cn\/blog-shared|loadService\(/);
|
|
32
|
+
expect(interactions).toContain('@zlooks.cn/blog-theme/client');
|
|
33
|
+
for (const method of ['searchPosts', 'likePost', 'unlikePost', 'listComments', 'createComment']) {
|
|
34
|
+
expect(interactions).toContain(method);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('renders Markdown without raw HTML injection', async () => {
|
|
39
|
+
const interactions = await readFile(resolve(root, 'src/plugin/blog-interactions.tsx'), 'utf8');
|
|
40
|
+
expect(interactions).toContain('react-markdown');
|
|
41
|
+
expect(interactions).not.toContain('dangerouslySetInnerHTML');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2024",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["DOM", "DOM.Iterable", "ES2024"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"types": ["node"],
|
|
12
|
+
"verbatimModuleSyntax": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx", "vitest.config.ts"],
|
|
15
|
+
"exclude": ["node_modules", "dist", ".hile-rsc"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2024",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"types": ["node"],
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"sourceMap": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts"],
|
|
16
|
+
"exclude": ["src/plugin", "node_modules", "dist", ".hile-rsc", "test"]
|
|
17
|
+
}
|