@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,93 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import RstRenderer from './RstRenderer';
4
+
5
+ describe('RstRenderer', () => {
6
+ test('renders pre-rendered html when available', () => {
7
+ const html = renderToStaticMarkup(
8
+ <RstRenderer
9
+ content="Fallback body"
10
+ html={
11
+ '<section><h2 id="intro">Intro</h2><figure class="docutils"><img src="/posts/demo/test.png" alt="Test" onerror="alert(2)" /><figcaption>Caption</figcaption></figure><aside class="admonition note"><p class="admonition-title">Note</p><p>Keep me</p></aside><p><a href="/demo" onclick="alert(3)">Link</a></p><p><a href="javascript:alert(4)">Bad link</a></p><script>alert(1)</script><iframe src="https://example.com/embed"></iframe></section>'
12
+ }
13
+ />
14
+ );
15
+
16
+ expect(html).toContain('rst-rendered');
17
+ expect(html).toContain('id="intro"');
18
+ expect(html).toContain('<figure');
19
+ expect(html).toContain('<figcaption>Caption</figcaption>');
20
+ expect(html).toContain('admonition-title');
21
+ expect(html).toContain('/posts/demo/test.png');
22
+ expect(html).toContain('href="/demo"');
23
+ expect(html).not.toContain('alert(1)');
24
+ expect(html).not.toContain('<script');
25
+ expect(html).not.toContain('<iframe');
26
+ expect(html).not.toContain('onclick');
27
+ expect(html).not.toContain('onerror');
28
+ expect(html).not.toContain('javascript:alert(4)');
29
+ });
30
+
31
+ test('blocks data urls on images', () => {
32
+ const html = renderToStaticMarkup(
33
+ <RstRenderer
34
+ content="Fallback body"
35
+ html={'<p><img src="data:image/svg+xml,<svg onload=alert(1)>" alt="Bad" /></p>'}
36
+ />
37
+ );
38
+
39
+ expect(html).toContain('<img');
40
+ expect(html).not.toContain('data:image');
41
+ });
42
+
43
+ test('preserves MathML elements', () => {
44
+ const html = renderToStaticMarkup(
45
+ <RstRenderer
46
+ content="Fallback body"
47
+ html={'<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>2</mn></mrow></math>'}
48
+ />
49
+ );
50
+
51
+ expect(html).toContain('<math');
52
+ expect(html).toContain('<mrow');
53
+ expect(html).toContain('<mi>x</mi>');
54
+ });
55
+
56
+ test('wraps rendered rst tables with the same scroll container pattern as markdown', () => {
57
+ const html = renderToStaticMarkup(
58
+ <RstRenderer
59
+ content="Fallback body"
60
+ html={'<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>B</td></tr></tbody></table>'}
61
+ />
62
+ );
63
+
64
+ expect(html).toContain('class="rst-table-wrapper"');
65
+ expect(html).toContain('<table>');
66
+ expect(html).toContain('<th>A</th>');
67
+ expect(html).toContain('<td>B</td>');
68
+ });
69
+
70
+ test('renders converted headings, links, and code blocks through the markdown renderer', () => {
71
+ const html = renderToStaticMarkup(
72
+ <RstRenderer
73
+ content={[
74
+ 'Section',
75
+ '-------',
76
+ '',
77
+ 'Paragraph with `Example <https://example.com>`_.',
78
+ '',
79
+ '.. code-block:: ts',
80
+ '',
81
+ ' export const value = 1;',
82
+ ].join('\n')}
83
+ />
84
+ );
85
+
86
+ expect(html).toContain('Section');
87
+ expect(html).toContain('https://example.com');
88
+ expect(html).toContain('language-ts');
89
+ expect(html).toContain('<code class="language-ts"');
90
+ expect(html).toContain('token keyword');
91
+ expect(html).toContain('token number');
92
+ });
93
+ });
@@ -0,0 +1,122 @@
1
+ import MarkdownRenderer from '@/components/MarkdownRenderer';
2
+ import KatexStyles from '@/components/KatexStyles';
3
+ import type { SlugRegistryEntry } from '@/lib/markdown';
4
+ import { rstToMarkdown } from '@/lib/rst';
5
+ import sanitizeHtml from 'sanitize-html';
6
+
7
+ interface RstRendererProps {
8
+ content: string;
9
+ html?: string;
10
+ latex?: boolean;
11
+ slug?: string;
12
+ slugRegistry?: Map<string, SlugRegistryEntry>;
13
+ }
14
+
15
+ const proseClasses = `prose prose-lg max-w-none min-w-0 overflow-x-hidden text-foreground
16
+ prose-headings:font-serif prose-headings:text-heading
17
+ prose-p:text-foreground prose-p:leading-loose
18
+ prose-strong:text-heading prose-strong:font-semibold
19
+ prose-code:bg-muted/15 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:border prose-code:border-muted/20 prose-code:text-[0.9em] prose-code:font-medium
20
+ prose-code:before:content-none prose-code:after:content-none
21
+ prose-blockquote:italic
22
+ prose-th:text-heading prose-td:text-foreground
23
+ dark:prose-invert`;
24
+
25
+ const allowedTags = [
26
+ ...(sanitizeHtml.defaults.allowedTags ?? []),
27
+ 'section',
28
+ 'img',
29
+ 'source',
30
+ 'figure',
31
+ 'figcaption',
32
+ 'aside',
33
+ 'math',
34
+ 'annotation',
35
+ 'annotation-xml',
36
+ 'maction',
37
+ 'menclose',
38
+ 'merror',
39
+ 'mfenced',
40
+ 'mfrac',
41
+ 'mi',
42
+ 'mmultiscripts',
43
+ 'mn',
44
+ 'mo',
45
+ 'mover',
46
+ 'mpadded',
47
+ 'mphantom',
48
+ 'mprescripts',
49
+ 'mroot',
50
+ 'mrow',
51
+ 'ms',
52
+ 'mspace',
53
+ 'msqrt',
54
+ 'mstyle',
55
+ 'msub',
56
+ 'msubsup',
57
+ 'msup',
58
+ 'mtable',
59
+ 'mtd',
60
+ 'mtext',
61
+ 'mtr',
62
+ 'munder',
63
+ 'munderover',
64
+ 'semantics',
65
+ ];
66
+
67
+ const allowedAttributes: sanitizeHtml.IOptions['allowedAttributes'] = {
68
+ ...sanitizeHtml.defaults.allowedAttributes,
69
+ '*': ['id', 'class', 'title', 'lang', 'dir', 'role', 'aria-label', 'aria-hidden'],
70
+ a: ['href', 'name', 'target', 'rel', 'id', 'class', 'title'],
71
+ img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading', 'decoding', 'class', 'id'],
72
+ source: ['src', 'srcset', 'type'],
73
+ td: ['colspan', 'rowspan', 'align'],
74
+ th: ['colspan', 'rowspan', 'align', 'scope'],
75
+ ol: ['start', 'reversed', 'type'],
76
+ li: ['value'],
77
+ math: ['display', 'xmlns'],
78
+ annotation: ['encoding'],
79
+ 'annotation-xml': ['encoding'],
80
+ };
81
+
82
+ function sanitizeRenderedHtml(html: string): string {
83
+ return sanitizeHtml(html, {
84
+ allowedTags,
85
+ allowedAttributes,
86
+ allowedSchemes: ['http', 'https', 'mailto'],
87
+ allowedSchemesByTag: {
88
+ img: ['http', 'https'],
89
+ },
90
+ allowProtocolRelative: false,
91
+ });
92
+ }
93
+
94
+ export default function RstRenderer({ content, html, latex = false, slug, slugRegistry }: RstRendererProps) {
95
+ if (html) {
96
+ const sanitizedHtml = sanitizeRenderedHtml(html).replace(
97
+ /<table\b([^>]*)>/g,
98
+ '<div class="rst-table-wrapper"><table$1>'
99
+ ).replace(/<\/table>/g, '</table></div>');
100
+
101
+ return (
102
+ <>
103
+ {latex && <KatexStyles />}
104
+ <div className="bg-background">
105
+ <div
106
+ className={`${proseClasses} rst-rendered`}
107
+ dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
108
+ />
109
+ </div>
110
+ </>
111
+ );
112
+ }
113
+
114
+ return (
115
+ <MarkdownRenderer
116
+ content={rstToMarkdown(content)}
117
+ latex={latex}
118
+ slug={slug}
119
+ slugRegistry={slugRegistry}
120
+ />
121
+ );
122
+ }
@@ -2,6 +2,7 @@ import Link from 'next/link';
2
2
  import { Suspense } from 'react';
