@hutusi/amytis 1.15.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/CLAUDE.md +90 -219
- package/bun.lock +185 -547
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +217 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +178 -0
- package/eslint.config.mjs +18 -6
- package/package.json +42 -20
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/render-rst.py +207 -3
- package/scripts/sync-vuepress-book.ts +499 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/page.tsx +67 -32
- package/src/app/globals.css +503 -123
- package/src/app/page.tsx +1 -1
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CodeBlock.test.tsx +93 -8
- package/src/components/CodeBlock.tsx +39 -101
- package/src/components/CodeBlockToolbar.tsx +88 -0
- package/src/components/CodeGroup.tsx +81 -0
- package/src/components/CoverImage.tsx +1 -0
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +3 -3
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +144 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/RstRenderer.test.tsx +15 -15
- package/src/components/RstRenderer.tsx +37 -2
- package/src/components/Search.tsx +18 -4
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/i18n/translations.ts +2 -0
- package/src/layouts/BookLayout.tsx +35 -4
- package/src/layouts/PostLayout.tsx +5 -1
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +203 -50
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/remark-book-chapter-links.ts +106 -0
- package/src/lib/remark-code-group.ts +54 -0
- package/src/lib/remark-github-alerts.test.ts +83 -0
- package/src/lib/remark-github-alerts.ts +65 -0
- package/src/lib/remark-vuepress-containers.ts +130 -0
- package/src/lib/rst-renderer.ts +19 -7
- package/src/lib/rst.test.ts +212 -2
- package/src/lib/rst.ts +217 -13
- package/src/lib/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/urls.ts +57 -0
- package/src/test-utils/render.ts +23 -0
- package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
- package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
- package/tests/helpers/env.ts +19 -0
- package/tests/integration/book-chapter-links.test.ts +107 -0
- package/tests/integration/books-nested-toc.test.ts +176 -0
- package/tests/integration/books.test.ts +3 -2
- package/tests/integration/code-block-features.test.ts +188 -0
- package/tests/integration/code-group.test.ts +183 -0
- package/tests/integration/code-notation.test.ts +97 -0
- package/tests/integration/github-alerts.test.ts +82 -0
- package/tests/integration/markdown-external-links.test.ts +103 -0
- package/tests/integration/normalize-vuepress-math.test.ts +149 -0
- package/tests/integration/reading-time-headings.test.ts +8 -6
- package/tests/integration/series-draft.test.ts +6 -13
- package/tests/integration/sync-vuepress-book.test.ts +240 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/static-params.test.ts +32 -19
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { getSeriesData, getAllSeries } from "../../src/lib/markdown";
|
|
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
|
-
}
|
|
3
|
+
import { setEnvVar, restoreEnvVar } from "../helpers/env";
|
|
11
4
|
|
|
12
5
|
describe("Integration: Series Draft Support", () => {
|
|
13
6
|
test("all series are included when NODE_ENV is not production", () => {
|
|
@@ -28,8 +21,8 @@ describe("Integration: Series Draft Support", () => {
|
|
|
28
21
|
const originalEnv = process.env.NODE_ENV;
|
|
29
22
|
const originalPythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
|
|
30
23
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
setEnvVar("NODE_ENV", "production");
|
|
25
|
+
setEnvVar("AMYTIS_ENABLE_PYTHON_RST", "0");
|
|
33
26
|
// This should not throw; draft series are simply excluded
|
|
34
27
|
const series = getAllSeries();
|
|
35
28
|
expect(typeof series).toBe("object");
|
|
@@ -43,10 +36,10 @@ describe("Integration: Series Draft Support", () => {
|
|
|
43
36
|
const originalEnv = process.env.NODE_ENV;
|
|
44
37
|
const originalPythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
|
|
45
38
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
setEnvVar("NODE_ENV", "production");
|
|
40
|
+
setEnvVar("AMYTIS_ENABLE_PYTHON_RST", "0");
|
|
48
41
|
const allSeries = getAllSeries();
|
|
49
|
-
|
|
42
|
+
|
|
50
43
|
// Verify that every series returned has draft: false (or undefined which defaults to false)
|
|
51
44
|
Object.keys(allSeries).forEach(slug => {
|
|
52
45
|
const seriesData = getSeriesData(slug);
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { spawnSync } from "child_process";
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import matter from "gray-matter";
|
|
7
|
+
|
|
8
|
+
const FIXTURE_SOURCE = path.resolve("tests/fixtures/sync-vuepress-book/docs");
|
|
9
|
+
|
|
10
|
+
// Invoke through the published `bun run sync-vuepress-book` entrypoint and the
|
|
11
|
+
// `--source` / `--dest` flags so the test exercises everything a real user does:
|
|
12
|
+
// the package.json script wiring + the CLI argv parser, not just the inner sync
|
|
13
|
+
// pipeline.
|
|
14
|
+
function runSync(source: string, dest: string) {
|
|
15
|
+
return spawnSync(
|
|
16
|
+
"bun",
|
|
17
|
+
["run", "sync-vuepress-book", "--source", source, "--dest", dest],
|
|
18
|
+
{
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
cwd: process.cwd(),
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("Integration: sync-vuepress-book script", () => {
|
|
26
|
+
let dest: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
dest = mkdtempSync(path.join(tmpdir(), "sync-vuepress-book-"));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
if (dest && existsSync(dest)) rmSync(dest, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("runs end-to-end and produces the expected layout", () => {
|
|
37
|
+
const res = runSync(FIXTURE_SOURCE, dest);
|
|
38
|
+
expect(res.status).toBe(0);
|
|
39
|
+
|
|
40
|
+
// Markdown content was rsynced verbatim (no rewriting on copy).
|
|
41
|
+
expect(existsSync(path.join(dest, "intro", "welcome.md"))).toBe(true);
|
|
42
|
+
expect(existsSync(path.join(dest, "maths", "linear", "vectors.md"))).toBe(true);
|
|
43
|
+
expect(existsSync(path.join(dest, "maths", "linear", "matrices.md"))).toBe(true);
|
|
44
|
+
|
|
45
|
+
// Asset folders co-located with chapters were copied.
|
|
46
|
+
expect(existsSync(path.join(dest, "maths", "linear", "assets", "diagram.png"))).toBe(true);
|
|
47
|
+
|
|
48
|
+
// .vuepress was excluded.
|
|
49
|
+
expect(existsSync(path.join(dest, ".vuepress"))).toBe(false);
|
|
50
|
+
|
|
51
|
+
// index.mdx exists with parsed nested TOC.
|
|
52
|
+
const indexPath = path.join(dest, "index.mdx");
|
|
53
|
+
expect(existsSync(indexPath)).toBe(true);
|
|
54
|
+
const { data } = matter(readFileSync(indexPath, "utf8")) as unknown as { data: Record<string, unknown> };
|
|
55
|
+
expect(data.title).toBe("Fixture Book");
|
|
56
|
+
const chapters = data.chapters as Array<Record<string, unknown>>;
|
|
57
|
+
expect(chapters[0]).toMatchObject({ title: "Intro", id: "intro/welcome" });
|
|
58
|
+
expect(chapters[1]).toMatchObject({ section: "Maths" });
|
|
59
|
+
const mathsItems = (chapters[1].items as Array<Record<string, unknown>>);
|
|
60
|
+
expect(mathsItems[0]).toMatchObject({ section: "Linear Algebra" });
|
|
61
|
+
const linearItems = mathsItems[0].items as Array<Record<string, unknown>>;
|
|
62
|
+
expect(linearItems).toEqual([
|
|
63
|
+
{ title: "Vectors", id: "maths/linear/vectors" },
|
|
64
|
+
{ title: "Matrices", id: "maths/linear/matrices" },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("preserves user-controlled frontmatter on re-run", () => {
|
|
69
|
+
// First run creates index.mdx.
|
|
70
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
71
|
+
|
|
72
|
+
// Author edits cover image + featured flag.
|
|
73
|
+
const indexPath = path.join(dest, "index.mdx");
|
|
74
|
+
const parsed = matter(readFileSync(indexPath, "utf8")) as unknown as { data: Record<string, unknown>; content: string };
|
|
75
|
+
const edited = matter.stringify(parsed.content, {
|
|
76
|
+
...parsed.data,
|
|
77
|
+
coverImage: "text:FB",
|
|
78
|
+
featured: true,
|
|
79
|
+
excerpt: "A tiny book for tests.",
|
|
80
|
+
});
|
|
81
|
+
writeFileSync(indexPath, edited);
|
|
82
|
+
|
|
83
|
+
// Re-run should keep the edited fields and refresh `chapters`.
|
|
84
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
85
|
+
const { data: refreshed } = matter(readFileSync(indexPath, "utf8")) as unknown as { data: Record<string, unknown> };
|
|
86
|
+
expect(refreshed.coverImage).toBe("text:FB");
|
|
87
|
+
expect(refreshed.featured).toBe(true);
|
|
88
|
+
expect(refreshed.excerpt).toBe("A tiny book for tests.");
|
|
89
|
+
expect(Array.isArray(refreshed.chapters)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("prunes dest files removed upstream (mirror semantics)", () => {
|
|
93
|
+
// First sync — dest now has every fixture file.
|
|
94
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
95
|
+
expect(existsSync(path.join(dest, "maths", "linear", "matrices.md"))).toBe(true);
|
|
96
|
+
|
|
97
|
+
// Simulate an upstream deletion by syncing from a smaller temp source tree
|
|
98
|
+
// (just the bits we need for the still-listed chapters) and a config that
|
|
99
|
+
// no longer references matrices.
|
|
100
|
+
const trimmed = mkdtempSync(path.join(tmpdir(), "sync-trimmed-"));
|
|
101
|
+
try {
|
|
102
|
+
const docs = path.join(trimmed, "docs");
|
|
103
|
+
const vp = path.join(docs, ".vuepress");
|
|
104
|
+
mkdirSync(vp, { recursive: true });
|
|
105
|
+
writeFileSync(
|
|
106
|
+
path.join(vp, "config.js"),
|
|
107
|
+
`export default {
|
|
108
|
+
title: 'Fixture Book',
|
|
109
|
+
theme: { sidebar: [{ text: 'Vectors', link: '/maths/linear/vectors' }] },
|
|
110
|
+
}
|
|
111
|
+
`,
|
|
112
|
+
);
|
|
113
|
+
mkdirSync(path.join(docs, "maths", "linear"), { recursive: true });
|
|
114
|
+
writeFileSync(path.join(docs, "maths", "linear", "vectors.md"), "---\ntitle: Vectors\n---\n# Vectors\n");
|
|
115
|
+
|
|
116
|
+
expect(runSync(docs, dest).status).toBe(0);
|
|
117
|
+
|
|
118
|
+
// Vectors survives, matrices is gone, the now-empty assets/ dir is cleaned up.
|
|
119
|
+
expect(existsSync(path.join(dest, "maths", "linear", "vectors.md"))).toBe(true);
|
|
120
|
+
expect(existsSync(path.join(dest, "maths", "linear", "matrices.md"))).toBe(false);
|
|
121
|
+
expect(existsSync(path.join(dest, "intro"))).toBe(false);
|
|
122
|
+
expect(existsSync(path.join(dest, "maths", "linear", "assets"))).toBe(false);
|
|
123
|
+
// index.mdx is regenerated, not pruned.
|
|
124
|
+
expect(existsSync(path.join(dest, "index.mdx"))).toBe(true);
|
|
125
|
+
} finally {
|
|
126
|
+
rmSync(trimmed, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("preserves user-added dotfiles on re-run (out-of-band overlay state)", () => {
|
|
131
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
132
|
+
const dotfile = path.join(dest, ".gitkeep");
|
|
133
|
+
writeFileSync(dotfile, "");
|
|
134
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
135
|
+
expect(existsSync(dotfile)).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("resolves folder-index sidebar links (e.g. /guide/ → guide/README.md)", () => {
|
|
139
|
+
const folder = mkdtempSync(path.join(tmpdir(), "sync-folder-"));
|
|
140
|
+
try {
|
|
141
|
+
const docs = path.join(folder, "docs");
|
|
142
|
+
const vp = path.join(docs, ".vuepress");
|
|
143
|
+
mkdirSync(vp, { recursive: true });
|
|
144
|
+
writeFileSync(
|
|
145
|
+
path.join(vp, "config.js"),
|
|
146
|
+
`export default {
|
|
147
|
+
title: 'Folder-Index Book',
|
|
148
|
+
theme: { sidebar: [{ text: 'Guide', link: '/guide/' }] },
|
|
149
|
+
}
|
|
150
|
+
`,
|
|
151
|
+
);
|
|
152
|
+
mkdirSync(path.join(docs, "guide"), { recursive: true });
|
|
153
|
+
writeFileSync(path.join(docs, "guide", "README.md"), "---\ntitle: Guide\n---\n# Guide\n");
|
|
154
|
+
|
|
155
|
+
const res = runSync(docs, dest);
|
|
156
|
+
expect(res.status).toBe(0);
|
|
157
|
+
// The chapter id strips the trailing slash, so the folder-index target
|
|
158
|
+
// exists at <dest>/guide/README.md and the TOC entry's id is `guide`.
|
|
159
|
+
const { data } = matter(readFileSync(path.join(dest, "index.mdx"), "utf8")) as unknown as { data: Record<string, unknown> };
|
|
160
|
+
expect((data.chapters as Array<{ id: string }>)[0].id).toBe("guide");
|
|
161
|
+
expect(existsSync(path.join(dest, "guide", "README.md"))).toBe(true);
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(folder, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("rejects a config.ts with a clear message instead of acorn parse failure", () => {
|
|
168
|
+
const tsConfig = mkdtempSync(path.join(tmpdir(), "sync-ts-config-"));
|
|
169
|
+
try {
|
|
170
|
+
const docs = path.join(tsConfig, "docs");
|
|
171
|
+
const vp = path.join(docs, ".vuepress");
|
|
172
|
+
mkdirSync(vp, { recursive: true });
|
|
173
|
+
writeFileSync(
|
|
174
|
+
path.join(vp, "config.ts"),
|
|
175
|
+
"const x: number = 1; export default { theme: { sidebar: [] } }\n",
|
|
176
|
+
);
|
|
177
|
+
const res = runSync(docs, dest);
|
|
178
|
+
expect(res.status).not.toBe(0);
|
|
179
|
+
expect(res.stderr).toMatch(/config\.ts/);
|
|
180
|
+
// Match the actionable phrasing only — if a regression let the raw
|
|
181
|
+
// acorn parse error through, that should fail this assertion.
|
|
182
|
+
expect(res.stderr).toMatch(/Compile to JavaScript|JS-only/);
|
|
183
|
+
} finally {
|
|
184
|
+
rmSync(tsConfig, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("drops a leaf with id 'contents' from the TOC (Amytis renders its own)", () => {
|
|
189
|
+
const withContents = mkdtempSync(path.join(tmpdir(), "sync-contents-"));
|
|
190
|
+
try {
|
|
191
|
+
const docs = path.join(withContents, "docs");
|
|
192
|
+
const vp = path.join(docs, ".vuepress");
|
|
193
|
+
mkdirSync(vp, { recursive: true });
|
|
194
|
+
writeFileSync(
|
|
195
|
+
path.join(vp, "config.js"),
|
|
196
|
+
`export default {
|
|
197
|
+
title: 'TOC-Heavy Book',
|
|
198
|
+
theme: {
|
|
199
|
+
sidebar: [
|
|
200
|
+
{ text: '目录', link: 'contents' },
|
|
201
|
+
{ text: 'Real', link: '/real-chapter' },
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
`,
|
|
206
|
+
);
|
|
207
|
+
writeFileSync(path.join(docs, "contents.md"), "# Table of Contents\n- [Real](real-chapter.md)\n");
|
|
208
|
+
writeFileSync(path.join(docs, "real-chapter.md"), "---\ntitle: Real\n---\n# Real\n");
|
|
209
|
+
|
|
210
|
+
const res = runSync(docs, dest);
|
|
211
|
+
expect(res.status).toBe(0);
|
|
212
|
+
const { data } = matter(readFileSync(path.join(dest, "index.mdx"), "utf8")) as unknown as { data: Record<string, unknown> };
|
|
213
|
+
const chapterIds = (data.chapters as Array<{ id?: string; section?: string }>).map(c => c.id ?? c.section);
|
|
214
|
+
expect(chapterIds).toEqual(["real-chapter"]);
|
|
215
|
+
// The summary mentions the dropped leaf so the run isn't silent.
|
|
216
|
+
expect(res.stdout).toMatch(/contents/);
|
|
217
|
+
} finally {
|
|
218
|
+
rmSync(withContents, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("exits with an error when a sidebar leaf points to a missing source file", () => {
|
|
223
|
+
// Create a corrupt config with a broken link.
|
|
224
|
+
const broken = mkdtempSync(path.join(tmpdir(), "sync-broken-"));
|
|
225
|
+
try {
|
|
226
|
+
const docsDir = path.join(broken, "docs");
|
|
227
|
+
const vp = path.join(docsDir, ".vuepress");
|
|
228
|
+
mkdirSync(vp, { recursive: true });
|
|
229
|
+
writeFileSync(
|
|
230
|
+
path.join(vp, "config.js"),
|
|
231
|
+
"export default { theme: { sidebar: [{ text: 'Missing', link: '/nope' }] } }\n",
|
|
232
|
+
);
|
|
233
|
+
const res = runSync(docsDir, dest);
|
|
234
|
+
expect(res.status).not.toBe(0);
|
|
235
|
+
expect(res.stderr).toContain("source files that do not exist");
|
|
236
|
+
} finally {
|
|
237
|
+
rmSync(broken, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
|
3
|
+
import { renderAsync } from "@/test-utils/render";
|
|
4
|
+
|
|
5
|
+
describe("Integration: VuePress :::container alerts", () => {
|
|
6
|
+
test(":::note renders as a note GitHub Alert", async () => {
|
|
7
|
+
const html = await renderAsync(
|
|
8
|
+
MarkdownRenderer({ content: "::: note\n\nBody text\n:::" }),
|
|
9
|
+
);
|
|
10
|
+
expect(html).toContain('class="alert alert-note"');
|
|
11
|
+
expect(html).toContain("Body text");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test(":::tip preserves a custom title", async () => {
|
|
15
|
+
const html = await renderAsync(
|
|
16
|
+
MarkdownRenderer({ content: "::: tip 智慧的疆界\n\nBody\n:::" }),
|
|
17
|
+
);
|
|
18
|
+
expect(html).toContain('class="alert alert-tip"');
|
|
19
|
+
expect(html).toContain("智慧的疆界");
|
|
20
|
+
// The hardcoded default label should not appear when a custom title is given.
|
|
21
|
+
expect(html).not.toMatch(/<span>Tip<\/span>/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test(":::warning renders as a warning alert", async () => {
|
|
25
|
+
const html = await renderAsync(
|
|
26
|
+
MarkdownRenderer({ content: "::: warning\n\nWatch out\n:::" }),
|
|
27
|
+
);
|
|
28
|
+
expect(html).toContain('class="alert alert-warning"');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test(":::danger maps to caution (GitHub Alert vocabulary)", async () => {
|
|
32
|
+
const html = await renderAsync(
|
|
33
|
+
MarkdownRenderer({ content: "::: danger\n\nUnsafe\n:::" }),
|
|
34
|
+
);
|
|
35
|
+
expect(html).toContain('class="alert alert-caution"');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test(":::info maps to note", async () => {
|
|
39
|
+
const html = await renderAsync(
|
|
40
|
+
MarkdownRenderer({ content: "::: info\n\nFYI\n:::" }),
|
|
41
|
+
);
|
|
42
|
+
expect(html).toContain('class="alert alert-note"');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("unknown container names pass through without becoming alerts", async () => {
|
|
46
|
+
const html = await renderAsync(
|
|
47
|
+
MarkdownRenderer({ content: "::: random\n\nSomething\n:::" }),
|
|
48
|
+
);
|
|
49
|
+
expect(html).not.toContain('class="alert');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("plain content is not rewritten", async () => {
|
|
53
|
+
const html = await renderAsync(
|
|
54
|
+
MarkdownRenderer({ content: "Plain paragraph." }),
|
|
55
|
+
);
|
|
56
|
+
expect(html).not.toContain('class="alert');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("VuePress syntax inside a fenced code block is NOT rewritten", async () => {
|
|
60
|
+
// A documentation example showing VuePress syntax verbatim. The normalizer
|
|
61
|
+
// must skip fenced regions; otherwise the code sample silently becomes the
|
|
62
|
+
// syntax itself and the doc example breaks.
|
|
63
|
+
const html = await renderAsync(
|
|
64
|
+
MarkdownRenderer({
|
|
65
|
+
content: [
|
|
66
|
+
"Here is how to write a tip:",
|
|
67
|
+
"",
|
|
68
|
+
"```markdown",
|
|
69
|
+
"::: tip 智慧的疆界",
|
|
70
|
+
"Body of the tip.",
|
|
71
|
+
":::",
|
|
72
|
+
"```",
|
|
73
|
+
"",
|
|
74
|
+
"And here is a real one:",
|
|
75
|
+
"",
|
|
76
|
+
"::: tip 真实的提示",
|
|
77
|
+
"Hello",
|
|
78
|
+
":::",
|
|
79
|
+
].join("\n"),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
// The code block keeps the relaxed `::: tip ...` form verbatim — no
|
|
83
|
+
// `:::tip[...]` rewrite leaks through.
|
|
84
|
+
expect(html).toContain("::: tip 智慧的疆界");
|
|
85
|
+
expect(html).not.toContain(":::tip[智慧的疆界]");
|
|
86
|
+
// The real container outside the code block still renders as an alert.
|
|
87
|
+
expect(html).toContain('class="alert alert-tip"');
|
|
88
|
+
expect(html).toContain("真实的提示");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("fence character type matters (~~~ vs ```)", async () => {
|
|
92
|
+
// A `~~~` fence isn't closed by a ``` line, so the container after the
|
|
93
|
+
// ``` is still inside the open `~~~` fence and must NOT be rewritten.
|
|
94
|
+
const html = await renderAsync(
|
|
95
|
+
MarkdownRenderer({
|
|
96
|
+
content: [
|
|
97
|
+
"~~~",
|
|
98
|
+
"::: tip Inside tilde fence",
|
|
99
|
+
"```",
|
|
100
|
+
"::: tip Still inside",
|
|
101
|
+
"~~~",
|
|
102
|
+
].join("\n"),
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
expect(html).not.toContain('class="alert');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -16,7 +16,7 @@ describe("Tooling: New Post Script", () => {
|
|
|
16
16
|
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
17
17
|
});
|
|
18
18
|
createdDirs.forEach(dir => {
|
|
19
|
-
if (fs.existsSync(dir)) fs.
|
|
19
|
+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
22
|
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* • afterAll restores the real module so any subsequent tests see real data.
|
|
22
22
|
*/
|
|
23
23
|
import { describe, test, expect, mock, beforeAll, beforeEach, afterAll, afterEach } from 'bun:test';
|
|
24
|
+
import { setEnvVar, restoreEnvVar } from '../helpers/env';
|
|
24
25
|
|
|
25
26
|
// ─── Capture real modules ─────────────────────────────────────────────────────
|
|
26
27
|
// Static imports are hoisted and resolved before any executable code (including
|
|
@@ -36,7 +37,18 @@ import * as realUrls from '../../src/lib/urls';
|
|
|
36
37
|
// reference, but they are never mutated during tests so this is safe.
|
|
37
38
|
const snapshotUrls = { ...realUrls };
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
// Mock-post shape: only `slug` is required; the named fields are the ones
|
|
41
|
+
// production code paths read. Extra fields (full Post shape) are allowed via
|
|
42
|
+
// the index signature so individual tests can pass realistic fixtures without
|
|
43
|
+
// every property having to be enumerated here.
|
|
44
|
+
let mockedPosts: Array<{
|
|
45
|
+
slug: string;
|
|
46
|
+
series?: string;
|
|
47
|
+
redirectFrom?: string[];
|
|
48
|
+
draft?: boolean;
|
|
49
|
+
title?: string;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}> = [];
|
|
40
52
|
let mockedNotes: Array<{ slug: string }> = [];
|
|
41
53
|
let mockedSeries: Record<string, Array<{ slug: string }>> = {};
|
|
42
54
|
let mockedSeriesData: Record<string, { redirectFrom?: string[]; title?: string }> = {};
|
|
@@ -148,7 +160,7 @@ beforeEach(() => {
|
|
|
148
160
|
mockedNotes = [];
|
|
149
161
|
mockedSeries = {};
|
|
150
162
|
mockedSeriesData = {};
|
|
151
|
-
|
|
163
|
+
restoreEnvVar('NODE_ENV', originalNodeEnv);
|
|
152
164
|
});
|
|
153
165
|
|
|
154
166
|
afterEach(() => {
|
|
@@ -156,7 +168,7 @@ afterEach(() => {
|
|
|
156
168
|
mockedNotes = [];
|
|
157
169
|
mockedSeries = {};
|
|
158
170
|
mockedSeriesData = {};
|
|
159
|
-
|
|
171
|
+
restoreEnvVar('NODE_ENV', originalNodeEnv);
|
|
160
172
|
});
|
|
161
173
|
|
|
162
174
|
// ─── Restore real modules ─────────────────────────────────────────────────────
|
|
@@ -201,7 +213,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
201
213
|
|
|
202
214
|
test('notes/[slug] includes raw and encoded Unicode slug in non-production', async () => {
|
|
203
215
|
mockedNotes = [{ slug: '推理模型' }];
|
|
204
|
-
|
|
216
|
+
setEnvVar('NODE_ENV', 'development');
|
|
205
217
|
const { generateStaticParams } = await import('../../src/app/notes/[slug]/page');
|
|
206
218
|
const params = generateStaticParams();
|
|
207
219
|
expect(params).toContainEqual({ slug: '推理模型' });
|
|
@@ -210,7 +222,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
210
222
|
|
|
211
223
|
test('notes/[slug] includes only raw Unicode slug in production', async () => {
|
|
212
224
|
mockedNotes = [{ slug: '推理模型' }];
|
|
213
|
-
|
|
225
|
+
setEnvVar('NODE_ENV', 'production');
|
|
214
226
|
const { generateStaticParams } = await import('../../src/app/notes/[slug]/page');
|
|
215
227
|
const params = generateStaticParams();
|
|
216
228
|
expect(params).toContainEqual({ slug: '推理模型' });
|
|
@@ -232,10 +244,10 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
232
244
|
expect(params).toEqual([{ slug: '_' }]);
|
|
233
245
|
});
|
|
234
246
|
|
|
235
|
-
test('books/[slug]/[chapter] returns [{ slug: "_", chapter: "_" }]', async () => {
|
|
236
|
-
const { generateStaticParams } = await import('../../src/app/books/[slug]/[chapter]/page');
|
|
247
|
+
test('books/[slug]/[...chapter] returns [{ slug: "_", chapter: ["_"] }]', async () => {
|
|
248
|
+
const { generateStaticParams } = await import('../../src/app/books/[slug]/[...chapter]/page');
|
|
237
249
|
const params = await generateStaticParams();
|
|
238
|
-
expect(params).toEqual([{ slug: '_', chapter: '_' }]);
|
|
250
|
+
expect(params).toEqual([{ slug: '_', chapter: ['_'] }]);
|
|
239
251
|
});
|
|
240
252
|
});
|
|
241
253
|
|
|
@@ -257,7 +269,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
257
269
|
|
|
258
270
|
test('series/[slug] includes raw and encoded Unicode slug in non-production', async () => {
|
|
259
271
|
mockedSeries = { '软件构架设计': [] };
|
|
260
|
-
|
|
272
|
+
setEnvVar('NODE_ENV', 'development');
|
|
261
273
|
const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
|
|
262
274
|
const params = await generateStaticParams();
|
|
263
275
|
expect(params).toContainEqual({ slug: '软件构架设计' });
|
|
@@ -266,7 +278,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
266
278
|
|
|
267
279
|
test('series/[slug] includes only raw Unicode slug in production', async () => {
|
|
268
280
|
mockedSeries = { '软件构架设计': [] };
|
|
269
|
-
|
|
281
|
+
setEnvVar('NODE_ENV', 'production');
|
|
270
282
|
const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
|
|
271
283
|
const params = await generateStaticParams();
|
|
272
284
|
expect(params).toContainEqual({ slug: '软件构架设计' });
|
|
@@ -281,7 +293,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
281
293
|
|
|
282
294
|
test('series/[slug]/page/[page] includes encoded Unicode slug in non-production', async () => {
|
|
283
295
|
mockedSeries = { '软件构架设计': Array.from({ length: 6 }, (_, i) => ({ slug: `p${i + 1}` })) };
|
|
284
|
-
|
|
296
|
+
setEnvVar('NODE_ENV', 'development');
|
|
285
297
|
const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
|
|
286
298
|
const params = await generateStaticParams();
|
|
287
299
|
expect(params).toContainEqual({ slug: '软件构架设计', page: '2' });
|
|
@@ -361,7 +373,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
361
373
|
|
|
362
374
|
test('posts/[slug] includes raw and encoded Unicode slug in non-production', async () => {
|
|
363
375
|
mockedPosts = [{ slug: '中文测试文章' }];
|
|
364
|
-
|
|
376
|
+
setEnvVar('NODE_ENV', 'development');
|
|
365
377
|
const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
|
|
366
378
|
const params = await generateStaticParams();
|
|
367
379
|
|
|
@@ -371,7 +383,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
371
383
|
|
|
372
384
|
test('posts/[slug] includes only raw Unicode slug in production', async () => {
|
|
373
385
|
mockedPosts = [{ slug: '中文测试文章' }];
|
|
374
|
-
|
|
386
|
+
setEnvVar('NODE_ENV', 'production');
|
|
375
387
|
const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
|
|
376
388
|
const params = await generateStaticParams();
|
|
377
389
|
|
|
@@ -485,7 +497,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
485
497
|
|
|
486
498
|
test('[slug]/page does not include single-segment redirectFrom for draft posts in production', async () => {
|
|
487
499
|
mockedPosts = [{ slug: 'my-post', draft: true, redirectFrom: ['/old-slug'] }];
|
|
488
|
-
|
|
500
|
+
setEnvVar('NODE_ENV', 'production');
|
|
489
501
|
const { generateStaticParams } = await import('../../src/app/[slug]/page');
|
|
490
502
|
const params = await generateStaticParams();
|
|
491
503
|
expect(params).not.toContainEqual({ slug: 'old-slug' });
|
|
@@ -588,7 +600,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
588
600
|
test('[slug]/[postSlug] includes encoded Unicode postSlug variants in non-production', async () => {
|
|
589
601
|
// Use redirectFrom to place a Unicode postSlug at a 2-segment path — no url mock needed.
|
|
590
602
|
mockedPosts = [{ slug: 'my-post', redirectFrom: ['/old-prefix/中文文章'] }];
|
|
591
|
-
|
|
603
|
+
setEnvVar('NODE_ENV', 'development');
|
|
592
604
|
const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
|
|
593
605
|
const params = await generateStaticParams();
|
|
594
606
|
expect(params).toContainEqual({ slug: 'old-prefix', postSlug: '中文文章' });
|
|
@@ -597,7 +609,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
597
609
|
|
|
598
610
|
test('[slug]/[postSlug] includes encoded Unicode prefix variants in non-production', async () => {
|
|
599
611
|
mockedSeries = { '软件构架设计': [{ slug: 'architecture-post' }] };
|
|
600
|
-
|
|
612
|
+
setEnvVar('NODE_ENV', 'development');
|
|
601
613
|
const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
|
|
602
614
|
const params = await generateStaticParams();
|
|
603
615
|
expect(params).toContainEqual({ slug: '软件构架设计', postSlug: 'architecture-post' });
|
|
@@ -606,7 +618,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
606
618
|
|
|
607
619
|
test('[slug]/[postSlug] includes encoded Unicode prefix and postSlug variants together in non-production', async () => {
|
|
608
620
|
mockedSeries = { '软件构架设计': [{ slug: '中文文章' }] };
|
|
609
|
-
|
|
621
|
+
setEnvVar('NODE_ENV', 'development');
|
|
610
622
|
const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
|
|
611
623
|
const params = await generateStaticParams();
|
|
612
624
|
expect(params).toContainEqual({ slug: '软件构架设计', postSlug: '中文文章' });
|
|
@@ -617,7 +629,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
617
629
|
|
|
618
630
|
test('[slug]/[postSlug] does not include encoded Unicode postSlug variants in production', async () => {
|
|
619
631
|
mockedPosts = [{ slug: 'my-post', redirectFrom: ['/old-prefix/中文文章'] }];
|
|
620
|
-
|
|
632
|
+
setEnvVar('NODE_ENV', 'production');
|
|
621
633
|
const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
|
|
622
634
|
const params = await generateStaticParams();
|
|
623
635
|
expect(params).toContainEqual({ slug: 'old-prefix', postSlug: '中文文章' });
|
|
@@ -641,7 +653,8 @@ describe('generateStaticParams — placeholder when content is empty', () => {
|
|
|
641
653
|
imageBaseSlug: 'posts',
|
|
642
654
|
category: 'Test',
|
|
643
655
|
tags: [],
|
|
644
|
-
|
|
656
|
+
readingMinutes: 1,
|
|
657
|
+
wordCount: 0,
|
|
645
658
|
}];
|
|
646
659
|
|
|
647
660
|
const page = await import('../../src/app/[slug]/[postSlug]/page');
|