@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.
Files changed (120) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +89 -219
  5. package/bun.lock +185 -547
  6. package/content/books/sample-book/index.mdx +3 -0
  7. package/content/posts/code-block-features-showcase.mdx +223 -0
  8. package/docs/ALERTS.md +112 -0
  9. package/docs/ARCHITECTURE.md +298 -5
  10. package/docs/CODE-BLOCKS.md +238 -0
  11. package/docs/CONTRIBUTING.md +25 -0
  12. package/docs/DIGITAL_GARDEN.md +1 -1
  13. package/docs/guides/README.md +11 -0
  14. package/docs/guides/importing-vuepress-books.md +237 -0
  15. package/eslint.config.mjs +18 -6
  16. package/package.json +42 -20
  17. package/scripts/generate-code-group-icons.ts +79 -0
  18. package/scripts/render-rst.py +207 -3
  19. package/scripts/sync-vuepress-book.ts +710 -0
  20. package/site.config.example.ts +3 -3
  21. package/site.config.ts +3 -3
  22. package/src/app/[slug]/layout.tsx +30 -0
  23. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  24. package/src/app/books/[slug]/layout.tsx +24 -0
  25. package/src/app/books/[slug]/page.tsx +85 -34
  26. package/src/app/globals.css +570 -123
  27. package/src/app/page.tsx +7 -1
  28. package/src/app/posts/layout.tsx +20 -0
  29. package/src/app/series/[slug]/page.tsx +33 -9
  30. package/src/app/sitemap.ts +3 -3
  31. package/src/components/ArticleCopyCleaner.tsx +64 -0
  32. package/src/components/BookMobileNav.tsx +44 -50
  33. package/src/components/BookReadingShell.tsx +145 -0
  34. package/src/components/BookSidebar.tsx +0 -0
  35. package/src/components/CodeBlock.test.tsx +93 -8
  36. package/src/components/CodeBlock.tsx +39 -101
  37. package/src/components/CodeBlockToolbar.tsx +88 -0
  38. package/src/components/CodeGroup.tsx +81 -0
  39. package/src/components/CoverImage.tsx +1 -0
  40. package/src/components/CuratedSeriesSection.tsx +28 -10
  41. package/src/components/ExternalLinkIcon.tsx +15 -0
  42. package/src/components/FeaturedStoriesSection.tsx +44 -23
  43. package/src/components/Footer.tsx +1 -1
  44. package/src/components/GithubAlert.tsx +97 -0
  45. package/src/components/ImmersiveReader.tsx +130 -0
  46. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  47. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  48. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  49. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  50. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  51. package/src/components/ImmersiveToggleButton.tsx +45 -0
  52. package/src/components/MarkdownRenderer.test.tsx +14 -4
  53. package/src/components/MarkdownRenderer.tsx +175 -23
  54. package/src/components/Mermaid.tsx +32 -1
  55. package/src/components/Navbar.tsx +3 -1
  56. package/src/components/PostList.tsx +1 -1
  57. package/src/components/PostNavigation.tsx +13 -2
  58. package/src/components/PostReadingShell.tsx +68 -0
  59. package/src/components/PostSidebar.tsx +13 -2
  60. package/src/components/ReadingProgressBar.tsx +1 -1
  61. package/src/components/RstRenderer.test.tsx +15 -15
  62. package/src/components/RstRenderer.tsx +37 -2
  63. package/src/components/Search.tsx +18 -4
  64. package/src/components/SelectedBooksSection.tsx +27 -8
  65. package/src/components/SeriesCatalog.tsx +1 -1
  66. package/src/components/ShareBar.tsx +5 -0
  67. package/src/components/TocPanel.tsx +10 -2
  68. package/src/hooks/useActiveHeading.ts +35 -13
  69. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  70. package/src/i18n/translations.ts +44 -0
  71. package/src/layouts/BookLayout.tsx +62 -74
  72. package/src/layouts/PostLayout.tsx +154 -111
  73. package/src/lib/code-group-icons.test.ts +78 -0
  74. package/src/lib/code-group-icons.ts +148 -0
  75. package/src/lib/immersive-reading-prefs.ts +104 -0
  76. package/src/lib/markdown.test.ts +56 -13
  77. package/src/lib/markdown.ts +217 -57
  78. package/src/lib/normalize-vuepress-math.ts +118 -0
  79. package/src/lib/rehype-fence-meta.ts +22 -0
  80. package/src/lib/remark-book-chapter-links.ts +106 -0
  81. package/src/lib/remark-code-group.ts +54 -0
  82. package/src/lib/remark-github-alerts.test.ts +83 -0
  83. package/src/lib/remark-github-alerts.ts +65 -0
  84. package/src/lib/remark-vuepress-containers.ts +130 -0
  85. package/src/lib/rst-renderer.ts +19 -7
  86. package/src/lib/rst.test.ts +212 -2
  87. package/src/lib/rst.ts +217 -13
  88. package/src/lib/scroll-utils.ts +44 -6
  89. package/src/lib/shiki-rst.ts +185 -0
  90. package/src/lib/shiki.test.ts +153 -0
  91. package/src/lib/shiki.ts +292 -0
  92. package/src/lib/shuffle.ts +15 -1
  93. package/src/lib/sort.ts +15 -0
  94. package/src/lib/urls.ts +62 -0
  95. package/src/test-utils/render.ts +23 -0
  96. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  97. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  98. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  99. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  100. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  101. package/tests/helpers/env.ts +19 -0
  102. package/tests/integration/book-chapter-links.test.ts +107 -0
  103. package/tests/integration/book-index-cta.test.ts +87 -0
  104. package/tests/integration/books-nested-toc.test.ts +176 -0
  105. package/tests/integration/books.test.ts +3 -2
  106. package/tests/integration/code-block-features.test.ts +188 -0
  107. package/tests/integration/code-group.test.ts +183 -0
  108. package/tests/integration/code-notation.test.ts +97 -0
  109. package/tests/integration/github-alerts.test.ts +82 -0
  110. package/tests/integration/markdown-external-links.test.ts +103 -0
  111. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  112. package/tests/integration/reading-time-headings.test.ts +8 -6
  113. package/tests/integration/series-draft.test.ts +6 -13
  114. package/tests/integration/series-index-cta.test.ts +88 -0
  115. package/tests/integration/sync-vuepress-book.test.ts +443 -0
  116. package/tests/integration/vuepress-containers.test.ts +107 -0
  117. package/tests/tooling/new-post.test.ts +1 -1
  118. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  119. package/tests/unit/static-params.test.ts +32 -19
  120. package/vercel.json +7 -0
