@hutusi/amytis 1.16.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 (51) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +16 -0
  4. package/CLAUDE.md +10 -11
  5. package/docs/ARCHITECTURE.md +81 -0
  6. package/docs/DIGITAL_GARDEN.md +1 -1
  7. package/docs/guides/importing-vuepress-books.md +95 -36
  8. package/package.json +1 -1
  9. package/scripts/sync-vuepress-book.ts +277 -66
  10. package/site.config.example.ts +3 -3
  11. package/site.config.ts +3 -3
  12. package/src/app/[slug]/layout.tsx +30 -0
  13. package/src/app/books/[slug]/layout.tsx +24 -0
  14. package/src/app/books/[slug]/page.tsx +18 -2
  15. package/src/app/globals.css +67 -0
  16. package/src/app/page.tsx +6 -0
  17. package/src/app/posts/layout.tsx +20 -0
  18. package/src/app/series/[slug]/page.tsx +33 -9
  19. package/src/components/BookReadingShell.tsx +145 -0
  20. package/src/components/BookSidebar.tsx +0 -0
  21. package/src/components/CuratedSeriesSection.tsx +28 -10
  22. package/src/components/FeaturedStoriesSection.tsx +41 -20
  23. package/src/components/Footer.tsx +1 -1
  24. package/src/components/ImmersiveReader.tsx +130 -0
  25. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  26. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  27. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  28. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  29. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  30. package/src/components/ImmersiveToggleButton.tsx +45 -0
  31. package/src/components/MarkdownRenderer.tsx +31 -0
  32. package/src/components/Navbar.tsx +3 -1
  33. package/src/components/PostReadingShell.tsx +68 -0
  34. package/src/components/ReadingProgressBar.tsx +1 -1
  35. package/src/components/SelectedBooksSection.tsx +27 -8
  36. package/src/hooks/useActiveHeading.ts +35 -13
  37. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  38. package/src/i18n/translations.ts +42 -0
  39. package/src/layouts/BookLayout.tsx +46 -89
  40. package/src/layouts/PostLayout.tsx +154 -115
  41. package/src/lib/immersive-reading-prefs.ts +104 -0
  42. package/src/lib/markdown.ts +18 -11
  43. package/src/lib/scroll-utils.ts +44 -6
  44. package/src/lib/shuffle.ts +15 -1
  45. package/src/lib/sort.ts +15 -0
  46. package/src/lib/urls.ts +5 -0
  47. package/tests/integration/book-index-cta.test.ts +87 -0
  48. package/tests/integration/series-index-cta.test.ts +88 -0
  49. package/tests/integration/sync-vuepress-book.test.ts +205 -2
  50. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  51. package/vercel.json +7 -0
@@ -8,11 +8,15 @@ import type * as acorn from 'acorn';
8
8
  // bun run sync-vuepress-book --source <vuepress-docs-dir> --dest <amytis-book-dir>
9
9
  // bun run sync-vuepress-book <source> <dest> (positional shorthand)
10
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).
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.
16
20
  //
17
21
  // Re-runnable: any subsequent run mirrors the current state of the source.
18
22
 
@@ -21,18 +25,43 @@ import type * as acorn from 'acorn';
21
25
  interface CliArgs {
22
26
  source: string;
23
27
  dest: string;
28
+ skipCommon: boolean;
29
+ skipPatterns: string[];
24
30
  }
25
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
+
26
44
  function parseArgs(argv: string[]): CliArgs {
27
45
  const positional: string[] = [];
28
46
  let source: string | undefined;
29
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
+ };
30
55
  for (let i = 0; i < argv.length; i++) {
31
56
  const a = argv[i];
32
57
  if (a === '--source') { source = argv[++i]; continue; }
33
58
  if (a === '--dest') { dest = argv[++i]; continue; }
34
59
  if (a.startsWith('--source=')) { source = a.slice('--source='.length); continue; }
35
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; }
36
65
  if (a === '--help' || a === '-h') {
37
66
  printUsageAndExit(0);
38
67
  }
