@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
@@ -1,6 +1,7 @@
1
1
  import { Suspense } from 'react';
2
2
  import { PostData } from '@/lib/markdown';
3
3
  import MarkdownRenderer from '@/components/MarkdownRenderer';
4
+ import RstRenderer from '@/components/RstRenderer';
4
5
  import SimpleLayoutHeader from '@/components/SimpleLayoutHeader';
5
6
  import LocaleSwitch from '@/components/LocaleSwitch';
6
7
  import PostSidebar from '@/components/PostSidebar';
@@ -28,6 +29,12 @@ export default function SimpleLayout({ post, titleKey, subtitleKey }: SimpleLayo
28
29
  )
29
30
  : undefined;
30
31
 
32
+ const renderContent = (content: string) => (
33
+ post.sourceFormat === 'rst'
34
+ ? <RstRenderer content={content} html={content === post.content ? post.renderedHtml : undefined} latex={post.latex} slug={post.imageBaseSlug} />
35
+ : <MarkdownRenderer content={content} latex={post.latex} slug={post.imageBaseSlug} />
36
+ );
37
+
31
38
  const articleContent = (
32
39
  <>
33
40
  <SimpleLayoutHeader
@@ -40,16 +47,16 @@ export default function SimpleLayout({ post, titleKey, subtitleKey }: SimpleLayo
40
47
  {localeEntries.length > 0 ? (
41
48
  <LocaleSwitch>
42
49
  <div data-locale={defaultLocale}>
43
- <MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} />
50
+ {renderContent(post.content)}
44
51
  </div>
45
52
  {localeEntries.map(([locale, data]) => (
46
53
  <div key={locale} data-locale={locale} style={{ display: 'none' }}>
47
- <MarkdownRenderer content={data.content} latex={post.latex} slug={post.imageBaseSlug} />
54
+ {renderContent(data.content)}
48
55
  </div>
49
56
  ))}
50
57
  </LocaleSwitch>
51
58
  ) : (
52
- <MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} />
59
+ renderContent(post.content)
53
60
  )}
54
61
  </>
55
62
  );
@@ -7,6 +7,7 @@ import rehypeStringify from 'rehype-stringify';
7
7
  import { getAllPosts, getAllFlows } from './markdown';
8
8
  import { siteConfig } from '../../site.config';
9
9
  import { getPostUrl, getFlowUrl } from './urls';
10
+ import { resolveLocale } from './i18n';
10
11
 
