@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
@@ -5,6 +5,7 @@ import { siteConfig } from '../../site.config';
5
5
  import GithubSlugger from 'github-slugger';
6
6
  import { z } from 'zod';
7
7
  import { getPostUrl } from './urls';
8
+ import { byDateAsc, byDateDesc } from './sort';
8
9
  import { parseRstDocument, RstParseError } from './rst';
9
10
  import { renderRstFile, renderRstFilesBatch, type RenderedRstDocument } from './rst-renderer';
10
11
 
@@ -123,7 +124,8 @@ export interface PostData {
123
124
  commentable?: boolean;
124
125
  externalLinks?: ExternalLink[];
125
126
  redirectFrom?: string[];
126
- readingTime: string;
127
+ readingMinutes: number;
128
+ wordCount: number;
127
129
  content: string;
128
130
  renderedHtml?: string;
129
131
  plainText?: string;
@@ -263,11 +265,10 @@ function shouldUsePythonRstRenderer(): boolean {
263
265
  return process.env.NODE_ENV !== 'test';
264
266
  }
265
267
 
266
- export function calculateReadingTime(content: string): string {
267
- const wordsPerMinute = 200;
268
- const hanCharsPerMinute = 300;
269
-
270
- // Strip tags and common markdown syntax before counting.
268
+ // Shared text-stripping + tokenization used by both `calculateReadingMinutes`
269
+ // and `calculateWordCount`. Both metrics need the same view of "what counts
270
+ // as a word," so funnel them through a single source of truth.
271
+ function countContentTokens(content: string): { latinWords: number; hanChars: number } {
271
272
  const text = content
272
273
  .replace(/<\/?[^>]+(>|$)/g, "")
273
274
  .replace(/```[\s\S]*?```/g, "")
@@ -275,15 +276,47 @@ export function calculateReadingTime(content: string): string {
275
276
  .replace(/!\[[^\]]*\]\([^)]+\)/g, "")
276
277
  .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
277
278
  .replace(/[#*_~>\-[\]()]/g, " ");
279
+ return countTokenizedText(text);
280
+ }
281
+
282
+ function countTokenizedText(text: string): { latinWords: number; hanChars: number } {
283
+ const hanChars = (text.match(HAN_CHAR_RE) || []).length;
284
+ const latinWords = (text.match(LATIN_WORD_RE) || []).length;
285
+ return { latinWords, hanChars };
286
+ }
287
+
288
+ // Han character ranges: CJK Unified Ideographs Extension A, CJK Unified
289
+ // Ideographs, CJK Compatibility Ideographs.
290
+ const HAN_CHAR_RE = /[㐀-䶿一-鿿豈-﫿]/g;
291
+ // Latin word: alphanumeric runs allowing apostrophes/hyphens between runs.
292
+ const LATIN_WORD_RE = /[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g;
278
293
 
279
- const hanCharCount = (text.match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length;
280
- const latinWordCount = (text.match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g) || []).length;
294
+ /**
295
+ * Estimated minutes-to-read, ceiled to a whole minute and floored at 1.
296
+ * Returns a raw number so layouts can localize via `t('reading_time')`
297
+ * — store-as-number rather than pre-baked "N min read" string lets
298
+ * the locale switch take effect at render time.
299
+ */
300
+ export function calculateReadingMinutes(content: string): number {
301
+ const wordsPerMinute = 200;
302
+ const hanCharsPerMinute = 300;
303
+ const { latinWords, hanChars } = countContentTokens(content);
304
+ const estimatedMinutes = (latinWords / wordsPerMinute) + (hanChars / hanCharsPerMinute);
305
+ return Math.max(1, Math.ceil(estimatedMinutes));
306
+ }
281
307
 
282
- const estimatedMinutes = (latinWordCount / wordsPerMinute) + (hanCharCount / hanCharsPerMinute);
283
- const minutes = Math.max(1, Math.ceil(estimatedMinutes));
284
- return `${minutes} min read`;
308
+ /**
309
+ * Aggregate word count: Latin word matches plus Han characters.
310
+ * Han is counted per-character (the convention in Chinese typography
311
+ * — "字数" literally means "character count") while Latin counts per
312
+ * whitespace-bounded token. Returns 0 for empty input.
313
+ */
314
+ export function calculateWordCount(content: string): number {
315
+ const { latinWords, hanChars } = countContentTokens(content);
316
+ return latinWords + hanChars;
285
317
  }
286
318
 
319
+
287
320
  export function generateExcerpt(content: string): string {
288
321
  let plain = content.replace(/^#+\s+/gm, '');
289
322
  plain = plain.replace(/```[\s\S]*?```/g, '');
@@ -608,8 +641,9 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
608
641
  }
609
642
 
610
643
  const excerpt = data.excerpt || generateExcerpt(contentWithoutH1);
611
- const readingTime = calculateReadingTime(contentWithoutH1);
612
-
644
+ const readingMinutes = calculateReadingMinutes(contentWithoutH1);
645
+ const wordCount = calculateWordCount(contentWithoutH1);
646
+
613
647
  let date = data.date;
614
648
  if (!date && dateFromFileName) date = dateFromFileName;
615
649
  if (!date) date = fs.statSync(fullPath).mtime.toISOString().split('T')[0];
@@ -647,7 +681,8 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
647
681
  items: data.items as CollectionItem[] | undefined,
648
682
  externalLinks: data.externalLinks,
649
683
  redirectFrom: data.redirectFrom,
650
- readingTime,
684
+ readingMinutes,
685
+ wordCount,
651
686
  content: contentWithoutH1,
652
687
  headings,
653
688
  imageBaseSlug,
@@ -680,7 +715,8 @@ function parseRstFile(
680
715
  let parsedText: string | undefined;
681
716
  let parsedHeadings: Heading[];
682
717
  let parsedExcerpt: string;
683
- let parsedReadingTime: string;
718
+ let parsedReadingMinutes: number;
719
+ let parsedWordCount: number;
684
720
  let parsedHtml: string | undefined;
685
721
  let data: ReturnType<typeof parseRstDocument>['metadata'];
686
722
  try {
@@ -691,7 +727,8 @@ function parseRstFile(
691
727
  parsedText = rendered.text;
692
728
  parsedHeadings = rendered.headings;
693
729
  parsedExcerpt = rendered.excerpt;
694
- parsedReadingTime = rendered.readingTime;
730
+ parsedReadingMinutes = rendered.readingMinutes;
731
+ parsedWordCount = rendered.wordCount;
695
732
  parsedHtml = rendered.html;
696
733
  data = rendered.metadata;
697
734
  } else if (shouldUsePythonRstRenderer() && pythonRstRendererAvailable !== false) {
@@ -702,7 +739,8 @@ function parseRstFile(
702
739
  parsedText = rendered.text;
703
740
  parsedHeadings = rendered.headings;
704
741
  parsedExcerpt = rendered.excerpt;
705
- parsedReadingTime = rendered.readingTime;
742
+ parsedReadingMinutes = rendered.readingMinutes;
743
+ parsedWordCount = rendered.wordCount;
706
744
  parsedHtml = rendered.html;
707
745
  data = rendered.metadata;
708
746
  } else {
@@ -720,7 +758,8 @@ function parseRstFile(
720
758
  parsedBody = parsed.body;
721
759
  parsedHeadings = parsed.headings;
722
760
  parsedExcerpt = parsed.excerpt;
723
- parsedReadingTime = parsed.readingTime;
761
+ parsedReadingMinutes = parsed.readingMinutes;
762
+ parsedWordCount = parsed.wordCount;
724
763
  data = parsed.metadata;
725
764
  }
726
765
 
@@ -783,7 +822,8 @@ function parseRstFile(
783
822
  toc: data.toc ?? true,
784
823
  commentable: data.commentable,
785
824
  redirectFrom: data.redirectFrom ?? [],
786
- readingTime: parsedReadingTime,
825
+ readingMinutes: parsedReadingMinutes,
826
+ wordCount: parsedWordCount,
787
827
  content: parsedBody,
788
828
  renderedHtml: parsedHtml,
789
829
  plainText: parsedText,
@@ -931,7 +971,7 @@ export function getAllPosts(): PostData[] {
931
971
  }
932
972
  return true;
933
973
  })
934
- .sort((a, b) => (a.date < b.date ? 1 : -1));
974
+ .sort(byDateDesc);
935
975
  postsCache.set(cacheKey, result);
936
976
  return result;
937
977
  }
@@ -1210,9 +1250,9 @@ export function getSeriesPosts(seriesName: string): PostData[] {
1210
1250
  // Default Sort: date-desc (Newest first)
1211
1251
  const sortOrder = seriesData?.sort || 'date-desc';
1212
1252
  if (sortOrder === 'date-asc') {
1213
- posts.sort((a, b) => (a.date > b.date ? 1 : -1));
1253
+ posts.sort(byDateAsc);
1214
1254
  } else {
1215
- posts.sort((a, b) => (a.date < b.date ? 1 : -1));
1255
+ posts.sort(byDateDesc);
1216
1256
  }
1217
1257
  }
1218
1258
 
@@ -1253,7 +1293,7 @@ export function getAllSeries(): Record<string, PostData[]> {
1253
1293
  return; // Skip draft series in production
1254
1294
  }
1255
1295
  series[slug] = seriesData?.type === 'collection'
1256
- ? getCollectionPosts(slug).slice().sort((a, b) => (a.date < b.date ? 1 : -1))
1296
+ ? getCollectionPosts(slug).slice().sort(byDateDesc)
1257
1297
  : getSeriesPosts(slug);
1258
1298
  });
1259
1299
 
@@ -1456,14 +1496,32 @@ export function getCollectionsForPost(postSlug: string): CollectionContext[] {
1456
1496
  export interface BookChapterEntry {
1457
1497
  title: string;
1458
1498
  id: string;
1499
+ /** Legacy single-level grouping; set when the chapter sits under a `{ part, chapters }` item. */
1459
1500
  part?: string;
1501
+ /** Deepest section title above this chapter (last element of sectionPath). */
1502
+ section?: string;
1503
+ /** Full ancestry of section titles from outermost to innermost. */
1504
+ sectionPath?: string[];
1505
+ }
1506
+
1507
+ export interface BookChapterRef {
1508
+ title: string;
1509
+ id: string;
1460
1510
  }
1461
1511
 
1462
1512
  export interface BookTocPart {
1463
1513
  part: string;
1464
- chapters: { title: string; id: string }[];
1514
+ chapters: BookChapterRef[];
1465
1515
  }
1466
- export type BookTocItem = BookTocPart | { title: string; id: string };
1516
+
1517
+ /** Nested grouping. `items` may recurse into further sections or hold leaf chapter refs. */
1518
+ export interface BookTocSection {
1519
+ section: string;
1520
+ collapsible?: boolean;
1521
+ items: Array<BookTocSection | BookChapterRef>;
1522
+ }
1523
+
1524
+ export type BookTocItem = BookTocPart | BookTocSection | BookChapterRef;
1467
1525
 
1468
1526
  export interface BookData {
1469
1527
  title: string;
@@ -1474,6 +1532,16 @@ export interface BookData {
1474
1532
  featured: boolean;
1475
1533
  draft: boolean;
1476
1534
  authors: string[];
1535
+ /** Book-level LaTeX flag — when true, all chapters render math even if their
1536
+ * own frontmatter omits `latex: true`. Cheaper for math-heavy books than
1537
+ * annotating every chapter file. */
1538
+ latex: boolean;
1539
+ /** Whether the chapter-page header renders the chapter's `excerpt`. Defaults
1540
+ * to false: the typical case is that a chapter opens with its own lede
1541
+ * paragraph, and an excerpt line above it just duplicates that text in the
1542
+ * header. Set to true on books where the excerpt is a distinct subtitle
1543
+ * the author actually wants the reader to see at the top of every chapter. */
1544
+ showChapterExcerpt: boolean;
1477
1545
  content: string;
1478
1546
  toc: BookTocItem[];
1479
1547
  chapters: BookChapterEntry[];
@@ -1488,8 +1556,11 @@ export interface BookChapterData {
1488
1556
  excerpt?: string;
1489
1557
  latex: boolean;
1490
1558
  commentable?: boolean;
1491
- readingTime: string;
1559
+ readingMinutes: number;
1560
+ wordCount: number;
1492
1561
  isFolder: boolean;
1562
+ /** Absolute path of the markdown source file. Used to resolve relative `.md` links. */
1563
+ sourcePath: string;
1493
1564
  prevChapter: { title: string; id: string } | null;
1494
1565
  nextChapter: { title: string; id: string } | null;
1495
1566
  }
@@ -1499,15 +1570,25 @@ const BookChapterRefSchema = z.object({
1499
1570
  id: z.string(),
1500
1571
  });
1501
1572
 
1573
+ // Recursive: a section can nest further sections or leaf chapter refs.
1574
+ const BookTocSectionSchema: z.ZodType<BookTocSection> = z.lazy(() =>
1575
+ z.object({
1576
+ section: z.string(),
1577
+ collapsible: z.boolean().optional(),
1578
+ items: z.array(z.union([BookTocSectionSchema, BookChapterRefSchema])),
1579
+ })
1580
+ );
1581
+
1502
1582
  const BookTocItemSchema: z.ZodType<BookTocItem> = z.union([
1503
1583
  z.object({
1504
1584
  part: z.string(),
1505
1585
  chapters: z.array(BookChapterRefSchema),
1506
1586
  }),
1587
+ BookTocSectionSchema,
1507
1588
  BookChapterRefSchema,
1508
1589
  ]);
1509
1590
 
1510
- const BookSchema = z.object({
1591
+ export const BookSchema = z.object({
1511
1592
  title: z.string(),
1512
1593
  excerpt: z.string().optional(),
1513
1594
  date: z.union([z.string(), z.date()]).transform(val => new Date(val).toISOString().split('T')[0]),
@@ -1515,24 +1596,47 @@ const BookSchema = z.object({
1515
1596
  featured: z.boolean().optional().default(false),
1516
1597
  draft: z.boolean().optional().default(false),
1517
1598
  authors: z.array(z.string()).optional().default([]),
1599
+ latex: z.boolean().optional().default(false),
1600
+ showChapterExcerpt: z.boolean().optional().default(false),
1518
1601
  chapters: z.array(BookTocItemSchema),
1519
1602
  });
1520
1603
 
1521
1604
  const BookChapterSchema = z.object({
1522
- title: z.string(),
1605
+ title: z.string().optional(),
1523
1606
  excerpt: z.string().optional(),
1524
1607
  draft: z.boolean().optional().default(false),
1525
1608
  latex: z.boolean().optional().default(false),
1526
1609
  commentable: z.boolean().optional(),
1527
1610
  });
1528
1611
 
1529
- function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
1612
+ export function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
1530
1613
  const result: BookChapterEntry[] = [];
1614
+
1615
+ const walkSection = (
1616
+ items: Array<BookTocSection | BookChapterRef>,
1617
+ sectionPath: string[],
1618
+ ): void => {
1619
+ for (const item of items) {
1620
+ if ('section' in item) {
1621
+ walkSection(item.items, [...sectionPath, item.section]);
1622
+ } else {
1623
+ result.push({
1624
+ title: item.title,
1625
+ id: item.id,
1626
+ section: sectionPath[sectionPath.length - 1],
1627
+ sectionPath: sectionPath.length > 0 ? [...sectionPath] : undefined,
1628
+ });
1629
+ }
1630
+ }
1631
+ };
1632
+
1531
1633
  for (const item of toc) {
1532
1634
  if ('part' in item) {
1533
1635
  for (const ch of item.chapters) {
1534
1636
  result.push({ title: ch.title, id: ch.id, part: item.part });
1535
1637
  }
1638
+ } else if ('section' in item) {
1639
+ walkSection([item], []);
1536
1640
  } else {
1537
1641
  result.push({ title: item.title, id: item.id });
1538
1642
  }
@@ -1540,6 +1644,41 @@ function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
1540
1644
  return result;
1541
1645
  }
1542
1646
 
1647
+ /**
1648
+ * Resolves a chapter id (possibly nested with `/`) to a markdown file on disk.
1649
+ * Returns `{ path, isFolder }` if a file exists in one of the six supported
1650
+ * forms (`<id>.mdx`, `<id>.md`, `<id>/index.mdx`, `<id>/index.md`,
1651
+ * `<id>/README.mdx`, `<id>/README.md`), or `null` if the id has no match.
1652
+ * Guards against `..`-style path escapes — any id that resolves outside
1653
+ * `bookDir` returns null.
1654
+ */
1655
+ function resolveChapterFilePath(
1656
+ bookDir: string,
1657
+ chapterId: string,
1658
+ ): { path: string; isFolder: boolean } | null {
1659
+ const bookDirResolved = path.resolve(bookDir);
1660
+ const candidate = path.resolve(bookDir, chapterId);
1661
+ if (
1662
+ candidate !== bookDirResolved &&
1663
+ !candidate.startsWith(bookDirResolved + path.sep)
1664
+ ) {
1665
+ return null;
1666
+ }
1667
+ const chMdx = `${candidate}.mdx`;
1668
+ const chMd = `${candidate}.md`;
1669
+ const chFolderMdx = path.join(candidate, 'index.mdx');
1670
+ const chFolderMd = path.join(candidate, 'index.md');
1671
+ const chFolderReadmeMdx = path.join(candidate, 'README.mdx');
1672
+ const chFolderReadmeMd = path.join(candidate, 'README.md');
1673
+ if (fs.existsSync(chMdx)) return { path: chMdx, isFolder: false };
1674
+ if (fs.existsSync(chMd)) return { path: chMd, isFolder: false };
1675
+ if (fs.existsSync(chFolderMdx)) return { path: chFolderMdx, isFolder: true };
1676
+ if (fs.existsSync(chFolderMd)) return { path: chFolderMd, isFolder: true };
1677
+ if (fs.existsSync(chFolderReadmeMdx)) return { path: chFolderReadmeMdx, isFolder: true };
1678
+ if (fs.existsSync(chFolderReadmeMd)) return { path: chFolderReadmeMd, isFolder: true };
1679
+ return null;
1680
+ }
1681
+
1543
1682
  export function getBookData(slug: string): BookData | null {
1544
1683
  if (!fs.existsSync(booksDirectory)) return null;
1545
1684
  const bookDir = path.join(booksDirectory, slug);
@@ -1562,17 +1701,22 @@ export function getBookData(slug: string): BookData | null {
1562
1701
  }
1563
1702
  const data = parsed.data;
1564
1703
 
1565
- // Warn about missing chapter files
1704
+ // Resolve chapter file paths and surface missing files as build-time errors
1705
+ // (strict-build invariant: misconfiguration must fail loudly, not silently).
1566
1706
  const chapters = flattenBookChapters(data.chapters);
1707
+ const missing: string[] = [];
1567
1708
  for (const ch of chapters) {
1568
- const chMdx = path.join(bookDir, `${ch.id}.mdx`);
1569
- const chMd = path.join(bookDir, `${ch.id}.md`);
1570
- const chFolderMdx = path.join(bookDir, ch.id, 'index.mdx');
1571
- const chFolderMd = path.join(bookDir, ch.id, 'index.md');
1572
- if (!fs.existsSync(chMdx) && !fs.existsSync(chMd) && !fs.existsSync(chFolderMdx) && !fs.existsSync(chFolderMd)) {
1573
- console.warn(`Book "${slug}": chapter "${ch.id}" not found`);
1709
+ if (!resolveChapterFilePath(bookDir, ch.id)) {
1710
+ missing.push(ch.id);
1574
1711
  }
1575
1712
  }
1713
+ if (missing.length > 0) {
1714
+ throw new Error(
1715
+ `[amytis] Book "${slug}" references chapter${missing.length === 1 ? '' : 's'} ` +
1716
+ `with no matching file on disk: ${missing.map(id => `"${id}"`).join(', ')}. ` +
1717
+ `Expected one of <bookDir>/<id>.{md,mdx}, <bookDir>/<id>/index.{md,mdx}, or <bookDir>/<id>/README.{md,mdx}.`
1718
+ );
1719
+ }
1576
1720
 
1577
1721
  let coverImage = data.coverImage;
1578
1722
  if (coverImage && !coverImage.startsWith('http') && !coverImage.startsWith('/') && !coverImage.startsWith('text:')) {
@@ -1594,6 +1738,8 @@ export function getBookData(slug: string): BookData | null {
1594
1738
  featured: data.featured,
1595
1739
  draft: data.draft,
1596
1740
  authors,
1741
+ latex: data.latex,
1742
+ showChapterExcerpt: data.showChapterExcerpt,
1597
1743
  content: content.trim(),
1598
1744
  toc: data.chapters,
1599
1745
  chapters,
@@ -1605,17 +1751,9 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
1605
1751
  if (!book) return null;
1606
1752
 
1607
1753
  const bookDir = path.join(booksDirectory, bookSlug);
1608
- const chMdx = path.join(bookDir, `${chapterSlug}.mdx`);
1609
- const chMd = path.join(bookDir, `${chapterSlug}.md`);
1610
- const chFolderMdx = path.join(bookDir, chapterSlug, 'index.mdx');
1611
- const chFolderMd = path.join(bookDir, chapterSlug, 'index.md');
1612
- let fullPath = '';
1613
- let isFolder = false;
1614
- if (fs.existsSync(chMdx)) fullPath = chMdx;
1615
- else if (fs.existsSync(chMd)) fullPath = chMd;
1616
- else if (fs.existsSync(chFolderMdx)) { fullPath = chFolderMdx; isFolder = true; }
1617
- else if (fs.existsSync(chFolderMd)) { fullPath = chFolderMd; isFolder = true; }
1618
- else return null;
1754
+ const resolved = resolveChapterFilePath(bookDir, chapterSlug);
1755
+ if (!resolved) return null;
1756
+ const { path: fullPath, isFolder } = resolved;
1619
1757
 
1620
1758
  const fileContents = fs.readFileSync(fullPath, 'utf8');
1621
1759
  const { data: rawData, content } = matter(fileContents);
@@ -1633,7 +1771,8 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
1633
1771
 
1634
1772
  const contentWithoutH1 = content.replace(/^\s*#\s+[^\n]+/, '').trim();
1635
1773
  const headings = getHeadings(content);
1636
- const readingTime = calculateReadingTime(contentWithoutH1);
1774
+ const readingMinutes = calculateReadingMinutes(contentWithoutH1);
1775
+ const wordCount = calculateWordCount(contentWithoutH1);
1637
1776
  const excerpt = data.excerpt || generateExcerpt(contentWithoutH1);
1638
1777
 
1639
1778
  // Find prev/next
@@ -1641,22 +1780,40 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
1641
1780
  const prevChapter = chapterIndex > 0 ? book.chapters[chapterIndex - 1] : null;
1642
1781
  const nextChapter = chapterIndex < book.chapters.length - 1 ? book.chapters[chapterIndex + 1] : null;
1643
1782
 
1783
+ // Title resolution: frontmatter wins, then book TOC entry, then first H1
1784
+ // in the body. VuePress chapters often omit frontmatter entirely and rely
1785
+ // on the H1 as the title, so this fallback chain keeps the import flow lossless.
1786
+ const fallbackFromToc = chapterIndex >= 0 ? book.chapters[chapterIndex].title : undefined;
1787
+ const h1Match = content.match(/^\s*#\s+([^\n]+)/);
1788
+ const fallbackFromH1 = h1Match?.[1].trim();
1789
+ const title = data.title || fallbackFromToc || fallbackFromH1 || chapterSlug;
1790
+
1644
1791
  return {
1645
- title: data.title,
1792
+ title,
1646
1793
  slug: chapterSlug,
1647
1794
  bookSlug,
1648
1795
  content: contentWithoutH1,
1649
1796
  headings,
1650
1797
  excerpt,
1651
- latex: data.latex,
1798
+ // Chapter-level `latex: true` takes precedence; otherwise inherit the
1799
+ // book-level flag so math-heavy books don't need per-chapter annotation.
1800
+ latex: data.latex || book.latex,
1652
1801
  commentable: data.commentable,
1653
- readingTime,
1802
+ readingMinutes,
1803
+ wordCount,
1654
1804
  isFolder,
1805
+ sourcePath: fullPath,
1655
1806
  prevChapter: prevChapter ? { title: prevChapter.title, id: prevChapter.id } : null,
1656
1807
  nextChapter: nextChapter ? { title: nextChapter.title, id: nextChapter.id } : null,
1657
1808
  };
1658
1809
  }
1659
1810
 
1811
+ /** Absolute path of a book's content directory. Useful for plugins that
1812
+ * need to resolve relative paths from chapter source files. */
1813
+ export function getBookDirPath(bookSlug: string): string {
1814
+ return path.join(booksDirectory, bookSlug);
1815
+ }
1816
+
1660
1817
  export function getAllBooks(): BookData[] {
1661
1818
  if (!fs.existsSync(booksDirectory)) return [];
1662
1819
 
@@ -1671,7 +1828,7 @@ export function getAllBooks(): BookData[] {
1671
1828
  books.push(book);
1672
1829
  }
1673
1830
 
1674
- return books.sort((a, b) => (a.date < b.date ? 1 : -1));
1831
+ return books.sort(byDateDesc);
1675
1832
  }
1676
1833
 
1677
1834
  export function getFeaturedBooks(): BookData[] {
@@ -1790,7 +1947,7 @@ export function getAllFlows(): FlowData[] {
1790
1947
  }
1791
1948
  return true;
1792
1949
  })
1793
- .sort((a, b) => (a.date < b.date ? 1 : -1));
1950
+ .sort(byDateDesc);
1794
1951
  }
1795
1952
 
1796
1953
  export function getFlowBySlug(slug: string): FlowData | null {
@@ -1874,7 +2031,8 @@ export interface NoteData {
1874
2031
  content: string;
1875
2032
  excerpt: string;
1876
2033
  headings: Heading[];
1877
- readingTime: string;
2034
+ readingMinutes: number;
2035
+ wordCount: number;
1878
2036
  }
1879
2037
 
1880
2038
  function parseNoteFile(fullPath: string, slug: string): NoteData {
@@ -1892,7 +2050,8 @@ function parseNoteFile(fullPath: string, slug: string): NoteData {
1892
2050
  const date = data.date || fs.statSync(fullPath).mtime.toISOString().split('T')[0];
1893
2051
  const excerpt = generateExcerpt(contentWithoutH1);
1894
2052
  const headings = getHeadings(content);
1895
- const readingTime = calculateReadingTime(contentWithoutH1);
2053
+ const readingMinutes = calculateReadingMinutes(contentWithoutH1);
2054
+ const wordCount = calculateWordCount(contentWithoutH1);
1896
2055
 
1897
2056
  return {
1898
2057
  slug,
@@ -1907,7 +2066,8 @@ function parseNoteFile(fullPath: string, slug: string): NoteData {
1907
2066
  content: contentWithoutH1,
1908
2067
  excerpt,
1909
2068
  headings,
1910
- readingTime,
2069
+ readingMinutes,
2070
+ wordCount,
1911
2071
  };
1912
2072
  }
1913
2073
 
@@ -1938,7 +2098,7 @@ export function getAllNotes(): NoteData[] {
1938
2098
 
1939
2099
  _allNotes = notes
1940
2100
  .filter(note => process.env.NODE_ENV !== 'production' || !note.draft)
1941
- .sort((a, b) => (a.date < b.date ? 1 : -1));
2101
+ .sort(byDateDesc);
1942
2102
 
1943
2103
  return _allNotes;
1944
2104
  }
@@ -0,0 +1,118 @@
1
+ const FENCE_OPEN_RE = /^[ \t]*(`{3,}|~{3,})/;
2
+
3
+ /**
4
+ * VuePress's math plugin accepts block math with the `$$` markers on the
5
+ * same line as the math body — `$$ \mathbf{A} = \begin{bmatrix}` opening
6
+ * and `\end{bmatrix} $$` closing. `remark-math` (the upstream micromark
7
+ * extension) is stricter: a block-math opener must be on its own line and
8
+ * the closer must be on its own line. When that's violated, the parser
9
+ * falls back to *inline* math — which either explodes in KaTeX (when the
10
+ * body contains `\\` line breaks or `&` column separators) or silently
11
+ * mis-renders as inline (no `katex-display` wrapper, so it loses block
12
+ * margin and centering even though it visually looks fine).
13
+ *
14
+ * This pre-processor splits VuePress-style fences onto their own lines
15
+ * before parsing, so imported chapters render correctly without touching
16
+ * their source files. Two cases:
17
+ *
18
+ * $$ \mathbf{A} = \begin{bmatrix} $$
19
+ * a & b \\ becomes → \mathbf{A} = \begin{bmatrix}
20
+ * c & d a & b \\
21
+ * \end{bmatrix} $$ c & d
22
+ * \end{bmatrix}
23
+ * $$
24
+ *
25
+ * $$
26
+ * $$ x = y $$ becomes → x = y
27
+ * $$
28
+ *
29
+ * - Inline math (`$x$`, with a single `$`) is never matched — only `$$`.
30
+ * - Empty single-line blocks (`$$$$`, `$$ $$`) are left alone.
31
+ * - Fenced code blocks (``` and ~~~) are skipped so code samples that
32
+ * *show* the VuePress syntax verbatim aren't mutated. Fence semantics
33
+ * match CommonMark: closer must be the same character type and at
34
+ * least as long as the opener.
35
+ *
36
+ * Idempotent: re-running on already-normalized content is a no-op, so
37
+ * it's safe to apply unconditionally whenever LaTeX rendering is on.
38
+ */
39
+ export function normalizeVuepressBlockMath(source: string): string {
40
+ const lines = source.split('\n');
41
+ const out: string[] = [];
42
+ let inMath = false;
43
+ let openFence: string | null = null;
44
+ // Indent of the current block-math opener — preserved on every emitted
45
+ // synthetic line so list-item-nested math (4-space indent inside a `-`
46
+ // bullet) doesn't lose its list context when we split the opener / closer.
47
+ let blockIndent = '';
48
+
49
+ for (const line of lines) {
50
+ if (openFence !== null) {
51
+ // Inside a code fence — pass through verbatim, just track close.
52
+ out.push(line);
53
+ const closeRe = new RegExp(`^[ \\t]*${openFence[0]}{${openFence.length},}\\s*$`);
54
+ if (closeRe.test(line)) openFence = null;
55
+ continue;
56
+ }
57
+
58
+ const fenceOpen = line.match(FENCE_OPEN_RE);
59
+ if (fenceOpen) {
60
+ openFence = fenceOpen[1];
61
+ out.push(line);
62
+ continue;
63
+ }
64
+
65
+ if (!inMath) {
66
+ // Match an opener with content tacked on after `$$`. The trimmed body
67
+ // must NOT itself end in `$$` — that would make it a single-line block.
68
+ const m = line.match(/^([ \t]*)\$\$(.+)$/);
69
+ if (m) {
70
+ const rest = m[2].trimEnd();
71
+ if (rest.endsWith('$$')) {
72
+ // Single-line block math like `$$ x $$`. micromark-extension-math
73
+ // parses this as *inline* math (no `katex-display` wrapper), so
74
+ // expand it to opener / body / closer on three lines.
75
+ const body = rest.slice(0, -2).trim();
76
+ if (body.length === 0) {
77
+ // `$$$$` or `$$ $$` — degenerate, leave alone.
78
+ out.push(line);
79
+ continue;
80
+ }
81
+ out.push(`${m[1]}$$`);
82
+ out.push(`${m[1]}${body}`);
83
+ out.push(`${m[1]}$$`);
84
+ continue;
85
+ }
86
+ blockIndent = m[1];
87
+ out.push(`${blockIndent}$$`);
88
+ // Re-apply the opener's indent on the math body so list-nested
89
+ // blocks stay inside their list item. Trim only the gap between
90
+ // `$$` and the actual math content (e.g. `$$ \mathbf{A}` → `\mathbf{A}`).
91
+ out.push(`${blockIndent}${m[2].trimStart()}`);
92
+ inMath = true;
93
+ continue;
94
+ }
95
+ out.push(line);
96
+ continue;
97
+ }
98
+
99
+ // Inside a block-math run started above. Look for an inline closer.
100
+ const close = line.match(/^(.*?)[ \t]*\$\$[ \t]*$/);
101
+ if (close && !line.trim().startsWith('$$')) {
102
+ // Closer with content before `$$` on the same line — split.
103
+ // `close[1]` already includes its own leading whitespace, so we don't
104
+ // need to re-apply blockIndent to it; we only need to indent the `$$`.
105
+ if (close[1].length > 0) out.push(close[1]);
106
+ out.push(`${blockIndent}$$`);
107
+ inMath = false;
108
+ blockIndent = '';
109
+ continue;
110
+ }
111
+ out.push(line);
112
+ if (line.trim() === '$$') {
113
+ inMath = false;
114
+ blockIndent = '';
115
+ }
116
+ }
117
+ return out.join('\n');
118
+ }
@@ -0,0 +1,22 @@
1
+ import { visit } from 'unist-util-visit';
2
+ import type { Element, Root } from 'hast';
3
+
4
+ /**
5
+ * mdast-util-to-hast preserves a fenced code block's meta string under
6
+ * `node.data.meta`, but react-markdown v10 strips `data` before invoking
7
+ * component overrides — so the meta becomes invisible at render time.
8
+ * This tiny rehype pass copies it to a real `data-meta` HTML attribute
9
+ * that survives the round trip and is reachable as `props['data-meta']`.
10
+ */
11
+ export default function rehypeFenceMeta() {
12
+ return (tree: Root) => {
13
+ visit(tree, 'element', (node: Element) => {
14
+ if (node.tagName !== 'code') return;
15
+ const meta = (node.data as { meta?: string } | undefined)?.meta;
16
+ if (typeof meta === 'string' && meta.length > 0) {
17
+ node.properties = node.properties ?? {};
18
+ node.properties['data-meta'] = meta;
19
+ }
20
+ });
21
+ };
22
+ }