@@ -44,16 +73,30 @@ function parseArgs(argv: string[]): CliArgs {
44
73
  return {
45
74
  source: path.resolve(source!),
46
75
  dest: path.resolve(dest!),
76
+ skipCommon,
77
+ skipPatterns,
47
78
  };
48
79
  }
49
80
 
50
81
  function printUsageAndExit(code: number): never {
51
82
  console.error(
52
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' +
53
96
  '\n' +
54
97
  'Examples:\n' +
55
98
  ' 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'
99
+ ' bun run sync-vuepress-book /path/to/dmla/docs content/books/dmla --skip "*.bak,dist"'
57
100
  );
58
101
  process.exit(code);
59
102
  }
@@ -208,7 +251,9 @@ type TocItem = Section | ChapterRef;
208
251
  function normalizeLink(link: string): string {
209
252
  // VuePress sidebar links may use any of: leading slash, no slash, trailing
210
253
  // slash (folder-index style like `/guide/`), or an explicit `.md`/`.mdx`
211
- // suffix. The canonical Amytis chapter id has none of those.
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.
212
257
  let s: string;
213
258
  try {
214
259
  s = decodeURIComponent(link.trim());
@@ -222,66 +267,190 @@ function normalizeLink(link: string): string {
222
267
  return s;
223
268
  }
224
269
 
225
- function isChapterLeaf(item: Record<string, unknown>): item is { text: string; link: string } {
226
- return typeof item.link === 'string' && !item.children;
227
- }
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']);
228
276
 
229
- function isSectionGroup(item: Record<string, unknown>): item is { text: string; children: SidebarItem[]; collapsible?: boolean } {
230
- return Array.isArray(item.children);
277
+ function isSkippedMetaLeaf(id: string): boolean {
278
+ const tail = id.split('/').pop() ?? id;
279
+ return SKIPPED_LEAF_IDS.has(tail.toLowerCase());
231
280
  }
232
281
 
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
282
  interface ConvertWarnings {
239
283
  emptySections: string[]; // sections with no items
240
- sectionWithOwnLink: string[]; // ignored own-page link on a group header
241
284
  unsupported: string[]; // strings or other forms we skip
242
285
  skippedMetaLeaves: string[]; // leaves dropped because their id is a known meta-nav slug
243
286
  }
244
287
 
245
- function convertSidebar(sidebar: SidebarItem[], warnings: ConvertWarnings): TocItem[] {
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[] {
246
395
  const result: TocItem[] = [];
247
396
  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));
397
+ const norm = normalizeRawEntry(raw);
398
+ if (!norm) {
399
+ warnings.unsupported.push(typeof raw === 'string' ? raw : JSON.stringify(raw));
256
400
  continue;
257
401
  }
258
402
 
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);
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);
268
434
  result.push(section);
269
435
  continue;
270
436
  }
271
437
 
272
- if (isChapterLeaf(item)) {
273
- const id = normalizeLink(item.link);
274
- if (SKIPPED_LEAF_IDS.has(id)) {
275
- warnings.skippedMetaLeaves.push(`${text} (${id})`);
438
+ if (hasPath && pathId) {
439
+ if (isSkippedMetaLeaf(pathId)) {
440
+ warnings.skippedMetaLeaves.push(`${norm.title ?? pathId} (${pathId})`);
276
441
  continue;
277
442
  }
278
- result.push({ title: text, id });
443
+ result.push({
444
+ title: resolveTitle(norm, pathId, sourceDir),
445
+ id: pathId,
446
+ });
279
447
  continue;
280
448
  }
281
449
 
282
- // {text, no link, no children} — a section header that's a placeholder.
283
- warnings.emptySections.push(text);
284
- result.push({ section: text, items: [] });
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: [] });
285
454
  }
286
455
  return result;
287
456
  }
@@ -320,6 +489,22 @@ function resolveSourceFile(sourceDir: string, chapterId: string): string | null
320
489
 
