@hutusi/amytis 1.14.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 (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +90 -219
  5. package/README.md +33 -1
  6. package/README.zh.md +33 -1
  7. package/TODO.md +10 -0
  8. package/bun.lock +205 -539
  9. package/content/books/sample-book/index.mdx +3 -0
  10. package/content/posts/code-block-features-showcase.mdx +223 -0
  11. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  12. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  13. package/content/series/rst-legacy/getting-started.rst +24 -0
  14. package/content/series/rst-legacy/index.rst +9 -0
  15. package/content/series/rst-readme/README.rst +9 -0
  16. package/content/series/rst-readme/readme-index-post.rst +10 -0
  17. package/content/series/rst-toctree/first-post.rst +6 -0
  18. package/content/series/rst-toctree/index.rst +10 -0
  19. package/content/series/rst-toctree/second-post.rst +6 -0
  20. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/index.rst +12 -0
  22. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  23. package/docs/ALERTS.md +112 -0
  24. package/docs/ARCHITECTURE.md +239 -8
  25. package/docs/CODE-BLOCKS.md +238 -0
  26. package/docs/CONTRIBUTING.md +36 -0
  27. package/docs/guides/README.md +11 -0
  28. package/docs/guides/importing-vuepress-books.md +178 -0
  29. package/eslint.config.mjs +20 -6
  30. package/next.config.ts +2 -2
  31. package/package.json +52 -24
  32. package/packages/create-amytis/package.json +1 -1
  33. package/packages/create-amytis/src/index.test.ts +43 -1
  34. package/packages/create-amytis/src/index.ts +64 -8
  35. package/public/next-image-export-optimizer-hashes.json +14 -73
  36. package/scripts/build-pagefind.ts +172 -0
  37. package/scripts/copy-assets.ts +246 -56
  38. package/scripts/generate-code-group-icons.ts +79 -0
  39. package/scripts/generate-knowledge-graph.ts +2 -1
  40. package/scripts/render-rst.py +923 -0
  41. package/scripts/run-with-rst-python.ts +42 -0
  42. package/scripts/sync-vuepress-book.ts +499 -0
  43. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  44. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  45. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  46. package/src/app/books/[slug]/page.tsx +67 -32
  47. package/src/app/globals.css +639 -94
  48. package/src/app/page.tsx +1 -1
  49. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  50. package/src/app/series/[slug]/page.tsx +11 -13
  51. package/src/app/series/page.tsx +3 -3
  52. package/src/app/sitemap.ts +3 -3
  53. package/src/components/ArticleCopyCleaner.tsx +64 -0
  54. package/src/components/AuthorCard.tsx +25 -16
  55. package/src/components/BookMobileNav.tsx +44 -50
  56. package/src/components/BookSidebar.tsx +0 -0
  57. package/src/components/CodeBlock.test.tsx +93 -8
  58. package/src/components/CodeBlock.tsx +39 -101
  59. package/src/components/CodeBlockToolbar.tsx +88 -0
  60. package/src/components/CodeGroup.tsx +81 -0
  61. package/src/components/CoverImage.tsx +6 -2
  62. package/src/components/ExternalLinkIcon.tsx +15 -0
  63. package/src/components/FeaturedStoriesSection.tsx +3 -3
  64. package/src/components/GithubAlert.tsx +97 -0
  65. package/src/components/MarkdownRenderer.test.tsx +30 -4
  66. package/src/components/MarkdownRenderer.tsx +148 -24
  67. package/src/components/Mermaid.tsx +32 -1
  68. package/src/components/PostList.tsx +1 -1
  69. package/src/components/PostNavigation.tsx +13 -2
  70. package/src/components/PostSidebar.tsx +13 -2
  71. package/src/components/RstRenderer.test.tsx +93 -0
  72. package/src/components/RstRenderer.tsx +157 -0
  73. package/src/components/Search.tsx +18 -4
  74. package/src/components/SeriesCatalog.tsx +1 -1
  75. package/src/components/ShareBar.tsx +5 -0
  76. package/src/components/TocPanel.tsx +10 -2
  77. package/src/i18n/translations.ts +2 -0
  78. package/src/layouts/BookLayout.tsx +35 -4
  79. package/src/layouts/PostLayout.tsx +10 -2
  80. package/src/layouts/SimpleLayout.tsx +10 -3
  81. package/src/lib/code-group-icons.test.ts +78 -0
  82. package/src/lib/code-group-icons.ts +148 -0
  83. package/src/lib/image-utils.test.ts +19 -0
  84. package/src/lib/image-utils.ts +11 -0
  85. package/src/lib/markdown.test.ts +195 -14
  86. package/src/lib/markdown.ts +928 -254
  87. package/src/lib/normalize-vuepress-math.ts +118 -0
  88. package/src/lib/rehype-fence-meta.ts +22 -0
  89. package/src/lib/rehype-image-metadata.ts +2 -2
  90. package/src/lib/remark-book-chapter-links.ts +106 -0
  91. package/src/lib/remark-code-group.ts +54 -0
  92. package/src/lib/remark-github-alerts.test.ts +83 -0
  93. package/src/lib/remark-github-alerts.ts +65 -0
  94. package/src/lib/remark-vuepress-containers.ts +130 -0
  95. package/src/lib/rst-renderer.test.ts +355 -0
  96. package/src/lib/rst-renderer.ts +629 -0
  97. package/src/lib/rst.test.ts +350 -0
  98. package/src/lib/rst.ts +674 -0
  99. package/src/lib/series-redirects.ts +42 -0
  100. package/src/lib/shiki-rst.ts +185 -0
  101. package/src/lib/shiki.test.ts +153 -0
  102. package/src/lib/shiki.ts +292 -0
  103. package/src/lib/urls.ts +57 -0
  104. package/src/test-utils/render.ts +23 -0
  105. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  106. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  107. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  108. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  109. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  110. package/tests/helpers/env.ts +19 -0
  111. package/tests/integration/book-chapter-links.test.ts +107 -0
  112. package/tests/integration/books-nested-toc.test.ts +176 -0
  113. package/tests/integration/books.test.ts +3 -2
  114. package/tests/integration/code-block-features.test.ts +188 -0
  115. package/tests/integration/code-group.test.ts +183 -0
  116. package/tests/integration/code-notation.test.ts +97 -0
  117. package/tests/integration/feed-utils.test.ts +13 -0
  118. package/tests/integration/github-alerts.test.ts +82 -0
  119. package/tests/integration/markdown-external-links.test.ts +103 -0
  120. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  121. package/tests/integration/reading-time-headings.test.ts +12 -14
  122. package/tests/integration/series-draft.test.ts +12 -5
  123. package/tests/integration/series.test.ts +93 -0
  124. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  125. package/tests/integration/vuepress-containers.test.ts +107 -0
  126. package/tests/tooling/build-pagefind.test.ts +66 -0
  127. package/tests/tooling/new-post.test.ts +1 -1
  128. package/tests/unit/static-params.test.ts +166 -13
@@ -5,6 +5,8 @@ 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 { parseRstDocument, RstParseError } from './rst';
9
+ import { renderRstFile, renderRstFilesBatch, type RenderedRstDocument } from './rst-renderer';
8
10
 
9
11
  const contentDirectory = path.join(process.cwd(), 'content', 'posts');
10
12
  const pagesDirectory = path.join(process.cwd(), 'content');
@@ -13,6 +15,10 @@ const booksDirectory = path.join(process.cwd(), 'content', 'books');
13
15
  const flowsDirectory = path.join(process.cwd(), 'content', 'flows');
14
16
  const notesDirectory = path.join(process.cwd(), 'content', 'notes');
15
17
 
18
+ function readUtf8File(filePath: string): string {
19
+ return fs.readFileSync(/* turbopackIgnore: true */ filePath, 'utf8');
20
+ }
21
+
16
22
  const ExternalLinkSchema = z.object({
17
23
  name: z.string(),
18
24
  url: z.string().url(),
@@ -117,19 +123,151 @@ export interface PostData {
117
123
  commentable?: boolean;
118
124
  externalLinks?: ExternalLink[];
119
125
  redirectFrom?: string[];
120
- readingTime: string;
126
+ readingMinutes: number;
127
+ wordCount: number;
121
128
  content: string;
129
+ renderedHtml?: string;
130
+ plainText?: string;
122
131
  headings: Heading[];
123
132
  contentLocales?: Record<string, { content: string; title?: string; excerpt?: string; headings?: Heading[] }>;
124
133
  /** Public-relative base path used for resolving co-located images (e.g. "posts/my-post" or "posts" for root flat files). */
125
134
  imageBaseSlug: string;
135
+ sourceFormat?: 'markdown' | 'rst';
126
136
  }
127
137
 
128
- export function calculateReadingTime(content: string): string {
129
- const wordsPerMinute = 200;
130
- const hanCharsPerMinute = 300;
138
+ type SeriesFormat = 'markdown' | 'rst';
139
+
140
+ interface SeriesIndexInfo {
141
+ format: SeriesFormat;
142
+ fullPath: string;
143
+ }
144
+
145
+ interface SeriesContentEntry {
146
+ fullPath: string;
147
+ slug: string;
148
+ dateFromFileName?: string;
149
+ }
150
+
151
+ interface PendingRstPostEntry {
152
+ fullPath: string;
153
+ slug: string;
154
+ dateFromFileName?: string;
155
+ seriesSlug?: string;
156
+ }
157
+
158
+ function getCacheEnvKey(): string {
159
+ return process.env.NODE_ENV === 'production' ? 'production' : 'development';
160
+ }
161
+
162
+ const postsCache = new Map<string, PostData[]>();
163
+ const pagesCache = new Map<string, PostData[]>();
164
+ const tagsCache = new Map<string, Record<string, number>>();
165
+ const authorsCache = new Map<string, Record<string, number>>();
166
+ const featuredPostsCache = new Map<string, PostData[]>();
167
+ const adjacentPostsCache = new Map<string, Map<string, { prev: PostData | null; next: PostData | null }>>();
168
+ const relatedPostsCache = new Map<string, Map<string, PostData[]>>();
169
+ const seriesDataCache = new Map<string, Map<string, PostData | null>>();
170
+ const seriesPostsCache = new Map<string, Map<string, PostData[]>>();
171
+ const allSeriesCache = new Map<string, Record<string, PostData[]>>();
172
+ const featuredSeriesCache = new Map<string, Record<string, PostData[]>>();
173
+ const seriesLatestDateCache = new Map<string, Map<string, string>>();
174
+ const collectionPostsCache = new Map<string, Map<string, PostData[]>>();
175
+ const collectionsForPostCache = new Map<string, Map<string, CollectionContext[]>>();
176
+ const seriesAuthorsCache = new Map<string, Map<string, string[] | null>>();
177
+ const seriesTitleCache = new Map<string, Map<string, string | undefined>>();
178
+ let pythonRstRendererAvailable: boolean | null = null;
179
+
180
+ const PYTHON_RUNTIME_UNAVAILABLE_PATTERN = /docutils|No module named|python(?:3)? .*not found|interpreter not found|ENOENT.*python/i;
181
+
182
+ function isPythonRuntimeUnavailable(error: unknown): boolean {
183
+ if (!(error instanceof Error)) return false;
184
+ if (error.message.includes('__RST_FALLBACK__')) return true;
185
+ if (error.message.includes('rST file not found')) return false;
186
+ return PYTHON_RUNTIME_UNAVAILABLE_PATTERN.test(error.message);
187
+ }
188
+
189
+ function getRstImageBaseSlug(fullPath: string, slug: string): string {
190
+ const isRootFlatPost = path.basename(fullPath) !== 'index.rst' &&
191
+ path.dirname(fullPath) === contentDirectory;
192
+ return isRootFlatPost ? 'posts' : `posts/${slug}`;
193
+ }
194
+
195
+ function isSeriesIndexRst(fullPath: string, slug: string, seriesName?: string): boolean {
196
+ return Boolean(
197
+ seriesName &&
198
+ slug === seriesName &&
199
+ (path.basename(fullPath) === 'index.rst' || path.basename(fullPath) === 'README.rst')
200
+ );
201
+ }
202
+
203
+ function slugFromRstToctreeTarget(target: string): string | null {
204
+ const trimmed = target.trim();
205
+ if (!trimmed || trimmed.startsWith(':')) return null;
206
+ if (/^[a-z]+:\/\//i.test(trimmed) || trimmed.startsWith('/')) return null;
207
+
208
+ const withoutAnchor = trimmed.split('#')[0]?.split('?')[0]?.trim();
209
+ if (!withoutAnchor) return null;
210
+
211
+ const normalized = withoutAnchor.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
212
+ if (!normalized || normalized.startsWith('../')) return null;
213
+
214
+ const withoutExt = normalized.replace(/\.rst$/i, '');
215
+ const parts = withoutExt.split('/').filter(Boolean);
216
+ if (parts.length === 0) return null;
217
+
218
+ const last = parts[parts.length - 1];
219
+ if (last === 'index' || last === 'README') {
220
+ return parts.length > 1 ? parts[parts.length - 2] : null;
221
+ }
222
+
223
+ return last;
224
+ }
225
+
226
+ function extractRstToctreePosts(source: string): string[] {
227
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
228
+ const posts: string[] = [];
229
+ const seen = new Set<string>();
230
+
231
+ for (let i = 0; i < lines.length; i++) {
232
+ if (!/^\s*\.\.\s+toctree::\s*$/.test(lines[i])) continue;
233
+
234
+ i++;
235
+ while (i < lines.length) {
236
+ const line = lines[i];
237
+ if (!line.trim()) {
238
+ i++;
239
+ continue;
240
+ }
241
+ if (!/^\s+/.test(line)) {
242
+ i--;
243
+ break;
244
+ }
245
+
246
+ const trimmed = line.trim();
247
+ if (!trimmed.startsWith(':')) {
248
+ const slug = slugFromRstToctreeTarget(trimmed);
249
+ if (slug && !seen.has(slug)) {
250
+ seen.add(slug);
251
+ posts.push(slug);
252
+ }
253
+ }
254
+ i++;
255
+ }
256
+ }
131
257
 
132
- // Strip tags and common markdown syntax before counting.
258
+ return posts;
259
+ }
260
+
261
+ function shouldUsePythonRstRenderer(): boolean {
262
+ if (process.env.AMYTIS_ENABLE_PYTHON_RST === '1') return true;
263
+ if (process.env.AMYTIS_ENABLE_PYTHON_RST === '0') return false;
264
+ return process.env.NODE_ENV !== 'test';
265
+ }
266
+
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 } {
133
271
  const text = content
134
272
  .replace(/<\/?[^>]+(>|$)/g, "")
135
273
  .replace(/```[\s\S]*?```/g, "")
@@ -137,15 +275,47 @@ export function calculateReadingTime(content: string): string {
137
275
  .replace(/!\[[^\]]*\]\([^)]+\)/g, "")
138
276
  .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
139
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
+ }
140
286
 
141
- const hanCharCount = (text.match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length;
142
- const latinWordCount = (text.match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g) || []).length;
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;
143
292
 
144
- const estimatedMinutes = (latinWordCount / wordsPerMinute) + (hanCharCount / hanCharsPerMinute);
145
- const minutes = Math.max(1, Math.ceil(estimatedMinutes));
146
- return `${minutes} min read`;
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
+ }
306
+
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;
147
316
  }
148
317
 
318
+
149
319
  export function generateExcerpt(content: string): string {
150
320
  let plain = content.replace(/^#+\s+/gm, '');
151
321
  plain = plain.replace(/```[\s\S]*?```/g, '');
@@ -183,22 +353,47 @@ export function getHeadings(content: string): Heading[] {
183
353
  * Returns null if no authors are configured (as opposed to the default fallback).
184
354
  */
185
355
  export function getSeriesAuthors(seriesSlug: string): string[] | null {
186
- if (!fs.existsSync(seriesDirectory)) return null;
187
- const indexPathMdx = path.join(seriesDirectory, seriesSlug, 'index.mdx');
188
- const indexPathMd = path.join(seriesDirectory, seriesSlug, 'index.md');
356
+ const cacheKey = getCacheEnvKey();
357
+ let bySlug = seriesAuthorsCache.get(cacheKey);
358
+ if (!bySlug) {
359
+ bySlug = new Map();
360
+ seriesAuthorsCache.set(cacheKey, bySlug);
361
+ }
362
+ if (bySlug.has(seriesSlug)) return bySlug.get(seriesSlug) ?? null;
189
363
 
190
- let fullPath = '';
191
- if (fs.existsSync(indexPathMdx)) fullPath = indexPathMdx;
192
- else if (fs.existsSync(indexPathMd)) fullPath = indexPathMd;
193
- else return null;
364
+ const indexInfo = resolveSeriesIndexInfo(seriesSlug);
365
+ if (!indexInfo) {
366
+ bySlug.set(seriesSlug, null);
367
+ return null;
368
+ }
194
369
 
195
- const { data } = matter(fs.readFileSync(fullPath, 'utf8'));
370
+ if (indexInfo.format === 'rst') {
371
+ const parsed = parseRstDocument(readUtf8File(indexInfo.fullPath));
372
+ if (parsed.metadata.authors && parsed.metadata.authors.length > 0) {
373
+ bySlug.set(seriesSlug, parsed.metadata.authors);
374
+ return parsed.metadata.authors;
375
+ }
376
+ if (parsed.metadata.author && typeof parsed.metadata.author === 'string') {
377
+ const authors = [parsed.metadata.author];
378
+ bySlug.set(seriesSlug, authors);
379
+ return authors;
380
+ }
381
+ bySlug.set(seriesSlug, null);
382
+ return null;
383
+ }
384
+
385
+ const { data } = matter(readUtf8File(indexInfo.fullPath));
196
386
  if (data.authors && Array.isArray(data.authors) && data.authors.length > 0) {
197
- return data.authors;
387
+ const authors = data.authors as string[];
388
+ bySlug.set(seriesSlug, authors);
389
+ return authors;
198
390
  }
199
391
  if (data.author && typeof data.author === 'string') {
200
- return [data.author];
392
+ const authors = [data.author as string];
393
+ bySlug.set(seriesSlug, authors);
394
+ return authors;
201
395
  }
396
+ bySlug.set(seriesSlug, null);
202
397
  return null;
203
398
  }
204
399
 
@@ -221,17 +416,186 @@ export function resolveSeriesAuthors(slug: string, posts: PostData[]): string[]
221
416
  .map(([name]) => name);
222
417
  }
223
418
 
419
+ function parseSlugAndDate(rawName: string): { slug: string; dateFromFileName?: string } {
420
+ const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
421
+ const match = rawName.match(dateRegex);
422
+
423
+ if (match) {
424
+ return {
425
+ dateFromFileName: match[1],
426
+ slug: siteConfig.posts?.includeDateInUrl ? rawName : match[2],
427
+ };
428
+ }
429
+
430
+ return { slug: rawName };
431
+ }
432
+
433
+ function isMarkdownFilename(name: string): boolean {
434
+ return name.endsWith('.md') || name.endsWith('.mdx');
435
+ }
436
+
437
+ function isRstFilename(name: string): boolean {
438
+ return name.endsWith('.rst');
439
+ }
440
+
441
+ function assertSafeSeriesSlug(seriesSlug: string): void {
442
+ if (!seriesSlug || path.isAbsolute(seriesSlug)) {
443
+ throw new Error(`[amytis] Invalid series slug "${seriesSlug}".`);
444
+ }
445
+
446
+ const segments = seriesSlug.split(/[\\/]/);
447
+ if (segments.length !== 1 || segments[0] === '.' || segments[0] === '..') {
448
+ throw new Error(`[amytis] Invalid series slug "${seriesSlug}".`);
449
+ }
450
+ }
451
+
452
+ function resolveUniqueSeriesIndex(seriesSlug: string, format: SeriesFormat): string | null {
453
+ assertSafeSeriesSlug(seriesSlug);
454
+ const seriesPath = path.join(seriesDirectory, seriesSlug);
455
+ const candidates = format === 'rst'
456
+ ? ['index.rst', 'README.rst']
457
+ : ['index.mdx', 'index.md', 'README.mdx', 'README.md'];
458
+
459
+ const matches = candidates
460
+ .map(name => path.join(seriesPath, name))
461
+ .filter(fullPath => fs.existsSync(fullPath));
462
+
463
+ if (matches.length > 1) {
464
+ throw new Error(
465
+ `[amytis] Series "${seriesSlug}" has multiple ${format} index files: ${matches.map(match => path.basename(match)).join(', ')}.`
466
+ );
467
+ }
468
+
469
+ return matches[0] ?? null;
470
+ }
471
+
472
+ function resolveSeriesIndexInfo(slug: string): SeriesIndexInfo | null {
473
+ assertSafeSeriesSlug(slug);
474
+ if (!fs.existsSync(seriesDirectory)) return null;
475
+ const seriesPath = path.join(seriesDirectory, slug);
476
+ if (!fs.existsSync(seriesPath) || !fs.statSync(seriesPath).isDirectory()) return null;
477
+
478
+ const rstIndex = resolveUniqueSeriesIndex(slug, 'rst');
479
+ const markdownIndex = resolveUniqueSeriesIndex(slug, 'markdown');
480
+
481
+ if (rstIndex && markdownIndex) {
482
+ throw new Error(
483
+ `[amytis] Series "${slug}" cannot contain both rST and Markdown index files (${path.basename(rstIndex)} and ${path.basename(markdownIndex)}).`
484
+ );
485
+ }
486
+ if (rstIndex) return { format: 'rst', fullPath: rstIndex };
487
+ if (markdownIndex) return { format: 'markdown', fullPath: markdownIndex };
488
+ return null;
489
+ }
490
+
491
+ function getSeriesContentEntries(seriesSlug: string): SeriesContentEntry[] {
492
+ const indexInfo = resolveSeriesIndexInfo(seriesSlug);
493
+ if (!indexInfo) return [];
494
+
495
+ const seriesPath = path.join(seriesDirectory, seriesSlug);
496
+ const seriesItems = fs.readdirSync(seriesPath, { withFileTypes: true });
497
+ const entries: SeriesContentEntry[] = [];
498
+ const seenSlugs = new Map<string, string>();
499
+ const seriesIndexBasenames = new Set(['index.rst', 'README.rst', 'index.md', 'index.mdx', 'README.md', 'README.mdx']);
500
+
501
+ for (const item of seriesItems) {
502
+ if (seriesIndexBasenames.has(item.name)) continue;
503
+
504
+ if (item.isFile()) {
505
+ const isMarkdown = isMarkdownFilename(item.name);
506
+ const isRst = isRstFilename(item.name);
507
+ if (!isMarkdown && !isRst) continue;
508
+
509
+ const itemFormat: SeriesFormat = isRst ? 'rst' : 'markdown';
510
+ if (itemFormat !== indexInfo.format) {
511
+ throw new Error(`[amytis] Series "${seriesSlug}" mixes ${indexInfo.format} and ${itemFormat} files. Offending file: ${item.name}`);
512
+ }
513
+
514
+ const rawName = item.name.replace(/\.(mdx?|rst)$/, '');
515
+ const { slug, dateFromFileName } = parseSlugAndDate(rawName);
516
+ const prior = seenSlugs.get(slug);
517
+ if (prior) {
518
+ throw new Error(`[amytis] Series "${seriesSlug}" contains duplicate post slug "${slug}" from "${prior}" and "${item.name}".`);
519
+ }
520
+ seenSlugs.set(slug, item.name);
521
+ entries.push({ fullPath: path.join(seriesPath, item.name), slug, dateFromFileName });
522
+ continue;
523
+ }
524
+
525
+ if (item.isDirectory()) {
526
+ const folderPath = path.join(seriesPath, item.name);
527
+ const folderIndexRst = path.join(folderPath, 'index.rst');
528
+ const folderIndexMdx = path.join(folderPath, 'index.mdx');
529
+ const folderIndexMd = path.join(folderPath, 'index.md');
530
+ const hasRst = fs.existsSync(folderIndexRst);
531
+ const hasMdx = fs.existsSync(folderIndexMdx);
532
+ const hasMd = fs.existsSync(folderIndexMd);
533
+ const markdownCount = Number(hasMdx) + Number(hasMd);
534
+ const totalIndexCount = Number(hasRst) + markdownCount;
535
+
536
+ if (totalIndexCount === 0) continue;
537
+ if (hasRst && markdownCount > 0) {
538
+ throw new Error(`[amytis] Series "${seriesSlug}" post folder "${item.name}" cannot contain both index.rst and Markdown index files.`);
539
+ }
540
+ if (markdownCount > 1) {
541
+ throw new Error(`[amytis] Series "${seriesSlug}" post folder "${item.name}" cannot contain both index.md and index.mdx.`);
542
+ }
543
+
544
+ const itemFormat: SeriesFormat = hasRst ? 'rst' : 'markdown';
545
+ if (itemFormat !== indexInfo.format) {
546
+ throw new Error(`[amytis] Series "${seriesSlug}" mixes ${indexInfo.format} and ${itemFormat} files. Offending folder: ${item.name}`);
547
+ }
548
+
549
+ const { slug, dateFromFileName } = parseSlugAndDate(item.name);
550
+ const prior = seenSlugs.get(slug);
551
+ if (prior) {
552
+ throw new Error(`[amytis] Series "${seriesSlug}" contains duplicate post slug "${slug}" from "${prior}" and "${item.name}".`);
553
+ }
554
+ seenSlugs.set(slug, item.name);
555
+ entries.push({
556
+ fullPath: hasRst ? folderIndexRst : (hasMdx ? folderIndexMdx : folderIndexMd),
557
+ slug,
558
+ dateFromFileName,
559
+ });
560
+ }
561
+ }
562
+
563
+ return entries;
564
+ }
565
+
224
566
  function getSeriesTitle(slug: string): string | undefined {
225
- if (!fs.existsSync(seriesDirectory)) return undefined;
226
- const indexPathMdx = path.join(seriesDirectory, slug, 'index.mdx');
227
- const indexPathMd = path.join(seriesDirectory, slug, 'index.md');
228
- let fullPath = '';
229
- if (fs.existsSync(indexPathMdx)) fullPath = indexPathMdx;
230
- else if (fs.existsSync(indexPathMd)) fullPath = indexPathMd;
231
- else return undefined;
232
- const { data } = matter(fs.readFileSync(fullPath, 'utf8'));
233
- if (data.draft === true) return undefined;
234
- return typeof data.title === 'string' ? data.title : undefined;
567
+ const cacheKey = getCacheEnvKey();
568
+ let bySlug = seriesTitleCache.get(cacheKey);
569
+ if (!bySlug) {
570
+ bySlug = new Map();
571
+ seriesTitleCache.set(cacheKey, bySlug);
572
+ }
573
+ if (bySlug.has(slug)) return bySlug.get(slug);
574
+
575
+ const indexInfo = resolveSeriesIndexInfo(slug);
576
+ if (!indexInfo) {
577
+ bySlug.set(slug, undefined);
578
+ return undefined;
579
+ }
580
+
581
+ if (indexInfo.format === 'rst') {
582
+ const parsed = parseRstDocument(readUtf8File(indexInfo.fullPath));
583
+ if (parsed.metadata.draft === true) {
584
+ bySlug.set(slug, undefined);
585
+ return undefined;
586
+ }
587
+ bySlug.set(slug, parsed.title);
588
+ return parsed.title;
589
+ }
590
+
591
+ const { data } = matter(readUtf8File(indexInfo.fullPath));
592
+ if (data.draft === true) {
593
+ bySlug.set(slug, undefined);
594
+ return undefined;
595
+ }
596
+ const title = typeof data.title === 'string' ? data.title : undefined;
597
+ bySlug.set(slug, title);
598
+ return title;
235
599
  }
236
600
 
237
601
  function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: string, seriesName?: string): PostData {
@@ -276,11 +640,12 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
276
640
  }
277
641
 
278
642
  const excerpt = data.excerpt || generateExcerpt(contentWithoutH1);
279
- const readingTime = calculateReadingTime(contentWithoutH1);
280
-
643
+ const readingMinutes = calculateReadingMinutes(contentWithoutH1);
644
+ const wordCount = calculateWordCount(contentWithoutH1);
645
+
281
646
  let date = data.date;
282
647
  if (!date && dateFromFileName) date = dateFromFileName;
283
- if (!date) date = new Date().toISOString().split('T')[0]; // Fallback
648
+ if (!date) date = fs.statSync(fullPath).mtime.toISOString().split('T')[0];
284
649
 
285
650
  const headings = getHeadings(content);
286
651
 
@@ -315,15 +680,189 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
315
680
  items: data.items as CollectionItem[] | undefined,
316
681
  externalLinks: data.externalLinks,
317
682
  redirectFrom: data.redirectFrom,
318
- readingTime,
683
+ readingMinutes,
684
+ wordCount,
319
685
  content: contentWithoutH1,
320
686
  headings,
321
687
  imageBaseSlug,
688
+ sourceFormat: 'markdown',
322
689
  };
323
690
  }
