@hutusi/amytis 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CLAUDE.md +90 -219
  3. package/bun.lock +185 -547
  4. package/content/books/sample-book/index.mdx +3 -0
  5. package/content/posts/code-block-features-showcase.mdx +223 -0
  6. package/docs/ALERTS.md +112 -0
  7. package/docs/ARCHITECTURE.md +217 -5
  8. package/docs/CODE-BLOCKS.md +238 -0
  9. package/docs/CONTRIBUTING.md +25 -0
  10. package/docs/guides/README.md +11 -0
  11. package/docs/guides/importing-vuepress-books.md +178 -0
  12. package/eslint.config.mjs +18 -6
  13. package/package.json +42 -20
  14. package/scripts/generate-code-group-icons.ts +79 -0
  15. package/scripts/render-rst.py +207 -3
  16. package/scripts/sync-vuepress-book.ts +499 -0
  17. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  18. package/src/app/books/[slug]/page.tsx +67 -32
  19. package/src/app/globals.css +503 -123
  20. package/src/app/page.tsx +1 -1
  21. package/src/app/sitemap.ts +3 -3
  22. package/src/components/ArticleCopyCleaner.tsx +64 -0
  23. package/src/components/BookMobileNav.tsx +44 -50
  24. package/src/components/BookSidebar.tsx +0 -0
  25. package/src/components/CodeBlock.test.tsx +93 -8
  26. package/src/components/CodeBlock.tsx +39 -101
  27. package/src/components/CodeBlockToolbar.tsx +88 -0
  28. package/src/components/CodeGroup.tsx +81 -0
  29. package/src/components/CoverImage.tsx +1 -0
  30. package/src/components/ExternalLinkIcon.tsx +15 -0
  31. package/src/components/FeaturedStoriesSection.tsx +3 -3
  32. package/src/components/GithubAlert.tsx +97 -0
  33. package/src/components/MarkdownRenderer.test.tsx +14 -4
  34. package/src/components/MarkdownRenderer.tsx +144 -23
  35. package/src/components/Mermaid.tsx +32 -1
  36. package/src/components/PostList.tsx +1 -1
  37. package/src/components/PostNavigation.tsx +13 -2
  38. package/src/components/PostSidebar.tsx +13 -2
  39. package/src/components/RstRenderer.test.tsx +15 -15
  40. package/src/components/RstRenderer.tsx +37 -2
  41. package/src/components/Search.tsx +18 -4
  42. package/src/components/SeriesCatalog.tsx +1 -1
  43. package/src/components/ShareBar.tsx +5 -0
  44. package/src/components/TocPanel.tsx +10 -2
  45. package/src/i18n/translations.ts +2 -0
  46. package/src/layouts/BookLayout.tsx +35 -4
  47. package/src/layouts/PostLayout.tsx +5 -1
  48. package/src/lib/code-group-icons.test.ts +78 -0
  49. package/src/lib/code-group-icons.ts +148 -0
  50. package/src/lib/markdown.test.ts +56 -13
  51. package/src/lib/markdown.ts +203 -50
  52. package/src/lib/normalize-vuepress-math.ts +118 -0
  53. package/src/lib/rehype-fence-meta.ts +22 -0
  54. package/src/lib/remark-book-chapter-links.ts +106 -0
  55. package/src/lib/remark-code-group.ts +54 -0
  56. package/src/lib/remark-github-alerts.test.ts +83 -0
  57. package/src/lib/remark-github-alerts.ts +65 -0
  58. package/src/lib/remark-vuepress-containers.ts +130 -0
  59. package/src/lib/rst-renderer.ts +19 -7
  60. package/src/lib/rst.test.ts +212 -2
  61. package/src/lib/rst.ts +217 -13
  62. package/src/lib/shiki-rst.ts +185 -0
  63. package/src/lib/shiki.test.ts +153 -0
  64. package/src/lib/shiki.ts +292 -0
  65. package/src/lib/urls.ts +57 -0
  66. package/src/test-utils/render.ts +23 -0
  67. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  68. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  69. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  70. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  71. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  72. package/tests/helpers/env.ts +19 -0
  73. package/tests/integration/book-chapter-links.test.ts +107 -0
  74. package/tests/integration/books-nested-toc.test.ts +176 -0
  75. package/tests/integration/books.test.ts +3 -2
  76. package/tests/integration/code-block-features.test.ts +188 -0
  77. package/tests/integration/code-group.test.ts +183 -0
  78. package/tests/integration/code-notation.test.ts +97 -0
  79. package/tests/integration/github-alerts.test.ts +82 -0
  80. package/tests/integration/markdown-external-links.test.ts +103 -0
  81. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  82. package/tests/integration/reading-time-headings.test.ts +8 -6
  83. package/tests/integration/series-draft.test.ts +6 -13
  84. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  85. package/tests/integration/vuepress-containers.test.ts +107 -0
  86. package/tests/tooling/new-post.test.ts +1 -1
  87. package/tests/unit/static-params.test.ts +32 -19
@@ -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 = renderToStaticMarkup(
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 = renderToStaticMarkup(
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 = renderToStaticMarkup(
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 = renderToStaticMarkup(
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 = renderToStaticMarkup(
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
- expect(html).toContain('language-ts');
89
- expect(html).toContain('<code class="language-ts"');
90
- expect(html).toContain('token keyword');
91
- expect(html).toContain('token number');
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
- const sanitizedHtml = sanitizeRenderedHtml(html).replace(
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.readingTime}</span>
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
- <nav aria-label={t('on_this_page')} className={className}>
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 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted">
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'}`}
@@ -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 { BookData, BookChapterData } from '@/lib/markdown';
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 `![](./assets/...)`-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">{chapter.readingTime}</span>
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 content={chapter.content} latex={chapter.latex} slug={chapter.isFolder ? `books/${book.slug}/${chapter.slug}` : `books/${book.slug}`} />
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">{post.readingTime}</span>
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
+ }