@hutusi/amytis 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CLAUDE.md +90 -219
  3. package/bun.lock +185 -547
  4. package/content/books/sample-book/index.mdx +3 -0
  5. package/content/posts/code-block-features-showcase.mdx +223 -0
  6. package/docs/ALERTS.md +112 -0
  7. package/docs/ARCHITECTURE.md +217 -5
  8. package/docs/CODE-BLOCKS.md +238 -0
  9. package/docs/CONTRIBUTING.md +25 -0
  10. package/docs/guides/README.md +11 -0
  11. package/docs/guides/importing-vuepress-books.md +178 -0
  12. package/eslint.config.mjs +18 -6
  13. package/package.json +42 -20
  14. package/scripts/generate-code-group-icons.ts +79 -0
  15. package/scripts/render-rst.py +207 -3
  16. package/scripts/sync-vuepress-book.ts +499 -0
  17. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  18. package/src/app/books/[slug]/page.tsx +67 -32
  19. package/src/app/globals.css +503 -123
  20. package/src/app/page.tsx +1 -1
  21. package/src/app/sitemap.ts +3 -3
  22. package/src/components/ArticleCopyCleaner.tsx +64 -0
  23. package/src/components/BookMobileNav.tsx +44 -50
  24. package/src/components/BookSidebar.tsx +0 -0
  25. package/src/components/CodeBlock.test.tsx +93 -8
  26. package/src/components/CodeBlock.tsx +39 -101
  27. package/src/components/CodeBlockToolbar.tsx +88 -0
  28. package/src/components/CodeGroup.tsx +81 -0
  29. package/src/components/CoverImage.tsx +1 -0
  30. package/src/components/ExternalLinkIcon.tsx +15 -0
  31. package/src/components/FeaturedStoriesSection.tsx +3 -3
  32. package/src/components/GithubAlert.tsx +97 -0
  33. package/src/components/MarkdownRenderer.test.tsx +14 -4
  34. package/src/components/MarkdownRenderer.tsx +144 -23
  35. package/src/components/Mermaid.tsx +32 -1
  36. package/src/components/PostList.tsx +1 -1
  37. package/src/components/PostNavigation.tsx +13 -2
  38. package/src/components/PostSidebar.tsx +13 -2
  39. package/src/components/RstRenderer.test.tsx +15 -15
  40. package/src/components/RstRenderer.tsx +37 -2
  41. package/src/components/Search.tsx +18 -4
  42. package/src/components/SeriesCatalog.tsx +1 -1
  43. package/src/components/ShareBar.tsx +5 -0
  44. package/src/components/TocPanel.tsx +10 -2
  45. package/src/i18n/translations.ts +2 -0
  46. package/src/layouts/BookLayout.tsx +35 -4
  47. package/src/layouts/PostLayout.tsx +5 -1
  48. package/src/lib/code-group-icons.test.ts +78 -0
  49. package/src/lib/code-group-icons.ts +148 -0
  50. package/src/lib/markdown.test.ts +56 -13
  51. package/src/lib/markdown.ts +203 -50
  52. package/src/lib/normalize-vuepress-math.ts +118 -0
  53. package/src/lib/rehype-fence-meta.ts +22 -0
  54. package/src/lib/remark-book-chapter-links.ts +106 -0
  55. package/src/lib/remark-code-group.ts +54 -0
  56. package/src/lib/remark-github-alerts.test.ts +83 -0
  57. package/src/lib/remark-github-alerts.ts +65 -0
  58. package/src/lib/remark-vuepress-containers.ts +130 -0
  59. package/src/lib/rst-renderer.ts +19 -7
  60. package/src/lib/rst.test.ts +212 -2
  61. package/src/lib/rst.ts +217 -13
  62. package/src/lib/shiki-rst.ts +185 -0
  63. package/src/lib/shiki.test.ts +153 -0
  64. package/src/lib/shiki.ts +292 -0
  65. package/src/lib/urls.ts +57 -0
  66. package/src/test-utils/render.ts +23 -0
  67. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  68. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  69. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  70. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  71. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  72. package/tests/helpers/env.ts +19 -0
  73. package/tests/integration/book-chapter-links.test.ts +107 -0
  74. package/tests/integration/books-nested-toc.test.ts +176 -0
  75. package/tests/integration/books.test.ts +3 -2
  76. package/tests/integration/code-block-features.test.ts +188 -0
  77. package/tests/integration/code-group.test.ts +183 -0
  78. package/tests/integration/code-notation.test.ts +97 -0
  79. package/tests/integration/github-alerts.test.ts +82 -0
  80. package/tests/integration/markdown-external-links.test.ts +103 -0
  81. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  82. package/tests/integration/reading-time-headings.test.ts +8 -6
  83. package/tests/integration/series-draft.test.ts +6 -13
  84. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  85. package/tests/integration/vuepress-containers.test.ts +107 -0
  86. package/tests/tooling/new-post.test.ts +1 -1
  87. package/tests/unit/static-params.test.ts +32 -19