3
3
  import { getAuthorSlug, PostData, BacklinkSource, SlugRegistryEntry, CollectionContext } from '@/lib/markdown';
4
4
  import MarkdownRenderer from '@/components/MarkdownRenderer';
5
+ import RstRenderer from '@/components/RstRenderer';
5
6
  import RelatedPosts from '@/components/RelatedPosts';
6
7
  import SeriesList from '@/components/SeriesList';
7
8
  import PostSidebar from '@/components/PostSidebar';
@@ -41,6 +42,9 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
41
42
  ? `${siteConfig.baseUrl.replace(/\/+$/, '')}${getStaticPageUrl(post.slug)}`
42
43
  : `${siteConfig.baseUrl}${getPostUrl(post)}`;
43
44
  const commentSlug = isStaticPage ? `pages/${post.slug}` : post.slug;
45
+ const bodyRenderer = post.sourceFormat === 'rst'
46
+ ? <RstRenderer content={post.content} html={post.renderedHtml} latex={post.latex} slug={post.imageBaseSlug} slugRegistry={slugRegistry} />
47
+ : <MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} slugRegistry={slugRegistry} />;
44
48
 
45
49
  return (
46
50
  <div className="layout-container">
@@ -136,7 +140,7 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
136
140
  </div>
137
141
  )}
138
142
 
139
- <MarkdownRenderer content={post.content} latex={post.latex} slug={post.imageBaseSlug} slugRegistry={slugRegistry} />
143
+ {bodyRenderer}
140
144
 
141
145
  {siteConfig.posts?.authors?.showAuthorCard !== false && (
142
146
  <AuthorCard authors={post.authors} />
@@ -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
  );
@@ -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
  });