@hutusi/amytis 1.15.0 → 1.17.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.
- package/.claude/rules/immersive-reading.md +21 -0
- package/.claude/rules/rst.md +13 -0
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +89 -219
- package/bun.lock +185 -547
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +298 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +237 -0
- package/eslint.config.mjs +18 -6
- package/package.json +42 -20
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/render-rst.py +207 -3
- package/scripts/sync-vuepress-book.ts +710 -0
- package/site.config.example.ts +3 -3
- package/site.config.ts +3 -3
- package/src/app/[slug]/layout.tsx +30 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +85 -34
- package/src/app/globals.css +570 -123
- package/src/app/page.tsx +7 -1
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookReadingShell.tsx +145 -0
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CodeBlock.test.tsx +93 -8
- package/src/components/CodeBlock.tsx +39 -101
- package/src/components/CodeBlockToolbar.tsx +88 -0
- package/src/components/CodeGroup.tsx +81 -0
- package/src/components/CoverImage.tsx +1 -0
- package/src/components/CuratedSeriesSection.tsx +28 -10
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +44 -23
- package/src/components/Footer.tsx +1 -1
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/ImmersiveReader.tsx +130 -0
- package/src/components/ImmersiveReaderTopBar.tsx +106 -0
- package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
- package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
- package/src/components/ImmersiveReadingProvider.tsx +168 -0
- package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
- package/src/components/ImmersiveToggleButton.tsx +45 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +175 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/ReadingProgressBar.tsx +1 -1
- package/src/components/RstRenderer.test.tsx +15 -15
- package/src/components/RstRenderer.tsx +37 -2
- package/src/components/Search.tsx +18 -4
- package/src/components/SelectedBooksSection.tsx +27 -8
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +44 -0
- package/src/layouts/BookLayout.tsx +62 -74
- package/src/layouts/PostLayout.tsx +154 -111
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +217 -57
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/remark-book-chapter-links.ts +106 -0
- package/src/lib/remark-code-group.ts +54 -0
- package/src/lib/remark-github-alerts.test.ts +83 -0
- package/src/lib/remark-github-alerts.ts +65 -0
- package/src/lib/remark-vuepress-containers.ts +130 -0
- package/src/lib/rst-renderer.ts +19 -7
- package/src/lib/rst.test.ts +212 -2
- package/src/lib/rst.ts +217 -13
- package/src/lib/scroll-utils.ts +44 -6
- package/src/lib/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +62 -0
- package/src/test-utils/render.ts +23 -0
- package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
- package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
- package/tests/helpers/env.ts +19 -0
- package/tests/integration/book-chapter-links.test.ts +107 -0
- package/tests/integration/book-index-cta.test.ts +87 -0
- package/tests/integration/books-nested-toc.test.ts +176 -0
- package/tests/integration/books.test.ts +3 -2
- package/tests/integration/code-block-features.test.ts +188 -0
- package/tests/integration/code-group.test.ts +183 -0
- package/tests/integration/code-notation.test.ts +97 -0
- package/tests/integration/github-alerts.test.ts +82 -0
- package/tests/integration/markdown-external-links.test.ts +103 -0
- package/tests/integration/normalize-vuepress-math.test.ts +149 -0
- package/tests/integration/reading-time-headings.test.ts +8 -6
- package/tests/integration/series-draft.test.ts +6 -13
- package/tests/integration/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +443 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/tests/unit/static-params.test.ts +32 -19
- package/vercel.json +7 -0
|
@@ -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
|
+
});
|
|
@@ -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="["npm","yarn"]" 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="["npm","yarn"]" 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="["npm","yarn"]" 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="["npm","yarn","mystery"]" 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 = '[](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
|
+
});
|