@hutusi/amytis 1.15.0 → 1.16.0

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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CLAUDE.md +90 -219
  3. package/bun.lock +185 -547
  4. package/content/books/sample-book/index.mdx +3 -0
  5. package/content/posts/code-block-features-showcase.mdx +223 -0
  6. package/docs/ALERTS.md +112 -0
  7. package/docs/ARCHITECTURE.md +217 -5
  8. package/docs/CODE-BLOCKS.md +238 -0
  9. package/docs/CONTRIBUTING.md +25 -0
  10. package/docs/guides/README.md +11 -0
  11. package/docs/guides/importing-vuepress-books.md +178 -0
  12. package/eslint.config.mjs +18 -6
  13. package/package.json +42 -20
  14. package/scripts/generate-code-group-icons.ts +79 -0
  15. package/scripts/render-rst.py +207 -3
  16. package/scripts/sync-vuepress-book.ts +499 -0
  17. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  18. package/src/app/books/[slug]/page.tsx +67 -32
  19. package/src/app/globals.css +503 -123
  20. package/src/app/page.tsx +1 -1
  21. package/src/app/sitemap.ts +3 -3
  22. package/src/components/ArticleCopyCleaner.tsx +64 -0
  23. package/src/components/BookMobileNav.tsx +44 -50
  24. package/src/components/BookSidebar.tsx +0 -0
  25. package/src/components/CodeBlock.test.tsx +93 -8
  26. package/src/components/CodeBlock.tsx +39 -101
  27. package/src/components/CodeBlockToolbar.tsx +88 -0
  28. package/src/components/CodeGroup.tsx +81 -0
  29. package/src/components/CoverImage.tsx +1 -0
  30. package/src/components/ExternalLinkIcon.tsx +15 -0
  31. package/src/components/FeaturedStoriesSection.tsx +3 -3
  32. package/src/components/GithubAlert.tsx +97 -0
  33. package/src/components/MarkdownRenderer.test.tsx +14 -4
  34. package/src/components/MarkdownRenderer.tsx +144 -23
  35. package/src/components/Mermaid.tsx +32 -1
  36. package/src/components/PostList.tsx +1 -1
  37. package/src/components/PostNavigation.tsx +13 -2
  38. package/src/components/PostSidebar.tsx +13 -2
  39. package/src/components/RstRenderer.test.tsx +15 -15
  40. package/src/components/RstRenderer.tsx +37 -2
  41. package/src/components/Search.tsx +18 -4
  42. package/src/components/SeriesCatalog.tsx +1 -1
  43. package/src/components/ShareBar.tsx +5 -0
  44. package/src/components/TocPanel.tsx +10 -2
  45. package/src/i18n/translations.ts +2 -0
  46. package/src/layouts/BookLayout.tsx +35 -4
  47. package/src/layouts/PostLayout.tsx +5 -1
  48. package/src/lib/code-group-icons.test.ts +78 -0
  49. package/src/lib/code-group-icons.ts +148 -0
  50. package/src/lib/markdown.test.ts +56 -13
  51. package/src/lib/markdown.ts +203 -50
  52. package/src/lib/normalize-vuepress-math.ts +118 -0
  53. package/src/lib/rehype-fence-meta.ts +22 -0
  54. package/src/lib/remark-book-chapter-links.ts +106 -0
  55. package/src/lib/remark-code-group.ts +54 -0
  56. package/src/lib/remark-github-alerts.test.ts +83 -0
  57. package/src/lib/remark-github-alerts.ts +65 -0
  58. package/src/lib/remark-vuepress-containers.ts +130 -0
  59. package/src/lib/rst-renderer.ts +19 -7
  60. package/src/lib/rst.test.ts +212 -2
  61. package/src/lib/rst.ts +217 -13
  62. package/src/lib/shiki-rst.ts +185 -0
  63. package/src/lib/shiki.test.ts +153 -0
  64. package/src/lib/shiki.ts +292 -0
  65. package/src/lib/urls.ts +57 -0
  66. package/src/test-utils/render.ts +23 -0
  67. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  68. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  69. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  70. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  71. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  72. package/tests/helpers/env.ts +19 -0
  73. package/tests/integration/book-chapter-links.test.ts +107 -0
  74. package/tests/integration/books-nested-toc.test.ts +176 -0
  75. package/tests/integration/books.test.ts +3 -2
  76. package/tests/integration/code-block-features.test.ts +188 -0
  77. package/tests/integration/code-group.test.ts +183 -0
  78. package/tests/integration/code-notation.test.ts +97 -0
  79. package/tests/integration/github-alerts.test.ts +82 -0
  80. package/tests/integration/markdown-external-links.test.ts +103 -0
  81. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  82. package/tests/integration/reading-time-headings.test.ts +8 -6
  83. package/tests/integration/series-draft.test.ts +6 -13
  84. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  85. package/tests/integration/vuepress-containers.test.ts +107 -0
  86. package/tests/tooling/new-post.test.ts +1 -1
  87. package/tests/unit/static-params.test.ts +32 -19
