@hutusi/amytis 1.13.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 (91) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +32 -0
  4. package/GEMINI.md +9 -1
  5. package/README.md +36 -2
  6. package/README.zh.md +36 -2
  7. package/TODO.md +10 -0
  8. package/bun.lock +123 -91
  9. package/content/flows/2026/03/05.md +1 -0
  10. package/content/flows/2026/03/07.md +2 -0
  11. package/content/series/modern-web-dev/index.mdx +4 -2
  12. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  13. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  14. package/content/series/rst-legacy/getting-started.rst +24 -0
  15. package/content/series/rst-legacy/index.rst +9 -0
  16. package/content/series/rst-readme/README.rst +9 -0
  17. package/content/series/rst-readme/readme-index-post.rst +10 -0
  18. package/content/series/rst-toctree/first-post.rst +6 -0
  19. package/content/series/rst-toctree/index.rst +10 -0
  20. package/content/series/rst-toctree/second-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  22. package/content/series/rst-toctree-precedence/index.rst +12 -0
  23. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  24. package/docs/ARCHITECTURE.md +30 -4
  25. package/docs/CONTRIBUTING.md +11 -0
  26. package/docs/DIGITAL_GARDEN.md +22 -1
  27. package/eslint.config.mjs +2 -0
  28. package/next.config.ts +2 -2
  29. package/package.json +27 -21
  30. package/packages/create-amytis/package.json +1 -1
  31. package/packages/create-amytis/src/index.test.ts +43 -1
  32. package/packages/create-amytis/src/index.ts +64 -8
  33. package/public/next-image-export-optimizer-hashes.json +14 -73
  34. package/scripts/build-pagefind.ts +172 -0
  35. package/scripts/copy-assets.ts +246 -56
  36. package/scripts/generate-knowledge-graph.ts +2 -1
  37. package/scripts/new-flow.ts +1 -0
  38. package/scripts/render-rst.py +719 -0
  39. package/scripts/run-with-rst-python.ts +42 -0
  40. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  41. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  42. package/src/app/all.atom/route.ts +7 -0
  43. package/src/app/all.xml/route.ts +7 -0
  44. package/src/app/archive/page.tsx +7 -4
  45. package/src/app/feed.atom/route.ts +2 -57
  46. package/src/app/feed.xml/route.ts +2 -64
  47. package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
  48. package/src/app/flows/feed.atom/route.ts +7 -0
  49. package/src/app/flows/feed.xml/route.ts +7 -0
  50. package/src/app/globals.css +165 -0
  51. package/src/app/page.tsx +1 -0
  52. package/src/app/posts/feed.atom/route.ts +9 -0
  53. package/src/app/posts/feed.xml/route.ts +9 -0
  54. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  55. package/src/app/series/[slug]/page.tsx +11 -13
  56. package/src/app/series/page.tsx +3 -3
  57. package/src/components/AuthorCard.tsx +25 -16
  58. package/src/components/CoverImage.tsx +5 -2
  59. package/src/components/FlowCalendarSidebar.tsx +1 -1
  60. package/src/components/FlowContent.tsx +2 -1
  61. package/src/components/FlowTimelineEntry.tsx +7 -1
  62. package/src/components/Footer.tsx +1 -1
  63. package/src/components/MarkdownRenderer.test.tsx +22 -0
  64. package/src/components/MarkdownRenderer.tsx +22 -17
  65. package/src/components/Navbar.tsx +1 -1
  66. package/src/components/PostSidebar.tsx +1 -1
  67. package/src/components/RecentNotesSection.tsx +4 -0
  68. package/src/components/RstRenderer.test.tsx +93 -0
  69. package/src/components/RstRenderer.tsx +122 -0
  70. package/src/layouts/PostLayout.tsx +5 -1
  71. package/src/layouts/SimpleLayout.tsx +10 -3
  72. package/src/lib/feed-utils.ts +158 -18
  73. package/src/lib/image-utils.test.ts +19 -0
  74. package/src/lib/image-utils.ts +11 -0
  75. package/src/lib/markdown.test.ts +140 -2
  76. package/src/lib/markdown.ts +747 -214
  77. package/src/lib/rehype-image-metadata.ts +2 -2
  78. package/src/lib/rst-renderer.test.ts +355 -0
  79. package/src/lib/rst-renderer.ts +617 -0
  80. package/src/lib/rst.test.ts +140 -0
  81. package/src/lib/rst.ts +470 -0
  82. package/src/lib/series-redirects.ts +42 -0
  83. package/tests/e2e/navigation.test.ts +26 -0
  84. package/tests/integration/collections.test.ts +17 -2
  85. package/tests/integration/feed-utils.test.ts +65 -0
  86. package/tests/integration/flow-title.test.ts +53 -0
  87. package/tests/integration/reading-time-headings.test.ts +5 -9
  88. package/tests/integration/series-draft.test.ts +16 -2
  89. package/tests/integration/series.test.ts +93 -0
  90. package/tests/tooling/build-pagefind.test.ts +66 -0
  91. package/tests/unit/static-params.test.ts +140 -0
