@hutusi/amytis 1.14.0 → 1.15.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 (63) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +33 -1
  5. package/README.zh.md +33 -1
  6. package/TODO.md +10 -0
  7. package/bun.lock +69 -41
  8. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  9. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  10. package/content/series/rst-legacy/getting-started.rst +24 -0
  11. package/content/series/rst-legacy/index.rst +9 -0
  12. package/content/series/rst-readme/README.rst +9 -0
  13. package/content/series/rst-readme/readme-index-post.rst +10 -0
  14. package/content/series/rst-toctree/first-post.rst +6 -0
  15. package/content/series/rst-toctree/index.rst +10 -0
  16. package/content/series/rst-toctree/second-post.rst +6 -0
  17. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  18. package/content/series/rst-toctree-precedence/index.rst +12 -0
  19. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  20. package/docs/ARCHITECTURE.md +22 -3
  21. package/docs/CONTRIBUTING.md +11 -0
  22. package/eslint.config.mjs +2 -0
  23. package/next.config.ts +2 -2
  24. package/package.json +22 -16
  25. package/packages/create-amytis/package.json +1 -1
  26. package/packages/create-amytis/src/index.test.ts +43 -1
  27. package/packages/create-amytis/src/index.ts +64 -8
  28. package/public/next-image-export-optimizer-hashes.json +14 -73
  29. package/scripts/build-pagefind.ts +172 -0
  30. package/scripts/copy-assets.ts +246 -56
  31. package/scripts/generate-knowledge-graph.ts +2 -1
  32. package/scripts/render-rst.py +719 -0
  33. package/scripts/run-with-rst-python.ts +42 -0
  34. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  35. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  36. package/src/app/globals.css +165 -0
  37. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  38. package/src/app/series/[slug]/page.tsx +11 -13
  39. package/src/app/series/page.tsx +3 -3
  40. package/src/components/AuthorCard.tsx +25 -16
  41. package/src/components/CoverImage.tsx +5 -2
  42. package/src/components/MarkdownRenderer.test.tsx +16 -0
  43. package/src/components/MarkdownRenderer.tsx +4 -1
  44. package/src/components/RstRenderer.test.tsx +93 -0
  45. package/src/components/RstRenderer.tsx +122 -0
  46. package/src/layouts/PostLayout.tsx +5 -1
  47. package/src/layouts/SimpleLayout.tsx +10 -3
  48. package/src/lib/image-utils.test.ts +19 -0
  49. package/src/lib/image-utils.ts +11 -0
  50. package/src/lib/markdown.test.ts +140 -2
  51. package/src/lib/markdown.ts +731 -210
  52. package/src/lib/rehype-image-metadata.ts +2 -2
  53. package/src/lib/rst-renderer.test.ts +355 -0
  54. package/src/lib/rst-renderer.ts +617 -0
  55. package/src/lib/rst.test.ts +140 -0
  56. package/src/lib/rst.ts +470 -0
  57. package/src/lib/series-redirects.ts +42 -0
  58. package/tests/integration/feed-utils.test.ts +13 -0
  59. package/tests/integration/reading-time-headings.test.ts +5 -9
  60. package/tests/integration/series-draft.test.ts +16 -2
  61. package/tests/integration/series.test.ts +93 -0
  62. package/tests/tooling/build-pagefind.test.ts +66 -0
  63. package/tests/unit/static-params.test.ts +140 -0