@@ -0,0 +1,710 @@
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 project's `.vuepress/config.{js,mjs}`, extracts the sidebar
12
+ // literal via AST parsing, converts it to the nested {section, items} TOC
13
+ // format Amytis books support natively, copies the source markdown + asset
14
+ // tree into the destination, and rewrites the dest's index.mdx with the new
15
+ // TOC (preserving user-controlled frontmatter fields).
16
+ //
17
+ // Supports both VuePress 2 (`{ text, link }` / `{ text, children }`) and
18
+ // VuePress 1 (`{ title, path }` / `{ title, children }`, plus bare string
19
+ // child paths and `path + children` group-with-index pages) sidebar shapes.
20
+ //
21
+ // Re-runnable: any subsequent run mirrors the current state of the source.
22
+
23
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
24
+
25
+ interface CliArgs {
26
+ source: string;
27
+ dest: string;
28
+ skipCommon: boolean;
29
+ skipPatterns: string[];
30
+ }
31
+
32
+ // Common build manifests / lockfiles that VuePress books carry at their
33
+ // repo root but which are never book content. Authors who genuinely want
34
+ // these synced into `content/books/<slug>/` can pass `--no-skip-common`.
35
+ const COMMON_SKIP_FILENAMES = [
36
+ 'package.json',
37
+ 'package-lock.json',
38
+ 'yarn.lock',
39
+ 'pnpm-lock.yaml',
40
+ 'bun.lock',
41
+ 'bun.lockb',
42
+ ];
43
+
44
+ function parseArgs(argv: string[]): CliArgs {
45
+ const positional: string[] = [];
46
+ let source: string | undefined;
47
+ let dest: string | undefined;
48
+ let skipCommon = true;
49
+ const skipPatterns: string[] = [];
50
+ const pushSkip = (raw: string) => {
51
+ for (const p of raw.split(',').map(s => s.trim()).filter(Boolean)) {
52
+ skipPatterns.push(p);
53
+ }
54
+ };
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const a = argv[i];
57
+ if (a === '--source') { source = argv[++i]; continue; }
58
+ if (a === '--dest') { dest = argv[++i]; continue; }
59
+ if (a.startsWith('--source=')) { source = a.slice('--source='.length); continue; }
60
+ if (a.startsWith('--dest=')) { dest = a.slice('--dest='.length); continue; }
61
+ if (a === '--skip-common') { skipCommon = true; continue; }
62
+ if (a === '--no-skip-common') { skipCommon = false; continue; }
63
+ if (a === '--skip') { pushSkip(argv[++i] ?? ''); continue; }
64
+ if (a.startsWith('--skip=')) { pushSkip(a.slice('--skip='.length)); continue; }
65
+ if (a === '--help' || a === '-h') {
66
+ printUsageAndExit(0);
67
+ }
68
+ positional.push(a);
69
+ }
70
+ if (!source && positional[0]) source = positional[0];
71
+ if (!dest && positional[1]) dest = positional[1];
72
+ if (!source || !dest) printUsageAndExit(1);
73
+ return {
74
+ source: path.resolve(source!),
75
+ dest: path.resolve(dest!),
76
+ skipCommon,
77
+ skipPatterns,
78
+ };
79
+ }
80
+
81
+ function printUsageAndExit(code: number): never {
82
+ console.error(
83
+ 'Usage: bun run sync-vuepress-book --source <vuepress-docs-dir> --dest <amytis-book-dir>\n' +
84
+ ' [--no-skip-common] [--skip <pattern,pattern,…>]\n' +
85
+ '\n' +
86
+ 'Options:\n' +
87
+ ' --source <dir> VuePress docs root (the parent of `.vuepress/`).\n' +
88
+ ' --dest <dir> Amytis book dir to write to (typically `content/books/<slug>`).\n' +
89
+ ' --skip-common Skip lockfiles + package manifests (default: on).\n' +
90
+ ' Filenames: ' + COMMON_SKIP_FILENAMES.join(', ') + '.\n' +
91
+ ' --no-skip-common Disable the common skip list (copy everything).\n' +
92
+ ' --skip <pat,pat,…> Skip files whose basename matches any of the\n' +
93
+ ' given glob patterns. Repeatable. Applied to\n' +
94
+ ' both files and directories. Examples:\n' +
95
+ ' --skip "*.bak,Dockerfile,build"\n' +
96
+ '\n' +
97
+ 'Examples:\n' +
98
+ ' bun run sync-vuepress-book --source /path/to/dmla/docs --dest content/books/dmla\n' +
99
+ ' bun run sync-vuepress-book /path/to/dmla/docs content/books/dmla --skip "*.bak,dist"'
100
+ );
101
+ process.exit(code);
102
+ }
103
+
104
+ // ─── VuePress sidebar extraction ─────────────────────────────────────────────
105
+
106
+ // JS/ESM only — acorn 8.x has no TypeScript support, so a `config.ts` is
107
+ // rejected with a helpful error rather than producing a parse failure deep
108
+ // in `extractSidebar`. Users with a `.ts` config can compile to `.js` and
109
+ // place the result next to the original, or rename to `.mjs`.
110
+ const CONFIG_CANDIDATES = ['config.mjs', 'config.js'];
111
+ const UNSUPPORTED_CONFIG_CANDIDATES = ['config.ts'];
112
+
113
+ function findVuepressConfig(sourceDir: string): string {
114
+ const dir = path.join(sourceDir, '.vuepress');
115
+ if (!fs.existsSync(dir)) {
116
+ throw new Error(`[amytis] VuePress config dir not found at ${dir}. Expected the source to be a VuePress \`docs/\` folder.`);
117
+ }
118
+ for (const name of CONFIG_CANDIDATES) {
119
+ const p = path.join(dir, name);
120
+ if (fs.existsSync(p)) return p;
121
+ }
122
+ for (const name of UNSUPPORTED_CONFIG_CANDIDATES) {
123
+ const p = path.join(dir, name);
124
+ if (fs.existsSync(p)) {
125
+ throw new Error(
126
+ `[amytis] Found ${name} at ${p}, but the importer parses configs with ` +
127
+ `acorn (JS-only). Compile to JavaScript first (\`tsc\` or \`bun build --no-bundle\`) ` +
128
+ `and place the result alongside the original, or rename to .mjs if it's pure ESM.`
129
+ );
130
+ }
131
+ }
132
+ throw new Error(`[amytis] No VuePress config found in ${dir} (looked for ${CONFIG_CANDIDATES.join(', ')}).`);
133
+ }
134
+
135
+ // JSON-like value reconstructed from the AST.
136
+ type SidebarItem =
137
+ | { text?: string; link?: string; children?: SidebarItem[]; collapsible?: boolean; [k: string]: unknown }
138
+ | string;
139
+
140
+ /**
141
+ * Recursively converts a JS literal AST node into a plain JS value. Supports
142
+ * string / numeric / boolean / null literals, arrays, and plain object
143
+ * expressions with string-keyed string-or-shorthand properties. Throws on
144
+ * anything else — better to fail loudly than to silently drop config fields.
145
+ */
146
+ function literalNodeToValue(node: acorn.AnyNode): unknown {
147
+ if (node.type === 'Literal') return (node as acorn.Literal).value;
148
+ if (node.type === 'ArrayExpression') {
149
+ return (node as acorn.ArrayExpression).elements.map(el => {
150
+ if (el === null) return null;
151
+ if (el.type === 'SpreadElement') {
152
+ throw new Error('[amytis] Unsupported `...spread` in sidebar literal');
153
+ }
154
+ return literalNodeToValue(el);
155
+ });
156
+ }
157
+ if (node.type === 'ObjectExpression') {
158
+ const out: Record<string, unknown> = {};
159
+ for (const prop of (node as acorn.ObjectExpression).properties) {
160
+ if (prop.type !== 'Property') {
161
+ throw new Error(`[amytis] Unsupported property type "${prop.type}" in sidebar literal`);
162
+ }
163
+ let key: string;
164
+ if (prop.key.type === 'Identifier') {
165
+ key = (prop.key as acorn.Identifier).name;
166
+ } else if (prop.key.type === 'Literal' && typeof (prop.key as acorn.Literal).value === 'string') {
167
+ key = (prop.key as acorn.Literal).value as string;
168
+ } else {
169
+ throw new Error(`[amytis] Unsupported key node "${prop.key.type}" in sidebar literal`);
170
+ }
171
+ out[key] = literalNodeToValue(prop.value);
172
+ }
173
+ return out;
174
+ }
175
+ if (node.type === 'TemplateLiteral') {
176
+ const tpl = node as acorn.TemplateLiteral;
177
+ if (tpl.expressions.length > 0) {
178
+ throw new Error('[amytis] Template literals with `${...}` interpolation are not supported in sidebar values');
179
+ }
180
+ return tpl.quasis.map(q => q.value.cooked ?? '').join('');
181
+ }
182
+ if (node.type === 'UnaryExpression') {
183
+ const un = node as acorn.UnaryExpression;
184
+ if (un.operator === '-' || un.operator === '+' || un.operator === '!') {
185
+ const inner = literalNodeToValue(un.argument);
186
+ switch (un.operator) {
187
+ case '-': return -(inner as number);
188
+ case '+': return +(inner as number);
189
+ case '!': return !inner;
190
+ }
191
+ }
192
+ }
193
+ throw new Error(`[amytis] Unsupported AST node "${node.type}" while reading sidebar literal`);
194
+ }
195
+
196
+ /**
197
+ * Walks the parsed AST looking for the `sidebar:` property anywhere in the
198
+ * file (it's typically inside a `dmlaTheme({...})` call argument in dmla; the
199
+ * exact wrapper varies by theme so we don't rely on its name). Returns the
200
+ * first array-valued match.
201
+ */
202
+ function extractSidebarFromAst(ast: acorn.Program): SidebarItem[] {
203
+ let found: acorn.AnyNode | undefined;
204
+ const visit = (n: unknown) => {
205
+ if (found || !n || typeof n !== 'object') return;
206
+ const node = n as Record<string, unknown> & { type?: string };
207
+ if (
208
+ node.type === 'Property' &&
209
+ ((node.key as { type?: string; name?: string; value?: string })?.name === 'sidebar' ||
210
+ (node.key as { type?: string; name?: string; value?: string })?.value === 'sidebar') &&
211
+ (node.value as { type?: string })?.type === 'ArrayExpression'
212
+ ) {
213
+ found = node.value as acorn.AnyNode;
214
+ return;
215
+ }
216
+ for (const key of Object.keys(node)) {
217
+ if (key === 'loc' || key === 'range' || key === 'start' || key === 'end' || key === 'parent') continue;
218
+ const v = node[key];
219
+ if (Array.isArray(v)) {
220
+ for (const item of v) visit(item);
221
+ } else if (v && typeof v === 'object') {
222
+ visit(v);
223
+ }
224
+ }
225
+ };
226
+ visit(ast);
227
+ if (!found) {
228
+ throw new Error('[amytis] Could not locate a `sidebar: [...]` property in the VuePress config');
229
+ }
230
+ return literalNodeToValue(found) as SidebarItem[];
231
+ }
232
+
233
+ function extractSidebar(configPath: string): SidebarItem[] {
234
+ const source = fs.readFileSync(configPath, 'utf8');
235
+ // sourceType: module since VuePress configs use ESM `import`.
236
+ const ast = acornParse(source, {
237
+ ecmaVersion: 'latest',
238
+ sourceType: 'module',
239
+ allowReturnOutsideFunction: false,
240
+ locations: false,
241
+ });
242
+ return extractSidebarFromAst(ast as acorn.Program);
243
+ }
244
+
245
+ // ─── Sidebar → Amytis TOC ────────────────────────────────────────────────────
246
+
247
+ type ChapterRef = { title: string; id: string };
248
+ type Section = { section: string; collapsible?: boolean; items: Array<Section | ChapterRef> };
249
+ type TocItem = Section | ChapterRef;
250
+
251
+ function normalizeLink(link: string): string {
252
+ // VuePress sidebar links may use any of: leading slash, no slash, trailing
253
+ // slash (folder-index style like `/guide/`), or an explicit `.md`/`.mdx`
254
+ // suffix. The canonical Amytis chapter id has none of those — trailing
255
+ // slashes are stripped and `resolveSourceFile` finds the `<id>/README.md`
256
+ // companion via its candidate list.
257
+ let s: string;
258
+ try {
259
+ s = decodeURIComponent(link.trim());
260
+ } catch {
261
+ s = link.trim();
262
+ }
263
+ if (s.startsWith('/')) s = s.slice(1);
264
+ if (s.endsWith('/')) s = s.replace(/\/+$/, '');
265
+ if (s.endsWith('.md')) s = s.slice(0, -3);
266
+ if (s.endsWith('.mdx')) s = s.slice(0, -4);
267
+ return s;
268
+ }
269
+
270
+ // Sidebar leaves whose normalized id matches one of these (case-insensitive)
271
+ // are dropped from the generated TOC. They're VuePress / GitBook conventions
272
+ // for a hand-written table-of-contents page that duplicates what Amytis's
273
+ // book landing page already renders. `SUMMARY` covers GitBook-style
274
+ // `SUMMARY.md` entries common in VP1 imports.
275
+ const SKIPPED_LEAF_IDS = new Set(['contents', 'summary']);
276
+
277
+ function isSkippedMetaLeaf(id: string): boolean {
278
+ const tail = id.split('/').pop() ?? id;
279
+ return SKIPPED_LEAF_IDS.has(tail.toLowerCase());
280
+ }
281
+
282
+ interface ConvertWarnings {
283
+ emptySections: string[]; // sections with no items
284
+ unsupported: string[]; // strings or other forms we skip
285
+ skippedMetaLeaves: string[]; // leaves dropped because their id is a known meta-nav slug
286
+ }
287
+
288
+ /**
289
+ * Common shape for both VP1 and VP2 sidebar entries. Produced by
290
+ * `normalizeRawEntry` so the downstream walker only deals with one schema.
291
+ */
292
+ interface NormalizedEntry {
293
+ title?: string; // missing for bare-string entries
294
+ path?: string; // normalized via normalizeLink()
295
+ children?: SidebarItem[];
296
+ collapsible?: boolean;
297
+ }
298
+
299
+ /**
300
+ * Collapses a VuePress 1.x or 2.x sidebar entry into the common
301
+ * `NormalizedEntry` shape, returning `null` for anything we can't recognize.
302
+ *
303
+ * - VP2 leaf: `{ text, link }` → `{ title, path }`
304
+ * - VP2 section: `{ text, children, collapsible? }`
305
+ * - VP1 leaf: `{ title, path }` (no children)
306
+ * - VP1 section: `{ title, children, collapsable? }`
307
+ * - VP1 indexed: `{ title, path, children }` (README promoted later)
308
+ * - Bare string: `'/foo/bar'` → `{ path }` (title resolved
309
+ * from the source file)
310
+ */
311
+ function normalizeRawEntry(raw: SidebarItem): NormalizedEntry | null {
312
+ if (typeof raw === 'string') {
313
+ return { path: raw };
314
+ }
315
+ if (!raw || typeof raw !== 'object') return null;
316
+ const item = raw as Record<string, unknown>;
317
+
318
+ const title = typeof item.text === 'string'
319
+ ? item.text
320
+ : typeof item.title === 'string'
321
+ ? item.title
322
+ : undefined;
323
+ const path = typeof item.link === 'string'
324
+ ? item.link
325
+ : typeof item.path === 'string'
326
+ ? item.path
327
+ : undefined;
328
+ const children = Array.isArray(item.children) ? (item.children as SidebarItem[]) : undefined;
329
+
330
+ // VP2 uses `collapsible` (boolean); VP1 uses `collapsable` (boolean meaning
331
+ // "user may collapse"). Both map to the same Amytis hint.
332
+ let collapsible: boolean | undefined;
333
+ if (typeof item.collapsible === 'boolean') collapsible = item.collapsible;
334
+ else if (typeof item.collapsable === 'boolean') collapsible = item.collapsable;
335
+
336
+ // Must carry at least a title, a path, or children — otherwise there's
337
+ // nothing to convert. (Bare strings are already handled above.)
338
+ if (!title && !path && !children) return null;
339
+
340
+ return { title, path, children, collapsible };
341
+ }
342
+
343
+ /**
344
+ * Reads a chapter title from a source markdown file. Tries frontmatter
345
+ * `title` first, then the first H1 in the body, then falls back to a
346
+ * titleized slug. Used when the sidebar entry was a bare string (VP1 style)
347
+ * or when we're promoting a section's README as a chapter.
348
+ *
349
+ * Returns `null` if the file can't be read — caller decides the fallback.
350
+ */
351
+ function readTitleFromSource(absPath: string | null): string | null {
352
+ if (!absPath || !fs.existsSync(absPath)) return null;
353
+ try {
354
+ const raw = fs.readFileSync(absPath, 'utf8');
355
+ const parsed = matter(raw);
356
+ const fmTitle = (parsed.data as { title?: unknown }).title;
357
+ if (typeof fmTitle === 'string' && fmTitle.trim()) return fmTitle.trim();
358
+ const h1 = parsed.content.match(/^\s*#\s+(.+?)\s*$/m);
359
+ if (h1) return h1[1].trim();
360
+ } catch {
361
+ return null;
362
+ }
363
+ return null;
364
+ }
365
+
366
+ function titleizeSlug(id: string): string {
367
+ const tail = id.split('/').pop() ?? id;
368
+ return tail
369
+ .split(/[-_]/)
370
+ .filter(Boolean)
371
+ .map(s => s.charAt(0).toUpperCase() + s.slice(1))
372
+ .join(' ') || id;
373
+ }
374
+
375
+ function resolveTitle(
376
+ norm: NormalizedEntry,
377
+ id: string | null,
378
+ sourceDir: string,
379
+ ): string {
380
+ if (norm.title) return norm.title;
381
+ if (id) {
382
+ const src = resolveSourceFile(sourceDir, id);
383
+ const fromFile = readTitleFromSource(src);
384
+ if (fromFile) return fromFile;
385
+ return titleizeSlug(id);
386
+ }
387
+ return '(untitled)';
388
+ }
389
+
390
+ function convertSidebar(
391
+ sidebar: SidebarItem[],
392
+ sourceDir: string,
393
+ warnings: ConvertWarnings,
394
+ ): TocItem[] {
395
+ const result: TocItem[] = [];
396
+ for (const raw of sidebar) {
397
+ const norm = normalizeRawEntry(raw);
398
+ if (!norm) {
399
+ warnings.unsupported.push(typeof raw === 'string' ? raw : JSON.stringify(raw));
400
+ continue;
401
+ }
402
+
403
+ const hasChildren = !!norm.children && norm.children.length > 0;
404
+ const hasPath = typeof norm.path === 'string';
405
+ const pathId = hasPath ? normalizeLink(norm.path!) : null;
406
+
407
+ if (hasChildren) {
408
+ const items: Array<Section | ChapterRef> = [];
409
+ // VP1 sections often carry both a `path` (the section's README index
410
+ // page) and `children` (sub-chapters). Promote the README as the first
411
+ // chapter so its content stays reachable from the sidebar — matches
412
+ // VuePress UX where clicking the section title navigates to its README.
413
+ //
414
+ // Skip `norm.title` for the promoted chapter — it belongs to the
415
+ // section header. Read the chapter's own title from the README's
416
+ // frontmatter / H1 instead so the sidebar doesn't show the section
417
+ // name twice (once as the section, once as its first child).
418
+ if (pathId) {
419
+ if (isSkippedMetaLeaf(pathId)) {
420
+ warnings.skippedMetaLeaves.push(`${norm.title ?? pathId} (${pathId})`);
421
+ } else {
422
+ items.push({
423
+ title: resolveTitle({ ...norm, title: undefined }, pathId, sourceDir),
424
+ id: pathId,
425
+ });
426
+ }
427
+ }
428
+ items.push(...convertSidebar(norm.children!, sourceDir, warnings));
429
+
430
+ const sectionTitle = norm.title ?? '(untitled)';
431
+ const section: Section = { section: sectionTitle, items };
432
+ if (typeof norm.collapsible === 'boolean') section.collapsible = norm.collapsible;
433
+ if (items.length === 0) warnings.emptySections.push(sectionTitle);
434
+ result.push(section);
435
+ continue;
436
+ }
437
+
438
+ if (hasPath && pathId) {
439
+ if (isSkippedMetaLeaf(pathId)) {
440
+ warnings.skippedMetaLeaves.push(`${norm.title ?? pathId} (${pathId})`);
441
+ continue;
442
+ }
443
+ result.push({
444
+ title: resolveTitle(norm, pathId, sourceDir),
445
+ id: pathId,
446
+ });
447
+ continue;
448
+ }
449
+
450
+ // {title, no path, no children} — a section header that's a placeholder.
451
+ const placeholderTitle = norm.title ?? '(untitled)';
452
+ warnings.emptySections.push(placeholderTitle);
453
+ result.push({ section: placeholderTitle, items: [] });
454
+ }
455
+ return result;
456
+ }
457
+
458
+ // ─── Leaf validation ─────────────────────────────────────────────────────────
459
+
460
+ function collectChapterIds(toc: TocItem[], out: ChapterRef[] = []): ChapterRef[] {
461
+ for (const item of toc) {
462
+ if ('section' in item) collectChapterIds(item.items, out);
463
+ else out.push(item);
464
+ }
465
+ return out;
466
+ }
467
+
468
+ function resolveSourceFile(sourceDir: string, chapterId: string): string | null {
469
+ // VuePress folder-index conventions: `/guide/` resolves to `guide/README.md`
470
+ // or `guide/index.md` inside the docs tree. Earlier candidates win.
471
+ const candidates = chapterId === ''
472
+ ? ['README.md', 'README.mdx', 'index.md', 'index.mdx']
473
+ : [
474
+ `${chapterId}.md`,
475
+ `${chapterId}.mdx`,
476
+ `${chapterId}/README.md`,
477
+ `${chapterId}/README.mdx`,
478
+ `${chapterId}/index.md`,
479
+ `${chapterId}/index.mdx`,
480
+ ];
481
+ for (const rel of candidates) {
482
+ const p = path.join(sourceDir, rel);
483
+ if (fs.existsSync(p)) return p;
484
+ }
485
+ return null;
486
+ }
487
+
488
+ // ─── Rsync ───────────────────────────────────────────────────────────────────
489
+
490
+ const COPY_SKIP = new Set(['.vuepress', 'node_modules', '.git', '.DS_Store']);
491
+
492
+ /**
493
+ * Compiles a basename glob pattern (`*`, `?`, literal segments) into a
494
+ * RegExp. Patterns match the basename of a file or directory, never the
495
+ * full relative path — keeps the mental model close to `.gitignore`'s
496
+ * unanchored entries.
497
+ */
498
+ function compileBasenameGlob(pattern: string): RegExp {
499
+ let re = '';
500
+ for (const ch of pattern) {
501
+ if (ch === '*') re += '.*';
502
+ else if (ch === '?') re += '.';
503
+ else re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
504
+ }
505
+ return new RegExp(`^${re}$`);
506
+ }
507
+
508
+ /**
509
+ * Files in the dest that are NOT in the source must NOT be pruned by the
510
+ * mirror: index.mdx is generated by writeIndexMdx (its frontmatter is the
511
+ * sync output), and dotfiles are by convention out-of-band overlay state
512
+ * (`.gitkeep`, OS metadata, editor scratch files) that the importer never
513
+ * created and shouldn't touch.
514
+ */
515
+ function isDestManagedByImporter(relPath: string): boolean {
516
+ if (relPath === 'index.mdx') return false;
517
+ if (relPath.split(path.sep).some(part => part.startsWith('.'))) return false;
518
+ return true;
519
+ }
520
+
521
+ interface SyncOptions {
522
+ skipCommon: boolean;
523
+ skipPatterns: string[];
524
+ }
525
+
526
+ /**
527
+ * Mirror the source tree into the dest: copy every non-excluded file from
528
+ * source, then prune any importer-managed file under dest that doesn't
529
+ * exist in source. The "mirror" semantics matter on re-runs after an
530
+ * upstream rename or deletion — without the prune, stale content lingers
531
+ * in the dest and stays reachable.
532
+ */
533
+ function syncTree(srcDir: string, destDir: string, opts: SyncOptions): { files: number; assets: number; skipped: string[] } {
534
+ let files = 0;
535
+ let assets = 0;
536
+ const skipped: string[] = [];
537
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
538
+
539
+ const commonSkip = opts.skipCommon ? new Set(COMMON_SKIP_FILENAMES) : new Set<string>();
540
+ const customRegexes = opts.skipPatterns.map(compileBasenameGlob);
541
+
542
+ const shouldSkip = (name: string): boolean => {
543
+ if (commonSkip.has(name)) return true;
544
+ for (const re of customRegexes) if (re.test(name)) return true;
545
+ return false;
546
+ };
547
+
548
+ const sourceRelPaths = new Set<string>();
549
+
550
+ const walkSource = (src: string, dest: string, relBase: string) => {
551
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
552
+ if (COPY_SKIP.has(entry.name)) continue;
553
+ if (entry.name.startsWith('.')) continue;
554
+ if (shouldSkip(entry.name)) {
555
+ skipped.push(relBase ? path.join(relBase, entry.name) : entry.name);
556
+ continue;
557
+ }
558
+ const sPath = path.join(src, entry.name);
559
+ const dPath = path.join(dest, entry.name);
560
+ const relPath = relBase ? path.join(relBase, entry.name) : entry.name;
561
+ sourceRelPaths.add(relPath);
562
+ if (entry.isDirectory()) {
563
+ if (!fs.existsSync(dPath)) fs.mkdirSync(dPath, { recursive: true });
564
+ walkSource(sPath, dPath, relPath);
565
+ } else if (entry.isFile()) {
566
+ fs.copyFileSync(sPath, dPath);
567
+ if (/\.mdx?$/i.test(entry.name)) files += 1;
568
+ else assets += 1;
569
+ }
570
+ }
571
+ };
572
+ walkSource(srcDir, destDir, '');
573
+
574
+ // Prune importer-managed dest paths not present in the source set.
575
+ // Depth-first so empty directories left after pruning their contents get
576
+ // removed in the same pass.
577
+ const prune = (dir: string, relBase: string): boolean => {
578
+ let stillHasContent = false;
579
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
580
+ const dPath = path.join(dir, entry.name);
581
+ const relPath = relBase ? path.join(relBase, entry.name) : entry.name;
582
+ if (!isDestManagedByImporter(relPath)) {
583
+ stillHasContent = true;
584
+ continue;
585
+ }
586
+ if (entry.isDirectory()) {
587
+ const childKept = prune(dPath, relPath);
588
+ if (!sourceRelPaths.has(relPath) && !childKept) {
589
+ fs.rmdirSync(dPath);
590
+ } else {
591
+ stillHasContent = stillHasContent || childKept || sourceRelPaths.has(relPath);
592
+ }
593
+ } else {
594
+ if (sourceRelPaths.has(relPath)) {
595
+ stillHasContent = true;
596
+ } else {
597
+ fs.unlinkSync(dPath);
598
+ }
599
+ }
600
+ }
601
+ return stillHasContent;
602
+ };
603
+ prune(destDir, '');
604
+
605
+ return { files, assets, skipped };
606
+ }
607
+
608
+ // ─── index.mdx writing ───────────────────────────────────────────────────────
609
+
610
+ interface BookFrontmatter {
611
+ title?: string;
612
+ excerpt?: string;
613
+ date?: string;
614
+ coverImage?: string;
615
+ featured?: boolean;
616
+ draft?: boolean;
617
+ authors?: string[];
618
+ latex?: boolean;
619
+ chapters?: unknown;
620
+ [k: string]: unknown;
621
+ }
622
+
623
+ function loadVuepressTitle(configPath: string): string | undefined {
624
+ const source = fs.readFileSync(configPath, 'utf8');
625
+ // Cheap scan — the title is a top-level string property. AST round-trip is
626
+ // overkill here.
627
+ const m = source.match(/\btitle\s*:\s*['"]([^'"]+)['"]/);
628
+ return m ? m[1] : undefined;
629
+ }
630
+
631
+ function writeIndexMdx(destDir: string, configPath: string, toc: TocItem[]): void {
632
+ const indexPath = path.join(destDir, 'index.mdx');
633
+
634
+ if (fs.existsSync(indexPath)) {
635
+ // Re-sync: the script owns `chapters:` and nothing else. Every other
636
+ // frontmatter key + the prose body is preserved as-is. Defaults that
637
+ // were sensible at first-sync time would now be unwanted overrides of
638
+ // what the author has chosen (including intentionally-blank values).
639
+ const raw = fs.readFileSync(indexPath, 'utf8');
640
+ const parsed = matter(raw);
641
+ const data: BookFrontmatter = { ...(parsed.data as BookFrontmatter), chapters: toc };
642
+ fs.writeFileSync(indexPath, matter.stringify(parsed.content, data));
643
+ return;
644
+ }
645
+
646
+ // First sync: bootstrap an index.mdx with the minimum the runtime's Zod
647
+ // book schema requires (`title:`) plus a couple of low-stakes defaults so
648
+ // the book is immediately loadable. The author edits to taste; subsequent
649
+ // re-syncs will preserve those edits.
650
+ const data: BookFrontmatter = {
651
+ title: loadVuepressTitle(configPath) ?? path.basename(destDir),
652
+ date: new Date().toISOString().split('T')[0],
653
+ draft: false,
654
+ featured: false,
655
+ chapters: toc,
656
+ };
657
+ const body = `\nImported from VuePress source at ${path.relative(process.cwd(), path.dirname(path.dirname(configPath)))}.\n`;
658
+ fs.writeFileSync(indexPath, matter.stringify(body, data));
659
+ }
660
+
661
+ // ─── Main ────────────────────────────────────────────────────────────────────
662
+
663
+ function main() {
664
+ const { source, dest, skipCommon, skipPatterns } = parseArgs(process.argv.slice(2));
665
+
666
+ if (!fs.existsSync(source)) {
667
+ throw new Error(`[amytis] Source directory does not exist: ${source}`);
668
+ }
669
+
670
+ const configPath = findVuepressConfig(source);
671
+ console.log(`[sync-vuepress-book] Reading sidebar from ${path.relative(process.cwd(), configPath)}`);
672
+
673
+ const sidebar = extractSidebar(configPath);
674
+ const warnings: ConvertWarnings = { emptySections: [], unsupported: [], skippedMetaLeaves: [] };
675
+ const toc = convertSidebar(sidebar, source, warnings);
676
+
677
+ const chapters = collectChapterIds(toc);
678
+ const missing: string[] = [];
679
+ for (const ch of chapters) {
680
+ if (!resolveSourceFile(source, ch.id)) missing.push(ch.id);
681
+ }
682
+ if (missing.length > 0) {
683
+ throw new Error(
684
+ `[amytis] ${missing.length} sidebar leaf chapter${missing.length === 1 ? '' : 's'} ` +
685
+ `point to source files that do not exist:\n ${missing.map(m => `${m}.md`).join('\n ')}\n` +
686
+ `Fix the sidebar in ${path.relative(process.cwd(), configPath)} or write the missing files before syncing.`
687
+ );
688
+ }
689
+
690
+ console.log(`[sync-vuepress-book] Copying ${path.relative(process.cwd(), source)} → ${path.relative(process.cwd(), dest)}`);
691
+ const { files, assets, skipped } = syncTree(source, dest, { skipCommon, skipPatterns });
692
+
693
+ writeIndexMdx(dest, configPath, toc);
694
+
695
+ console.log(`[sync-vuepress-book] Done. ${files} markdown files, ${assets} asset files copied, ${chapters.length} chapters mapped.`);
696
+ if (skipped.length > 0) {
697
+ console.log(`[sync-vuepress-book] Skipped ${skipped.length} file${skipped.length === 1 ? '' : 's'} matching skip rules: ${skipped.join(', ')}`);
698
+ }
699
+ if (warnings.emptySections.length > 0) {
700
+ console.warn(`[sync-vuepress-book] Empty sections (no items): ${warnings.emptySections.join(', ')}`);
701
+ }
702
+ if (warnings.unsupported.length > 0) {
703
+ console.warn(`[sync-vuepress-book] Skipped unsupported sidebar entries: ${warnings.unsupported.join(', ')}`);
704
+ }
705
+ if (warnings.skippedMetaLeaves.length > 0) {
706
+ console.log(`[sync-vuepress-book] Dropped meta-nav leaves from TOC (Amytis already renders one): ${warnings.skippedMetaLeaves.join(', ')}`);
707
+ }
708
+ }
709
+
710
+ main();