@hutusi/amytis 1.15.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/CLAUDE.md +90 -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 +217 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +178 -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 +499 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/page.tsx +67 -32
- package/src/app/globals.css +503 -123
- package/src/app/page.tsx +1 -1
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- 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/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +3 -3
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +144 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostSidebar.tsx +13 -2
- 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/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/i18n/translations.ts +2 -0
- package/src/layouts/BookLayout.tsx +35 -4
- package/src/layouts/PostLayout.tsx +5 -1
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +203 -50
- 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/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/urls.ts +57 -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/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/sync-vuepress-book.test.ts +240 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/static-params.test.ts +32 -19
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
2
|
import RstRenderer from './RstRenderer';
|
|
3
|
+
import { renderAsync } from '@/test-utils/render';
|
|
4
4
|
|
|
5
5
|
describe('RstRenderer', () => {
|
|
6
|
-
test('renders pre-rendered html when available', () => {
|
|
7
|
-
const html =
|
|
6
|
+
test('renders pre-rendered html when available', async () => {
|
|
7
|
+
const html = await renderAsync(
|
|
8
8
|
<RstRenderer
|
|
9
9
|
content="Fallback body"
|
|
10
10
|
html={
|
|
@@ -28,8 +28,8 @@ describe('RstRenderer', () => {
|
|
|
28
28
|
expect(html).not.toContain('javascript:alert(4)');
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
test('blocks data urls on images', () => {
|
|
32
|
-
const html =
|
|
31
|
+
test('blocks data urls on images', async () => {
|
|
32
|
+
const html = await renderAsync(
|
|
33
33
|
<RstRenderer
|
|
34
34
|
content="Fallback body"
|
|
35
35
|
html={'<p><img src="data:image/svg+xml,<svg onload=alert(1)>" alt="Bad" /></p>'}
|
|
@@ -40,8 +40,8 @@ describe('RstRenderer', () => {
|
|
|
40
40
|
expect(html).not.toContain('data:image');
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
test('preserves MathML elements', () => {
|
|
44
|
-
const html =
|
|
43
|
+
test('preserves MathML elements', async () => {
|
|
44
|
+
const html = await renderAsync(
|
|
45
45
|
<RstRenderer
|
|
46
46
|
content="Fallback body"
|
|
47
47
|
html={'<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>2</mn></mrow></math>'}
|
|
@@ -53,8 +53,8 @@ describe('RstRenderer', () => {
|
|
|
53
53
|
expect(html).toContain('<mi>x</mi>');
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
test('wraps rendered rst tables with the same scroll container pattern as markdown', () => {
|
|
57
|
-
const html =
|
|
56
|
+
test('wraps rendered rst tables with the same scroll container pattern as markdown', async () => {
|
|
57
|
+
const html = await renderAsync(
|
|
58
58
|
<RstRenderer
|
|
59
59
|
content="Fallback body"
|
|
60
60
|
html={'<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>B</td></tr></tbody></table>'}
|
|
@@ -67,8 +67,8 @@ describe('RstRenderer', () => {
|
|
|
67
67
|
expect(html).toContain('<td>B</td>');
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
test('renders converted headings, links, and code blocks through the markdown renderer', () => {
|
|
71
|
-
const html =
|
|
70
|
+
test('renders converted headings, links, and code blocks through the markdown renderer', async () => {
|
|
71
|
+
const html = await renderAsync(
|
|
72
72
|
<RstRenderer
|
|
73
73
|
content={[
|
|
74
74
|
'Section',
|
|
@@ -85,9 +85,9 @@ describe('RstRenderer', () => {
|
|
|
85
85
|
|
|
86
86
|
expect(html).toContain('Section');
|
|
87
87
|
expect(html).toContain('https://example.com');
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
expect(html).toContain('
|
|
91
|
-
expect(html).toContain('
|
|
88
|
+
// Shiki produces a .shiki container with language-aware token spans, not Prism's
|
|
89
|
+
// legacy class="language-ts" + token markup. Assert the new highlighter ran.
|
|
90
|
+
expect(html).toContain('class="shiki');
|
|
91
|
+
expect(html).toContain('export');
|
|
92
92
|
});
|
|
93
93
|
});
|
|
@@ -2,6 +2,7 @@ import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
|
2
2
|
import KatexStyles from '@/components/KatexStyles';
|
|
3
3
|
import type { SlugRegistryEntry } from '@/lib/markdown';
|
|
4
4
|
import { rstToMarkdown } from '@/lib/rst';
|
|
5
|
+
import { applyShikiToRstHtml } from '@/lib/shiki-rst';
|
|
5
6
|
import sanitizeHtml from 'sanitize-html';
|
|
6
7
|
|
|
7
8
|
interface RstRendererProps {
|
|
@@ -30,6 +31,11 @@ const allowedTags = [
|
|
|
30
31
|
'figure',
|
|
31
32
|
'figcaption',
|
|
32
33
|
'aside',
|
|
34
|
+
// Tabbed code groups (CSS-only via radio + label). Without these on the
|
|
35
|
+
// allowlist, the rST path drops to stacked code blocks with no tabs.
|
|
36
|
+
// transformTags below restricts `input` to type="radio" only.
|
|
37
|
+
'input',
|
|
38
|
+
'label',
|
|
33
39
|
'math',
|
|
34
40
|
'annotation',
|
|
35
41
|
'annotation-xml',
|
|
@@ -64,6 +70,13 @@ const allowedTags = [
|
|
|
64
70
|
'semantics',
|
|
65
71
|
];
|
|
66
72
|
|
|
73
|
+
// Shiki emits inline `style="--shiki-light:#...; --shiki-dark:#..."` CSS vars on
|
|
74
|
+
// every token <span> when running in dual-theme mode, plus our custom transformers
|
|
75
|
+
// add `data-language`, `data-line-numbers`, `data-highlighted-line`, and `data-title`
|
|
76
|
+
// to <pre>/<span>. Stripping any of these silently kills syntax highlighting in rST
|
|
77
|
+
// output while leaving Markdown unaffected — covered by RstRenderer.test.tsx.
|
|
78
|
+
const codeBlockAttrs = ['style', 'data-language', 'data-line', 'data-line-numbers', 'data-highlighted-line', 'data-title', 'tabindex'];
|
|
79
|
+
|
|
67
80
|
const allowedAttributes: sanitizeHtml.IOptions['allowedAttributes'] = {
|
|
68
81
|
...sanitizeHtml.defaults.allowedAttributes,
|
|
69
82
|
'*': ['id', 'class', 'title', 'lang', 'dir', 'role', 'aria-label', 'aria-hidden'],
|
|
@@ -77,6 +90,15 @@ const allowedAttributes: sanitizeHtml.IOptions['allowedAttributes'] = {
|
|
|
77
90
|
math: ['display', 'xmlns'],
|
|
78
91
|
annotation: ['encoding'],
|
|
79
92
|
'annotation-xml': ['encoding'],
|
|
93
|
+
pre: ['class', 'style', ...codeBlockAttrs],
|
|
94
|
+
code: ['class', 'style', ...codeBlockAttrs],
|
|
95
|
+
span: ['class', 'style', ...codeBlockAttrs],
|
|
96
|
+
div: ['class', 'style', 'data-group-id', 'data-panel', ...codeBlockAttrs],
|
|
97
|
+
// Tabbed code groups: input is restricted to type=radio via transformTags.
|
|
98
|
+
// Defense-in-depth: even if an unexpected attr slips in, the CSS-only tab
|
|
99
|
+
// mechanism can't do anything dangerous with a stray radio button.
|
|
100
|
+
input: ['type', 'name', 'id', 'checked', 'data-idx', 'aria-controls', 'tabindex', 'class'],
|
|
101
|
+
label: ['for', 'class', 'role', 'aria-controls', 'tabindex', 'data-cg-icon'],
|
|
80
102
|
};
|
|
81
103
|
|
|
82
104
|
function sanitizeRenderedHtml(html: string): string {
|
|
@@ -88,12 +110,25 @@ function sanitizeRenderedHtml(html: string): string {
|
|
|
88
110
|
img: ['http', 'https'],
|
|
89
111
|
},
|
|
90
112
|
allowProtocolRelative: false,
|
|
113
|
+
transformTags: {
|
|
114
|
+
// Restrict <input> to type="radio" only. Anything else gets stripped.
|
|
115
|
+
// Prevents an rST author from injecting password/file/etc. inputs.
|
|
116
|
+
input: (tagName, attribs) => {
|
|
117
|
+
if (attribs.type !== 'radio') {
|
|
118
|
+
return { tagName: 'span', attribs: {} };
|
|
119
|
+
}
|
|
120
|
+
return { tagName, attribs };
|
|
121
|
+
},
|
|
122
|
+
},
|
|
91
123
|
});
|
|
92
124
|
}
|
|
93
125
|
|
|
94
|
-
export default function RstRenderer({ content, html, latex = false, slug, slugRegistry }: RstRendererProps) {
|
|
126
|
+
export default async function RstRenderer({ content, html, latex = false, slug, slugRegistry }: RstRendererProps) {
|
|
95
127
|
if (html) {
|
|
96
|
-
|
|
128
|
+
// The docutils pass emits opaque <pre data-amytis-code> markers; run them through
|
|
129
|
+
// Shiki here (server-side, build-time for SSG) before sanitizing.
|
|
130
|
+
const highlighted = await applyShikiToRstHtml(html);
|
|
131
|
+
const sanitizedHtml = sanitizeRenderedHtml(highlighted).replace(
|
|
97
132
|
/<table\b([^>]*)>/g,
|
|
98
133
|
'<div class="rst-table-wrapper"><table$1>'
|
|
99
134
|
).replace(/<\/table>/g, '</table></div>');
|
|
@@ -130,8 +130,11 @@ export default function Search() {
|
|
|
130
130
|
// True while debounce is pending — suppress "no results" flash
|
|
131
131
|
const isTyping = query.length > 0 && query !== debouncedQuery;
|
|
132
132
|
|
|
133
|
-
// Load recent searches on mount
|
|
133
|
+
// Load recent searches on mount. localStorage is unavailable during SSR,
|
|
134
|
+
// so this can't be hoisted into useState's initializer without breaking
|
|
135
|
+
// hydration — the mount-only effect is the documented React pattern.
|
|
134
136
|
useEffect(() => {
|
|
137
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
135
138
|
setRecentSearches(loadRecentSearches());
|
|
136
139
|
}, []);
|
|
137
140
|
|
|
@@ -142,16 +145,22 @@ export default function Search() {
|
|
|
142
145
|
}
|
|
143
146
|
}, [isOpen]);
|
|
144
147
|
|
|
145
|
-
// Debounce query
|
|
148
|
+
// Debounce query. The sync reset when `query` is empty is intentional:
|
|
149
|
+
// skipping it would leave stale results visible for DEBOUNCE_MS after the
|
|
150
|
+
// user clears the input.
|
|
146
151
|
useEffect(() => {
|
|
152
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
147
153
|
if (!query) { setDebouncedQuery(''); return; }
|
|
148
154
|
const timer = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS);
|
|
149
155
|
return () => clearTimeout(timer);
|
|
150
156
|
}, [query]);
|
|
151
157
|
|
|
152
|
-
// Run Pagefind search on debounced query
|
|
158
|
+
// Run Pagefind search on debounced query. Synchronous resets when the
|
|
159
|
+
// query becomes empty are the simplest way to clear results state without
|
|
160
|
+
// threading conditional renders through every consumer of allResults.
|
|
153
161
|
useEffect(() => {
|
|
154
162
|
if (!debouncedQuery) {
|
|
163
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
155
164
|
setAllResults([]);
|
|
156
165
|
setActiveIndex(-1);
|
|
157
166
|
setActiveType('All');
|
|
@@ -214,17 +223,22 @@ export default function Search() {
|
|
|
214
223
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
215
224
|
}, []);
|
|
216
225
|
|
|
217
|
-
// Focus on open; full reset on close
|
|
226
|
+
// Focus on open; full reset on close. The 6 resets are batched into a
|
|
227
|
+
// single React render — the rule's "cascading renders" warning doesn't
|
|
228
|
+
// apply when state changes are batched as siblings, only when one update
|
|
229
|
+
// triggers the next.
|
|
218
230
|
useEffect(() => {
|
|
219
231
|
if (isOpen) {
|
|
220
232
|
setTimeout(() => inputRef.current?.focus(), 100);
|
|
221
233
|
} else {
|
|
234
|
+
/* eslint-disable react-hooks/set-state-in-effect */
|
|
222
235
|
setQuery('');
|
|
223
236
|
setDebouncedQuery('');
|
|
224
237
|
setAllResults([]);
|
|
225
238
|
setActiveIndex(-1);
|
|
226
239
|
setActiveType('All');
|
|
227
240
|
setIsFetching(false);
|
|
241
|
+
/* eslint-enable react-hooks/set-state-in-effect */
|
|
228
242
|
}
|
|
229
243
|
}, [isOpen]);
|
|
230
244
|
|
|
@@ -68,7 +68,7 @@ export default function SeriesCatalog({ posts, startIndex = 0, totalPosts, colle
|
|
|
68
68
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted mb-3">
|
|
69
69
|
<span>{post.date}</span>
|
|
70
70
|
<span className="hidden sm:inline">•</span>
|
|
71
|
-
<span className="text-accent/80">{post.
|
|
71
|
+
<span className="text-accent/80">{post.readingMinutes} {t('reading_time')}</span>
|
|
72
72
|
{post.category && (
|
|
73
73
|
<>
|
|
74
74
|
<span className="hidden sm:inline">•</span>
|
|
@@ -77,6 +77,10 @@ export default function ShareBar({ url, title, className = '' }: ShareBarProps)
|
|
|
77
77
|
const btnClass = 'inline-flex items-center justify-center w-8 h-8 rounded text-muted hover:text-accent hover:bg-muted/10 transition-colors';
|
|
78
78
|
|
|
79
79
|
return (
|
|
80
|
+
// suppressHydrationWarning on locale-bound nodes is a band-aid for the
|
|
81
|
+
// known static-export + client-i18n drift: SSR renders defaultLocale,
|
|
82
|
+
// `useLanguage()` hook serves the user's saved locale on hydration. The
|
|
83
|
+
// real fix is per-locale URL routing, tracked as a separate refactor.
|
|
80
84
|
<div className={`flex flex-row flex-wrap gap-1 ${className}`}>
|
|
81
85
|
{platforms.map((platform) => {
|
|
82
86
|
const { label, Icon } = PLATFORM_META[platform];
|
|
@@ -90,6 +94,7 @@ export default function ShareBar({ url, title, className = '' }: ShareBarProps)
|
|
|
90
94
|
title={copyLabel}
|
|
91
95
|
aria-label={copyLabel}
|
|
92
96
|
className={`${btnClass} ${copied ? 'text-accent' : ''}`}
|
|
97
|
+
suppressHydrationWarning
|
|
93
98
|
>
|
|
94
99
|
{copied ? <LuCheck size={16} /> : <Icon size={16} />}
|
|
95
100
|
</button>
|
|
@@ -19,9 +19,16 @@ export default function TocPanel({ headings, className = '' }: TocPanelProps) {
|
|
|
19
19
|
if (headings.length === 0) return null;
|
|
20
20
|
|
|
21
21
|
return (
|
|
22
|
-
|
|
22
|
+
// suppressHydrationWarning on locale-bound nodes is a band-aid for the
|
|
23
|
+
// known static-export + client-i18n drift: SSR renders defaultLocale,
|
|
24
|
+
// `useLanguage()` hook serves the user's saved locale on hydration. The
|
|
25
|
+
// real fix is per-locale URL routing, tracked as a separate refactor.
|
|
26
|
+
<nav aria-label={t('on_this_page')} className={className} suppressHydrationWarning>
|
|
23
27
|
<div className="flex items-center justify-between mb-3">
|
|
24
|
-
<span
|
|
28
|
+
<span
|
|
29
|
+
className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted"
|
|
30
|
+
suppressHydrationWarning
|
|
31
|
+
>
|
|
25
32
|
{t('on_this_page')}
|
|
26
33
|
</span>
|
|
27
34
|
<button
|
|
@@ -30,6 +37,7 @@ export default function TocPanel({ headings, className = '' }: TocPanelProps) {
|
|
|
30
37
|
className="text-muted hover:text-foreground transition-colors"
|
|
31
38
|
aria-expanded={!collapsed}
|
|
32
39
|
aria-label={collapsed ? t('toc_expand') : t('toc_collapse')}
|
|
40
|
+
suppressHydrationWarning
|
|
33
41
|
>
|
|
34
42
|
<svg
|
|
35
43
|
className={`w-3.5 h-3.5 transition-transform duration-200 ${collapsed ? '' : 'rotate-180'}`}
|
package/src/i18n/translations.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const translations = {
|
|
|
14
14
|
view_archive: "View Archive",
|
|
15
15
|
written_by: "Written by",
|
|
16
16
|
reading_time: "min read",
|
|
17
|
+
words: "words",
|
|
17
18
|
next_page: "Next",
|
|
18
19
|
prev_page: "Prev",
|
|
19
20
|
back_to_home: "Back to Home",
|
|
@@ -160,6 +161,7 @@ export const translations = {
|
|
|
160
161
|
view_archive: "查看归档",
|
|
161
162
|
written_by: "作者",
|
|
162
163
|
reading_time: "分钟阅读",
|
|
164
|
+
words: "字",
|
|
163
165
|
next_page: "下一页",
|
|
164
166
|
prev_page: "上一页",
|
|
165
167
|
back_to_home: "返回首页",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { BookData, BookChapterData, getBookDirPath } from '@/lib/markdown';
|
|
2
3
|
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
3
4
|
import BookSidebar from '@/components/BookSidebar';
|
|
4
5
|
import BookMobileNav from '@/components/BookMobileNav';
|
|
@@ -16,6 +17,22 @@ interface BookLayoutProps {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export default function BookLayout({ book, chapter }: BookLayoutProps) {
|
|
20
|
+
const bookDir = getBookDirPath(book.slug);
|
|
21
|
+
const validChapterIds = new Set(book.chapters.map(c => c.id));
|
|
22
|
+
|
|
23
|
+
// `slug` is the public-relative directory used by rehype-image-metadata to
|
|
24
|
+
// resolve ``-style refs. For nested flat chapters
|
|
25
|
+
// (e.g. id `maths/linear/vectors`) the image's parent dir is the chapter's
|
|
26
|
+
// parent dir, not the book root — without this, all chapter images point
|
|
27
|
+
// at `/books/<slug>/assets/...` instead of `/books/<slug>/<dir>/assets/...`.
|
|
28
|
+
let imageSlug: string;
|
|
29
|
+
if (chapter.isFolder) {
|
|
30
|
+
imageSlug = `books/${book.slug}/${chapter.slug}`;
|
|
31
|
+
} else {
|
|
32
|
+
const parentDir = path.posix.dirname(chapter.slug);
|
|
33
|
+
imageSlug = parentDir === '.' ? `books/${book.slug}` : `books/${book.slug}/${parentDir}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
return (
|
|
20
37
|
<div className="layout-container lg:max-w-7xl">
|
|
21
38
|
<ReadingProgressBar />
|
|
@@ -50,14 +67,18 @@ export default function BookLayout({ book, chapter }: BookLayoutProps) {
|
|
|
50
67
|
{t('chapter')}
|
|
51
68
|
</span>
|
|
52
69
|
<span className="w-1 h-1 rounded-full bg-muted/30" />
|
|
53
|
-
<span className="font-mono">
|
|
70
|
+
<span className="font-mono">
|
|
71
|
+
{chapter.wordCount.toLocaleString()} {t('words')}
|
|
72
|
+
</span>
|
|
73
|
+
<span className="w-1 h-1 rounded-full bg-muted/30" />
|
|
74
|
+
<span className="font-mono text-muted/70">{chapter.readingMinutes} {t('reading_time')}</span>
|
|
54
75
|
</div>
|
|
55
76
|
|
|
56
77
|
<h1 className="text-3xl md:text-4xl font-serif font-bold text-heading leading-tight mb-4">
|
|
57
78
|
{chapter.title}
|
|
58
79
|
</h1>
|
|
59
80
|
|
|
60
|
-
{chapter.excerpt && (
|
|
81
|
+
{book.showChapterExcerpt && chapter.excerpt && (
|
|
61
82
|
<p className="text-lg text-muted font-serif italic leading-relaxed">
|
|
62
83
|
{chapter.excerpt}
|
|
63
84
|
</p>
|
|
@@ -65,7 +86,17 @@ export default function BookLayout({ book, chapter }: BookLayoutProps) {
|
|
|
65
86
|
</header>
|
|
66
87
|
|
|
67
88
|
{/* Content */}
|
|
68
|
-
<MarkdownRenderer
|
|
89
|
+
<MarkdownRenderer
|
|
90
|
+
content={chapter.content}
|
|
91
|
+
latex={chapter.latex}
|
|
92
|
+
slug={imageSlug}
|
|
93
|
+
bookContext={{
|
|
94
|
+
bookSlug: book.slug,
|
|
95
|
+
bookDir,
|
|
96
|
+
chapterSourcePath: chapter.sourcePath,
|
|
97
|
+
validChapterIds,
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
69
100
|
|
|
70
101
|
{/* Comments */}
|
|
71
102
|
{resolveCommentable(chapter.commentable, 'bookChapters') && (
|
|
@@ -85,7 +85,11 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
85
85
|
<span className="w-1 h-1 rounded-full bg-muted/30" />
|
|
86
86
|
<time className="font-mono" data-pagefind-meta="date[content]">{post.date}</time>
|
|
87
87
|
<span className="w-1 h-1 rounded-full bg-muted/30" />
|
|
88
|
-
<span className="font-mono">
|
|
88
|
+
<span className="font-mono">
|
|
89
|
+
{post.wordCount.toLocaleString()} {t('words')}
|
|
90
|
+
</span>
|
|
91
|
+
<span className="w-1 h-1 rounded-full bg-muted/30" />
|
|
92
|
+
<span className="font-mono text-muted/70">{post.readingMinutes} {t('reading_time')}</span>
|
|
89
93
|
</div>
|
|
90
94
|
|
|
91
95
|
<h1 className="text-4xl md:text-5xl font-serif font-bold text-heading leading-tight mb-4">
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { resolveCodeGroupIcon } from './code-group-icons';
|
|
3
|
+
|
|
4
|
+
describe('resolveCodeGroupIcon', () => {
|
|
5
|
+
test('exact-label matches for package managers', () => {
|
|
6
|
+
expect(resolveCodeGroupIcon('npm')).toBe('npm');
|
|
7
|
+
expect(resolveCodeGroupIcon('yarn')).toBe('yarn');
|
|
8
|
+
expect(resolveCodeGroupIcon('pnpm')).toBe('pnpm');
|
|
9
|
+
expect(resolveCodeGroupIcon('bun')).toBe('bun');
|
|
10
|
+
expect(resolveCodeGroupIcon('deno')).toBe('deno');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('exact-label matches are case-insensitive and trim whitespace', () => {
|
|
14
|
+
expect(resolveCodeGroupIcon('NPM')).toBe('npm');
|
|
15
|
+
expect(resolveCodeGroupIcon(' Yarn ')).toBe('yarn');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('exact-label matches for tools', () => {
|
|
19
|
+
expect(resolveCodeGroupIcon('docker')).toBe('docker');
|
|
20
|
+
expect(resolveCodeGroupIcon('vite')).toBe('vite');
|
|
21
|
+
expect(resolveCodeGroupIcon('next.js')).toBe('nextjs');
|
|
22
|
+
expect(resolveCodeGroupIcon('nodejs')).toBe('node');
|
|
23
|
+
expect(resolveCodeGroupIcon('tailwindcss')).toBe('tailwind');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('filename matches win over extension matches', () => {
|
|
27
|
+
// tsconfig.json maps to typescript via the filename table; otherwise
|
|
28
|
+
// its `.json` extension would route it to the json icon.
|
|
29
|
+
expect(resolveCodeGroupIcon('tsconfig.json')).toBe('typescript');
|
|
30
|
+
expect(resolveCodeGroupIcon('package.json')).toBe('node');
|
|
31
|
+
expect(resolveCodeGroupIcon('Dockerfile')).toBe('docker');
|
|
32
|
+
expect(resolveCodeGroupIcon('vite.config.ts')).toBe('vite');
|
|
33
|
+
expect(resolveCodeGroupIcon('next.config.mjs')).toBe('nextjs');
|
|
34
|
+
expect(resolveCodeGroupIcon('tailwind.config.js')).toBe('tailwind');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('filename match strips directory paths', () => {
|
|
38
|
+
expect(resolveCodeGroupIcon('src/app/Dockerfile')).toBe('docker');
|
|
39
|
+
expect(resolveCodeGroupIcon('apps/web/package.json')).toBe('node');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('extension match for arbitrary file paths', () => {
|
|
43
|
+
expect(resolveCodeGroupIcon('foo.ts')).toBe('typescript');
|
|
44
|
+
expect(resolveCodeGroupIcon('src/index.tsx')).toBe('typescript');
|
|
45
|
+
expect(resolveCodeGroupIcon('hello.py')).toBe('python');
|
|
46
|
+
expect(resolveCodeGroupIcon('main.rs')).toBe('rust');
|
|
47
|
+
expect(resolveCodeGroupIcon('config.yml')).toBe('yaml');
|
|
48
|
+
expect(resolveCodeGroupIcon('README.md')).toBe('markdown');
|
|
49
|
+
expect(resolveCodeGroupIcon('install.sh')).toBe('bash');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('language-name aliases resolve to a canonical icon key', () => {
|
|
53
|
+
expect(resolveCodeGroupIcon('TypeScript')).toBe('typescript');
|
|
54
|
+
expect(resolveCodeGroupIcon('ts')).toBe('typescript');
|
|
55
|
+
expect(resolveCodeGroupIcon('Python')).toBe('python');
|
|
56
|
+
expect(resolveCodeGroupIcon('Go')).toBe('go');
|
|
57
|
+
expect(resolveCodeGroupIcon('golang')).toBe('go');
|
|
58
|
+
expect(resolveCodeGroupIcon('c++')).toBe('cpp');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('returns null for labels that do not match any rule', () => {
|
|
62
|
+
expect(resolveCodeGroupIcon('mystery')).toBeNull();
|
|
63
|
+
expect(resolveCodeGroupIcon('totally-fake-name')).toBeNull();
|
|
64
|
+
expect(resolveCodeGroupIcon('')).toBeNull();
|
|
65
|
+
expect(resolveCodeGroupIcon(' ')).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('does not match Object.prototype keys via the `in` operator', () => {
|
|
69
|
+
// `'constructor' in {}` is true because of the prototype chain; using
|
|
70
|
+
// Object.hasOwn (instead of `in`) prevents the resolver from returning
|
|
71
|
+
// prototype values for crafted labels.
|
|
72
|
+
expect(resolveCodeGroupIcon('constructor')).toBeNull();
|
|
73
|
+
expect(resolveCodeGroupIcon('toString')).toBeNull();
|
|
74
|
+
expect(resolveCodeGroupIcon('hasOwnProperty')).toBeNull();
|
|
75
|
+
expect(resolveCodeGroupIcon('valueOf')).toBeNull();
|
|
76
|
+
expect(resolveCodeGroupIcon('__proto__')).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps a code-group tab label to an icon key. The key drives a CSS rule in
|
|
3
|
+
* globals.css that paints the icon via .cg-tab[data-cg-icon="<key>"]::before.
|
|
4
|
+
*
|
|
5
|
+
* Match cascade (first hit wins):
|
|
6
|
+
* 1. exact label match ("npm", "yarn", "vite", "deno")
|
|
7
|
+
* 2. filename match ("package.json", "vite.config.ts", "Dockerfile")
|
|
8
|
+
* 3. extension match ("foo.ts" → typescript, "x.yml" → yaml)
|
|
9
|
+
* 4. language alias ("ts" → typescript, "py" → python)
|
|
10
|
+
*
|
|
11
|
+
* Returns null when nothing matches — caller renders the tab without an icon.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const EXACT: Record<string, string> = {
|
|
15
|
+
npm: 'npm',
|
|
16
|
+
yarn: 'yarn',
|
|
17
|
+
pnpm: 'pnpm',
|
|
18
|
+
bun: 'bun',
|
|
19
|
+
deno: 'deno',
|
|
20
|
+
vite: 'vite',
|
|
21
|
+
docker: 'docker',
|
|
22
|
+
node: 'node',
|
|
23
|
+
nodejs: 'node',
|
|
24
|
+
'node.js': 'node',
|
|
25
|
+
react: 'react',
|
|
26
|
+
vue: 'vue',
|
|
27
|
+
nextjs: 'nextjs',
|
|
28
|
+
'next.js': 'nextjs',
|
|
29
|
+
next: 'nextjs',
|
|
30
|
+
tailwind: 'tailwind',
|
|
31
|
+
tailwindcss: 'tailwind',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const FILENAMES: Record<string, string> = {
|
|
35
|
+
'package.json': 'node',
|
|
36
|
+
'package-lock.json': 'npm',
|
|
37
|
+
'yarn.lock': 'yarn',
|
|
38
|
+
'pnpm-lock.yaml': 'pnpm',
|
|
39
|
+
'bun.lockb': 'bun',
|
|
40
|
+
'tsconfig.json': 'typescript',
|
|
41
|
+
'jsconfig.json': 'javascript',
|
|
42
|
+
dockerfile: 'docker',
|
|
43
|
+
'.dockerignore': 'docker',
|
|
44
|
+
'docker-compose.yml': 'docker',
|
|
45
|
+
'docker-compose.yaml': 'docker',
|
|
46
|
+
'vite.config.ts': 'vite',
|
|
47
|
+
'vite.config.js': 'vite',
|
|
48
|
+
'vite.config.mts': 'vite',
|
|
49
|
+
'vite.config.mjs': 'vite',
|
|
50
|
+
'next.config.ts': 'nextjs',
|
|
51
|
+
'next.config.js': 'nextjs',
|
|
52
|
+
'next.config.mjs': 'nextjs',
|
|
53
|
+
'tailwind.config.ts': 'tailwind',
|
|
54
|
+
'tailwind.config.js': 'tailwind',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const EXTENSIONS: Record<string, string> = {
|
|
58
|
+
ts: 'typescript',
|
|
59
|
+
tsx: 'typescript',
|
|
60
|
+
cts: 'typescript',
|
|
61
|
+
mts: 'typescript',
|
|
62
|
+
js: 'javascript',
|
|
63
|
+
jsx: 'javascript',
|
|
64
|
+
cjs: 'javascript',
|
|
65
|
+
mjs: 'javascript',
|
|
66
|
+
py: 'python',
|
|
67
|
+
rs: 'rust',
|
|
68
|
+
go: 'go',
|
|
69
|
+
java: 'java',
|
|
70
|
+
rb: 'ruby',
|
|
71
|
+
php: 'php',
|
|
72
|
+
c: 'c',
|
|
73
|
+
h: 'c',
|
|
74
|
+
cpp: 'cpp',
|
|
75
|
+
cc: 'cpp',
|
|
76
|
+
cxx: 'cpp',
|
|
77
|
+
hpp: 'cpp',
|
|
78
|
+
html: 'html',
|
|
79
|
+
htm: 'html',
|
|
80
|
+
css: 'css',
|
|
81
|
+
scss: 'css',
|
|
82
|
+
sass: 'css',
|
|
83
|
+
json: 'json',
|
|
84
|
+
yml: 'yaml',
|
|
85
|
+
yaml: 'yaml',
|
|
86
|
+
md: 'markdown',
|
|
87
|
+
mdx: 'markdown',
|
|
88
|
+
sh: 'bash',
|
|
89
|
+
bash: 'bash',
|
|
90
|
+
zsh: 'bash',
|
|
91
|
+
vue: 'vue',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const LANGUAGE_ALIASES: Record<string, string> = {
|
|
95
|
+
typescript: 'typescript',
|
|
96
|
+
ts: 'typescript',
|
|
97
|
+
javascript: 'javascript',
|
|
98
|
+
js: 'javascript',
|
|
99
|
+
python: 'python',
|
|
100
|
+
py: 'python',
|
|
101
|
+
rust: 'rust',
|
|
102
|
+
rs: 'rust',
|
|
103
|
+
go: 'go',
|
|
104
|
+
golang: 'go',
|
|
105
|
+
java: 'java',
|
|
106
|
+
ruby: 'ruby',
|
|
107
|
+
rb: 'ruby',
|
|
108
|
+
php: 'php',
|
|
109
|
+
c: 'c',
|
|
110
|
+
cpp: 'cpp',
|
|
111
|
+
'c++': 'cpp',
|
|
112
|
+
cxx: 'cpp',
|
|
113
|
+
html: 'html',
|
|
114
|
+
css: 'css',
|
|
115
|
+
json: 'json',
|
|
116
|
+
yaml: 'yaml',
|
|
117
|
+
yml: 'yaml',
|
|
118
|
+
markdown: 'markdown',
|
|
119
|
+
md: 'markdown',
|
|
120
|
+
bash: 'bash',
|
|
121
|
+
sh: 'bash',
|
|
122
|
+
shell: 'bash',
|
|
123
|
+
zsh: 'bash',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export function resolveCodeGroupIcon(label: string): string | null {
|
|
127
|
+
const trimmed = label.trim().toLowerCase();
|
|
128
|
+
if (!trimmed) return null;
|
|
129
|
+
|
|
130
|
+
// Use Object.hasOwn instead of the `in` operator so we don't accidentally
|
|
131
|
+
// return prototype-chain values (e.g. `constructor`, `toString`) for crafted
|
|
132
|
+
// labels — `'constructor' in EXACT` would otherwise be `true`.
|
|
133
|
+
if (Object.hasOwn(EXACT, trimmed)) return EXACT[trimmed];
|
|
134
|
+
|
|
135
|
+
// Filename lookup uses the basename (strip any path prefix).
|
|
136
|
+
const basename = trimmed.includes('/') ? trimmed.slice(trimmed.lastIndexOf('/') + 1) : trimmed;
|
|
137
|
+
if (Object.hasOwn(FILENAMES, basename)) return FILENAMES[basename];
|
|
138
|
+
|
|
139
|
+
// Extension fallback — take the portion after the LAST dot in the basename.
|
|
140
|
+
const dot = basename.lastIndexOf('.');
|
|
141
|
+
if (dot >= 0 && dot < basename.length - 1) {
|
|
142
|
+
const ext = basename.slice(dot + 1);
|
|
143
|
+
if (Object.hasOwn(EXTENSIONS, ext)) return EXTENSIONS[ext];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (Object.hasOwn(LANGUAGE_ALIASES, trimmed)) return LANGUAGE_ALIASES[trimmed];
|
|
147
|
+
return null;
|
|
148
|
+
}
|