@hutusi/amytis 1.15.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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CLAUDE.md +90 -219
  3. package/bun.lock +185 -547
  4. package/content/books/sample-book/index.mdx +3 -0
  5. package/content/posts/code-block-features-showcase.mdx +223 -0
  6. package/docs/ALERTS.md +112 -0
  7. package/docs/ARCHITECTURE.md +217 -5
  8. package/docs/CODE-BLOCKS.md +238 -0
  9. package/docs/CONTRIBUTING.md +25 -0
  10. package/docs/guides/README.md +11 -0
  11. package/docs/guides/importing-vuepress-books.md +178 -0
  12. package/eslint.config.mjs +18 -6
  13. package/package.json +42 -20
  14. package/scripts/generate-code-group-icons.ts +79 -0
  15. package/scripts/render-rst.py +207 -3
  16. package/scripts/sync-vuepress-book.ts +499 -0
  17. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  18. package/src/app/books/[slug]/page.tsx +67 -32
  19. package/src/app/globals.css +503 -123
  20. package/src/app/page.tsx +1 -1
  21. package/src/app/sitemap.ts +3 -3
  22. package/src/components/ArticleCopyCleaner.tsx +64 -0
  23. package/src/components/BookMobileNav.tsx +44 -50
  24. package/src/components/BookSidebar.tsx +0 -0
  25. package/src/components/CodeBlock.test.tsx +93 -8
  26. package/src/components/CodeBlock.tsx +39 -101
  27. package/src/components/CodeBlockToolbar.tsx +88 -0
  28. package/src/components/CodeGroup.tsx +81 -0
  29. package/src/components/CoverImage.tsx +1 -0
  30. package/src/components/ExternalLinkIcon.tsx +15 -0
  31. package/src/components/FeaturedStoriesSection.tsx +3 -3
  32. package/src/components/GithubAlert.tsx +97 -0
  33. package/src/components/MarkdownRenderer.test.tsx +14 -4
  34. package/src/components/MarkdownRenderer.tsx +144 -23
  35. package/src/components/Mermaid.tsx +32 -1
  36. package/src/components/PostList.tsx +1 -1
  37. package/src/components/PostNavigation.tsx +13 -2
  38. package/src/components/PostSidebar.tsx +13 -2
  39. package/src/components/RstRenderer.test.tsx +15 -15
  40. package/src/components/RstRenderer.tsx +37 -2
  41. package/src/components/Search.tsx +18 -4
  42. package/src/components/SeriesCatalog.tsx +1 -1
  43. package/src/components/ShareBar.tsx +5 -0
  44. package/src/components/TocPanel.tsx +10 -2
  45. package/src/i18n/translations.ts +2 -0
  46. package/src/layouts/BookLayout.tsx +35 -4
  47. package/src/layouts/PostLayout.tsx +5 -1
  48. package/src/lib/code-group-icons.test.ts +78 -0
  49. package/src/lib/code-group-icons.ts +148 -0
  50. package/src/lib/markdown.test.ts +56 -13
  51. package/src/lib/markdown.ts +203 -50
  52. package/src/lib/normalize-vuepress-math.ts +118 -0
  53. package/src/lib/rehype-fence-meta.ts +22 -0
  54. package/src/lib/remark-book-chapter-links.ts +106 -0
  55. package/src/lib/remark-code-group.ts +54 -0
  56. package/src/lib/remark-github-alerts.test.ts +83 -0
  57. package/src/lib/remark-github-alerts.ts +65 -0
  58. package/src/lib/remark-vuepress-containers.ts +130 -0
  59. package/src/lib/rst-renderer.ts +19 -7
  60. package/src/lib/rst.test.ts +212 -2
  61. package/src/lib/rst.ts +217 -13
  62. package/src/lib/shiki-rst.ts +185 -0
  63. package/src/lib/shiki.test.ts +153 -0
  64. package/src/lib/shiki.ts +292 -0
  65. package/src/lib/urls.ts +57 -0
  66. package/src/test-utils/render.ts +23 -0
  67. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  68. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  69. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  70. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  71. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  72. package/tests/helpers/env.ts +19 -0
  73. package/tests/integration/book-chapter-links.test.ts +107 -0
  74. package/tests/integration/books-nested-toc.test.ts +176 -0
  75. package/tests/integration/books.test.ts +3 -2
  76. package/tests/integration/code-block-features.test.ts +188 -0
  77. package/tests/integration/code-group.test.ts +183 -0
  78. package/tests/integration/code-notation.test.ts +97 -0
  79. package/tests/integration/github-alerts.test.ts +82 -0
  80. package/tests/integration/markdown-external-links.test.ts +103 -0
  81. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  82. package/tests/integration/reading-time-headings.test.ts +8 -6
  83. package/tests/integration/series-draft.test.ts +6 -13
  84. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  85. package/tests/integration/vuepress-containers.test.ts +107 -0
  86. package/tests/tooling/new-post.test.ts +1 -1
  87. package/tests/unit/static-params.test.ts +32 -19
