@hutusi/amytis 1.14.0 → 1.16.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 (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +90 -219
  5. package/README.md +33 -1
  6. package/README.zh.md +33 -1
  7. package/TODO.md +10 -0
  8. package/bun.lock +205 -539
  9. package/content/books/sample-book/index.mdx +3 -0
  10. package/content/posts/code-block-features-showcase.mdx +223 -0
  11. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  12. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  13. package/content/series/rst-legacy/getting-started.rst +24 -0
  14. package/content/series/rst-legacy/index.rst +9 -0
  15. package/content/series/rst-readme/README.rst +9 -0
  16. package/content/series/rst-readme/readme-index-post.rst +10 -0
  17. package/content/series/rst-toctree/first-post.rst +6 -0
  18. package/content/series/rst-toctree/index.rst +10 -0
  19. package/content/series/rst-toctree/second-post.rst +6 -0
  20. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/index.rst +12 -0
  22. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  23. package/docs/ALERTS.md +112 -0
  24. package/docs/ARCHITECTURE.md +239 -8
  25. package/docs/CODE-BLOCKS.md +238 -0
  26. package/docs/CONTRIBUTING.md +36 -0
  27. package/docs/guides/README.md +11 -0
  28. package/docs/guides/importing-vuepress-books.md +178 -0
  29. package/eslint.config.mjs +20 -6
  30. package/next.config.ts +2 -2
  31. package/package.json +52 -24
  32. package/packages/create-amytis/package.json +1 -1
  33. package/packages/create-amytis/src/index.test.ts +43 -1
  34. package/packages/create-amytis/src/index.ts +64 -8
  35. package/public/next-image-export-optimizer-hashes.json +14 -73
  36. package/scripts/build-pagefind.ts +172 -0
  37. package/scripts/copy-assets.ts +246 -56
  38. package/scripts/generate-code-group-icons.ts +79 -0
  39. package/scripts/generate-knowledge-graph.ts +2 -1
  40. package/scripts/render-rst.py +923 -0
  41. package/scripts/run-with-rst-python.ts +42 -0
  42. package/scripts/sync-vuepress-book.ts +499 -0
  43. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  44. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  45. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  46. package/src/app/books/[slug]/page.tsx +67 -32
  47. package/src/app/globals.css +639 -94
  48. package/src/app/page.tsx +1 -1
  49. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  50. package/src/app/series/[slug]/page.tsx +11 -13
  51. package/src/app/series/page.tsx +3 -3
  52. package/src/app/sitemap.ts +3 -3
  53. package/src/components/ArticleCopyCleaner.tsx +64 -0
  54. package/src/components/AuthorCard.tsx +25 -16
  55. package/src/components/BookMobileNav.tsx +44 -50
  56. package/src/components/BookSidebar.tsx +0 -0
  57. package/src/components/CodeBlock.test.tsx +93 -8
  58. package/src/components/CodeBlock.tsx +39 -101
  59. package/src/components/CodeBlockToolbar.tsx +88 -0
  60. package/src/components/CodeGroup.tsx +81 -0
  61. package/src/components/CoverImage.tsx +6 -2
  62. package/src/components/ExternalLinkIcon.tsx +15 -0
  63. package/src/components/FeaturedStoriesSection.tsx +3 -3
  64. package/src/components/GithubAlert.tsx +97 -0
  65. package/src/components/MarkdownRenderer.test.tsx +30 -4
  66. package/src/components/MarkdownRenderer.tsx +148 -24
  67. package/src/components/Mermaid.tsx +32 -1
  68. package/src/components/PostList.tsx +1 -1
  69. package/src/components/PostNavigation.tsx +13 -2
  70. package/src/components/PostSidebar.tsx +13 -2
  71. package/src/components/RstRenderer.test.tsx +93 -0
  72. package/src/components/RstRenderer.tsx +157 -0
  73. package/src/components/Search.tsx +18 -4
  74. package/src/components/SeriesCatalog.tsx +1 -1
  75. package/src/components/ShareBar.tsx +5 -0
  76. package/src/components/TocPanel.tsx +10 -2
  77. package/src/i18n/translations.ts +2 -0
  78. package/src/layouts/BookLayout.tsx +35 -4
  79. package/src/layouts/PostLayout.tsx +10 -2
  80. package/src/layouts/SimpleLayout.tsx +10 -3
  81. package/src/lib/code-group-icons.test.ts +78 -0
  82. package/src/lib/code-group-icons.ts +148 -0
  83. package/src/lib/image-utils.test.ts +19 -0
  84. package/src/lib/image-utils.ts +11 -0
  85. package/src/lib/markdown.test.ts +195 -14
  86. package/src/lib/markdown.ts +928 -254
  87. package/src/lib/normalize-vuepress-math.ts +118 -0
  88. package/src/lib/rehype-fence-meta.ts +22 -0
  89. package/src/lib/rehype-image-metadata.ts +2 -2
  90. package/src/lib/remark-book-chapter-links.ts +106 -0
  91. package/src/lib/remark-code-group.ts +54 -0
  92. package/src/lib/remark-github-alerts.test.ts +83 -0
  93. package/src/lib/remark-github-alerts.ts +65 -0
  94. package/src/lib/remark-vuepress-containers.ts +130 -0
  95. package/src/lib/rst-renderer.test.ts +355 -0
  96. package/src/lib/rst-renderer.ts +629 -0
  97. package/src/lib/rst.test.ts +350 -0
  98. package/src/lib/rst.ts +674 -0
  99. package/src/lib/series-redirects.ts +42 -0
  100. package/src/lib/shiki-rst.ts +185 -0
  101. package/src/lib/shiki.test.ts +153 -0
  102. package/src/lib/shiki.ts +292 -0
  103. package/src/lib/urls.ts +57 -0
  104. package/src/test-utils/render.ts +23 -0
  105. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  106. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  107. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  108. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  109. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  110. package/tests/helpers/env.ts +19 -0
  111. package/tests/integration/book-chapter-links.test.ts +107 -0
  112. package/tests/integration/books-nested-toc.test.ts +176 -0
  113. package/tests/integration/books.test.ts +3 -2
  114. package/tests/integration/code-block-features.test.ts +188 -0
  115. package/tests/integration/code-group.test.ts +183 -0
  116. package/tests/integration/code-notation.test.ts +97 -0
  117. package/tests/integration/feed-utils.test.ts +13 -0
  118. package/tests/integration/github-alerts.test.ts +82 -0
  119. package/tests/integration/markdown-external-links.test.ts +103 -0
  120. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  121. package/tests/integration/reading-time-headings.test.ts +12 -14
  122. package/tests/integration/series-draft.test.ts +12 -5
  123. package/tests/integration/series.test.ts +93 -0
  124. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  125. package/tests/integration/vuepress-containers.test.ts +107 -0
  126. package/tests/tooling/build-pagefind.test.ts +66 -0
  127. package/tests/tooling/new-post.test.ts +1 -1
  128. package/tests/unit/static-params.test.ts +166 -13
@@ -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,38 @@
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
+ calculateReadingMinutes,
9
+ calculateWordCount,
10
+ getHeadings,
11
+ getAuthorSlug,
12
+ getPythonRstRendererAvailabilityForTests,
13
+ parseMarkdownFileForTests,
14
+ parseRstFileForTests,
15
+ resetPythonRstRendererAvailabilityForTests,
16
+ } from "./markdown";
17
+
18
+ const previousEnablePythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
19
+ const previousRstPython = process.env.AMYTIS_RST_PYTHON;
20
+
21
+ afterEach(() => {
22
+ if (previousEnablePythonRst === undefined) {
23
+ delete process.env.AMYTIS_ENABLE_PYTHON_RST;
24
+ } else {
25
+ process.env.AMYTIS_ENABLE_PYTHON_RST = previousEnablePythonRst;
26
+ }
27
+
28
+ if (previousRstPython === undefined) {
29
+ delete process.env.AMYTIS_RST_PYTHON;
30
+ } else {
31
+ process.env.AMYTIS_RST_PYTHON = previousRstPython;
32
+ }
33
+
34
+ resetPythonRstRendererAvailabilityForTests();
35
+ });
3
36
 