321
490
  const COPY_SKIP = new Set(['.vuepress', 'node_modules', '.git', '.DS_Store']);
322
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
+
323
508
  /**
324
509
  * Files in the dest that are NOT in the source must NOT be pruned by the
325
510
  * mirror: index.mdx is generated by writeIndexMdx (its frontmatter is the
@@ -333,6 +518,11 @@ function isDestManagedByImporter(relPath: string): boolean {
333
518
  return true;
334
519
  }
335
520
 
521
+ interface SyncOptions {
522
+ skipCommon: boolean;
523
+ skipPatterns: string[];
524
+ }
525
+
336
526
  /**
337
527
  * Mirror the source tree into the dest: copy every non-excluded file from
338
528
  * source, then prune any importer-managed file under dest that doesn't
@@ -340,17 +530,31 @@ function isDestManagedByImporter(relPath: string): boolean {
340
530
  * upstream rename or deletion — without the prune, stale content lingers
341
531
  * in the dest and stays reachable.
342
532
  */
343
- function syncTree(srcDir: string, destDir: string): { files: number; assets: number } {
533
+ function syncTree(srcDir: string, destDir: string, opts: SyncOptions): { files: number; assets: number; skipped: string[] } {
344
534
  let files = 0;
345
535
  let assets = 0;
536
+ const skipped: string[] = [];
346
537
  if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
347
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
+
348
548
  const sourceRelPaths = new Set<string>();
349
549
 
350
550
  const walkSource = (src: string, dest: string, relBase: string) => {
351
551
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
352
552
  if (COPY_SKIP.has(entry.name)) continue;
353
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
+ }
354
558
  const sPath = path.join(src, entry.name);
355
559
  const dPath = path.join(dest, entry.name);
356
560
  const relPath = relBase ? path.join(relBase, entry.name) : entry.name;
@@ -398,7 +602,7 @@ function syncTree(srcDir: string, destDir: string): { files: number; assets: num
398
602
  };
399
603
  prune(destDir, '');
400
604
 
401
- return { files, assets };
605
+ return { files, assets, skipped };
402
606
  }
403
607
 
404
608
  // ─── index.mdx writing ───────────────────────────────────────────────────────
@@ -426,31 +630,38 @@ function loadVuepressTitle(configPath: string): string | undefined {
426
630
 
427
631
  function writeIndexMdx(destDir: string, configPath: string, toc: TocItem[]): void {
428
632
  const indexPath = path.join(destDir, 'index.mdx');
429
- let existing: { data: BookFrontmatter; content: string } = { data: {}, content: '' };
633
+
430
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).
431
639
  const raw = fs.readFileSync(indexPath, 'utf8');
432
640
  const parsed = matter(raw);
433
- existing = { data: parsed.data as BookFrontmatter, content: parsed.content };
641
+ const data: BookFrontmatter = { ...(parsed.data as BookFrontmatter), chapters: toc };
642
+ fs.writeFileSync(indexPath, matter.stringify(parsed.content, data));
643
+ return;
434
644
  }
435
645
 
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
-
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`;
447
658
  fs.writeFileSync(indexPath, matter.stringify(body, data));
448
659
  }
449
660
 
450
661
  // ─── Main ────────────────────────────────────────────────────────────────────
451
662
 
452
663
  function main() {
453
- const { source, dest } = parseArgs(process.argv.slice(2));
664
+ const { source, dest, skipCommon, skipPatterns } = parseArgs(process.argv.slice(2));
454
665
 
455
666
  if (!fs.existsSync(source)) {
456
667
  throw new Error(`[amytis] Source directory does not exist: ${source}`);
@@ -460,8 +671,8 @@ function main() {
460
671
  console.log(`[sync-vuepress-book] Reading sidebar from ${path.relative(process.cwd(), configPath)}`);
461
672
 
462
673
  const sidebar = extractSidebar(configPath);
463
- const warnings: ConvertWarnings = { emptySections: [], sectionWithOwnLink: [], unsupported: [], skippedMetaLeaves: [] };
464
- const toc = convertSidebar(sidebar, warnings);
674
+ const warnings: ConvertWarnings = { emptySections: [], unsupported: [], skippedMetaLeaves: [] };
675
+ const toc = convertSidebar(sidebar, source, warnings);
465
676
 
466
677
  const chapters = collectChapterIds(toc);
467
678
  const missing: string[] = [];
@@ -477,17 +688,17 @@ function main() {
477
688
  }
478
689
 
479
690
  console.log(`[sync-vuepress-book] Copying ${path.relative(process.cwd(), source)} → ${path.relative(process.cwd(), dest)}`);
480
- const { files, assets } = syncTree(source, dest);
691
+ const { files, assets, skipped } = syncTree(source, dest, { skipCommon, skipPatterns });
481
692
 
482
693
  writeIndexMdx(dest, configPath, toc);
483
694
 
484
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
+ }
485
699
  if (warnings.emptySections.length > 0) {
486
700
  console.warn(`[sync-vuepress-book] Empty sections (no items): ${warnings.emptySections.join(', ')}`);
487
701
  }
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
702
  if (warnings.unsupported.length > 0) {
492
703
  console.warn(`[sync-vuepress-book] Skipped unsupported sidebar entries: ${warnings.unsupported.join(', ')}`);
493
704
  }
@@ -140,11 +140,11 @@ export const siteConfig = {
140
140
  homepage: {
141
141
  sections: [
142
142
  { id: 'hero', enabled: true, weight: 1 },
143
- { id: 'featured-posts', enabled: true, weight: 2, maxItems: 4 },
143
+ { id: 'featured-posts', enabled: true, weight: 2, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
144
144
  { id: 'latest-posts', enabled: true, weight: 3, maxItems: 3 },
145
145
  { id: 'recent-flows', enabled: false, weight: 4, maxItems: 8 },
146
- { id: 'featured-series', enabled: true, weight: 5, maxItems: 6 },
147
- { id: 'featured-books', enabled: false, weight: 6, maxItems: 4 },
146
+ { id: 'featured-series', enabled: true, weight: 5, maxItems: 6, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
147
+ { id: 'featured-books', enabled: false, weight: 6, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
148
148
  ],
149
149
  },
150
150
 
package/site.config.ts CHANGED
@@ -139,11 +139,11 @@ export const siteConfig = {
139
139
  homepage: {
140
140
  sections: [
141
141
  { id: 'hero', enabled: true, weight: 1 },
142
- { id: 'featured-posts', enabled: true, weight: 2, maxItems: 4 },
142
+ { id: 'featured-posts', enabled: true, weight: 2, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
143
143
  { id: 'latest-posts', enabled: true, weight: 3, maxItems: 4 },
144
144
  { id: 'recent-flows', enabled: true, weight: 4, maxItems: 7 },
145
- { id: 'featured-series', enabled: true, weight: 5, maxItems: 6 },
146
- { id: 'featured-books', enabled: true, weight: 6, maxItems: 4 },
145
+ { id: 'featured-series', enabled: true, weight: 5, maxItems: 6, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
146
+ { id: 'featured-books', enabled: true, weight: 6, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
147
147
  ],
148
148
  },
149
149
 
@@ -0,0 +1,30 @@
1
+ import { Suspense, type ReactNode } from 'react';
2
+ import { ImmersiveReadingProvider } from '@/components/ImmersiveReadingProvider';
3
+ import ImmersiveReadingFlagHandler from '@/components/ImmersiveReadingFlagHandler';
4
+
5
+ // Mounts the immersive-reading state above the series-prefixed post route
6
+ // (`/<series-slug>/<post>` when series.autoPaths is enabled, which is the
7
+ // default). This is what lets immersive mode persist across client-side
8
+ // navigation between sibling posts in the same series — without it, the
9
+ // provider would remount on every post navigation and reader state would
10
+ // reset.
11
+ //
12
+ // Note this layout wraps ALL single-segment routes under `/`, not just series
13
+ // posts (also redirectFrom aliases, custom-path posts, etc.). The provider
14
+ // only activates when the toggle is clicked, and the toggle is gated on
15
+ // `post.series`, so non-series routes pay only the mount cost.
16
+ //
17
+ // The flag handler reads `?immersive=1` from the URL (set by the CTA on the
18
+ // series index page) and enters the reader. It's wrapped in <Suspense> on its
19
+ // own so its `useSearchParams` bailout doesn't drag {children} out of static
20
+ // prerender.
21
+ export default function SlugLayout({ children }: { children: ReactNode }) {
22
+ return (
23
+ <ImmersiveReadingProvider>
24
+ <Suspense fallback={null}>
25
+ <ImmersiveReadingFlagHandler />
26
+ </Suspense>
27
+ {children}
28
+ </ImmersiveReadingProvider>
29
+ );
30
+ }
@@ -0,0 +1,24 @@
1
+ import { Suspense, type ReactNode } from 'react';
2
+ import { ImmersiveReadingProvider } from '@/components/ImmersiveReadingProvider';
3
+ import ImmersiveReadingFlagHandler from '@/components/ImmersiveReadingFlagHandler';
4
+
5
+ // Mounts the immersive-reading state above the chapter route. This is what
6
+ // lets immersive mode persist across client-side navigation between chapters
7
+ // of the same book (state would otherwise reset on every chapter unmount).
8
+ // State is in-memory only — a hard refresh or navigating to a different book
9
+ // resets it.
10
+ //
11
+ // The flag handler reads `?immersive=1` from the URL (set by the CTA on the
12
+ // book index page) and enters the reader. It's wrapped in <Suspense> on its
13
+ // own so its `useSearchParams` bailout doesn't drag {children} (the chapter
14
+ // page) out of static prerender.
15
+ export default function BookSlugLayout({ children }: { children: ReactNode }) {
16
+ return (
17
+ <ImmersiveReadingProvider>
18
+ <Suspense fallback={null}>
19
+ <ImmersiveReadingFlagHandler />
20
+ </Suspense>
21
+ {children}
22
+ </ImmersiveReadingProvider>
23
+ );
24
+ }
@@ -159,9 +159,9 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
159
159
  </p>
160
160
  )}
161
161
 
162
- {/* Start Reading CTA */}
162
+ {/* Start Reading CTAs */}
163
163
  {firstChapter && (
164
- <div className="mt-8">
164
+ <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
165
165
  <Link
166
166
  href={getBookChapterUrl(book.slug, firstChapter.id)}
167
167
  className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-white rounded-xl font-sans font-medium text-sm hover:bg-accent/90 no-underline transition-colors shadow-lg shadow-accent/20"
@@ -171,6 +171,22 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
171
171
  <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
172
172
  </svg>
173
173
  </Link>
174
+ {/* Secondary CTA — opens the first chapter in immersive mode.
175
+ The `?immersive=1` query param is read by ImmersiveReadingProvider
176
+ on mount, which calls enter() then strips the flag from the URL
177
+ so back-navigation doesn't re-trigger it. */}
178
+ <Link
179
+ href={`${getBookChapterUrl(book.slug, firstChapter.id)}?immersive=1`}
180
+ className="inline-flex items-center gap-2 px-5 py-3 border border-muted/30 text-foreground/80 hover:text-accent hover:border-accent/50 rounded-xl font-sans font-medium text-sm no-underline transition-colors"
181
+ >
182
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
183
+ <path d="M3 7V5a2 2 0 0 1 2-2h2" />
184
+ <path d="M17 3h2a2 2 0 0 1 2 2v2" />
185
+ <path d="M21 17v2a2 2 0 0 1-2 2h-2" />
186
+ <path d="M7 21H5a2 2 0 0 1-2-2v-2" />
187
+ </svg>
188
+ {t('immersive_reading')}
189
+ </Link>
174
190
  </div>
175
191
  )}
176
192
  </div>