@@ -44,6 +44,21 @@ describe("Integration: Collections", () => {
44
44
  expect(slugs).toContain("understanding-react-hooks");
45
45
  });
46
46
 
47
+ test("getCollectionPosts resolves namespaced post entries properly", () => {
48
+ // The modern-web-dev collection uses the "posts/asynchronous-javascript" syntax.
49
+ // This test ensures that the namespaced resolution logic successfully found it.
50
+ const posts = getCollectionPosts("modern-web-dev");
51
+ const foundPost = posts.find(p => p.slug === "asynchronous-javascript");
52
+ expect(foundPost).toBeDefined();
53
+ // Because the namespace was "posts/", its series should be undefined
54
+ expect(foundPost!.series).toBeUndefined();
55
+
56
+ // Also verify series-based namespace
57
+ const seriesPost = posts.find(p => p.slug === "02-architecture");
58
+ expect(seriesPost).toBeDefined();
59
+ expect(seriesPost!.series).toBe("digital-garden");
60
+ });
61
+
47
62
  test("getCollectionPosts includes posts from referenced series", () => {
48
63
  const posts = getCollectionPosts("modern-web-dev");
49
64
  const slugs = posts.map((p) => p.slug);
@@ -108,8 +123,8 @@ describe("Integration: Collections", () => {
108
123
  test("getAllSeries includes collection with correct post count", () => {
109
124
  const all = getAllSeries();
110
125
  expect(all).toHaveProperty("modern-web-dev");
111
- // modern-web-dev has 2 standalone posts + 2 from nextjs-deep-dive
112
- expect(all["modern-web-dev"].length).toBe(4);
126
+ // modern-web-dev has 2 standalone posts + 4 from nextjs-deep-dive
127
+ expect(all["modern-web-dev"].length).toBe(6);
113
128
  });
114
129
 
115
130
  test("getAllSeries collection posts are sorted by date descending", () => {
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { getFeedItems } from "../../src/lib/feed-utils";
3
3
  import { getAllPosts, getAllFlows } from "../../src/lib/markdown";
4
+ import { getPostUrl, getFlowUrl } from "../../src/lib/urls";
4
5
  import { siteConfig } from "../../site.config";
5
6
 
6
7
  describe("Integration: Feed Utils", () => {
@@ -44,6 +45,19 @@ describe("Integration: Feed Utils", () => {
44
45
  }
45
46
  });
46
47
 
48
+ test("feed items have valid dates", () => {
49
+ const originalMaxItems = siteConfig.feed.maxItems;
50
+ try {
51
+ siteConfig.feed.maxItems = 0;
52
+ const items = getFeedItems();
53
+ items.forEach((item) => {
54
+ expect(Number.isNaN(item.date.getTime())).toBe(false);
55
+ });
56
+ } finally {
57
+ siteConfig.feed.maxItems = originalMaxItems;
58
+ }
59
+ });
60
+
47
61
  test("maxItems = 0 returns all posts", () => {
48
62
  const originalMaxItems = siteConfig.feed.maxItems;
49
63
  const originalIncludeFlows = siteConfig.feed.includeFlows;
@@ -142,4 +156,55 @@ describe("Integration: Feed Utils", () => {
142
156
  }
143
157
  });
144
158
  });
159
+
160
+ test("feedType 'posts' returns only post items", () => {
161
+ const originalMaxItems = siteConfig.feed.maxItems;
162
+ try {
163
+ siteConfig.feed.maxItems = 0;
164
+ const items = getFeedItems('posts');
165
+ const allPosts = getAllPosts();
166
+ const baseUrl = siteConfig.baseUrl.replace(/\/+$/, "");
167
+ expect(items.length).toBe(allPosts.length);
168
+ expect(items.map((item) => item.url).sort()).toEqual(
169
+ allPosts.map((post) => `${baseUrl}${getPostUrl(post)}`).sort()
170
+ );
171
+ } finally {
172
+ siteConfig.feed.maxItems = originalMaxItems;
173
+ }
174
+ });
175
+
176
+ test("feedType 'flows' returns only flow items", () => {
177
+ const originalMaxItems = siteConfig.feed.maxItems;
178
+ try {
179
+ siteConfig.feed.maxItems = 0;
180
+ const items = getFeedItems('flows');
181
+ const allFlows = getAllFlows();
182
+ const baseUrl = siteConfig.baseUrl.replace(/\/+$/, "");
183
+ expect(items.length).toBe(allFlows.length);
184
+ expect(items.map((item) => item.url).sort()).toEqual(
185
+ allFlows.map((flow) => `${baseUrl}${getFlowUrl(flow.slug)}`).sort()
186
+ );
187
+ } finally {
188
+ siteConfig.feed.maxItems = originalMaxItems;
189
+ }
190
+ });
191
+
192
+ test("feedType 'all' returns both post and flow items", () => {
193
+ const originalMaxItems = siteConfig.feed.maxItems;
194
+ try {
195
+ siteConfig.feed.maxItems = 0;
196
+ const items = getFeedItems('all');
197
+ const allPosts = getAllPosts();
198
+ const allFlows = getAllFlows();
199
+ const baseUrl = siteConfig.baseUrl.replace(/\/+$/, "");
200
+ expect(items.map((item) => item.url).sort()).toEqual(
201
+ [
202
+ ...allPosts.map((post) => `${baseUrl}${getPostUrl(post)}`),
203
+ ...allFlows.map((flow) => `${baseUrl}${getFlowUrl(flow.slug)}`),
204
+ ].sort()
205
+ );
206
+ } finally {
207
+ siteConfig.feed.maxItems = originalMaxItems;
208
+ }
209
+ });
145
210
  });
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getFlowBySlug, getAllFlows } from "../../src/lib/markdown";
3
+
4
+ describe("Integration: Flow Title Resolution", () => {
5
+ test("frontmatter title takes priority over H1 and date", () => {
6
+ // content/flows/2026/03/05.md has `title: 'JSDoc type comments'` in frontmatter
7
+ const flow = getFlowBySlug("2026/03/05");
8
+ expect(flow).not.toBeNull();
9
+ expect(flow!.title).toBe("JSDoc type comments");
10
+ });
11
+
12
+ test("H1 heading is extracted as title when no frontmatter title", () => {
13
+ // content/flows/2026/03/07.md has no frontmatter title but has `# Using Claude Code`
14
+ const flow = getFlowBySlug("2026/03/07");
15
+ expect(flow).not.toBeNull();
16
+ expect(flow!.title).toBe("Using Claude Code");
17
+ });
18
+
19
+ test("date is used as fallback when no frontmatter title or H1", () => {
20
+ // content/flows/2026/02/05.md has no title and no H1
21
+ const flow = getFlowBySlug("2026/02/05");
22
+ expect(flow).not.toBeNull();
23
+ expect(flow!.title).toBe("2026-02-05");
24
+ });
25
+
26
+ test("H1 heading is stripped from content body", () => {
27
+ const flow = getFlowBySlug("2026/03/07");
28
+ expect(flow).not.toBeNull();
29
+ // The H1 should be extracted as title but removed from content
30
+ expect(flow!.content).not.toMatch(/^#\s+Using Claude Code/m);
31
+ // The body content should still be present
32
+ expect(flow!.content).toContain("Claude Code");
33
+ });
34
+
35
+ test("every flow has a non-empty title", () => {
36
+ const flows = getAllFlows();
37
+ expect(flows.length).toBeGreaterThan(0);
38
+ flows.forEach((flow) => {
39
+ expect(flow.title).toBeTruthy();
40
+ expect(flow.title.trim().length).toBeGreaterThan(0);
41
+ });
42
+ });
43
+
44
+ test("flow with frontmatter title preserves content H1 independently", () => {
45
+ // content/flows/2026/03/05.md has frontmatter title — any H1 in content
46
+ // should still be stripped, but title comes from frontmatter
47
+ const flow = getFlowBySlug("2026/03/05");
48
+ expect(flow).not.toBeNull();
49
+ expect(flow!.title).toBe("JSDoc type comments");
50
+ // Content should not start with an H1
51
+ expect(flow!.content).not.toMatch(/^\s*#\s+/);
52
+ });
53
+ });
@@ -50,16 +50,12 @@ describe("Integration: Reading Time & Headings", () => {
50
50
  });
51
51
 
52
52
  test("short posts have 1 min read", () => {
53
- const posts = getAllPosts();
54
- // Find a short post (content < 200 words)
55
- const shortPost = posts.find((p) => {
56
- const wordCount = p.content.split(/\s+/).length;
57
- return wordCount < 200;
58
- });
59
-
60
- if (shortPost) {
61
- expect(shortPost.readingTime).toBe("1 min read");
53
+ const shortPost = getPostBySlug("legacy-markdown");
54
+ expect(shortPost).toBeDefined();
55
+ if (!shortPost) {
56
+ throw new Error("fixture 'legacy-markdown' not found");
62
57
  }
58
+ expect(shortPost.readingTime).toBe("1 min read");
63
59
  });
64
60
 
65
61
  test("multilingual post has headings with correct IDs", () => {
@@ -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();