@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,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
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['test/**/*.test.{ts,tsx}'],
6
+ server: { deps: { inline: [/@zlooks\.cn\/ui/, /antd/, /@ant-design/, /@rc-component/] } },
7
+ },
8
+ });