@hutusi/amytis 1.14.0 → 1.15.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 (63) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +33 -1
  5. package/README.zh.md +33 -1
  6. package/TODO.md +10 -0
  7. package/bun.lock +69 -41
  8. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  9. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  10. package/content/series/rst-legacy/getting-started.rst +24 -0
  11. package/content/series/rst-legacy/index.rst +9 -0
  12. package/content/series/rst-readme/README.rst +9 -0
  13. package/content/series/rst-readme/readme-index-post.rst +10 -0
  14. package/content/series/rst-toctree/first-post.rst +6 -0
  15. package/content/series/rst-toctree/index.rst +10 -0
  16. package/content/series/rst-toctree/second-post.rst +6 -0
  17. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  18. package/content/series/rst-toctree-precedence/index.rst +12 -0
  19. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  20. package/docs/ARCHITECTURE.md +22 -3
  21. package/docs/CONTRIBUTING.md +11 -0
  22. package/eslint.config.mjs +2 -0
  23. package/next.config.ts +2 -2
  24. package/package.json +22 -16
  25. package/packages/create-amytis/package.json +1 -1
  26. package/packages/create-amytis/src/index.test.ts +43 -1
  27. package/packages/create-amytis/src/index.ts +64 -8
  28. package/public/next-image-export-optimizer-hashes.json +14 -73
  29. package/scripts/build-pagefind.ts +172 -0
  30. package/scripts/copy-assets.ts +246 -56
  31. package/scripts/generate-knowledge-graph.ts +2 -1
  32. package/scripts/render-rst.py +719 -0
  33. package/scripts/run-with-rst-python.ts +42 -0
  34. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  35. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  36. package/src/app/globals.css +165 -0
  37. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  38. package/src/app/series/[slug]/page.tsx +11 -13
  39. package/src/app/series/page.tsx +3 -3
  40. package/src/components/AuthorCard.tsx +25 -16
  41. package/src/components/CoverImage.tsx +5 -2
  42. package/src/components/MarkdownRenderer.test.tsx +16 -0
  43. package/src/components/MarkdownRenderer.tsx +4 -1
  44. package/src/components/RstRenderer.test.tsx +93 -0
  45. package/src/components/RstRenderer.tsx +122 -0
  46. package/src/layouts/PostLayout.tsx +5 -1
  47. package/src/layouts/SimpleLayout.tsx +10 -3
  48. package/src/lib/image-utils.test.ts +19 -0
  49. package/src/lib/image-utils.ts +11 -0
  50. package/src/lib/markdown.test.ts +140 -2
  51. package/src/lib/markdown.ts +731 -210
  52. package/src/lib/rehype-image-metadata.ts +2 -2
  53. package/src/lib/rst-renderer.test.ts +355 -0
  54. package/src/lib/rst-renderer.ts +617 -0
  55. package/src/lib/rst.test.ts +140 -0
  56. package/src/lib/rst.ts +470 -0
  57. package/src/lib/series-redirects.ts +42 -0
  58. package/tests/integration/feed-utils.test.ts +13 -0
  59. package/tests/integration/reading-time-headings.test.ts +5 -9
  60. package/tests/integration/series-draft.test.ts +16 -2
  61. package/tests/integration/series.test.ts +93 -0
  62. package/tests/tooling/build-pagefind.test.ts +66 -0
  63. package/tests/unit/static-params.test.ts +140 -0
