@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,185 @@
1
+ import { unified } from 'unified';
2
+ import rehypeParse from 'rehype-parse';
3
+ import { visit } from 'unist-util-visit';
4
+ import { toHtml } from 'hast-util-to-html';
5
+ import type { Element, Root, RootContent } from 'hast';
6
+ import { expandLineRanges, highlightToHast } from './shiki';
7
+ import { resolveCodeGroupIcon } from './code-group-icons';
8
+
9
+ interface AmytisCodeMarker {
10
+ element: Element;
11
+ code: string;
12
+ language: string;
13
+ highlightLines: number[];
14
+ showLineNumbers: boolean;
15
+ title: string | undefined;
16
+ }
17
+
18
+ function getAttr(node: Element, name: string): string | undefined {
19
+ const value = node.properties?.[name];
20
+ if (value == null) return undefined;
21
+ if (Array.isArray(value)) return value.join(' ');
22
+ return String(value);
23
+ }
24
+
25
+ function isAmytisCodeMarker(node: Element): boolean {
26
+ return node.tagName === 'pre' && node.properties != null && 'dataAmytisCode' in node.properties;
27
+ }
28
+
29
+ function isAmytisCodeGroupMarker(node: Element): boolean {
30
+ return node.tagName === 'div' && node.properties != null && 'dataAmytisCodeGroup' in node.properties;
31
+ }
32
+
33
+ function extractCode(element: Element): string {
34
+ // The marker shape is <pre data-amytis-code><code>...</code></pre>
35
+ const codeChild = element.children.find(
36
+ (child): child is Element => child.type === 'element' && child.tagName === 'code',
37
+ );
38
+ if (!codeChild) return '';
39
+ let buffer = '';
40
+ for (const child of codeChild.children) {
41
+ if (child.type === 'text') buffer += child.value;
42
+ }
43
+ return buffer;
44
+ }
45
+
46
+ function readMarker(element: Element): AmytisCodeMarker {
47
+ const code = extractCode(element);
48
+ const language = getAttr(element, 'dataLanguage') ?? '';
49
+ const highlightLinesAttr = getAttr(element, 'dataHighlightLines');
50
+ const highlightLines = highlightLinesAttr ? expandLineRanges(highlightLinesAttr) : [];
51
+ const showLineNumbers = (getAttr(element, 'dataLineNumbers') ?? '').toLowerCase() === 'true';
52
+ const titleAttr = getAttr(element, 'dataTitle');
53
+ const title = titleAttr && titleAttr.trim().length > 0 ? titleAttr : undefined;
54
+
55
+ return { element, code, language, highlightLines, showLineNumbers, title };
56
+ }
57
+
58
+ /**
59
+ * Walks rendered rST HTML for opaque <pre data-amytis-code> markers (emitted by
60
+ * scripts/render-rst.py for every literal_block) and replaces each with Shiki's
61
+ * highlighted output. Same highlighter singleton + transformers as the MDX/Markdown
62
+ * path, so rST and Markdown code blocks render identically.
63
+ */
64
+ export async function applyShikiToRstHtml(html: string): Promise<string> {
65
+ const tree = unified()
66
+ .use(rehypeParse, { fragment: true })
67
+ .parse(html) as Root;
68
+
69
+ const markers: AmytisCodeMarker[] = [];
70
+ visit(tree, 'element', (node: Element) => {
71
+ if (isAmytisCodeMarker(node)) {
72
+ markers.push(readMarker(node));
73
+ }
74
+ });
75
+
76
+ if (markers.length === 0) {
77
+ return html;
78
+ }
79
+
80
+ const highlighted = new Map<Element, RootContent[]>();
81
+ for (const marker of markers) {
82
+ const hast = await highlightToHast(marker.code, marker.language, {
83
+ showLineNumbers: marker.showLineNumbers,
84
+ highlightLines: marker.highlightLines,
85
+ title: marker.title,
86
+ });
87
+ highlighted.set(marker.element, hast.children as RootContent[]);
88
+ }
89
+
90
+ visit(tree, 'element', (node: Element) => {
91
+ if (!isAmytisCodeMarker(node)) return;
92
+ const replacement = highlighted.get(node);
93
+ if (!replacement || replacement.length === 0) return;
94
+ // Promote the highlighted <pre> in place by mutating the marker into a wrapper.
95
+ // We replace the marker element's tagName/properties/children with the first
96
+ // (and only) <pre> from Shiki's output. rehype-parse strips DOCTYPEs etc.
97
+ const first = replacement[0];
98
+ if (first.type !== 'element') return;
99
+ node.tagName = first.tagName;
100
+ node.properties = first.properties ?? {};
101
+ node.children = first.children;
102
+ });
103
+
104
+ // Second pass: rewrite <div data-amytis-code-group> markers into the same
105
+ // radio+label tab structure that CodeGroup.tsx emits, so the existing
106
+ // .code-group CSS rules style both pipelines identically.
107
+ visit(tree, 'element', (node: Element) => {
108
+ if (!isAmytisCodeGroupMarker(node)) return;
109
+
110
+ const labelsAttr = getAttr(node, 'dataLabels') ?? '[]';
111
+ const groupId = getAttr(node, 'dataGroupId') ?? `cg-${Math.random().toString(36).slice(2, 8)}`;
112
+ let labels: string[];
113
+ try {
114
+ const parsed = JSON.parse(labelsAttr);
115
+ labels = Array.isArray(parsed) ? parsed.map(String) : [];
116
+ } catch {
117
+ labels = [];
118
+ }
119
+
120
+ // The marker's children are the already-highlighted <pre class="shiki"> blocks
121
+ // (from the first pass) — or fallback bare nodes if something went sideways.
122
+ const panels = node.children.filter(
123
+ (child): child is Element => child.type === 'element',
124
+ );
125
+ const tabs = labels.length > 0 ? labels : panels.map((_, i) => `Tab ${i + 1}`);
126
+
127
+ const radioInputs: Element[] = tabs.map((_, i) => ({
128
+ type: 'element',
129
+ tagName: 'input',
130
+ properties: {
131
+ type: 'radio',
132
+ name: `cg-${groupId}`,
133
+ id: `cg-${groupId}-${i}`,
134
+ 'data-idx': String(i),
135
+ ...(i === 0 ? { checked: true } : {}),
136
+ 'aria-controls': `cg-${groupId}-panel-${i}`,
137
+ tabIndex: i === 0 ? 0 : -1,
138
+ },
139
+ children: [],
140
+ }));
141
+
142
+ const tabLabels: Element[] = tabs.map((label, i) => {
143
+ const icon = resolveCodeGroupIcon(label);
144
+ return {
145
+ type: 'element' as const,
146
+ tagName: 'label',
147
+ properties: {
148
+ htmlFor: `cg-${groupId}-${i}`,
149
+ className: ['cg-tab'],
150
+ role: 'tab',
151
+ ...(icon ? { 'data-cg-icon': icon } : {}),
152
+ },
153
+ children: [{ type: 'text' as const, value: label }],
154
+ };
155
+ });
156
+
157
+ const tablist: Element = {
158
+ type: 'element',
159
+ tagName: 'div',
160
+ properties: { className: ['cg-tablist'], role: 'tablist' },
161
+ children: tabLabels,
162
+ };
163
+
164
+ const panelDivs: Element[] = panels.map((panel, i) => ({
165
+ type: 'element',
166
+ tagName: 'div',
167
+ properties: {
168
+ className: ['cg-panel'],
169
+ 'data-panel': String(i),
170
+ role: 'tabpanel',
171
+ id: `cg-${groupId}-panel-${i}`,
172
+ },
173
+ children: [panel],
174
+ }));
175
+
176
+ node.tagName = 'div';
177
+ node.properties = {
178
+ className: ['code-group', 'my-6'],
179
+ 'data-group-id': groupId,
180
+ };
181
+ node.children = [...radioInputs, tablist, ...panelDivs];
182
+ });
183
+
184
+ return toHtml(tree);
185
+ }
@@ -0,0 +1,153 @@
1
+ import { describe, expect, mock, spyOn, test } from 'bun:test';
2
+ import {
3
+ getLanguageDisplayName,
4
+ highlightToHast,
5
+ parseFenceMeta,
6
+ resetUnknownLangWarningsForTests,
7
+ } from './shiki';
8
+
9
+ describe('parseFenceMeta', () => {
10
+ test('returns empty object for empty input', () => {
11
+ expect(parseFenceMeta(undefined)).toEqual({});
12
+ expect(parseFenceMeta(null)).toEqual({});
13
+ expect(parseFenceMeta('')).toEqual({});
14
+ });
15
+
16
+ test('extracts title from title="..."', () => {
17
+ expect(parseFenceMeta('title="src/app.ts"').title).toBe('src/app.ts');
18
+ });
19
+
20
+ test('flags linenos', () => {
21
+ expect(parseFenceMeta('linenos').showLineNumbers).toBe(true);
22
+ });
23
+
24
+ test('expands {1,3-5} highlight ranges', () => {
25
+ expect(parseFenceMeta('{1,3-5}').highlightLines).toEqual([1, 3, 4, 5]);
26
+ });
27
+
28
+ test('extracts [label] at the start as tabLabel', () => {
29
+ expect(parseFenceMeta('[npm]').tabLabel).toBe('npm');
30
+ expect(parseFenceMeta(' [yarn] ').tabLabel).toBe('yarn');
31
+ });
32
+
33
+ test('does not confuse [label] with the {1,3-5} highlight syntax', () => {
34
+ // Square brackets and curly braces are distinct — different meta features.
35
+ const result = parseFenceMeta('[npm] {1,3-5}');
36
+ expect(result.tabLabel).toBe('npm');
37
+ expect(result.highlightLines).toEqual([1, 3, 4, 5]);
38
+ });
39
+
40
+ test('combines all meta fields in one fence', () => {
41
+ const result = parseFenceMeta('[npm] title="install.sh" linenos {1,3-5}');
42
+ expect(result.tabLabel).toBe('npm');
43
+ expect(result.title).toBe('install.sh');
44
+ expect(result.showLineNumbers).toBe(true);
45
+ expect(result.highlightLines).toEqual([1, 3, 4, 5]);
46
+ });
47
+
48
+ test('ignores [label] that is not at the start of the meta', () => {
49
+ // The convention is [label] FIRST. A bracket-token deeper into the meta is
50
+ // not interpreted as a label — keeps the grammar unambiguous.
51
+ expect(parseFenceMeta('linenos [late]').tabLabel).toBeUndefined();
52
+ });
53
+
54
+ test('whitespace-only [ ] does not leak an empty-string label', () => {
55
+ // `[ ]` would otherwise produce an empty string, which bypasses downstream
56
+ // `?? language` fallbacks (empty string isn't nullish). Result: blank tabs.
57
+ expect(parseFenceMeta('[ ]').tabLabel).toBeUndefined();
58
+ expect(parseFenceMeta('[]').tabLabel).toBeUndefined();
59
+ });
60
+ });
61
+
62
+ describe('getLanguageDisplayName', () => {
63
+ test('returns the proper-case brand form for a canonical language', () => {
64
+ expect(getLanguageDisplayName('typescript')).toBe('TypeScript');
65
+ expect(getLanguageDisplayName('python')).toBe('Python');
66
+ expect(getLanguageDisplayName('ocaml')).toBe('OCaml');
67
+ });
68
+
69
+ test('resolves aliases to the canonical display name', () => {
70
+ expect(getLanguageDisplayName('ts')).toBe('TypeScript');
71
+ expect(getLanguageDisplayName('js')).toBe('JavaScript');
72
+ expect(getLanguageDisplayName('py')).toBe('Python');
73
+ });
74
+
75
+ test('handles alias tokens with special characters', () => {
76
+ // `c++` is a Shiki alias that resolves to `cpp` → `C++` display.
77
+ expect(getLanguageDisplayName('c++')).toBe('C++');
78
+ });
79
+
80
+ test('falls back to the raw input for unrecognized languages', () => {
81
+ // Defensive — highlightToHast throws on unknown langs, so this branch is
82
+ // only reachable for callers that opt to render a label without highlighting.
83
+ expect(getLanguageDisplayName('totally-fake')).toBe('totally-fake');
84
+ });
85
+
86
+ test('handles plaintext aliases', () => {
87
+ expect(getLanguageDisplayName('plaintext')).toBe('Plain text');
88
+ expect(getLanguageDisplayName('text')).toBe('Plain text');
89
+ expect(getLanguageDisplayName('txt')).toBe('Plain text');
90
+ });
91
+
92
+ test('resolves previously-unregistered Shiki langs (regression: production build)', () => {
93
+ // Before the lazy-load refactor, `make` was rejected at build time because it
94
+ // wasn't in a hand-maintained SHIKI_LANGS list. Now resolved via Shiki's bundle.
95
+ expect(getLanguageDisplayName('make')).toBe('Makefile');
96
+ expect(getLanguageDisplayName('makefile')).toBe('Makefile');
97
+ expect(getLanguageDisplayName('dockerfile')).toBe('Dockerfile');
98
+ expect(getLanguageDisplayName('toml')).toBe('TOML');
99
+ expect(getLanguageDisplayName('kotlin')).toBe('Kotlin');
100
+ });
101
+
102
+ test('resolves community aliases Shiki does not ship natively (regression: golang)', () => {
103
+ // Shiki's bundledLanguagesInfo for `go` does not list `golang` as an alias,
104
+ // and similarly for several other community-written names. The
105
+ // COMMUNITY_ALIASES overlay in shiki.ts adds them.
106
+ expect(getLanguageDisplayName('golang')).toBe('Go');
107
+ expect(getLanguageDisplayName('node')).toBe('JavaScript');
108
+ expect(getLanguageDisplayName('nodejs')).toBe('JavaScript');
109
+ expect(getLanguageDisplayName('obj-c')).toBe('Objective-C');
110
+ expect(getLanguageDisplayName('gnumakefile')).toBe('Makefile');
111
+ expect(getLanguageDisplayName('bsdmakefile')).toBe('Makefile');
112
+ });
113
+ });
114
+
115
+ describe('highlightToHast strict-build behavior', () => {
116
+ test('lazy-loads any language in Shiki bundle (previously-unregistered: make)', async () => {
117
+ // Regression: the production build broke when a real post used ```make. Strict-build
118
+ // is supposed to fail typos, not legitimate bundled languages — this test locks in
119
+ // that any bundled lang works without prior registration.
120
+ const hast = await highlightToHast('all:\n\t@echo hi\n', 'make');
121
+ expect(hast.type).toBe('root');
122
+ // The highlighted output should contain at least one element.
123
+ expect(hast.children.length).toBeGreaterThan(0);
124
+ });
125
+
126
+ test('renders unknown languages as plaintext + emits a deduped warn', async () => {
127
+ // Warn-and-degrade: a typo'd or otherwise unknown fence language renders as
128
+ // plaintext (so the production build never fails on a single fence) and emits
129
+ // a one-line console.warn that authors running a clean local build can spot.
130
+ // The warning dedupes per-process so noisy content doesn't spam the log.
131
+ resetUnknownLangWarningsForTests();
132
+ const warn = spyOn(console, 'warn').mockImplementation(() => {});
133
+ try {
134
+ const hast1 = await highlightToHast('x = 1', 'totally-not-a-real-lang');
135
+ const hast2 = await highlightToHast('y = 2', 'totally-not-a-real-lang');
136
+ expect(hast1.type).toBe('root');
137
+ expect(hast2.type).toBe('root');
138
+ expect(warn).toHaveBeenCalledTimes(1);
139
+ expect(warn.mock.calls[0]?.[0]).toMatch(/\[shiki\] Unknown code-block language "totally-not-a-real-lang"/);
140
+ } finally {
141
+ warn.mockRestore();
142
+ mock.restore();
143
+ // Reset on the way out too so subsequent tests / re-runs don't inherit
144
+ // the dedup state populated by this test.
145
+ resetUnknownLangWarningsForTests();
146
+ }
147
+ });
148
+
149
+ test('empty fence language renders as plaintext without throwing', async () => {
150
+ const hast = await highlightToHast('plain content', '');
151
+ expect(hast.type).toBe('root');
152
+ });
153
+ });
@@ -0,0 +1,292 @@
1
+ import {
2
+ bundledLanguages,
3
+ bundledLanguagesInfo,
4
+ createHighlighter,
5
+ type BundledLanguage,
6
+ type Highlighter,
7
+ type ShikiTransformer,
8
+ } from 'shiki';
9
+ import {
10
+ transformerNotationDiff,
11
+ transformerNotationErrorLevel,
12
+ transformerNotationFocus,
13
+ transformerNotationHighlight,
14
+ } from '@shikijs/transformers';
15
+ import type { Root } from 'hast';
16
+
17
+ export const SHIKI_THEMES = { light: 'github-light', dark: 'github-dark' } as const;
18
+
19
+ // Discovery from Shiki's own metadata, not a hand-maintained list. `bundledLanguagesInfo`
20
+ // gives us every canonical id, its proper-case display name, and the aliases Shiki natively
21
+ // understands (e.g. ts/cts/mts → typescript). Building ALIAS_MAP and DISPLAY_MAP once at
22
+ // module init lets us resolve any of Shiki's ~235 bundled languages without curating a list.
23
+ const ALIAS_MAP: Record<string, string> = {};
24
+ const DISPLAY_MAP: Record<string, string> = {};
25
+ for (const info of bundledLanguagesInfo) {
26
+ ALIAS_MAP[info.id] = info.id;
27
+ DISPLAY_MAP[info.id] = info.name ?? info.id;
28
+ for (const alias of info.aliases ?? []) {
29
+ ALIAS_MAP[alias] = info.id;
30
+ }
31
+ }
32
+
33
+ // Amytis-specific overlay. `plaintext` is Shiki's built-in "special" language (always
34
+ // available without a grammar load), but Shiki doesn't list it in bundledLanguagesInfo,
35
+ // so we register it explicitly. The empty-string fence (```\n...\n```) maps to plaintext
36
+ // too. svg/plain/text/txt are our own conventional aliases.
37
+ const PLAINTEXT_DISPLAY = 'Plain text';
38
+ ALIAS_MAP['plaintext'] = 'plaintext';
39
+ ALIAS_MAP['text'] = 'plaintext';
40
+ ALIAS_MAP['txt'] = 'plaintext';
41
+ ALIAS_MAP['plain'] = 'plaintext';
42
+ ALIAS_MAP[''] = 'plaintext';
43
+ ALIAS_MAP['svg'] = ALIAS_MAP['xml'] ?? 'xml';
44
+ DISPLAY_MAP['plaintext'] = PLAINTEXT_DISPLAY;
45
+
46
+ // Community aliases Shiki doesn't ship as official aliases in bundledLanguagesInfo.
47
+ // Each entry maps a name authors commonly write to a verified-bundled canonical id.
48
+ // Slow-growing list — Shiki's official aliases cover most cases. Extend here when a
49
+ // build hits an unknown-lang throw for a real community-name alias (not a typo).
50
+ const COMMUNITY_ALIASES: Record<string, string> = {
51
+ golang: 'go', // `go` ships without `golang` in Shiki's aliases
52
+ node: 'javascript', // node code snippets routinely written as ```node
53
+ nodejs: 'javascript',
54
+ 'obj-c': 'objective-c', // Shiki ships `objc` and `objective-c` but not `obj-c`
55
+ gnumakefile: 'make', // GNU/BSD-disambiguated makefile names
56
+ bsdmakefile: 'make',
57
+ };
58
+ for (const [alias, canonical] of Object.entries(COMMUNITY_ALIASES)) {
59
+ ALIAS_MAP[alias] = canonical;
60
+ }
61
+
62
+ function resolveCanonical(language: string): string | null {
63
+ const key = (language || '').toLowerCase();
64
+ return ALIAS_MAP[key] ?? null;
65
+ }
66
+
67
+ export function getLanguageDisplayName(language: string): string {
68
+ const canonical = resolveCanonical(language);
69
+ if (canonical && DISPLAY_MAP[canonical]) return DISPLAY_MAP[canonical];
70
+ // Defensive — highlightToHast throws on unknown langs, so this branch
71
+ // shouldn't trigger at render time. Returns the raw input for safety.
72
+ return language;
73
+ }
74
+
75
+ declare global {
76
+ var __amytisShikiHighlighter: Promise<Highlighter> | undefined;
77
+ }
78
+
79
+ export function getHighlighter(): Promise<Highlighter> {
80
+ if (!globalThis.__amytisShikiHighlighter) {
81
+ // Preload only `plaintext` — Shiki's special always-available lang. Every other
82
+ // bundled grammar is loaded lazily on first use via ensureLanguageLoaded below.
83
+ // Drastically smaller cold-start than eagerly loading 20+ grammars.
84
+ globalThis.__amytisShikiHighlighter = createHighlighter({
85
+ themes: [SHIKI_THEMES.light, SHIKI_THEMES.dark],
86
+ langs: ['plaintext'],
87
+ });
88
+ }
89
+ return globalThis.__amytisShikiHighlighter;
90
+ }
91
+
92
+ export function resetHighlighterForTests(): void {
93
+ globalThis.__amytisShikiHighlighter = undefined;
94
+ }
95
+
96
+ async function ensureLanguageLoaded(highlighter: Highlighter, canonical: string): Promise<void> {
97
+ if (canonical === 'plaintext') return; // Shiki's special lang — always available.
98
+ if (highlighter.getLoadedLanguages().includes(canonical)) return;
99
+ const loader = bundledLanguages[canonical as BundledLanguage];
100
+ if (!loader) return; // Defensive — shouldn't happen since resolveCanonical gates entry.
101
+ await highlighter.loadLanguage(loader);
102
+ }
103
+
104
+ export interface ParsedFenceMeta {
105
+ title?: string;
106
+ showLineNumbers?: boolean;
107
+ highlightLines?: number[];
108
+ tabLabel?: string;
109
+ raw?: string;
110
+ }
111
+
112
+ export function parseFenceMeta(meta: string | undefined | null): ParsedFenceMeta {
113
+ if (!meta) return {};
114
+ const result: ParsedFenceMeta = { raw: meta };
115
+
116
+ // Docusaurus-style [label] at the start of the meta — used by tabbed code groups
117
+ // to name each tab. Stays harmlessly attached to non-grouped blocks too. Square
118
+ // brackets are unambiguous against the curly-brace {1,3-5} highlight syntax.
119
+ const labelMatch = meta.match(/^\s*\[([^\]]+)\]/);
120
+ if (labelMatch) {
121
+ // Trim and discard if empty — `[ ]` would otherwise leak an empty-string
122
+ // label that bypasses downstream `?? language` fallbacks (empty isn't nullish).
123
+ const label = labelMatch[1].trim();
124
+ if (label) result.tabLabel = label;
125
+ }
126
+
127
+ const titleMatch = meta.match(/title=(?:"([^"]*)"|'([^']*)')/);
128
+ if (titleMatch) result.title = titleMatch[1] ?? titleMatch[2] ?? '';
129
+
130
+ if (/(?:^|\s)(linenos|showLineNumbers)(?=\s|$)/.test(meta)) {
131
+ result.showLineNumbers = true;
132
+ }
133
+
134
+ const highlightMatch = meta.match(/\{([\d,\s-]+)\}/);
135
+ if (highlightMatch) {
136
+ const expanded = expandLineRanges(highlightMatch[1]);
137
+ if (expanded.length > 0) result.highlightLines = expanded;
138
+ }
139
+
140
+ return result;
141
+ }
142
+
143
+ export function expandLineRanges(spec: string): number[] {
144
+ const seen = new Set<number>();
145
+ for (const raw of spec.split(',')) {
146
+ const piece = raw.trim();
147
+ if (!piece) continue;
148
+ const range = piece.match(/^(\d+)\s*-\s*(\d+)$/);
149
+ if (range) {
150
+ const a = Number(range[1]);
151
+ const b = Number(range[2]);
152
+ const [lo, hi] = a <= b ? [a, b] : [b, a];
153
+ for (let i = lo; i <= hi; i++) seen.add(i);
154
+ } else if (/^\d+$/.test(piece)) {
155
+ seen.add(Number(piece));
156
+ }
157
+ }
158
+ return [...seen].sort((a, b) => a - b);
159
+ }
160
+
161
+ function ensureClassList(value: unknown): string {
162
+ if (typeof value === 'string') return value;
163
+ if (Array.isArray(value)) return value.filter((v) => typeof v === 'string').join(' ');
164
+ return '';
165
+ }
166
+
167
+ function addClass(node: { properties?: Record<string, unknown> }, cls: string): void {
168
+ node.properties = node.properties ?? {};
169
+ const existing = ensureClassList(node.properties.class);
170
+ node.properties.class = existing ? `${existing} ${cls}` : cls;
171
+ }
172
+
173
+ function setProperty(
174
+ node: { properties?: Record<string, unknown> },
175
+ key: string,
176
+ value: string,
177
+ ): void {
178
+ node.properties = node.properties ?? {};
179
+ node.properties[key] = value;
180
+ }
181
+
182
+ function transformerLineNumbers(enabled: boolean): ShikiTransformer {
183
+ return {
184
+ name: 'amytis:line-numbers',
185
+ pre(node) {
186
+ if (enabled) setProperty(node, 'data-line-numbers', 'true');
187
+ },
188
+ };
189
+ }
190
+
191
+ function transformerTitle(title?: string): ShikiTransformer {
192
+ return {
193
+ name: 'amytis:title',
194
+ pre(node) {
195
+ if (title) setProperty(node, 'data-title', title);
196
+ },
197
+ };
198
+ }
199
+
200
+ function transformerHighlightLines(lines: number[] | undefined): ShikiTransformer {
201
+ const set = new Set(lines ?? []);
202
+ return {
203
+ name: 'amytis:highlight-lines',
204
+ line(node, lineIdx) {
205
+ if (set.has(lineIdx)) {
206
+ addClass(node, 'highlighted');
207
+ setProperty(node, 'data-highlighted-line', String(lineIdx));
208
+ }
209
+ },
210
+ };
211
+ }
212
+
213
+ function transformerDiffBg(lang: string, source: string): ShikiTransformer {
214
+ if (lang !== 'diff') return { name: 'amytis:diff-bg-noop' };
215
+ const lines = source.split('\n');
216
+ return {
217
+ name: 'amytis:diff-bg',
218
+ line(node, lineIdx) {
219
+ const text = lines[lineIdx - 1] ?? '';
220
+ if (text.startsWith('+') && !text.startsWith('+++')) {
221
+ addClass(node, 'diff add');
222
+ } else if (text.startsWith('-') && !text.startsWith('---')) {
223
+ addClass(node, 'diff remove');
224
+ }
225
+ },
226
+ };
227
+ }
228
+
229
+ export interface HighlightOpts {
230
+ showLineNumbers?: boolean;
231
+ highlightLines?: number[];
232
+ title?: string;
233
+ }
234
+
235
+ // Module-level dedup so we don't spam build logs when the same unknown lang
236
+ // appears in many posts. Reset between processes; not a leak across builds.
237
+ const warnedUnknownLangs = new Set<string>();
238
+
239
+ export function resetUnknownLangWarningsForTests(): void {
240
+ warnedUnknownLangs.clear();
241
+ }
242
+
243
+ export async function highlightToHast(
244
+ code: string,
245
+ language: string,
246
+ opts: HighlightOpts = {},
247
+ ): Promise<Root> {
248
+ const canonical = resolveCanonical(language);
249
+ // Soft fallback for unknown fence languages: render as plaintext + warn (deduped).
250
+ // The CLAUDE.md "strict build over silent runtime failure" principle is correct
251
+ // for structural misconfiguration (frontmatter, slugs, redirects), but wrong here:
252
+ // "typo" and "community alias" look identical from our side, and Shiki's alias
253
+ // coverage is narrower than what blog authors routinely write. Failing a whole
254
+ // production deploy because one fence used `\`\`\`golang` is worse than the block
255
+ // rendering as monochrome monospace with a build-time warn. Authors running a
256
+ // clean local build still see real typos in the warn output.
257
+ if (!canonical && language && !warnedUnknownLangs.has(language)) {
258
+ warnedUnknownLangs.add(language);
259
+ console.warn(
260
+ `[shiki] Unknown code-block language "${language}" — rendering as plaintext. To fix highlighting, add an entry to COMMUNITY_ALIASES in src/lib/shiki.ts, or use a recognized name. Use \`plaintext\` explicitly to silence this warning.`,
261
+ );
262
+ }
263
+
264
+ const lang = canonical ?? 'plaintext';
265
+ const highlighter = await getHighlighter();
266
+ await ensureLanguageLoaded(highlighter, lang);
267
+
268
+ return highlighter.codeToHast(code, {
269
+ lang,
270
+ themes: SHIKI_THEMES,
271
+ defaultColor: false,
272
+ transformers: [
273
+ transformerLineNumbers(!!opts.showLineNumbers),
274
+ transformerTitle(opts.title),
275
+ transformerHighlightLines(opts.highlightLines),
276
+ transformerDiffBg(lang, code),
277
+ // VitePress-style notation comments inside the source:
278
+ // // [!code focus] dim/blur non-focused lines (hover to reveal)
279
+ // // [!code error] red line tinting
280
+ // // [!code warning] amber line tinting
281
+ // // [!code highlight] same .highlighted class as the meta {1,3-5} syntax
282
+ // // [!code ++] / [!code --] same .diff.add / .diff.remove classes as the
283
+ // raw +/- transformer in diff fences; they coexist.
284
+ // The class names emitted (.focused/.error/.warning/.highlighted/.diff.add/.diff.remove)
285
+ // are styled by globals.css alongside our existing rules.
286
+ transformerNotationFocus({ classActivePre: 'has-focused' }),
287
+ transformerNotationErrorLevel(),
288
+ transformerNotationHighlight(),
289
+ transformerNotationDiff(),
290
+ ],
291
+ });
292
+ }
package/src/lib/urls.ts CHANGED
@@ -105,3 +105,60 @@ export function getStaticPageUrl(slug: string): string {
105
105
  export function getPostUrlInCollection(post: { slug: string; series?: string }, collectionSlug: string): string {
106
106
  return `${getPostUrl(post)}?${new URLSearchParams({ collection: collectionSlug }).toString()}`;
107
107
  }
108
+
109
+ let cachedSiteHost: string | null | undefined;
110
+
111
+ function getSiteHost(): string | null {
112
+ if (cachedSiteHost !== undefined) return cachedSiteHost;
113
+ try {
114
+ cachedSiteHost = new URL(siteConfig.baseUrl).host;
115
+ } catch {
116
+ cachedSiteHost = null;
117
+ }
118
+ return cachedSiteHost;
119
+ }
120
+
121
+ /**
122
+ * True when `href` points to a different host than `siteConfig.baseUrl`.
123
+ *
124
+ * - `http://` / `https://` absolute URLs and protocol-relative `//host/...`
125
+ * are tested by host comparison.
126
+ * - `mailto:`, `tel:`, `sms:`, `ftp:`, `javascript:` and other non-http
127
+ * schemes return false — they're "external" in spirit but have different
128
+ * click semantics and don't warrant an outward-arrow indicator.
129
+ * - Hash-only (`#foo`), query-only (`?foo`), relative paths (`/foo`,
130
+ * `foo.md`), empty strings → false.
131
+ * - Malformed URLs → false (defensive — don't decorate something we can't parse).
132
+ */
133
+ export function isExternalUrl(href: string | undefined | null): boolean {
134
+ if (!href) return false;
135
+ if (href.startsWith('#') || href.startsWith('?')) return false;
136
+ if (/^(mailto|tel|sms|ftp|javascript):/i.test(href)) return false;
137
+
138
+ const siteHost = getSiteHost();
139
+ if (!siteHost) return false;
140
+
141
+ if (href.startsWith('//')) {
142
+ // Prefix a dummy scheme so the URL parser handles auth (`//user:pass@host`),
143
+ // port-only (`//:80`), and IPv6 forms correctly — substring splitting on
144
+ // `/` is too coarse for any of those.
145
+ try {
146
+ return new URL(`https:${href}`).host !== siteHost;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+ if (/^https?:\/\//i.test(href)) {
152
+ try {
153
+ return new URL(href).host !== siteHost;
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /** Test-only: drop the cached site host so a test can re-read `siteConfig.baseUrl`. */
162
+ export function resetSiteHostCacheForTests(): void {
163
+ cachedSiteHost = undefined;
164
+ }