@@ -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();
@@ -6,11 +6,31 @@ import BookLayout from '@/layouts/BookLayout';
6
6
  import { resolveLocale } from '@/lib/i18n';
7
7
  import { buildBookChapterJsonLd, serializeJsonLd } from '@/lib/json-ld';
8
8
  import { getBookUrl, getBookChapterUrl } from '@/lib/urls';
9
+ import { safeDecodeParam } from '@/lib/series-redirects';
10
+
11
+ /**
12
+ * The chapter route is a catch-all (`[...chapter]`) so that nested chapter ids
13
+ * like `maths/linear/introduction` can be served at `/books/<slug>/maths/linear/introduction`
14
+ * — mapping VuePress-style nested folder paths to URLs 1:1. Single-segment legacy
15
+ * ids continue to work since catch-all matches one-or-more segments.
16
+ */
17
+
18
+ function chapterIdFromParams(rawChapter: string | string[] | undefined): string {
19
+ if (!rawChapter) return '';
20
+ if (Array.isArray(rawChapter)) {
21
+ return rawChapter.map(safeDecodeParam).join('/');
22
+ }
23
+ return safeDecodeParam(rawChapter);
24
+ }
25
+
26
+ function chapterIdToParamSegments(chapterId: string): string[] {
27
+ return chapterId.split('/').filter(Boolean);
28
+ }
9
29
 
10
30
  export async function generateStaticParams() {
11
31
  const books = getAllBooks();
12
- if (books.length === 0) return [{ slug: '_', chapter: '_' }];
13
- const params: { slug: string; chapter: string }[] = [];
32
+ if (books.length === 0) return [{ slug: '_', chapter: ['_'] }];
33
+ const params: { slug: string; chapter: string[] }[] = [];
14
34
 
15
35
  for (const book of books) {
16
36
  for (const ch of book.chapters) {
@@ -19,21 +39,23 @@ export async function generateStaticParams() {
19
39
  // frontmatter) would cause notFound() at render time, which in
20
40
  // output:export dev mode surfaces as a confusing "missing param" 500.
21
41
  if (getBookChapter(book.slug, ch.id) !== null) {
22
- params.push({ slug: book.slug, chapter: ch.id });
42
+ params.push({ slug: book.slug, chapter: chapterIdToParamSegments(ch.id) });
23
43
  }
24
44
  }
25
45
  }
26
46
 
27
47
  // Ensure we never return an empty array with output: export
28
- return params.length > 0 ? params : [{ slug: '_', chapter: '_' }];
48
+ return params.length > 0 ? params : [{ slug: '_', chapter: ['_'] }];
29
49
  }
30
50
 
31
51
  export const dynamicParams = false;
32
52
 
33
- export async function generateMetadata({ params }: { params: Promise<{ slug: string; chapter: string }> }): Promise<Metadata> {
53
+ type ChapterPageParams = Promise<{ slug: string; chapter: string[] }>;
54
+
55
+ export async function generateMetadata({ params }: { params: ChapterPageParams }): Promise<Metadata> {
34
56
  const { slug: rawSlug, chapter: rawChapter } = await params;
35
- const slug = decodeURIComponent(rawSlug);
36
- const chapterSlug = decodeURIComponent(rawChapter);
57
+ const slug = safeDecodeParam(rawSlug);
58
+ const chapterSlug = chapterIdFromParams(rawChapter);
37
59
 
38
60
  const book = getBookData(slug);
39
61
  const chapter = getBookChapter(slug, chapterSlug);
@@ -66,10 +88,10 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
66
88
  };
67
89
  }
68
90
 
69
- export default async function BookChapterPage({ params }: { params: Promise<{ slug: string; chapter: string }> }) {
91
+ export default async function BookChapterPage({ params }: { params: ChapterPageParams }) {
70
92
  const { slug: rawSlug, chapter: rawChapter } = await params;
71
- const slug = decodeURIComponent(rawSlug);
72
- const chapterSlug = decodeURIComponent(rawChapter);
93
+ const slug = safeDecodeParam(rawSlug);
94
+ const chapterSlug = chapterIdFromParams(rawChapter);
73
95
 
74
96
  const book = getBookData(slug);
75
97
  const chapter = getBookChapter(slug, chapterSlug);