@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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/CHANGELOG.md +32 -0
- package/GEMINI.md +9 -1
- package/README.md +36 -2
- package/README.zh.md +36 -2
- package/TODO.md +10 -0
- package/bun.lock +123 -91
- package/content/flows/2026/03/05.md +1 -0
- package/content/flows/2026/03/07.md +2 -0
- package/content/series/modern-web-dev/index.mdx +4 -2
- package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
- package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
- package/content/series/rst-legacy/getting-started.rst +24 -0
- package/content/series/rst-legacy/index.rst +9 -0
- package/content/series/rst-readme/README.rst +9 -0
- package/content/series/rst-readme/readme-index-post.rst +10 -0
- package/content/series/rst-toctree/first-post.rst +6 -0
- package/content/series/rst-toctree/index.rst +10 -0
- package/content/series/rst-toctree/second-post.rst +6 -0
- package/content/series/rst-toctree-precedence/first-post.rst +6 -0
- package/content/series/rst-toctree-precedence/index.rst +12 -0
- package/content/series/rst-toctree-precedence/second-post.rst +6 -0
- package/docs/ARCHITECTURE.md +30 -4
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/DIGITAL_GARDEN.md +22 -1
- package/eslint.config.mjs +2 -0
- package/next.config.ts +2 -2
- package/package.json +27 -21
- package/packages/create-amytis/package.json +1 -1
- package/packages/create-amytis/src/index.test.ts +43 -1
- package/packages/create-amytis/src/index.ts +64 -8
- package/public/next-image-export-optimizer-hashes.json +14 -73
- package/scripts/build-pagefind.ts +172 -0
- package/scripts/copy-assets.ts +246 -56
- package/scripts/generate-knowledge-graph.ts +2 -1
- package/scripts/new-flow.ts +1 -0
- package/scripts/render-rst.py +719 -0
- package/scripts/run-with-rst-python.ts +42 -0
- package/src/app/[slug]/[postSlug]/page.tsx +20 -10
- package/src/app/[slug]/page/[page]/page.tsx +15 -0
- package/src/app/all.atom/route.ts +7 -0
- package/src/app/all.xml/route.ts +7 -0
- package/src/app/archive/page.tsx +7 -4
- package/src/app/feed.atom/route.ts +2 -57
- package/src/app/feed.xml/route.ts +2 -64
- package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
- package/src/app/flows/feed.atom/route.ts +7 -0
- package/src/app/flows/feed.xml/route.ts +7 -0
- package/src/app/globals.css +165 -0
- package/src/app/page.tsx +1 -0
- package/src/app/posts/feed.atom/route.ts +9 -0
- package/src/app/posts/feed.xml/route.ts +9 -0
- package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
- package/src/app/series/[slug]/page.tsx +11 -13
- package/src/app/series/page.tsx +3 -3
- package/src/components/AuthorCard.tsx +25 -16
- package/src/components/CoverImage.tsx +5 -2
- package/src/components/FlowCalendarSidebar.tsx +1 -1
- package/src/components/FlowContent.tsx +2 -1
- package/src/components/FlowTimelineEntry.tsx +7 -1
- package/src/components/Footer.tsx +1 -1
- package/src/components/MarkdownRenderer.test.tsx +22 -0
- package/src/components/MarkdownRenderer.tsx +22 -17
- package/src/components/Navbar.tsx +1 -1
- package/src/components/PostSidebar.tsx +1 -1
- package/src/components/RecentNotesSection.tsx +4 -0
- package/src/components/RstRenderer.test.tsx +93 -0
- package/src/components/RstRenderer.tsx +122 -0
- package/src/layouts/PostLayout.tsx +5 -1
- package/src/layouts/SimpleLayout.tsx +10 -3
- package/src/lib/feed-utils.ts +158 -18
- package/src/lib/image-utils.test.ts +19 -0
- package/src/lib/image-utils.ts +11 -0
- package/src/lib/markdown.test.ts +140 -2
- package/src/lib/markdown.ts +747 -214
- package/src/lib/rehype-image-metadata.ts +2 -2
- package/src/lib/rst-renderer.test.ts +355 -0
- package/src/lib/rst-renderer.ts +617 -0
- package/src/lib/rst.test.ts +140 -0
- package/src/lib/rst.ts +470 -0
- package/src/lib/series-redirects.ts +42 -0
- package/tests/e2e/navigation.test.ts +26 -0
- package/tests/integration/collections.test.ts +17 -2
- package/tests/integration/feed-utils.test.ts +65 -0
- package/tests/integration/flow-title.test.ts +53 -0
- package/tests/integration/reading-time-headings.test.ts +5 -9
- package/tests/integration/series-draft.test.ts +16 -2
- package/tests/integration/series.test.ts +93 -0
- package/tests/tooling/build-pagefind.test.ts +66 -0
- 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 +
|
|
112
|
-
expect(all["modern-web-dev"].length).toBe(
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|