@@ -0,0 +1,183 @@
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 Group Tabs', () => {
7
+ describe('Markdown / MDX :::code-group', () => {
8
+ test('three-tab npm/yarn/bun group renders the radio + label + panel structure', async () => {
9
+ const content = [
10
+ ':::code-group',
11
+ '```bash [npm]',
12
+ 'npm install foo',
13
+ '```',
14
+ '```bash [yarn]',
15
+ 'yarn add foo',
16
+ '```',
17
+ '```bash [bun]',
18
+ 'bun add foo',
19
+ '```',
20
+ ':::',
21
+ ].join('\n');
22
+
23
+ const html = await renderAsync(MarkdownRenderer({ content }));
24
+
25
+ expect(html).toContain('class="code-group');
26
+ expect((html.match(/type="radio"/g) || []).length).toBe(3);
27
+ expect((html.match(/class="cg-panel"/g) || []).length).toBe(3);
28
+ // First tab checked by default.
29
+ expect(html).toMatch(/data-idx="0"[^>]*checked/);
30
+ expect(html).not.toMatch(/data-idx="1"[^>]*checked/);
31
+ // All three labels surfaced.
32
+ expect(html).toContain('>npm<');
33
+ expect(html).toContain('>yarn<');
34
+ expect(html).toContain('>bun<');
35
+ // Resolver-driven tab icons: each label gets a data-cg-icon attribute on
36
+ // the <label> element. The CSS in globals.css paints the matching icon.
37
+ expect(html).toContain('data-cg-icon="npm"');
38
+ expect(html).toContain('data-cg-icon="yarn"');
39
+ expect(html).toContain('data-cg-icon="bun"');
40
+ });
41
+
42
+ test('missing [label] falls back to the language name', async () => {
43
+ const content = [
44
+ ':::code-group',
45
+ '```bash',
46
+ 'echo bare',
47
+ '```',
48
+ '```python',
49
+ 'print("bare")',
50
+ '```',
51
+ ':::',
52
+ ].join('\n');
53
+
54
+ const html = await renderAsync(MarkdownRenderer({ content }));
55
+
56
+ expect(html).toContain('>bash<');
57
+ expect(html).toContain('>python<');
58
+ });
59
+
60
+ test('inner blocks still go through Shiki', async () => {
61
+ const content = [
62
+ ':::code-group',
63
+ '```ts [TS]',
64
+ 'export const x: number = 1;',
65
+ '```',
66
+ '```python [Py]',
67
+ 'def f(): return 1',
68
+ '```',
69
+ ':::',
70
+ ].join('\n');
71
+
72
+ const html = await renderAsync(MarkdownRenderer({ content }));
73
+
74
+ // Both panels should contain a Shiki-highlighted <pre>.
75
+ expect((html.match(/class="shiki/g) || []).length).toBe(2);
76
+ });
77
+ });
78
+
79
+ describe('rST .. code-group:: (fallback parser path)', () => {
80
+ test('rST code-group with :label: per inner block renders the same structure', async () => {
81
+ const content = [
82
+ 'Heading',
83
+ '=======',
84
+ '',
85
+ '.. code-group::',
86
+ '',
87
+ ' .. code-block:: bash',
88
+ ' :label: npm',
89
+ '',
90
+ ' npm install foo',
91
+ '',
92
+ ' .. code-block:: bash',
93
+ ' :label: yarn',
94
+ '',
95
+ ' yarn add foo',
96
+ ].join('\n');
97
+
98
+ const html = await renderAsync(RstRenderer({ content }));
99
+
100
+ expect(html).toContain('class="code-group');
101
+ expect((html.match(/type="radio"/g) || []).length).toBe(2);
102
+ expect((html.match(/class="cg-panel"/g) || []).length).toBe(2);
103
+ expect(html).toContain('>npm<');
104
+ expect(html).toContain('>yarn<');
105
+ });
106
+
107
+ test('sanitize-html keeps the radio + label markup intact on the html-path', async () => {
108
+ // Simulate what the Python rST renderer would emit: a div data-amytis-code-group
109
+ // wrapper around <pre data-amytis-code> marker children. The applyShikiToRstHtml
110
+ // pass then expands it into the tab structure, and sanitize-html must keep the
111
+ // <input type=radio> and <label for=...> markup intact.
112
+ const wrapperHtml =
113
+ '<div data-amytis-code-group="" data-labels="[&quot;npm&quot;,&quot;yarn&quot;]" data-group-id="rst-test1">' +
114
+ '<pre data-amytis-code="" data-language="bash"><code>npm install foo</code></pre>' +
115
+ '<pre data-amytis-code="" data-language="bash"><code>yarn add foo</code></pre>' +
116
+ '</div>';
117
+
118
+ const html = await renderAsync(RstRenderer({ content: 'unused', html: wrapperHtml }));
119
+
120
+ expect(html).toContain('class="code-group');
121
+ expect(html).toContain('type="radio"');
122
+ expect((html.match(/type="radio"/g) || []).length).toBe(2);
123
+ expect(html).toContain('for="cg-rst-test1-0"');
124
+ expect(html).toContain('>npm<');
125
+ expect(html).toContain('>yarn<');
126
+ });
127
+
128
+ test('two adjacent code-groups with identical labels keep independent radio groups', async () => {
129
+ // Regression test for the coderabbit-flagged collision: two groups with the
130
+ // same tab labels used to get the same data-group-id (hashed from labels),
131
+ // which coupled their radio buttons. Each group must have its own name="".
132
+ const wrapperHtml =
133
+ '<div data-amytis-code-group="" data-labels="[&quot;npm&quot;,&quot;yarn&quot;]" data-group-id="rst-1">' +
134
+ '<pre data-amytis-code="" data-language="bash"><code>npm install a</code></pre>' +
135
+ '<pre data-amytis-code="" data-language="bash"><code>yarn add a</code></pre>' +
136
+ '</div>' +
137
+ '<div data-amytis-code-group="" data-labels="[&quot;npm&quot;,&quot;yarn&quot;]" data-group-id="rst-2">' +
138
+ '<pre data-amytis-code="" data-language="bash"><code>npm install b</code></pre>' +
139
+ '<pre data-amytis-code="" data-language="bash"><code>yarn add b</code></pre>' +
140
+ '</div>';
141
+
142
+ const html = await renderAsync(RstRenderer({ content: 'unused', html: wrapperHtml }));
143
+
144
+ // Each group's radios must use a distinct `name` attribute so checking a tab
145
+ // in one doesn't deselect a tab in the other.
146
+ expect(html).toContain('name="cg-rst-1"');
147
+ expect(html).toContain('name="cg-rst-2"');
148
+ // 4 radios total (2 per group), 4 panels total.
149
+ expect((html.match(/type="radio"/g) || []).length).toBe(4);
150
+ expect((html.match(/class="cg-panel"/g) || []).length).toBe(4);
151
+ });
152
+
153
+ test('rST tab labels get a data-cg-icon attribute that survives sanitize-html', async () => {
154
+ // The label allowlist in RstRenderer.tsx must permit `data-cg-icon` or the
155
+ // icons silently disappear on the rST path while MDX works. Synthesize the
156
+ // rST html-path input directly so we exercise applyShikiToRstHtml + sanitize.
157
+ const wrapperHtml =
158
+ '<div data-amytis-code-group="" data-labels="[&quot;npm&quot;,&quot;yarn&quot;,&quot;mystery&quot;]" data-group-id="rst-icon">' +
159
+ '<pre data-amytis-code="" data-language="bash"><code>npm i</code></pre>' +
160
+ '<pre data-amytis-code="" data-language="bash"><code>yarn add</code></pre>' +
161
+ '<pre data-amytis-code="" data-language="bash"><code>echo</code></pre>' +
162
+ '</div>';
163
+
164
+ const html = await renderAsync(RstRenderer({ content: 'unused', html: wrapperHtml }));
165
+
166
+ expect(html).toContain('data-cg-icon="npm"');
167
+ expect(html).toContain('data-cg-icon="yarn"');
168
+ // No icon resolves for `mystery`; the corresponding label has no attribute.
169
+ expect(html).not.toContain('data-cg-icon="mystery"');
170
+ });
171
+
172
+ test('non-radio inputs are downgraded by transformTags', async () => {
173
+ // If an rST author tries to inject a non-radio input through raw HTML, the
174
+ // transformTags rule should strip the tagName down to <span>.
175
+ const malicious = '<p>before</p><input type="password" name="x"><p>after</p>';
176
+ const html = await renderAsync(RstRenderer({ content: 'unused', html: malicious }));
177
+
178
+ expect(html).not.toContain('<input');
179
+ expect(html).toContain('before');
180
+ expect(html).toContain('after');
181
+ });
182
+ });
183
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import MarkdownRenderer from '@/components/MarkdownRenderer';
3
+ import { renderAsync } from '@/test-utils/render';
4
+
5
+ describe('Integration: Shiki Notation Comments', () => {
6
+ test('// [!code focus] dims non-focused lines via .has-focused on <pre> and .focused on the line', async () => {
7
+ const content = [
8
+ '```ts',
9
+ 'const a = 1',
10
+ 'const b = 2 // [!code focus]',
11
+ 'const c = 3',
12
+ '```',
13
+ ].join('\n');
14
+
15
+ const html = await renderAsync(MarkdownRenderer({ content }));
16
+
17
+ expect(html).toContain('has-focused');
18
+ expect(html).toMatch(/class="line focused"/);
19
+ });
20
+
21
+ test('// [!code error] and // [!code warning] add .line.error / .line.warning classes', async () => {
22
+ const content = [
23
+ '```ts',
24
+ 'failHere() // [!code error]',
25
+ 'warnHere() // [!code warning]',
26
+ 'okHere()',
27
+ '```',
28
+ ].join('\n');
29
+
30
+ const html = await renderAsync(MarkdownRenderer({ content }));
31
+
32
+ // transformerNotationErrorLevel adds `highlighted` plus the error/warning class.
33
+ expect(html).toMatch(/class="line[^"]*\berror\b/);
34
+ expect(html).toMatch(/class="line[^"]*\bwarning\b/);
35
+ });
36
+
37
+ test('// [!code highlight] adds .line.highlighted (same class as {1,3-5} fence meta)', async () => {
38
+ const content = ['```ts', 'pickMe() // [!code highlight]', 'ignoreMe()', '```'].join('\n');
39
+ const html = await renderAsync(MarkdownRenderer({ content }));
40
+
41
+ expect(html).toMatch(/class="line[^"]*\bhighlighted\b/);
42
+ });
43
+
44
+ test('// [!code ++] / [!code --] add .diff.add / .diff.remove (same classes as raw diff fences)', async () => {
45
+ const content = [
46
+ '```ts',
47
+ 'const old = 1 // [!code --]',
48
+ 'const next = 2 // [!code ++]',
49
+ '```',
50
+ ].join('\n');
51
+
52
+ const html = await renderAsync(MarkdownRenderer({ content }));
53
+
54
+ expect(html).toContain('diff add');
55
+ expect(html).toContain('diff remove');
56
+ });
57
+
58
+ test('notation diff and raw diff (in a ```diff fence) coexist without conflict', async () => {
59
+ // Two blocks in the same render. The raw-diff transformer fires for the
60
+ // ```diff fence's +/- lines; the notation transformer fires for the
61
+ // [!code ++/--] comments in the ts fence. Same class names; no clash.
62
+ const content = [
63
+ '```diff',
64
+ '-old line',
65
+ '+new line',
66
+ '```',
67
+ '',
68
+ '```ts',
69
+ 'const x = 1 // [!code --]',
70
+ 'const y = 2 // [!code ++]',
71
+ '```',
72
+ ].join('\n');
73
+
74
+ const html = await renderAsync(MarkdownRenderer({ content }));
75
+
76
+ // Two blocks × one add + one remove each = at least 2 of each.
77
+ const addCount = (html.match(/diff add/g) || []).length;
78
+ const removeCount = (html.match(/diff remove/g) || []).length;
79
+ expect(addCount).toBeGreaterThanOrEqual(2);
80
+ expect(removeCount).toBeGreaterThanOrEqual(2);
81
+ });
82
+
83
+ test('Python `# [!code focus]` style comment works for non-C languages', async () => {
84
+ const content = [
85
+ '```python',
86
+ 'def fib(n):',
87
+ ' if n < 2: return n # [!code focus]',
88
+ ' return fib(n-1) + fib(n-2)',
89
+ '```',
90
+ ].join('\n');
91
+
92
+ const html = await renderAsync(MarkdownRenderer({ content }));
93
+
94
+ expect(html).toContain('has-focused');
95
+ expect(html).toMatch(/class="line focused"/);
96
+ });
97
+ });
@@ -0,0 +1,82 @@
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: GitHub-flavored Alerts', () => {
7
+ const types = [
8
+ { marker: 'NOTE', cssClass: 'alert-note', label: 'Note' },
9
+ { marker: 'TIP', cssClass: 'alert-tip', label: 'Tip' },
10
+ { marker: 'IMPORTANT', cssClass: 'alert-important', label: 'Important' },
11
+ { marker: 'WARNING', cssClass: 'alert-warning', label: 'Warning' },
12
+ { marker: 'CAUTION', cssClass: 'alert-caution', label: 'Caution' },
13
+ ];
14
+
15
+ test.each(types)('renders [!$marker] as <aside class="alert $cssClass"> with the $label title', async ({ marker, cssClass, label }) => {
16
+ const content = `> [!${marker}]\n> body content for ${marker.toLowerCase()}`;
17
+ const html = await renderAsync(MarkdownRenderer({ content }));
18
+
19
+ expect(html).toContain(`class="alert ${cssClass}"`);
20
+ expect(html).toContain(`>${label}<`);
21
+ expect(html).toContain(`body content for ${marker.toLowerCase()}`);
22
+ });
23
+
24
+ test('plain blockquote without a marker stays as <blockquote>', async () => {
25
+ const html = await renderAsync(
26
+ MarkdownRenderer({ content: '> just a plain quote\n> over two lines' }),
27
+ );
28
+
29
+ expect(html).toContain('<blockquote');
30
+ expect(html).not.toContain('class="alert');
31
+ });
32
+
33
+ test('unknown [!UNKNOWN] type passes through as a plain blockquote (no transform)', async () => {
34
+ const html = await renderAsync(
35
+ MarkdownRenderer({ content: '> [!UNKNOWN]\n> body' }),
36
+ );
37
+
38
+ expect(html).toContain('<blockquote');
39
+ expect(html).not.toContain('class="alert');
40
+ // The literal marker should still be visible since we didn't transform.
41
+ expect(html).toContain('[!UNKNOWN]');
42
+ });
43
+
44
+ test('alert body keeps markdown formatting (links, code, lists)', async () => {
45
+ const content = [
46
+ '> [!TIP]',
47
+ '> Read the [docs](https://example.com) and run `bun test`.',
48
+ '>',
49
+ '> - first item',
50
+ '> - second item',
51
+ ].join('\n');
52
+
53
+ const html = await renderAsync(MarkdownRenderer({ content }));
54
+
55
+ expect(html).toContain('class="alert alert-tip"');
56
+ expect(html).toContain('href="https://example.com"');
57
+ expect(html).toContain('<code');
58
+ expect(html).toContain('<ul');
59
+ });
60
+
61
+ test('rST .. note:: produces a docutils admonition that inherits the same alert color palette via CSS', async () => {
62
+ // rST goes through rstToMarkdown → MarkdownRenderer on systems without docutils.
63
+ // The resulting blockquote is NOT a GitHub-alert marker (it's the docutils
64
+ // "Note" admonition path), but the CSS rules in globals.css color the
65
+ // .rst-rendered aside.admonition-note element with the same --alert-accent
66
+ // variable as the MDX .alert-note. This test exercises the fallback path
67
+ // (no Python docutils available locally) — it should at least render the
68
+ // body content somewhere.
69
+ const content = [
70
+ 'Heading',
71
+ '=======',
72
+ '',
73
+ '.. note::',
74
+ '',
75
+ ' rst-side note content',
76
+ ].join('\n');
77
+
78
+ const html = await renderAsync(RstRenderer({ content }));
79
+
80
+ expect(html).toContain('rst-side note content');
81
+ });
82
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import MarkdownRenderer from '@/components/MarkdownRenderer';
3
+ import { renderAsync } from '@/test-utils/render';
4
+ import { isExternalUrl } from '@/lib/urls';
5
+ import { siteConfig } from '../../site.config';
6
+
7
+ describe('Unit: isExternalUrl', () => {
8
+ test('absolute http(s) to a different host is external', () => {
9
+ expect(isExternalUrl('https://en.wikipedia.org/wiki/Feature_engineering')).toBe(true);
10
+ expect(isExternalUrl('http://example.com/')).toBe(true);
11
+ });
12
+
13
+ test('absolute URL pointing back at siteConfig.baseUrl is internal', () => {
14
+ const u = new URL(siteConfig.baseUrl);
15
+ expect(isExternalUrl(`${u.origin}/posts/foo`)).toBe(false);
16
+ });
17
+
18
+ test('protocol-relative URL is compared by host', () => {
19
+ expect(isExternalUrl('//en.wikipedia.org/wiki/Vector')).toBe(true);
20
+ const u = new URL(siteConfig.baseUrl);
21
+ expect(isExternalUrl(`//${u.host}/posts/foo`)).toBe(false);
22
+ });
23
+
24
+ test('protocol-relative with auth or port: classified by parsed host', () => {
25
+ // `//user:pass@host/...` — host is `host`, NOT `user:pass@host`. Substring
26
+ // splitting on `/` would have got this wrong.
27
+ expect(isExternalUrl('//user:pass@en.wikipedia.org/wiki/Vector')).toBe(true);
28
+ // `//host:8080/...` — port is part of host.
29
+ expect(isExternalUrl('//en.wikipedia.org:8080/wiki/Vector')).toBe(true);
30
+ // `//:80/path` — no host, URL parsing rejects it → false (non-external).
31
+ expect(isExternalUrl('//:80/path')).toBe(false);
32
+ });
33
+
34
+ test('relative paths, hash, and query are internal', () => {
35
+ expect(isExternalUrl('/posts/foo')).toBe(false);
36
+ expect(isExternalUrl('foo.md')).toBe(false);
37
+ expect(isExternalUrl('#section')).toBe(false);
38
+ expect(isExternalUrl('?tab=2')).toBe(false);
39
+ });
40
+
41
+ test('non-http schemes are not treated as external links', () => {
42
+ // Semantically external, but click semantics differ — no outward arrow.
43
+ expect(isExternalUrl('mailto:foo@bar.com')).toBe(false);
44
+ expect(isExternalUrl('tel:+1234')).toBe(false);
45
+ expect(isExternalUrl('ftp://example.com/')).toBe(false);
46
+ expect(isExternalUrl('javascript:void(0)')).toBe(false);
47
+ });
48
+
49
+ test('empty / nullish / malformed returns false', () => {
50
+ expect(isExternalUrl('')).toBe(false);
51
+ expect(isExternalUrl(undefined)).toBe(false);
52
+ expect(isExternalUrl(null)).toBe(false);
53
+ expect(isExternalUrl('http://[malformed')).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe('Integration: external-link icon in MarkdownRenderer', () => {
58
+ test('external link gets the LuArrowUpRight icon and target=_blank', async () => {
59
+ const content = 'See [Wikipedia](https://en.wikipedia.org/wiki/Vector) for details.';
60
+ const html = await renderAsync(MarkdownRenderer({ content }));
61
+ // react-icons/lu renders an inline <svg>; LuArrowUpRight has a distinctive path.
62
+ // We assert on stable surface: presence of an <svg> inside the anchor + new-tab attrs.
63
+ expect(html).toMatch(/<a [^>]*href="https:\/\/en\.wikipedia\.org\/wiki\/Vector"[^>]*target="_blank"/);
64
+ expect(html).toContain('rel="noopener noreferrer"');
65
+ // The icon is an inline svg appended directly after the link text inside the anchor.
66
+ expect(html).toMatch(/Wikipedia<svg/);
67
+ });
68
+
69
+ test('internal link gets no icon and no target=_blank', async () => {
70
+ const content = 'See [vectors](/books/dmla/maths/linear/vectors/) for details.';
71
+ const html = await renderAsync(MarkdownRenderer({ content }));
72
+ expect(html).toContain('href="/books/dmla/maths/linear/vectors/"');
73
+ expect(html).not.toMatch(/href="\/books\/dmla\/maths\/linear\/vectors\/"[^>]*target="_blank"/);
74
+ // No svg inside this specific anchor.
75
+ expect(html).not.toMatch(/vectors<\/a>[^<]*<svg/);
76
+ });
77
+
78
+ test('mailto link is not decorated', async () => {
79
+ const content = 'Email [me](mailto:foo@bar.com).';
80
+ const html = await renderAsync(MarkdownRenderer({ content }));
81
+ expect(html).toContain('href="mailto:foo@bar.com"');
82
+ expect(html).not.toMatch(/href="mailto:[^"]+"[^>]*target="_blank"/);
83
+ });
84
+
85
+ test('absolute URL to the site host stays internal', async () => {
86
+ const u = new URL(siteConfig.baseUrl);
87
+ const content = `See [home](${u.origin}/posts/foo).`;
88
+ const html = await renderAsync(MarkdownRenderer({ content }));
89
+ // Escape regex metacharacters in the interpolated origin — without this,
90
+ // dots in e.g. `amytis.vercel.app` would match any character, making the
91
+ // `not.toMatch` assertion too lax.
92
+ const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
+ expect(html).not.toMatch(new RegExp(`href="${escapeRegex(u.origin)}/posts/foo"[^>]*target="_blank"`));
94
+ });
95
+
96
+ test('image-as-link skips the icon (image already signals the destination)', async () => {
97
+ const content = '[![logo](/logo.png)](https://example.com/)';
98
+ const html = await renderAsync(MarkdownRenderer({ content }));
99
+ // Anchor is still external (target=_blank), but no icon appended after the <img>.
100
+ expect(html).toContain('target="_blank"');
101
+ expect(html).not.toMatch(/<img[^>]*>\s*<svg/);
102
+ });
103
+ });
@@ -0,0 +1,149 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { normalizeVuepressBlockMath } from "../../src/lib/normalize-vuepress-math";
3
+ import MarkdownRenderer from "@/components/MarkdownRenderer";
4
+ import { renderAsync } from "@/test-utils/render";
5
+
6
+ describe("Integration: normalizeVuepressBlockMath", () => {
7
+ test("splits an inline-style $$ opener+closer onto their own lines", () => {
8
+ const src = [
9
+ "$$ \\mathbf{A} = \\begin{bmatrix}",
10
+ "a & b \\\\",
11
+ "c & d",
12
+ "\\end{bmatrix} $$",
13
+ ].join("\n");
14
+ const out = normalizeVuepressBlockMath(src);
15
+ expect(out.split("\n")).toEqual([
16
+ "$$",
17
+ "\\mathbf{A} = \\begin{bmatrix}",
18
+ "a & b \\\\",
19
+ "c & d",
20
+ "\\end{bmatrix}",
21
+ "$$",
22
+ ]);
23
+ });
24
+
25
+ test("expands single-line $$ x $$ onto three lines so remark-math treats it as block", () => {
26
+ // micromark-extension-math requires `$$` on its own line; single-line
27
+ // collapses to inline (no katex-display, no centering, no block margin).
28
+ const src = "$$ x^2 + y^2 = 1 $$";
29
+ expect(normalizeVuepressBlockMath(src).split("\n")).toEqual([
30
+ "$$",
31
+ "x^2 + y^2 = 1",
32
+ "$$",
33
+ ]);
34
+ });
35
+
36
+ test("leaves degenerate empty $$$$ alone", () => {
37
+ expect(normalizeVuepressBlockMath("$$$$")).toBe("$$$$");
38
+ expect(normalizeVuepressBlockMath("$$ $$")).toBe("$$ $$");
39
+ });
40
+
41
+ test("preserves the opener indent when expanding a single-line block", () => {
42
+ const src = " $$ y = mx + b $$";
43
+ expect(normalizeVuepressBlockMath(src).split("\n")).toEqual([
44
+ " $$",
45
+ " y = mx + b",
46
+ " $$",
47
+ ]);
48
+ });
49
+
50
+ test("does not touch inline $...$ math", () => {
51
+ const src = "An equation: $x = 1$ in the middle of a paragraph.";
52
+ expect(normalizeVuepressBlockMath(src)).toBe(src);
53
+ });
54
+
55
+ test("is idempotent — already-normalized blocks pass through unchanged", () => {
56
+ const src = ["$$", "x = 1", "$$"].join("\n");
57
+ expect(normalizeVuepressBlockMath(src)).toBe(src);
58
+ expect(normalizeVuepressBlockMath(normalizeVuepressBlockMath(src))).toBe(src);
59
+ });
60
+
61
+ test("skips $$ inside fenced code blocks (doc examples)", () => {
62
+ const src = [
63
+ "Here is the source:",
64
+ "",
65
+ "```",
66
+ "$$ \\mathbf{A} = \\begin{bmatrix} a \\end{bmatrix} $$",
67
+ "```",
68
+ "",
69
+ "Real math follows:",
70
+ "",
71
+ "$$ y = mx + b $$",
72
+ ].join("\n");
73
+ const out = normalizeVuepressBlockMath(src);
74
+ // The code-block example is preserved verbatim — no split.
75
+ expect(out).toContain("$$ \\mathbf{A} = \\begin{bmatrix} a \\end{bmatrix} $$");
76
+ // The real single-line block math after the fence is expanded onto its
77
+ // own three lines so remark-math recognizes it as block.
78
+ expect(out).toContain("$$\ny = mx + b\n$$");
79
+ expect(out).not.toContain("$$ y = mx + b $$");
80
+ });
81
+
82
+ test("preserves opener indent on split lines for list-nested block math", () => {
83
+ // A 4-space-indented block inside a bullet item. Without indent
84
+ // preservation, the split body lines drop out of the list and the
85
+ // following inline math gets parsed as one big malformed math span.
86
+ const src = [
87
+ "- Item with embedded math:",
88
+ "",
89
+ " $$\\mathbf{A} = \\begin{bmatrix}",
90
+ " a & b",
91
+ " \\end{bmatrix}$$",
92
+ "",
93
+ "- Next item with inline math: $\\mathbf{X}$, comma here.",
94
+ ].join("\n");
95
+ const out = normalizeVuepressBlockMath(src);
96
+ // Synthetic opener line carries the original 4-space indent so it stays
97
+ // inside the list item.
98
+ expect(out).toContain(" $$\n \\mathbf{A}");
99
+ // Closer's `$$` likewise stays indented.
100
+ expect(out).toContain(" \\end{bmatrix}\n $$");
101
+ });
102
+
103
+ test("handles multiple block-math runs in the same source", () => {
104
+ const src = [
105
+ "$$ a = 1",
106
+ "b = 2 $$",
107
+ "",
108
+ "Some prose.",
109
+ "",
110
+ "$$ c = 3",
111
+ "d = 4 $$",
112
+ ].join("\n");
113
+ const out = normalizeVuepressBlockMath(src);
114
+ // Both runs split, prose preserved.
115
+ expect(out.split(/^\$\$$/m).length).toBeGreaterThanOrEqual(5);
116
+ expect(out).toContain("Some prose.");
117
+ });
118
+ });
119
+
120
+ describe("Integration: end-to-end LaTeX rendering for VuePress-style block math", () => {
121
+ test("a multi-line bmatrix block renders as a katex-display, not katex-error", async () => {
122
+ const html = await renderAsync(
123
+ MarkdownRenderer({
124
+ content: [
125
+ "$$ \\mathbf{A} = \\begin{bmatrix}",
126
+ "a & b \\\\",
127
+ "c & d",
128
+ "\\end{bmatrix} $$",
129
+ ].join("\n"),
130
+ latex: true,
131
+ }),
132
+ );
133
+ expect(html).toContain("katex-display");
134
+ expect(html).not.toContain("katex-error");
135
+ });
136
+
137
+ test("normalization only runs when latex is true (idempotent so this is a perf hint)", async () => {
138
+ // With latex disabled, the math fences are passed through unchanged
139
+ // to ReactMarkdown — same input the engine would have always seen.
140
+ // We just verify the page renders without crashing.
141
+ const html = await renderAsync(
142
+ MarkdownRenderer({
143
+ content: "$$ x $$",
144
+ latex: false,
145
+ }),
146
+ );
147
+ expect(html).toContain("$$"); // not turned into math because latex was off
148
+ });
149
+ });
@@ -2,22 +2,24 @@ import { describe, expect, test } from "bun:test";
2
2
  import { getAllPosts, getPostBySlug } from "../../src/lib/markdown";
3
3
 
4
4
  describe("Integration: Reading Time & Headings", () => {
5
- test("posts have readingTime matching expected format", () => {
5
+ test("posts have a positive whole-minute readingMinutes", () => {
6
6
  const posts = getAllPosts();
7
7
  expect(posts.length).toBeGreaterThan(0);
8
8
 
9
9
  posts.forEach((post) => {
10
- expect(post.readingTime).toMatch(/^\d+ min read$/);
10
+ expect(Number.isInteger(post.readingMinutes)).toBe(true);
11
+ expect(post.readingMinutes).toBeGreaterThanOrEqual(1);
11
12
  });
12
13
  });
13
14
 
14
- test("kitchen-sink post has readingTime in correct format", () => {
15
+ test("kitchen-sink post has a positive readingMinutes", () => {
15
16
  const post = getPostBySlug("kitchen-sink");
16
17
  if (!post) {
17
18
  console.warn("Skipping: kitchen-sink post not found");
18
19
  return;
19
20
  }
20
- expect(post.readingTime).toMatch(/^\d+ min read$/);
21
+ expect(Number.isInteger(post.readingMinutes)).toBe(true);
22
+ expect(post.readingMinutes).toBeGreaterThanOrEqual(1);
21
23
  });
22
24
 
23
25
  test("headings on real posts have correct structure", () => {
@@ -49,13 +51,13 @@ describe("Integration: Reading Time & Headings", () => {
49
51
  });
50
52
  });
51
53
 
52
- test("short posts have 1 min read", () => {
54
+ test("short posts have readingMinutes === 1 (floor)", () => {
53
55
  const shortPost = getPostBySlug("legacy-markdown");
54
56
  expect(shortPost).toBeDefined();
55
57
  if (!shortPost) {
56
58
  throw new Error("fixture 'legacy-markdown' not found");
57
59
  }
58
- expect(shortPost.readingTime).toBe("1 min read");
60
+ expect(shortPost.readingMinutes).toBe(1);
59
61
  });
60
62
 
61
63
  test("multilingual post has headings with correct IDs", () => {