@hutusi/amytis 1.14.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 (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +90 -219
  5. package/README.md +33 -1
  6. package/README.zh.md +33 -1
  7. package/TODO.md +10 -0
  8. package/bun.lock +205 -539
  9. package/content/books/sample-book/index.mdx +3 -0
  10. package/content/posts/code-block-features-showcase.mdx +223 -0
  11. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  12. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  13. package/content/series/rst-legacy/getting-started.rst +24 -0
  14. package/content/series/rst-legacy/index.rst +9 -0
  15. package/content/series/rst-readme/README.rst +9 -0
  16. package/content/series/rst-readme/readme-index-post.rst +10 -0
  17. package/content/series/rst-toctree/first-post.rst +6 -0
  18. package/content/series/rst-toctree/index.rst +10 -0
  19. package/content/series/rst-toctree/second-post.rst +6 -0
  20. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/index.rst +12 -0
  22. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  23. package/docs/ALERTS.md +112 -0
  24. package/docs/ARCHITECTURE.md +239 -8
  25. package/docs/CODE-BLOCKS.md +238 -0
  26. package/docs/CONTRIBUTING.md +36 -0
  27. package/docs/guides/README.md +11 -0
  28. package/docs/guides/importing-vuepress-books.md +178 -0
  29. package/eslint.config.mjs +20 -6
  30. package/next.config.ts +2 -2
  31. package/package.json +52 -24
  32. package/packages/create-amytis/package.json +1 -1
  33. package/packages/create-amytis/src/index.test.ts +43 -1
  34. package/packages/create-amytis/src/index.ts +64 -8
  35. package/public/next-image-export-optimizer-hashes.json +14 -73
  36. package/scripts/build-pagefind.ts +172 -0
  37. package/scripts/copy-assets.ts +246 -56
  38. package/scripts/generate-code-group-icons.ts +79 -0
  39. package/scripts/generate-knowledge-graph.ts +2 -1
  40. package/scripts/render-rst.py +923 -0
  41. package/scripts/run-with-rst-python.ts +42 -0
  42. package/scripts/sync-vuepress-book.ts +499 -0
  43. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  44. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  45. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  46. package/src/app/books/[slug]/page.tsx +67 -32
  47. package/src/app/globals.css +639 -94
  48. package/src/app/page.tsx +1 -1
  49. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  50. package/src/app/series/[slug]/page.tsx +11 -13
  51. package/src/app/series/page.tsx +3 -3
  52. package/src/app/sitemap.ts +3 -3
  53. package/src/components/ArticleCopyCleaner.tsx +64 -0
  54. package/src/components/AuthorCard.tsx +25 -16
  55. package/src/components/BookMobileNav.tsx +44 -50
  56. package/src/components/BookSidebar.tsx +0 -0
  57. package/src/components/CodeBlock.test.tsx +93 -8
  58. package/src/components/CodeBlock.tsx +39 -101
  59. package/src/components/CodeBlockToolbar.tsx +88 -0
  60. package/src/components/CodeGroup.tsx +81 -0
  61. package/src/components/CoverImage.tsx +6 -2
  62. package/src/components/ExternalLinkIcon.tsx +15 -0
  63. package/src/components/FeaturedStoriesSection.tsx +3 -3
  64. package/src/components/GithubAlert.tsx +97 -0
  65. package/src/components/MarkdownRenderer.test.tsx +30 -4
  66. package/src/components/MarkdownRenderer.tsx +148 -24
  67. package/src/components/Mermaid.tsx +32 -1
  68. package/src/components/PostList.tsx +1 -1
  69. package/src/components/PostNavigation.tsx +13 -2
  70. package/src/components/PostSidebar.tsx +13 -2
  71. package/src/components/RstRenderer.test.tsx +93 -0
  72. package/src/components/RstRenderer.tsx +157 -0
  73. package/src/components/Search.tsx +18 -4
  74. package/src/components/SeriesCatalog.tsx +1 -1
  75. package/src/components/ShareBar.tsx +5 -0
  76. package/src/components/TocPanel.tsx +10 -2
  77. package/src/i18n/translations.ts +2 -0
  78. package/src/layouts/BookLayout.tsx +35 -4
  79. package/src/layouts/PostLayout.tsx +10 -2
  80. package/src/layouts/SimpleLayout.tsx +10 -3
  81. package/src/lib/code-group-icons.test.ts +78 -0
  82. package/src/lib/code-group-icons.ts +148 -0
  83. package/src/lib/image-utils.test.ts +19 -0
  84. package/src/lib/image-utils.ts +11 -0
  85. package/src/lib/markdown.test.ts +195 -14
  86. package/src/lib/markdown.ts +928 -254
  87. package/src/lib/normalize-vuepress-math.ts +118 -0
  88. package/src/lib/rehype-fence-meta.ts +22 -0
  89. package/src/lib/rehype-image-metadata.ts +2 -2
  90. package/src/lib/remark-book-chapter-links.ts +106 -0
  91. package/src/lib/remark-code-group.ts +54 -0
  92. package/src/lib/remark-github-alerts.test.ts +83 -0
  93. package/src/lib/remark-github-alerts.ts +65 -0
  94. package/src/lib/remark-vuepress-containers.ts +130 -0
  95. package/src/lib/rst-renderer.test.ts +355 -0
  96. package/src/lib/rst-renderer.ts +629 -0
  97. package/src/lib/rst.test.ts +350 -0
  98. package/src/lib/rst.ts +674 -0
  99. package/src/lib/series-redirects.ts +42 -0
  100. package/src/lib/shiki-rst.ts +185 -0
  101. package/src/lib/shiki.test.ts +153 -0
  102. package/src/lib/shiki.ts +292 -0
  103. package/src/lib/urls.ts +57 -0
  104. package/src/test-utils/render.ts +23 -0
  105. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  106. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  107. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  108. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  109. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  110. package/tests/helpers/env.ts +19 -0
  111. package/tests/integration/book-chapter-links.test.ts +107 -0
  112. package/tests/integration/books-nested-toc.test.ts +176 -0
  113. package/tests/integration/books.test.ts +3 -2
  114. package/tests/integration/code-block-features.test.ts +188 -0
  115. package/tests/integration/code-group.test.ts +183 -0
  116. package/tests/integration/code-notation.test.ts +97 -0
  117. package/tests/integration/feed-utils.test.ts +13 -0
  118. package/tests/integration/github-alerts.test.ts +82 -0
  119. package/tests/integration/markdown-external-links.test.ts +103 -0
  120. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  121. package/tests/integration/reading-time-headings.test.ts +12 -14
  122. package/tests/integration/series-draft.test.ts +12 -5
  123. package/tests/integration/series.test.ts +93 -0
  124. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  125. package/tests/integration/vuepress-containers.test.ts +107 -0
  126. package/tests/tooling/build-pagefind.test.ts +66 -0
  127. package/tests/tooling/new-post.test.ts +1 -1
  128. package/tests/unit/static-params.test.ts +166 -13
@@ -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 }> = {};
@@ -131,6 +143,7 @@ beforeAll(() => {
131
143
  getSeriesData: (slug: string) => mockedSeriesData[slug] ?? null,
132
144
  getSeriesPosts: () => [],
133
145
  getSeriesAuthors: () => [],
146
+ getCollectionsForPost: () => [],
134
147
 
135
148
  getAuthorSlug: (name: string) =>
136
149
  name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
@@ -147,7 +160,7 @@ beforeEach(() => {
147
160
  mockedNotes = [];
148
161
  mockedSeries = {};
149
162
  mockedSeriesData = {};
150
- process.env.NODE_ENV = originalNodeEnv;
163
+ restoreEnvVar('NODE_ENV', originalNodeEnv);
151
164
  });
152
165
 
153
166
  afterEach(() => {
@@ -155,7 +168,7 @@ afterEach(() => {
155
168
  mockedNotes = [];
156
169
  mockedSeries = {};
157
170
  mockedSeriesData = {};
158
- process.env.NODE_ENV = originalNodeEnv;
171
+ restoreEnvVar('NODE_ENV', originalNodeEnv);
159
172
  });
160
173
 
161
174
  // ─── Restore real modules ─────────────────────────────────────────────────────
@@ -200,7 +213,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
200
213
 
201
214
  test('notes/[slug] includes raw and encoded Unicode slug in non-production', async () => {
202
215
  mockedNotes = [{ slug: '推理模型' }];
203
- process.env.NODE_ENV = 'development';
216
+ setEnvVar('NODE_ENV', 'development');
204
217
  const { generateStaticParams } = await import('../../src/app/notes/[slug]/page');
205
218
  const params = generateStaticParams();
206
219
  expect(params).toContainEqual({ slug: '推理模型' });
@@ -209,7 +222,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
209
222
 
210
223
  test('notes/[slug] includes only raw Unicode slug in production', async () => {
211
224
  mockedNotes = [{ slug: '推理模型' }];
212
- process.env.NODE_ENV = 'production';
225
+ setEnvVar('NODE_ENV', 'production');
213
226
  const { generateStaticParams } = await import('../../src/app/notes/[slug]/page');
214
227
  const params = generateStaticParams();
215
228
  expect(params).toContainEqual({ slug: '推理模型' });
@@ -231,10 +244,10 @@ describe('generateStaticParams — placeholder when content is empty', () => {
231
244
  expect(params).toEqual([{ slug: '_' }]);
232
245
  });
233
246
 
234
- test('books/[slug]/[chapter] returns [{ slug: "_", chapter: "_" }]', async () => {
235
- 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');
236
249
  const params = await generateStaticParams();
237
- expect(params).toEqual([{ slug: '_', chapter: '_' }]);
250
+ expect(params).toEqual([{ slug: '_', chapter: ['_'] }]);
238
251
  });
239
252
  });
240
253
 
@@ -254,11 +267,101 @@ describe('generateStaticParams — placeholder when content is empty', () => {
254
267
  expect(params).toContainEqual({ slug: 'old-name' });
255
268
  });
256
269
 
270
+ test('series/[slug] includes raw and encoded Unicode slug in non-production', async () => {
271
+ mockedSeries = { '软件构架设计': [] };
272
+ setEnvVar('NODE_ENV', 'development');
273
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
274
+ const params = await generateStaticParams();
275
+ expect(params).toContainEqual({ slug: '软件构架设计' });
276
+ expect(params).toContainEqual({ slug: '%E8%BD%AF%E4%BB%B6%E6%9E%84%E6%9E%B6%E8%AE%BE%E8%AE%A1' });
277
+ });
278
+
279
+ test('series/[slug] includes only raw Unicode slug in production', async () => {
280
+ mockedSeries = { '软件构架设计': [] };
281
+ setEnvVar('NODE_ENV', 'production');
282
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
283
+ const params = await generateStaticParams();
284
+ expect(params).toContainEqual({ slug: '软件构架设计' });
285
+ expect(params).not.toContainEqual({ slug: '%E8%BD%AF%E4%BB%B6%E6%9E%84%E6%9E%B6%E8%AE%BE%E8%AE%A1' });
286
+ });
287
+
257
288
  test('series/[slug]/page/[page] returns [{ slug: "_", page: "2" }]', async () => {
258
289
  const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
259
290
  const params = await generateStaticParams();
260
291
  expect(params).toEqual([{ slug: '_', page: '2' }]);
261
292
  });
293
+
294
+ test('series/[slug]/page/[page] includes encoded Unicode slug in non-production', async () => {
295
+ mockedSeries = { '软件构架设计': Array.from({ length: 6 }, (_, i) => ({ slug: `p${i + 1}` })) };
296
+ setEnvVar('NODE_ENV', 'development');
297
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
298
+ const params = await generateStaticParams();
299
+ expect(params).toContainEqual({ slug: '软件构架设计', page: '2' });
300
+ expect(params).toContainEqual({ slug: '%E8%BD%AF%E4%BB%B6%E6%9E%84%E6%9E%B6%E8%AE%BE%E8%AE%A1', page: '2' });
301
+ });
302
+
303
+ test('series/[slug]/page/[page] includes redirectFrom slug when series is renamed', async () => {
304
+ mockedSeries = { 'new-name': Array.from({ length: 6 }, (_, i) => ({ slug: `p${i + 1}` })) };
305
+ mockedSeriesData = { 'new-name': { redirectFrom: ['/series/old-name'], title: 'New Series' } };
306
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
307
+ const params = await generateStaticParams();
308
+ expect(params).toContainEqual({ slug: 'new-name', page: '2' });
309
+ expect(params).toContainEqual({ slug: 'old-name', page: '2' });
310
+ });
311
+
312
+ test('series/[slug]/page/[page] redirects old alias slugs to the canonical paginated path', async () => {
313
+ mockedSeries = { 'new-name': Array.from({ length: 6 }, (_, i) => ({ slug: `p${i + 1}` })) };
314
+ mockedSeriesData = { 'new-name': { redirectFrom: ['/series/old-name'], title: 'New Series' } };
315
+ const page = await import('../../src/app/series/[slug]/page/[page]/page');
316
+ await expect(page.default({
317
+ params: Promise.resolve({ slug: 'old-name', page: '2' }),
318
+ })).resolves.toBeDefined();
319
+ });
320
+
321
+ test('series routes match percent-encoded redirectFrom aliases after normalization', async () => {
322
+ mockedSeries = { '软件构架设计': Array.from({ length: 6 }, (_, i) => ({ slug: `p${i + 1}` })) };
323
+ mockedSeriesData = {
324
+ '软件构架设计': {
325
+ redirectFrom: ['/series/%E8%BD%AF%E4%BB%B6%E8%AE%BE%E8%AE%A1'],
326
+ title: '软件构架设计',
327
+ },
328
+ };
329
+
330
+ const seriesPage = await import('../../src/app/series/[slug]/page');
331
+ await expect(seriesPage.default({
332
+ params: Promise.resolve({ slug: '%E8%BD%AF%E4%BB%B6%E8%AE%BE%E8%AE%A1' }),
333
+ })).resolves.toBeDefined();
334
+
335
+ const paginatedPage = await import('../../src/app/series/[slug]/page/[page]/page');
336
+ await expect(paginatedPage.default({
337
+ params: Promise.resolve({ slug: '%E8%BD%AF%E4%BB%B6%E8%AE%BE%E8%AE%A1', page: '2' }),
338
+ })).resolves.toBeDefined();
339
+ });
340
+
341
+ test('series/[slug]/page/[page] throws when redirectFrom alias conflicts with an existing series slug', async () => {
342
+ mockedSeries = {
343
+ 'existing-slug': Array.from({ length: 6 }, (_, i) => ({ slug: `a${i + 1}` })),
344
+ 'new-name': Array.from({ length: 6 }, (_, i) => ({ slug: `b${i + 1}` })),
345
+ };
346
+ mockedSeriesData = {
347
+ 'new-name': { redirectFrom: ['/series/existing-slug'], title: 'New Series' },
348
+ };
349
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
350
+ await expect(generateStaticParams()).rejects.toThrow(/conflicts with an existing series slug/i);
351
+ });
352
+
353
+ test('series/[slug]/page/[page] throws when two series claim the same redirectFrom alias', async () => {
354
+ mockedSeries = {
355
+ 'first-series': Array.from({ length: 6 }, (_, i) => ({ slug: `a${i + 1}` })),
356
+ 'second-series': Array.from({ length: 6 }, (_, i) => ({ slug: `b${i + 1}` })),
357
+ };
358
+ mockedSeriesData = {
359
+ 'first-series': { redirectFrom: ['/series/old-name'], title: 'First' },
360
+ 'second-series': { redirectFrom: ['/series/old-name'], title: 'Second' },
361
+ };
362
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
363
+ await expect(generateStaticParams()).rejects.toThrow(/claimed by both/i);
364
+ });
262
365
  });
263
366
 
264
367
  describe('posts routes', () => {
@@ -270,7 +373,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
270
373
 
271
374
  test('posts/[slug] includes raw and encoded Unicode slug in non-production', async () => {
272
375
  mockedPosts = [{ slug: '中文测试文章' }];
273
- process.env.NODE_ENV = 'development';
376
+ setEnvVar('NODE_ENV', 'development');
274
377
  const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
275
378
  const params = await generateStaticParams();
276
379
 
@@ -280,7 +383,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
280
383
 
281
384
  test('posts/[slug] includes only raw Unicode slug in production', async () => {
282
385
  mockedPosts = [{ slug: '中文测试文章' }];
283
- process.env.NODE_ENV = 'production';
386
+ setEnvVar('NODE_ENV', 'production');
284
387
  const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
285
388
  const params = await generateStaticParams();
286
389
 
@@ -394,7 +497,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
394
497
 
395
498
  test('[slug]/page does not include single-segment redirectFrom for draft posts in production', async () => {
396
499
  mockedPosts = [{ slug: 'my-post', draft: true, redirectFrom: ['/old-slug'] }];
397
- process.env.NODE_ENV = 'production';
500
+ setEnvVar('NODE_ENV', 'production');
398
501
  const { generateStaticParams } = await import('../../src/app/[slug]/page');
399
502
  const params = await generateStaticParams();
400
503
  expect(params).not.toContainEqual({ slug: 'old-slug' });
@@ -497,22 +600,72 @@ describe('generateStaticParams — placeholder when content is empty', () => {
497
600
  test('[slug]/[postSlug] includes encoded Unicode postSlug variants in non-production', async () => {
498
601
  // Use redirectFrom to place a Unicode postSlug at a 2-segment path — no url mock needed.
499
602
  mockedPosts = [{ slug: 'my-post', redirectFrom: ['/old-prefix/中文文章'] }];
500
- process.env.NODE_ENV = 'development';
603
+ setEnvVar('NODE_ENV', 'development');
501
604
  const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
502
605
  const params = await generateStaticParams();
503
606
  expect(params).toContainEqual({ slug: 'old-prefix', postSlug: '中文文章' });
504
607
  expect(params).toContainEqual({ slug: 'old-prefix', postSlug: encodeURIComponent('中文文章') });
505
608
  });
506
609
 
610
+ test('[slug]/[postSlug] includes encoded Unicode prefix variants in non-production', async () => {
611
+ mockedSeries = { '软件构架设计': [{ slug: 'architecture-post' }] };
612
+ setEnvVar('NODE_ENV', 'development');
613
+ const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
614
+ const params = await generateStaticParams();
615
+ expect(params).toContainEqual({ slug: '软件构架设计', postSlug: 'architecture-post' });
616
+ expect(params).toContainEqual({ slug: encodeURIComponent('软件构架设计'), postSlug: 'architecture-post' });
617
+ });
618
+
619
+ test('[slug]/[postSlug] includes encoded Unicode prefix and postSlug variants together in non-production', async () => {
620
+ mockedSeries = { '软件构架设计': [{ slug: '中文文章' }] };
621
+ setEnvVar('NODE_ENV', 'development');
622
+ const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
623
+ const params = await generateStaticParams();
624
+ expect(params).toContainEqual({ slug: '软件构架设计', postSlug: '中文文章' });
625
+ expect(params).toContainEqual({ slug: encodeURIComponent('软件构架设计'), postSlug: '中文文章' });
626
+ expect(params).toContainEqual({ slug: '软件构架设计', postSlug: encodeURIComponent('中文文章') });
627
+ expect(params).toContainEqual({ slug: encodeURIComponent('软件构架设计'), postSlug: encodeURIComponent('中文文章') });
628
+ });
629
+
507
630
  test('[slug]/[postSlug] does not include encoded Unicode postSlug variants in production', async () => {
508
631
  mockedPosts = [{ slug: 'my-post', redirectFrom: ['/old-prefix/中文文章'] }];
509
- process.env.NODE_ENV = 'production';
632
+ setEnvVar('NODE_ENV', 'production');
510
633
  const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
511
634
  const params = await generateStaticParams();
512
635
  expect(params).toContainEqual({ slug: 'old-prefix', postSlug: '中文文章' });
513
636
  expect(params).not.toContainEqual({ slug: 'old-prefix', postSlug: encodeURIComponent('中文文章') });
514
637
  });
515
638
 
639
+ test('[slug]/[postSlug] page resolves encoded Unicode series prefix without notFound', async () => {
640
+ mockedSeries = { '软件构架设计': [{ slug: 'my-post' }] };
641
+ mockedSeriesData = { '软件构架设计': { title: '软件构架设计' } };
642
+ mockedPosts = [{
643
+ slug: 'my-post',
644
+ title: 'My Post',
645
+ excerpt: 'Excerpt',
646
+ date: '2026-01-01',
647
+ authors: ['Author'],
648
+ series: '软件构架设计',
649
+ redirectFrom: ['/软件构架设计/my-post'],
650
+ layout: 'post',
651
+ content: 'Body',
652
+ headings: [],
653
+ imageBaseSlug: 'posts',
654
+ category: 'Test',
655
+ tags: [],
656
+ readingMinutes: 1,
657
+ wordCount: 0,
658
+ }];
659
+
660
+ const page = await import('../../src/app/[slug]/[postSlug]/page');
661
+ await expect(page.default({
662
+ params: Promise.resolve({
663
+ slug: encodeURIComponent('软件构架设计'),
664
+ postSlug: 'my-post',
665
+ }),
666
+ })).resolves.toBeDefined();
667
+ });
668
+
516
669
  test('[slug]/page/[page]/page returns placeholder when no custom paths', async () => {
517
670
  const { generateStaticParams } = await import('../../src/app/[slug]/page/[page]/page');
518
671
  const params = await generateStaticParams();