@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
package/src/app/page.tsx
CHANGED
package/src/app/sitemap.ts
CHANGED
|
@@ -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}
|
|
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}
|
|
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
|
-
<
|
|
89
|
-
{item.chapters.map(ch => {
|
|
90
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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("
|
|
17
|
-
expect(html).toContain("
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
import
|
|
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
|
-
|
|
8
|
+
title?: string;
|
|
9
|
+
showLineNumbers?: boolean;
|
|
10
|
+
highlightLines?: number[];
|
|
58
11
|
}
|
|
59
12
|
|
|
60
|
-
export default function CodeBlock({
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
</
|
|
92
|
-
|
|
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
|
}
|