@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
package/src/app/page.tsx CHANGED
@@ -109,7 +109,7 @@ export default function Home() {
109
109
  excerpt: p.excerpt,
110
110
  date: p.date,
111
111
  category: p.category,
112
- readingTime: p.readingTime,
112
+ readingMinutes: p.readingMinutes,
113
113
  coverImage: p.coverImage,
114
114
  series: p.series,
115
115
  pinned: p.pinned,
@@ -1,7 +1,7 @@
1
1
  import { MetadataRoute } from 'next';
2
2
  import { getAllPosts, getAllPages, getAllBooks, getAllFlows } from '@/lib/markdown';
3
3
  import { siteConfig } from '../../site.config';
4
- import { getPostUrl } from '@/lib/urls';
4
+ import { getPostUrl, getBookUrl, getBookChapterUrl } from '@/lib/urls';
5
5
 
6
6
  export const dynamic = 'force-static';
7
7
 
@@ -29,13 +29,13 @@ export default function sitemap(): MetadataRoute.Sitemap {
29
29
 
30
30
  const bookUrls = books.flatMap((book) => [
31
31
  {
32
- url: `${baseUrl}/books/${book.slug}`,
32
+ url: `${baseUrl}${getBookUrl(book.slug)}`,
33
33
  lastModified: book.date,
34
34
  changeFrequency: 'monthly' as const,
35
35
  priority: 0.8,
36
36
  },
37
37
  ...book.chapters.map((ch) => ({
38
- url: `${baseUrl}/books/${book.slug}/${ch.id}`,
38
+ url: `${baseUrl}${getBookChapterUrl(book.slug, ch.id)}`,
39
39
  lastModified: book.date,
40
40
  changeFrequency: 'monthly' as const,
41
41
  priority: 0.7,
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, type ReactNode } from 'react';
4
+
5
+ const KEEP_BG_SELECTOR = 'pre, code, blockquote, .admonition, [class*="admonition"]';
6
+ const STRIP_BG_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, ul, ol, li, div, span, td, th, tr, article, section';
7
+
8
+ function isMeaningfulBg(value: string): boolean {
9
+ if (!value) return false;
10
+ const v = value.trim().toLowerCase();
11
+ if (v === 'transparent' || v === 'rgba(0, 0, 0, 0)' || v === 'rgb(0, 0, 0, 0)') return false;
12
+ return true;
13
+ }
14
+
15
+ export default function ArticleCopyCleaner({ children }: { children: ReactNode }) {
16
+ const rootRef = useRef<HTMLDivElement>(null);
17
+
18
+ useEffect(() => {
19
+ const root = rootRef.current;
20
+ if (!root) return;
21
+
22
+ const handleCopy = (event: ClipboardEvent) => {
23
+ const selection = window.getSelection();
24
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
25
+
26
+ const range = selection.getRangeAt(0);
27
+ const anchor = range.commonAncestorContainer;
28
+ if (!(anchor instanceof Node) || !root.contains(anchor)) return;
29
+
30
+ const sandbox = document.createElement('div');
31
+ sandbox.setAttribute('aria-hidden', 'true');
32
+ sandbox.style.cssText = 'position:fixed;left:-99999px;top:0;visibility:hidden;pointer-events:none;';
33
+ sandbox.appendChild(range.cloneContents());
34
+ root.appendChild(sandbox);
35
+
36
+ try {
37
+ sandbox.querySelectorAll<HTMLElement>(KEEP_BG_SELECTOR).forEach((el) => {
38
+ const bg = getComputedStyle(el).backgroundColor;
39
+ if (isMeaningfulBg(bg)) el.style.backgroundColor = bg;
40
+ });
41
+
42
+ sandbox.querySelectorAll<HTMLElement>(STRIP_BG_SELECTOR).forEach((el) => {
43
+ if (el.matches(KEEP_BG_SELECTOR)) return;
44
+ el.style.removeProperty('background-color');
45
+ el.style.removeProperty('background');
46
+ });
47
+
48
+ const clipboard = event.clipboardData;
49
+ if (!clipboard) return;
50
+
51
+ clipboard.setData('text/html', sandbox.innerHTML);
52
+ clipboard.setData('text/plain', selection.toString());
53
+ event.preventDefault();
54
+ } finally {
55
+ sandbox.remove();
56
+ }
57
+ };
58
+
59
+ root.addEventListener('copy', handleCopy);
60
+ return () => root.removeEventListener('copy', handleCopy);
61
+ }, []);
62
+
63
+ return <div ref={rootRef}>{children}</div>;
64
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState } from 'react';
4
4
  import Link from 'next/link';
5
- import { BookTocItem, BookChapterEntry } from '@/lib/markdown';
5
+ import { BookTocItem, BookTocSection, BookChapterRef, BookChapterEntry } from '@/lib/markdown';
6
6
  import { useLanguage } from './LanguageProvider';
7
7
  import PrevNextNav from './PrevNextNav';
8
8
  import { getBookChapterUrl } from '@/lib/urls';
@@ -23,6 +23,42 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
23
23
  const prevChapter = currentIndex > 0 ? chapters[currentIndex - 1] : null;
24
24
  const nextChapter = currentIndex < chapters.length - 1 ? chapters[currentIndex + 1] : null;
25
25
 
26
+ const renderChapterRow = (ch: BookChapterRef, key: string) => {
27
+ const isCurrent = ch.id === currentChapter;
28
+ const chIdx = chapters.findIndex(c => c.id === ch.id);
29
+ const isPast = chIdx >= 0 && chIdx < currentIndex;
30
+ return isCurrent ? (
31
+ <div key={key} className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-accent/5">
32
+ <span className="text-sm font-semibold text-accent truncate">{ch.title}</span>
33
+ </div>
34
+ ) : (
35
+ <Link
36
+ key={key}
37
+ href={getBookChapterUrl(bookSlug, ch.id)}
38
+ className={`block py-1.5 px-2 rounded-lg text-sm no-underline hover:bg-muted/5 transition-colors ${
39
+ isPast ? 'text-foreground/70 hover:text-foreground' : 'text-muted hover:text-foreground'
40
+ }`}
41
+ >
42
+ {ch.title}
43
+ </Link>
44
+ );
45
+ };
46
+
47
+ const renderSection = (section: BookTocSection, key: string) => (
48
+ <div key={key}>
49
+ <div className="text-[10px] font-sans font-bold uppercase tracking-wider text-muted px-2 py-1.5">
50
+ {section.section}
51
+ </div>
52
+ <div className="space-y-1 pl-2">
53
+ {section.items.map((child, idx) =>
54
+ 'section' in child
55
+ ? renderSection(child, `${key}-${idx}`)
56
+ : renderChapterRow(child, `${key}-${child.id}`)
57
+ )}
58
+ </div>
59
+ </div>
60
+ );
61
+
26
62
  return (
27
63
  <div className="lg:hidden p-5 bg-muted/5 rounded-xl border border-muted/20">
28
64
  {/* Header */}
@@ -85,58 +121,16 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
85
121
  <div className="text-[10px] font-sans font-bold uppercase tracking-wider text-muted px-2 py-1.5">
86
122
  {item.part}
87
123
  </div>
88
- <ol className="space-y-1">
89
- {item.chapters.map(ch => {
90
- const isCurrent = ch.id === currentChapter;
91
- const chIdx = chapters.findIndex(c => c.id === ch.id);
92
- const isPast = chIdx < currentIndex;
93
-
94
- return (
95
- <li key={ch.id}>
96
- {isCurrent ? (
97
- <div className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-accent/5">
98
- <span className="text-sm font-semibold text-accent truncate">{ch.title}</span>
99
- </div>
100
- ) : (
101
- <Link
102
- href={getBookChapterUrl(bookSlug, ch.id)}
103
- className={`block py-1.5 px-2 rounded-lg text-sm no-underline hover:bg-muted/5 transition-colors ${
104
- isPast ? 'text-foreground/70 hover:text-foreground' : 'text-muted hover:text-foreground'
105
- }`}
106
- >
107
- {ch.title}
108
- </Link>
109
- )}
110
- </li>
111
- );
112
- })}
113
- </ol>
114
- </div>
115
- );
116
- } else {
117
- const isCurrent = item.id === currentChapter;
118
- const chIdx = chapters.findIndex(c => c.id === item.id);
119
- const isPast = chIdx < currentIndex;
120
-
121
- return (
122
- <div key={item.id}>
123
- {isCurrent ? (
124
- <div className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-accent/5">
125
- <span className="text-sm font-semibold text-accent truncate">{item.title}</span>
126
- </div>
127
- ) : (
128
- <Link
129
- href={`/books/${bookSlug}/${item.id}`}
130
- className={`block py-1.5 px-2 rounded-lg text-sm no-underline hover:bg-muted/5 transition-colors ${
131
- isPast ? 'text-foreground/70 hover:text-foreground' : 'text-muted hover:text-foreground'
132
- }`}
133
- >
134
- {item.title}
135
- </Link>
136
- )}
124
+ <div className="space-y-1">
125
+ {item.chapters.map(ch => renderChapterRow(ch, `part-${tocIdx}-${ch.id}`))}
126
+ </div>
137
127
  </div>
138
128
  );
139
129
  }
130
+ if ('section' in item) {
131
+ return renderSection(item, `section-${tocIdx}`);
132
+ }
133
+ return renderChapterRow(item, `chapter-${item.id}`);
140
134
  })}
141
135
  </div>
142
136
  )}
