@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,23 @@
1
+ import type { ReactElement } from 'react';
2
+ import { renderToReadableStream } from 'react-dom/server';
3
+
4
+ /**
5
+ * Renders a React tree (including async server components) to a full HTML string.
6
+ * Use this in tests where the tree contains async components — sync renderers like
7
+ * renderToStaticMarkup throw "A component suspended" for async server components.
8
+ */
9
+ export async function renderAsync(element: ReactElement): Promise<string> {
10
+ const stream = await renderToReadableStream(element);
11
+ await stream.allReady;
12
+ const reader = stream.getReader();
13
+ const decoder = new TextDecoder();
14
+ let html = '';
15
+ while (true) {
16
+ const { done, value } = await reader.read();
17
+ if (done) break;
18
+ if (value) html += decoder.decode(value, { stream: !done });
19
+ }
20
+ // Flush any trailing buffered bytes from incomplete sequences (no-op for well-formed UTF-8).
21
+ html += decoder.decode();
22
+ return html;
23
+ }
@@ -0,0 +1,43 @@
1
+ // Minimal VuePress 2 config used by the sync-vuepress-book integration test.
2
+ // Mimics the structural shape of a real dmla-like config: a `theme(...)`
3
+ // wrapper around an options object whose `sidebar` property is the literal
4
+ // the importer needs to find.
5
+
6
+ import dmlaTheme from './fake-theme.js'
7
+
8
+ export default {
9
+ lang: 'zh-CN',
10
+ title: 'Fixture Book',
11
+ description: 'A tiny VuePress book used in tests',
12
+
13
+ theme: dmlaTheme({
14
+ sidebar: [
15
+ {
16
+ text: 'Intro',
17
+ collapsible: false,
18
+ link: '/intro/welcome',
19
+ },
20
+ {
21
+ text: 'Maths',
22
+ collapsible: false,
23
+ children: [
24
+ {
25
+ text: 'Linear Algebra',
26
+ collapsible: false,
27
+ children: [
28
+ { text: 'Vectors', link: '/maths/linear/vectors' },
29
+ { text: 'Matrices', link: '/maths/linear/matrices' },
30
+ ],
31
+ },
32
+ ],
33
+ },
34
+ {
35
+ // Empty section — simulates the dmla config's placeholder sections
36
+ // ("alignment", "reasoning") so the importer warns instead of throws.
37
+ text: 'TBD',
38
+ collapsible: false,
39
+ children: [],
40
+ },
41
+ ],
42
+ }),
43
+ }
@@ -0,0 +1,7 @@
1
+ ---
2
+ title: "Welcome"
3
+ ---
4
+
5
+ # Welcome
6
+
7
+ A short welcome chapter.
@@ -0,0 +1 @@
1
+ stub binary image placeholder
@@ -0,0 +1,7 @@
1
+ ---
2
+ title: "Matrices"
3
+ ---
4
+
5
+ # Matrices
6
+
7
+ Builds on [vectors](vectors.md).
@@ -0,0 +1,9 @@
1
+ ---
2
+ title: "Vectors"
3
+ ---
4
+
5
+ # Vectors
6
+
7
+ See [matrices](matrices.md) for the next chapter.
8
+
9
+ ![diagram](./assets/diagram.png)
@@ -0,0 +1,19 @@
1
+ // @types/node 25+ types specific env vars (NODE_ENV, etc.) as readonly on
2
+ // `process.env`. Test code that wants to mutate them for the duration of a
3
+ // scenario should go through these helpers — they widen the property type so
4
+ // TS allows the write while still working at runtime exactly like
5
+ // `process.env[key] = ...` always has.
6
+
7
+ type MutableEnv = Record<string, string | undefined>;
8
+
9
+ export function setEnvVar(key: string, value: string): void {
10
+ (process.env as MutableEnv)[key] = value;
11
+ }
12
+
13
+ export function restoreEnvVar(key: string, value: string | undefined): void {
14
+ if (value === undefined) {
15
+ delete (process.env as MutableEnv)[key];
16
+ } else {
17
+ (process.env as MutableEnv)[key] = value;
18
+ }
19
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import MarkdownRenderer from "@/components/MarkdownRenderer";
3
+ import { renderAsync } from "@/test-utils/render";
4
+ import path from "path";
5
+
6
+ const fixtureBookDir = path.resolve("tests/fixtures/book-chapter-links");
7
+ const chapterSourcePath = path.join(fixtureBookDir, "maths/linear/introduction.md");
8
+
9
+ const bookContext = {
10
+ bookSlug: "dmla",
11
+ bookDir: fixtureBookDir,
12
+ chapterSourcePath,
13
+ validChapterIds: new Set([
14
+ "maths/linear/introduction",
15
+ "maths/linear/vectors",
16
+ "maths/linear/matrices",
17
+ "deep-learning/perceptron",
18
+ ]),
19
+ };
20
+
21
+ describe("Integration: remark-book-chapter-links", () => {
22
+ test("rewrites a relative sibling .md link to its canonical chapter URL", async () => {
23
+ const html = await renderAsync(
24
+ MarkdownRenderer({
25
+ content: "See [vectors](vectors.md) for details.",
26
+ bookContext,
27
+ }),
28
+ );
29
+ expect(html).toContain('href="/books/dmla/maths/linear/vectors"');
30
+ expect(html).not.toContain('href="vectors.md"');
31
+ });
32
+
33
+ test("preserves fragment anchors when rewriting", async () => {
34
+ const html = await renderAsync(
35
+ MarkdownRenderer({
36
+ content: "See [tensors](matrices.md#tensors).",
37
+ bookContext,
38
+ }),
39
+ );
40
+ expect(html).toContain('href="/books/dmla/maths/linear/matrices#tensors"');
41
+ });
42
+
43
+ test("rewrites a parent-directory .md link", async () => {
44
+ const html = await renderAsync(
45
+ MarkdownRenderer({
46
+ content: "See [perceptron](../../deep-learning/perceptron.md).",
47
+ bookContext,
48
+ }),
49
+ );
50
+ expect(html).toContain('href="/books/dmla/deep-learning/perceptron"');
51
+ });
52
+
53
+ test("leaves external http links untouched", async () => {
54
+ const html = await renderAsync(
55
+ MarkdownRenderer({
56
+ content: "See [Wiki](https://en.wikipedia.org/wiki/Vector_space).",
57
+ bookContext,
58
+ }),
59
+ );
60
+ expect(html).toContain('href="https://en.wikipedia.org/wiki/Vector_space"');
61
+ });
62
+
63
+ test("leaves hash-only links untouched", async () => {
64
+ const html = await renderAsync(
65
+ MarkdownRenderer({
66
+ content: "[Top](#top)",
67
+ bookContext,
68
+ }),
69
+ );
70
+ expect(html).toContain('href="#top"');
71
+ });
72
+
73
+ test("warns and leaves the link unrewritten when target is not in the TOC", async () => {
74
+ const html = await renderAsync(
75
+ MarkdownRenderer({
76
+ content: "Broken [link](nonexistent.md).",
77
+ bookContext,
78
+ }),
79
+ );
80
+ // The unmatched link is kept as-is — it will 404 if clicked, but doesn't
81
+ // block the build. Matches the Shiki "unknown language → warn" precedent.
82
+ expect(html).toContain('href="nonexistent.md"');
83
+ });
84
+
85
+ test("malformed percent-encoding in a link does not crash the render", async () => {
86
+ // `%E0%A4%A` is a truncated UTF-8 sequence — bare decodeURIComponent throws
87
+ // URIError on this. The plugin must swallow that and not blow up the build.
88
+ const html = await renderAsync(
89
+ MarkdownRenderer({
90
+ content: "Sketchy [link](%E0%A4%A.md).",
91
+ bookContext,
92
+ }),
93
+ );
94
+ // Either the link survives as-is or it's silently dropped — what matters
95
+ // is that we don't get an unhandled URIError tearing down the render.
96
+ expect(html).toContain("Sketchy");
97
+ });
98
+
99
+ test("non-book content (no bookContext) is not rewritten", async () => {
100
+ const html = await renderAsync(
101
+ MarkdownRenderer({
102
+ content: "See [vectors](vectors.md).",
103
+ }),
104
+ );
105
+ expect(html).toContain('href="vectors.md"');
106
+ });
107
+ });
@@ -0,0 +1,176 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ flattenBookChapters,
4
+ BookSchema,
5
+ type BookTocItem,
6
+ } from "../../src/lib/markdown";
7
+
8
+ describe("Integration: Books nested TOC", () => {
9
+ test("schema accepts the legacy { part, chapters } shape", () => {
10
+ const result = BookSchema.safeParse({
11
+ title: "Legacy Book",
12
+ date: "2026-01-01",
13
+ chapters: [
14
+ {
15
+ part: "Part I",
16
+ chapters: [
17
+ { title: "Intro", id: "intro" },
18
+ { title: "Setup", id: "setup" },
19
+ ],
20
+ },
21
+ {
22
+ part: "Part II",
23
+ chapters: [{ title: "Outro", id: "outro" }],
24
+ },
25
+ ],
26
+ });
27
+ expect(result.success).toBe(true);
28
+ });
29
+
30
+ test("schema accepts the new { section, items } shape with arbitrary nesting", () => {
31
+ const result = BookSchema.safeParse({
32
+ title: "VuePress Book",
33
+ date: "2026-01-01",
34
+ chapters: [
35
+ {
36
+ section: "Maths",
37
+ items: [
38
+ {
39
+ section: "Linear Algebra",
40
+ items: [
41
+ { title: "Intro", id: "maths/linear/introduction" },
42
+ { title: "Vectors", id: "maths/linear/vectors" },
43
+ ],
44
+ },
45
+ {
46
+ section: "Calculus",
47
+ items: [{ title: "Derivative", id: "maths/calculus/derivative" }],
48
+ },
49
+ ],
50
+ },
51
+ ],
52
+ });
53
+ expect(result.success).toBe(true);
54
+ });
55
+
56
+ test("schema accepts bare chapter refs at the top level", () => {
57
+ const result = BookSchema.safeParse({
58
+ title: "Flat Book",
59
+ date: "2026-01-01",
60
+ chapters: [
61
+ { title: "Chapter 1", id: "ch1" },
62
+ { title: "Chapter 2", id: "ch2" },
63
+ ],
64
+ });
65
+ expect(result.success).toBe(true);
66
+ });
67
+
68
+ test("schema accepts a mixed TOC (legacy parts + new sections + bare refs)", () => {
69
+ const result = BookSchema.safeParse({
70
+ title: "Mixed Book",
71
+ date: "2026-01-01",
72
+ chapters: [
73
+ { part: "Part A", chapters: [{ title: "A1", id: "a1" }] },
74
+ {
75
+ section: "Section B",
76
+ items: [{ title: "B1", id: "section-b/b1" }],
77
+ },
78
+ { title: "Standalone", id: "standalone" },
79
+ ],
80
+ });
81
+ expect(result.success).toBe(true);
82
+ });
83
+
84
+ test("schema rejects a section with neither items nor a section title", () => {
85
+ const result = BookSchema.safeParse({
86
+ title: "Bad Book",
87
+ date: "2026-01-01",
88
+ chapters: [{ section: "No items" } as unknown],
89
+ });
90
+ expect(result.success).toBe(false);
91
+ });
92
+
93
+ test("flattenBookChapters preserves order across legacy parts", () => {
94
+ const toc: BookTocItem[] = [
95
+ {
96
+ part: "Part I",
97
+ chapters: [
98
+ { title: "Intro", id: "intro" },
99
+ { title: "Setup", id: "setup" },
100
+ ],
101
+ },
102
+ { part: "Part II", chapters: [{ title: "Outro", id: "outro" }] },
103
+ ];
104
+ const flat = flattenBookChapters(toc);
105
+ expect(flat.map((c) => c.id)).toEqual(["intro", "setup", "outro"]);
106
+ expect(flat[0].part).toBe("Part I");
107
+ expect(flat[1].part).toBe("Part I");
108
+ expect(flat[2].part).toBe("Part II");
109
+ expect(flat[0].section).toBeUndefined();
110
+ expect(flat[0].sectionPath).toBeUndefined();
111
+ });
112
+
113
+ test("flattenBookChapters walks nested sections in source order", () => {
114
+ const toc: BookTocItem[] = [
115
+ {
116
+ section: "Maths",
117
+ items: [
118
+ {
119
+ section: "Linear Algebra",
120
+ items: [
121
+ { title: "Intro", id: "maths/linear/introduction" },
122
+ { title: "Vectors", id: "maths/linear/vectors" },
123
+ ],
124
+ },
125
+ {
126
+ section: "Calculus",
127
+ items: [{ title: "Derivative", id: "maths/calculus/derivative" }],
128
+ },
129
+ ],
130
+ },
131
+ ];
132
+ const flat = flattenBookChapters(toc);
133
+ expect(flat.map((c) => c.id)).toEqual([
134
+ "maths/linear/introduction",
135
+ "maths/linear/vectors",
136
+ "maths/calculus/derivative",
137
+ ]);
138
+ });
139
+
140
+ test("flattenBookChapters annotates section + sectionPath for nested chapters", () => {
141
+ const toc: BookTocItem[] = [
142
+ {
143
+ section: "Maths",
144
+ items: [
145
+ {
146
+ section: "Linear Algebra",
147
+ items: [{ title: "Intro", id: "maths/linear/introduction" }],
148
+ },
149
+ ],
150
+ },
151
+ ];
152
+ const flat = flattenBookChapters(toc);
153
+ expect(flat).toHaveLength(1);
154
+ expect(flat[0].section).toBe("Linear Algebra");
155
+ expect(flat[0].sectionPath).toEqual(["Maths", "Linear Algebra"]);
156
+ expect(flat[0].part).toBeUndefined();
157
+ });
158
+
159
+ test("flattenBookChapters handles mixed legacy + new + bare entries", () => {
160
+ const toc: BookTocItem[] = [
161
+ { part: "Part A", chapters: [{ title: "A1", id: "a1" }] },
162
+ {
163
+ section: "Section B",
164
+ items: [{ title: "B1", id: "section-b/b1" }],
165
+ },
166
+ { title: "Standalone", id: "standalone" },
167
+ ];
168
+ const flat = flattenBookChapters(toc);
169
+ expect(flat.map((c) => c.id)).toEqual(["a1", "section-b/b1", "standalone"]);
170
+ expect(flat[0].part).toBe("Part A");
171
+ expect(flat[1].section).toBe("Section B");
172
+ expect(flat[1].sectionPath).toEqual(["Section B"]);
173
+ expect(flat[2].part).toBeUndefined();
174
+ expect(flat[2].section).toBeUndefined();
175
+ });
176
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { getAllBooks, getFeaturedBooks } from "../../src/lib/markdown";
3
+ import { setEnvVar, restoreEnvVar } from "../helpers/env";
3
4
 
4
5
  describe("Integration: Books", () => {
5
6
  test("getAllBooks returns an array", () => {
@@ -48,14 +49,14 @@ describe("Integration: Books", () => {
48
49
 
49
50
  test("getAllBooks excludes drafts in production", () => {
50
51
  const prev = process.env.NODE_ENV;
51
- process.env.NODE_ENV = "production";
52
+ setEnvVar("NODE_ENV", "production");
52
53
  try {
53
54
  const books = getAllBooks();
54
55
  books.forEach((book) => {
55
56
  expect(book.draft).toBe(false);
56
57
  });
57
58
  } finally {
58
- process.env.NODE_ENV = prev;
59
+ restoreEnvVar("NODE_ENV", prev);
59
60
  }
60
61
  });
61
62
  });
@@ -0,0 +1,188 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import MarkdownRenderer from '@/components/MarkdownRenderer';
3
+ import RstRenderer from '@/components/RstRenderer';
4
+ import { renderAsync } from '@/test-utils/render';
5
+
6
+ describe('Integration: Code Block Features', () => {
7
+ describe('Markdown / MDX', () => {
8
+ test('highlights specific lines from {n,n-m} fence meta', async () => {
9
+ const content = [
10
+ '```ts {1,3-4}',
11
+ 'const a = 1;',
12
+ 'const b = 2;',
13
+ 'const c = 3;',
14
+ 'const d = 4;',
15
+ '```',
16
+ ].join('\n');
17
+
18
+ const html = await renderAsync(MarkdownRenderer({ content }));
19
+
20
+ // Lines 1, 3, 4 should be marked as highlighted; 2 should not.
21
+ expect(html).toContain('data-highlighted-line="1"');
22
+ expect(html).toContain('data-highlighted-line="3"');
23
+ expect(html).toContain('data-highlighted-line="4"');
24
+ expect(html).not.toContain('data-highlighted-line="2"');
25
+ });
26
+
27
+ test('opt-in line numbers via `linenos` fence meta', async () => {
28
+ const content = ['```js linenos', 'const x = 1;', '```'].join('\n');
29
+ const html = await renderAsync(MarkdownRenderer({ content }));
30
+
31
+ expect(html).toContain('data-line-numbers="true"');
32
+ });
33
+
34
+ test('title bar from title="..." fence meta', async () => {
35
+ const content = ['```ts title="src/app.ts"', 'export const x = 1;', '```'].join('\n');
36
+ const html = await renderAsync(MarkdownRenderer({ content }));
37
+
38
+ expect(html).toContain('src/app.ts');
39
+ expect(html).toContain('cb-title');
40
+ });
41
+
42
+ test('diff fence colors +/- lines with diff add/remove classes', async () => {
43
+ const content = ['```diff', '-old', '+new', ' unchanged', '```'].join('\n');
44
+ const html = await renderAsync(MarkdownRenderer({ content }));
45
+
46
+ expect(html).toContain('diff add');
47
+ expect(html).toContain('diff remove');
48
+ });
49
+
50
+ test('mermaid blocks do not run through Shiki', async () => {
51
+ const content = ['```mermaid', 'graph TD; A-->B;', '```'].join('\n');
52
+ const html = await renderAsync(MarkdownRenderer({ content }));
53
+
54
+ // Mermaid is short-circuited in MarkdownRenderer before CodeBlock is invoked,
55
+ // so no Shiki wrapper should appear for a mermaid fence.
56
+ expect(html).not.toContain('class="shiki');
57
+ // The Mermaid component delegates client-side rendering; assert its container.
58
+ expect(html.toLowerCase()).toContain('mermaid');
59
+ });
60
+
61
+ // Extract the Mermaid outer-wrapper class string so token assertions are
62
+ // order-independent and scoped to the wrapper — not the surrounding prose
63
+ // chrome (which carries `prose-code:*` utilities that share substrings).
64
+ const findMermaidWrapperClass = (html: string): string => {
65
+ // The wrapper precedes the inner `class="mermaid ..."` element. Match the
66
+ // *previous* class attribute by anchoring on the inner mermaid class.
67
+ const m = html.match(/class="([^"]*)"\s*><div class="mermaid /);
68
+ return m?.[1] ?? '';
69
+ };
70
+
71
+ test('mermaid renders with a frameless wrapper (no border/padding/shadow)', async () => {
72
+ // Mermaid SVG nodes carry their own borders, so the prose pipeline does
73
+ // not wrap diagrams in a framed container the way it wraps tables.
74
+ const content = ['```mermaid', 'graph TD; A-->B;', '```'].join('\n');
75
+ const html = await renderAsync(MarkdownRenderer({ content }));
76
+ const wrapper = findMermaidWrapperClass(html);
77
+
78
+ expect(wrapper).toContain('my-6');
79
+ expect(wrapper).toContain('overflow-x-auto');
80
+ expect(wrapper).not.toContain('shadow-sm');
81
+ expect(wrapper).not.toContain('p-4');
82
+ expect(wrapper).not.toContain('md:p-8');
83
+ expect(wrapper).not.toContain('border');
84
+ expect(html.toLowerCase()).toContain('mermaid');
85
+ });
86
+
87
+ test('legacy `compact` fence meta is a no-op (no regression for old content)', async () => {
88
+ // ` ```mermaid compact ` used to opt out of a framed wrapper that no
89
+ // longer exists. The flag stays unrecognised by the pipeline and must
90
+ // render identically to a bare ` ```mermaid ` fence — otherwise the 52
91
+ // historical `compact` blocks in `content/` would regress.
92
+ const bare = await renderAsync(
93
+ MarkdownRenderer({ content: ['```mermaid', 'graph TD; A-->B;', '```'].join('\n') }),
94
+ );
95
+ const withCompact = await renderAsync(
96
+ MarkdownRenderer({ content: ['```mermaid compact', 'graph TD; A-->B;', '```'].join('\n') }),
97
+ );
98
+
99
+ expect(findMermaidWrapperClass(withCompact)).toBe(findMermaidWrapperClass(bare));
100
+ });
101
+
102
+ test('unknown language renders as plaintext (warn-and-degrade)', async () => {
103
+ // Production deploys can't fail on a single unknown fence — render as
104
+ // plaintext and emit a build-time warn instead. Three previous failures
105
+ // (make, golang, plus the alias overlay) demonstrated that strict-build
106
+ // at the fence-language layer was the wrong trade-off.
107
+ const content = ['```fakelang', 'should still render', '```'].join('\n');
108
+ const html = await renderAsync(MarkdownRenderer({ content }));
109
+
110
+ expect(html).toContain('class="shiki');
111
+ expect(html).toContain('should still render');
112
+ // Should NOT throw.
113
+ });
114
+
115
+ test('explicit `plaintext` fences render unhighlighted without erroring', async () => {
116
+ const content = ['```plaintext', 'just prose', '```'].join('\n');
117
+ const html = await renderAsync(MarkdownRenderer({ content }));
118
+
119
+ expect(html).toContain('class="shiki');
120
+ expect(html).toContain('just prose');
121
+ });
122
+
123
+ test('previously-unregistered Shiki languages (make, dockerfile, etc.) lazy-load on demand', async () => {
124
+ // Regression: production build broke when a real post used ```make. The fix
125
+ // resolves any of Shiki's ~235 bundled languages via its own metadata; the lang
126
+ // is loaded the first time it's seen, no hand-maintained allowlist required.
127
+ const content = ['```make', 'all:', '\t@echo "Building..."', '\tgcc -o app main.c', '```'].join('\n');
128
+ const html = await renderAsync(MarkdownRenderer({ content }));
129
+
130
+ expect(html).toContain('class="shiki');
131
+ // Header label uses Shiki's proper-case name from bundledLanguagesInfo.
132
+ expect(html).toContain('>Makefile<');
133
+ // Source lines survive and get token coloring.
134
+ expect(html).toContain('all');
135
+ expect(html).toContain('gcc');
136
+ });
137
+
138
+ test('community-alias `golang` resolves to Go (regression: production build)', async () => {
139
+ // Shiki does NOT list `golang` as an alias of `go` in its bundledLanguagesInfo,
140
+ // so a fence using ```golang would throw before the COMMUNITY_ALIASES overlay
141
+ // was added. The overlay maps it to the bundled `go` grammar.
142
+ const content = ['```golang', 'package main', '', 'func main() {', '\tprintln("hi")', '}', '```'].join('\n');
143
+ const html = await renderAsync(MarkdownRenderer({ content }));
144
+
145
+ expect(html).toContain('class="shiki');
146
+ expect(html).toContain('>Go<');
147
+ expect(html).toContain('package');
148
+ expect(html).toContain('main');
149
+ });
150
+ });
151
+
152
+ describe('rST', () => {
153
+ test('rST :linenos:, :emphasize-lines:, :caption: render through Shiki', async () => {
154
+ const content = [
155
+ 'Section',
156
+ '-------',
157
+ '',
158
+ '.. code-block:: python',
159
+ ' :linenos:',
160
+ ' :emphasize-lines: 1,3-4',
161
+ ' :caption: app.py',
162
+ '',
163
+ ' def fib(n):',
164
+ ' if n < 2:',
165
+ ' return n',
166
+ ' return fib(n - 1) + fib(n - 2)',
167
+ ].join('\n');
168
+
169
+ const html = await renderAsync(RstRenderer({ content }));
170
+
171
+ expect(html).toContain('class="shiki');
172
+ expect(html).toContain('data-line-numbers="true"');
173
+ expect(html).toContain('data-highlighted-line="1"');
174
+ expect(html).toContain('data-highlighted-line="3"');
175
+ expect(html).toContain('data-highlighted-line="4"');
176
+ // :caption: surfaces as title bar text in the wrapper header.
177
+ expect(html).toContain('app.py');
178
+ });
179
+
180
+ test('rST :: literal block renders as plaintext through Shiki', async () => {
181
+ const content = ['Section', '-------', '', 'Example::', '', ' plain literal'].join('\n');
182
+ const html = await renderAsync(RstRenderer({ content }));
183
+
184
+ expect(html).toContain('class="shiki');
185
+ expect(html).toContain('plain literal');
186
+ });
187
+ });
188
+ });