11
12
  export interface FeedItem {
12
13
  title: string;
@@ -30,39 +31,178 @@ function markdownToHtml(markdown: string): string {
30
31
  return String(result);
31
32
  }
32
33
 
34
+ export type FeedType = 'main' | 'posts' | 'flows' | 'all';
35
+
33
36
  /**
34
37
  * Returns feed items for RSS/Atom generation.
35
- * Includes all published posts (converted to HTML) and optionally flow notes
36
- * when `siteConfig.feed.includeFlows` is enabled. Results are sorted by date
37
- * descending and capped at `siteConfig.feed.maxItems` (0 = unlimited).
38
+ * - 'main': Respects `siteConfig.feed.includeFlows`
39
+ * - 'posts': Only posts
40
+ * - 'flows': Only flows
41
+ * - 'all': Both posts and flows, ignoring `includeFlows`
38
42
  */
39
- export function getFeedItems(): FeedItem[] {
43
+ export function getFeedItems(feedType: FeedType = 'main', includeFullContent: boolean = false): FeedItem[] {
40
44
  const { maxItems, includeFlows } = siteConfig.feed;
41
45
  const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
42
46
 
43
- const postItems: FeedItem[] = getAllPosts().map((post) => ({
47
+ let items: FeedItem[] = [];
48
+
49
+ const getPostItems = () => getAllPosts().map((post) => ({
44
50
  title: post.title,
45
51
  url: `${baseUrl}${getPostUrl(post)}`,
46
52
  date: new Date(post.date),
47
53
  excerpt: post.excerpt,
48
- content: markdownToHtml(post.content),
54
+ content: includeFullContent ? markdownToHtml(post.content) : '',
49
55
  tags: post.tags || [],
50
56
  authors: post.authors,
51
57
  }));
52
58
 
53
- let items: FeedItem[] = postItems;
54
-
55
- if (includeFlows) {
56
- const flowItems: FeedItem[] = getAllFlows().map((flow) => ({
57
- title: flow.title,
58
- url: `${baseUrl}${getFlowUrl(flow.slug)}`,
59
- date: new Date(flow.date),
60
- excerpt: flow.excerpt,
61
- content: markdownToHtml(flow.content),
62
- tags: flow.tags || [],
63
- }));
64
- items = [...postItems, ...flowItems].sort((a, b) => b.date.getTime() - a.date.getTime());
59
+ const getFlowItems = () => getAllFlows().map((flow) => ({
60
+ title: flow.title,
61
+ url: `${baseUrl}${getFlowUrl(flow.slug)}`,
62
+ date: new Date(flow.date),
63
+ excerpt: flow.excerpt,
64
+ content: includeFullContent ? markdownToHtml(flow.content) : '',
65
+ tags: flow.tags || [],
66
+ }));
67
+
68
+ if (feedType === 'posts') {
69
+ items = getPostItems();
70
+ } else if (feedType === 'flows') {
71
+ items = getFlowItems();
72
+ } else if (feedType === 'all') {
73
+ items = [...getPostItems(), ...getFlowItems()];
74
+ } else {
75
+ // main
76
+ items = includeFlows ? [...getPostItems(), ...getFlowItems()] : getPostItems();
65
77
  }
66
78
 
79
+ // Sort descending by date
80
+ items.sort((a, b) => b.date.getTime() - a.date.getTime());
81
+
67
82
  return maxItems > 0 ? items.slice(0, maxItems) : items;
68
83
  }
84
+
85
+ const escapeXml = (v: string) =>
86
+ v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
87
+ .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
88
+
89
+ const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
90
+
91
+ export function generateRssFeed(feedType: FeedType, selfUrlPath: string): Response {
92
+ const { format, content: contentMode } = siteConfig.feed;
93
+ if (format === 'atom') {
94
+ return new Response('Not Found', { status: 404 });
95
+ }
96
+
97
+ const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
98
+ const useFullContent = contentMode === 'full';
99
+ const items = getFeedItems(feedType, useFullContent);
100
+ const contentNs = useFullContent ? ' xmlns:content="http://purl.org/rss/modules/content/"' : '';
101
+ const siteTitle = resolveLocale(siteConfig.title);
102
+ const lastBuildDate = items[0]?.date.toUTCString() ?? new Date().toUTCString();
103
+
104
+ const selfUrl = `${baseUrl}${selfUrlPath}`;
105
+
106
+ const imageXml = siteConfig.ogImage
107
+ ? `\n <image>\n <url>${escapeXml(baseUrl + siteConfig.ogImage)}</url>\n <title>${escapeXml(siteTitle)}</title>\n <link>${escapeXml(baseUrl)}</link>\n </image>`
108
+ : '';
109
+
110
+ const rssItemsXml = items
111
+ .map((item) => {
112
+ const fullContentXml = useFullContent
113
+ ? `\n <content:encoded><![CDATA[${escapeCdata(item.content)}]]></content:encoded>`
114
+ : '';
115
+ const authorsXml = item.authors?.length
116
+ ? item.authors.map((a) => `\n <dc:creator><![CDATA[${escapeCdata(a)}]]></dc:creator>`).join('')
117
+ : '';
118
+ return `
119
+ <item>
120
+ <title><![CDATA[${escapeCdata(item.title)}]]></title>
121
+ <link>${escapeXml(item.url)}</link>
122
+ <guid isPermaLink="true">${escapeXml(item.url)}</guid>
123
+ <pubDate>${item.date.toUTCString()}</pubDate>
124
+ <description><![CDATA[${escapeCdata(item.excerpt)}]]></description>${fullContentXml}${authorsXml}
125
+ ${item.tags.map((tag) => `<category><![CDATA[${escapeCdata(tag)}]]></category>`).join('')}
126
+ </item>`;
127
+ })
128
+ .join('');
129
+
130
+ const rssXml = `<?xml version="1.0" encoding="UTF-8" ?>
131
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"${contentNs}>
132
+ <channel>
133
+ <title><![CDATA[${escapeCdata(siteTitle)}]]></title>
134
+ <link>${escapeXml(baseUrl)}</link>
135
+ <description><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></description>
136
+ <language>${siteConfig.i18n.defaultLocale}</language>
137
+ <lastBuildDate>${lastBuildDate}</lastBuildDate>
138
+ <atom:link href="${escapeXml(selfUrl)}" rel="self" type="application/rss+xml" />${imageXml}
139
+ ${rssItemsXml}
140
+ </channel>
141
+ </rss>`;
142
+
143
+ return new Response(rssXml, {
144
+ headers: {
145
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
146
+ 'Cache-Control': 'public, max-age=3600',
147
+ },
148
+ });
149
+ }
150
+
151
+ export function generateAtomFeed(feedType: FeedType, selfUrlPath: string): Response {
152
+ const { format, content: contentMode } = siteConfig.feed;
153
+ if (format === 'rss') {
154
+ return new Response('Not Found', { status: 404 });
155
+ }
156
+
157
+ const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
158
+ const useFullContent = contentMode === 'full';
159
+ const items = getFeedItems(feedType, useFullContent);
160
+ const feedUpdated = items[0]?.date.toISOString() ?? new Date().toISOString();
161
+
162
+ const selfUrl = `${baseUrl}${selfUrlPath}`;
163
+
164
+ const hasAllAuthors = items.every(item => item.authors && item.authors.length > 0);
165
+ const siteTitle = resolveLocale(siteConfig.title);
166
+ const defaultAuthor = siteConfig.posts?.authors?.default?.[0];
167
+ const feedAuthorName = defaultAuthor ? defaultAuthor : siteTitle;
168
+ const feedAuthorXml = hasAllAuthors ? '' : `\n <author><name>${escapeXml(feedAuthorName)}</name></author>`;
169
+
170
+ const entriesXml = items
171
+ .map((item) => {
172
+ const contentXml = useFullContent
173
+ ? `<content type="html"><![CDATA[${escapeCdata(item.content)}]]></content>\n <summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`
174
+ : `<summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`;
175
+ const authorsXml = item.authors?.map((a) => `<author><name>${escapeXml(a)}</name></author>`).join('') ?? '';
176
+ const categoriesXml = item.tags.map((tag) => `<category term="${escapeXml(tag)}" />`).join('');
177
+ return `
178
+ <entry>
179
+ <title><![CDATA[${escapeCdata(item.title)}]]></title>
180
+ <link href="${escapeXml(item.url)}" />
181
+ <id>${escapeXml(item.url)}</id>
182
+ <published>${item.date.toISOString()}</published>
183
+ <updated>${item.date.toISOString()}</updated>
184
+ ${contentXml}
185
+ ${authorsXml}
186
+ ${categoriesXml}
187
+ </entry>`;
188
+ })
189
+ .join('');
190
+
191
+ const atomXml = `<?xml version="1.0" encoding="UTF-8" ?>
192
+ <feed xmlns="http://www.w3.org/2005/Atom">
193
+ <title><![CDATA[${escapeCdata(resolveLocale(siteConfig.title))}]]></title>
194
+ <link href="${escapeXml(baseUrl)}" />
195
+ <link href="${escapeXml(selfUrl)}" rel="self" type="application/atom+xml" />
196
+ <id>${escapeXml(selfUrl)}</id>
197
+ <updated>${feedUpdated}</updated>
198
+ <subtitle><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></subtitle>${feedAuthorXml}
199
+ ${entriesXml}
200
+ </feed>`;
201
+
202
+ return new Response(atomXml, {
203
+ headers: {
204
+ 'Content-Type': 'application/atom+xml; charset=utf-8',
205
+ 'Cache-Control': 'public, max-age=3600',
206
+ },
207
+ });
208
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getCdnImageUrl, shouldBypassImageOptimization } from "./image-utils";
3
+
4
+ describe("image-utils", () => {
5
+ test("getCdnImageUrl leaves external and special URLs unchanged", () => {
6
+ expect(getCdnImageUrl("https://example.com/image.jpg", "https://cdn.example.com")).toBe("https://example.com/image.jpg");
7
+ expect(getCdnImageUrl("text:Cover", "https://cdn.example.com")).toBe("text:Cover");
8
+ expect(getCdnImageUrl("data:image/png;base64,abc", "https://cdn.example.com")).toBe("data:image/png;base64,abc");
9
+ });
10
+
11
+ test("shouldBypassImageOptimization skips avif and webp sources", () => {
12
+ expect(shouldBypassImageOptimization("/images/background-new-wave.avif")).toBe(true);
13
+ expect(shouldBypassImageOptimization("/images/already-optimized.webp")).toBe(true);
14
+ expect(shouldBypassImageOptimization("/images/already-optimized.WEBP?version=1")).toBe(true);
15
+ expect(shouldBypassImageOptimization("/images/already-optimized.webp?version=1#hero")).toBe(true);
16
+ expect(shouldBypassImageOptimization("/images/photo.jpg")).toBe(false);
17
+ expect(shouldBypassImageOptimization("/images/photo.png#fragment")).toBe(false);
18
+ });
19
+ });
@@ -10,3 +10,14 @@ export function getCdnImageUrl(src: string, cdnBaseUrl: string): string {
10
10
  const path = src.startsWith('/') ? src : `/${src}`;
11
11
  return `${base}${path}`;
12
12
  }
13
+
14
+ /**
15
+ * Certain source formats should bypass next-image-export-optimizer entirely.
16
+ * AVIF currently has an upstream path-generation bug when WEBP output is enabled,
17
+ * and user-supplied WEBP files are often already optimized enough to serve directly.
18
+ */
19
+ export function shouldBypassImageOptimization(src: string): boolean {
20
+ if (!src) return false;
21
+ const pathWithoutQuery = src.split('#')[0]?.split('?')[0] ?? src;
22
+ return /\.(avif|webp)$/i.test(pathWithoutQuery);
23
+ }
@@ -1,5 +1,37 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { generateExcerpt, calculateReadingTime, getHeadings, getAuthorSlug } from "./markdown";
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+ import { RstParseError } from "./rst";
6
+ import {
7
+ generateExcerpt,
8
+ calculateReadingTime,
9
+ getHeadings,
10
+ getAuthorSlug,
11
+ getPythonRstRendererAvailabilityForTests,
12
+ parseMarkdownFileForTests,
13
+ parseRstFileForTests,
14
+ resetPythonRstRendererAvailabilityForTests,
15
+ } from "./markdown";
16
+
17
+ const previousEnablePythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
18
+ const previousRstPython = process.env.AMYTIS_RST_PYTHON;
19
+
20
+ afterEach(() => {
21
+ if (previousEnablePythonRst === undefined) {
22
+ delete process.env.AMYTIS_ENABLE_PYTHON_RST;
23
+ } else {
24
+ process.env.AMYTIS_ENABLE_PYTHON_RST = previousEnablePythonRst;
25
+ }
26
+
27
+ if (previousRstPython === undefined) {
28
+ delete process.env.AMYTIS_RST_PYTHON;
29
+ } else {
30
+ process.env.AMYTIS_RST_PYTHON = previousRstPython;
31
+ }
32
+
33
+ resetPythonRstRendererAvailabilityForTests();
34
+ });
3
35
 
4
36
  describe("markdown utils", () => {
5
37
  describe("generateExcerpt", () => {
@@ -124,4 +156,110 @@ describe("markdown utils", () => {
124
156
  expect(getAuthorSlug(" John Hu ")).toBe("john-hu");
125
157
  });
126
158
  });
159
+
160
+ describe("rST parsing fallbacks", () => {
161
+ test("uses markdown file mtime when frontmatter date and slug date are missing", () => {
162
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "amytis-md-"));
163
+ const filePath = path.join(tempDir, "legacy.mdx");
164
+ fs.writeFileSync(
165
+ filePath,
166
+ [
167
+ "---",
168
+ 'title: "Legacy Markdown"',
169
+ "---",
170
+ "",
171
+ "Body",
172
+ "",
173
+ ].join("\n"),
174
+ "utf8",
175
+ );
176
+
177
+ const expectedDate = "2021-03-17";
178
+ const expectedTime = new Date(`${expectedDate}T12:00:00Z`);
179
+ fs.utimesSync(filePath, expectedTime, expectedTime);
180
+
181
+ try {
182
+ const post = parseMarkdownFileForTests(filePath, "legacy");
183
+ expect(post.date).toBe(expectedDate);
184
+ } finally {
185
+ fs.rmSync(tempDir, { recursive: true, force: true });
186
+ }
187
+ });
188
+
189
+ test("includes the source file path in rst parse errors", () => {
190
+ process.env.AMYTIS_ENABLE_PYTHON_RST = "0";
191
+
192
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "amytis-rst-"));
193
+ const filePath = path.join(tempDir, "broken.rst");
194
+ fs.writeFileSync(
195
+ filePath,
196
+ [
197
+ ":Date: 2021-16-15",
198
+ "",
199
+ "Broken Title",
200
+ "************",
201
+ "",
202
+ "Body",
203
+ "",
204
+ ].join("\n"),
205
+ "utf8",
206
+ );
207
+
208
+ try {
209
+ expect(() => parseRstFileForTests(filePath, "broken")).toThrow(
210
+ new RstParseError(`Invalid date: 2021-16-15 (${filePath})`)
211
+ );
212
+ } finally {
213
+ fs.rmSync(tempDir, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ test("falls back to the legacy rst parser when python runtime is unavailable", () => {
218
+ process.env.AMYTIS_ENABLE_PYTHON_RST = "1";
219
+ process.env.AMYTIS_RST_PYTHON = "python-does-not-exist";
220
+ resetPythonRstRendererAvailabilityForTests();
221
+
222
+ const post = parseRstFileForTests(
223
+ path.join(process.cwd(), "content/series/rst-legacy/getting-started.rst"),
224
+ "getting-started",
225
+ undefined,
226
+ "rst-legacy",
227
+ );
228
+
229
+ expect(post.title).toBe("Getting Started With rST");
230
+ expect(post.renderedHtml).toBeUndefined();
231
+ expect(post.content).toContain("Overview\n--------");
232
+ expect(post.content).toContain(".. code-block:: ts");
233
+ expect(getPythonRstRendererAvailabilityForTests()).toBe(false);
234
+ });
235
+
236
+ test("uses rst file mtime when metadata date and slug date are missing", () => {
237
+ process.env.AMYTIS_ENABLE_PYTHON_RST = "0";
238
+
239
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "amytis-rst-"));
240
+ const filePath = path.join(tempDir, "legacy.rst");
241
+ fs.writeFileSync(
242
+ filePath,
243
+ [
244
+ "Legacy rST",
245
+ "**********",
246
+ "",
247
+ "Body",
248
+ "",
249
+ ].join("\n"),
250
+ "utf8",
251
+ );
252
+
253
+ const expectedDate = "2020-04-09";
254
+ const expectedTime = new Date(`${expectedDate}T12:00:00Z`);
255
+ fs.utimesSync(filePath, expectedTime, expectedTime);
256
+
257
+ try {
258
+ const post = parseRstFileForTests(filePath, "legacy");
259
+ expect(post.date).toBe(expectedDate);
260
+ } finally {
261
+ fs.rmSync(tempDir, { recursive: true, force: true });
262
+ }
263
+ });
264
+ });
127
265
  });