@hutusi/amytis 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CLAUDE.md +90 -219
  3. package/bun.lock +185 -547
  4. package/content/books/sample-book/index.mdx +3 -0
  5. package/content/posts/code-block-features-showcase.mdx +223 -0
  6. package/docs/ALERTS.md +112 -0
  7. package/docs/ARCHITECTURE.md +217 -5
  8. package/docs/CODE-BLOCKS.md +238 -0
  9. package/docs/CONTRIBUTING.md +25 -0
  10. package/docs/guides/README.md +11 -0
  11. package/docs/guides/importing-vuepress-books.md +178 -0
  12. package/eslint.config.mjs +18 -6
  13. package/package.json +42 -20
  14. package/scripts/generate-code-group-icons.ts +79 -0
  15. package/scripts/render-rst.py +207 -3
  16. package/scripts/sync-vuepress-book.ts +499 -0
  17. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  18. package/src/app/books/[slug]/page.tsx +67 -32
  19. package/src/app/globals.css +503 -123
  20. package/src/app/page.tsx +1 -1
  21. package/src/app/sitemap.ts +3 -3
  22. package/src/components/ArticleCopyCleaner.tsx +64 -0
  23. package/src/components/BookMobileNav.tsx +44 -50
  24. package/src/components/BookSidebar.tsx +0 -0
  25. package/src/components/CodeBlock.test.tsx +93 -8
  26. package/src/components/CodeBlock.tsx +39 -101
  27. package/src/components/CodeBlockToolbar.tsx +88 -0
  28. package/src/components/CodeGroup.tsx +81 -0
  29. package/src/components/CoverImage.tsx +1 -0
  30. package/src/components/ExternalLinkIcon.tsx +15 -0
  31. package/src/components/FeaturedStoriesSection.tsx +3 -3
  32. package/src/components/GithubAlert.tsx +97 -0
  33. package/src/components/MarkdownRenderer.test.tsx +14 -4
  34. package/src/components/MarkdownRenderer.tsx +144 -23
  35. package/src/components/Mermaid.tsx +32 -1
  36. package/src/components/PostList.tsx +1 -1
  37. package/src/components/PostNavigation.tsx +13 -2
  38. package/src/components/PostSidebar.tsx +13 -2
  39. package/src/components/RstRenderer.test.tsx +15 -15
  40. package/src/components/RstRenderer.tsx +37 -2
  41. package/src/components/Search.tsx +18 -4
  42. package/src/components/SeriesCatalog.tsx +1 -1
  43. package/src/components/ShareBar.tsx +5 -0
  44. package/src/components/TocPanel.tsx +10 -2
  45. package/src/i18n/translations.ts +2 -0
  46. package/src/layouts/BookLayout.tsx +35 -4
  47. package/src/layouts/PostLayout.tsx +5 -1
  48. package/src/lib/code-group-icons.test.ts +78 -0
  49. package/src/lib/code-group-icons.ts +148 -0
  50. package/src/lib/markdown.test.ts +56 -13
  51. package/src/lib/markdown.ts +203 -50
  52. package/src/lib/normalize-vuepress-math.ts +118 -0
  53. package/src/lib/rehype-fence-meta.ts +22 -0
  54. package/src/lib/remark-book-chapter-links.ts +106 -0
  55. package/src/lib/remark-code-group.ts +54 -0
  56. package/src/lib/remark-github-alerts.test.ts +83 -0
  57. package/src/lib/remark-github-alerts.ts +65 -0
  58. package/src/lib/remark-vuepress-containers.ts +130 -0
  59. package/src/lib/rst-renderer.ts +19 -7
  60. package/src/lib/rst.test.ts +212 -2
  61. package/src/lib/rst.ts +217 -13
  62. package/src/lib/shiki-rst.ts +185 -0
  63. package/src/lib/shiki.test.ts +153 -0
  64. package/src/lib/shiki.ts +292 -0
  65. package/src/lib/urls.ts +57 -0
  66. package/src/test-utils/render.ts +23 -0
  67. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  68. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  69. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  70. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  71. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  72. package/tests/helpers/env.ts +19 -0
  73. package/tests/integration/book-chapter-links.test.ts +107 -0
  74. package/tests/integration/books-nested-toc.test.ts +176 -0
  75. package/tests/integration/books.test.ts +3 -2
  76. package/tests/integration/code-block-features.test.ts +188 -0
  77. package/tests/integration/code-group.test.ts +183 -0
  78. package/tests/integration/code-notation.test.ts +97 -0
  79. package/tests/integration/github-alerts.test.ts +82 -0
  80. package/tests/integration/markdown-external-links.test.ts +103 -0
  81. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  82. package/tests/integration/reading-time-headings.test.ts +8 -6
  83. package/tests/integration/series-draft.test.ts +6 -13
  84. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  85. package/tests/integration/vuepress-containers.test.ts +107 -0
  86. package/tests/tooling/new-post.test.ts +1 -1
  87. package/tests/unit/static-params.test.ts +32 -19
