@hutusi/amytis 1.13.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 (91) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +32 -0
  4. package/GEMINI.md +9 -1
  5. package/README.md +36 -2
  6. package/README.zh.md +36 -2
  7. package/TODO.md +10 -0
  8. package/bun.lock +123 -91
  9. package/content/flows/2026/03/05.md +1 -0
  10. package/content/flows/2026/03/07.md +2 -0
  11. package/content/series/modern-web-dev/index.mdx +4 -2
  12. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  13. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  14. package/content/series/rst-legacy/getting-started.rst +24 -0
  15. package/content/series/rst-legacy/index.rst +9 -0
  16. package/content/series/rst-readme/README.rst +9 -0
  17. package/content/series/rst-readme/readme-index-post.rst +10 -0
  18. package/content/series/rst-toctree/first-post.rst +6 -0
  19. package/content/series/rst-toctree/index.rst +10 -0
  20. package/content/series/rst-toctree/second-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  22. package/content/series/rst-toctree-precedence/index.rst +12 -0
  23. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  24. package/docs/ARCHITECTURE.md +30 -4
  25. package/docs/CONTRIBUTING.md +11 -0
  26. package/docs/DIGITAL_GARDEN.md +22 -1
  27. package/eslint.config.mjs +2 -0
  28. package/next.config.ts +2 -2
  29. package/package.json +27 -21
  30. package/packages/create-amytis/package.json +1 -1
  31. package/packages/create-amytis/src/index.test.ts +43 -1
  32. package/packages/create-amytis/src/index.ts +64 -8
  33. package/public/next-image-export-optimizer-hashes.json +14 -73
  34. package/scripts/build-pagefind.ts +172 -0
  35. package/scripts/copy-assets.ts +246 -56
  36. package/scripts/generate-knowledge-graph.ts +2 -1
  37. package/scripts/new-flow.ts +1 -0
  38. package/scripts/render-rst.py +719 -0
  39. package/scripts/run-with-rst-python.ts +42 -0
  40. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  41. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  42. package/src/app/all.atom/route.ts +7 -0
  43. package/src/app/all.xml/route.ts +7 -0
  44. package/src/app/archive/page.tsx +7 -4
  45. package/src/app/feed.atom/route.ts +2 -57
  46. package/src/app/feed.xml/route.ts +2 -64
  47. package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
  48. package/src/app/flows/feed.atom/route.ts +7 -0
  49. package/src/app/flows/feed.xml/route.ts +7 -0
  50. package/src/app/globals.css +165 -0
  51. package/src/app/page.tsx +1 -0
  52. package/src/app/posts/feed.atom/route.ts +9 -0
  53. package/src/app/posts/feed.xml/route.ts +9 -0
  54. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  55. package/src/app/series/[slug]/page.tsx +11 -13
  56. package/src/app/series/page.tsx +3 -3
  57. package/src/components/AuthorCard.tsx +25 -16
  58. package/src/components/CoverImage.tsx +5 -2
  59. package/src/components/FlowCalendarSidebar.tsx +1 -1
  60. package/src/components/FlowContent.tsx +2 -1
  61. package/src/components/FlowTimelineEntry.tsx +7 -1
  62. package/src/components/Footer.tsx +1 -1
  63. package/src/components/MarkdownRenderer.test.tsx +22 -0
  64. package/src/components/MarkdownRenderer.tsx +22 -17
  65. package/src/components/Navbar.tsx +1 -1
  66. package/src/components/PostSidebar.tsx +1 -1
  67. package/src/components/RecentNotesSection.tsx +4 -0
  68. package/src/components/RstRenderer.test.tsx +93 -0
  69. package/src/components/RstRenderer.tsx +122 -0
  70. package/src/layouts/PostLayout.tsx +5 -1
  71. package/src/layouts/SimpleLayout.tsx +10 -3
  72. package/src/lib/feed-utils.ts +158 -18
  73. package/src/lib/image-utils.test.ts +19 -0
  74. package/src/lib/image-utils.ts +11 -0
  75. package/src/lib/markdown.test.ts +140 -2
  76. package/src/lib/markdown.ts +747 -214
  77. package/src/lib/rehype-image-metadata.ts +2 -2
  78. package/src/lib/rst-renderer.test.ts +355 -0
  79. package/src/lib/rst-renderer.ts +617 -0
  80. package/src/lib/rst.test.ts +140 -0
  81. package/src/lib/rst.ts +470 -0
  82. package/src/lib/series-redirects.ts +42 -0
  83. package/tests/e2e/navigation.test.ts +26 -0
  84. package/tests/integration/collections.test.ts +17 -2
  85. package/tests/integration/feed-utils.test.ts +65 -0
  86. package/tests/integration/flow-title.test.ts +53 -0
  87. package/tests/integration/reading-time-headings.test.ts +5 -9
  88. package/tests/integration/series-draft.test.ts +16 -2
  89. package/tests/integration/series.test.ts +93 -0
  90. package/tests/tooling/build-pagefind.test.ts +66 -0
  91. package/tests/unit/static-params.test.ts +140 -0
