@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.
Files changed (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +90 -219
  5. package/README.md +33 -1
  6. package/README.zh.md +33 -1
  7. package/TODO.md +10 -0
  8. package/bun.lock +205 -539
  9. package/content/books/sample-book/index.mdx +3 -0
  10. package/content/posts/code-block-features-showcase.mdx +223 -0
  11. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  12. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  13. package/content/series/rst-legacy/getting-started.rst +24 -0
  14. package/content/series/rst-legacy/index.rst +9 -0
  15. package/content/series/rst-readme/README.rst +9 -0
  16. package/content/series/rst-readme/readme-index-post.rst +10 -0
  17. package/content/series/rst-toctree/first-post.rst +6 -0
  18. package/content/series/rst-toctree/index.rst +10 -0
  19. package/content/series/rst-toctree/second-post.rst +6 -0
  20. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/index.rst +12 -0
  22. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  23. package/docs/ALERTS.md +112 -0
  24. package/docs/ARCHITECTURE.md +239 -8
  25. package/docs/CODE-BLOCKS.md +238 -0
  26. package/docs/CONTRIBUTING.md +36 -0
  27. package/docs/guides/README.md +11 -0
  28. package/docs/guides/importing-vuepress-books.md +178 -0
  29. package/eslint.config.mjs +20 -6
  30. package/next.config.ts +2 -2
  31. package/package.json +52 -24
  32. package/packages/create-amytis/package.json +1 -1
  33. package/packages/create-amytis/src/index.test.ts +43 -1
  34. package/packages/create-amytis/src/index.ts +64 -8
  35. package/public/next-image-export-optimizer-hashes.json +14 -73
  36. package/scripts/build-pagefind.ts +172 -0
  37. package/scripts/copy-assets.ts +246 -56
  38. package/scripts/generate-code-group-icons.ts +79 -0
  39. package/scripts/generate-knowledge-graph.ts +2 -1
  40. package/scripts/render-rst.py +923 -0
  41. package/scripts/run-with-rst-python.ts +42 -0
  42. package/scripts/sync-vuepress-book.ts +499 -0
  43. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  44. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  45. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  46. package/src/app/books/[slug]/page.tsx +67 -32
  47. package/src/app/globals.css +639 -94
  48. package/src/app/page.tsx +1 -1
  49. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  50. package/src/app/series/[slug]/page.tsx +11 -13
  51. package/src/app/series/page.tsx +3 -3
  52. package/src/app/sitemap.ts +3 -3
  53. package/src/components/ArticleCopyCleaner.tsx +64 -0
  54. package/src/components/AuthorCard.tsx +25 -16
  55. package/src/components/BookMobileNav.tsx +44 -50
  56. package/src/components/BookSidebar.tsx +0 -0
  57. package/src/components/CodeBlock.test.tsx +93 -8
  58. package/src/components/CodeBlock.tsx +39 -101
  59. package/src/components/CodeBlockToolbar.tsx +88 -0
  60. package/src/components/CodeGroup.tsx +81 -0
  61. package/src/components/CoverImage.tsx +6 -2
  62. package/src/components/ExternalLinkIcon.tsx +15 -0
  63. package/src/components/FeaturedStoriesSection.tsx +3 -3
  64. package/src/components/GithubAlert.tsx +97 -0
  65. package/src/components/MarkdownRenderer.test.tsx +30 -4
  66. package/src/components/MarkdownRenderer.tsx +148 -24
  67. package/src/components/Mermaid.tsx +32 -1
  68. package/src/components/PostList.tsx +1 -1
  69. package/src/components/PostNavigation.tsx +13 -2
  70. package/src/components/PostSidebar.tsx +13 -2
  71. package/src/components/RstRenderer.test.tsx +93 -0
  72. package/src/components/RstRenderer.tsx +157 -0
  73. package/src/components/Search.tsx +18 -4
  74. package/src/components/SeriesCatalog.tsx +1 -1
  75. package/src/components/ShareBar.tsx +5 -0
  76. package/src/components/TocPanel.tsx +10 -2
  77. package/src/i18n/translations.ts +2 -0
  78. package/src/layouts/BookLayout.tsx +35 -4
  79. package/src/layouts/PostLayout.tsx +10 -2
  80. package/src/layouts/SimpleLayout.tsx +10 -3
  81. package/src/lib/code-group-icons.test.ts +78 -0
  82. package/src/lib/code-group-icons.ts +148 -0
  83. package/src/lib/image-utils.test.ts +19 -0
  84. package/src/lib/image-utils.ts +11 -0
  85. package/src/lib/markdown.test.ts +195 -14
  86. package/src/lib/markdown.ts +928 -254
  87. package/src/lib/normalize-vuepress-math.ts +118 -0
  88. package/src/lib/rehype-fence-meta.ts +22 -0
  89. package/src/lib/rehype-image-metadata.ts +2 -2
  90. package/src/lib/remark-book-chapter-links.ts +106 -0
  91. package/src/lib/remark-code-group.ts +54 -0
  92. package/src/lib/remark-github-alerts.test.ts +83 -0
  93. package/src/lib/remark-github-alerts.ts +65 -0
  94. package/src/lib/remark-vuepress-containers.ts +130 -0
  95. package/src/lib/rst-renderer.test.ts +355 -0
  96. package/src/lib/rst-renderer.ts +629 -0
  97. package/src/lib/rst.test.ts +350 -0
  98. package/src/lib/rst.ts +674 -0
  99. package/src/lib/series-redirects.ts +42 -0
  100. package/src/lib/shiki-rst.ts +185 -0
  101. package/src/lib/shiki.test.ts +153 -0
  102. package/src/lib/shiki.ts +292 -0
  103. package/src/lib/urls.ts +57 -0
  104. package/src/test-utils/render.ts +23 -0
  105. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  106. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  107. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  108. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  109. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  110. package/tests/helpers/env.ts +19 -0
  111. package/tests/integration/book-chapter-links.test.ts +107 -0
  112. package/tests/integration/books-nested-toc.test.ts +176 -0
  113. package/tests/integration/books.test.ts +3 -2
  114. package/tests/integration/code-block-features.test.ts +188 -0
  115. package/tests/integration/code-group.test.ts +183 -0
  116. package/tests/integration/code-notation.test.ts +97 -0
  117. package/tests/integration/feed-utils.test.ts +13 -0
  118. package/tests/integration/github-alerts.test.ts +82 -0
  119. package/tests/integration/markdown-external-links.test.ts +103 -0
  120. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  121. package/tests/integration/reading-time-headings.test.ts +12 -14
  122. package/tests/integration/series-draft.test.ts +12 -5
  123. package/tests/integration/series.test.ts +93 -0
  124. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  125. package/tests/integration/vuepress-containers.test.ts +107 -0
  126. package/tests/tooling/build-pagefind.test.ts +66 -0
  127. package/tests/tooling/new-post.test.ts +1 -1
  128. package/tests/unit/static-params.test.ts +166 -13
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+
5
+ const [command, ...args] = Bun.argv.slice(2);
6
+
7
+ if (!command) {
8
+ console.error('Missing command to run.');
9
+ process.exit(1);
10
+ }
11
+
12
+ const env = { ...process.env };
13
+ if (!env.AMYTIS_RST_PYTHON) {
14
+ const localPython = path.join(
15
+ process.cwd(),
16
+ '.venv-rst',
17
+ process.platform === 'win32' ? 'Scripts' : 'bin',
18
+ process.platform === 'win32' ? 'python.exe' : 'python',
19
+ );
20
+ if (fs.existsSync(localPython)) {
21
+ env.AMYTIS_RST_PYTHON = localPython;
22
+ }
23
+ }
24
+
25
+ const child = spawn(command, args, {
26
+ stdio: 'inherit',
27
+ env,
28
+ shell: process.platform === 'win32',
29
+ });
30
+
31
+ child.on('exit', (code, signal) => {
32
+ if (signal) {
33
+ process.kill(process.pid, signal);
34
+ return;
35
+ }
36
+ process.exit(code ?? 1);
37
+ });
38
+
39
+ child.on('error', (error) => {
40
+ console.error(error.message);
41
+ process.exit(1);
42
+ });
@@ -0,0 +1,499 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import matter from 'gray-matter';
4
+ import { parse as acornParse } from 'acorn';
5
+ import type * as acorn from 'acorn';
6
+
7
+ // Usage:
8
+ // bun run sync-vuepress-book --source <vuepress-docs-dir> --dest <amytis-book-dir>
9
+ // bun run sync-vuepress-book <source> <dest> (positional shorthand)
10
+ //
11
+ // Walks a VuePress 2 project's `.vuepress/config.{js,mjs,ts}`, extracts the
12
+ // sidebar literal via AST parsing, converts it to the nested {section, items}
13
+ // TOC format Amytis books support natively, copies the source markdown +
14
+ // asset tree into the destination, and rewrites the dest's index.mdx with
15
+ // the new TOC (preserving user-controlled frontmatter fields).
16
+ //
17
+ // Re-runnable: any subsequent run mirrors the current state of the source.
18
+
19
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
20
+
21
+ interface CliArgs {
22
+ source: string;
23
+ dest: string;
24
+ }
25
+
26
+ function parseArgs(argv: string[]): CliArgs {
27
+ const positional: string[] = [];
28
+ let source: string | undefined;
29
+ let dest: string | undefined;
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === '--source') { source = argv[++i]; continue; }
33
+ if (a === '--dest') { dest = argv[++i]; continue; }
34
+ if (a.startsWith('--source=')) { source = a.slice('--source='.length); continue; }
35
+ if (a.startsWith('--dest=')) { dest = a.slice('--dest='.length); continue; }
36
+ if (a === '--help' || a === '-h') {
37
+ printUsageAndExit(0);
38
+ }
39
+ positional.push(a);
40
+ }
41
+ if (!source && positional[0]) source = positional[0];
42
+ if (!dest && positional[1]) dest = positional[1];
43
+ if (!source || !dest) printUsageAndExit(1);
44
+ return {
45
+ source: path.resolve(source!),
46
+ dest: path.resolve(dest!),
47
+ };
48
+ }
49
+
50
+ function printUsageAndExit(code: number): never {
51
+ console.error(
52
+ 'Usage: bun run sync-vuepress-book --source <vuepress-docs-dir> --dest <amytis-book-dir>\n' +
53
+ '\n' +
54
+ 'Examples:\n' +
55
+ ' bun run sync-vuepress-book --source /path/to/dmla/docs --dest content/books/dmla\n' +
56
+ ' bun run sync-vuepress-book /path/to/dmla/docs content/books/dmla'
57
+ );
58
+ process.exit(code);
59
+ }
60
+
61
+ // ─── VuePress sidebar extraction ─────────────────────────────────────────────
62
+
63
+ // JS/ESM only — acorn 8.x has no TypeScript support, so a `config.ts` is
64
+ // rejected with a helpful error rather than producing a parse failure deep
65
+ // in `extractSidebar`. Users with a `.ts` config can compile to `.js` and
66
+ // place the result next to the original, or rename to `.mjs`.
67
+ const CONFIG_CANDIDATES = ['config.mjs', 'config.js'];
68
+ const UNSUPPORTED_CONFIG_CANDIDATES = ['config.ts'];
69
+
70
+ function findVuepressConfig(sourceDir: string): string {
71
+ const dir = path.join(sourceDir, '.vuepress');
72
+ if (!fs.existsSync(dir)) {
73
+ throw new Error(`[amytis] VuePress config dir not found at ${dir}. Expected the source to be a VuePress \`docs/\` folder.`);
74
+ }
75
+ for (const name of CONFIG_CANDIDATES) {
76
+ const p = path.join(dir, name);
77
+ if (fs.existsSync(p)) return p;
78
+ }
79
+ for (const name of UNSUPPORTED_CONFIG_CANDIDATES) {
80
+ const p = path.join(dir, name);
81
+ if (fs.existsSync(p)) {
82
+ throw new Error(
83
+ `[amytis] Found ${name} at ${p}, but the importer parses configs with ` +
84
+ `acorn (JS-only). Compile to JavaScript first (\`tsc\` or \`bun build --no-bundle\`) ` +
85
+ `and place the result alongside the original, or rename to .mjs if it's pure ESM.`
86
+ );
87
+ }
88
+ }
89
+ throw new Error(`[amytis] No VuePress config found in ${dir} (looked for ${CONFIG_CANDIDATES.join(', ')}).`);
90
+ }
91
+
92
+ // JSON-like value reconstructed from the AST.
93
+ type SidebarItem =
94
+ | { text?: string; link?: string; children?: SidebarItem[]; collapsible?: boolean; [k: string]: unknown }
95
+ | string;
96
+
97
+ /**
98
+ * Recursively converts a JS literal AST node into a plain JS value. Supports
99
+ * string / numeric / boolean / null literals, arrays, and plain object
100
+ * expressions with string-keyed string-or-shorthand properties. Throws on
101
+ * anything else — better to fail loudly than to silently drop config fields.
102
+ */
103
+ function literalNodeToValue(node: acorn.AnyNode): unknown {
104
+ if (node.type === 'Literal') return (node as acorn.Literal).value;
105
+ if (node.type === 'ArrayExpression') {
106
+ return (node as acorn.ArrayExpression).elements.map(el => {
107
+ if (el === null) return null;
108
+ if (el.type === 'SpreadElement') {
109
+ throw new Error('[amytis] Unsupported `...spread` in sidebar literal');
110
+ }
111
+ return literalNodeToValue(el);
112
+ });
113
+ }
114
+ if (node.type === 'ObjectExpression') {
115
+ const out: Record<string, unknown> = {};
116
+ for (const prop of (node as acorn.ObjectExpression).properties) {
117
+ if (prop.type !== 'Property') {
118
+ throw new Error(`[amytis] Unsupported property type "${prop.type}" in sidebar literal`);
119
+ }
120
+ let key: string;
121
+ if (prop.key.type === 'Identifier') {
122
+ key = (prop.key as acorn.Identifier).name;
123
+ } else if (prop.key.type === 'Literal' && typeof (prop.key as acorn.Literal).value === 'string') {
124
+ key = (prop.key as acorn.Literal).value as string;
125
+ } else {
126
+ throw new Error(`[amytis] Unsupported key node "${prop.key.type}" in sidebar literal`);
127
+ }
128
+ out[key] = literalNodeToValue(prop.value);
129
+ }
130
+ return out;
131
+ }
132
+ if (node.type === 'TemplateLiteral') {
133
+ const tpl = node as acorn.TemplateLiteral;
134
+ if (tpl.expressions.length > 0) {
135
+ throw new Error('[amytis] Template literals with `${...}` interpolation are not supported in sidebar values');
136
+ }
137
+ return tpl.quasis.map(q => q.value.cooked ?? '').join('');
138
+ }
139
+ if (node.type === 'UnaryExpression') {
140
+ const un = node as acorn.UnaryExpression;
141
+ if (un.operator === '-' || un.operator === '+' || un.operator === '!') {
142
+ const inner = literalNodeToValue(un.argument);
143
+ switch (un.operator) {
144
+ case '-': return -(inner as number);
145
+ case '+': return +(inner as number);
146
+ case '!': return !inner;
147
+ }
148
+ }
149
+ }
150
+ throw new Error(`[amytis] Unsupported AST node "${node.type}" while reading sidebar literal`);
151
+ }
152
+
153
+ /**
154
+ * Walks the parsed AST looking for the `sidebar:` property anywhere in the
155
+ * file (it's typically inside a `dmlaTheme({...})` call argument in dmla; the
156
+ * exact wrapper varies by theme so we don't rely on its name). Returns the
157
+ * first array-valued match.
158
+ */
159
+ function extractSidebarFromAst(ast: acorn.Program): SidebarItem[] {
160
+ let found: acorn.AnyNode | undefined;
161
+ const visit = (n: unknown) => {
162
+ if (found || !n || typeof n !== 'object') return;
163
+ const node = n as Record<string, unknown> & { type?: string };
164
+ if (
165
+ node.type === 'Property' &&
166
+ ((node.key as { type?: string; name?: string; value?: string })?.name === 'sidebar' ||
167
+ (node.key as { type?: string; name?: string; value?: string })?.value === 'sidebar') &&
168
+ (node.value as { type?: string })?.type === 'ArrayExpression'
169
+ ) {
170
+ found = node.value as acorn.AnyNode;
171
+ return;
172
+ }
173
+ for (const key of Object.keys(node)) {
174
+ if (key === 'loc' || key === 'range' || key === 'start' || key === 'end' || key === 'parent') continue;
175
+ const v = node[key];
176
+ if (Array.isArray(v)) {
177
+ for (const item of v) visit(item);
178
+ } else if (v && typeof v === 'object') {
179
+ visit(v);
180
+ }
181
+ }
182
+ };
183
+ visit(ast);
184
+ if (!found) {
185
+ throw new Error('[amytis] Could not locate a `sidebar: [...]` property in the VuePress config');
186
+ }
187
+ return literalNodeToValue(found) as SidebarItem[];
188
+ }
189
+
190
+ function extractSidebar(configPath: string): SidebarItem[] {
191
+ const source = fs.readFileSync(configPath, 'utf8');
192
+ // sourceType: module since VuePress configs use ESM `import`.
193
+ const ast = acornParse(source, {
194
+ ecmaVersion: 'latest',
195
+ sourceType: 'module',
196
+ allowReturnOutsideFunction: false,
197
+ locations: false,
198
+ });
199
+ return extractSidebarFromAst(ast as acorn.Program);
200
+ }
201
+
202
+ // ─── Sidebar → Amytis TOC ────────────────────────────────────────────────────
203
+
204
+ type ChapterRef = { title: string; id: string };
205
+ type Section = { section: string; collapsible?: boolean; items: Array<Section | ChapterRef> };
206
+ type TocItem = Section | ChapterRef;
207
+
208
+ function normalizeLink(link: string): string {
209
+ // VuePress sidebar links may use any of: leading slash, no slash, trailing
210
+ // slash (folder-index style like `/guide/`), or an explicit `.md`/`.mdx`
211
+ // suffix. The canonical Amytis chapter id has none of those.
212
+ let s: string;
213
+ try {
214
+ s = decodeURIComponent(link.trim());
215
+ } catch {
216
+ s = link.trim();
217
+ }
218
+ if (s.startsWith('/')) s = s.slice(1);
219
+ if (s.endsWith('/')) s = s.replace(/\/+$/, '');
220
+ if (s.endsWith('.md')) s = s.slice(0, -3);
221
+ if (s.endsWith('.mdx')) s = s.slice(0, -4);
222
+ return s;
223
+ }
224
+
225
+ function isChapterLeaf(item: Record<string, unknown>): item is { text: string; link: string } {
226
+ return typeof item.link === 'string' && !item.children;
227
+ }
228
+
229
+ function isSectionGroup(item: Record<string, unknown>): item is { text: string; children: SidebarItem[]; collapsible?: boolean } {
230
+ return Array.isArray(item.children);
231
+ }
232
+
233
+ // Sidebar leaves whose normalized id matches one of these are dropped from the
234
+ // generated TOC. They're VuePress conventions for a hand-written table-of-
235
+ // contents page that duplicates what Amytis's book landing page already renders.
236
+ const SKIPPED_LEAF_IDS = new Set(['contents']);
237
+
238
+ interface ConvertWarnings {
239
+ emptySections: string[]; // sections with no items
240
+ sectionWithOwnLink: string[]; // ignored own-page link on a group header
241
+ unsupported: string[]; // strings or other forms we skip
242
+ skippedMetaLeaves: string[]; // leaves dropped because their id is a known meta-nav slug
243
+ }
244
+
245
+ function convertSidebar(sidebar: SidebarItem[], warnings: ConvertWarnings): TocItem[] {
246
+ const result: TocItem[] = [];
247
+ for (const raw of sidebar) {
248
+ if (typeof raw === 'string') {
249
+ warnings.unsupported.push(raw);
250
+ continue;
251
+ }
252
+ const item = raw as Record<string, unknown>;
253
+ const text = typeof item.text === 'string' ? item.text : undefined;
254
+ if (!text) {
255
+ warnings.unsupported.push(JSON.stringify(item));
256
+ continue;
257
+ }
258
+
259
+ if (isSectionGroup(item)) {
260
+ if (typeof (item as { link?: unknown }).link === 'string') warnings.sectionWithOwnLink.push(text);
261
+ const subItems = convertSidebar(item.children as SidebarItem[], warnings);
262
+ const section: Section = {
263
+ section: text,
264
+ items: subItems,
265
+ };
266
+ if (typeof item.collapsible === 'boolean') section.collapsible = item.collapsible;
267
+ if (subItems.length === 0) warnings.emptySections.push(text);
268
+ result.push(section);
269
+ continue;
270
+ }
271
+
272
+ if (isChapterLeaf(item)) {
273
+ const id = normalizeLink(item.link);
274
+ if (SKIPPED_LEAF_IDS.has(id)) {
275
+ warnings.skippedMetaLeaves.push(`${text} (${id})`);
276
+ continue;
277
+ }
278
+ result.push({ title: text, id });
279
+ continue;
280
+ }
281
+
282
+ // {text, no link, no children} — a section header that's a placeholder.
283
+ warnings.emptySections.push(text);
284
+ result.push({ section: text, items: [] });
285
+ }
286
+ return result;
287
+ }
288
+
289
+ // ─── Leaf validation ─────────────────────────────────────────────────────────
290
+
291
+ function collectChapterIds(toc: TocItem[], out: ChapterRef[] = []): ChapterRef[] {
292
+ for (const item of toc) {
293
+ if ('section' in item) collectChapterIds(item.items, out);
294
+ else out.push(item);
295
+ }
296
+ return out;
297
+ }
298
+
299
+ function resolveSourceFile(sourceDir: string, chapterId: string): string | null {
300
+ // VuePress folder-index conventions: `/guide/` resolves to `guide/README.md`
301
+ // or `guide/index.md` inside the docs tree. Earlier candidates win.
302
+ const candidates = chapterId === ''
303
+ ? ['README.md', 'README.mdx', 'index.md', 'index.mdx']
304
+ : [
305
+ `${chapterId}.md`,
306
+ `${chapterId}.mdx`,
307
+ `${chapterId}/README.md`,
308
+ `${chapterId}/README.mdx`,
309
+ `${chapterId}/index.md`,
310
+ `${chapterId}/index.mdx`,
311
+ ];
312
+ for (const rel of candidates) {
313
+ const p = path.join(sourceDir, rel);
314
+ if (fs.existsSync(p)) return p;
315
+ }
316
+ return null;
317
+ }
318
+
319
+ // ─── Rsync ───────────────────────────────────────────────────────────────────
320
+
321
+ const COPY_SKIP = new Set(['.vuepress', 'node_modules', '.git', '.DS_Store']);
322
+
323
+ /**
324
+ * Files in the dest that are NOT in the source must NOT be pruned by the
325
+ * mirror: index.mdx is generated by writeIndexMdx (its frontmatter is the
326
+ * sync output), and dotfiles are by convention out-of-band overlay state
327
+ * (`.gitkeep`, OS metadata, editor scratch files) that the importer never
328
+ * created and shouldn't touch.
329
+ */
330
+ function isDestManagedByImporter(relPath: string): boolean {
331
+ if (relPath === 'index.mdx') return false;
332
+ if (relPath.split(path.sep).some(part => part.startsWith('.'))) return false;
333
+ return true;
334
+ }
335
+
336
+ /**
337
+ * Mirror the source tree into the dest: copy every non-excluded file from
338
+ * source, then prune any importer-managed file under dest that doesn't
339
+ * exist in source. The "mirror" semantics matter on re-runs after an
340
+ * upstream rename or deletion — without the prune, stale content lingers
341
+ * in the dest and stays reachable.
342
+ */
343
+ function syncTree(srcDir: string, destDir: string): { files: number; assets: number } {
344
+ let files = 0;
345
+ let assets = 0;
346
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
347
+
348
+ const sourceRelPaths = new Set<string>();
349
+
350
+ const walkSource = (src: string, dest: string, relBase: string) => {
351
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
352
+ if (COPY_SKIP.has(entry.name)) continue;
353
+ if (entry.name.startsWith('.')) continue;
354
+ const sPath = path.join(src, entry.name);
355
+ const dPath = path.join(dest, entry.name);
356
+ const relPath = relBase ? path.join(relBase, entry.name) : entry.name;
357
+ sourceRelPaths.add(relPath);
358
+ if (entry.isDirectory()) {
359
+ if (!fs.existsSync(dPath)) fs.mkdirSync(dPath, { recursive: true });
360
+ walkSource(sPath, dPath, relPath);
361
+ } else if (entry.isFile()) {
362
+ fs.copyFileSync(sPath, dPath);
363
+ if (/\.mdx?$/i.test(entry.name)) files += 1;
364
+ else assets += 1;
365
+ }
366
+ }
367
+ };
368
+ walkSource(srcDir, destDir, '');
369
+
370
+ // Prune importer-managed dest paths not present in the source set.
371
+ // Depth-first so empty directories left after pruning their contents get
372
+ // removed in the same pass.
373
+ const prune = (dir: string, relBase: string): boolean => {
374
+ let stillHasContent = false;
375
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
376
+ const dPath = path.join(dir, entry.name);
377
+ const relPath = relBase ? path.join(relBase, entry.name) : entry.name;
378
+ if (!isDestManagedByImporter(relPath)) {
379
+ stillHasContent = true;
380
+ continue;
381
+ }
382
+ if (entry.isDirectory()) {
383
+ const childKept = prune(dPath, relPath);
384
+ if (!sourceRelPaths.has(relPath) && !childKept) {
385
+ fs.rmdirSync(dPath);
386
+ } else {
387
+ stillHasContent = stillHasContent || childKept || sourceRelPaths.has(relPath);
388
+ }
389
+ } else {
390
+ if (sourceRelPaths.has(relPath)) {
391
+ stillHasContent = true;
392
+ } else {
393
+ fs.unlinkSync(dPath);
394
+ }
395
+ }
396
+ }
397
+ return stillHasContent;
398
+ };
399
+ prune(destDir, '');
400
+
401
+ return { files, assets };
402
+ }
403
+
404
+ // ─── index.mdx writing ───────────────────────────────────────────────────────
405
+
406
+ interface BookFrontmatter {
407
+ title?: string;
408
+ excerpt?: string;
409
+ date?: string;
410
+ coverImage?: string;
411
+ featured?: boolean;
412
+ draft?: boolean;
413
+ authors?: string[];
414
+ latex?: boolean;
415
+ chapters?: unknown;
416
+ [k: string]: unknown;
417
+ }
418
+
419
+ function loadVuepressTitle(configPath: string): string | undefined {
420
+ const source = fs.readFileSync(configPath, 'utf8');
421
+ // Cheap scan — the title is a top-level string property. AST round-trip is
422
+ // overkill here.
423
+ const m = source.match(/\btitle\s*:\s*['"]([^'"]+)['"]/);
424
+ return m ? m[1] : undefined;
425
+ }
426
+
427
+ function writeIndexMdx(destDir: string, configPath: string, toc: TocItem[]): void {
428
+ const indexPath = path.join(destDir, 'index.mdx');
429
+ let existing: { data: BookFrontmatter; content: string } = { data: {}, content: '' };
430
+ if (fs.existsSync(indexPath)) {
431
+ const raw = fs.readFileSync(indexPath, 'utf8');
432
+ const parsed = matter(raw);
433
+ existing = { data: parsed.data as BookFrontmatter, content: parsed.content };
434
+ }
435
+
436
+ const data: BookFrontmatter = { ...existing.data };
437
+ if (!data.title) data.title = loadVuepressTitle(configPath) ?? path.basename(destDir);
438
+ if (!data.date) data.date = new Date().toISOString().split('T')[0];
439
+ if (data.draft === undefined) data.draft = false;
440
+ if (data.featured === undefined) data.featured = false;
441
+ data.chapters = toc;
442
+
443
+ const body = existing.content.trim().length > 0
444
+ ? existing.content
445
+ : `\nImported from VuePress source at ${path.relative(process.cwd(), path.dirname(path.dirname(configPath)))}.\n`;
446
+
447
+ fs.writeFileSync(indexPath, matter.stringify(body, data));
448
+ }
449
+
450
+ // ─── Main ────────────────────────────────────────────────────────────────────
451
+
452
+ function main() {
453
+ const { source, dest } = parseArgs(process.argv.slice(2));
454
+
455
+ if (!fs.existsSync(source)) {
456
+ throw new Error(`[amytis] Source directory does not exist: ${source}`);
457
+ }
458
+
459
+ const configPath = findVuepressConfig(source);
460
+ console.log(`[sync-vuepress-book] Reading sidebar from ${path.relative(process.cwd(), configPath)}`);
461
+
462
+ const sidebar = extractSidebar(configPath);
463
+ const warnings: ConvertWarnings = { emptySections: [], sectionWithOwnLink: [], unsupported: [], skippedMetaLeaves: [] };
464
+ const toc = convertSidebar(sidebar, warnings);
465
+
466
+ const chapters = collectChapterIds(toc);
467
+ const missing: string[] = [];
468
+ for (const ch of chapters) {
469
+ if (!resolveSourceFile(source, ch.id)) missing.push(ch.id);
470
+ }
471
+ if (missing.length > 0) {
472
+ throw new Error(
473
+ `[amytis] ${missing.length} sidebar leaf chapter${missing.length === 1 ? '' : 's'} ` +
474
+ `point to source files that do not exist:\n ${missing.map(m => `${m}.md`).join('\n ')}\n` +
475
+ `Fix the sidebar in ${path.relative(process.cwd(), configPath)} or write the missing files before syncing.`
476
+ );
477
+ }
478
+
479
+ console.log(`[sync-vuepress-book] Copying ${path.relative(process.cwd(), source)} → ${path.relative(process.cwd(), dest)}`);
480
+ const { files, assets } = syncTree(source, dest);
481
+
482
+ writeIndexMdx(dest, configPath, toc);
483
+
484
+ console.log(`[sync-vuepress-book] Done. ${files} markdown files, ${assets} asset files copied, ${chapters.length} chapters mapped.`);
485
+ if (warnings.emptySections.length > 0) {
486
+ console.warn(`[sync-vuepress-book] Empty sections (no items): ${warnings.emptySections.join(', ')}`);
487
+ }
488
+ if (warnings.sectionWithOwnLink.length > 0) {
489
+ console.warn(`[sync-vuepress-book] Sections with an own-page link were treated as pure groups; the link was dropped: ${warnings.sectionWithOwnLink.join(', ')}`);
490
+ }
491
+ if (warnings.unsupported.length > 0) {
492
+ console.warn(`[sync-vuepress-book] Skipped unsupported sidebar entries: ${warnings.unsupported.join(', ')}`);
493
+ }
494
+ if (warnings.skippedMetaLeaves.length > 0) {
495
+ console.log(`[sync-vuepress-book] Dropped meta-nav leaves from TOC (Amytis already renders one): ${warnings.skippedMetaLeaves.join(', ')}`);
496
+ }
497
+ }
498
+
499
+ main();
@@ -67,17 +67,26 @@ export async function generateStaticParams() {
67
67
  }
