@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.
- package/.claude/rules/immersive-reading.md +21 -0
- package/.claude/rules/rst.md +13 -0
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +89 -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 +298 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +237 -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 +710 -0
- package/site.config.example.ts +3 -3
- package/site.config.ts +3 -3
- package/src/app/[slug]/layout.tsx +30 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +85 -34
- package/src/app/globals.css +570 -123
- package/src/app/page.tsx +7 -1
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookReadingShell.tsx +145 -0
- 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/CuratedSeriesSection.tsx +28 -10
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +44 -23
- package/src/components/Footer.tsx +1 -1
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/ImmersiveReader.tsx +130 -0
- package/src/components/ImmersiveReaderTopBar.tsx +106 -0
- package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
- package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
- package/src/components/ImmersiveReadingProvider.tsx +168 -0
- package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
- package/src/components/ImmersiveToggleButton.tsx +45 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +175 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/ReadingProgressBar.tsx +1 -1
- 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/SelectedBooksSection.tsx +27 -8
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +44 -0
- package/src/layouts/BookLayout.tsx +62 -74
- package/src/layouts/PostLayout.tsx +154 -111
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +217 -57
- 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/scroll-utils.ts +44 -6
- 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/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +62 -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/book-index-cta.test.ts +87 -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/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +443 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/tests/unit/static-params.test.ts +32 -19
- package/vercel.json +7 -0
package/src/lib/markdown.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1253
|
+
posts.sort(byDateAsc);
|
|
1214
1254
|
} else {
|
|
1215
|
-
posts.sort(
|
|
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(
|
|
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:
|
|
1514
|
+
chapters: BookChapterRef[];
|
|
1465
1515
|
}
|
|
1466
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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`);
|
|
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
|
|
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;
|
|
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
|
|
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
|
|
1792
|
+
title,
|
|
1646
1793
|
slug: chapterSlug,
|
|
1647
1794
|
bookSlug,
|
|
1648
1795
|
content: contentWithoutH1,
|
|
1649
1796
|
headings,
|
|
1650
1797
|
excerpt,
|
|
1651
|
-
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|