@@ -0,0 +1,140 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { parseRstDocument, rstToMarkdown, RstParseError } from './rst';
3
+
4
+ describe('rst utils', () => {
5
+ test('parses title, metadata, headings, and markdown conversion', () => {
6
+ const doc = parseRstDocument([
7
+ 'Rst Title',
8
+ '=========',
9
+ '',
10
+ ':date: 2026-01-01',
11
+ ':tags: rst, migration',
12
+ ':draft: false',
13
+ '',
14
+ 'Section',
15
+ '-------',
16
+ '',
17
+ 'Paragraph with `Link <https://example.com>`_.',
18
+ ].join('\n'));
19
+
20
+ expect(doc.title).toBe('Rst Title');
21
+ expect(doc.metadata.date).toBe('2026-01-01');
22
+ expect(doc.metadata.tags).toEqual(['rst', 'migration']);
23
+ expect(doc.metadata.draft).toBe(false);
24
+ expect(doc.markdownBody).toContain('### Section');
25
+ expect(doc.markdownBody).toContain('[Link](https://example.com)');
26
+ expect(doc.headings).toEqual([{ id: 'section', text: 'Section', level: 3 }]);
27
+ });
28
+
29
+ test('converts code blocks and image directives', () => {
30
+ const markdown = rstToMarkdown([
31
+ '.. code-block:: js',
32
+ '',
33
+ ' console.log("hi");',
34
+ '',
35
+ '.. image:: ./images/test.svg',
36
+ ' :alt: Test image',
37
+ ].join('\n'));
38
+
39
+ expect(markdown).toContain('```js');
40
+ expect(markdown).toContain('console.log("hi");');
41
+ expect(markdown).toContain('![Test image](./images/test.svg)');
42
+ });
43
+
44
+ test('does not treat generic directives as literal code blocks', () => {
45
+ const markdown = rstToMarkdown([
46
+ '.. note::',
47
+ '',
48
+ ' Keep this as prose.',
49
+ ].join('\n'));
50
+
51
+ expect(markdown).toContain('.. note::');
52
+ expect(markdown).not.toContain('```');
53
+ });
54
+
55
+ test('ignores unknown metadata fields and rejects malformed supported values', () => {
56
+ const ignored = parseRstDocument([
57
+ 'Title',
58
+ '=====',
59
+ '',
60
+ ':custom-field: keep legacy metadata around',
61
+ '',
62
+ 'Body',
63
+ ].join('\n'));
64
+
65
+ expect(ignored.metadata).toEqual({});
66
+
67
+ expect(() => parseRstDocument([
68
+ 'Title',
69
+ '=====',
70
+ '',
71
+ ':draft: maybe',
72
+ '',
73
+ 'Body',
74
+ ].join('\n'))).toThrow(RstParseError);
75
+
76
+ expect(() => parseRstDocument([
77
+ 'Title',
78
+ '=====',
79
+ '',
80
+ ':date: 2021-16-15',
81
+ '',
82
+ 'Body',
83
+ ].join('\n'))).toThrow(RstParseError);
84
+ });
85
+
86
+ test('accepts legacy non-zero-padded dates and normalizes them', () => {
87
+ const doc = parseRstDocument([
88
+ 'Title',
89
+ '=====',
90
+ '',
91
+ ':date: 2022-3-17',
92
+ '',
93
+ 'Body',
94
+ ].join('\n'));
95
+
96
+ expect(doc.metadata.date).toBe('2022-03-17');
97
+ });
98
+
99
+ test('accepts leading comments and metadata before the document title', () => {
100
+ const doc = parseRstDocument([
101
+ '.. Kenneth Lee 版权所有 2018-2020',
102
+ '',
103
+ ':Authors: Kenneth Lee',
104
+ ':Version: 1.0',
105
+ '',
106
+ '从香农熵谈设计文档写作',
107
+ '************************',
108
+ '',
109
+ '正文。',
110
+ ].join('\n'));
111
+
112
+ expect(doc.title).toBe('从香农熵谈设计文档写作');
113
+ expect(doc.metadata.authors).toEqual(['Kenneth Lee']);
114
+ expect(doc.body).toBe('正文。');
115
+ });
116
+
117
+ test('does not auto-generate excerpts when rST metadata omits them', () => {
118
+ const doc = parseRstDocument([
119
+ 'Title',
120
+ '=====',
121
+ '',
122
+ 'Paragraph with `Link <https://example.com>`_.',
123
+ ].join('\n'));
124
+
125
+ expect(doc.excerpt).toBe('');
126
+ });
127
+
128
+ test('preserves explicit excerpts from rST metadata', () => {
129
+ const doc = parseRstDocument([
130
+ 'Title',
131
+ '=====',
132
+ '',
133
+ ':excerpt: Paragraph with `Link <https://example.com>`_.',
134
+ '',
135
+ 'Body.',
136
+ ].join('\n'));
137
+
138
+ expect(doc.excerpt).toBe('Paragraph with `Link <https://example.com>`_.');
139
+ });
140
+ });
package/src/lib/rst.ts ADDED
@@ -0,0 +1,470 @@
1
+ import GithubSlugger from 'github-slugger';
2
+
3
+ export interface RstHeading {
4
+ id: string;
5
+ text: string;
6
+ level: number;
7
+ }
8
+
9
+ export interface RstMetadata {
10
+ date?: string;
11
+ subtitle?: string;
12
+ excerpt?: string;
13
+ category?: string;
14
+ tags?: string[];
15
+ authors?: string[];
16
+ author?: string;
17
+ layout?: string;
18
+ series?: string;
19
+ coverImage?: string;
20
+ sort?: 'date-desc' | 'date-asc' | 'manual';
21
+ posts?: string[];
22
+ featured?: boolean;
23
+ pinned?: boolean;
24
+ draft?: boolean;
25
+ latex?: boolean;
26
+ toc?: boolean;
27
+ commentable?: boolean;
28
+ redirectFrom?: string[];
29
+ type?: 'collection';
30
+ }
31
+
32
+ export interface ParsedRstDocument {
33
+ title: string;
34
+ body: string;
35
+ markdownBody: string;
36
+ metadata: RstMetadata;
37
+ headings: RstHeading[];
38
+ excerpt: string;
39
+ readingTime: string;
40
+ }
41
+
42
+ export class RstParseError extends Error {
43
+ constructor(message: string) {
44
+ super(message);
45
+ this.name = 'RstParseError';
46
+ }
47
+ }
48
+
49
+ const SUPPORTED_FIELDS = new Set([
50
+ 'date',
51
+ 'subtitle',
52
+ 'excerpt',
53
+ 'category',
54
+ 'tags',
55
+ 'authors',
56
+ 'author',
57
+ 'layout',
58
+ 'series',
59
+ 'coverimage',
60
+ 'sort',
61
+ 'posts',
62
+ 'featured',
63
+ 'pinned',
64
+ 'draft',
65
+ 'latex',
66
+ 'toc',
67
+ 'commentable',
68
+ 'redirectfrom',
69
+ 'type',
70
+ ]);
71
+
72
+ function normalizeLines(source: string): string[] {
73
+ return source.replace(/\r\n?/g, '\n').split('\n');
74
+ }
75
+
76
+ function isAdornmentLine(line: string): boolean {
77
+ const trimmed = line.trim();
78
+ return /^([=\-~^"`+#*])\1{2,}$/.test(trimmed);
79
+ }
80
+
81
+ function extractTitle(lines: string[]): { title: string; titleIndex: number; nextIndex: number } {
82
+ for (let i = 0; i + 1 < lines.length; i++) {
83
+ const titleLine = lines[i].trim();
84
+ const underline = lines[i + 1].trim();
85
+
86
+ if (!titleLine) continue;
87
+ if (/^\s/.test(lines[i])) continue;
88
+ if (!isAdornmentLine(underline)) continue;
89
+
90
+ return { title: titleLine, titleIndex: i, nextIndex: i + 2 };
91
+ }
92
+
93
+ throw new RstParseError('Missing top-level rST title.');
94
+ }
95
+
96
+ function parseBoolean(field: string, value: string): boolean {
97
+ if (value === 'true') return true;
98
+ if (value === 'false') return false;
99
+ throw new RstParseError(`Invalid boolean for "${field}": ${value}`);
100
+ }
101
+
102
+ function parseCsv(value: string): string[] {
103
+ if (!value.trim()) return [];
104
+ return value
105
+ .split(',')
106
+ .map(part => part.trim())
107
+ .filter(Boolean);
108
+ }
109
+
110
+ function parseDate(value: string): string {
111
+ const match = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
112
+ if (!match) {
113
+ throw new RstParseError(`Invalid date: ${value}`);
114
+ }
115
+
116
+ const [, year, month, day] = match;
117
+ const normalized = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
118
+
119
+ const parsed = new Date(`${normalized}T00:00:00Z`);
120
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== normalized) {
121
+ throw new RstParseError(`Invalid date: ${value}`);
122
+ }
123
+
124
+ return normalized;
125
+ }
126
+
127
+ function parseSort(value: string): 'date-desc' | 'date-asc' | 'manual' {
128
+ if (value === 'date-desc' || value === 'date-asc' || value === 'manual') {
129
+ return value;
130
+ }
131
+ throw new RstParseError(`Invalid sort value: ${value}`);
132
+ }
133
+
134
+ function parseType(value: string): 'collection' {
135
+ if (value === 'collection') {
136
+ return value;
137
+ }
138
+ throw new RstParseError(`Invalid type value: ${value}`);
139
+ }
140
+
141
+ function setMetadataField(metadata: RstMetadata, field: string, value: string): void {
142
+ const key = field.toLowerCase();
143
+ if (!SUPPORTED_FIELDS.has(key)) return;
144
+
145
+ switch (key) {
146
+ case 'date':
147
+ metadata.date = parseDate(value);
148
+ break;
149
+ case 'subtitle':
150
+ metadata.subtitle = value;
151
+ break;
152
+ case 'excerpt':
153
+ metadata.excerpt = value;
154
+ break;
155
+ case 'category':
156
+ metadata.category = value;
157
+ break;
158
+ case 'tags':
159
+ metadata.tags = parseCsv(value);
160
+ break;
161
+ case 'authors':
162
+ metadata.authors = parseCsv(value);
163
+ break;
164
+ case 'author':
165
+ metadata.author = value;
166
+ break;
167
+ case 'layout':
168
+ metadata.layout = value;
169
+ break;
170
+ case 'series':
171
+ metadata.series = value;
172
+ break;
173
+ case 'coverimage':
174
+ metadata.coverImage = value;
175
+ break;
176
+ case 'sort':
177
+ metadata.sort = parseSort(value);
178
+ break;
179
+ case 'posts':
180
+ metadata.posts = parseCsv(value);
181
+ break;
182
+ case 'featured':
183
+ metadata.featured = parseBoolean(field, value);
184
+ break;
185
+ case 'pinned':
186
+ metadata.pinned = parseBoolean(field, value);
187
+ break;
188
+ case 'draft':
189
+ metadata.draft = parseBoolean(field, value);
190
+ break;
191
+ case 'latex':
192
+ metadata.latex = parseBoolean(field, value);
193
+ break;
194
+ case 'toc':
195
+ metadata.toc = parseBoolean(field, value);
196
+ break;
197
+ case 'commentable':
198
+ metadata.commentable = parseBoolean(field, value);
199
+ break;
200
+ case 'redirectfrom':
201
+ metadata.redirectFrom = parseCsv(value);
202
+ break;
203
+ case 'type':
204
+ metadata.type = parseType(value);
205
+ break;
206
+ }
207
+ }
208
+
209
+ function extractMetadata(lines: string[], startIndex: number): { metadata: RstMetadata; nextIndex: number } {
210
+ const metadata: RstMetadata = {};
211
+ let i = startIndex;
212
+ while (i < lines.length && !lines[i].trim()) i++;
213
+
214
+ while (i < lines.length) {
215
+ const match = lines[i].match(/^:([A-Za-z][\w-]*):\s*(.*)$/);
216
+ if (!match) break;
217
+
218
+ const field = match[1];
219
+ const continuation: string[] = [match[2]];
220
+ i++;
221
+
222
+ while (i < lines.length) {
223
+ const next = lines[i];
224
+ if (!next.trim()) break;
225
+ if (/^:([A-Za-z][\w-]*):\s*(.*)$/.test(next)) break;
226
+ if (/^\s+/.test(next)) {
227
+ continuation.push(next.trim());
228
+ i++;
229
+ continue;
230
+ }
231
+ break;
232
+ }
233
+
234
+ setMetadataField(metadata, field, continuation.join(' ').trim());
235
+
236
+ if (i < lines.length && !lines[i].trim()) {
237
+ i++;
238
+ if (i < lines.length && !/^:([A-Za-z][\w-]*):\s*(.*)$/.test(lines[i])) break;
239
+ }
240
+ }
241
+
242
+ while (i < lines.length && !lines[i].trim()) i++;
243
+ return { metadata, nextIndex: i };
244
+ }
245
+
246
+ function extractPreambleMetadata(lines: string[]): RstMetadata {
247
+ const metadata: RstMetadata = {};
248
+
249
+ for (let i = 0; i < lines.length; i++) {
250
+ const match = lines[i].match(/^:([A-Za-z][\w-]*):\s*(.*)$/);
251
+ if (!match) continue;
252
+
253
+ const field = match[1];
254
+ const continuation: string[] = [match[2]];
255
+ i++;
256
+
257
+ while (i < lines.length) {
258
+ const next = lines[i];
259
+ if (!next.trim()) break;
260
+ if (/^:([A-Za-z][\w-]*):\s*(.*)$/.test(next)) {
261
+ i--;
262
+ break;
263
+ }
264
+ if (/^\s+/.test(next)) {
265
+ continuation.push(next.trim());
266
+ i++;
267
+ continue;
268
+ }
269
+ i--;
270
+ break;
271
+ }
272
+
273
+ setMetadataField(metadata, field, continuation.join(' ').trim());
274
+ }
275
+
276
+ return metadata;
277
+ }
278
+
279
+ function mergeMetadata(base: RstMetadata, override: RstMetadata): RstMetadata {
280
+ return {
281
+ ...base,
282
+ ...override,
283
+ tags: override.tags ?? base.tags,
284
+ authors: override.authors ?? base.authors,
285
+ posts: override.posts ?? base.posts,
286
+ redirectFrom: override.redirectFrom ?? base.redirectFrom,
287
+ };
288
+ }
289
+
290
+ function convertInlineRst(text: string): string {
291
+ return text
292
+ .replace(/``([^`]+)``/g, '`$1`')
293
+ .replace(/`([^`]+?)\s*<([^>]+)>`__/g, '[$1]($2)')
294
+ .replace(/`([^`]+?)\s*<([^>]+)>`_/g, '[$1]($2)');
295
+ }
296
+
297
+ function detectHeadingLevel(adornment: string): number | null {
298
+ const marker = adornment.trim()[0];
299
+ if (marker === '=') return 2;
300
+ if (marker === '-' || marker === '~' || marker === '^') return 3;
301
+ return null;
302
+ }
303
+
304
+ function readIndentedBlock(lines: string[], startIndex: number): { content: string[]; nextIndex: number } {
305
+ let i = startIndex;
306
+ while (i < lines.length && !lines[i].trim()) i++;
307
+ if (i >= lines.length || !/^\s+/.test(lines[i])) {
308
+ return { content: [], nextIndex: startIndex };
309
+ }
310
+
311
+ const indent = lines[i].match(/^\s+/)?.[0].length ?? 0;
312
+ const content: string[] = [];
313
+
314
+ while (i < lines.length) {
315
+ const line = lines[i];
316
+ if (!line.trim()) {
317
+ content.push('');
318
+ i++;
319
+ continue;
320
+ }
321
+ const currentIndent = line.match(/^\s+/)?.[0].length ?? 0;
322
+ if (currentIndent < indent) break;
323
+ content.push(line.slice(indent));
324
+ i++;
325
+ }
326
+
327
+ while (content.length > 0 && content[0] === '') content.shift();
328
+ while (content.length > 0 && content[content.length - 1] === '') content.pop();
329
+
330
+ return { content, nextIndex: i };
331
+ }
332
+
333
+ export function rstToMarkdown(body: string): string {
334
+ const lines = normalizeLines(body);
335
+ const out: string[] = [];
336
+
337
+ for (let i = 0; i < lines.length; i++) {
338
+ const line = lines[i];
339
+ const trimmed = line.trim();
340
+
341
+ if (!trimmed) {
342
+ out.push('');
343
+ continue;
344
+ }
345
+
346
+ if (
347
+ i + 1 < lines.length &&
348
+ line.trim() &&
349
+ isAdornmentLine(lines[i + 1]) &&
350
+ !/^\s/.test(line)
351
+ ) {
352
+ const level = detectHeadingLevel(lines[i + 1]);
353
+ if (level !== null) {
354
+ out.push(`${'#'.repeat(level)} ${convertInlineRst(trimmed)}`);
355
+ out.push('');
356
+ i++;
357
+ continue;
358
+ }
359
+ }
360
+
361
+ const imageMatch = line.match(/^\.\.\s+image::\s+(.+?)\s*$/);
362
+ if (imageMatch) {
363
+ let alt = '';
364
+ let j = i + 1;
365
+ while (j < lines.length && !lines[j].trim()) j++;
366
+ while (j < lines.length && /^\s+:[A-Za-z-]+:/.test(lines[j])) {
367
+ const optionMatch = lines[j].match(/^\s+:([A-Za-z-]+):\s*(.*)$/);
368
+ if (optionMatch?.[1].toLowerCase() === 'alt') {
369
+ alt = optionMatch[2].trim();
370
+ }
371
+ j++;
372
+ }
373
+ out.push(`![${alt}](${imageMatch[1].trim()})`);
374
+ out.push('');
375
+ i = j - 1;
376
+ continue;
377
+ }
378
+
379
+ const codeMatch = line.match(/^\.\.\s+(?:code-block|code)::\s*([A-Za-z0-9_-]+)?\s*$/);
380
+ if (codeMatch) {
381
+ const { content, nextIndex } = readIndentedBlock(lines, i + 1);
382
+ out.push(`\`\`\`${codeMatch[1] ?? ''}`.trimEnd());
383
+ out.push(...content);
384
+ out.push('```');
385
+ out.push('');
386
+ i = nextIndex - 1;
387
+ continue;
388
+ }
389
+
390
+ if (trimmed.endsWith('::') && !trimmed.startsWith('..')) {
391
+ const { content, nextIndex } = readIndentedBlock(lines, i + 1);
392
+ if (content.length > 0) {
393
+ const prefix = trimmed === '::' ? '' : convertInlineRst(trimmed.slice(0, -1));
394
+ if (prefix) {
395
+ out.push(prefix);
396
+ out.push('');
397
+ }
398
+ out.push('```');
399
+ out.push(...content);
400
+ out.push('```');
401
+ out.push('');
402
+ i = nextIndex - 1;
403
+ continue;
404
+ }
405
+ }
406
+
407
+ if (/^\s*[-*+]\s+/.test(line) || /^\s*\d+\.\s+/.test(line)) {
408
+ out.push(convertInlineRst(line));
409
+ continue;
410
+ }
411
+
412
+ out.push(convertInlineRst(line));
413
+ }
414
+
415
+ return out.join('\n').trim();
416
+ }
417
+
418
+ function calculateReadingTime(content: string): string {
419
+ const wordsPerMinute = 200;
420
+ const hanCharsPerMinute = 300;
421
+
422
+ const text = content
423
+ .replace(/<\/?[^>]+(>|$)/g, '')
424
+ .replace(/```[\s\S]*?```/g, '')
425
+ .replace(/`[^`]*`/g, '')
426
+ .replace(/!\[[^\]]*\]\([^)]+\)/g, '')
427
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
428
+ .replace(/[#*_~>\-[\]()]/g, ' ');
429
+
430
+ const hanCharCount = (text.match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length;
431
+ const latinWordCount = (text.match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g) || []).length;
432
+
433
+ const estimatedMinutes = (latinWordCount / wordsPerMinute) + (hanCharCount / hanCharsPerMinute);
434
+ const minutes = Math.max(1, Math.ceil(estimatedMinutes));
435
+ return `${minutes} min read`;
436
+ }
437
+
438
+ function getHeadings(content: string): RstHeading[] {
439
+ const regex = /^(#{2,3})\s+(.*)$/gm;
440
+ const headings: RstHeading[] = [];
441
+ const slugger = new GithubSlugger();
442
+ let match: RegExpExecArray | null;
443
+
444
+ while ((match = regex.exec(content)) !== null) {
445
+ const level = match[1].length;
446
+ const text = match[2].trim();
447
+ headings.push({ id: slugger.slug(text), text, level });
448
+ }
449
+ return headings;
450
+ }
451
+
452
+ export function parseRstDocument(source: string): ParsedRstDocument {
453
+ const lines = normalizeLines(source);
454
+ const { title, titleIndex, nextIndex } = extractTitle(lines);
455
+ const preTitleMetadata = extractPreambleMetadata(lines.slice(0, titleIndex));
456
+ const { metadata: postTitleMetadata, nextIndex: contentIndex } = extractMetadata(lines, nextIndex);
457
+ const metadata = mergeMetadata(preTitleMetadata, postTitleMetadata);
458
+ const body = lines.slice(contentIndex).join('\n').trim();
459
+ const markdownBody = rstToMarkdown(body);
460
+
461
+ return {
462
+ title,
463
+ body,
464
+ markdownBody,
465
+ metadata,
466
+ headings: getHeadings(markdownBody),
467
+ excerpt: metadata.excerpt ?? '',
468
+ readingTime: calculateReadingTime(markdownBody),
469
+ };
470
+ }
@@ -0,0 +1,42 @@
1
+ import { getAllSeries, getSeriesData, PostData } from '@/lib/markdown';
2
+
3
+ export function safeDecodeParam(param: string): string {
4
+ try {
5
+ return decodeURIComponent(param);
6
+ } catch {
7
+ return param;
8
+ }
9
+ }
10
+
11
+ function normalizeRedirectPath(path: string): string | null {
12
+ const trimmed = path.trim();
13
+ if (!trimmed) return null;
14
+
15
+ try {
16
+ const decoded = decodeURIComponent(trimmed);
17
+ return decoded.startsWith('/') ? decoded : `/${decoded}`;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export function findSeriesByRedirectFrom(path: string): { slug: string; data: PostData } | null {
24
+ const normalizedPath = normalizeRedirectPath(path);
25
+ if (!normalizedPath) return null;
26
+
27
+ for (const seriesSlug of Object.keys(getAllSeries())) {
28
+ const data = getSeriesData(seriesSlug);
29
+ if (!data) continue;
30
+
31
+ const hasRedirect = (data.redirectFrom ?? []).some((redirectFrom) => {
32
+ const normalizedRedirect = normalizeRedirectPath(redirectFrom);
33
+ return normalizedRedirect === normalizedPath;
34
+ });
35
+
36
+ if (hasRedirect) {
37
+ return { slug: seriesSlug, data };
38
+ }
39
+ }
40
+
41
+ return null;
42
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, test, expect } from "bun:test";
2
+ import { siteConfig } from "../../site.config";
2
3
 
3
4
  const BASE_URL = "http://localhost:3000";
4
5
 
@@ -54,6 +55,7 @@ describe("E2E: Navigation & Assets", () => {
54
55
 
55
56
  test("feed.atom should be a valid Atom feed", async () => {
56
57
  if (!(await isServerRunning())) return;
58
+ if (siteConfig.feed.format === "rss") return; // skip if Atom is disabled
57
59
 
58
60
  const response = await fetch(`${BASE_URL}/feed.atom`);
59
61
  expect(response.status).toBe(200);
@@ -62,4 +64,28 @@ describe("E2E: Navigation & Assets", () => {
62
64
  expect(text).toContain('xmlns="http://www.w3.org/2005/Atom"');
63
65
  expect(text).toContain("<entry>");
64
66
  });
67
+
68
+ test("type-specific feeds should be accessible", async () => {
69
+ if (!(await isServerRunning())) return;
70
+
71
+ const feedUrls = [
72
+ "/posts/feed.xml",
73
+ "/flows/feed.xml",
74
+ "/all.xml",
75
+ ];
76
+
77
+ if (siteConfig.feed.format === "atom" || siteConfig.feed.format === "both") {
78
+ feedUrls.push(
79
+ "/posts/feed.atom",
80
+ "/flows/feed.atom",
81
+ "/all.atom"
82
+ );
83
+ }
84
+
85
+ for (const url of feedUrls) {
86
+ const response = await fetch(`${BASE_URL}${url}`);
87
+ expect(response.status).toBe(200);
88
+ expect(response.headers.get("content-type")).toContain("xml");
89
+ }
90
+ });
65
91
  });