4
37
  describe("markdown utils", () => {
5
38
  describe("generateExcerpt", () => {
@@ -34,38 +67,80 @@ describe("markdown utils", () => {
34
67
  });
35
68
  });
36
69
 
37
- describe("calculateReadingTime", () => {
38
- test("short content returns 1 min read", () => {
70
+ describe("calculateReadingMinutes", () => {
71
+ test("short content returns 1 minute (floor)", () => {
39
72
  const text = "Hello world, this is a short post.";
40
- expect(calculateReadingTime(text)).toBe("1 min read");
73
+ expect(calculateReadingMinutes(text)).toBe(1);
41
74
  });
42
75
 
43
- test("600 words returns 3 min read", () => {
76
+ test("600 words returns 3 minutes", () => {
44
77
  const words = Array(600).fill("word").join(" ");
45
- expect(calculateReadingTime(words)).toBe("3 min read");
78
+ expect(calculateReadingMinutes(words)).toBe(3);
46
79
  });
47
80
 
48
- test("empty content returns 1 min read", () => {
49
- expect(calculateReadingTime("")).toBe("1 min read");
81
+ test("empty content returns 1 (floor)", () => {
82
+ expect(calculateReadingMinutes("")).toBe(1);
50
83
  });
51
84
 
52
85
  test("strips markdown formatting before counting", () => {
53
86
  // 400 actual words surrounded by markdown syntax
54
87
  const words = Array(400).fill("**word**").join(" ");
55
- const result = calculateReadingTime(words);
56
- expect(result).toBe("2 min read");
88
+ expect(calculateReadingMinutes(words)).toBe(2);
57
89
  });
58
90
 
59
- test("counts Chinese characters for reading time", () => {
91
+ test("counts Chinese characters at 300 cpm", () => {
60
92
  const han = "中".repeat(600);
61
- expect(calculateReadingTime(han)).toBe("2 min read");
93
+ expect(calculateReadingMinutes(han)).toBe(2);
62
94
  });
63
95
 
64
96
  test("combines Latin words and Chinese characters", () => {
65
97
  const latinWords = Array(200).fill("word").join(" ");
66
98
  const han = "中".repeat(300);
67
99
  const mixed = `${latinWords} ${han}`;
68
- expect(calculateReadingTime(mixed)).toBe("2 min read");
100
+ expect(calculateReadingMinutes(mixed)).toBe(2);
101
+ });
102
+ });
103
+
104
+ describe("calculateWordCount", () => {
105
+ test("empty content returns 0", () => {
106
+ expect(calculateWordCount("")).toBe(0);
107
+ });
108
+
109
+ test("Latin words: each whitespace-bounded token counts once", () => {
110
+ expect(calculateWordCount("Hello world, this is a short post.")).toBe(7);
111
+ expect(calculateWordCount(Array(600).fill("word").join(" "))).toBe(600);
112
+ });
113
+
114
+ test("Chinese characters count per-character", () => {
115
+ expect(calculateWordCount("中".repeat(600))).toBe(600);
116
+ });
117
+
118
+ test("mixed Latin + Chinese sums both counts", () => {
119
+ const latin = Array(200).fill("word").join(" ");
120
+ const han = "中".repeat(300);
121
+ expect(calculateWordCount(`${latin} ${han}`)).toBe(500);
122
+ });
123
+
124
+ test("strips fenced code blocks before counting", () => {
125
+ const src = ["pre", "```", "code line one two three", "```", "post"].join("\n");
126
+ // Only "pre" and "post" count.
127
+ expect(calculateWordCount(src)).toBe(2);
128
+ });
129
+
130
+ test("strips inline HTML tags before counting", () => {
131
+ expect(calculateWordCount("hello <span>world</span> again")).toBe(3);
132
+ });
133
+
134
+ test("strips markdown link syntax, keeps link text", () => {
135
+ expect(calculateWordCount("See [the docs](https://example.com) here")).toBe(4);
136
+ });
137
+
138
+ test("matches calculateReadingMinutes on the same input", () => {
139
+ // The two metrics share a tokenizer; both should agree on the underlying
140
+ // token counts. 600 Latin words → 600 wordCount, 3-minute reading time.
141
+ const text = Array(600).fill("word").join(" ");
142
+ expect(calculateWordCount(text)).toBe(600);
143
+ expect(calculateReadingMinutes(text)).toBe(3);
69
144
  });
70
145
  });
71
146
 
@@ -124,4 +199,110 @@ describe("markdown utils", () => {
124
199
  expect(getAuthorSlug(" John Hu ")).toBe("john-hu");
125
200
  });
126
201
  });
202
+
203
+ describe("rST parsing fallbacks", () => {
204
+ test("uses markdown file mtime when frontmatter date and slug date are missing", () => {
205
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "amytis-md-"));
206
+ const filePath = path.join(tempDir, "legacy.mdx");
207
+ fs.writeFileSync(
208
+ filePath,
209
+ [
210
+ "---",
211
+ 'title: "Legacy Markdown"',
212
+ "---",
213
+ "",
214
+ "Body",
215
+ "",
216
+ ].join("\n"),
217
+ "utf8",
218
+ );
219
+
220
+ const expectedDate = "2021-03-17";
221
+ const expectedTime = new Date(`${expectedDate}T12:00:00Z`);
222
+ fs.utimesSync(filePath, expectedTime, expectedTime);
223
+
224
+ try {
225
+ const post = parseMarkdownFileForTests(filePath, "legacy");
226
+ expect(post.date).toBe(expectedDate);
227
+ } finally {
228
+ fs.rmSync(tempDir, { recursive: true, force: true });
229
+ }
230
+ });
231
+
232
+ test("includes the source file path in rst parse errors", () => {
233
+ process.env.AMYTIS_ENABLE_PYTHON_RST = "0";
234
+
235
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "amytis-rst-"));
236
+ const filePath = path.join(tempDir, "broken.rst");
237
+ fs.writeFileSync(
238
+ filePath,
239
+ [
240
+ ":Date: 2021-16-15",
241
+ "",
242
+ "Broken Title",
243
+ "************",
244
+ "",
245
+ "Body",
246
+ "",
247
+ ].join("\n"),
248
+ "utf8",
249
+ );
250
+
251
+ try {
252
+ expect(() => parseRstFileForTests(filePath, "broken")).toThrow(
253
+ new RstParseError(`Invalid date: 2021-16-15 (${filePath})`)
254
+ );
255
+ } finally {
256
+ fs.rmSync(tempDir, { recursive: true, force: true });
257
+ }
258
+ });
259
+
260
+ test("falls back to the legacy rst parser when python runtime is unavailable", () => {
261
+ process.env.AMYTIS_ENABLE_PYTHON_RST = "1";
262
+ process.env.AMYTIS_RST_PYTHON = "python-does-not-exist";
263
+ resetPythonRstRendererAvailabilityForTests();
264
+
265
+ const post = parseRstFileForTests(
266
+ path.join(process.cwd(), "content/series/rst-legacy/getting-started.rst"),
267
+ "getting-started",
268
+ undefined,
269
+ "rst-legacy",
270
+ );
271
+
272
+ expect(post.title).toBe("Getting Started With rST");
273
+ expect(post.renderedHtml).toBeUndefined();
274
+ expect(post.content).toContain("Overview\n--------");
275
+ expect(post.content).toContain(".. code-block:: ts");
276
+ expect(getPythonRstRendererAvailabilityForTests()).toBe(false);
277
+ });
278
+
279
+ test("uses rst file mtime when metadata date and slug date are missing", () => {
280
+ process.env.AMYTIS_ENABLE_PYTHON_RST = "0";
281
+
282
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "amytis-rst-"));
283
+ const filePath = path.join(tempDir, "legacy.rst");
284
+ fs.writeFileSync(
285
+ filePath,
286
+ [
287
+ "Legacy rST",
288
+ "**********",
289
+ "",
290
+ "Body",
291
+ "",
292
+ ].join("\n"),
293
+ "utf8",
294
+ );
295
+
296
+ const expectedDate = "2020-04-09";
297
+ const expectedTime = new Date(`${expectedDate}T12:00:00Z`);
298
+ fs.utimesSync(filePath, expectedTime, expectedTime);
299
+
300
+ try {
301
+ const post = parseRstFileForTests(filePath, "legacy");
302
+ expect(post.date).toBe(expectedDate);
303
+ } finally {
304
+ fs.rmSync(tempDir, { recursive: true, force: true });
305
+ }
306
+ });
307
+ });
127
308
  });