68
68
  }
69
69
 
70
- // Work around Next dev static-param checks for percent-encoded Unicode postSlugs
71
- // under `output: "export"` — dev server may receive percent-encoded forms of Unicode paths.
70
+ // Work around Next dev static-param checks for percent-encoded Unicode slugs
71
+ // under `output: "export"` — dev server may receive encoded forms of either segment.
72
72
  // Include encoded variants in development only; production export keeps raw segment values.
73
73
  if (process.env.NODE_ENV !== 'production') {
74
74
  const existing = new Set(params.map(p => `${p.slug}/${p.postSlug}`));
75
75
  for (const p of [...params]) {
76
+ const encodedSlug = encodeURIComponent(p.slug);
76
77
  const encodedPostSlug = encodeURIComponent(p.postSlug);
77
- const key = `${p.slug}/${encodedPostSlug}`;
78
- if (!existing.has(key)) {
79
- existing.add(key);
80
- params.push({ slug: p.slug, postSlug: encodedPostSlug });
78
+ const variants = [
79
+ { slug: p.slug, postSlug: encodedPostSlug },
80
+ { slug: encodedSlug, postSlug: p.postSlug },
81
+ { slug: encodedSlug, postSlug: encodedPostSlug },
82
+ ];
83
+
84
+ for (const variant of variants) {
85
+ const key = `${variant.slug}/${variant.postSlug}`;
86
+ if (!existing.has(key)) {
87
+ existing.add(key);
88
+ params.push(variant);
89
+ }
81
90
  }
82
91
  }
83
92
  }
@@ -154,7 +163,8 @@ export default async function PrefixPostPage({
154
163
  params: Promise<{ slug: string; postSlug: string }>;
155
164
  }) {
156
165
  const { slug: prefix, postSlug: rawPostSlug } = await params;
157
- const currentPath = `/${safeDecodeParam(prefix)}/${safeDecodeParam(rawPostSlug)}`;
166
+ const decodedPrefix = safeDecodeParam(prefix);
167
+ const currentPath = `/${decodedPrefix}/${safeDecodeParam(rawPostSlug)}`;
158
168
 
159
169
  // Resolve the post: first by slug, then fall back to redirectFrom lookup for renamed slugs.
160
170
  const post =
@@ -168,9 +178,9 @@ export default async function PrefixPostPage({
168
178
  // or a legacy redirectFrom path declared on the resolved post.
169
179
  const basePath = getPostsBasePath();
170
180
  const customPaths = getSeriesCustomPaths();
171
- const isValidBasePath = prefix === basePath && basePath !== 'posts';
172
- const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === prefix)?.[0];
173
- const isAutoSeriesPath = getSeriesAutoPaths() && !Object.hasOwn(customPaths, prefix) && getSeriesData(prefix) !== null;
181
+ const isValidBasePath = decodedPrefix === basePath && basePath !== 'posts';
182
+ const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === decodedPrefix)?.[0];
183
+ const isAutoSeriesPath = getSeriesAutoPaths() && !Object.hasOwn(customPaths, decodedPrefix) && getSeriesData(decodedPrefix) !== null;
174
184
  const isLegacyRedirect = post.redirectFrom?.includes(currentPath) ?? false;
175
185
 
176
186
  if (!isValidBasePath && !matchedSeriesSlug && !isAutoSeriesPath && !isLegacyRedirect) {
@@ -57,6 +57,21 @@ export async function generateStaticParams() {
57
57
  }
58
58
  }
59
59
 
60
+ // Work around Next dev static-param checks for percent-encoded Unicode slugs
61
+ // under `output: "export"` — dev server may receive encoded forms of the
62
+ // prefix segment for paginated listings.
63
+ if (process.env.NODE_ENV !== 'production') {
64
+ const existing = new Set(params.map(p => `${p.slug}/${p.page}`));
65
+ for (const p of [...params]) {
66
+ const encodedSlug = encodeURIComponent(p.slug);
67
+ const key = `${encodedSlug}/${p.page}`;
68
+ if (!existing.has(key)) {
69
+ existing.add(key);
70
+ params.push({ slug: encodedSlug, page: p.page });
71
+ }
72
+ }
73
+ }
74
+
60
75
  // Placeholder keeps Next.js happy with output: export when no custom paths configured.
61
76
  // dynamicParams = false ensures any unrecognised slug/page combo returns 404.
62
77
  return params.length > 0 ? params : [{ slug: '_', page: '2' }];