@hutusi/amytis 1.14.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +90 -219
- package/README.md +33 -1
- package/README.zh.md +33 -1
- package/TODO.md +10 -0
- package/bun.lock +205 -539
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- 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/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +239 -8
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +36 -0
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +178 -0
- package/eslint.config.mjs +20 -6
- package/next.config.ts +2 -2
- package/package.json +52 -24
- 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-code-group-icons.ts +79 -0
- package/scripts/generate-knowledge-graph.ts +2 -1
- package/scripts/render-rst.py +923 -0
- package/scripts/run-with-rst-python.ts +42 -0
- package/scripts/sync-vuepress-book.ts +499 -0
- package/src/app/[slug]/[postSlug]/page.tsx +20 -10
- package/src/app/[slug]/page/[page]/page.tsx +15 -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 +639 -94
- package/src/app/page.tsx +1 -1
- 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/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/AuthorCard.tsx +25 -16
- 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 +6 -2
- 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 +30 -4
- package/src/components/MarkdownRenderer.tsx +148 -24
- 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 +93 -0
- package/src/components/RstRenderer.tsx +157 -0
- 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 +10 -2
- package/src/layouts/SimpleLayout.tsx +10 -3
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/image-utils.test.ts +19 -0
- package/src/lib/image-utils.ts +11 -0
- package/src/lib/markdown.test.ts +195 -14
- package/src/lib/markdown.ts +928 -254
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/rehype-image-metadata.ts +2 -2
- 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.test.ts +355 -0
- package/src/lib/rst-renderer.ts +629 -0
- package/src/lib/rst.test.ts +350 -0
- package/src/lib/rst.ts +674 -0
- package/src/lib/series-redirects.ts +42 -0
- 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/feed-utils.test.ts +13 -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 +12 -14
- package/tests/integration/series-draft.test.ts +12 -5
- package/tests/integration/series.test.ts +93 -0
- package/tests/integration/sync-vuepress-book.test.ts +240 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/build-pagefind.test.ts +66 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/static-params.test.ts +166 -13
|
@@ -2,22 +2,24 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { getAllPosts, getPostBySlug } from "../../src/lib/markdown";
|
|
3
3
|
|
|
4
4
|
describe("Integration: Reading Time & Headings", () => {
|
|
5
|
-
test("posts have
|
|
5
|
+
test("posts have a positive whole-minute readingMinutes", () => {
|
|
6
6
|
const posts = getAllPosts();
|
|
7
7
|
expect(posts.length).toBeGreaterThan(0);
|
|
8
8
|
|
|
9
9
|
posts.forEach((post) => {
|
|
10
|
-
expect(post.
|
|
10
|
+
expect(Number.isInteger(post.readingMinutes)).toBe(true);
|
|
11
|
+
expect(post.readingMinutes).toBeGreaterThanOrEqual(1);
|
|
11
12
|
});
|
|
12
13
|
});
|
|
13
14
|
|
|
14
|
-
test("kitchen-sink post has
|
|
15
|
+
test("kitchen-sink post has a positive readingMinutes", () => {
|
|
15
16
|
const post = getPostBySlug("kitchen-sink");
|
|
16
17
|
if (!post) {
|
|
17
18
|
console.warn("Skipping: kitchen-sink post not found");
|
|
18
19
|
return;
|
|
19
20
|
}
|
|
20
|
-
expect(post.
|
|
21
|
+
expect(Number.isInteger(post.readingMinutes)).toBe(true);
|
|
22
|
+
expect(post.readingMinutes).toBeGreaterThanOrEqual(1);
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
test("headings on real posts have correct structure", () => {
|
|
@@ -49,17 +51,13 @@ describe("Integration: Reading Time & Headings", () => {
|
|
|
49
51
|
});
|
|
50
52
|
});
|
|
51
53
|
|
|
52
|
-
test("short posts have 1
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return wordCount < 200;
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
if (shortPost) {
|
|
61
|
-
expect(shortPost.readingTime).toBe("1 min read");
|
|
54
|
+
test("short posts have readingMinutes === 1 (floor)", () => {
|
|
55
|
+
const shortPost = getPostBySlug("legacy-markdown");
|
|
56
|
+
expect(shortPost).toBeDefined();
|
|
57
|
+
if (!shortPost) {
|
|
58
|
+
throw new Error("fixture 'legacy-markdown' not found");
|
|
62
59
|
}
|
|
60
|
+
expect(shortPost.readingMinutes).toBe(1);
|
|
63
61
|
});
|
|
64
62
|
|
|
65
63
|
test("multilingual post has headings with correct IDs", () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { getSeriesData, getAllSeries } from "../../src/lib/markdown";
|
|
3
|
+
import { setEnvVar, restoreEnvVar } from "../helpers/env";
|
|
3
4
|
|
|
4
5
|
describe("Integration: Series Draft Support", () => {
|
|
5
6
|
test("all series are included when NODE_ENV is not production", () => {
|
|
@@ -18,29 +19,35 @@ describe("Integration: Series Draft Support", () => {
|
|
|
18
19
|
|
|
19
20
|
test("draft filtering code path runs without error in production mode", () => {
|
|
20
21
|
const originalEnv = process.env.NODE_ENV;
|
|
22
|
+
const originalPythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
|
|
21
23
|
try {
|
|
22
|
-
|
|
24
|
+
setEnvVar("NODE_ENV", "production");
|
|
25
|
+
setEnvVar("AMYTIS_ENABLE_PYTHON_RST", "0");
|
|
23
26
|
// This should not throw; draft series are simply excluded
|
|
24
27
|
const series = getAllSeries();
|
|
25
28
|
expect(typeof series).toBe("object");
|
|
26
29
|
} finally {
|
|
27
|
-
|
|
30
|
+
restoreEnvVar("NODE_ENV", originalEnv);
|
|
31
|
+
restoreEnvVar("AMYTIS_ENABLE_PYTHON_RST", originalPythonRst);
|
|
28
32
|
}
|
|
29
33
|
});
|
|
30
34
|
|
|
31
35
|
test("draft series are excluded in production mode", () => {
|
|
32
36
|
const originalEnv = process.env.NODE_ENV;
|
|
37
|
+
const originalPythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
|
|
33
38
|
try {
|
|
34
|
-
|
|
39
|
+
setEnvVar("NODE_ENV", "production");
|
|
40
|
+
setEnvVar("AMYTIS_ENABLE_PYTHON_RST", "0");
|
|
35
41
|
const allSeries = getAllSeries();
|
|
36
|
-
|
|
42
|
+
|
|
37
43
|
// Verify that every series returned has draft: false (or undefined which defaults to false)
|
|
38
44
|
Object.keys(allSeries).forEach(slug => {
|
|
39
45
|
const seriesData = getSeriesData(slug);
|
|
40
46
|
expect(seriesData?.draft).not.toBe(true);
|
|
41
47
|
});
|
|
42
48
|
} finally {
|
|
43
|
-
|
|
49
|
+
restoreEnvVar("NODE_ENV", originalEnv);
|
|
50
|
+
restoreEnvVar("AMYTIS_ENABLE_PYTHON_RST", originalPythonRst);
|
|
44
51
|
}
|
|
45
52
|
});
|
|
46
53
|
});
|
|
@@ -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,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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
|