@@ -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(),
@@ -119,10 +125,142 @@ export interface PostData {
119
125
  redirectFrom?: string[];
120
126
  readingTime: string;
121
127
  content: string;
128
+ renderedHtml?: string;
129
+ plainText?: string;
122
130
  headings: Heading[];
123
131
  contentLocales?: Record<string, { content: string; title?: string; excerpt?: string; headings?: Heading[] }>;
124
132
  /** Public-relative base path used for resolving co-located images (e.g. "posts/my-post" or "posts" for root flat files). */
125
133
  imageBaseSlug: string;
134
+ sourceFormat?: 'markdown' | 'rst';
135
+ }
136
+
137
+ type SeriesFormat = 'markdown' | 'rst';
138
+
139
+ interface SeriesIndexInfo {
140
+ format: SeriesFormat;
141
+ fullPath: string;
142
+ }
143
+
144
+ interface SeriesContentEntry {
145
+ fullPath: string;
146
+ slug: string;
147
+ dateFromFileName?: string;
148
+ }
149
+
150
+ interface PendingRstPostEntry {
151
+ fullPath: string;
152
+ slug: string;
153
+ dateFromFileName?: string;
154
+ seriesSlug?: string;
155
+ }
156
+
157
+ function getCacheEnvKey(): string {
158
+ return process.env.NODE_ENV === 'production' ? 'production' : 'development';
159
+ }
160
+
161
+ const postsCache = new Map<string, PostData[]>();
162
+ const pagesCache = new Map<string, PostData[]>();
163
+ const tagsCache = new Map<string, Record<string, number>>();
164
+ const authorsCache = new Map<string, Record<string, number>>();
165
+ const featuredPostsCache = new Map<string, PostData[]>();
166
+ const adjacentPostsCache = new Map<string, Map<string, { prev: PostData | null; next: PostData | null }>>();
167
+ const relatedPostsCache = new Map<string, Map<string, PostData[]>>();
168
+ const seriesDataCache = new Map<string, Map<string, PostData | null>>();
169
+ const seriesPostsCache = new Map<string, Map<string, PostData[]>>();
170
+ const allSeriesCache = new Map<string, Record<string, PostData[]>>();
171
+ const featuredSeriesCache = new Map<string, Record<string, PostData[]>>();
172
+ const seriesLatestDateCache = new Map<string, Map<string, string>>();
173
+ const collectionPostsCache = new Map<string, Map<string, PostData[]>>();
174
+ const collectionsForPostCache = new Map<string, Map<string, CollectionContext[]>>();
175
+ const seriesAuthorsCache = new Map<string, Map<string, string[] | null>>();
176
+ const seriesTitleCache = new Map<string, Map<string, string | undefined>>();
177
+ let pythonRstRendererAvailable: boolean | null = null;
178
+
179
+ const PYTHON_RUNTIME_UNAVAILABLE_PATTERN = /docutils|No module named|python(?:3)? .*not found|interpreter not found|ENOENT.*python/i;
180
+
181
+ function isPythonRuntimeUnavailable(error: unknown): boolean {
182
+ if (!(error instanceof Error)) return false;
183
+ if (error.message.includes('__RST_FALLBACK__')) return true;
184
+ if (error.message.includes('rST file not found')) return false;
185
+ return PYTHON_RUNTIME_UNAVAILABLE_PATTERN.test(error.message);
186
+ }
187
+
188
+ function getRstImageBaseSlug(fullPath: string, slug: string): string {
189
+ const isRootFlatPost = path.basename(fullPath) !== 'index.rst' &&
190
+ path.dirname(fullPath) === contentDirectory;
191
+ return isRootFlatPost ? 'posts' : `posts/${slug}`;
192
+ }
193
+
194
+ function isSeriesIndexRst(fullPath: string, slug: string, seriesName?: string): boolean {
195
+ return Boolean(
196
+ seriesName &&
197
+ slug === seriesName &&
198
+ (path.basename(fullPath) === 'index.rst' || path.basename(fullPath) === 'README.rst')
199
+ );
200
+ }
201
+
202
+ function slugFromRstToctreeTarget(target: string): string | null {
203
+ const trimmed = target.trim();
204
+ if (!trimmed || trimmed.startsWith(':')) return null;
205
+ if (/^[a-z]+:\/\//i.test(trimmed) || trimmed.startsWith('/')) return null;
206
+
207
+ const withoutAnchor = trimmed.split('#')[0]?.split('?')[0]?.trim();
208
+ if (!withoutAnchor) return null;
209
+
210
+ const normalized = withoutAnchor.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
211
+ if (!normalized || normalized.startsWith('../')) return null;
212
+
213
+ const withoutExt = normalized.replace(/\.rst$/i, '');
214
+ const parts = withoutExt.split('/').filter(Boolean);
215
+ if (parts.length === 0) return null;
216
+
217
+ const last = parts[parts.length - 1];
218
+ if (last === 'index' || last === 'README') {
219
+ return parts.length > 1 ? parts[parts.length - 2] : null;
220
+ }
221
+
222
+ return last;
223
+ }
224
+
225
+ function extractRstToctreePosts(source: string): string[] {
226
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
227
+ const posts: string[] = [];
228
+ const seen = new Set<string>();
229
+
230
+ for (let i = 0; i < lines.length; i++) {
231
+ if (!/^\s*\.\.\s+toctree::\s*$/.test(lines[i])) continue;
232
+
233
+ i++;
234
+ while (i < lines.length) {
235
+ const line = lines[i];
236
+ if (!line.trim()) {
237
+ i++;
238
+ continue;
239
+ }
240
+ if (!/^\s+/.test(line)) {
241
+ i--;
242
+ break;
243
+ }
244
+
245
+ const trimmed = line.trim();
246
+ if (!trimmed.startsWith(':')) {
247
+ const slug = slugFromRstToctreeTarget(trimmed);
248
+ if (slug && !seen.has(slug)) {
249
+ seen.add(slug);
250
+ posts.push(slug);
251
+ }
252
+ }
253
+ i++;
254
+ }
255
+ }
256
+
257
+ return posts;
258
+ }
259
+
260
+ function shouldUsePythonRstRenderer(): boolean {
261
+ if (process.env.AMYTIS_ENABLE_PYTHON_RST === '1') return true;
262
+ if (process.env.AMYTIS_ENABLE_PYTHON_RST === '0') return false;
263
+ return process.env.NODE_ENV !== 'test';
126
264
  }
127
265
 
128
266
  export function calculateReadingTime(content: string): string {
@@ -183,22 +321,47 @@ export function getHeadings(content: string): Heading[] {
183
321
  * Returns null if no authors are configured (as opposed to the default fallback).
184
322
  */
185
323
  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');
324
+ const cacheKey = getCacheEnvKey();
325
+ let bySlug = seriesAuthorsCache.get(cacheKey);
326
+ if (!bySlug) {
327
+ bySlug = new Map();
328
+ seriesAuthorsCache.set(cacheKey, bySlug);
329
+ }
330
+ if (bySlug.has(seriesSlug)) return bySlug.get(seriesSlug) ?? null;
189
331
 
190
- let fullPath = '';
191
- if (fs.existsSync(indexPathMdx)) fullPath = indexPathMdx;
192
- else if (fs.existsSync(indexPathMd)) fullPath = indexPathMd;
193
- else return null;
332
+ const indexInfo = resolveSeriesIndexInfo(seriesSlug);
333
+ if (!indexInfo) {
334
+ bySlug.set(seriesSlug, null);
335
+ return null;
336
+ }
337
+
338
+ if (indexInfo.format === 'rst') {
339
+ const parsed = parseRstDocument(readUtf8File(indexInfo.fullPath));
340
+ if (parsed.metadata.authors && parsed.metadata.authors.length > 0) {
341
+ bySlug.set(seriesSlug, parsed.metadata.authors);
342
+ return parsed.metadata.authors;
343
+ }
344
+ if (parsed.metadata.author && typeof parsed.metadata.author === 'string') {
345
+ const authors = [parsed.metadata.author];
346
+ bySlug.set(seriesSlug, authors);
347
+ return authors;
348
+ }
349
+ bySlug.set(seriesSlug, null);
350
+ return null;
351
+ }
194
352
 
195
- const { data } = matter(fs.readFileSync(fullPath, 'utf8'));
353
+ const { data } = matter(readUtf8File(indexInfo.fullPath));
196
354
  if (data.authors && Array.isArray(data.authors) && data.authors.length > 0) {
197
- return data.authors;
355
+ const authors = data.authors as string[];
356
+ bySlug.set(seriesSlug, authors);
357
+ return authors;
198
358
  }
199
359
  if (data.author && typeof data.author === 'string') {
200
- return [data.author];
360
+ const authors = [data.author as string];
361
+ bySlug.set(seriesSlug, authors);
362
+ return authors;
201
363
  }
364
+ bySlug.set(seriesSlug, null);
202
365
  return null;
203
366
  }
204
367
 
@@ -221,17 +384,186 @@ export function resolveSeriesAuthors(slug: string, posts: PostData[]): string[]
221
384
  .map(([name]) => name);
222
385
  }
223
386
 
387
+ function parseSlugAndDate(rawName: string): { slug: string; dateFromFileName?: string } {
388
+ const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
389
+ const match = rawName.match(dateRegex);
390
+
391
+ if (match) {
392
+ return {
393
+ dateFromFileName: match[1],
394
+ slug: siteConfig.posts?.includeDateInUrl ? rawName : match[2],
395
+ };
396
+ }
397
+
398
+ return { slug: rawName };
399
+ }
400
+
401
+ function isMarkdownFilename(name: string): boolean {
402
+ return name.endsWith('.md') || name.endsWith('.mdx');
403
+ }
404
+
405
+ function isRstFilename(name: string): boolean {
406
+ return name.endsWith('.rst');
407
+ }
408
+
409
+ function assertSafeSeriesSlug(seriesSlug: string): void {
410
+ if (!seriesSlug || path.isAbsolute(seriesSlug)) {
411
+ throw new Error(`[amytis] Invalid series slug "${seriesSlug}".`);
412
+ }
413
+
414
+ const segments = seriesSlug.split(/[\\/]/);
415
+ if (segments.length !== 1 || segments[0] === '.' || segments[0] === '..') {
416
+ throw new Error(`[amytis] Invalid series slug "${seriesSlug}".`);
417
+ }
418
+ }
419
+
420
+ function resolveUniqueSeriesIndex(seriesSlug: string, format: SeriesFormat): string | null {
421
+ assertSafeSeriesSlug(seriesSlug);
422
+ const seriesPath = path.join(seriesDirectory, seriesSlug);
423
+ const candidates = format === 'rst'
424
+ ? ['index.rst', 'README.rst']
425
+ : ['index.mdx', 'index.md', 'README.mdx', 'README.md'];
426
+
427
+ const matches = candidates
428
+ .map(name => path.join(seriesPath, name))
429
+ .filter(fullPath => fs.existsSync(fullPath));
430
+
431
+ if (matches.length > 1) {
432
+ throw new Error(
433
+ `[amytis] Series "${seriesSlug}" has multiple ${format} index files: ${matches.map(match => path.basename(match)).join(', ')}.`
434
+ );
435
+ }
436
+
437
+ return matches[0] ?? null;
438
+ }
439
+
440
+ function resolveSeriesIndexInfo(slug: string): SeriesIndexInfo | null {
441
+ assertSafeSeriesSlug(slug);
442
+ if (!fs.existsSync(seriesDirectory)) return null;
443
+ const seriesPath = path.join(seriesDirectory, slug);
444
+ if (!fs.existsSync(seriesPath) || !fs.statSync(seriesPath).isDirectory()) return null;
445
+
446
+ const rstIndex = resolveUniqueSeriesIndex(slug, 'rst');
447
+ const markdownIndex = resolveUniqueSeriesIndex(slug, 'markdown');
448
+
449
+ if (rstIndex && markdownIndex) {
450
+ throw new Error(
451
+ `[amytis] Series "${slug}" cannot contain both rST and Markdown index files (${path.basename(rstIndex)} and ${path.basename(markdownIndex)}).`
452
+ );
453
+ }
454
+ if (rstIndex) return { format: 'rst', fullPath: rstIndex };
455
+ if (markdownIndex) return { format: 'markdown', fullPath: markdownIndex };
456
+ return null;
457
+ }
458
+
459
+ function getSeriesContentEntries(seriesSlug: string): SeriesContentEntry[] {
460
+ const indexInfo = resolveSeriesIndexInfo(seriesSlug);
461
+ if (!indexInfo) return [];
462
+
463
+ const seriesPath = path.join(seriesDirectory, seriesSlug);
464
+ const seriesItems = fs.readdirSync(seriesPath, { withFileTypes: true });
465
+ const entries: SeriesContentEntry[] = [];
466
+ const seenSlugs = new Map<string, string>();
467
+ const seriesIndexBasenames = new Set(['index.rst', 'README.rst', 'index.md', 'index.mdx', 'README.md', 'README.mdx']);
468
+
469
+ for (const item of seriesItems) {
470
+ if (seriesIndexBasenames.has(item.name)) continue;
471
+
472
+ if (item.isFile()) {
473
+ const isMarkdown = isMarkdownFilename(item.name);
474
+ const isRst = isRstFilename(item.name);
475
+ if (!isMarkdown && !isRst) continue;
476
+
477
+ const itemFormat: SeriesFormat = isRst ? 'rst' : 'markdown';
478
+ if (itemFormat !== indexInfo.format) {
479
+ throw new Error(`[amytis] Series "${seriesSlug}" mixes ${indexInfo.format} and ${itemFormat} files. Offending file: ${item.name}`);
480
+ }
481
+
482
+ const rawName = item.name.replace(/\.(mdx?|rst)$/, '');
483
+ const { slug, dateFromFileName } = parseSlugAndDate(rawName);
484
+ const prior = seenSlugs.get(slug);
485
+ if (prior) {
486
+ throw new Error(`[amytis] Series "${seriesSlug}" contains duplicate post slug "${slug}" from "${prior}" and "${item.name}".`);
487
+ }
488
+ seenSlugs.set(slug, item.name);
489
+ entries.push({ fullPath: path.join(seriesPath, item.name), slug, dateFromFileName });
490
+ continue;
491
+ }
492
+
493
+ if (item.isDirectory()) {
494
+ const folderPath = path.join(seriesPath, item.name);
495
+ const folderIndexRst = path.join(folderPath, 'index.rst');
496
+ const folderIndexMdx = path.join(folderPath, 'index.mdx');
497
+ const folderIndexMd = path.join(folderPath, 'index.md');
498
+ const hasRst = fs.existsSync(folderIndexRst);
499
+ const hasMdx = fs.existsSync(folderIndexMdx);
500
+ const hasMd = fs.existsSync(folderIndexMd);
501
+ const markdownCount = Number(hasMdx) + Number(hasMd);
502
+ const totalIndexCount = Number(hasRst) + markdownCount;
503
+
504
+ if (totalIndexCount === 0) continue;
505
+ if (hasRst && markdownCount > 0) {
506
+ throw new Error(`[amytis] Series "${seriesSlug}" post folder "${item.name}" cannot contain both index.rst and Markdown index files.`);
507
+ }
508
+ if (markdownCount > 1) {
509
+ throw new Error(`[amytis] Series "${seriesSlug}" post folder "${item.name}" cannot contain both index.md and index.mdx.`);
510
+ }
511
+
512
+ const itemFormat: SeriesFormat = hasRst ? 'rst' : 'markdown';
513
+ if (itemFormat !== indexInfo.format) {
514
+ throw new Error(`[amytis] Series "${seriesSlug}" mixes ${indexInfo.format} and ${itemFormat} files. Offending folder: ${item.name}`);
515
+ }
516
+
517
+ const { slug, dateFromFileName } = parseSlugAndDate(item.name);
518
+ const prior = seenSlugs.get(slug);
519
+ if (prior) {
520
+ throw new Error(`[amytis] Series "${seriesSlug}" contains duplicate post slug "${slug}" from "${prior}" and "${item.name}".`);
521
+ }
522
+ seenSlugs.set(slug, item.name);
523
+ entries.push({
524
+ fullPath: hasRst ? folderIndexRst : (hasMdx ? folderIndexMdx : folderIndexMd),
525
+ slug,
526
+ dateFromFileName,
527
+ });
528
+ }
529
+ }
530
+
531
+ return entries;
532
+ }
533
+
224
534
  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;
535
+ const cacheKey = getCacheEnvKey();
536
+ let bySlug = seriesTitleCache.get(cacheKey);
537
+ if (!bySlug) {
538
+ bySlug = new Map();
539
+ seriesTitleCache.set(cacheKey, bySlug);
540
+ }
541
+ if (bySlug.has(slug)) return bySlug.get(slug);
542
+
543
+ const indexInfo = resolveSeriesIndexInfo(slug);
544
+ if (!indexInfo) {
545
+ bySlug.set(slug, undefined);
546
+ return undefined;
547
+ }
548
+
549
+ if (indexInfo.format === 'rst') {
550
+ const parsed = parseRstDocument(readUtf8File(indexInfo.fullPath));
551
+ if (parsed.metadata.draft === true) {
552
+ bySlug.set(slug, undefined);
553
+ return undefined;
554
+ }
555
+ bySlug.set(slug, parsed.title);
556
+ return parsed.title;
557
+ }
558
+
559
+ const { data } = matter(readUtf8File(indexInfo.fullPath));
560
+ if (data.draft === true) {
561
+ bySlug.set(slug, undefined);
562
+ return undefined;
563
+ }
564
+ const title = typeof data.title === 'string' ? data.title : undefined;
565
+ bySlug.set(slug, title);
566
+ return title;
235
567
  }
