@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
@@ -0,0 +1,617 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { RstMetadata, RstParseError } from './rst';
7
+
8
+ export interface PythonRstHeading {
9
+ id: string;
10
+ text: string;
11
+ level: number;
12
+ }
13
+
14
+ export interface PythonRstAsset {
15
+ original: string;
16
+ resolved: string;
17
+ exists: boolean;
18
+ }
19
+
20
+ export interface PythonRstRenderResult {
21
+ title: string;
22
+ html: string;
23
+ text: string;
24
+ headings: PythonRstHeading[];
25
+ metadata: Record<string, unknown>;
26
+ assets?: PythonRstAsset[];
27
+ warnings?: string[];
28
+ }
29
+
30
+ export interface RenderedRstDocument {
31
+ title: string;
32
+ html: string;
33
+ text: string;
34
+ headings: PythonRstHeading[];
35
+ metadata: RstMetadata;
36
+ excerpt: string;
37
+ readingTime: string;
38
+ assets: PythonRstAsset[];
39
+ warnings: string[];
40
+ }
41
+
42
+ export interface PythonRstBatchEntry {
43
+ file: string;
44
+ imageBaseSlug: string;
45
+ }
46
+
47
+ interface PythonRstBatchResponseItem {
48
+ file: string;
49
+ ok: boolean;
50
+ result?: PythonRstRenderResult;
51
+ error?: string;
52
+ }
53
+
54
+ interface PythonCommandSpec {
55
+ executable: string;
56
+ args: string[];
57
+ cacheKey: string;
58
+ }
59
+
60
+ interface RstRendererDiskCacheEntry {
61
+ version: string;
62
+ sourceHash: string;
63
+ imageBaseSlug: string;
64
+ pythonCacheKey: string;
65
+ rendered: RenderedRstDocument;
66
+ }
67
+
68
+ const rstRenderCache = new Map<string, RenderedRstDocument>();
69
+ const PYTHON_RENDERER_MAX_BUFFER = 1024 * 1024 * 128;
70
+ const RST_RENDERER_DISK_CACHE_VERSION = '1';
71
+ const rstRendererCacheDir = path.join(process.cwd(), '.cache', 'rst-renderer');
72
+ let resolvedPythonCommandSpec: PythonCommandSpec | null = null;
73
+ let pythonRendererInvocationCount = 0;
74
+
75
+ export function resetPythonCommandSpecForTests(): void {
76
+ resolvedPythonCommandSpec = null;
77
+ }
78
+
79
+ export function getPythonRendererInvocationCountForTests(): number {
80
+ return pythonRendererInvocationCount;
81
+ }
82
+
83
+ export function resetRstRendererCachesForTests(): void {
84
+ rstRenderCache.clear();
85
+ resolvedPythonCommandSpec = null;
86
+ pythonRendererInvocationCount = 0;
87
+ }
88
+
89
+ function ensureSpawnOutputString(output: string | NodeJS.ArrayBufferView | null | undefined): string {
90
+ if (typeof output === 'string') return output;
91
+ if (!output) return '';
92
+ return Buffer.from(output.buffer, output.byteOffset, output.byteLength).toString('utf8');
93
+ }
94
+
95
+ function getRstRendererSourceHash(filePath: string): string {
96
+ return createHash('sha1').update(fs.readFileSync(filePath)).digest('hex');
97
+ }
98
+
99
+ function canonicalizeSourcePath(filePath: string): string {
100
+ try {
101
+ return fs.realpathSync(filePath);
102
+ } catch {
103
+ return path.resolve(filePath);
104
+ }
105
+ }
106
+
107
+ function getRenderCacheKey(filePath: string, imageBaseSlug: string): string {
108
+ const stats = fs.statSync(filePath);
109
+ return `${getPythonCommandSpecForRstRenderer().cacheKey}::${filePath}::${imageBaseSlug}::${stats.mtimeMs}::${stats.size}`;
110
+ }
111
+
112
+ function getRstRendererDiskCachePath(filePath: string): string {
113
+ const cacheKey = createHash('sha1')
114
+ .update(canonicalizeSourcePath(filePath))
115
+ .digest('hex');
116
+ return path.join(rstRendererCacheDir, `${cacheKey}.json`);
117
+ }
118
+
119
+ export function getRstRendererDiskCachePathForTests(filePath: string): string {
120
+ return getRstRendererDiskCachePath(filePath);
121
+ }
122
+
123
+ function loadRenderedRstDocumentFromDiskCache(filePath: string, imageBaseSlug: string): RenderedRstDocument | null {
124
+ const cachePath = getRstRendererDiskCachePath(filePath);
125
+ if (!fs.existsSync(cachePath)) return null;
126
+
127
+ try {
128
+ const raw = fs.readFileSync(cachePath, 'utf8');
129
+ const parsed = JSON.parse(raw) as Partial<RstRendererDiskCacheEntry>;
130
+ if (
131
+ parsed.version !== RST_RENDERER_DISK_CACHE_VERSION ||
132
+ parsed.imageBaseSlug !== imageBaseSlug ||
133
+ parsed.pythonCacheKey !== getPythonCommandSpecForRstRenderer().cacheKey ||
134
+ parsed.sourceHash !== getRstRendererSourceHash(filePath) ||
135
+ !parsed.rendered
136
+ ) {
137
+ return null;
138
+ }
139
+
140
+ return parsed.rendered as RenderedRstDocument;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function writeRenderedRstDocumentToDiskCache(filePath: string, imageBaseSlug: string, rendered: RenderedRstDocument): void {
147
+ const cachePath = getRstRendererDiskCachePath(filePath);
148
+ const entry: RstRendererDiskCacheEntry = {
149
+ version: RST_RENDERER_DISK_CACHE_VERSION,
150
+ sourceHash: getRstRendererSourceHash(filePath),
151
+ imageBaseSlug,
152
+ pythonCacheKey: getPythonCommandSpecForRstRenderer().cacheKey,
153
+ rendered,
154
+ };
155
+
156
+ try {
157
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
158
+ fs.writeFileSync(cachePath, JSON.stringify(entry), 'utf8');
159
+ } catch {
160
+ // Best-effort cache persistence; rendering should still succeed.
161
+ }
162
+ }
163
+
164
+ export function getPythonCommandSpecForRstRenderer(): PythonCommandSpec {
165
+ if (resolvedPythonCommandSpec) {
166
+ return resolvedPythonCommandSpec;
167
+ }
168
+
169
+ if (process.env.AMYTIS_RST_PYTHON) {
170
+ resolvedPythonCommandSpec = {
171
+ executable: process.env.AMYTIS_RST_PYTHON,
172
+ args: [],
173
+ cacheKey: process.env.AMYTIS_RST_PYTHON,
174
+ };
175
+ return resolvedPythonCommandSpec;
176
+ }
177
+
178
+ const candidates: PythonCommandSpec[] = process.platform === 'win32'
179
+ ? [
180
+ { executable: 'py', args: ['-3'], cacheKey: 'py::-3' },
181
+ { executable: 'python', args: [], cacheKey: 'python' },
182
+ ]
183
+ : [
184
+ { executable: 'python3', args: [], cacheKey: 'python3' },
185
+ { executable: 'python', args: [], cacheKey: 'python' },
186
+ ];
187
+
188
+ for (const candidate of candidates) {
189
+ const probe = spawnSync(candidate.executable, [...candidate.args, '--version'], {
190
+ encoding: 'utf8',
191
+ });
192
+ if (!probe.error && probe.status === 0) {
193
+ resolvedPythonCommandSpec = candidate;
194
+ return resolvedPythonCommandSpec;
195
+ }
196
+ }
197
+
198
+ console.warn(
199
+ `[rst-renderer] No Python candidate responded to --version; using fallback ${candidates.map((candidate) =>
200
+ [candidate.executable, ...candidate.args].join(' ')
201
+ ).join(', ')}`
202
+ );
203
+
204
+ resolvedPythonCommandSpec = process.platform === 'win32'
205
+ ? { executable: 'py', args: ['-3'], cacheKey: 'py::-3' }
206
+ : { executable: 'python3', args: [], cacheKey: 'python3' };
207
+ return resolvedPythonCommandSpec;
208
+ }
209
+
210
+ function parseBoolean(field: string, value: unknown): boolean {
211
+ if (value === true || value === false) return value;
212
+ if (typeof value === 'string') {
213
+ if (value === 'true') return true;
214
+ if (value === 'false') return false;
215
+ }
216
+ throw new RstParseError(`Invalid boolean for "${field}": ${String(value)}`);
217
+ }
218
+
219
+ function parseString(field: string, value: unknown): string {
220
+ if (typeof value !== 'string') {
221
+ throw new RstParseError(`Invalid value for "${field}": ${String(value)}`);
222
+ }
223
+ return value.trim();
224
+ }
225
+
226
+ function parseStringArray(field: string, value: unknown): string[] {
227
+ if (Array.isArray(value)) {
228
+ return value.map((item) => parseString(field, item)).filter(Boolean);
229
+ }
230
+ if (typeof value === 'string') {
231
+ return value
232
+ .split(',')
233
+ .map((item) => item.trim())
234
+ .filter(Boolean);
235
+ }
236
+ throw new RstParseError(`Invalid list for "${field}": ${String(value)}`);
237
+ }
238
+
239
+ function parseDate(value: unknown): string {
240
+ const date = parseString('date', value);
241
+ const match = date.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
242
+ if (!match) {
243
+ throw new RstParseError(`Invalid date: ${date}`);
244
+ }
245
+
246
+ const [, year, month, day] = match;
247
+ const normalized = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
248
+
249
+ const parsed = new Date(`${normalized}T00:00:00Z`);
250
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== normalized) {
251
+ throw new RstParseError(`Invalid date: ${date}`);
252
+ }
253
+
254
+ return normalized;
255
+ }
256
+
257
+ function parseSort(value: unknown): 'date-desc' | 'date-asc' | 'manual' {
258
+ const sort = parseString('sort', value);
259
+ if (sort === 'date-desc' || sort === 'date-asc' || sort === 'manual') {
260
+ return sort;
261
+ }
262
+ throw new RstParseError(`Invalid sort value: ${sort}`);
263
+ }
264
+
265
+ function parseType(value: unknown): 'collection' {
266
+ const type = parseString('type', value);
267
+ if (type === 'collection') return type;
268
+ throw new RstParseError(`Invalid type value: ${type}`);
269
+ }
270
+
271
+ export function normalizePythonRstMetadata(metadata: Record<string, unknown>): RstMetadata {
272
+ const normalized: RstMetadata = {};
273
+
274
+ for (const [rawKey, rawValue] of Object.entries(metadata)) {
275
+ const key = rawKey.toLowerCase();
276
+
277
+ switch (key) {
278
+ case 'date':
279
+ normalized.date = parseDate(rawValue);
280
+ break;
281
+ case 'subtitle':
282
+ normalized.subtitle = parseString('subtitle', rawValue);
283
+ break;
284
+ case 'excerpt':
285
+ normalized.excerpt = parseString('excerpt', rawValue);
286
+ break;
287
+ case 'category':
288
+ normalized.category = parseString('category', rawValue);
289
+ break;
290
+ case 'tags':
291
+ normalized.tags = parseStringArray('tags', rawValue);
292
+ break;
293
+ case 'authors':
294
+ normalized.authors = parseStringArray('authors', rawValue);
295
+ break;
296
+ case 'author':
297
+ normalized.author = parseString('author', rawValue);
298
+ break;
299
+ case 'layout':
300
+ normalized.layout = parseString('layout', rawValue);
301
+ break;
302
+ case 'series':
303
+ normalized.series = parseString('series', rawValue);
304
+ break;
305
+ case 'coverimage':
306
+ case 'coverImage':
307
+ normalized.coverImage = parseString('coverImage', rawValue);
308
+ break;
309
+ case 'sort':
310
+ normalized.sort = parseSort(rawValue);
311
+ break;
312
+ case 'posts':
313
+ normalized.posts = parseStringArray('posts', rawValue);
314
+ break;
315
+ case 'featured':
316
+ normalized.featured = parseBoolean('featured', rawValue);
317
+ break;
318
+ case 'pinned':
319
+ normalized.pinned = parseBoolean('pinned', rawValue);
320
+ break;
321
+ case 'draft':
322
+ normalized.draft = parseBoolean('draft', rawValue);
323
+ break;
324
+ case 'latex':
325
+ normalized.latex = parseBoolean('latex', rawValue);
326
+ break;
327
+ case 'toc':
328
+ normalized.toc = parseBoolean('toc', rawValue);
329
+ break;
330
+ case 'commentable':
331
+ normalized.commentable = parseBoolean('commentable', rawValue);
332
+ break;
333
+ case 'redirectfrom':
334
+ case 'redirectFrom':
335
+ normalized.redirectFrom = parseStringArray('redirectFrom', rawValue);
336
+ break;
337
+ case 'type':
338
+ normalized.type = parseType(rawValue);
339
+ break;
340
+ default:
341
+ break;
342
+ }
343
+ }
344
+
345
+ return normalized;
346
+ }
347
+
348
+ function calculateReadingTimeFromText(text: string): string {
349
+ const wordsPerMinute = 200;
350
+ const hanCharsPerMinute = 300;
351
+
352
+ const hanCharCount = (text.match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length;
353
+ const latinWordCount = (text.match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g) || []).length;
354
+
355
+ const estimatedMinutes = (latinWordCount / wordsPerMinute) + (hanCharCount / hanCharsPerMinute);
356
+ return `${Math.max(1, Math.ceil(estimatedMinutes))} min read`;
357
+ }
358
+
359
+ export function validatePythonRstResult(result: PythonRstRenderResult, filePath: string): void {
360
+ if (!result || typeof result !== 'object') {
361
+ throw new RstParseError(`Invalid renderer output for ${filePath}: expected object.`);
362
+ }
363
+
364
+ if (typeof result.title !== 'string' || !result.title.trim()) {
365
+ throw new RstParseError(`Invalid renderer output for ${filePath}: missing title.`);
366
+ }
367
+ if (typeof result.html !== 'string') {
368
+ throw new RstParseError(`Invalid renderer output for ${filePath}: missing html.`);
369
+ }
370
+ if (typeof result.text !== 'string') {
371
+ throw new RstParseError(`Invalid renderer output for ${filePath}: missing text.`);
372
+ }
373
+ if (!Array.isArray(result.headings)) {
374
+ throw new RstParseError(`Invalid renderer output for ${filePath}: headings must be an array.`);
375
+ }
376
+ if (!result.headings.every((heading) =>
377
+ heading &&
378
+ typeof heading.id === 'string' &&
379
+ typeof heading.text === 'string' &&
380
+ typeof heading.level === 'number'
381
+ )) {
382
+ throw new RstParseError(`Invalid renderer output for ${filePath}: malformed heading entry.`);
383
+ }
384
+ if (!result.metadata || typeof result.metadata !== 'object' || Array.isArray(result.metadata)) {
385
+ throw new RstParseError(`Invalid renderer output for ${filePath}: metadata must be an object.`);
386
+ }
387
+ if (result.assets && !Array.isArray(result.assets)) {
388
+ throw new RstParseError(`Invalid renderer output for ${filePath}: assets must be an array.`);
389
+ }
390
+ if (result.assets && !result.assets.every((asset) =>
391
+ asset &&
392
+ typeof asset.original === 'string' &&
393
+ typeof asset.resolved === 'string' &&
394
+ typeof asset.exists === 'boolean'
395
+ )) {
396
+ throw new RstParseError(`Invalid renderer output for ${filePath}: malformed asset entry.`);
397
+ }
398
+ if (result.warnings && !Array.isArray(result.warnings)) {
399
+ throw new RstParseError(`Invalid renderer output for ${filePath}: warnings must be an array.`);
400
+ }
401
+ }
402
+
403
+ export function runPythonRstRenderer(filePath: string, imageBaseSlug: string): PythonRstRenderResult {
404
+ pythonRendererInvocationCount += 1;
405
+ const scriptPath = path.join(process.cwd(), 'scripts', 'render-rst.py');
406
+ const pythonCommand = getPythonCommandSpecForRstRenderer();
407
+ const result = spawnSync(pythonCommand.executable, [
408
+ ...pythonCommand.args,
409
+ scriptPath,
410
+ '--file',
411
+ filePath,
412
+ '--image-base-slug',
413
+ imageBaseSlug,
414
+ '--strict',
415
+ ], {
416
+ encoding: 'utf8',
417
+ maxBuffer: PYTHON_RENDERER_MAX_BUFFER,
418
+ });
419
+
420
+ if (result.error) {
421
+ throw new RstParseError(`Failed to run Python rST renderer for ${filePath}: ${result.error.message}`);
422
+ }
423
+
424
+ const stderr = ensureSpawnOutputString(result.stderr);
425
+ const stdout = ensureSpawnOutputString(result.stdout);
426
+
427
+ if (result.status !== 0) {
428
+ throw new RstParseError(
429
+ stderr.trim() || `Python rST renderer exited with status ${result.status} for ${filePath}.`
430
+ );
431
+ }
432
+
433
+ try {
434
+ return JSON.parse(stdout) as PythonRstRenderResult;
435
+ } catch (error) {
436
+ throw new RstParseError(
437
+ `Invalid JSON from Python rST renderer for ${filePath}: ${error instanceof Error ? error.message : String(error)}`
438
+ );
439
+ }
440
+ }
441
+
442
+ export function runPythonRstRendererBatch(entries: PythonRstBatchEntry[]): Map<string, PythonRstRenderResult> {
443
+ if (entries.length === 0) return new Map();
444
+ pythonRendererInvocationCount += 1;
445
+
446
+ const scriptPath = path.join(process.cwd(), 'scripts', 'render-rst.py');
447
+ const pythonCommand = getPythonCommandSpecForRstRenderer();
448
+ const shouldUseBatchFile = process.platform === 'win32' && pythonCommand.executable === 'py';
449
+ let batchFilePath: string | null = null;
450
+
451
+ let result: ReturnType<typeof spawnSync>;
452
+ try {
453
+ if (shouldUseBatchFile) {
454
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amytis-rst-batch-'));
455
+ batchFilePath = path.join(tempDir, 'batch.json');
456
+ fs.writeFileSync(batchFilePath, JSON.stringify(entries), 'utf8');
457
+
458
+ result = spawnSync(pythonCommand.executable, [
459
+ ...pythonCommand.args,
460
+ scriptPath,
461
+ '--batch-file',
462
+ batchFilePath,
463
+ '--strict',
464
+ ], {
465
+ encoding: 'utf8',
466
+ maxBuffer: PYTHON_RENDERER_MAX_BUFFER,
467
+ });
468
+ } else {
469
+ result = spawnSync(pythonCommand.executable, [
470
+ ...pythonCommand.args,
471
+ scriptPath,
472
+ '--batch-stdin',
473
+ '--strict',
474
+ ], {
475
+ encoding: 'utf8',
476
+ input: JSON.stringify(entries),
477
+ maxBuffer: PYTHON_RENDERER_MAX_BUFFER,
478
+ });
479
+ }
480
+ } finally {
481
+ if (batchFilePath) {
482
+ try {
483
+ fs.rmSync(path.dirname(batchFilePath), { recursive: true, force: true });
484
+ } catch {
485
+ // Best-effort cleanup for Windows batch temp files.
486
+ }
487
+ }
488
+ }
489
+
490
+ if (result.error) {
491
+ throw new RstParseError(`Failed to run Python rST renderer batch: ${result.error.message}`);
492
+ }
493
+
494
+ const stderr = ensureSpawnOutputString(result.stderr);
495
+ const stdout = ensureSpawnOutputString(result.stdout);
496
+
497
+ if (result.status !== 0) {
498
+ throw new RstParseError(
499
+ stderr.trim() || `Python rST renderer batch exited with status ${result.status}.`
500
+ );
501
+ }
502
+
503
+ let parsed: PythonRstBatchResponseItem[];
504
+ try {
505
+ parsed = JSON.parse(stdout) as PythonRstBatchResponseItem[];
506
+ } catch (error) {
507
+ throw new RstParseError(
508
+ `Invalid JSON from Python rST renderer batch: ${error instanceof Error ? error.message : String(error)}`
509
+ );
510
+ }
511
+
512
+ if (!Array.isArray(parsed)) {
513
+ throw new RstParseError('Invalid batch response from Python rST renderer: expected an array.');
514
+ }
515
+
516
+ const renderedByFile = new Map<string, PythonRstRenderResult>();
517
+ for (const item of parsed) {
518
+ if (!item || typeof item.file !== 'string' || typeof item.ok !== 'boolean') {
519
+ throw new RstParseError('Invalid batch response item from Python rST renderer.');
520
+ }
521
+ if (!item.ok) {
522
+ throw new RstParseError(item.error || `Python rST renderer batch failed for ${item.file}.`);
523
+ }
524
+ if (!item.result) {
525
+ throw new RstParseError(`Python rST renderer batch returned no result for ${item.file}.`);
526
+ }
527
+ renderedByFile.set(canonicalizeSourcePath(item.file), item.result);
528
+ }
529
+
530
+ return renderedByFile;
531
+ }
532
+
533
+ export function renderRstFile(filePath: string, imageBaseSlug: string): RenderedRstDocument {
534
+ const cacheKey = getRenderCacheKey(filePath, imageBaseSlug);
535
+ const cached = rstRenderCache.get(cacheKey);
536
+ if (cached) {
537
+ return cached;
538
+ }
539
+
540
+ const diskCached = loadRenderedRstDocumentFromDiskCache(filePath, imageBaseSlug);
541
+ if (diskCached) {
542
+ rstRenderCache.set(cacheKey, diskCached);
543
+ return diskCached;
544
+ }
545
+
546
+ const result = runPythonRstRenderer(filePath, imageBaseSlug);
547
+ validatePythonRstResult(result, filePath);
548
+ const metadata = normalizePythonRstMetadata(result.metadata);
549
+
550
+ const rendered = {
551
+ title: result.title,
552
+ html: result.html,
553
+ text: result.text,
554
+ headings: result.headings,
555
+ metadata,
556
+ excerpt: metadata.excerpt ?? '',
557
+ readingTime: calculateReadingTimeFromText(result.text),
558
+ assets: result.assets ?? [],
559
+ warnings: (result.warnings ?? []).map((warning) => String(warning)),
560
+ };
561
+
562
+ rstRenderCache.set(cacheKey, rendered);
563
+ writeRenderedRstDocumentToDiskCache(filePath, imageBaseSlug, rendered);
564
+ return rendered;
565
+ }
566
+
567
+ export function renderRstFilesBatch(entries: PythonRstBatchEntry[]): Map<string, RenderedRstDocument> {
568
+ const renderedByFile = new Map<string, RenderedRstDocument>();
569
+ const uncachedEntries: PythonRstBatchEntry[] = [];
570
+
571
+ for (const entry of entries) {
572
+ const cacheKey = getRenderCacheKey(entry.file, entry.imageBaseSlug);
573
+ const cached = rstRenderCache.get(cacheKey);
574
+ if (cached) {
575
+ renderedByFile.set(entry.file, cached);
576
+ continue;
577
+ }
578
+
579
+ const diskCached = loadRenderedRstDocumentFromDiskCache(entry.file, entry.imageBaseSlug);
580
+ if (diskCached) {
581
+ rstRenderCache.set(cacheKey, diskCached);
582
+ renderedByFile.set(entry.file, diskCached);
583
+ continue;
584
+ }
585
+ uncachedEntries.push(entry);
586
+ }
587
+
588
+ if (uncachedEntries.length === 0) {
589
+ return renderedByFile;
590
+ }
591
+
592
+ const batchResults = runPythonRstRendererBatch(uncachedEntries);
593
+ for (const entry of uncachedEntries) {
594
+ const result = batchResults.get(canonicalizeSourcePath(entry.file));
595
+ if (!result) {
596
+ throw new RstParseError(`Python rST renderer batch returned no result for ${entry.file}.`);
597
+ }
598
+ validatePythonRstResult(result, entry.file);
599
+ const metadata = normalizePythonRstMetadata(result.metadata);
600
+ const rendered: RenderedRstDocument = {
601
+ title: result.title,
602
+ html: result.html,
603
+ text: result.text,
604
+ headings: result.headings,
605
+ metadata,
606
+ excerpt: metadata.excerpt ?? '',
607
+ readingTime: calculateReadingTimeFromText(result.text),
608
+ assets: result.assets ?? [],
609
+ warnings: (result.warnings ?? []).map((warning) => String(warning)),
610
+ };
611
+ rstRenderCache.set(getRenderCacheKey(entry.file, entry.imageBaseSlug), rendered);
612
+ writeRenderedRstDocumentToDiskCache(entry.file, entry.imageBaseSlug, rendered);
613
+ renderedByFile.set(entry.file, rendered);
614
+ }
615
+
616
+ return renderedByFile;
617
+ }