324
691
 
692
+ export function parseMarkdownFileForTests(
693
+ fullPath: string,
694
+ slug: string,
695
+ dateFromFileName?: string,
696
+ seriesName?: string,
697
+ ): PostData {
698
+ return parseMarkdownFile(fullPath, slug, dateFromFileName, seriesName);
699
+ }
700
+
701
+ function parseRstFile(
702
+ fullPath: string,
703
+ slug: string,
704
+ dateFromFileName?: string,
705
+ seriesName?: string,
706
+ preRendered?: RenderedRstDocument,
707
+ ): PostData {
708
+ try {
709
+ const imageBaseSlug = getRstImageBaseSlug(fullPath, slug);
710
+ const fileContents = fs.readFileSync(fullPath, 'utf8');
711
+
712
+ let parsedTitle: string;
713
+ let parsedBody: string;
714
+ let parsedText: string | undefined;
715
+ let parsedHeadings: Heading[];
716
+ let parsedExcerpt: string;
717
+ let parsedReadingMinutes: number;
718
+ let parsedWordCount: number;
719
+ let parsedHtml: string | undefined;
720
+ let data: ReturnType<typeof parseRstDocument>['metadata'];
721
+ try {
722
+ if (preRendered) {
723
+ const rendered = preRendered;
724
+ parsedTitle = rendered.title;
725
+ parsedBody = rendered.text;
726
+ parsedText = rendered.text;
727
+ parsedHeadings = rendered.headings;
728
+ parsedExcerpt = rendered.excerpt;
729
+ parsedReadingMinutes = rendered.readingMinutes;
730
+ parsedWordCount = rendered.wordCount;
731
+ parsedHtml = rendered.html;
732
+ data = rendered.metadata;
733
+ } else if (shouldUsePythonRstRenderer() && pythonRstRendererAvailable !== false) {
734
+ const rendered = renderRstFile(fullPath, imageBaseSlug);
735
+ pythonRstRendererAvailable = true;
736
+ parsedTitle = rendered.title;
737
+ parsedBody = rendered.text;
738
+ parsedText = rendered.text;
739
+ parsedHeadings = rendered.headings;
740
+ parsedExcerpt = rendered.excerpt;
741
+ parsedReadingMinutes = rendered.readingMinutes;
742
+ parsedWordCount = rendered.wordCount;
743
+ parsedHtml = rendered.html;
744
+ data = rendered.metadata;
745
+ } else {
746
+ throw new Error('__RST_FALLBACK__');
747
+ }
748
+ } catch (error) {
749
+ if (!isPythonRuntimeUnavailable(error)) {
750
+ throw error;
751
+ }
752
+ if (pythonRstRendererAvailable !== false) {
753
+ pythonRstRendererAvailable = false;
754
+ }
755
+ const parsed = parseRstDocument(fileContents);
756
+ parsedTitle = parsed.title;
757
+ parsedBody = parsed.body;
758
+ parsedHeadings = parsed.headings;
759
+ parsedExcerpt = parsed.excerpt;
760
+ parsedReadingMinutes = parsed.readingMinutes;
761
+ parsedWordCount = parsed.wordCount;
762
+ data = parsed.metadata;
763
+ }
764
+
765
+ const effectiveSeriesSlug = data.series || seriesName;
766
+ let authors: string[] = [];
767
+ if (data.authors && data.authors.length > 0) {
768
+ authors = data.authors;
769
+ } else if (data.author) {
770
+ authors = [data.author];
771
+ } else {
772
+ if (effectiveSeriesSlug) {
773
+ const seriesAuthors = getSeriesAuthors(effectiveSeriesSlug);
774
+ if (seriesAuthors) authors = seriesAuthors;
775
+ }
776
+ if (authors.length === 0) {
777
+ const defaultAuthors = siteConfig.posts?.authors?.default;
778
+ if (defaultAuthors && defaultAuthors.length > 0) {
779
+ authors = defaultAuthors;
780
+ }
781
+ }
782
+ }
783
+
784
+ let date = data.date;
785
+ if (!date && dateFromFileName) date = dateFromFileName;
786
+ if (!date) date = fs.statSync(fullPath).mtime.toISOString().split('T')[0];
787
+
788
+ let coverImage = data.coverImage;
789
+ if (coverImage && !coverImage.startsWith('http') && !coverImage.startsWith('/') && !coverImage.startsWith('text:')) {
790
+ const cleanPath = coverImage.replace(/^\.\//, '');
791
+ coverImage = `/${imageBaseSlug}/${cleanPath}`;
792
+ }
793
+ const toctreePosts = isSeriesIndexRst(fullPath, slug, seriesName)
794
+ ? extractRstToctreePosts(fileContents)
795
+ : [];
796
+ const seriesPosts = data.posts && data.posts.length > 0
797
+ ? data.posts
798
+ : ((data.sort === undefined || data.sort === 'manual') && toctreePosts.length > 0 ? toctreePosts : undefined);
799
+ const sort = data.sort ?? (seriesPosts ? 'manual' : 'date-desc');
800
+
801
+ return {
802
+ slug,
803
+ title: parsedTitle,
804
+ subtitle: data.subtitle,
805
+ date,
806
+ excerpt: data.excerpt || parsedExcerpt,
807
+ category: data.category ?? 'Uncategorized',
808
+ tags: data.tags ?? [],
809
+ authors,
810
+ layout: data.layout ?? 'post',
811
+ series: effectiveSeriesSlug,
812
+ seriesTitle: effectiveSeriesSlug ? getSeriesTitle(effectiveSeriesSlug) : undefined,
813
+ coverImage,
814
+ sort,
815
+ posts: seriesPosts,
816
+ type: data.type,
817
+ featured: data.featured ?? false,
818
+ pinned: data.pinned ?? false,
819
+ draft: data.draft ?? false,
820
+ latex: data.latex ?? false,
821
+ toc: data.toc ?? true,
822
+ commentable: data.commentable,
823
+ redirectFrom: data.redirectFrom ?? [],
824
+ readingMinutes: parsedReadingMinutes,
825
+ wordCount: parsedWordCount,
826
+ content: parsedBody,
827
+ renderedHtml: parsedHtml,
828
+ plainText: parsedText,
829
+ headings: parsedHeadings,
830
+ imageBaseSlug,
831
+ sourceFormat: 'rst',
832
+ };
833
+ } catch (error) {
834
+ if (error instanceof RstParseError) {
835
+ throw new RstParseError(`${error.message} (${fullPath})`);
836
+ }
837
+ throw error;
838
+ }
839
+ }
840
+
841
+ export function parseRstFileForTests(
842
+ fullPath: string,
843
+ slug: string,
844
+ dateFromFileName?: string,
845
+ seriesName?: string,
846
+ preRendered?: RenderedRstDocument,
847
+ ): PostData {
848
+ return parseRstFile(fullPath, slug, dateFromFileName, seriesName, preRendered);
849
+ }
850
+
851
+ export function resetPythonRstRendererAvailabilityForTests(value: boolean | null = null): void {
852
+ pythonRstRendererAvailable = value;
853
+ }
854
+
855
+ export function getPythonRstRendererAvailabilityForTests(): boolean | null {
856
+ return pythonRstRendererAvailable;
857
+ }
858
+
325
859
  export function getAllPosts(): PostData[] {
860
+ const cacheKey = getCacheEnvKey();
861
+ const cached = postsCache.get(cacheKey);
862
+ if (cached) return cached;
863
+
326
864
  const allPostsData: PostData[] = [];
865
+ const pendingRstPosts: PendingRstPostEntry[] = [];
327
866
 
328
867
  // Helper to process a directory
329
868
  const processDirectory = (dir: string, isSeriesDir: boolean = false) => {
@@ -336,81 +875,29 @@ export function getAllPosts(): PostData[] {
336
875
  let slug = '';
337
876
  let dateFromFileName = undefined;
338
877
 
339
- const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
340
878
  const rawName = item.name.replace(/\.mdx?$/, '');
341
- const match = rawName.match(dateRegex);
342
-
343
- if (match) {
344
- dateFromFileName = match[1];
345
- if (siteConfig.posts?.includeDateInUrl) {
346
- slug = rawName;
347
- } else {
348
- slug = match[2];
349
- }
350
- } else {
351
- slug = rawName;
352
- }
879
+ ({ slug, dateFromFileName } = parseSlugAndDate(rawName));
353
880
 
354
881
  // Handle Series Directory logic
355
882
  if (isSeriesDir) {
356
883
  if (item.isDirectory()) {
357
- const seriesSlug = item.name; // Folder name is series slug
358
- const seriesPath = path.join(dir, item.name);
359
- const seriesItems = fs.readdirSync(seriesPath, { withFileTypes: true });
360
-
361
- seriesItems.forEach(sItem => {
362
- // Skip series metadata file itself
363
- if (sItem.name === 'index.md' || sItem.name === 'index.mdx') return;
364
-
365
- // 1. File-based posts: series/slug/post.mdx
366
- if (sItem.isFile() && (sItem.name.endsWith('.md') || sItem.name.endsWith('.mdx'))) {
367
- const sRawName = sItem.name.replace(/\.mdx?$/, '');
368
- const sMatch = sRawName.match(dateRegex);
369
- let sSlug = sRawName;
370
- let sDate = undefined;
371
- if (sMatch) {
372
- sDate = sMatch[1];
373
- sSlug = siteConfig.posts?.includeDateInUrl ? sRawName : sMatch[2];
374
- }
375
-
376
- allPostsData.push(parseMarkdownFile(
377
- path.join(seriesPath, sItem.name),
378
- sSlug,
379
- sDate,
380
- seriesSlug
381
- ));
382
- }
383
- // 2. Folder-based posts: series/slug/post-folder/index.mdx
384
- else if (sItem.isDirectory()) {
385
- const postFolder = path.join(seriesPath, sItem.name);
386
- const postIndexMdx = path.join(postFolder, 'index.mdx');
387
- const postIndexMd = path.join(postFolder, 'index.md');
388
- let postFullPath = '';
389
-
390
- if (fs.existsSync(postIndexMdx)) postFullPath = postIndexMdx;
391
- else if (fs.existsSync(postIndexMd)) postFullPath = postIndexMd;
392
-
393
- if (postFullPath) {
394
- // Handle date prefix in folder name
395
- const sMatch = sItem.name.match(dateRegex);
396
- let sSlug = sItem.name;
397
- let sDate = undefined;
398
-
399
- if (sMatch) {
400
- sDate = sMatch[1];
401
- sSlug = siteConfig.posts?.includeDateInUrl ? sItem.name : sMatch[2];
402
- }
403
-
404
- allPostsData.push(parseMarkdownFile(
405
- postFullPath,
406
- sSlug,
407
- sDate,
408
- seriesSlug
409
- ));
410
- }
411
- }
412
- });
413
- return; // Processed this series folder
884
+ const seriesSlug = item.name;
885
+ const indexInfo = resolveSeriesIndexInfo(seriesSlug);
886
+ if (!indexInfo) return;
887
+
888
+ getSeriesContentEntries(seriesSlug).forEach(entry => {
889
+ if (indexInfo.format === 'rst') {
890
+ pendingRstPosts.push({
891
+ fullPath: entry.fullPath,
892
+ slug: entry.slug,
893
+ dateFromFileName: entry.dateFromFileName,
894
+ seriesSlug,
895
+ });
896
+ } else {
897
+ allPostsData.push(parseMarkdownFile(entry.fullPath, entry.slug, entry.dateFromFileName, seriesSlug));
898
+ }
899
+ });
900
+ return;
414
901
  }
415
902
  }
416
903
 
@@ -434,7 +921,41 @@ export function getAllPosts(): PostData[] {
434
921
  processDirectory(contentDirectory);
435
922
  processDirectory(seriesDirectory, true);
436
923
 
437
- return allPostsData
924
+ if (pendingRstPosts.length > 0) {
925
+ let batchRenderedByFile: Map<string, RenderedRstDocument> | null = null;
926
+
927
+ if (shouldUsePythonRstRenderer() && pythonRstRendererAvailable !== false) {
928
+ try {
929
+ batchRenderedByFile = renderRstFilesBatch(
930
+ pendingRstPosts.map(entry => ({
931
+ file: entry.fullPath,
932
+ imageBaseSlug: getRstImageBaseSlug(entry.fullPath, entry.slug),
933
+ }))
934
+ );
935
+ pythonRstRendererAvailable = true;
936
+ } catch (error) {
937
+ if (isPythonRuntimeUnavailable(error)) {
938
+ pythonRstRendererAvailable = false;
939
+ } else {
940
+ throw error;
941
+ }
942
+ }
943
+ }
944
+
945
+ pendingRstPosts.forEach(entry => {
946
+ allPostsData.push(
947
+ parseRstFile(
948
+ entry.fullPath,
949
+ entry.slug,
950
+ entry.dateFromFileName,
951
+ entry.seriesSlug,
952
+ batchRenderedByFile?.get(entry.fullPath),
953
+ )
954
+ );
955
+ });
956
+ }
957
+
958
+ const result = allPostsData
438
959
  .filter(post => {
439
960
  if (post.category === 'Page') return false;
440
961
 
@@ -450,6 +971,8 @@ export function getAllPosts(): PostData[] {
450
971
  return true;
451
972
  })
452
973
  .sort((a, b) => (a.date < b.date ? 1 : -1));
974
+ postsCache.set(cacheKey, result);
975
+ return result;
453
976
  }
454
977
 
455
978
  /**
@@ -463,109 +986,8 @@ export function getListingPosts(): PostData[] {
463
986
  return getAllPosts().filter(p => !p.series || !excluded.has(p.series));
464
987
  }
465
988
 
466
- function findPostFile(name: string, targetSlug: string): PostData | null {
467
- // Check standard posts
468
- let fullPath = path.join(contentDirectory, `${name}.mdx`);
469
- if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug);
470
-
471
- fullPath = path.join(contentDirectory, `${name}.md`);
472
- if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug);
473
-
474
- if (fs.existsSync(path.join(contentDirectory, name))) {
475
- fullPath = path.join(contentDirectory, name, 'index.mdx');
476
- if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug);
477
-
478
- fullPath = path.join(contentDirectory, name, 'index.md');
479
- if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug);
480
- }
481
-
482
- // Check series posts
483
- if (fs.existsSync(seriesDirectory)) {
484
- const seriesFolders = fs.readdirSync(seriesDirectory);
485
- for (const folder of seriesFolders) {
486
- const folderPath = path.join(seriesDirectory, folder);
487
- if (!fs.statSync(folderPath).isDirectory()) continue;
488
-
489
- // Check file-based
490
- fullPath = path.join(folderPath, `${name}.mdx`);
491
- if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug, undefined, folder);
492
-
493
- fullPath = path.join(folderPath, `${name}.md`);
494
- if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug, undefined, folder);
495
-
496
- // Check folder-based
497
- const postFolderPath = path.join(folderPath, name);
498
- if (fs.existsSync(postFolderPath) && fs.statSync(postFolderPath).isDirectory()) {
499
- fullPath = path.join(postFolderPath, 'index.mdx');
500
- if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug, undefined, folder);
501
-
502
- fullPath = path.join(postFolderPath, 'index.md');
503
- if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug, undefined, folder);
504
- }
505
- }
506
- }
507
-
508
- return null;
509
- }
510
-
511
989
  export function getPostBySlug(slug: string): PostData | null {
512
- let post: PostData | null = null;
513
-
514
- if (siteConfig.posts?.includeDateInUrl) {
515
- post = findPostFile(slug, slug);
516
- } else {
517
- post = findPostFile(slug, slug);
518
- if (!post) {
519
- // Search in content/posts
520
- const items = fs.existsSync(contentDirectory) ? fs.readdirSync(contentDirectory) : [];
521
- for (const item of items) {
522
- const rawName = item.replace(/\.mdx?$/, '');
523
- const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
524
- const match = rawName.match(dateRegex);
525
-
526
- if (match && match[2] === slug) {
527
- post = findPostFile(rawName, slug);
528
- break;
529
- }
530
- }
531
-
532
- // If not found, search in series folders
533
- if (!post && fs.existsSync(seriesDirectory)) {
534
- const seriesFolders = fs.readdirSync(seriesDirectory);
535
- for (const folder of seriesFolders) {
536
- const folderPath = path.join(seriesDirectory, folder);
537
- if (!fs.statSync(folderPath).isDirectory()) continue;
538
-
539
- const sItems = fs.readdirSync(folderPath);
540
- for (const sItem of sItems) {
541
- const sRawName = sItem.replace(/\.mdx?$/, '');
542
- // Also check folders
543
- const sDateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
544
- const sMatch = sRawName.match(sDateRegex);
545
-
546
- if (sMatch && sMatch[2] === slug) {
547
- post = findPostFile(sRawName, slug);
548
- break;
549
- }
550
- }
551
- if (post) break;
552
- }
553
- }
554
- }
555
- }
556
-
557
- if (!post) return null;
558
-
559
- if (process.env.NODE_ENV === 'production' && post.draft) {
560
- return null;
561
- }
562
-
563
- if (!siteConfig.posts?.showFuturePosts) {
564
- const postDate = new Date(post.date);
565
- const now = new Date();
566
- if (postDate > now) return null;
567
- }
568
- return post;
990
+ return getAllPosts().find(post => post.slug === slug) ?? null;
569
991
  }
570
992
 
571
993
  /**
@@ -621,8 +1043,12 @@ export function getPageBySlug(slug: string): PostData | null {
621
1043
  }
622
1044
 
623
1045
  export function getAllPages(): PostData[] {
1046
+ const cacheKey = getCacheEnvKey();
1047
+ const cached = pagesCache.get(cacheKey);
1048
+ if (cached) return cached;
1049
+
624
1050
  const items = fs.readdirSync(pagesDirectory, { withFileTypes: true });
625
- return items
1051
+ const result = items
626
1052
  .filter(item => {
627
1053
  if (!item.isFile()) return false;
628
1054
  if (!item.name.endsWith('.mdx') && !item.name.endsWith('.md')) return false;
@@ -639,6 +1065,8 @@ export function getAllPages(): PostData[] {
639
1065
  const fullPath = path.join(pagesDirectory, item.name);
640
1066
  return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
641
1067
  });
1068
+ pagesCache.set(cacheKey, result);
1069
+ return result;
642
1070
  }
643
1071
 
644
1072
  export function getPostsByTag(tag: string): PostData[] {
@@ -661,6 +1089,10 @@ export function getFlowTags(): Record<string, number> {
661
1089
  }
662
1090
 
663
1091
  export function getAllTags(): Record<string, number> {
1092
+ const cacheKey = getCacheEnvKey();
1093
+ const cached = tagsCache.get(cacheKey);
1094
+ if (cached) return cached;
1095
+
664
1096
  const allPosts = getAllPosts();
665
1097
  const allFlows = getAllFlows();
666
1098
  const allNotes = getAllNotes();
@@ -696,6 +1128,7 @@ export function getAllTags(): Record<string, number> {
696
1128
  for (const [key, count] of Object.entries(counts)) {
697
1129
  result[display[key]] = count;
698
1130
  }
1131
+ tagsCache.set(cacheKey, result);
699
1132
  return result;
700
1133
  }
701
1134
 
@@ -717,6 +1150,10 @@ export function getAuthorSlug(author: string): string {
717
1150
  }
718
1151
 
719
1152
  export function getAllAuthors(): Record<string, number> {
1153
+ const cacheKey = getCacheEnvKey();
1154
+ const cached = authorsCache.get(cacheKey);
1155
+ if (cached) return cached;
1156
+
720
1157
  const allPosts = getAllPosts();
721
1158
  const authors: Record<string, number> = {};
722
1159
 
@@ -729,7 +1166,7 @@ export function getAllAuthors(): Record<string, number> {
729
1166
  }
730
1167
  });
731
1168
  });
732
-
1169
+ authorsCache.set(cacheKey, authors);
733
1170
  return authors;
734
1171
  }
735
1172
 
@@ -747,6 +1184,16 @@ export function resolveAuthorParam(authorParam: string): string | null {
747
1184
  }
748
1185
 
749
1186
  export function getRelatedPosts(currentSlug: string, limit: number = 3): PostData[] {
1187
+ const cacheKey = getCacheEnvKey();
1188
+ let bySlug = relatedPostsCache.get(cacheKey);
1189
+ if (!bySlug) {
1190
+ bySlug = new Map();
1191
+ relatedPostsCache.set(cacheKey, bySlug);
1192
+ }
1193
+ const cacheId = `${currentSlug}:${limit}`;
1194
+ const cached = bySlug.get(cacheId);
1195
+ if (cached) return cached;
1196
+
750
1197
  const allPosts = getAllPosts();
751
1198
  const currentPost = allPosts.find(p => p.slug === currentSlug);
752
1199
 
@@ -770,10 +1217,20 @@ export function getRelatedPosts(currentSlug: string, limit: number = 3): PostDat
770
1217
  .slice(0, limit)
771
1218
  .map(item => item.post);
772
1219
 
1220
+ bySlug.set(cacheId, related);
773
1221
  return related;
774
1222
  }
775
1223
 
776
1224
  export function getSeriesPosts(seriesName: string): PostData[] {
1225
+ const cacheKey = getCacheEnvKey();
1226
+ let bySeries = seriesPostsCache.get(cacheKey);
1227
+ if (!bySeries) {
1228
+ bySeries = new Map();
1229
+ seriesPostsCache.set(cacheKey, bySeries);
1230
+ }
1231
+ const cached = bySeries.get(seriesName);
1232
+ if (cached) return cached;
1233
+
777
1234
  const seriesSlug = seriesName;
778
1235
  const seriesData = getSeriesData(seriesSlug);
779
1236
 
@@ -798,10 +1255,15 @@ export function getSeriesPosts(seriesName: string): PostData[] {
798
1255
  }
799
1256
  }
800
1257
 
1258
+ bySeries.set(seriesName, posts);
801
1259
  return posts;
802
1260
  }
803
1261
 
804
1262
  export function getAllSeries(): Record<string, PostData[]> {
1263
+ const cacheKey = getCacheEnvKey();
1264
+ const cached = allSeriesCache.get(cacheKey);
1265
+ if (cached) return cached;
1266
+
805
1267
  const allPosts = getAllPosts();
806
1268
  const series: Record<string, PostData[]> = {};
807
1269
  const seriesSet = new Set<string>();
@@ -834,25 +1296,89 @@ export function getAllSeries(): Record<string, PostData[]> {
834
1296
  : getSeriesPosts(slug);
835
1297
  });
836
1298
 
1299
+ allSeriesCache.set(cacheKey, series);
837
1300
  return series;
838
1301
  }
839
1302
 
1303
+ export function getSeriesLatestPostDate(slug: string): string {
1304
+ const cacheKey = getCacheEnvKey();
1305
+ let bySlug = seriesLatestDateCache.get(cacheKey);
1306
+ if (!bySlug) {
1307
+ bySlug = new Map();
1308
+ seriesLatestDateCache.set(cacheKey, bySlug);
1309
+ }
1310
+ const cached = bySlug.get(slug);
1311
+ if (cached !== undefined) return cached;
1312
+
1313
+ const seriesData = getSeriesData(slug);
1314
+ const posts = seriesData?.type === 'collection' ? getCollectionPosts(slug) : getSeriesPosts(slug);
1315
+ const latestPostDate = posts.reduce((latest, post) => (post.date > latest ? post.date : latest), '');
1316
+ const resolved = latestPostDate || seriesData?.date || '';
1317
+
1318
+ bySlug.set(slug, resolved);
1319
+ return resolved;
1320
+ }
1321
+
840
1322
  export function getFeaturedPosts(): PostData[] {
841
- const allPosts = getAllPosts();
842
- return allPosts.filter(post => post.featured);
1323
+ const cacheKey = getCacheEnvKey();
1324
+ const cached = featuredPostsCache.get(cacheKey);
1325
+ if (cached) return cached;
1326
+ const result = getAllPosts().filter(post => post.featured);
1327
+ featuredPostsCache.set(cacheKey, result);
1328
+ return result;
843
1329
  }
844
1330
 
845
1331
  export function getAdjacentPosts(slug: string): { prev: PostData | null; next: PostData | null } {
1332
+ const cacheKey = getCacheEnvKey();
1333
+ let bySlug = adjacentPostsCache.get(cacheKey);
1334
+ if (!bySlug) {
1335
+ bySlug = new Map();
1336
+ adjacentPostsCache.set(cacheKey, bySlug);
1337
+ }
1338
+ const currentPost = getPostBySlug(slug);
1339
+ if (currentPost?.series) {
1340
+ const seriesCacheKey = `${currentPost.series}/${slug}`;
1341
+ const cachedSeries = bySlug.get(seriesCacheKey);
1342
+ if (cachedSeries) return cachedSeries;
1343
+
1344
+ const seriesData = getSeriesData(currentPost.series);
1345
+ if (seriesData?.type !== 'collection') {
1346
+ const seriesPosts = getSeriesPosts(currentPost.series);
1347
+ const seriesIndex = seriesPosts.findIndex(post => post.slug === slug);
1348
+ if (seriesIndex !== -1) {
1349
+ const seriesResult = {
1350
+ prev: seriesIndex > 0 ? seriesPosts[seriesIndex - 1] : null,
1351
+ next: seriesIndex < seriesPosts.length - 1 ? seriesPosts[seriesIndex + 1] : null,
1352
+ };
1353
+ bySlug.set(seriesCacheKey, seriesResult);
1354
+ return seriesResult;
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ const cached = bySlug.get(slug);
1360
+ if (cached) return cached;
1361
+
846
1362
  const allPosts = getAllPosts(); // sorted desc by date (newest first)
847
1363
  const index = allPosts.findIndex(p => p.slug === slug);
848
- if (index === -1) return { prev: null, next: null };
849
- return {
1364
+ if (index === -1) {
1365
+ const empty = { prev: null, next: null };
1366
+ bySlug.set(slug, empty);
1367
+ return empty;
1368
+ }
1369
+ const result = {
850
1370
  prev: index < allPosts.length - 1 ? allPosts[index + 1] : null, // older post
851
1371
  next: index > 0 ? allPosts[index - 1] : null, // newer post
852
1372
  };
1373
+ bySlug.set(slug, result);
1374
+ return result;
853
1375
  }
854
1376
 
855
1377
  export function getFeaturedSeries(): Record<string, PostData[]> {
1378
+ const cacheKey = getCacheEnvKey();
1379
+ const cached = featuredSeriesCache.get(cacheKey);
1380
+ if (cached) return cached;
1381
+
856
1382
  const allSeries = getAllSeries();
857
1383
  const featuredSeries: Record<string, PostData[]> = {};
858
1384
 
@@ -863,25 +1389,47 @@ export function getFeaturedSeries(): Record<string, PostData[]> {
863
1389
  }
864
1390
  });
865
1391
 
1392
+ featuredSeriesCache.set(cacheKey, featuredSeries);
866
1393
  return featuredSeries;
867
1394
  }
868
1395
 
869
1396
  export function getSeriesData(slug: string): PostData | null {
870
- if (!fs.existsSync(seriesDirectory)) return null;
871
- const indexPathMdx = path.join(seriesDirectory, slug, 'index.mdx');
872
- const indexPathMd = path.join(seriesDirectory, slug, 'index.md');
1397
+ const cacheKey = getCacheEnvKey();
1398
+ let bySlug = seriesDataCache.get(cacheKey);
1399
+ if (!bySlug) {
1400
+ bySlug = new Map();
1401
+ seriesDataCache.set(cacheKey, bySlug);
1402
+ }
1403
+ if (bySlug.has(slug)) return bySlug.get(slug) ?? null;
873
1404
 
874
- let fullPath = '';
875
- if (fs.existsSync(indexPathMdx)) fullPath = indexPathMdx;
876
- else if (fs.existsSync(indexPathMd)) fullPath = indexPathMd;
877
- else return null;
1405
+ const indexInfo = resolveSeriesIndexInfo(slug);
1406
+ if (!indexInfo) {
1407
+ bySlug.set(slug, null);
1408
+ return null;
1409
+ }
878
1410
 
879
- return parseMarkdownFile(fullPath, slug, undefined, slug);
1411
+ const result = indexInfo.format === 'rst'
1412
+ ? parseRstFile(indexInfo.fullPath, slug, undefined, slug)
1413
+ : parseMarkdownFile(indexInfo.fullPath, slug, undefined, slug);
1414
+ bySlug.set(slug, result);
1415
+ return result;
880
1416
  }
881
1417
 
882
1418
  export function getCollectionPosts(collectionSlug: string): PostData[] {
1419
+ const cacheKey = getCacheEnvKey();
1420
+ let bySlug = collectionPostsCache.get(cacheKey);
1421
+ if (!bySlug) {
1422
+ bySlug = new Map();
1423
+ collectionPostsCache.set(cacheKey, bySlug);
1424
+ }
1425
+ const cached = bySlug.get(collectionSlug);
1426
+ if (cached) return cached;
1427
+
883
1428
  const data = getSeriesData(collectionSlug);
884
- if (data?.type !== 'collection' || !data.items) return [];
1429
+ if (data?.type !== 'collection' || !data.items) {
1430
+ bySlug.set(collectionSlug, []);
1431
+ return [];
1432
+ }
885
1433
 
886
1434
  const getCollectionKey = (post: PostData) =>
887
1435
  post.series ? `${post.series}/${post.slug}` : `posts/${post.slug}`;
@@ -890,7 +1438,7 @@ export function getCollectionPosts(collectionSlug: string): PostData[] {
890
1438
  const postIndex = new Map(allPosts.map((post) => [getCollectionKey(post), post]));
891
1439
  const seen = new Set<string>();
892
1440
 
893
- return data.items
1441
+ const result = data.items
894
1442
  .flatMap(item => {
895
1443
  if ('series' in item) {
896
1444
  const posts = getSeriesPosts(item.series);
@@ -909,9 +1457,20 @@ export function getCollectionPosts(collectionSlug: string): PostData[] {
909
1457
  seen.add(key);
910
1458
  return true;
911
1459
  });
1460
+ bySlug.set(collectionSlug, result);
1461
+ return result;
912
1462
  }
913
1463
 
914
1464
  export function getCollectionsForPost(postSlug: string): CollectionContext[] {
1465
+ const cacheKey = getCacheEnvKey();
1466
+ let bySlug = collectionsForPostCache.get(cacheKey);
1467
+ if (!bySlug) {
1468
+ bySlug = new Map();
1469
+ collectionsForPostCache.set(cacheKey, bySlug);
1470
+ }
1471
+ const cached = bySlug.get(postSlug);
1472
+ if (cached) return cached;
1473
+
915
1474
  if (!fs.existsSync(seriesDirectory)) return [];
916
1475
  const seriesFolders = fs.readdirSync(seriesDirectory, { withFileTypes: true });
917
1476
  const results: CollectionContext[] = [];
@@ -927,6 +1486,7 @@ export function getCollectionsForPost(postSlug: string): CollectionContext[] {
927
1486
  }
928
1487
  }
929
1488
 
1489
+ bySlug.set(postSlug, results);
930
1490
  return results;
931
1491
  }
932
1492
 
@@ -935,14 +1495,32 @@ export function getCollectionsForPost(postSlug: string): CollectionContext[] {
935
1495
  export interface BookChapterEntry {
936
1496
  title: string;
937
1497
  id: string;
1498
+ /** Legacy single-level grouping; set when the chapter sits under a `{ part, chapters }` item. */
938
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;
939
1509
  }
940
1510
 
941
1511
  export interface BookTocPart {
942
1512
  part: string;
943
- chapters: { title: string; id: string }[];
1513
+ chapters: BookChapterRef[];
1514
+ }
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>;
944
1521
  }
945
- export type BookTocItem = BookTocPart | { title: string; id: string };
1522
+
1523
+ export type BookTocItem = BookTocPart | BookTocSection | BookChapterRef;
946
1524
 
947
1525
  export interface BookData {
948
1526
  title: string;
@@ -953,6 +1531,16 @@ export interface BookData {
953
1531
  featured: boolean;
954
1532
  draft: boolean;
955
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;
956
1544
  content: string;
957
1545
  toc: BookTocItem[];
958
1546
  chapters: BookChapterEntry[];
@@ -967,8 +1555,11 @@ export interface BookChapterData {
967
1555
  excerpt?: string;
968
1556
  latex: boolean;
969
1557
  commentable?: boolean;
970
- readingTime: string;
1558
+ readingMinutes: number;
1559
+ wordCount: number;
971
1560
  isFolder: boolean;
1561
+ /** Absolute path of the markdown source file. Used to resolve relative `.md` links. */
1562
+ sourcePath: string;
972
1563
  prevChapter: { title: string; id: string } | null;
973
1564
  nextChapter: { title: string; id: string } | null;
974
1565
  }
@@ -978,15 +1569,25 @@ const BookChapterRefSchema = z.object({
978
1569
  id: z.string(),
979
1570
  });
980
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
+
981
1581
  const BookTocItemSchema: z.ZodType<BookTocItem> = z.union([
982
1582
  z.object({
983
1583
  part: z.string(),
984
1584
  chapters: z.array(BookChapterRefSchema),
985
1585
  }),
1586
+ BookTocSectionSchema,
986
1587
  BookChapterRefSchema,
987
1588
  ]);
988
1589
 
989
- const BookSchema = z.object({
1590
+ export const BookSchema = z.object({
990
1591
  title: z.string(),
991
1592
  excerpt: z.string().optional(),
992
1593
  date: z.union([z.string(), z.date()]).transform(val => new Date(val).toISOString().split('T')[0]),
@@ -994,24 +1595,47 @@ const BookSchema = z.object({
994
1595
  featured: z.boolean().optional().default(false),
995
1596
  draft: z.boolean().optional().default(false),
996
1597
  authors: z.array(z.string()).optional().default([]),
1598
+ latex: z.boolean().optional().default(false),
1599
+ showChapterExcerpt: z.boolean().optional().default(false),
997
1600
  chapters: z.array(BookTocItemSchema),
998
1601
  });
999
1602
 
1000
1603
  const BookChapterSchema = z.object({
1001
- title: z.string(),
1604
+ title: z.string().optional(),
1002
1605
  excerpt: z.string().optional(),
1003
1606
  draft: z.boolean().optional().default(false),
1004
1607
  latex: z.boolean().optional().default(false),
1005
1608
  commentable: z.boolean().optional(),
1006
1609
  });
1007
1610
 
1008
- function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
1611
+ export function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
1009
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
+
1010
1632
  for (const item of toc) {
1011
1633
  if ('part' in item) {
1012
1634
  for (const ch of item.chapters) {
1013
1635
  result.push({ title: ch.title, id: ch.id, part: item.part });
1014
1636
  }
1637
+ } else if ('section' in item) {
1638
+ walkSection([item], []);
1015
1639
  } else {
1016
1640
  result.push({ title: item.title, id: item.id });
1017
1641
  }
@@ -1019,6 +1643,35 @@ function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
1019
1643
  return result;
1020
1644
  }
1021
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
+
1022
1675
  export function getBookData(slug: string): BookData | null {
1023
1676
  if (!fs.existsSync(booksDirectory)) return null;
1024
1677
  const bookDir = path.join(booksDirectory, slug);
@@ -1041,17 +1694,22 @@ export function getBookData(slug: string): BookData | null {
1041
1694
  }
1042
1695
  const data = parsed.data;
1043
1696
 
1044
- // 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).
1045
1699
  const chapters = flattenBookChapters(data.chapters);
1700
+ const missing: string[] = [];
1046
1701
  for (const ch of chapters) {
1047
- const chMdx = path.join(bookDir, `${ch.id}.mdx`);
1048
- const chMd = path.join(bookDir, `${ch.id}.md`);
1049
- const chFolderMdx = path.join(bookDir, ch.id, 'index.mdx');
1050
- const chFolderMd = path.join(bookDir, ch.id, 'index.md');
1051
- if (!fs.existsSync(chMdx) && !fs.existsSync(chMd) && !fs.existsSync(chFolderMdx) && !fs.existsSync(chFolderMd)) {
1052
- console.warn(`Book "${slug}": chapter "${ch.id}" not found`);
1702
+ if (!resolveChapterFilePath(bookDir, ch.id)) {
1703
+ missing.push(ch.id);
1053
1704
  }
1054
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
+ }
1055
1713
 
1056
1714
  let coverImage = data.coverImage;
1057
1715
  if (coverImage && !coverImage.startsWith('http') && !coverImage.startsWith('/') && !coverImage.startsWith('text:')) {
@@ -1073,6 +1731,8 @@ export function getBookData(slug: string): BookData | null {
1073
1731
  featured: data.featured,
1074
1732
  draft: data.draft,
1075
1733
  authors,
1734
+ latex: data.latex,
1735
+ showChapterExcerpt: data.showChapterExcerpt,
1076
1736
  content: content.trim(),
1077
1737
  toc: data.chapters,
1078
1738
  chapters,
@@ -1084,17 +1744,9 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
1084
1744
  if (!book) return null;
1085
1745
 
1086
1746
  const bookDir = path.join(booksDirectory, bookSlug);
1087
- const chMdx = path.join(bookDir, `${chapterSlug}.mdx`);
1088
- const chMd = path.join(bookDir, `${chapterSlug}.md`);
1089
- const chFolderMdx = path.join(bookDir, chapterSlug, 'index.mdx');
1090
- const chFolderMd = path.join(bookDir, chapterSlug, 'index.md');
1091
- let fullPath = '';
1092
- let isFolder = false;
1093
- if (fs.existsSync(chMdx)) fullPath = chMdx;
1094
- else if (fs.existsSync(chMd)) fullPath = chMd;
1095
- else if (fs.existsSync(chFolderMdx)) { fullPath = chFolderMdx; isFolder = true; }
1096
- else if (fs.existsSync(chFolderMd)) { fullPath = chFolderMd; isFolder = true; }
1097
- else return null;
1747
+ const resolved = resolveChapterFilePath(bookDir, chapterSlug);
1748
+ if (!resolved) return null;
1749
+ const { path: fullPath, isFolder } = resolved;
1098
1750
 
1099
1751
  const fileContents = fs.readFileSync(fullPath, 'utf8');
1100
1752
  const { data: rawData, content } = matter(fileContents);
@@ -1112,7 +1764,8 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
1112
1764
 
1113
1765
  const contentWithoutH1 = content.replace(/^\s*#\s+[^\n]+/, '').trim();
1114
1766
  const headings = getHeadings(content);
1115
- const readingTime = calculateReadingTime(contentWithoutH1);
1767
+ const readingMinutes = calculateReadingMinutes(contentWithoutH1);
1768
+ const wordCount = calculateWordCount(contentWithoutH1);
1116
1769
  const excerpt = data.excerpt || generateExcerpt(contentWithoutH1);
1117
1770
 
1118
1771
  // Find prev/next
@@ -1120,22 +1773,40 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
1120
1773
  const prevChapter = chapterIndex > 0 ? book.chapters[chapterIndex - 1] : null;
1121
1774
  const nextChapter = chapterIndex < book.chapters.length - 1 ? book.chapters[chapterIndex + 1] : null;
1122
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
+
1123
1784
  return {
1124
- title: data.title,
1785
+ title,
1125
1786
  slug: chapterSlug,
1126
1787
  bookSlug,
1127
1788
  content: contentWithoutH1,
1128
1789
  headings,
1129
1790
  excerpt,
1130
- 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,
1131
1794
  commentable: data.commentable,
1132
- readingTime,
1795
+ readingMinutes,
1796
+ wordCount,
1133
1797
  isFolder,
1798
+ sourcePath: fullPath,
1134
1799
  prevChapter: prevChapter ? { title: prevChapter.title, id: prevChapter.id } : null,
1135
1800
  nextChapter: nextChapter ? { title: nextChapter.title, id: nextChapter.id } : null,
1136
1801
  };
1137
1802
  }
1138
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
+
1139
1810
  export function getAllBooks(): BookData[] {
1140
1811
  if (!fs.existsSync(booksDirectory)) return [];
1141
1812
 
@@ -1353,7 +2024,8 @@ export interface NoteData {
1353
2024
  content: string;
1354
2025
  excerpt: string;
1355
2026
  headings: Heading[];
1356
- readingTime: string;
2027
+ readingMinutes: number;
2028
+ wordCount: number;
1357
2029
  }
1358
2030
 
1359
2031
  function parseNoteFile(fullPath: string, slug: string): NoteData {
@@ -1371,7 +2043,8 @@ function parseNoteFile(fullPath: string, slug: string): NoteData {
1371
2043
  const date = data.date || fs.statSync(fullPath).mtime.toISOString().split('T')[0];
1372
2044
  const excerpt = generateExcerpt(contentWithoutH1);
1373
2045
  const headings = getHeadings(content);
1374
- const readingTime = calculateReadingTime(contentWithoutH1);
2046
+ const readingMinutes = calculateReadingMinutes(contentWithoutH1);
2047
+ const wordCount = calculateWordCount(contentWithoutH1);
1375
2048
 
1376
2049
  return {
1377
2050
  slug,
@@ -1386,7 +2059,8 @@ function parseNoteFile(fullPath: string, slug: string): NoteData {
1386
2059
  content: contentWithoutH1,
1387
2060
  excerpt,
1388
2061
  headings,
1389
- readingTime,
2062
+ readingMinutes,
2063
+ wordCount,
1390
2064
  };
1391
2065
  }
1392
2066