@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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/CHANGELOG.md +16 -0
- package/README.md +33 -1
- package/README.zh.md +33 -1
- package/TODO.md +10 -0
- package/bun.lock +69 -41
- 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 +22 -3
- package/docs/CONTRIBUTING.md +11 -0
- package/eslint.config.mjs +2 -0
- package/next.config.ts +2 -2
- package/package.json +22 -16
- 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/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/globals.css +165 -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/MarkdownRenderer.test.tsx +16 -0
- package/src/components/MarkdownRenderer.tsx +4 -1
- 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/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 +731 -210
- 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/integration/feed-utils.test.ts +13 -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
|
@@ -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();
|