@@ -5,7 +5,8 @@ import { afterEach, describe, expect, test } from "bun:test";
5
5
  import { RstParseError } from "./rst";
6
6
  import {
7
7
  generateExcerpt,
8
- calculateReadingTime,
8
+ calculateReadingMinutes,
9
+ calculateWordCount,
9
10
  getHeadings,
10
11
  getAuthorSlug,
11
12
  getPythonRstRendererAvailabilityForTests,
@@ -66,38 +67,80 @@ describe("markdown utils", () => {
66
67
  });
67
68
  });
68
69
 
69
- describe("calculateReadingTime", () => {
70
- test("short content returns 1 min read", () => {
70
+ describe("calculateReadingMinutes", () => {
71
+ test("short content returns 1 minute (floor)", () => {
71
72
  const text = "Hello world, this is a short post.";
72
- expect(calculateReadingTime(text)).toBe("1 min read");
73
+ expect(calculateReadingMinutes(text)).toBe(1);
73
74
  });
74
75
 
75
- test("600 words returns 3 min read", () => {
76
+ test("600 words returns 3 minutes", () => {
76
77
  const words = Array(600).fill("word").join(" ");
77
- expect(calculateReadingTime(words)).toBe("3 min read");
78
+ expect(calculateReadingMinutes(words)).toBe(3);
78
79
  });
79
80
 
80
- test("empty content returns 1 min read", () => {
81
- expect(calculateReadingTime("")).toBe("1 min read");
81
+ test("empty content returns 1 (floor)", () => {
82
+ expect(calculateReadingMinutes("")).toBe(1);
82
83
  });
83
84
 
84
85
  test("strips markdown formatting before counting", () => {
85
86
  // 400 actual words surrounded by markdown syntax
86
87
  const words = Array(400).fill("**word**").join(" ");
87
- const result = calculateReadingTime(words);
88
- expect(result).toBe("2 min read");
88
+ expect(calculateReadingMinutes(words)).toBe(2);
89
89
  });
90
90
 
91
- test("counts Chinese characters for reading time", () => {
91
+ test("counts Chinese characters at 300 cpm", () => {
92
92
  const han = "中".repeat(600);
93
- expect(calculateReadingTime(han)).toBe("2 min read");
93
+ expect(calculateReadingMinutes(han)).toBe(2);
94
94
  });
95
95
 
96
96
  test("combines Latin words and Chinese characters", () => {
97
97
  const latinWords = Array(200).fill("word").join(" ");
98
98
  const han = "中".repeat(300);
99
99
  const mixed = `${latinWords} ${han}`;
100
- expect(calculateReadingTime(mixed)).toBe("2 min read");
100
+ expect(calculateReadingMinutes(mixed)).toBe(2);
101
+ });
102
+ });
103
+
104
+ describe("calculateWordCount", () => {
105
+ test("empty content returns 0", () => {
106
+ expect(calculateWordCount("")).toBe(0);
107
+ });
108
+
109
+ test("Latin words: each whitespace-bounded token counts once", () => {
110
+ expect(calculateWordCount("Hello world, this is a short post.")).toBe(7);
111
+ expect(calculateWordCount(Array(600).fill("word").join(" "))).toBe(600);
112
+ });
113
+
114
+ test("Chinese characters count per-character", () => {
115
+ expect(calculateWordCount("中".repeat(600))).toBe(600);
116
+ });
117
+
118
+ test("mixed Latin + Chinese sums both counts", () => {
119
+ const latin = Array(200).fill("word").join(" ");
120
+ const han = "中".repeat(300);
121
+ expect(calculateWordCount(`${latin} ${han}`)).toBe(500);
122
+ });
123
+
124
+ test("strips fenced code blocks before counting", () => {
125
+ const src = ["pre", "```", "code line one two three", "```", "post"].join("\n");
126
+ // Only "pre" and "post" count.
127
+ expect(calculateWordCount(src)).toBe(2);
128
+ });
129
+
130
+ test("strips inline HTML tags before counting", () => {
131
+ expect(calculateWordCount("hello <span>world</span> again")).toBe(3);
132
+ });
133
+
134
+ test("strips markdown link syntax, keeps link text", () => {
135
+ expect(calculateWordCount("See [the docs](https://example.com) here")).toBe(4);
136
+ });
137
+
138
+ test("matches calculateReadingMinutes on the same input", () => {
139
+ // The two metrics share a tokenizer; both should agree on the underlying
140
+ // token counts. 600 Latin words → 600 wordCount, 3-minute reading time.
141
+ const text = Array(600).fill("word").join(" ");
142
+ expect(calculateWordCount(text)).toBe(600);
143
+ expect(calculateReadingMinutes(text)).toBe(3);
101
144
  });
102
145
  });
103
146
 
@@ -123,7 +123,8 @@ export interface PostData {
123
123
  commentable?: boolean;
124
124
  externalLinks?: ExternalLink[];
125
125
  redirectFrom?: string[];
126
- readingTime: string;
126
+ readingMinutes: number;
127
+ wordCount: number;
127
128
  content: string;
128
129
  renderedHtml?: string;
129
130
  plainText?: string;
@@ -263,11 +264,10 @@ function shouldUsePythonRstRenderer(): boolean {
263
264
  return process.env.NODE_ENV !== 'test';
264
265
  }
265
266
 
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.
267
+ // Shared text-stripping + tokenization used by both `calculateReadingMinutes`
268
+ // and `calculateWordCount`. Both metrics need the same view of "what counts
269
+ // as a word," so funnel them through a single source of truth.
270
+ function countContentTokens(content: string): { latinWords: number; hanChars: number } {
271
271
  const text = content
272
272
  .replace(/<\/?[^>]+(>|$)/g, "")
273
273
  .replace(/```[\s\S]*?```/g, "")
@@ -275,15 +275,47 @@ export function calculateReadingTime(content: string): string {
275
275
  .replace(/!\[[^\]]*\]\([^)]+\)/g, "")
276
276
  .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
277
277
  .replace(/[#*_~>\-[\]()]/g, " ");
278
+ return countTokenizedText(text);
279
+ }
280
+
281
+ function countTokenizedText(text: string): { latinWords: number; hanChars: number } {
282
+ const hanChars = (text.match(HAN_CHAR_RE) || []).length;
283
+ const latinWords = (text.match(LATIN_WORD_RE) || []).length;
284
+ return { latinWords, hanChars };
285
+ }
286
+
287
+ // Han character ranges: CJK Unified Ideographs Extension A, CJK Unified
288
+ // Ideographs, CJK Compatibility Ideographs.
289
+ const HAN_CHAR_RE = /[㐀-䶿一-鿿豈-﫿]/g;
290
+ // Latin word: alphanumeric runs allowing apostrophes/hyphens between runs.
291
+ const LATIN_WORD_RE = /[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g;
278
292
 
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;
293
+ /**
294
+ * Estimated minutes-to-read, ceiled to a whole minute and floored at 1.
295
+ * Returns a raw number so layouts can localize via `t('reading_time')`
296
+ * — store-as-number rather than pre-baked "N min read" string lets
297
+ * the locale switch take effect at render time.
298
+ */
299
+ export function calculateReadingMinutes(content: string): number {
300
+ const wordsPerMinute = 200;
301
+ const hanCharsPerMinute = 300;
302
+ const { latinWords, hanChars } = countContentTokens(content);
303
+ const estimatedMinutes = (latinWords / wordsPerMinute) + (hanChars / hanCharsPerMinute);
304
+ return Math.max(1, Math.ceil(estimatedMinutes));
305
+ }
281
306
 
282
- const estimatedMinutes = (latinWordCount / wordsPerMinute) + (hanCharCount / hanCharsPerMinute);
283
- const minutes = Math.max(1, Math.ceil(estimatedMinutes));
284
- return `${minutes} min read`;
307
+ /**
308
+ * Aggregate word count: Latin word matches plus Han characters.
309
+ * Han is counted per-character (the convention in Chinese typography
310
+ * — "字数" literally means "character count") while Latin counts per
311
+ * whitespace-bounded token. Returns 0 for empty input.
312
+ */
313
+ export function calculateWordCount(content: string): number {
314
+ const { latinWords, hanChars } = countContentTokens(content);
315
+ return latinWords + hanChars;
285
316
  }
286
317
 
318
+
287
319
  export function generateExcerpt(content: string): string {
288
320
  let plain = content.replace(/^#+\s+/gm, '');
289
321
  plain = plain.replace(/```[\s\S]*?```/g, '');
@@ -608,8 +640,9 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
608
640
  }
609
641
 
610
642
  const excerpt = data.excerpt || generateExcerpt(contentWithoutH1);
611
- const readingTime = calculateReadingTime(contentWithoutH1);
612
-
643
+ const readingMinutes = calculateReadingMinutes(contentWithoutH1);
644
+ const wordCount = calculateWordCount(contentWithoutH1);
645
+
613
646
  let date = data.date;
614
647
  if (!date && dateFromFileName) date = dateFromFileName;
615
648
  if (!date) date = fs.statSync(fullPath).mtime.toISOString().split('T')[0];
@@ -647,7 +680,8 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
647
680
  items: data.items as CollectionItem[] | undefined,
648
681
  externalLinks: data.externalLinks,
649
682
  redirectFrom: data.redirectFrom,
650
- readingTime,
683
+ readingMinutes,
684
+ wordCount,
651
685
  content: contentWithoutH1,
652
686
  headings,
653
687
  imageBaseSlug,
@@ -680,7 +714,8 @@ function parseRstFile(
680
714
  let parsedText: string | undefined;
681
715
  let parsedHeadings: Heading[];
682
716
  let parsedExcerpt: string;
683
- let parsedReadingTime: string;
717
+ let parsedReadingMinutes: number;
718
+ let parsedWordCount: number;
684
719
  let parsedHtml: string | undefined;
685
720
  let data: ReturnType<typeof parseRstDocument>['metadata'];
686
721
  try {
@@ -691,7 +726,8 @@ function parseRstFile(
691
726
  parsedText = rendered.text;
692
727
  parsedHeadings = rendered.headings;
693
728
  parsedExcerpt = rendered.excerpt;
694
- parsedReadingTime = rendered.readingTime;
729
+ parsedReadingMinutes = rendered.readingMinutes;
730
+ parsedWordCount = rendered.wordCount;
695
731
  parsedHtml = rendered.html;
696
732
  data = rendered.metadata;
697
733
  } else if (shouldUsePythonRstRenderer() && pythonRstRendererAvailable !== false) {
@@ -702,7 +738,8 @@ function parseRstFile(
702
738
  parsedText = rendered.text;
703
739
  parsedHeadings = rendered.headings;
704
740
  parsedExcerpt = rendered.excerpt;
705
- parsedReadingTime = rendered.readingTime;
741
+ parsedReadingMinutes = rendered.readingMinutes;
742
+ parsedWordCount = rendered.wordCount;
706
743
  parsedHtml = rendered.html;
707
744
  data = rendered.metadata;
708
745
  } else {
@@ -720,7 +757,8 @@ function parseRstFile(
720
757
  parsedBody = parsed.body;
721
758
  parsedHeadings = parsed.headings;
722
759
  parsedExcerpt = parsed.excerpt;
723
- parsedReadingTime = parsed.readingTime;
760
+ parsedReadingMinutes = parsed.readingMinutes;
761
+ parsedWordCount = parsed.wordCount;
724
762
  data = parsed.metadata;
725
763
  }
726
764
 
@@ -783,7 +821,8 @@ function parseRstFile(
783
821
  toc: data.toc ?? true,
784
822
  commentable: data.commentable,
785
823
  redirectFrom: data.redirectFrom ?? [],
786
- readingTime: parsedReadingTime,
824
+ readingMinutes: parsedReadingMinutes,
825
+ wordCount: parsedWordCount,
787
826
  content: parsedBody,
788
827
  renderedHtml: parsedHtml,
789
828
  plainText: parsedText,
@@ -1456,14 +1495,32 @@ export function getCollectionsForPost(postSlug: string): CollectionContext[] {
1456
1495
  export interface BookChapterEntry {
1457
1496
  title: string;
1458
1497
  id: string;
1498
+ /** Legacy single-level grouping; set when the chapter sits under a `{ part, chapters }` item. */
1459
1499
  part?: string;
1500
+ /** Deepest section title above this chapter (last element of sectionPath). */
1501
+ section?: string;
1502
+ /** Full ancestry of section titles from outermost to innermost. */
1503
+ sectionPath?: string[];
1504
+ }
1505
+
1506
+ export interface BookChapterRef {
1507
+ title: string;
1508
+ id: string;
1460
1509
  }
1461
1510
 
1462
1511
  export interface BookTocPart {
1463
1512
  part: string;
1464
- chapters: { title: string; id: string }[];
1513
+ chapters: BookChapterRef[];
1465
1514
  }
1466
- export type BookTocItem = BookTocPart | { title: string; id: string };
1515
+
1516
+ /** Nested grouping. `items` may recurse into further sections or hold leaf chapter refs. */
1517
+ export interface BookTocSection {
1518
+ section: string;
1519
+ collapsible?: boolean;
1520
+ items: Array<BookTocSection | BookChapterRef>;
1521
+ }
1522
+
1523
+ export type BookTocItem = BookTocPart | BookTocSection | BookChapterRef;
1467
1524
 
1468
1525
  export interface BookData {
1469
1526
  title: string;
@@ -1474,6 +1531,16 @@ export interface BookData {
1474
1531
  featured: boolean;
1475
1532
  draft: boolean;
1476
1533
  authors: string[];
1534
+ /** Book-level LaTeX flag — when true, all chapters render math even if their
1535
+ * own frontmatter omits `latex: true`. Cheaper for math-heavy books than
1536
+ * annotating every chapter file. */
1537
+ latex: boolean;
1538
+ /** Whether the chapter-page header renders the chapter's `excerpt`. Defaults
1539
+ * to false: the typical case is that a chapter opens with its own lede
1540
+ * paragraph, and an excerpt line above it just duplicates that text in the
1541
+ * header. Set to true on books where the excerpt is a distinct subtitle
1542
+ * the author actually wants the reader to see at the top of every chapter. */
1543
+ showChapterExcerpt: boolean;
1477
1544
  content: string;
1478
1545
  toc: BookTocItem[];
1479
1546
  chapters: BookChapterEntry[];
@@ -1488,8 +1555,11 @@ export interface BookChapterData {
1488
1555
  excerpt?: string;
1489
1556
  latex: boolean;
1490
1557
  commentable?: boolean;
1491
- readingTime: string;
1558
+ readingMinutes: number;
1559
+ wordCount: number;
1492
1560
  isFolder: boolean;
1561
+ /** Absolute path of the markdown source file. Used to resolve relative `.md` links. */
1562
+ sourcePath: string;
1493
1563
  prevChapter: { title: string; id: string } | null;
1494
1564
  nextChapter: { title: string; id: string } | null;
1495
1565
  }
@@ -1499,15 +1569,25 @@ const BookChapterRefSchema = z.object({
1499
1569
  id: z.string(),
1500
1570
  });
1501
1571
 
1572
+ // Recursive: a section can nest further sections or leaf chapter refs.
1573
+ const BookTocSectionSchema: z.ZodType<BookTocSection> = z.lazy(() =>
1574
+ z.object({
1575
+ section: z.string(),
1576
+ collapsible: z.boolean().optional(),
1577
+ items: z.array(z.union([BookTocSectionSchema, BookChapterRefSchema])),
1578
+ })
1579
+ );
1580
+
1502
1581
  const BookTocItemSchema: z.ZodType<BookTocItem> = z.union([
1503
1582
  z.object({
1504
1583
  part: z.string(),
1505
1584
  chapters: z.array(BookChapterRefSchema),
1506
1585
  }),
1586
+ BookTocSectionSchema,
1507
1587
  BookChapterRefSchema,
1508
1588
  ]);
1509
1589
 
1510
- const BookSchema = z.object({
1590
+ export const BookSchema = z.object({
1511
1591
  title: z.string(),
1512
1592
  excerpt: z.string().optional(),
1513
1593
  date: z.union([z.string(), z.date()]).transform(val => new Date(val).toISOString().split('T')[0]),
@@ -1515,24 +1595,47 @@ const BookSchema = z.object({
1515
1595
  featured: z.boolean().optional().default(false),
1516
1596
  draft: z.boolean().optional().default(false),
1517
1597
  authors: z.array(z.string()).optional().default([]),
1598
+ latex: z.boolean().optional().default(false),
1599
+ showChapterExcerpt: z.boolean().optional().default(false),
1518
1600
  chapters: z.array(BookTocItemSchema),
1519
1601
  });
1520
1602
 
1521
1603
  const BookChapterSchema = z.object({
1522
- title: z.string(),
1604
+ title: z.string().optional(),
1523
1605
  excerpt: z.string().optional(),
1524
1606
  draft: z.boolean().optional().default(false),
1525
1607
  latex: z.boolean().optional().default(false),
1526
1608
  commentable: z.boolean().optional(),
1527
1609
  });
1528
1610
 
1529
- function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
1611
+ export function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
1530
1612
  const result: BookChapterEntry[] = [];
1613
+
1614
+ const walkSection = (
1615
+ items: Array<BookTocSection | BookChapterRef>,
1616
+ sectionPath: string[],
1617
+ ): void => {
1618
+ for (const item of items) {
1619
+ if ('section' in item) {
1620
+ walkSection(item.items, [...sectionPath, item.section]);
1621
+ } else {
1622
+ result.push({
1623
+ title: item.title,
1624
+ id: item.id,
1625
+ section: sectionPath[sectionPath.length - 1],
1626
+ sectionPath: sectionPath.length > 0 ? [...sectionPath] : undefined,
1627
+ });
1628
+ }
1629
+ }
1630
+ };
1631
+
1531
1632
  for (const item of toc) {
1532
1633
  if ('part' in item) {
1533
1634
  for (const ch of item.chapters) {
1534
1635
  result.push({ title: ch.title, id: ch.id, part: item.part });
1535
1636
  }
1637
+ } else if ('section' in item) {
1638
+ walkSection([item], []);
1536
1639
  } else {
1537
1640
  result.push({ title: item.title, id: item.id });
1538
1641
  }
@@ -1540,6 +1643,35 @@ function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
1540
1643
  return result;
1541
1644
  }
1542
1645
 
1646
+ /**
1647
+ * Resolves a chapter id (possibly nested with `/`) to a markdown file on disk.
1648
+ * Returns `{ path, isFolder }` if a file exists in one of the four supported forms,
1649
+ * or `null` if the id has no match. Guards against `..`-style path escapes — any
1650
+ * id that resolves outside `bookDir` returns null.
1651
+ */
1652
+ function resolveChapterFilePath(
1653
+ bookDir: string,
1654
+ chapterId: string,
1655
+ ): { path: string; isFolder: boolean } | null {
1656
+ const bookDirResolved = path.resolve(bookDir);
1657
+ const candidate = path.resolve(bookDir, chapterId);
1658
+ if (
1659
+ candidate !== bookDirResolved &&
1660
+ !candidate.startsWith(bookDirResolved + path.sep)
1661
+ ) {
1662
+ return null;
1663
+ }
1664
+ const chMdx = `${candidate}.mdx`;
1665
+ const chMd = `${candidate}.md`;
1666
+ const chFolderMdx = path.join(candidate, 'index.mdx');
1667
+ const chFolderMd = path.join(candidate, 'index.md');
1668
+ if (fs.existsSync(chMdx)) return { path: chMdx, isFolder: false };
1669
+ if (fs.existsSync(chMd)) return { path: chMd, isFolder: false };
1670
+ if (fs.existsSync(chFolderMdx)) return { path: chFolderMdx, isFolder: true };
1671
+ if (fs.existsSync(chFolderMd)) return { path: chFolderMd, isFolder: true };
1672
+ return null;
1673
+ }
1674
+
1543
1675
  export function getBookData(slug: string): BookData | null {
1544
1676
  if (!fs.existsSync(booksDirectory)) return null;
1545
1677
  const bookDir = path.join(booksDirectory, slug);
@@ -1562,17 +1694,22 @@ export function getBookData(slug: string): BookData | null {
1562
1694
  }
1563
1695
  const data = parsed.data;
1564
1696
 
1565
- // Warn about missing chapter files
1697
+ // Resolve chapter file paths and surface missing files as build-time errors
1698
+ // (strict-build invariant: misconfiguration must fail loudly, not silently).
1566
1699
  const chapters = flattenBookChapters(data.chapters);
1700
+ const missing: string[] = [];
1567
1701
  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`);
1702
+ if (!resolveChapterFilePath(bookDir, ch.id)) {
1703
+ missing.push(ch.id);
1574
1704
  }
1575
1705
  }
1706
+ if (missing.length > 0) {
1707
+ throw new Error(
1708
+ `[amytis] Book "${slug}" references chapter${missing.length === 1 ? '' : 's'} ` +
1709
+ `with no matching file on disk: ${missing.map(id => `"${id}"`).join(', ')}. ` +
1710
+ `Expected one of <bookDir>/<id>.{md,mdx} or <bookDir>/<id>/index.{md,mdx}.`
1711
+ );
1712
+ }
1576
1713
 
1577
1714
  let coverImage = data.coverImage;
1578
1715
  if (coverImage && !coverImage.startsWith('http') && !coverImage.startsWith('/') && !coverImage.startsWith('text:')) {
@@ -1594,6 +1731,8 @@ export function getBookData(slug: string): BookData | null {
1594
1731
  featured: data.featured,
1595
1732
  draft: data.draft,
1596
1733
  authors,
1734
+ latex: data.latex,
1735
+ showChapterExcerpt: data.showChapterExcerpt,
1597
1736
  content: content.trim(),
1598
1737
  toc: data.chapters,
1599
1738
  chapters,
@@ -1605,17 +1744,9 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
1605
1744
  if (!book) return null;
1606
1745
 
1607
1746
  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;
1747
+ const resolved = resolveChapterFilePath(bookDir, chapterSlug);
1748
+ if (!resolved) return null;
1749
+ const { path: fullPath, isFolder } = resolved;
1619
1750
 
1620
1751
  const fileContents = fs.readFileSync(fullPath, 'utf8');
1621
1752
  const { data: rawData, content } = matter(fileContents);
@@ -1633,7 +1764,8 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
1633
1764
 
1634
1765
  const contentWithoutH1 = content.replace(/^\s*#\s+[^\n]+/, '').trim();
1635
1766
  const headings = getHeadings(content);
1636
- const readingTime = calculateReadingTime(contentWithoutH1);
1767
+ const readingMinutes = calculateReadingMinutes(contentWithoutH1);
1768
+ const wordCount = calculateWordCount(contentWithoutH1);
1637
1769
  const excerpt = data.excerpt || generateExcerpt(contentWithoutH1);
1638
1770
 
1639
1771
  // Find prev/next
@@ -1641,22 +1773,40 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
1641
1773
  const prevChapter = chapterIndex > 0 ? book.chapters[chapterIndex - 1] : null;
1642
1774
  const nextChapter = chapterIndex < book.chapters.length - 1 ? book.chapters[chapterIndex + 1] : null;
1643
1775
 
1776
+ // Title resolution: frontmatter wins, then book TOC entry, then first H1
1777
+ // in the body. VuePress chapters often omit frontmatter entirely and rely
1778
+ // on the H1 as the title, so this fallback chain keeps the import flow lossless.
1779
+ const fallbackFromToc = chapterIndex >= 0 ? book.chapters[chapterIndex].title : undefined;
1780
+ const h1Match = content.match(/^\s*#\s+([^\n]+)/);
1781
+ const fallbackFromH1 = h1Match?.[1].trim();
1782
+ const title = data.title || fallbackFromToc || fallbackFromH1 || chapterSlug;
1783
+
1644
1784
  return {
1645
- title: data.title,
1785
+ title,
1646
1786
  slug: chapterSlug,
1647
1787
  bookSlug,
1648
1788
  content: contentWithoutH1,
1649
1789
  headings,
1650
1790
  excerpt,
1651
- latex: data.latex,
1791
+ // Chapter-level `latex: true` takes precedence; otherwise inherit the
1792
+ // book-level flag so math-heavy books don't need per-chapter annotation.
1793
+ latex: data.latex || book.latex,
1652
1794
  commentable: data.commentable,
1653
- readingTime,
1795
+ readingMinutes,
1796
+ wordCount,
1654
1797
  isFolder,
1798
+ sourcePath: fullPath,
1655
1799
  prevChapter: prevChapter ? { title: prevChapter.title, id: prevChapter.id } : null,
1656
1800
  nextChapter: nextChapter ? { title: nextChapter.title, id: nextChapter.id } : null,
1657
1801
  };
1658
1802
  }
1659
1803
 
1804
+ /** Absolute path of a book's content directory. Useful for plugins that
1805
+ * need to resolve relative paths from chapter source files. */
1806
+ export function getBookDirPath(bookSlug: string): string {
1807
+ return path.join(booksDirectory, bookSlug);
1808
+ }
1809
+
1660
1810
  export function getAllBooks(): BookData[] {
1661
1811
  if (!fs.existsSync(booksDirectory)) return [];
1662
1812
 
@@ -1874,7 +2024,8 @@ export interface NoteData {
1874
2024
  content: string;
1875
2025
  excerpt: string;
1876
2026
  headings: Heading[];
1877
- readingTime: string;
2027
+ readingMinutes: number;
2028
+ wordCount: number;
1878
2029
  }
1879
2030
 
1880
2031
  function parseNoteFile(fullPath: string, slug: string): NoteData {
@@ -1892,7 +2043,8 @@ function parseNoteFile(fullPath: string, slug: string): NoteData {
1892
2043
  const date = data.date || fs.statSync(fullPath).mtime.toISOString().split('T')[0];
1893
2044
  const excerpt = generateExcerpt(contentWithoutH1);
1894
2045
  const headings = getHeadings(content);
1895
- const readingTime = calculateReadingTime(contentWithoutH1);
2046
+ const readingMinutes = calculateReadingMinutes(contentWithoutH1);
2047
+ const wordCount = calculateWordCount(contentWithoutH1);
1896
2048
 
1897
2049
  return {
1898
2050
  slug,
@@ -1907,7 +2059,8 @@ function parseNoteFile(fullPath: string, slug: string): NoteData {
1907
2059
  content: contentWithoutH1,
1908
2060
  excerpt,
1909
2061
  headings,
1910
- readingTime,
2062
+ readingMinutes,
2063
+ wordCount,
1911
2064
  };
1912
2065
  }
1913
2066
 
@@ -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
+ }