236
568
 
237
569
  function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: string, seriesName?: string): PostData {
@@ -280,7 +612,7 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
280
612
 
281
613
  let date = data.date;
282
614
  if (!date && dateFromFileName) date = dateFromFileName;
283
- if (!date) date = new Date().toISOString().split('T')[0]; // Fallback
615
+ if (!date) date = fs.statSync(fullPath).mtime.toISOString().split('T')[0];
284
616
 
285
617
  const headings = getHeadings(content);
286
618
 
@@ -319,11 +651,179 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
319
651
  content: contentWithoutH1,
320
652
  headings,
321
653
  imageBaseSlug,
654
+ sourceFormat: 'markdown',
322
655
  };
323
656
  }
324
657
 
658
+ export function parseMarkdownFileForTests(
659
+ fullPath: string,
660
+ slug: string,
661
+ dateFromFileName?: string,
662
+ seriesName?: string,
663
+ ): PostData {
664
+ return parseMarkdownFile(fullPath, slug, dateFromFileName, seriesName);
665
+ }
666
+
667
+ function parseRstFile(
668
+ fullPath: string,
669
+ slug: string,
670
+ dateFromFileName?: string,
671
+ seriesName?: string,
672
+ preRendered?: RenderedRstDocument,
673
+ ): PostData {
674
+ try {
675
+ const imageBaseSlug = getRstImageBaseSlug(fullPath, slug);
676
+ const fileContents = fs.readFileSync(fullPath, 'utf8');
677
+
678
+ let parsedTitle: string;
679
+ let parsedBody: string;
680
+ let parsedText: string | undefined;
681
+ let parsedHeadings: Heading[];
682
+ let parsedExcerpt: string;
683
+ let parsedReadingTime: string;
684
+ let parsedHtml: string | undefined;
685
+ let data: ReturnType<typeof parseRstDocument>['metadata'];
686
+ try {
687
+ if (preRendered) {
688
+ const rendered = preRendered;
689
+ parsedTitle = rendered.title;
690
+ parsedBody = rendered.text;
691
+ parsedText = rendered.text;
692
+ parsedHeadings = rendered.headings;
693
+ parsedExcerpt = rendered.excerpt;
694
+ parsedReadingTime = rendered.readingTime;
695
+ parsedHtml = rendered.html;
696
+ data = rendered.metadata;
697
+ } else if (shouldUsePythonRstRenderer() && pythonRstRendererAvailable !== false) {
698
+ const rendered = renderRstFile(fullPath, imageBaseSlug);
699
+ pythonRstRendererAvailable = true;
700
+ parsedTitle = rendered.title;
701
+ parsedBody = rendered.text;
702
+ parsedText = rendered.text;
703
+ parsedHeadings = rendered.headings;
704
+ parsedExcerpt = rendered.excerpt;
705
+ parsedReadingTime = rendered.readingTime;
706
+ parsedHtml = rendered.html;
707
+ data = rendered.metadata;
708
+ } else {
709
+ throw new Error('__RST_FALLBACK__');
710
+ }
711
+ } catch (error) {
712
+ if (!isPythonRuntimeUnavailable(error)) {
713
+ throw error;
714
+ }
715
+ if (pythonRstRendererAvailable !== false) {
716
+ pythonRstRendererAvailable = false;
717
+ }
718
+ const parsed = parseRstDocument(fileContents);
719
+ parsedTitle = parsed.title;
720
+ parsedBody = parsed.body;
721
+ parsedHeadings = parsed.headings;
722
+ parsedExcerpt = parsed.excerpt;
723
+ parsedReadingTime = parsed.readingTime;
724
+ data = parsed.metadata;
725
+ }
726
+
727
+ const effectiveSeriesSlug = data.series || seriesName;
728
+ let authors: string[] = [];
729
+ if (data.authors && data.authors.length > 0) {
730
+ authors = data.authors;
731
+ } else if (data.author) {
732
+ authors = [data.author];
733
+ } else {
734
+ if (effectiveSeriesSlug) {
735
+ const seriesAuthors = getSeriesAuthors(effectiveSeriesSlug);
736
+ if (seriesAuthors) authors = seriesAuthors;
737
+ }
738
+ if (authors.length === 0) {
739
+ const defaultAuthors = siteConfig.posts?.authors?.default;
740
+ if (defaultAuthors && defaultAuthors.length > 0) {
741
+ authors = defaultAuthors;
742
+ }
743
+ }
744
+ }
745
+
746
+ let date = data.date;
747
+ if (!date && dateFromFileName) date = dateFromFileName;
748
+ if (!date) date = fs.statSync(fullPath).mtime.toISOString().split('T')[0];
749
+
750
+ let coverImage = data.coverImage;
751
+ if (coverImage && !coverImage.startsWith('http') && !coverImage.startsWith('/') && !coverImage.startsWith('text:')) {
752
+ const cleanPath = coverImage.replace(/^\.\//, '');
753
+ coverImage = `/${imageBaseSlug}/${cleanPath}`;
754
+ }
755
+ const toctreePosts = isSeriesIndexRst(fullPath, slug, seriesName)
756
+ ? extractRstToctreePosts(fileContents)
757
+ : [];
758
+ const seriesPosts = data.posts && data.posts.length > 0
759
+ ? data.posts
760
+ : ((data.sort === undefined || data.sort === 'manual') && toctreePosts.length > 0 ? toctreePosts : undefined);
761
+ const sort = data.sort ?? (seriesPosts ? 'manual' : 'date-desc');
762
+
763
+ return {
764
+ slug,
765
+ title: parsedTitle,
766
+ subtitle: data.subtitle,
767
+ date,
768
+ excerpt: data.excerpt || parsedExcerpt,
769
+ category: data.category ?? 'Uncategorized',
770
+ tags: data.tags ?? [],
771
+ authors,
772
+ layout: data.layout ?? 'post',
773
+ series: effectiveSeriesSlug,
774
+ seriesTitle: effectiveSeriesSlug ? getSeriesTitle(effectiveSeriesSlug) : undefined,
775
+ coverImage,
776
+ sort,
777
+ posts: seriesPosts,
778
+ type: data.type,
779
+ featured: data.featured ?? false,
780
+ pinned: data.pinned ?? false,
781
+ draft: data.draft ?? false,
782
+ latex: data.latex ?? false,
783
+ toc: data.toc ?? true,
784
+ commentable: data.commentable,
785
+ redirectFrom: data.redirectFrom ?? [],
786
+ readingTime: parsedReadingTime,
787
+ content: parsedBody,
788
+ renderedHtml: parsedHtml,
789
+ plainText: parsedText,
790
+ headings: parsedHeadings,
791
+ imageBaseSlug,
792
+ sourceFormat: 'rst',
793
+ };
794
+ } catch (error) {
795
+ if (error instanceof RstParseError) {
796
+ throw new RstParseError(`${error.message} (${fullPath})`);
797
+ }
798
+ throw error;
799
+ }
800
+ }
801
+
802
+ export function parseRstFileForTests(
803
+ fullPath: string,
804
+ slug: string,
805
+ dateFromFileName?: string,
806
+ seriesName?: string,
807
+ preRendered?: RenderedRstDocument,
808
+ ): PostData {
809
+ return parseRstFile(fullPath, slug, dateFromFileName, seriesName, preRendered);
810
+ }
811
+
812
+ export function resetPythonRstRendererAvailabilityForTests(value: boolean | null = null): void {
813
+ pythonRstRendererAvailable = value;
814
+ }
815
+
816
+ export function getPythonRstRendererAvailabilityForTests(): boolean | null {
817
+ return pythonRstRendererAvailable;
818
+ }
819
+
325
820
  export function getAllPosts(): PostData[] {
821
+ const cacheKey = getCacheEnvKey();
822
+ const cached = postsCache.get(cacheKey);
823
+ if (cached) return cached;
824
+
326
825
  const allPostsData: PostData[] = [];
826
+ const pendingRstPosts: PendingRstPostEntry[] = [];
327
827
 
328
828
  // Helper to process a directory
329
829
  const processDirectory = (dir: string, isSeriesDir: boolean = false) => {
@@ -336,81 +836,29 @@ export function getAllPosts(): PostData[] {
336
836
  let slug = '';
337
837
  let dateFromFileName = undefined;
338
838
 
339
- const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
340
839
  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
- }
840
+ ({ slug, dateFromFileName } = parseSlugAndDate(rawName));
353
841
 
354
842
  // Handle Series Directory logic
355
843
  if (isSeriesDir) {
356
844
  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
845
+ const seriesSlug = item.name;
846
+ const indexInfo = resolveSeriesIndexInfo(seriesSlug);
847
+ if (!indexInfo) return;
848
+
849
+ getSeriesContentEntries(seriesSlug).forEach(entry => {
850
+ if (indexInfo.format === 'rst') {
851
+ pendingRstPosts.push({
852
+ fullPath: entry.fullPath,
853
+ slug: entry.slug,
854
+ dateFromFileName: entry.dateFromFileName,
855
+ seriesSlug,
856
+ });
857
+ } else {
858
+ allPostsData.push(parseMarkdownFile(entry.fullPath, entry.slug, entry.dateFromFileName, seriesSlug));
859
+ }
860
+ });
861
+ return;
414
862
  }
415
863
  }
416
864
 
@@ -434,7 +882,41 @@ export function getAllPosts(): PostData[] {
434
882
  processDirectory(contentDirectory);
435
883
  processDirectory(seriesDirectory, true);
436
884
 
437
- return allPostsData
885
+ if (pendingRstPosts.length > 0) {
886
+ let batchRenderedByFile: Map<string, RenderedRstDocument> | null = null;
887
+
888
+ if (shouldUsePythonRstRenderer() && pythonRstRendererAvailable !== false) {
889
+ try {
890
+ batchRenderedByFile = renderRstFilesBatch(
891
+ pendingRstPosts.map(entry => ({
892
+ file: entry.fullPath,
893
+ imageBaseSlug: getRstImageBaseSlug(entry.fullPath, entry.slug),
894
+ }))
895
+ );
896
+ pythonRstRendererAvailable = true;
897
+ } catch (error) {
898
+ if (isPythonRuntimeUnavailable(error)) {
899
+ pythonRstRendererAvailable = false;
900
+ } else {
901
+ throw error;
902
+ }
903
+ }
904
+ }
905
+
906
+ pendingRstPosts.forEach(entry => {
907
+ allPostsData.push(
908
+ parseRstFile(
909
+ entry.fullPath,
910
+ entry.slug,
911
+ entry.dateFromFileName,
912
+ entry.seriesSlug,
913
+ batchRenderedByFile?.get(entry.fullPath),
914
+ )
915
+ );
916
+ });
917
+ }
918
+
919
+ const result = allPostsData
438
920
  .filter(post => {
439
921
  if (post.category === 'Page') return false;
440
922
 
@@ -450,6 +932,8 @@ export function getAllPosts(): PostData[] {
450
932
  return true;
451
933
  })
452
934
  .sort((a, b) => (a.date < b.date ? 1 : -1));
935
+ postsCache.set(cacheKey, result);
936
+ return result;
453
937
  }
454
938
 
455
939
  /**
@@ -463,109 +947,8 @@ export function getListingPosts(): PostData[] {
463
947
  return getAllPosts().filter(p => !p.series || !excluded.has(p.series));
464
948
  }
465
949
 
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
950
  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;
951
+ return getAllPosts().find(post => post.slug === slug) ?? null;
569
952
  }
570
953
 
571
954
  /**
@@ -621,8 +1004,12 @@ export function getPageBySlug(slug: string): PostData | null {
621
1004
  }
622
1005
 
623
1006
  export function getAllPages(): PostData[] {
1007
+ const cacheKey = getCacheEnvKey();
1008
+ const cached = pagesCache.get(cacheKey);
1009
+ if (cached) return cached;
1010
+
624
1011
  const items = fs.readdirSync(pagesDirectory, { withFileTypes: true });
625
- return items
1012
+ const result = items
626
1013
  .filter(item => {
627
1014
  if (!item.isFile()) return false;
628
1015
  if (!item.name.endsWith('.mdx') && !item.name.endsWith('.md')) return false;
@@ -639,6 +1026,8 @@ export function getAllPages(): PostData[] {
639
1026
  const fullPath = path.join(pagesDirectory, item.name);
640
1027
  return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
641
1028
  });
1029
+ pagesCache.set(cacheKey, result);
1030
+ return result;
642
1031
  }
643
1032
 
644
1033
  export function getPostsByTag(tag: string): PostData[] {
@@ -661,6 +1050,10 @@ export function getFlowTags(): Record<string, number> {
661
1050
  }
662
1051
 
663
1052
  export function getAllTags(): Record<string, number> {
1053
+ const cacheKey = getCacheEnvKey();
1054
+ const cached = tagsCache.get(cacheKey);
1055
+ if (cached) return cached;
1056
+
664
1057
  const allPosts = getAllPosts();
665
1058
  const allFlows = getAllFlows();
666
1059
  const allNotes = getAllNotes();
@@ -696,6 +1089,7 @@ export function getAllTags(): Record<string, number> {
696
1089
  for (const [key, count] of Object.entries(counts)) {
697
1090
  result[display[key]] = count;
698
1091
  }
1092
+ tagsCache.set(cacheKey, result);
699
1093
  return result;
700
1094
  }
701
1095
 
@@ -717,6 +1111,10 @@ export function getAuthorSlug(author: string): string {
717
1111
  }
718
1112
 
719
1113
  export function getAllAuthors(): Record<string, number> {
1114
+ const cacheKey = getCacheEnvKey();
1115
+ const cached = authorsCache.get(cacheKey);
1116
+ if (cached) return cached;
1117
+
720
1118
  const allPosts = getAllPosts();
721
1119
  const authors: Record<string, number> = {};
722
1120
 
@@ -729,7 +1127,7 @@ export function getAllAuthors(): Record<string, number> {
729
1127
  }
730
1128
  });
731
1129
  });
732
-
1130
+ authorsCache.set(cacheKey, authors);
733
1131
  return authors;
734
1132
  }
735
1133
 
@@ -747,6 +1145,16 @@ export function resolveAuthorParam(authorParam: string): string | null {
747
1145
  }
748
1146
 
749
1147
  export function getRelatedPosts(currentSlug: string, limit: number = 3): PostData[] {
1148
+ const cacheKey = getCacheEnvKey();
1149
+ let bySlug = relatedPostsCache.get(cacheKey);
1150
+ if (!bySlug) {
1151
+ bySlug = new Map();
1152
+ relatedPostsCache.set(cacheKey, bySlug);
1153
+ }
1154
+ const cacheId = `${currentSlug}:${limit}`;
1155
+ const cached = bySlug.get(cacheId);
1156
+ if (cached) return cached;
1157
+
750
1158
  const allPosts = getAllPosts();
751
1159
  const currentPost = allPosts.find(p => p.slug === currentSlug);
752
1160
 
@@ -770,10 +1178,20 @@ export function getRelatedPosts(currentSlug: string, limit: number = 3): PostDat
770
1178
  .slice(0, limit)
771
1179
  .map(item => item.post);
772
1180
 
1181
+ bySlug.set(cacheId, related);
773
1182
  return related;
774
1183
  }
775
1184
 
776
1185
  export function getSeriesPosts(seriesName: string): PostData[] {
1186
+ const cacheKey = getCacheEnvKey();
1187
+ let bySeries = seriesPostsCache.get(cacheKey);
1188
+ if (!bySeries) {
1189
+ bySeries = new Map();
1190
+ seriesPostsCache.set(cacheKey, bySeries);
1191
+ }
1192
+ const cached = bySeries.get(seriesName);
1193
+ if (cached) return cached;
1194
+
777
1195
  const seriesSlug = seriesName;
778
1196
  const seriesData = getSeriesData(seriesSlug);
779
1197
 
@@ -798,10 +1216,15 @@ export function getSeriesPosts(seriesName: string): PostData[] {
798
1216
  }
799
1217
  }
800
1218
 
1219
+ bySeries.set(seriesName, posts);
801
1220
  return posts;
802
1221
  }
803
1222
 
804
1223
  export function getAllSeries(): Record<string, PostData[]> {
1224
+ const cacheKey = getCacheEnvKey();
1225
+ const cached = allSeriesCache.get(cacheKey);
1226
+ if (cached) return cached;
1227
+
805
1228
  const allPosts = getAllPosts();
806
1229
  const series: Record<string, PostData[]> = {};
807
1230
  const seriesSet = new Set<string>();
@@ -834,25 +1257,89 @@ export function getAllSeries(): Record<string, PostData[]> {
834
1257
  : getSeriesPosts(slug);
835
1258
  });
836
1259
 
1260
+ allSeriesCache.set(cacheKey, series);
837
1261
  return series;
838
1262
  }
839
1263
 
1264
+ export function getSeriesLatestPostDate(slug: string): string {
1265
+ const cacheKey = getCacheEnvKey();
1266
+ let bySlug = seriesLatestDateCache.get(cacheKey);
1267
+ if (!bySlug) {
1268
+ bySlug = new Map();
1269
+ seriesLatestDateCache.set(cacheKey, bySlug);
1270
+ }
1271
+ const cached = bySlug.get(slug);
1272
+ if (cached !== undefined) return cached;
1273
+
1274
+ const seriesData = getSeriesData(slug);
1275
+ const posts = seriesData?.type === 'collection' ? getCollectionPosts(slug) : getSeriesPosts(slug);
1276
+ const latestPostDate = posts.reduce((latest, post) => (post.date > latest ? post.date : latest), '');
1277
+ const resolved = latestPostDate || seriesData?.date || '';
1278
+
1279
+ bySlug.set(slug, resolved);
1280
+ return resolved;
1281
+ }
1282
+
840
1283
  export function getFeaturedPosts(): PostData[] {
841
- const allPosts = getAllPosts();
842
- return allPosts.filter(post => post.featured);
1284
+ const cacheKey = getCacheEnvKey();
1285
+ const cached = featuredPostsCache.get(cacheKey);
1286
+ if (cached) return cached;
1287
+ const result = getAllPosts().filter(post => post.featured);
1288
+ featuredPostsCache.set(cacheKey, result);
1289
+ return result;
843
1290
  }
844
1291
 
845
1292
  export function getAdjacentPosts(slug: string): { prev: PostData | null; next: PostData | null } {
1293
+ const cacheKey = getCacheEnvKey();
1294
+ let bySlug = adjacentPostsCache.get(cacheKey);
1295
+ if (!bySlug) {
1296
+ bySlug = new Map();
1297
+ adjacentPostsCache.set(cacheKey, bySlug);
1298
+ }
1299
+ const currentPost = getPostBySlug(slug);
1300
+ if (currentPost?.series) {
1301
+ const seriesCacheKey = `${currentPost.series}/${slug}`;
1302
+ const cachedSeries = bySlug.get(seriesCacheKey);
1303
+ if (cachedSeries) return cachedSeries;
1304
+
1305
+ const seriesData = getSeriesData(currentPost.series);
1306
+ if (seriesData?.type !== 'collection') {
1307
+ const seriesPosts = getSeriesPosts(currentPost.series);
1308
+ const seriesIndex = seriesPosts.findIndex(post => post.slug === slug);
1309
+ if (seriesIndex !== -1) {
1310
+ const seriesResult = {
1311
+ prev: seriesIndex > 0 ? seriesPosts[seriesIndex - 1] : null,
1312
+ next: seriesIndex < seriesPosts.length - 1 ? seriesPosts[seriesIndex + 1] : null,
1313
+ };
1314
+ bySlug.set(seriesCacheKey, seriesResult);
1315
+ return seriesResult;
1316
+ }
1317
+ }
1318
+ }
1319
+
1320
+ const cached = bySlug.get(slug);
1321
+ if (cached) return cached;
1322
+
846
1323
  const allPosts = getAllPosts(); // sorted desc by date (newest first)
847
1324
  const index = allPosts.findIndex(p => p.slug === slug);
848
- if (index === -1) return { prev: null, next: null };
849
- return {
1325
+ if (index === -1) {
1326
+ const empty = { prev: null, next: null };
1327
+ bySlug.set(slug, empty);
1328
+ return empty;
1329
+ }
1330
+ const result = {
850
1331
  prev: index < allPosts.length - 1 ? allPosts[index + 1] : null, // older post
851
1332
  next: index > 0 ? allPosts[index - 1] : null, // newer post
852
1333
  };
1334
+ bySlug.set(slug, result);
1335
+ return result;
853
1336
  }
854
1337
 
855
1338
  export function getFeaturedSeries(): Record<string, PostData[]> {
1339
+ const cacheKey = getCacheEnvKey();
1340
+ const cached = featuredSeriesCache.get(cacheKey);
1341
+ if (cached) return cached;
1342
+
856
1343
  const allSeries = getAllSeries();
857
1344
  const featuredSeries: Record<string, PostData[]> = {};
858
1345
 
@@ -863,25 +1350,47 @@ export function getFeaturedSeries(): Record<string, PostData[]> {
863
1350
  }
864
1351
  });
865
1352
 
1353
+ featuredSeriesCache.set(cacheKey, featuredSeries);
866
1354
  return featuredSeries;
867
1355
  }
868
1356
 
869
1357
  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');
1358
+ const cacheKey = getCacheEnvKey();
1359
+ let bySlug = seriesDataCache.get(cacheKey);
1360
+ if (!bySlug) {
1361
+ bySlug = new Map();
1362
+ seriesDataCache.set(cacheKey, bySlug);
1363
+ }
1364
+ if (bySlug.has(slug)) return bySlug.get(slug) ?? null;
873
1365
 
874
- let fullPath = '';
875
- if (fs.existsSync(indexPathMdx)) fullPath = indexPathMdx;
876
- else if (fs.existsSync(indexPathMd)) fullPath = indexPathMd;
877
- else return null;
1366
+ const indexInfo = resolveSeriesIndexInfo(slug);
1367
+ if (!indexInfo) {
1368
+ bySlug.set(slug, null);
1369
+ return null;
1370
+ }
878
1371
 
879
- return parseMarkdownFile(fullPath, slug, undefined, slug);
1372
+ const result = indexInfo.format === 'rst'
1373
+ ? parseRstFile(indexInfo.fullPath, slug, undefined, slug)
1374
+ : parseMarkdownFile(indexInfo.fullPath, slug, undefined, slug);
1375
+ bySlug.set(slug, result);
1376
+ return result;
880
1377
  }
881
1378
 
882
1379
  export function getCollectionPosts(collectionSlug: string): PostData[] {
1380
+ const cacheKey = getCacheEnvKey();
1381
+ let bySlug = collectionPostsCache.get(cacheKey);
1382
+ if (!bySlug) {
1383
+ bySlug = new Map();
1384
+ collectionPostsCache.set(cacheKey, bySlug);
1385
+ }
1386
+ const cached = bySlug.get(collectionSlug);
1387
+ if (cached) return cached;
1388
+
883
1389
  const data = getSeriesData(collectionSlug);
884
- if (data?.type !== 'collection' || !data.items) return [];
1390
+ if (data?.type !== 'collection' || !data.items) {
1391
+ bySlug.set(collectionSlug, []);
1392
+ return [];
1393
+ }
885
1394
 
886
1395
  const getCollectionKey = (post: PostData) =>
887
1396
  post.series ? `${post.series}/${post.slug}` : `posts/${post.slug}`;
@@ -890,7 +1399,7 @@ export function getCollectionPosts(collectionSlug: string): PostData[] {
890
1399
  const postIndex = new Map(allPosts.map((post) => [getCollectionKey(post), post]));
891
1400
  const seen = new Set<string>();
892
1401
 
893
- return data.items
1402
+ const result = data.items
894
1403
  .flatMap(item => {
895
1404
  if ('series' in item) {
896
1405
  const posts = getSeriesPosts(item.series);
@@ -909,9 +1418,20 @@ export function getCollectionPosts(collectionSlug: string): PostData[] {
909
1418
  seen.add(key);
910
1419
  return true;
911
1420
  });
1421
+ bySlug.set(collectionSlug, result);
1422
+ return result;
912
1423
  }
913
1424
 
914
1425
  export function getCollectionsForPost(postSlug: string): CollectionContext[] {
1426
+ const cacheKey = getCacheEnvKey();
1427
+ let bySlug = collectionsForPostCache.get(cacheKey);
1428
+ if (!bySlug) {
1429
+ bySlug = new Map();
1430
+ collectionsForPostCache.set(cacheKey, bySlug);
1431
+ }
1432
+ const cached = bySlug.get(postSlug);
1433
+ if (cached) return cached;
1434
+
915
1435
  if (!fs.existsSync(seriesDirectory)) return [];
916
1436
  const seriesFolders = fs.readdirSync(seriesDirectory, { withFileTypes: true });
917
1437
  const results: CollectionContext[] = [];
@@ -927,6 +1447,7 @@ export function getCollectionsForPost(postSlug: string): CollectionContext[] {
927
1447
  }
928
1448
  }
929
1449
 
1450
+ bySlug.set(postSlug, results);
930
1451
  return results;
931
1452
  }
932
1453