Binary file
@@ -2,18 +2,103 @@ import { describe, expect, test } from "bun:test";
2
2
  import { renderToStaticMarkup } from "react-dom/server";
3
3
  import CodeBlock from "./CodeBlock";
4
4
 
5
+ async function renderCodeBlock(element: Awaited<ReturnType<typeof CodeBlock>>): Promise<string> {
6
+ return renderToStaticMarkup(element);
7
+ }
8
+
5
9
  describe("CodeBlock", () => {
6
- test("keeps code scrolling inside its own container", () => {
7
- const html = renderToStaticMarkup(
8
- <CodeBlock language="typescript">
9
- {"const veryLongLine = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';"}
10
- </CodeBlock>
11
- );
10
+ test("keeps code scrolling inside its own container", async () => {
11
+ const element = await CodeBlock({
12
+ language: "typescript",
13
+ children: "const veryLongLine = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';",
14
+ });
15
+ const html = await renderCodeBlock(element);
12
16
 
13
17
  expect(html).toContain("relative my-6 w-full min-w-0 max-w-full");
14
18
  expect(html).toContain("overflow-x-auto");
15
19
  expect(html).toContain("overflow-y-hidden");
16
- expect(html).toContain("max-width:100%");
17
- expect(html).toContain("box-sizing:border-box");
20
+ expect(html).toContain("cb-root");
21
+ expect(html).toContain('class="shiki');
22
+ });
23
+
24
+ test("renders title bar when title prop is set", async () => {
25
+ const element = await CodeBlock({
26
+ language: "ts",
27
+ title: "src/app.ts",
28
+ children: "export const x = 1;",
29
+ });
30
+ const html = await renderCodeBlock(element);
31
+
32
+ expect(html).toContain("cb-title");
33
+ expect(html).toContain("src/app.ts");
34
+ });
35
+
36
+ test("flags <pre> with data-line-numbers when showLineNumbers is true", async () => {
37
+ const element = await CodeBlock({
38
+ language: "js",
39
+ showLineNumbers: true,
40
+ children: "const x = 1;\nconst y = 2;",
41
+ });
42
+ const html = await renderCodeBlock(element);
43
+
44
+ expect(html).toContain('data-line-numbers="true"');
45
+ });
46
+
47
+ test("marks highlighted lines from highlightLines prop", async () => {
48
+ const element = await CodeBlock({
49
+ language: "ts",
50
+ highlightLines: [2, 4],
51
+ children: "const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;",
52
+ });
53
+ const html = await renderCodeBlock(element);
54
+
55
+ expect(html).toContain('data-highlighted-line="2"');
56
+ expect(html).toContain('data-highlighted-line="4"');
57
+ expect(html).not.toContain('data-highlighted-line="1"');
58
+ expect(html).not.toContain('data-highlighted-line="3"');
59
+ });
60
+
61
+ test("applies diff add/remove classes for +/- lines in diff fences", async () => {
62
+ const element = await CodeBlock({
63
+ language: "diff",
64
+ children: "-removed\n+added\n unchanged",
65
+ });
66
+ const html = await renderCodeBlock(element);
67
+
68
+ expect(html).toContain("diff add");
69
+ expect(html).toContain("diff remove");
70
+ });
71
+
72
+ test("renders unknown languages as plaintext + emits a warn (warn-and-degrade)", async () => {
73
+ // Production deploys can't fail on a single unknown fence — render as
74
+ // plaintext and emit a build-time warn instead. CLAUDE.md's strict-build
75
+ // principle still applies for frontmatter/slugs/redirects, but not here.
76
+ const element = await CodeBlock({ language: "totally-made-up", children: "x" });
77
+ const html = await renderCodeBlock(element);
78
+ expect(html).toContain('class="shiki');
79
+ expect(html).toContain("totally-made-up");
80
+ });
81
+
82
+ test("renders plaintext when explicitly requested via `plaintext`/`text` alias", async () => {
83
+ const element = await CodeBlock({
84
+ language: "plaintext",
85
+ children: "no highlighting wanted here",
86
+ });
87
+ const html = await renderCodeBlock(element);
88
+
89
+ expect(html).toContain('class="shiki');
90
+ expect(html).toContain("no highlighting wanted here");
91
+ });
92
+
93
+ test("emits no client highlighter script tags", async () => {
94
+ const element = await CodeBlock({
95
+ language: "javascript",
96
+ children: "const x = 1;",
97
+ });
98
+ const html = await renderCodeBlock(element);
99
+
100
+ expect(html).not.toContain("<script");
101
+ expect(html).not.toContain("react-syntax-highlighter");
102
+ expect(html).not.toContain("token keyword");
18
103
  });
19
104
  });
@@ -1,114 +1,52 @@
1
- "use client";
2
-
3
- import React, { useState } from 'react';
4
- import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
5
- import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
6
- import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
7
- import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
8
- import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash';
9
- import markdown from 'react-syntax-highlighter/dist/esm/languages/prism/markdown';
10
- import json from 'react-syntax-highlighter/dist/esm/languages/prism/json';
11
- import css from 'react-syntax-highlighter/dist/esm/languages/prism/css';
12
- import python from 'react-syntax-highlighter/dist/esm/languages/prism/python';
13
- import rust from 'react-syntax-highlighter/dist/esm/languages/prism/rust';
14
- import go from 'react-syntax-highlighter/dist/esm/languages/prism/go';
15
- import c from 'react-syntax-highlighter/dist/esm/languages/prism/c';
16
- import cpp from 'react-syntax-highlighter/dist/esm/languages/prism/cpp';
17
- import java from 'react-syntax-highlighter/dist/esm/languages/prism/java';
18
- import ruby from 'react-syntax-highlighter/dist/esm/languages/prism/ruby';
19
- import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql';
20
- import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml';
21
- import diff from 'react-syntax-highlighter/dist/esm/languages/prism/diff';
22
- import markup from 'react-syntax-highlighter/dist/esm/languages/prism/markup';
23
-
24
- SyntaxHighlighter.registerLanguage('tsx', tsx);
25
- SyntaxHighlighter.registerLanguage('typescript', typescript);
26
- SyntaxHighlighter.registerLanguage('javascript', javascript);
27
- SyntaxHighlighter.registerLanguage('js', javascript);
28
- SyntaxHighlighter.registerLanguage('bash', bash);
29
- SyntaxHighlighter.registerLanguage('sh', bash);
30
- SyntaxHighlighter.registerLanguage('shell', bash);
31
- SyntaxHighlighter.registerLanguage('markdown', markdown);
32
- SyntaxHighlighter.registerLanguage('md', markdown);
33
- SyntaxHighlighter.registerLanguage('json', json);
34
- SyntaxHighlighter.registerLanguage('css', css);
35
- SyntaxHighlighter.registerLanguage('python', python);
36
- SyntaxHighlighter.registerLanguage('py', python);
37
- SyntaxHighlighter.registerLanguage('rust', rust);
38
- SyntaxHighlighter.registerLanguage('go', go);
39
- SyntaxHighlighter.registerLanguage('golang', go);
40
- SyntaxHighlighter.registerLanguage('c', c);
41
- SyntaxHighlighter.registerLanguage('cpp', cpp);
42
- SyntaxHighlighter.registerLanguage('c++', cpp);
43
- SyntaxHighlighter.registerLanguage('java', java);
44
- SyntaxHighlighter.registerLanguage('ruby', ruby);
45
- SyntaxHighlighter.registerLanguage('rb', ruby);
46
- SyntaxHighlighter.registerLanguage('sql', sql);
47
- SyntaxHighlighter.registerLanguage('yaml', yaml);
48
- SyntaxHighlighter.registerLanguage('yml', yaml);
49
- SyntaxHighlighter.registerLanguage('diff', diff);
50
- SyntaxHighlighter.registerLanguage('html', markup);
51
- SyntaxHighlighter.registerLanguage('xml', markup);
52
- SyntaxHighlighter.registerLanguage('svg', markup);
1
+ import { toHtml } from 'hast-util-to-html';
2
+ import CodeBlockToolbar from './CodeBlockToolbar';
3
+ import { getLanguageDisplayName, highlightToHast } from '@/lib/shiki';
53
4
 
54
5
  interface CodeBlockProps {
55
6
  language: string;
56
7
  children: string;
57
- [key: string]: unknown;
8
+ title?: string;
9
+ showLineNumbers?: boolean;
10
+ highlightLines?: number[];
58
11
  }
59
12
 
60
- export default function CodeBlock({ language, children, ...props }: CodeBlockProps) {
61
- const [copied, setCopied] = useState(false);
62
-
63
- const handleCopy = async () => {
64
- await navigator.clipboard.writeText(children);
65
- setCopied(true);
66
- setTimeout(() => setCopied(false), 2000);
67
- };
13
+ export default async function CodeBlock({
14
+ language,
15
+ children,
16
+ title,
17
+ showLineNumbers,
18
+ highlightLines,
19
+ }: CodeBlockProps) {
20
+ const hast = await highlightToHast(children, language, {
21
+ showLineNumbers,
22
+ highlightLines,
23
+ title,
24
+ });
25
+ const html = toHtml(hast);
26
+ const displayLang = getLanguageDisplayName(language || 'text');
68
27
 
69
28
  return (
70
- <div className="relative my-6 w-full min-w-0 max-w-full rounded-lg border border-muted/20 bg-background/50 overflow-hidden shadow-sm">
71
- <div className="flex items-center justify-between px-4 py-2 border-b border-muted/10 bg-muted/5">
72
- <span className="text-xs font-mono text-muted uppercase tracking-wider">
73
- {language || 'text'}
74
- </span>
75
- <button
76
- onClick={handleCopy}
77
- className="text-xs text-muted hover:text-accent transition-colors duration-200 flex items-center gap-1"
78
- aria-label="Copy code"
79
- >
80
- {copied ? (
81
- <>
82
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
83
- <span>Copied</span>
84
- </>
85
- ) : (
86
- <>
87
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
88
- <span>Copy</span>
89
- </>
29
+ <div
30
+ data-cb-root=""
31
+ className="cb-root relative my-6 w-full min-w-0 max-w-full rounded-lg border border-muted/20 bg-background/50 overflow-hidden shadow-sm"
32
+ >
33
+ <div className="cb-header flex items-center justify-between px-4 py-2 border-b border-muted/10 bg-muted/5 gap-3">
34
+ <div className="flex items-center gap-3 min-w-0">
35
+ <span className="cb-lang text-xs font-mono text-muted tracking-wider">
36
+ {displayLang}
37
+ </span>
38
+ {title && (
39
+ <span className="cb-title truncate text-xs text-foreground/80" title={title}>
40
+ {title}
41
+ </span>
90
42
  )}
91
- </button>
92
- </div>
93
- <div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden">
94
- <SyntaxHighlighter
95
- language={language || 'text'}
96
- PreTag="div"
97
- useInlineStyles={false}
98
- customStyle={{
99
- margin: 0,
100
- padding: '1.5rem',
101
- background: 'transparent',
102
- fontSize: '0.9rem',
103
- lineHeight: '1.6',
104
- maxWidth: '100%',
105
- boxSizing: 'border-box',
106
- }}
107
- {...props}
108
- >
109
- {children}
110
- </SyntaxHighlighter>
43
+ </div>
44
+ <CodeBlockToolbar code={children} />
111
45
  </div>
46
+ <div
47
+ className="cb-scroll w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden"
48
+ dangerouslySetInnerHTML={{ __html: html }}
49
+ />
112
50
  </div>
113
51
  );
114
52
  }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from 'react';
4
+
5
+ interface CodeBlockToolbarProps {
6
+ code: string;
7
+ }
8
+
9
+ export default function CodeBlockToolbar({ code }: CodeBlockToolbarProps) {
10
+ const [copied, setCopied] = useState(false);
11
+ const [wrapped, setWrapped] = useState(false);
12
+ const buttonRef = useRef<HTMLButtonElement | null>(null);
13
+
14
+ const handleCopy = async () => {
15
+ try {
16
+ await navigator.clipboard.writeText(code);
17
+ setCopied(true);
18
+ setTimeout(() => setCopied(false), 2000);
19
+ } catch {
20
+ // Clipboard may be unavailable (e.g. insecure context); fail silently.
21
+ }
22
+ };
23
+
24
+ const handleToggleWrap = () => {
25
+ const next = !wrapped;
26
+ setWrapped(next);
27
+ const root = buttonRef.current?.closest('[data-cb-root]') as HTMLElement | null;
28
+ if (root) {
29
+ if (next) {
30
+ root.setAttribute('data-wrap', 'true');
31
+ } else {
32
+ root.removeAttribute('data-wrap');
33
+ }
34
+ }
35
+ };
36
+
37
+ return (
38
+ <div className="flex items-center gap-3">
39
+ <button
40
+ ref={buttonRef}
41
+ onClick={handleToggleWrap}
42
+ className="text-xs text-muted hover:text-accent transition-colors duration-200 flex items-center gap-1"
43
+ aria-label={wrapped ? 'Disable word wrap' : 'Enable word wrap'}
44
+ aria-pressed={wrapped}
45
+ type="button"
46
+ >
47
+ {wrapped ? (
48
+ <>
49
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
50
+ <path d="M3 6h18" />
51
+ <path d="M3 12h15a3 3 0 0 1 0 6h-4" />
52
+ <path d="m16 16-2 2 2 2" />
53
+ <path d="M3 18h7" />
54
+ </svg>
55
+ <span>Wrap</span>
56
+ </>
57
+ ) : (
58
+ <>
59
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
60
+ <path d="M3 6h18" />
61
+ <path d="M3 12h18" />
62
+ <path d="M3 18h18" />
63
+ </svg>
64
+ <span>No wrap</span>
65
+ </>
66
+ )}
67
+ </button>
68
+ <button
69
+ onClick={handleCopy}
70
+ className="text-xs text-muted hover:text-accent transition-colors duration-200 flex items-center gap-1"
71
+ aria-label="Copy code"
72
+ type="button"
73
+ >
74
+ {copied ? (
75
+ <>
76
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
77
+ <span>Copied</span>
78
+ </>
79
+ ) : (
80
+ <>
81
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
82
+ <span>Copy</span>
83
+ </>
84
+ )}
85
+ </button>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,81 @@
1
+ import { Children, type ReactNode } from 'react';
2
+ import { resolveCodeGroupIcon } from '@/lib/code-group-icons';
3
+
4
+ interface CodeGroupProps {
5
+ 'data-labels'?: string;
6
+ 'data-group-id'?: string;
7
+ children?: ReactNode;
8
+ }
9
+
10
+ /**
11
+ * Tabbed code group widget. CSS-only — the radio + label sibling trick handles
12
+ * tab switching with zero JavaScript. The matching CSS in globals.css picks the
13
+ * checked input's matching panel via attribute selectors (input[data-idx=N] →
14
+ * .cg-panel[data-panel=N]).
15
+ *
16
+ * Children are already-highlighted CodeBlock server components from the
17
+ * outer MarkdownRenderer pipeline; this component just composes the tab UI
18
+ * around them.
19
+ */
20
+ export default function CodeGroup(props: CodeGroupProps) {
21
+ const labelsRaw = props['data-labels'] ?? '[]';
22
+ const groupId = props['data-group-id'] ?? 'cg-default';
23
+ let labels: string[];
24
+ try {
25
+ const parsed = JSON.parse(labelsRaw);
26
+ labels = Array.isArray(parsed) ? parsed.map(String) : [];
27
+ } catch {
28
+ labels = [];
29
+ }
30
+
31
+ // Drop whitespace text nodes that remark-rehype leaves between code children.
32
+ const panels = Children.toArray(props.children).filter((child) => {
33
+ if (typeof child === 'string') return child.trim().length > 0;
34
+ return true;
35
+ });
36
+ const tabs = labels.length > 0 ? labels : panels.map((_, i) => `Tab ${i + 1}`);
37
+
38
+ return (
39
+ <div className="code-group my-6" data-group-id={groupId}>
40
+ {tabs.map((_, i) => (
41
+ <input
42
+ key={`r-${i}`}
43
+ type="radio"
44
+ name={`cg-${groupId}`}
45
+ id={`cg-${groupId}-${i}`}
46
+ data-idx={String(i)}
47
+ defaultChecked={i === 0}
48
+ aria-controls={`cg-${groupId}-panel-${i}`}
49
+ tabIndex={i === 0 ? 0 : -1}
50
+ />
51
+ ))}
52
+ <div className="cg-tablist" role="tablist">
53
+ {tabs.map((label, i) => {
54
+ const icon = resolveCodeGroupIcon(label);
55
+ return (
56
+ <label
57
+ key={`l-${i}`}
58
+ htmlFor={`cg-${groupId}-${i}`}
59
+ className="cg-tab"
60
+ role="tab"
61
+ {...(icon ? { 'data-cg-icon': icon } : {})}
62
+ >
63
+ {label}
64
+ </label>
65
+ );
66
+ })}
67
+ </div>
68
+ {panels.map((child, i) => (
69
+ <div
70
+ key={`p-${i}`}
71
+ className="cg-panel"
72
+ data-panel={String(i)}
73
+ role="tabpanel"
74
+ id={`cg-${groupId}-panel-${i}`}
75
+ >
76
+ {child}
77
+ </div>
78
+ ))}
79
+ </div>
80
+ );
81
+ }
@@ -101,6 +101,7 @@ export default function CoverImage({ title, slug, src, className = "h-full w-ful
101
101
  unoptimized={!useBlurPlaceholder}
102
102
  placeholder={useBlurPlaceholder ? 'blur' : 'empty'}
103
103
  loading={loading}
104
+ fetchPriority={loading === 'eager' ? 'high' : 'low'}
104
105
  />
105
106
  );
106
107
  }