@@ -1,6 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { getSeriesData, getAllSeries } from "../../src/lib/markdown";
3
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
+ }
11
+
4
12
  describe("Integration: Series Draft Support", () => {
5
13
  test("all series are included when NODE_ENV is not production", () => {
6
14
  // In test environment NODE_ENV is typically 'test'
@@ -18,20 +26,25 @@ describe("Integration: Series Draft Support", () => {
18
26
 
19
27
  test("draft filtering code path runs without error in production mode", () => {
20
28
  const originalEnv = process.env.NODE_ENV;
29
+ const originalPythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
21
30
  try {
22
31
  process.env.NODE_ENV = "production";
32
+ process.env.AMYTIS_ENABLE_PYTHON_RST = "0";
23
33
  // This should not throw; draft series are simply excluded
24
34
  const series = getAllSeries();
25
35
  expect(typeof series).toBe("object");
26
36
  } finally {
27
- process.env.NODE_ENV = originalEnv;
37
+ restoreEnvVar("NODE_ENV", originalEnv);
38
+ restoreEnvVar("AMYTIS_ENABLE_PYTHON_RST", originalPythonRst);
28
39
  }
29
40
  });
30
41
 
31
42
  test("draft series are excluded in production mode", () => {
32
43
  const originalEnv = process.env.NODE_ENV;
44
+ const originalPythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
33
45
  try {
34
46
  process.env.NODE_ENV = "production";
47
+ process.env.AMYTIS_ENABLE_PYTHON_RST = "0";
35
48
  const allSeries = getAllSeries();
36
49
 
37
50
  // Verify that every series returned has draft: false (or undefined which defaults to false)
@@ -40,7 +53,8 @@ describe("Integration: Series Draft Support", () => {
40
53
  expect(seriesData?.draft).not.toBe(true);
41
54
  });
42
55
  } finally {
43
- process.env.NODE_ENV = originalEnv;
56
+ restoreEnvVar("NODE_ENV", originalEnv);
57
+ restoreEnvVar("AMYTIS_ENABLE_PYTHON_RST", originalPythonRst);
44
58
  }
45
59
  });
46
60
  });
@@ -1,7 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { parseRstDocument, RstParseError } from "../../src/lib/rst";
2
3
  import {
3
4
  getAllSeries,
5
+ getAdjacentPosts,
4
6
  getSeriesData,
7
+ getSeriesLatestPostDate,
5
8
  getSeriesPosts,
6
9
  getFeaturedPosts,
7
10
  getFeaturedSeries,
@@ -13,6 +16,10 @@ describe("Integration: Series", () => {
13
16
  expect(Object.keys(series).length).toBeGreaterThan(0);
14
17
  expect(series).toHaveProperty("nextjs-deep-dive");
15
18
  expect(series).toHaveProperty("digital-garden");
19
+ expect(series).toHaveProperty("rst-legacy");
20
+ expect(series).toHaveProperty("rst-readme");
21
+ expect(series).toHaveProperty("rst-toctree");
22
+ expect(series).toHaveProperty("rst-toctree-precedence");
16
23
  });
17
24
 
18
25
  test("getSeriesData returns metadata with correct fields", () => {
@@ -30,6 +37,49 @@ describe("Integration: Series", () => {
30
37
  expect(data).toBeNull();
31
38
  });
32
39
 
40
+ test("getSeriesData loads rST series metadata", () => {
41
+ const data = getSeriesData("rst-legacy");
42
+ expect(data).not.toBeNull();
43
+ expect(data!.title).toBe("Rst Legacy Series");
44
+ expect(data!.sourceFormat).toBe("rst");
45
+ expect(data!.sort).toBe("manual");
46
+ expect(data!.posts).toEqual(["getting-started", "deeper-notes"]);
47
+ });
48
+
49
+ test("getSeriesData accepts README.rst as the series index", () => {
50
+ const data = getSeriesData("rst-readme");
51
+ expect(data).not.toBeNull();
52
+ expect(data!.title).toBe("Rst README Series");
53
+ expect(data!.sourceFormat).toBe("rst");
54
+ expect(data!.posts).toEqual(["readme-index-post"]);
55
+ });
56
+
57
+ test("getSeriesData derives manual order from rST toctree when posts metadata is absent", () => {
58
+ const data = getSeriesData("rst-toctree");
59
+ expect(data).not.toBeNull();
60
+ expect(data!.title).toBe("Rst Toctree Series");
61
+ expect(data!.sourceFormat).toBe("rst");
62
+ expect(data!.sort).toBe("manual");
63
+ expect(data!.posts).toEqual(["second-post", "first-post"]);
64
+ });
65
+
66
+ test("getSeriesData rejects unsafe series slugs", () => {
67
+ expect(() => getSeriesData("../etc/passwd")).toThrow();
68
+ expect(() => getSeriesData("nested/slug")).toThrow();
69
+ });
70
+
71
+ test("rST series indexes reject impossible dates", () => {
72
+ expect(() => parseRstDocument([
73
+ "Broken rST Series",
74
+ "=================",
75
+ "",
76
+ ":date: 2021-16-15",
77
+ "",
78
+ "Body.",
79
+ "",
80
+ ].join("\n"))).toThrow(RstParseError);
81
+ });
82
+
33
83
  test("getSeriesPosts returns posts in manual order for manual series", () => {
34
84
  const seriesData = getSeriesData("nextjs-deep-dive");
35
85
  expect(seriesData?.sort).toBe("manual");
@@ -62,6 +112,49 @@ describe("Integration: Series", () => {
62
112
  expect(posts).toEqual([]);
63
113
  });
64
114
 
115
+ test("getSeriesPosts returns rST posts in manual order", () => {
116
+ const posts = getSeriesPosts("rst-legacy");
117
+ expect(posts.map(post => post.slug)).toEqual(["getting-started", "deeper-notes"]);
118
+ expect(posts.every(post => post.sourceFormat === "rst")).toBe(true);
119
+ });
120
+
121
+ test("getSeriesPosts loads posts for README.rst-based series", () => {
122
+ const posts = getSeriesPosts("rst-readme");
123
+ expect(posts.map(post => post.slug)).toEqual(["readme-index-post"]);
124
+ expect(posts[0]?.sourceFormat).toBe("rst");
125
+ });
126
+
127
+ test("getSeriesPosts follows rST toctree order when posts metadata is absent", () => {
128
+ const posts = getSeriesPosts("rst-toctree");
129
+ expect(posts.map(post => post.slug)).toEqual(["second-post", "first-post"]);
130
+ expect(posts.every(post => post.sourceFormat === "rst")).toBe(true);
131
+ });
132
+
133
+ test("getSeriesLatestPostDate uses the newest post date instead of manual series order", () => {
134
+ expect(getSeriesData("nextjs-deep-dive")?.sort).toBe("manual");
135
+ expect(getSeriesPosts("nextjs-deep-dive").map(post => post.date)).toEqual(["2026-01-30", "2026-01-31"]);
136
+ expect(getSeriesLatestPostDate("nextjs-deep-dive")).toBe("2026-01-31");
137
+ });
138
+
139
+ test("getAdjacentPosts follows rST series order instead of global post date order", () => {
140
+ const first = getAdjacentPosts("getting-started");
141
+ expect(first.prev?.slug ?? null).toBeNull();
142
+ expect(first.next?.slug).toBe("deeper-notes");
143
+
144
+ const second = getAdjacentPosts("deeper-notes");
145
+ expect(second.prev?.slug).toBe("getting-started");
146
+ expect(second.next?.slug ?? null).toBeNull();
147
+ });
148
+
149
+ test("explicit rST posts metadata takes precedence over toctree order", () => {
150
+ const data = getSeriesData("rst-toctree-precedence");
151
+ expect(data).not.toBeNull();
152
+ expect(data!.posts).toEqual(["first-post", "second-post"]);
153
+
154
+ const posts = getSeriesPosts("rst-toctree-precedence");
155
+ expect(posts.map(post => post.slug)).toEqual(["first-post", "second-post"]);
156
+ });
157
+
65
158
  test("getFeaturedPosts returns only posts with featured: true", () => {
66
159
  const featured = getFeaturedPosts();
67
160
  featured.forEach((post) => {
@@ -0,0 +1,66 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import {
6
+ collectHtmlFileHashes,
7
+ getPagefindManifestPathForTests,
8
+ shouldSkipPagefindBuild,
9
+ } from "../../scripts/build-pagefind";
10
+
11
+ const createdDirs: string[] = [];
12
+
13
+ afterEach(() => {
14
+ for (const dir of createdDirs.splice(0)) {
15
+ fs.rmSync(dir, { recursive: true, force: true });
16
+ }
17
+ fs.rmSync(path.join(process.cwd(), ".cache", "pagefind"), { recursive: true, force: true });
18
+ });
19
+
20
+ function makeTempDir(prefix: string): string {
21
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
22
+ createdDirs.push(dir);
23
+ return dir;
24
+ }
25
+
26
+ describe("Tooling: build-pagefind", () => {
27
+ test("collectHtmlFileHashes hashes HTML files by relative path", () => {
28
+ const siteDir = makeTempDir("amytis-pagefind-site-");
29
+ fs.mkdirSync(path.join(siteDir, "posts"), { recursive: true });
30
+ fs.writeFileSync(path.join(siteDir, "index.html"), "<html><body>Home</body></html>", "utf8");
31
+ fs.writeFileSync(path.join(siteDir, "posts", "hello.html"), "<html><body>Hello</body></html>", "utf8");
32
+ fs.writeFileSync(path.join(siteDir, "notes.txt"), "ignore", "utf8");
33
+
34
+ const hashes = collectHtmlFileHashes(siteDir);
35
+
36
+ expect(Object.keys(hashes)).toEqual(["index.html", "posts/hello.html"]);
37
+ expect(hashes["index.html"]).toBeTruthy();
38
+ expect(hashes["posts/hello.html"]).toBeTruthy();
39
+ });
40
+
41
+ test("skips Pagefind when HTML hashes and output path are unchanged", () => {
42
+ const siteDir = makeTempDir("amytis-pagefind-site-");
43
+ const outputDir = makeTempDir("amytis-pagefind-out-");
44
+ fs.writeFileSync(path.join(siteDir, "index.html"), "<html><body>Home</body></html>", "utf8");
45
+ fs.writeFileSync(path.join(outputDir, "pagefind.js"), "stub", "utf8");
46
+
47
+ const hashes = collectHtmlFileHashes(siteDir);
48
+ const manifestPath = getPagefindManifestPathForTests(siteDir, outputDir);
49
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
50
+ fs.writeFileSync(
51
+ manifestPath,
52
+ JSON.stringify({
53
+ version: "1",
54
+ sitePath: path.resolve(siteDir),
55
+ outputPath: path.resolve(outputDir),
56
+ files: hashes,
57
+ }),
58
+ "utf8",
59
+ );
60
+
61
+ expect(shouldSkipPagefindBuild(siteDir, outputDir, hashes)).toBe(true);
62
+
63
+ fs.writeFileSync(path.join(siteDir, "index.html"), "<html><body>Changed</body></html>", "utf8");
64
+ expect(shouldSkipPagefindBuild(siteDir, outputDir, collectHtmlFileHashes(siteDir))).toBe(false);
65
+ });
66
+ });
@@ -131,6 +131,7 @@ beforeAll(() => {
131
131
  getSeriesData: (slug: string) => mockedSeriesData[slug] ?? null,
132
132
  getSeriesPosts: () => [],
133
133
  getSeriesAuthors: () => [],
134
+ getCollectionsForPost: () => [],
134
135
 
135
136
  getAuthorSlug: (name: string) =>
136
137
  name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
@@ -254,11 +255,101 @@ describe('generateStaticParams — placeholder when content is empty', () => {
254
255
  expect(params).toContainEqual({ slug: 'old-name' });
255
256
  });
256
257
 
258
+ test('series/[slug] includes raw and encoded Unicode slug in non-production', async () => {
259
+ mockedSeries = { '软件构架设计': [] };
260
+ process.env.NODE_ENV = 'development';
261
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
262
+ const params = await generateStaticParams();
263
+ expect(params).toContainEqual({ slug: '软件构架设计' });
264
+ expect(params).toContainEqual({ slug: '%E8%BD%AF%E4%BB%B6%E6%9E%84%E6%9E%B6%E8%AE%BE%E8%AE%A1' });
265
+ });
266
+
267
+ test('series/[slug] includes only raw Unicode slug in production', async () => {
268
+ mockedSeries = { '软件构架设计': [] };
269
+ process.env.NODE_ENV = 'production';
270
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
271
+ const params = await generateStaticParams();
272
+ expect(params).toContainEqual({ slug: '软件构架设计' });
273
+ expect(params).not.toContainEqual({ slug: '%E8%BD%AF%E4%BB%B6%E6%9E%84%E6%9E%B6%E8%AE%BE%E8%AE%A1' });
274
+ });
275
+
257
276
  test('series/[slug]/page/[page] returns [{ slug: "_", page: "2" }]', async () => {
258
277
  const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
259
278
  const params = await generateStaticParams();
260
279
  expect(params).toEqual([{ slug: '_', page: '2' }]);
261
280
  });
281
+
282
+ test('series/[slug]/page/[page] includes encoded Unicode slug in non-production', async () => {
283
+ mockedSeries = { '软件构架设计': Array.from({ length: 6 }, (_, i) => ({ slug: `p${i + 1}` })) };
284
+ process.env.NODE_ENV = 'development';
285
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
286
+ const params = await generateStaticParams();
287
+ expect(params).toContainEqual({ slug: '软件构架设计', page: '2' });
288
+ expect(params).toContainEqual({ slug: '%E8%BD%AF%E4%BB%B6%E6%9E%84%E6%9E%B6%E8%AE%BE%E8%AE%A1', page: '2' });
289
+ });
290
+
291
+ test('series/[slug]/page/[page] includes redirectFrom slug when series is renamed', async () => {
292
+ mockedSeries = { 'new-name': Array.from({ length: 6 }, (_, i) => ({ slug: `p${i + 1}` })) };
293
+ mockedSeriesData = { 'new-name': { redirectFrom: ['/series/old-name'], title: 'New Series' } };
294
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
295
+ const params = await generateStaticParams();
296
+ expect(params).toContainEqual({ slug: 'new-name', page: '2' });
297
+ expect(params).toContainEqual({ slug: 'old-name', page: '2' });
298
+ });
299
+
300
+ test('series/[slug]/page/[page] redirects old alias slugs to the canonical paginated path', async () => {
301
+ mockedSeries = { 'new-name': Array.from({ length: 6 }, (_, i) => ({ slug: `p${i + 1}` })) };
302
+ mockedSeriesData = { 'new-name': { redirectFrom: ['/series/old-name'], title: 'New Series' } };
303
+ const page = await import('../../src/app/series/[slug]/page/[page]/page');
304
+ await expect(page.default({
305
+ params: Promise.resolve({ slug: 'old-name', page: '2' }),
306
+ })).resolves.toBeDefined();
307
+ });
308
+
309
+ test('series routes match percent-encoded redirectFrom aliases after normalization', async () => {
310
+ mockedSeries = { '软件构架设计': Array.from({ length: 6 }, (_, i) => ({ slug: `p${i + 1}` })) };
311
+ mockedSeriesData = {
312
+ '软件构架设计': {
313
+ redirectFrom: ['/series/%E8%BD%AF%E4%BB%B6%E8%AE%BE%E8%AE%A1'],
314
+ title: '软件构架设计',
315
+ },
316
+ };
317
+
318
+ const seriesPage = await import('../../src/app/series/[slug]/page');
319
+ await expect(seriesPage.default({
320
+ params: Promise.resolve({ slug: '%E8%BD%AF%E4%BB%B6%E8%AE%BE%E8%AE%A1' }),
321
+ })).resolves.toBeDefined();
322
+
323
+ const paginatedPage = await import('../../src/app/series/[slug]/page/[page]/page');
324
+ await expect(paginatedPage.default({
325
+ params: Promise.resolve({ slug: '%E8%BD%AF%E4%BB%B6%E8%AE%BE%E8%AE%A1', page: '2' }),
326
+ })).resolves.toBeDefined();
327
+ });
328
+
329
+ test('series/[slug]/page/[page] throws when redirectFrom alias conflicts with an existing series slug', async () => {
330
+ mockedSeries = {
331
+ 'existing-slug': Array.from({ length: 6 }, (_, i) => ({ slug: `a${i + 1}` })),
332
+ 'new-name': Array.from({ length: 6 }, (_, i) => ({ slug: `b${i + 1}` })),
333
+ };
334
+ mockedSeriesData = {
335
+ 'new-name': { redirectFrom: ['/series/existing-slug'], title: 'New Series' },
336
+ };
337
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
338
+ await expect(generateStaticParams()).rejects.toThrow(/conflicts with an existing series slug/i);
339
+ });
340
+
341
+ test('series/[slug]/page/[page] throws when two series claim the same redirectFrom alias', async () => {
342
+ mockedSeries = {
343
+ 'first-series': Array.from({ length: 6 }, (_, i) => ({ slug: `a${i + 1}` })),
344
+ 'second-series': Array.from({ length: 6 }, (_, i) => ({ slug: `b${i + 1}` })),
345
+ };
346
+ mockedSeriesData = {
347
+ 'first-series': { redirectFrom: ['/series/old-name'], title: 'First' },
348
+ 'second-series': { redirectFrom: ['/series/old-name'], title: 'Second' },
349
+ };
350
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
351
+ await expect(generateStaticParams()).rejects.toThrow(/claimed by both/i);
352
+ });
262
353
  });
263
354
 
264
355
  describe('posts routes', () => {
@@ -504,6 +595,26 @@ describe('generateStaticParams — placeholder when content is empty', () => {
504
595
  expect(params).toContainEqual({ slug: 'old-prefix', postSlug: encodeURIComponent('中文文章') });
505
596
  });
506
597
 
598
+ test('[slug]/[postSlug] includes encoded Unicode prefix variants in non-production', async () => {
599
+ mockedSeries = { '软件构架设计': [{ slug: 'architecture-post' }] };
600
+ process.env.NODE_ENV = 'development';
601
+ const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
602
+ const params = await generateStaticParams();
603
+ expect(params).toContainEqual({ slug: '软件构架设计', postSlug: 'architecture-post' });
604
+ expect(params).toContainEqual({ slug: encodeURIComponent('软件构架设计'), postSlug: 'architecture-post' });
605
+ });
606
+
607
+ test('[slug]/[postSlug] includes encoded Unicode prefix and postSlug variants together in non-production', async () => {
608
+ mockedSeries = { '软件构架设计': [{ slug: '中文文章' }] };
609
+ process.env.NODE_ENV = 'development';
610
+ const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
611
+ const params = await generateStaticParams();
612
+ expect(params).toContainEqual({ slug: '软件构架设计', postSlug: '中文文章' });
613
+ expect(params).toContainEqual({ slug: encodeURIComponent('软件构架设计'), postSlug: '中文文章' });
614
+ expect(params).toContainEqual({ slug: '软件构架设计', postSlug: encodeURIComponent('中文文章') });
615
+ expect(params).toContainEqual({ slug: encodeURIComponent('软件构架设计'), postSlug: encodeURIComponent('中文文章') });
616
+ });
617
+
507
618
  test('[slug]/[postSlug] does not include encoded Unicode postSlug variants in production', async () => {
508
619
  mockedPosts = [{ slug: 'my-post', redirectFrom: ['/old-prefix/中文文章'] }];
509
620
  process.env.NODE_ENV = 'production';
@@ -513,6 +624,35 @@ describe('generateStaticParams — placeholder when content is empty', () => {
513
624
  expect(params).not.toContainEqual({ slug: 'old-prefix', postSlug: encodeURIComponent('中文文章') });
514
625
  });
515
626
 
627
+ test('[slug]/[postSlug] page resolves encoded Unicode series prefix without notFound', async () => {
628
+ mockedSeries = { '软件构架设计': [{ slug: 'my-post' }] };
629
+ mockedSeriesData = { '软件构架设计': { title: '软件构架设计' } };
630
+ mockedPosts = [{
631
+ slug: 'my-post',
632
+ title: 'My Post',
633
+ excerpt: 'Excerpt',
634
+ date: '2026-01-01',
635
+ authors: ['Author'],
636
+ series: '软件构架设计',
637
+ redirectFrom: ['/软件构架设计/my-post'],
638
+ layout: 'post',
639
+ content: 'Body',
640
+ headings: [],
641
+ imageBaseSlug: 'posts',
642
+ category: 'Test',
643
+ tags: [],
644
+ readingTime: '1 min read',
645
+ }];
646
+
647
+ const page = await import('../../src/app/[slug]/[postSlug]/page');
648
+ await expect(page.default({
649
+ params: Promise.resolve({
650
+ slug: encodeURIComponent('软件构架设计'),
651
+ postSlug: 'my-post',
652
+ }),
653
+ })).resolves.toBeDefined();
654
+ });
655
+
516
656
  test('[slug]/page/[page]/page returns placeholder when no custom paths', async () => {
517
657
  const { generateStaticParams } = await import('../../src/app/[slug]/page/[page]/page');
518
658
  const params = await generateStaticParams();