@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,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
|
+
});
|
package/src/lib/shiki.ts
ADDED
|
@@ -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/shuffle.ts
CHANGED
|
@@ -10,6 +10,18 @@ export function shuffle<T>(array: T[]): T[] {
|
|
|
10
10
|
return shuffled;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* splitmix32 finalizer: decorrelates small consecutive integer seeds before
|
|
15
|
+
* they feed xorshift32. Without this, seeds like day-index 20608 / 20609 / 20610
|
|
16
|
+
* land on the same permutation for short arrays, making "daily rotation" invisible.
|
|
17
|
+
*/
|
|
18
|
+
function mixSeed(z: number): number {
|
|
19
|
+
z = (z + 0x9e3779b9) | 0;
|
|
20
|
+
z = Math.imul(z ^ (z >>> 16), 0x85ebca6b);
|
|
21
|
+
z = Math.imul(z ^ (z >>> 13), 0xc2b2ae35);
|
|
22
|
+
return (z ^ (z >>> 16)) >>> 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
/**
|
|
14
26
|
* Deterministic Fisher-Yates shuffle using a seeded xorshift32 PRNG.
|
|
15
27
|
* Produces the same order for the same seed on both server and client,
|
|
@@ -17,7 +29,9 @@ export function shuffle<T>(array: T[]): T[] {
|
|
|
17
29
|
*/
|
|
18
30
|
export function shuffleSeeded<T>(array: T[], seed: number): T[] {
|
|
19
31
|
const shuffled = [...array];
|
|
20
|
-
|
|
32
|
+
// splitmix32 is a bijection on 32-bit ints, so exactly one input maps to 0.
|
|
33
|
+
// Guard against that one input since xorshift32 locks at the all-zero state.
|
|
34
|
+
let s = mixSeed(seed || 1) || 1;
|
|
21
35
|
const rand = () => {
|
|
22
36
|
s ^= s << 13;
|
|
23
37
|
s ^= s >> 17;
|
package/src/lib/sort.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable date comparators that return 0 on ties so equal-date items preserve
|
|
3
|
+
* insertion order under V8's TimSort. Centralised here so every "newest-first"
|
|
4
|
+
* or "oldest-first" sort in the codebase uses the same antisymmetric comparator.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function byDateDesc<T extends { date: string }>(a: T, b: T): number {
|
|
8
|
+
if (a.date === b.date) return 0;
|
|
9
|
+
return a.date < b.date ? 1 : -1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function byDateAsc<T extends { date: string }>(a: T, b: T): number {
|
|
13
|
+
if (a.date === b.date) return 0;
|
|
14
|
+
return a.date > b.date ? 1 : -1;
|
|
15
|
+
}
|