@hutusi/amytis 1.15.0 → 1.17.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/.claude/rules/immersive-reading.md +21 -0
- package/.claude/rules/rst.md +13 -0
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +89 -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 +298 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +237 -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 +710 -0
- package/site.config.example.ts +3 -3
- package/site.config.ts +3 -3
- package/src/app/[slug]/layout.tsx +30 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +85 -34
- package/src/app/globals.css +570 -123
- package/src/app/page.tsx +7 -1
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookReadingShell.tsx +145 -0
- 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/CuratedSeriesSection.tsx +28 -10
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +44 -23
- package/src/components/Footer.tsx +1 -1
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/ImmersiveReader.tsx +130 -0
- package/src/components/ImmersiveReaderTopBar.tsx +106 -0
- package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
- package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
- package/src/components/ImmersiveReadingProvider.tsx +168 -0
- package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
- package/src/components/ImmersiveToggleButton.tsx +45 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +175 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/ReadingProgressBar.tsx +1 -1
- 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/SelectedBooksSection.tsx +27 -8
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +44 -0
- package/src/layouts/BookLayout.tsx +62 -74
- package/src/layouts/PostLayout.tsx +154 -111
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +217 -57
- 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/scroll-utils.ts +44 -6
- 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/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +62 -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/book-index-cta.test.ts +87 -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/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +443 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/tests/unit/static-params.test.ts +32 -19
- package/vercel.json +7 -0
|
@@ -0,0 +1,443 @@
|
|
|
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, extraArgs: string[] = []) {
|
|
15
|
+
return spawnSync(
|
|
16
|
+
"bun",
|
|
17
|
+
["run", "sync-vuepress-book", "--source", source, "--dest", dest, ...extraArgs],
|
|
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("re-sync only touches the chapters field — never re-applies first-sync defaults", () => {
|
|
93
|
+
// First run creates index.mdx with bootstrap defaults (title, date,
|
|
94
|
+
// draft, featured).
|
|
95
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
96
|
+
|
|
97
|
+
// Author strips the script's bootstrap defaults to validate that
|
|
98
|
+
// re-sync does not auto-fill them again. `featured` is explicitly
|
|
99
|
+
// removed; `date` is left as an empty string (which the old behavior
|
|
100
|
+
// would have stomped with today's date because `!data.date` is true
|
|
101
|
+
// for `""`).
|
|
102
|
+
const indexPath = path.join(dest, "index.mdx");
|
|
103
|
+
const parsed = matter(readFileSync(indexPath, "utf8")) as unknown as { data: Record<string, unknown>; content: string };
|
|
104
|
+
delete parsed.data.featured;
|
|
105
|
+
parsed.data.date = "";
|
|
106
|
+
parsed.data.draft = true;
|
|
107
|
+
writeFileSync(indexPath, matter.stringify(parsed.content, parsed.data));
|
|
108
|
+
|
|
109
|
+
// Re-run.
|
|
110
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
111
|
+
const refreshed = matter(readFileSync(indexPath, "utf8")) as unknown as { data: Record<string, unknown> };
|
|
112
|
+
|
|
113
|
+
// `chapters:` refreshed; everything else byte-equivalent to what the
|
|
114
|
+
// author wrote.
|
|
115
|
+
expect(Array.isArray(refreshed.data.chapters)).toBe(true);
|
|
116
|
+
expect(refreshed.data.featured).toBeUndefined();
|
|
117
|
+
expect(refreshed.data.date).toBe("");
|
|
118
|
+
expect(refreshed.data.draft).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("prunes dest files removed upstream (mirror semantics)", () => {
|
|
122
|
+
// First sync — dest now has every fixture file.
|
|
123
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
124
|
+
expect(existsSync(path.join(dest, "maths", "linear", "matrices.md"))).toBe(true);
|
|
125
|
+
|
|
126
|
+
// Simulate an upstream deletion by syncing from a smaller temp source tree
|
|
127
|
+
// (just the bits we need for the still-listed chapters) and a config that
|
|
128
|
+
// no longer references matrices.
|
|
129
|
+
const trimmed = mkdtempSync(path.join(tmpdir(), "sync-trimmed-"));
|
|
130
|
+
try {
|
|
131
|
+
const docs = path.join(trimmed, "docs");
|
|
132
|
+
const vp = path.join(docs, ".vuepress");
|
|
133
|
+
mkdirSync(vp, { recursive: true });
|
|
134
|
+
writeFileSync(
|
|
135
|
+
path.join(vp, "config.js"),
|
|
136
|
+
`export default {
|
|
137
|
+
title: 'Fixture Book',
|
|
138
|
+
theme: { sidebar: [{ text: 'Vectors', link: '/maths/linear/vectors' }] },
|
|
139
|
+
}
|
|
140
|
+
`,
|
|
141
|
+
);
|
|
142
|
+
mkdirSync(path.join(docs, "maths", "linear"), { recursive: true });
|
|
143
|
+
writeFileSync(path.join(docs, "maths", "linear", "vectors.md"), "---\ntitle: Vectors\n---\n# Vectors\n");
|
|
144
|
+
|
|
145
|
+
expect(runSync(docs, dest).status).toBe(0);
|
|
146
|
+
|
|
147
|
+
// Vectors survives, matrices is gone, the now-empty assets/ dir is cleaned up.
|
|
148
|
+
expect(existsSync(path.join(dest, "maths", "linear", "vectors.md"))).toBe(true);
|
|
149
|
+
expect(existsSync(path.join(dest, "maths", "linear", "matrices.md"))).toBe(false);
|
|
150
|
+
expect(existsSync(path.join(dest, "intro"))).toBe(false);
|
|
151
|
+
expect(existsSync(path.join(dest, "maths", "linear", "assets"))).toBe(false);
|
|
152
|
+
// index.mdx is regenerated, not pruned.
|
|
153
|
+
expect(existsSync(path.join(dest, "index.mdx"))).toBe(true);
|
|
154
|
+
} finally {
|
|
155
|
+
rmSync(trimmed, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("preserves user-added dotfiles on re-run (out-of-band overlay state)", () => {
|
|
160
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
161
|
+
const dotfile = path.join(dest, ".gitkeep");
|
|
162
|
+
writeFileSync(dotfile, "");
|
|
163
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
164
|
+
expect(existsSync(dotfile)).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("resolves folder-index sidebar links (e.g. /guide/ → guide/README.md)", () => {
|
|
168
|
+
const folder = mkdtempSync(path.join(tmpdir(), "sync-folder-"));
|
|
169
|
+
try {
|
|
170
|
+
const docs = path.join(folder, "docs");
|
|
171
|
+
const vp = path.join(docs, ".vuepress");
|
|
172
|
+
mkdirSync(vp, { recursive: true });
|
|
173
|
+
writeFileSync(
|
|
174
|
+
path.join(vp, "config.js"),
|
|
175
|
+
`export default {
|
|
176
|
+
title: 'Folder-Index Book',
|
|
177
|
+
theme: { sidebar: [{ text: 'Guide', link: '/guide/' }] },
|
|
178
|
+
}
|
|
179
|
+
`,
|
|
180
|
+
);
|
|
181
|
+
mkdirSync(path.join(docs, "guide"), { recursive: true });
|
|
182
|
+
writeFileSync(path.join(docs, "guide", "README.md"), "---\ntitle: Guide\n---\n# Guide\n");
|
|
183
|
+
|
|
184
|
+
const res = runSync(docs, dest);
|
|
185
|
+
expect(res.status).toBe(0);
|
|
186
|
+
// The chapter id strips the trailing slash, so the folder-index target
|
|
187
|
+
// exists at <dest>/guide/README.md and the TOC entry's id is `guide`.
|
|
188
|
+
const { data } = matter(readFileSync(path.join(dest, "index.mdx"), "utf8")) as unknown as { data: Record<string, unknown> };
|
|
189
|
+
expect((data.chapters as Array<{ id: string }>)[0].id).toBe("guide");
|
|
190
|
+
expect(existsSync(path.join(dest, "guide", "README.md"))).toBe(true);
|
|
191
|
+
} finally {
|
|
192
|
+
rmSync(folder, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("rejects a config.ts with a clear message instead of acorn parse failure", () => {
|
|
197
|
+
const tsConfig = mkdtempSync(path.join(tmpdir(), "sync-ts-config-"));
|
|
198
|
+
try {
|
|
199
|
+
const docs = path.join(tsConfig, "docs");
|
|
200
|
+
const vp = path.join(docs, ".vuepress");
|
|
201
|
+
mkdirSync(vp, { recursive: true });
|
|
202
|
+
writeFileSync(
|
|
203
|
+
path.join(vp, "config.ts"),
|
|
204
|
+
"const x: number = 1; export default { theme: { sidebar: [] } }\n",
|
|
205
|
+
);
|
|
206
|
+
const res = runSync(docs, dest);
|
|
207
|
+
expect(res.status).not.toBe(0);
|
|
208
|
+
expect(res.stderr).toMatch(/config\.ts/);
|
|
209
|
+
// Match the actionable phrasing only — if a regression let the raw
|
|
210
|
+
// acorn parse error through, that should fail this assertion.
|
|
211
|
+
expect(res.stderr).toMatch(/Compile to JavaScript|JS-only/);
|
|
212
|
+
} finally {
|
|
213
|
+
rmSync(tsConfig, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("drops a leaf with id 'contents' from the TOC (Amytis renders its own)", () => {
|
|
218
|
+
const withContents = mkdtempSync(path.join(tmpdir(), "sync-contents-"));
|
|
219
|
+
try {
|
|
220
|
+
const docs = path.join(withContents, "docs");
|
|
221
|
+
const vp = path.join(docs, ".vuepress");
|
|
222
|
+
mkdirSync(vp, { recursive: true });
|
|
223
|
+
writeFileSync(
|
|
224
|
+
path.join(vp, "config.js"),
|
|
225
|
+
`export default {
|
|
226
|
+
title: 'TOC-Heavy Book',
|
|
227
|
+
theme: {
|
|
228
|
+
sidebar: [
|
|
229
|
+
{ text: '目录', link: 'contents' },
|
|
230
|
+
{ text: 'Real', link: '/real-chapter' },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
`,
|
|
235
|
+
);
|
|
236
|
+
writeFileSync(path.join(docs, "contents.md"), "# Table of Contents\n- [Real](real-chapter.md)\n");
|
|
237
|
+
writeFileSync(path.join(docs, "real-chapter.md"), "---\ntitle: Real\n---\n# Real\n");
|
|
238
|
+
|
|
239
|
+
const res = runSync(docs, dest);
|
|
240
|
+
expect(res.status).toBe(0);
|
|
241
|
+
const { data } = matter(readFileSync(path.join(dest, "index.mdx"), "utf8")) as unknown as { data: Record<string, unknown> };
|
|
242
|
+
const chapterIds = (data.chapters as Array<{ id?: string; section?: string }>).map(c => c.id ?? c.section);
|
|
243
|
+
expect(chapterIds).toEqual(["real-chapter"]);
|
|
244
|
+
// The summary mentions the dropped leaf so the run isn't silent.
|
|
245
|
+
expect(res.stdout).toMatch(/contents/);
|
|
246
|
+
} finally {
|
|
247
|
+
rmSync(withContents, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("imports a VuePress 1.x sidebar (title/path/collapsable, bare-string children, README promotion, SUMMARY drop)", () => {
|
|
252
|
+
// VP1 uses a different vocabulary than VP2: `title` instead of `text`,
|
|
253
|
+
// `collapsable` instead of `collapsible`, plain string paths as children,
|
|
254
|
+
// and sections that carry both `path` (the section's README) and
|
|
255
|
+
// `children` (sub-chapters). This fixture exercises all four variants
|
|
256
|
+
// plus the GitBook-style SUMMARY.md drop.
|
|
257
|
+
const vp1 = mkdtempSync(path.join(tmpdir(), "sync-vp1-"));
|
|
258
|
+
try {
|
|
259
|
+
const docs = path.join(vp1, "docs");
|
|
260
|
+
const vp = path.join(docs, ".vuepress");
|
|
261
|
+
mkdirSync(vp, { recursive: true });
|
|
262
|
+
writeFileSync(
|
|
263
|
+
path.join(vp, "config.js"),
|
|
264
|
+
`module.exports = {
|
|
265
|
+
title: 'VP1 Fixture',
|
|
266
|
+
themeConfig: {
|
|
267
|
+
sidebar: [
|
|
268
|
+
{ title: '目录', collapsable: false, path: '/SUMMARY.md' },
|
|
269
|
+
{
|
|
270
|
+
title: 'Preface',
|
|
271
|
+
collapsable: false,
|
|
272
|
+
children: [
|
|
273
|
+
'/intro/about-me',
|
|
274
|
+
'/intro/about-book',
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
title: 'Architecture',
|
|
279
|
+
collapsable: false,
|
|
280
|
+
children: [
|
|
281
|
+
{
|
|
282
|
+
title: 'History',
|
|
283
|
+
path: '/arch/history/',
|
|
284
|
+
collapsable: false,
|
|
285
|
+
children: [
|
|
286
|
+
'/arch/history/monolithic',
|
|
287
|
+
'/arch/history/microservices',
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
'/arch/standalone-note',
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
title: 'Misc',
|
|
295
|
+
collapsable: false,
|
|
296
|
+
children: [
|
|
297
|
+
'/CHANGELOG.md',
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
`,
|
|
304
|
+
);
|
|
305
|
+
// Source files matching every sidebar reference. Titles come from
|
|
306
|
+
// frontmatter or H1 — the importer should pick them up for bare-string
|
|
307
|
+
// children that have no inline title.
|
|
308
|
+
writeFileSync(path.join(docs, "SUMMARY.md"), "# Summary\n- placeholder\n");
|
|
309
|
+
mkdirSync(path.join(docs, "intro"), { recursive: true });
|
|
310
|
+
writeFileSync(path.join(docs, "intro", "about-me.md"), "---\ntitle: About the Author\n---\n# About\n");
|
|
311
|
+
writeFileSync(path.join(docs, "intro", "about-book.md"), "# About this Book\n\nBody.\n");
|
|
312
|
+
mkdirSync(path.join(docs, "arch", "history"), { recursive: true });
|
|
313
|
+
writeFileSync(path.join(docs, "arch", "history", "README.md"), "# History of Architecture\n");
|
|
314
|
+
writeFileSync(path.join(docs, "arch", "history", "monolithic.md"), "# Monolithic\n");
|
|
315
|
+
writeFileSync(path.join(docs, "arch", "history", "microservices.md"), "# Microservices\n");
|
|
316
|
+
writeFileSync(path.join(docs, "arch", "standalone-note.md"), "# Standalone Note\n");
|
|
317
|
+
writeFileSync(path.join(docs, "CHANGELOG.md"), "# Changelog\n");
|
|
318
|
+
|
|
319
|
+
const res = runSync(docs, dest);
|
|
320
|
+
expect(res.status).toBe(0);
|
|
321
|
+
|
|
322
|
+
const { data } = matter(readFileSync(path.join(dest, "index.mdx"), "utf8")) as unknown as { data: Record<string, unknown> };
|
|
323
|
+
const chapters = data.chapters as Array<Record<string, unknown>>;
|
|
324
|
+
|
|
325
|
+
// SUMMARY.md dropped — TOC starts with the Preface section.
|
|
326
|
+
expect(chapters[0]).toMatchObject({ section: "Preface", collapsible: false });
|
|
327
|
+
|
|
328
|
+
// Bare-string children get their titles from the source files
|
|
329
|
+
// (frontmatter wins over first H1).
|
|
330
|
+
const prefaceItems = chapters[0].items as Array<Record<string, unknown>>;
|
|
331
|
+
expect(prefaceItems).toEqual([
|
|
332
|
+
{ title: "About the Author", id: "intro/about-me" },
|
|
333
|
+
{ title: "About this Book", id: "intro/about-book" },
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
// Architecture > History promotes the section's README as the first
|
|
337
|
+
// chapter (id `arch/history`, title from the README's H1), then
|
|
338
|
+
// appends the bare-string children.
|
|
339
|
+
const arch = chapters[1] as Record<string, unknown>;
|
|
340
|
+
expect(arch.section).toBe("Architecture");
|
|
341
|
+
const archItems = arch.items as Array<Record<string, unknown>>;
|
|
342
|
+
const history = archItems[0] as Record<string, unknown>;
|
|
343
|
+
expect(history.section).toBe("History");
|
|
344
|
+
expect(history.items).toEqual([
|
|
345
|
+
{ title: "History of Architecture", id: "arch/history" },
|
|
346
|
+
{ title: "Monolithic", id: "arch/history/monolithic" },
|
|
347
|
+
{ title: "Microservices", id: "arch/history/microservices" },
|
|
348
|
+
]);
|
|
349
|
+
// Standalone bare-string sibling of the History section.
|
|
350
|
+
expect(archItems[1]).toMatchObject({ title: "Standalone Note", id: "arch/standalone-note" });
|
|
351
|
+
|
|
352
|
+
// `/CHANGELOG.md` keeps its `.md` extension stripped — id is `CHANGELOG`.
|
|
353
|
+
const misc = chapters[2] as Record<string, unknown>;
|
|
354
|
+
expect((misc.items as Array<{ id: string }>)[0].id).toBe("CHANGELOG");
|
|
355
|
+
|
|
356
|
+
// SUMMARY drop is reported in stdout (same channel as the existing
|
|
357
|
+
// `contents` drop).
|
|
358
|
+
expect(res.stdout).toMatch(/SUMMARY/i);
|
|
359
|
+
|
|
360
|
+
// No "unsupported sidebar entries" warning — every VP1 shape was
|
|
361
|
+
// recognized.
|
|
362
|
+
expect(res.stderr).not.toMatch(/Skipped unsupported sidebar entries/);
|
|
363
|
+
} finally {
|
|
364
|
+
rmSync(vp1, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("skips common build manifests by default, honors --skip and --no-skip-common", () => {
|
|
369
|
+
// Set up a minimal VuePress book where the source root has package.json
|
|
370
|
+
// (junk), package-lock.json (junk), a custom .bak (skipped via --skip),
|
|
371
|
+
// and a Real.md (always copied).
|
|
372
|
+
const junky = mkdtempSync(path.join(tmpdir(), "sync-junky-"));
|
|
373
|
+
try {
|
|
374
|
+
const docs = path.join(junky, "docs");
|
|
375
|
+
const vp = path.join(docs, ".vuepress");
|
|
376
|
+
mkdirSync(vp, { recursive: true });
|
|
377
|
+
writeFileSync(
|
|
378
|
+
path.join(vp, "config.js"),
|
|
379
|
+
`export default {
|
|
380
|
+
title: 'Junky Book',
|
|
381
|
+
theme: { sidebar: [{ text: 'Real', link: '/real' }] },
|
|
382
|
+
}
|
|
383
|
+
`,
|
|
384
|
+
);
|
|
385
|
+
writeFileSync(path.join(docs, "real.md"), "---\ntitle: Real\n---\n# Real\n");
|
|
386
|
+
writeFileSync(path.join(docs, "package.json"), '{"name":"junky"}');
|
|
387
|
+
writeFileSync(path.join(docs, "package-lock.json"), '{"lockfileVersion":3}');
|
|
388
|
+
writeFileSync(path.join(docs, "bun.lockb"), "binary-ish");
|
|
389
|
+
writeFileSync(path.join(docs, "draft.bak"), "wip");
|
|
390
|
+
|
|
391
|
+
// 1. Defaults: --skip-common is on, --skip empty. Lockfiles dropped,
|
|
392
|
+
// bak file kept (it matches no rule).
|
|
393
|
+
let res = runSync(docs, dest);
|
|
394
|
+
expect(res.status).toBe(0);
|
|
395
|
+
expect(existsSync(path.join(dest, "real.md"))).toBe(true);
|
|
396
|
+
expect(existsSync(path.join(dest, "package.json"))).toBe(false);
|
|
397
|
+
expect(existsSync(path.join(dest, "package-lock.json"))).toBe(false);
|
|
398
|
+
expect(existsSync(path.join(dest, "bun.lockb"))).toBe(false);
|
|
399
|
+
expect(existsSync(path.join(dest, "draft.bak"))).toBe(true);
|
|
400
|
+
// Skip summary is printed so the user knows what got dropped.
|
|
401
|
+
expect(res.stdout).toMatch(/Skipped \d+ files? matching skip rules/);
|
|
402
|
+
|
|
403
|
+
// 2. --skip '*.bak' adds the bak pattern to the defaults.
|
|
404
|
+
rmSync(dest, { recursive: true, force: true });
|
|
405
|
+
mkdirSync(dest, { recursive: true });
|
|
406
|
+
res = runSync(docs, dest, ["--skip", "*.bak"]);
|
|
407
|
+
expect(res.status).toBe(0);
|
|
408
|
+
expect(existsSync(path.join(dest, "real.md"))).toBe(true);
|
|
409
|
+
expect(existsSync(path.join(dest, "package.json"))).toBe(false);
|
|
410
|
+
expect(existsSync(path.join(dest, "draft.bak"))).toBe(false);
|
|
411
|
+
|
|
412
|
+
// 3. --no-skip-common disables the default list; lockfiles come back.
|
|
413
|
+
rmSync(dest, { recursive: true, force: true });
|
|
414
|
+
mkdirSync(dest, { recursive: true });
|
|
415
|
+
res = runSync(docs, dest, ["--no-skip-common"]);
|
|
416
|
+
expect(res.status).toBe(0);
|
|
417
|
+
expect(existsSync(path.join(dest, "package.json"))).toBe(true);
|
|
418
|
+
expect(existsSync(path.join(dest, "package-lock.json"))).toBe(true);
|
|
419
|
+
expect(existsSync(path.join(dest, "bun.lockb"))).toBe(true);
|
|
420
|
+
} finally {
|
|
421
|
+
rmSync(junky, { recursive: true, force: true });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("exits with an error when a sidebar leaf points to a missing source file", () => {
|
|
426
|
+
// Create a corrupt config with a broken link.
|
|
427
|
+
const broken = mkdtempSync(path.join(tmpdir(), "sync-broken-"));
|
|
428
|
+
try {
|
|
429
|
+
const docsDir = path.join(broken, "docs");
|
|
430
|
+
const vp = path.join(docsDir, ".vuepress");
|
|
431
|
+
mkdirSync(vp, { recursive: true });
|
|
432
|
+
writeFileSync(
|
|
433
|
+
path.join(vp, "config.js"),
|
|
434
|
+
"export default { theme: { sidebar: [{ text: 'Missing', link: '/nope' }] } }\n",
|
|
435
|
+
);
|
|
436
|
+
const res = runSync(docsDir, dest);
|
|
437
|
+
expect(res.status).not.toBe(0);
|
|
438
|
+
expect(res.stderr).toContain("source files that do not exist");
|
|
439
|
+
} finally {
|
|
440
|
+
rmSync(broken, { recursive: true, force: true });
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
});
|
|
@@ -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
|
|