@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.
- package/CHANGELOG.md +26 -0
- package/CLAUDE.md +90 -219
- package/bun.lock +185 -547
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +217 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +178 -0
- package/eslint.config.mjs +18 -6
- package/package.json +42 -20
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/render-rst.py +207 -3
- package/scripts/sync-vuepress-book.ts +499 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/page.tsx +67 -32
- package/src/app/globals.css +503 -123
- package/src/app/page.tsx +1 -1
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CodeBlock.test.tsx +93 -8
- package/src/components/CodeBlock.tsx +39 -101
- package/src/components/CodeBlockToolbar.tsx +88 -0
- package/src/components/CodeGroup.tsx +81 -0
- package/src/components/CoverImage.tsx +1 -0
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +3 -3
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +144 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/RstRenderer.test.tsx +15 -15
- package/src/components/RstRenderer.tsx +37 -2
- package/src/components/Search.tsx +18 -4
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/i18n/translations.ts +2 -0
- package/src/layouts/BookLayout.tsx +35 -4
- package/src/layouts/PostLayout.tsx +5 -1
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +203 -50
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/remark-book-chapter-links.ts +106 -0
- package/src/lib/remark-code-group.ts +54 -0
- package/src/lib/remark-github-alerts.test.ts +83 -0
- package/src/lib/remark-github-alerts.ts +65 -0
- package/src/lib/remark-vuepress-containers.ts +130 -0
- package/src/lib/rst-renderer.ts +19 -7
- package/src/lib/rst.test.ts +212 -2
- package/src/lib/rst.ts +217 -13
- package/src/lib/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/urls.ts +57 -0
- package/src/test-utils/render.ts +23 -0
- package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
- package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
- package/tests/helpers/env.ts +19 -0
- package/tests/integration/book-chapter-links.test.ts +107 -0
- package/tests/integration/books-nested-toc.test.ts +176 -0
- package/tests/integration/books.test.ts +3 -2
- package/tests/integration/code-block-features.test.ts +188 -0
- package/tests/integration/code-group.test.ts +183 -0
- package/tests/integration/code-notation.test.ts +97 -0
- package/tests/integration/github-alerts.test.ts +82 -0
- package/tests/integration/markdown-external-links.test.ts +103 -0
- package/tests/integration/normalize-vuepress-math.test.ts +149 -0
- package/tests/integration/reading-time-headings.test.ts +8 -6
- package/tests/integration/series-draft.test.ts +6 -13
- package/tests/integration/sync-vuepress-book.test.ts +240 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/static-params.test.ts +32 -19
package/src/lib/markdown.test.ts
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
70
|
-
test("short content returns 1
|
|
70
|
+
describe("calculateReadingMinutes", () => {
|
|
71
|
+
test("short content returns 1 minute (floor)", () => {
|
|
71
72
|
const text = "Hello world, this is a short post.";
|
|
72
|
-
expect(
|
|
73
|
+
expect(calculateReadingMinutes(text)).toBe(1);
|
|
73
74
|
});
|
|
74
75
|
|
|
75
|
-
test("600 words returns 3
|
|
76
|
+
test("600 words returns 3 minutes", () => {
|
|
76
77
|
const words = Array(600).fill("word").join(" ");
|
|
77
|
-
expect(
|
|
78
|
+
expect(calculateReadingMinutes(words)).toBe(3);
|
|
78
79
|
});
|
|
79
80
|
|
|
80
|
-
test("empty content returns 1
|
|
81
|
-
expect(
|
|
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
|
-
|
|
88
|
-
expect(result).toBe("2 min read");
|
|
88
|
+
expect(calculateReadingMinutes(words)).toBe(2);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
test("counts Chinese characters
|
|
91
|
+
test("counts Chinese characters at 300 cpm", () => {
|
|
92
92
|
const han = "中".repeat(600);
|
|
93
|
-
expect(
|
|
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(
|
|
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
|
|
package/src/lib/markdown.ts
CHANGED
|
@@ -123,7 +123,8 @@ export interface PostData {
|
|
|
123
123
|
commentable?: boolean;
|
|
124
124
|
externalLinks?: ExternalLink[];
|
|
125
125
|
redirectFrom?: string[];
|
|
126
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1513
|
+
chapters: BookChapterRef[];
|
|
1465
1514
|
}
|
|
1466
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1569
|
-
|
|
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
|
|
1609
|
-
|
|
1610
|
-
const
|
|
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
|
|
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
|
|
1785
|
+
title,
|
|
1646
1786
|
slug: chapterSlug,
|
|
1647
1787
|
bookSlug,
|
|
1648
1788
|
content: contentWithoutH1,
|
|
1649
1789
|
headings,
|
|
1650
1790
|
excerpt,
|
|
1651
|
-
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|