@@ -1,13 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { getSeriesData, getAllSeries } from "../../src/lib/markdown";
3
-
4
- function restoreEnvVar(key: string, value: string | undefined): void {
5
- if (value === undefined) {
6
- delete process.env[key];
7
- } else {
8
- process.env[key] = value;
9
- }
10
- }
3
+ import { setEnvVar, restoreEnvVar } from "../helpers/env";
11
4
 
12
5
  describe("Integration: Series Draft Support", () => {
13
6
  test("all series are included when NODE_ENV is not production", () => {
@@ -28,8 +21,8 @@ describe("Integration: Series Draft Support", () => {
28
21
  const originalEnv = process.env.NODE_ENV;
29
22
  const originalPythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
30
23
  try {
31
- process.env.NODE_ENV = "production";
32
- process.env.AMYTIS_ENABLE_PYTHON_RST = "0";
24
+ setEnvVar("NODE_ENV", "production");
25
+ setEnvVar("AMYTIS_ENABLE_PYTHON_RST", "0");
33
26
  // This should not throw; draft series are simply excluded
34
27
  const series = getAllSeries();
35
28
  expect(typeof series).toBe("object");
@@ -43,10 +36,10 @@ describe("Integration: Series Draft Support", () => {
43
36
  const originalEnv = process.env.NODE_ENV;
44
37
  const originalPythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
45
38
  try {
46
- process.env.NODE_ENV = "production";
47
- process.env.AMYTIS_ENABLE_PYTHON_RST = "0";
39
+ setEnvVar("NODE_ENV", "production");
40
+ setEnvVar("AMYTIS_ENABLE_PYTHON_RST", "0");
48
41
  const allSeries = getAllSeries();
49
-
42
+
50
43
  // Verify that every series returned has draft: false (or undefined which defaults to false)
51
44
  Object.keys(allSeries).forEach(slug => {
52
45
  const seriesData = getSeriesData(slug);
@@ -0,0 +1,240 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
+ import { spawnSync } from "child_process";
3
+ import { mkdirSync, mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import path from "path";
6
+ import matter from "gray-matter";
7
+
8
+ const FIXTURE_SOURCE = path.resolve("tests/fixtures/sync-vuepress-book/docs");
9
+
10
+ // Invoke through the published `bun run sync-vuepress-book` entrypoint and the
11
+ // `--source` / `--dest` flags so the test exercises everything a real user does:
12
+ // the package.json script wiring + the CLI argv parser, not just the inner sync
13
+ // pipeline.
14
+ function runSync(source: string, dest: string) {
15
+ return spawnSync(
16
+ "bun",
17
+ ["run", "sync-vuepress-book", "--source", source, "--dest", dest],
18
+ {
19
+ encoding: "utf8",
20
+ cwd: process.cwd(),
21
+ },
22
+ );
23
+ }
24
+
25
+ describe("Integration: sync-vuepress-book script", () => {
26
+ let dest: string;
27
+
28
+ beforeEach(() => {
29
+ dest = mkdtempSync(path.join(tmpdir(), "sync-vuepress-book-"));
30
+ });
31
+
32
+ afterEach(() => {
33
+ if (dest && existsSync(dest)) rmSync(dest, { recursive: true, force: true });
34
+ });
35
+
36
+ test("runs end-to-end and produces the expected layout", () => {
37
+ const res = runSync(FIXTURE_SOURCE, dest);
38
+ expect(res.status).toBe(0);
39
+
40
+ // Markdown content was rsynced verbatim (no rewriting on copy).
41
+ expect(existsSync(path.join(dest, "intro", "welcome.md"))).toBe(true);
42
+ expect(existsSync(path.join(dest, "maths", "linear", "vectors.md"))).toBe(true);
43
+ expect(existsSync(path.join(dest, "maths", "linear", "matrices.md"))).toBe(true);
44
+
45
+ // Asset folders co-located with chapters were copied.
46
+ expect(existsSync(path.join(dest, "maths", "linear", "assets", "diagram.png"))).toBe(true);
47
+
48
+ // .vuepress was excluded.
49
+ expect(existsSync(path.join(dest, ".vuepress"))).toBe(false);
50
+
51
+ // index.mdx exists with parsed nested TOC.
52
+ const indexPath = path.join(dest, "index.mdx");
53
+ expect(existsSync(indexPath)).toBe(true);
54
+ const { data } = matter(readFileSync(indexPath, "utf8")) as unknown as { data: Record<string, unknown> };
55
+ expect(data.title).toBe("Fixture Book");
56
+ const chapters = data.chapters as Array<Record<string, unknown>>;
57
+ expect(chapters[0]).toMatchObject({ title: "Intro", id: "intro/welcome" });
58
+ expect(chapters[1]).toMatchObject({ section: "Maths" });
59
+ const mathsItems = (chapters[1].items as Array<Record<string, unknown>>);
60
+ expect(mathsItems[0]).toMatchObject({ section: "Linear Algebra" });
61
+ const linearItems = mathsItems[0].items as Array<Record<string, unknown>>;
62
+ expect(linearItems).toEqual([
63
+ { title: "Vectors", id: "maths/linear/vectors" },
64
+ { title: "Matrices", id: "maths/linear/matrices" },
65
+ ]);
66
+ });
67
+
68
+ test("preserves user-controlled frontmatter on re-run", () => {
69
+ // First run creates index.mdx.
70
+ expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
71
+
72
+ // Author edits cover image + featured flag.
73
+ const indexPath = path.join(dest, "index.mdx");
74
+ const parsed = matter(readFileSync(indexPath, "utf8")) as unknown as { data: Record<string, unknown>; content: string };
75
+ const edited = matter.stringify(parsed.content, {
76
+ ...parsed.data,
77
+ coverImage: "text:FB",
78
+ featured: true,
79
+ excerpt: "A tiny book for tests.",
80
+ });
81
+ writeFileSync(indexPath, edited);
82
+
83
+ // Re-run should keep the edited fields and refresh `chapters`.
84
+ expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
85
+ const { data: refreshed } = matter(readFileSync(indexPath, "utf8")) as unknown as { data: Record<string, unknown> };
86
+ expect(refreshed.coverImage).toBe("text:FB");
87
+ expect(refreshed.featured).toBe(true);
88
+ expect(refreshed.excerpt).toBe("A tiny book for tests.");
89
+ expect(Array.isArray(refreshed.chapters)).toBe(true);
90
+ });
91
+
92
+ test("prunes dest files removed upstream (mirror semantics)", () => {
93
+ // First sync — dest now has every fixture file.
94
+ expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
95
+ expect(existsSync(path.join(dest, "maths", "linear", "matrices.md"))).toBe(true);
96
+
97
+ // Simulate an upstream deletion by syncing from a smaller temp source tree
98
+ // (just the bits we need for the still-listed chapters) and a config that
99
+ // no longer references matrices.
100
+ const trimmed = mkdtempSync(path.join(tmpdir(), "sync-trimmed-"));
101
+ try {
102
+ const docs = path.join(trimmed, "docs");
103
+ const vp = path.join(docs, ".vuepress");
104
+ mkdirSync(vp, { recursive: true });
105
+ writeFileSync(
106
+ path.join(vp, "config.js"),
107
+ `export default {
108
+ title: 'Fixture Book',
109
+ theme: { sidebar: [{ text: 'Vectors', link: '/maths/linear/vectors' }] },
110
+ }
111
+ `,
112
+ );
113
+ mkdirSync(path.join(docs, "maths", "linear"), { recursive: true });
114
+ writeFileSync(path.join(docs, "maths", "linear", "vectors.md"), "---\ntitle: Vectors\n---\n# Vectors\n");
115
+
116
+ expect(runSync(docs, dest).status).toBe(0);
117
+
118
+ // Vectors survives, matrices is gone, the now-empty assets/ dir is cleaned up.
119
+ expect(existsSync(path.join(dest, "maths", "linear", "vectors.md"))).toBe(true);
120
+ expect(existsSync(path.join(dest, "maths", "linear", "matrices.md"))).toBe(false);
121
+ expect(existsSync(path.join(dest, "intro"))).toBe(false);
122
+ expect(existsSync(path.join(dest, "maths", "linear", "assets"))).toBe(false);
123
+ // index.mdx is regenerated, not pruned.
124
+ expect(existsSync(path.join(dest, "index.mdx"))).toBe(true);
125
+ } finally {
126
+ rmSync(trimmed, { recursive: true, force: true });
127
+ }
128
+ });
129
+
130
+ test("preserves user-added dotfiles on re-run (out-of-band overlay state)", () => {
131
+ expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
132
+ const dotfile = path.join(dest, ".gitkeep");
133
+ writeFileSync(dotfile, "");
134
+ expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
135
+ expect(existsSync(dotfile)).toBe(true);
136
+ });
137
+
138
+ test("resolves folder-index sidebar links (e.g. /guide/ → guide/README.md)", () => {
139
+ const folder = mkdtempSync(path.join(tmpdir(), "sync-folder-"));
140
+ try {
141
+ const docs = path.join(folder, "docs");
142
+ const vp = path.join(docs, ".vuepress");
143
+ mkdirSync(vp, { recursive: true });
144
+ writeFileSync(
145
+ path.join(vp, "config.js"),
146
+ `export default {
147
+ title: 'Folder-Index Book',
148
+ theme: { sidebar: [{ text: 'Guide', link: '/guide/' }] },
149
+ }
150
+ `,
151
+ );
152
+ mkdirSync(path.join(docs, "guide"), { recursive: true });
153
+ writeFileSync(path.join(docs, "guide", "README.md"), "---\ntitle: Guide\n---\n# Guide\n");
154
+
155
+ const res = runSync(docs, dest);
156
+ expect(res.status).toBe(0);
157
+ // The chapter id strips the trailing slash, so the folder-index target
158
+ // exists at <dest>/guide/README.md and the TOC entry's id is `guide`.
159
+ const { data } = matter(readFileSync(path.join(dest, "index.mdx"), "utf8")) as unknown as { data: Record<string, unknown> };
160
+ expect((data.chapters as Array<{ id: string }>)[0].id).toBe("guide");
161
+ expect(existsSync(path.join(dest, "guide", "README.md"))).toBe(true);
162
+ } finally {
163
+ rmSync(folder, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ test("rejects a config.ts with a clear message instead of acorn parse failure", () => {
168
+ const tsConfig = mkdtempSync(path.join(tmpdir(), "sync-ts-config-"));
169
+ try {
170
+ const docs = path.join(tsConfig, "docs");
171
+ const vp = path.join(docs, ".vuepress");
172
+ mkdirSync(vp, { recursive: true });
173
+ writeFileSync(
174
+ path.join(vp, "config.ts"),
175
+ "const x: number = 1; export default { theme: { sidebar: [] } }\n",
176
+ );
177
+ const res = runSync(docs, dest);
178
+ expect(res.status).not.toBe(0);
179
+ expect(res.stderr).toMatch(/config\.ts/);
180
+ // Match the actionable phrasing only — if a regression let the raw
181
+ // acorn parse error through, that should fail this assertion.
182
+ expect(res.stderr).toMatch(/Compile to JavaScript|JS-only/);
183
+ } finally {
184
+ rmSync(tsConfig, { recursive: true, force: true });
185
+ }
186
+ });
187
+
188
+ test("drops a leaf with id 'contents' from the TOC (Amytis renders its own)", () => {
189
+ const withContents = mkdtempSync(path.join(tmpdir(), "sync-contents-"));
190
+ try {
191
+ const docs = path.join(withContents, "docs");
192
+ const vp = path.join(docs, ".vuepress");
193
+ mkdirSync(vp, { recursive: true });
194
+ writeFileSync(
195
+ path.join(vp, "config.js"),
196
+ `export default {
197
+ title: 'TOC-Heavy Book',
198
+ theme: {
199
+ sidebar: [
200
+ { text: '目录', link: 'contents' },
201
+ { text: 'Real', link: '/real-chapter' },
202
+ ],
203
+ },
204
+ }
205
+ `,
206
+ );
207
+ writeFileSync(path.join(docs, "contents.md"), "# Table of Contents\n- [Real](real-chapter.md)\n");
208
+ writeFileSync(path.join(docs, "real-chapter.md"), "---\ntitle: Real\n---\n# Real\n");
209
+
210
+ const res = runSync(docs, dest);
211
+ expect(res.status).toBe(0);
212
+ const { data } = matter(readFileSync(path.join(dest, "index.mdx"), "utf8")) as unknown as { data: Record<string, unknown> };
213
+ const chapterIds = (data.chapters as Array<{ id?: string; section?: string }>).map(c => c.id ?? c.section);
214
+ expect(chapterIds).toEqual(["real-chapter"]);
215
+ // The summary mentions the dropped leaf so the run isn't silent.
216
+ expect(res.stdout).toMatch(/contents/);
217
+ } finally {
218
+ rmSync(withContents, { recursive: true, force: true });
219
+ }
220
+ });
221
+
222
+ test("exits with an error when a sidebar leaf points to a missing source file", () => {
223
+ // Create a corrupt config with a broken link.
224
+ const broken = mkdtempSync(path.join(tmpdir(), "sync-broken-"));
225
+ try {
226
+ const docsDir = path.join(broken, "docs");
227
+ const vp = path.join(docsDir, ".vuepress");
228
+ mkdirSync(vp, { recursive: true });
229
+ writeFileSync(
230
+ path.join(vp, "config.js"),
231
+ "export default { theme: { sidebar: [{ text: 'Missing', link: '/nope' }] } }\n",
232
+ );
233
+ const res = runSync(docsDir, dest);
234
+ expect(res.status).not.toBe(0);
235
+ expect(res.stderr).toContain("source files that do not exist");
236
+ } finally {
237
+ rmSync(broken, { recursive: true, force: true });
238
+ }
239
+ });
240
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import MarkdownRenderer from "@/components/MarkdownRenderer";
3
+ import { renderAsync } from "@/test-utils/render";
4
+
5
+ describe("Integration: VuePress :::container alerts", () => {
6
+ test(":::note renders as a note GitHub Alert", async () => {
7
+ const html = await renderAsync(
8
+ MarkdownRenderer({ content: "::: note\n\nBody text\n:::" }),
9
+ );
10
+ expect(html).toContain('class="alert alert-note"');
11
+ expect(html).toContain("Body text");
12
+ });
13
+
14
+ test(":::tip preserves a custom title", async () => {
15
+ const html = await renderAsync(
16
+ MarkdownRenderer({ content: "::: tip 智慧的疆界\n\nBody\n:::" }),
17
+ );
18
+ expect(html).toContain('class="alert alert-tip"');
19
+ expect(html).toContain("智慧的疆界");
20
+ // The hardcoded default label should not appear when a custom title is given.
21
+ expect(html).not.toMatch(/<span>Tip<\/span>/);
22
+ });
23
+
24
+ test(":::warning renders as a warning alert", async () => {
25
+ const html = await renderAsync(
26
+ MarkdownRenderer({ content: "::: warning\n\nWatch out\n:::" }),
27
+ );
28
+ expect(html).toContain('class="alert alert-warning"');
29
+ });
30
+
31
+ test(":::danger maps to caution (GitHub Alert vocabulary)", async () => {
32
+ const html = await renderAsync(
33
+ MarkdownRenderer({ content: "::: danger\n\nUnsafe\n:::" }),
34
+ );
35
+ expect(html).toContain('class="alert alert-caution"');
36
+ });
37
+
38
+ test(":::info maps to note", async () => {
39
+ const html = await renderAsync(
40
+ MarkdownRenderer({ content: "::: info\n\nFYI\n:::" }),
41
+ );
42
+ expect(html).toContain('class="alert alert-note"');
43
+ });
44
+
45
+ test("unknown container names pass through without becoming alerts", async () => {
46
+ const html = await renderAsync(
47
+ MarkdownRenderer({ content: "::: random\n\nSomething\n:::" }),
48
+ );
49
+ expect(html).not.toContain('class="alert');
50
+ });
51
+
52
+ test("plain content is not rewritten", async () => {
53
+ const html = await renderAsync(
54
+ MarkdownRenderer({ content: "Plain paragraph." }),
55
+ );
56
+ expect(html).not.toContain('class="alert');
57
+ });
58
+
59
+ test("VuePress syntax inside a fenced code block is NOT rewritten", async () => {
60
+ // A documentation example showing VuePress syntax verbatim. The normalizer
61
+ // must skip fenced regions; otherwise the code sample silently becomes the
62
+ // syntax itself and the doc example breaks.
63
+ const html = await renderAsync(
64
+ MarkdownRenderer({
65
+ content: [
66
+ "Here is how to write a tip:",
67
+ "",
68
+ "```markdown",
69
+ "::: tip 智慧的疆界",
70
+ "Body of the tip.",
71
+ ":::",
72
+ "```",
73
+ "",
74
+ "And here is a real one:",
75
+ "",
76
+ "::: tip 真实的提示",
77
+ "Hello",
78
+ ":::",
79
+ ].join("\n"),
80
+ }),
81
+ );
82
+ // The code block keeps the relaxed `::: tip ...` form verbatim — no
83
+ // `:::tip[...]` rewrite leaks through.
84
+ expect(html).toContain("::: tip 智慧的疆界");
85
+ expect(html).not.toContain(":::tip[智慧的疆界]");
86
+ // The real container outside the code block still renders as an alert.
87
+ expect(html).toContain('class="alert alert-tip"');
88
+ expect(html).toContain("真实的提示");
89
+ });
90
+
91
+ test("fence character type matters (~~~ vs ```)", async () => {
92
+ // A `~~~` fence isn't closed by a ``` line, so the container after the
93
+ // ``` is still inside the open `~~~` fence and must NOT be rewritten.
94
+ const html = await renderAsync(
95
+ MarkdownRenderer({
96
+ content: [
97
+ "~~~",
98
+ "::: tip Inside tilde fence",
99
+ "```",
100
+ "::: tip Still inside",
101
+ "~~~",
102
+ ].join("\n"),
103
+ }),
104
+ );
105
+ expect(html).not.toContain('class="alert');
106
+ });
107
+ });
@@ -16,7 +16,7 @@ describe("Tooling: New Post Script", () => {
16
16
  if (fs.existsSync(file)) fs.unlinkSync(file);
17
17
  });
18
18
  createdDirs.forEach(dir => {
19
- if (fs.existsSync(dir)) fs.rmdirSync(dir, { recursive: true });
19
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
20
20
  });
21
21
  });
22
22
 
@@ -21,6 +21,7 @@
21
21
  * • afterAll restores the real module so any subsequent tests see real data.
22
22
  */
23
23
  import { describe, test, expect, mock, beforeAll, beforeEach, afterAll, afterEach } from 'bun:test';
24
+ import { setEnvVar, restoreEnvVar } from '../helpers/env';
24
25
 
25
26
  // ─── Capture real modules ─────────────────────────────────────────────────────
26
27
  // Static imports are hoisted and resolved before any executable code (including
@@ -36,7 +37,18 @@ import * as realUrls from '../../src/lib/urls';
36
37
  // reference, but they are never mutated during tests so this is safe.
37
38
  const snapshotUrls = { ...realUrls };
38
39
 
39
- let mockedPosts: Array<{ slug: string; series?: string; redirectFrom?: string[] }> = [];
40
+ // Mock-post shape: only `slug` is required; the named fields are the ones
41
+ // production code paths read. Extra fields (full Post shape) are allowed via
42
+ // the index signature so individual tests can pass realistic fixtures without
43
+ // every property having to be enumerated here.
44
+ let mockedPosts: Array<{
45
+ slug: string;
46
+ series?: string;
47
+ redirectFrom?: string[];
48
+ draft?: boolean;
49
+ title?: string;
50
+ [key: string]: unknown;
51
+ }> = [];
40
52
  let mockedNotes: Array<{ slug: string }> = [];
41
53
  let mockedSeries: Record<string, Array<{ slug: string }>> = {};
42
54
  let mockedSeriesData: Record<string, { redirectFrom?: string[]; title?: string }> = {};
@@ -148,7 +160,7 @@ beforeEach(() => {
148
160
  mockedNotes = [];
149
161
  mockedSeries = {};
150
162
  mockedSeriesData = {};
151
- process.env.NODE_ENV = originalNodeEnv;
163
+ restoreEnvVar('NODE_ENV', originalNodeEnv);
152
164
  });
153
165
 
154
166
  afterEach(() => {
@@ -156,7 +168,7 @@ afterEach(() => {
156
168
  mockedNotes = [];
157
169
  mockedSeries = {};
158
170
  mockedSeriesData = {};
159
- process.env.NODE_ENV = originalNodeEnv;
171
+ restoreEnvVar('NODE_ENV', originalNodeEnv);
160
172
  });
161
173
 
162
174
  // ─── Restore real modules ─────────────────────────────────────────────────────
@@ -201,7 +213,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
201
213
 
202
214
  test('notes/[slug] includes raw and encoded Unicode slug in non-production', async () => {
203
215
  mockedNotes = [{ slug: '推理模型' }];
204
- process.env.NODE_ENV = 'development';
216
+ setEnvVar('NODE_ENV', 'development');
205
217
  const { generateStaticParams } = await import('../../src/app/notes/[slug]/page');
206
218
  const params = generateStaticParams();
207
219
  expect(params).toContainEqual({ slug: '推理模型' });
@@ -210,7 +222,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
210
222
 
211
223
  test('notes/[slug] includes only raw Unicode slug in production', async () => {
212
224
  mockedNotes = [{ slug: '推理模型' }];
213
- process.env.NODE_ENV = 'production';
225
+ setEnvVar('NODE_ENV', 'production');
214
226
  const { generateStaticParams } = await import('../../src/app/notes/[slug]/page');
215
227
  const params = generateStaticParams();
216
228
  expect(params).toContainEqual({ slug: '推理模型' });
@@ -232,10 +244,10 @@ describe('generateStaticParams — placeholder when content is empty', () => {
232
244
  expect(params).toEqual([{ slug: '_' }]);
233
245
  });
234
246
 
235
- test('books/[slug]/[chapter] returns [{ slug: "_", chapter: "_" }]', async () => {
236
- const { generateStaticParams } = await import('../../src/app/books/[slug]/[chapter]/page');
247
+ test('books/[slug]/[...chapter] returns [{ slug: "_", chapter: ["_"] }]', async () => {
248
+ const { generateStaticParams } = await import('../../src/app/books/[slug]/[...chapter]/page');
237
249
  const params = await generateStaticParams();
238
- expect(params).toEqual([{ slug: '_', chapter: '_' }]);
250
+ expect(params).toEqual([{ slug: '_', chapter: ['_'] }]);
239
251
  });
240
252
  });
241
253
 
@@ -257,7 +269,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
257
269
 
258
270
  test('series/[slug] includes raw and encoded Unicode slug in non-production', async () => {
259
271
  mockedSeries = { '软件构架设计': [] };
260
- process.env.NODE_ENV = 'development';
272
+ setEnvVar('NODE_ENV', 'development');
261
273
  const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
262
274
  const params = await generateStaticParams();
263
275
  expect(params).toContainEqual({ slug: '软件构架设计' });
@@ -266,7 +278,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
266
278
 
267
279
  test('series/[slug] includes only raw Unicode slug in production', async () => {
268
280
  mockedSeries = { '软件构架设计': [] };
269
- process.env.NODE_ENV = 'production';
281
+ setEnvVar('NODE_ENV', 'production');
270
282
  const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
271
283
  const params = await generateStaticParams();
272
284
  expect(params).toContainEqual({ slug: '软件构架设计' });
@@ -281,7 +293,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
281
293
 
282
294
  test('series/[slug]/page/[page] includes encoded Unicode slug in non-production', async () => {
283
295
  mockedSeries = { '软件构架设计': Array.from({ length: 6 }, (_, i) => ({ slug: `p${i + 1}` })) };
284
- process.env.NODE_ENV = 'development';
296
+ setEnvVar('NODE_ENV', 'development');
285
297
  const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
286
298
  const params = await generateStaticParams();
287
299
  expect(params).toContainEqual({ slug: '软件构架设计', page: '2' });
@@ -361,7 +373,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
361
373
 
362
374
  test('posts/[slug] includes raw and encoded Unicode slug in non-production', async () => {
363
375
  mockedPosts = [{ slug: '中文测试文章' }];
364
- process.env.NODE_ENV = 'development';
376
+ setEnvVar('NODE_ENV', 'development');
365
377
  const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
366
378
  const params = await generateStaticParams();
367
379
 
@@ -371,7 +383,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
371
383
 
372
384
  test('posts/[slug] includes only raw Unicode slug in production', async () => {
373
385
  mockedPosts = [{ slug: '中文测试文章' }];
374
- process.env.NODE_ENV = 'production';
386
+ setEnvVar('NODE_ENV', 'production');
375
387
  const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
376
388
  const params = await generateStaticParams();
377
389
 
@@ -485,7 +497,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
485
497
 
486
498
  test('[slug]/page does not include single-segment redirectFrom for draft posts in production', async () => {
487
499
  mockedPosts = [{ slug: 'my-post', draft: true, redirectFrom: ['/old-slug'] }];
488
- process.env.NODE_ENV = 'production';
500
+ setEnvVar('NODE_ENV', 'production');
489
501
  const { generateStaticParams } = await import('../../src/app/[slug]/page');
490
502
  const params = await generateStaticParams();
491
503
  expect(params).not.toContainEqual({ slug: 'old-slug' });
@@ -588,7 +600,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
588
600
  test('[slug]/[postSlug] includes encoded Unicode postSlug variants in non-production', async () => {
589
601
  // Use redirectFrom to place a Unicode postSlug at a 2-segment path — no url mock needed.
590
602
  mockedPosts = [{ slug: 'my-post', redirectFrom: ['/old-prefix/中文文章'] }];
591
- process.env.NODE_ENV = 'development';
603
+ setEnvVar('NODE_ENV', 'development');
592
604
  const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
593
605
  const params = await generateStaticParams();
594
606
  expect(params).toContainEqual({ slug: 'old-prefix', postSlug: '中文文章' });
@@ -597,7 +609,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
597
609
 
598
610
  test('[slug]/[postSlug] includes encoded Unicode prefix variants in non-production', async () => {
599
611
  mockedSeries = { '软件构架设计': [{ slug: 'architecture-post' }] };
600
- process.env.NODE_ENV = 'development';
612
+ setEnvVar('NODE_ENV', 'development');
601
613
  const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
602
614
  const params = await generateStaticParams();
603
615
  expect(params).toContainEqual({ slug: '软件构架设计', postSlug: 'architecture-post' });
@@ -606,7 +618,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
606
618
 
607
619
  test('[slug]/[postSlug] includes encoded Unicode prefix and postSlug variants together in non-production', async () => {
608
620
  mockedSeries = { '软件构架设计': [{ slug: '中文文章' }] };
609
- process.env.NODE_ENV = 'development';
621
+ setEnvVar('NODE_ENV', 'development');
610
622
  const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
611
623
  const params = await generateStaticParams();
612
624
  expect(params).toContainEqual({ slug: '软件构架设计', postSlug: '中文文章' });
@@ -617,7 +629,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
617
629
 
618
630
  test('[slug]/[postSlug] does not include encoded Unicode postSlug variants in production', async () => {
619
631
  mockedPosts = [{ slug: 'my-post', redirectFrom: ['/old-prefix/中文文章'] }];
620
- process.env.NODE_ENV = 'production';
632
+ setEnvVar('NODE_ENV', 'production');
621
633
  const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
622
634
  const params = await generateStaticParams();
623
635
  expect(params).toContainEqual({ slug: 'old-prefix', postSlug: '中文文章' });
@@ -641,7 +653,8 @@ describe('generateStaticParams — placeholder when content is empty', () => {
641
653
  imageBaseSlug: 'posts',
642
654
  category: 'Test',
643
655
  tags: [],
644
- readingTime: '1 min read',
656
+ readingMinutes: 1,
657
+ wordCount: 0,
645
658
  }];
646
659
 
647
660
  const page = await import('../../src/app/[slug]/[postSlug]/page');