@hutusi/amytis 1.14.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +90 -219
- package/README.md +33 -1
- package/README.zh.md +33 -1
- package/TODO.md +10 -0
- package/bun.lock +205 -539
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
- package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
- package/content/series/rst-legacy/getting-started.rst +24 -0
- package/content/series/rst-legacy/index.rst +9 -0
- package/content/series/rst-readme/README.rst +9 -0
- package/content/series/rst-readme/readme-index-post.rst +10 -0
- package/content/series/rst-toctree/first-post.rst +6 -0
- package/content/series/rst-toctree/index.rst +10 -0
- package/content/series/rst-toctree/second-post.rst +6 -0
- package/content/series/rst-toctree-precedence/first-post.rst +6 -0
- package/content/series/rst-toctree-precedence/index.rst +12 -0
- package/content/series/rst-toctree-precedence/second-post.rst +6 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +239 -8
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +36 -0
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +178 -0
- package/eslint.config.mjs +20 -6
- package/next.config.ts +2 -2
- package/package.json +52 -24
- package/packages/create-amytis/package.json +1 -1
- package/packages/create-amytis/src/index.test.ts +43 -1
- package/packages/create-amytis/src/index.ts +64 -8
- package/public/next-image-export-optimizer-hashes.json +14 -73
- package/scripts/build-pagefind.ts +172 -0
- package/scripts/copy-assets.ts +246 -56
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/generate-knowledge-graph.ts +2 -1
- package/scripts/render-rst.py +923 -0
- package/scripts/run-with-rst-python.ts +42 -0
- package/scripts/sync-vuepress-book.ts +499 -0
- package/src/app/[slug]/[postSlug]/page.tsx +20 -10
- package/src/app/[slug]/page/[page]/page.tsx +15 -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 +639 -94
- package/src/app/page.tsx +1 -1
- package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
- package/src/app/series/[slug]/page.tsx +11 -13
- package/src/app/series/page.tsx +3 -3
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/AuthorCard.tsx +25 -16
- 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 +6 -2
- 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 +30 -4
- package/src/components/MarkdownRenderer.tsx +148 -24
- 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 +93 -0
- package/src/components/RstRenderer.tsx +157 -0
- 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 +10 -2
- package/src/layouts/SimpleLayout.tsx +10 -3
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/image-utils.test.ts +19 -0
- package/src/lib/image-utils.ts +11 -0
- package/src/lib/markdown.test.ts +195 -14
- package/src/lib/markdown.ts +928 -254
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/rehype-image-metadata.ts +2 -2
- 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.test.ts +355 -0
- package/src/lib/rst-renderer.ts +629 -0
- package/src/lib/rst.test.ts +350 -0
- package/src/lib/rst.ts +674 -0
- package/src/lib/series-redirects.ts +42 -0
- 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/feed-utils.test.ts +13 -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 +12 -14
- package/tests/integration/series-draft.test.ts +12 -5
- package/tests/integration/series.test.ts +93 -0
- package/tests/integration/sync-vuepress-book.test.ts +240 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/build-pagefind.test.ts +66 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/static-params.test.ts +166 -13
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import ExportedImage from 'next-image-export-optimizer';
|
|
3
3
|
import { siteConfig } from '../../site.config';
|
|
4
|
-
import { getCdnImageUrl } from '@/lib/image-utils';
|
|
4
|
+
import { getCdnImageUrl, shouldBypassImageOptimization } from '@/lib/image-utils';
|
|
5
5
|
|
|
6
6
|
// Each palette defines a gradient background and text color for light/dark modes
|
|
7
7
|
const palettes = [
|
|
@@ -88,6 +88,8 @@ export default function CoverImage({ title, slug, src, className = "h-full w-ful
|
|
|
88
88
|
const imageSrc = getCdnImageUrl(src!, cdnBaseUrl);
|
|
89
89
|
const isCdn = cdnBaseUrl && imageSrc !== src;
|
|
90
90
|
const isDev = process.env.NODE_ENV === 'development';
|
|
91
|
+
const shouldBypassOptimization = shouldBypassImageOptimization(imageSrc);
|
|
92
|
+
const useBlurPlaceholder = !(isDev || !!isCdn || shouldBypassOptimization);
|
|
91
93
|
|
|
92
94
|
return (
|
|
93
95
|
<ExportedImage
|
|
@@ -96,8 +98,10 @@ export default function CoverImage({ title, slug, src, className = "h-full w-ful
|
|
|
96
98
|
className={className}
|
|
97
99
|
fill
|
|
98
100
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
|
99
|
-
unoptimized={
|
|
101
|
+
unoptimized={!useBlurPlaceholder}
|
|
102
|
+
placeholder={useBlurPlaceholder ? 'blur' : 'empty'}
|
|
100
103
|
loading={loading}
|
|
104
|
+
fetchPriority={loading === 'eager' ? 'high' : 'low'}
|
|
101
105
|
/>
|
|
102
106
|
);
|
|
103
107
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { LuArrowUpRight } from 'react-icons/lu';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inline outward-arrow indicator appended after external-link text by
|
|
5
|
+
* `MarkdownRenderer`'s `<a>` override. Sized relative to the surrounding
|
|
6
|
+
* text and aria-hidden — the link's accessible name already carries intent.
|
|
7
|
+
*/
|
|
8
|
+
export default function ExternalLinkIcon() {
|
|
9
|
+
return (
|
|
10
|
+
<LuArrowUpRight
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
className="inline-block align-text-top ml-0.5 text-[0.85em] opacity-70"
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -14,7 +14,7 @@ export interface FeaturedPost {
|
|
|
14
14
|
excerpt: string;
|
|
15
15
|
date: string;
|
|
16
16
|
category: string;
|
|
17
|
-
|
|
17
|
+
readingMinutes: number;
|
|
18
18
|
coverImage?: string;
|
|
19
19
|
series?: string;
|
|
20
20
|
pinned?: boolean;
|
|
@@ -110,7 +110,7 @@ export default function FeaturedStoriesSection({ allFeatured, maxItems }: Featur
|
|
|
110
110
|
<div className="flex items-center gap-2 text-xs font-mono text-white/60 mb-3 overflow-hidden">
|
|
111
111
|
<span className="text-accent uppercase tracking-wider truncate min-w-0">{hero.category}</span>
|
|
112
112
|
<span className="shrink-0">·</span>
|
|
113
|
-
<span className="shrink-0 whitespace-nowrap">{hero.
|
|
113
|
+
<span className="shrink-0 whitespace-nowrap">{hero.readingMinutes} {t('reading_time')}</span>
|
|
114
114
|
<span className="shrink-0">·</span>
|
|
115
115
|
<span className="shrink-0 whitespace-nowrap">{hero.date}</span>
|
|
116
116
|
</div>
|
|
@@ -141,7 +141,7 @@ export default function FeaturedStoriesSection({ allFeatured, maxItems }: Featur
|
|
|
141
141
|
<div className="flex items-center gap-2 text-xs font-mono text-muted mb-2">
|
|
142
142
|
<span className="text-accent uppercase tracking-wider truncate max-w-[4rem]">{post.category}</span>
|
|
143
143
|
<span className="shrink-0 hidden sm:inline">·</span>
|
|
144
|
-
<span className="shrink-0 hidden sm:inline">{post.
|
|
144
|
+
<span className="shrink-0 hidden sm:inline">{post.readingMinutes} {t('reading_time')}</span>
|
|
145
145
|
<span className="shrink-0">·</span>
|
|
146
146
|
<span className="shrink-0">{post.date}</span>
|
|
147
147
|
</div>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
type AlertType = 'note' | 'tip' | 'important' | 'warning' | 'caution';
|
|
4
|
+
|
|
5
|
+
const KNOWN_TYPES: ReadonlySet<AlertType> = new Set(['note', 'tip', 'important', 'warning', 'caution']);
|
|
6
|
+
|
|
7
|
+
const ALERT_LABELS: Record<AlertType, string> = {
|
|
8
|
+
note: 'Note',
|
|
9
|
+
tip: 'Tip',
|
|
10
|
+
important: 'Important',
|
|
11
|
+
warning: 'Warning',
|
|
12
|
+
caution: 'Caution',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
interface GithubAlertProps {
|
|
16
|
+
'data-alert-type'?: string;
|
|
17
|
+
/** Custom title from the directive label (e.g. `:::tip 智慧的疆界`). Falls back to ALERT_LABELS when absent. */
|
|
18
|
+
'data-alert-title'?: string;
|
|
19
|
+
children?: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function AlertIcon({ type }: { type: AlertType }) {
|
|
23
|
+
const common = {
|
|
24
|
+
width: 16,
|
|
25
|
+
height: 16,
|
|
26
|
+
viewBox: '0 0 24 24',
|
|
27
|
+
fill: 'none',
|
|
28
|
+
stroke: 'currentColor',
|
|
29
|
+
strokeWidth: 2,
|
|
30
|
+
strokeLinecap: 'round' as const,
|
|
31
|
+
strokeLinejoin: 'round' as const,
|
|
32
|
+
'aria-hidden': true,
|
|
33
|
+
};
|
|
34
|
+
switch (type) {
|
|
35
|
+
case 'note':
|
|
36
|
+
return (
|
|
37
|
+
<svg {...common}>
|
|
38
|
+
<circle cx="12" cy="12" r="10" />
|
|
39
|
+
<path d="M12 16v-4" />
|
|
40
|
+
<path d="M12 8h.01" />
|
|
41
|
+
</svg>
|
|
42
|
+
);
|
|
43
|
+
case 'tip':
|
|
44
|
+
return (
|
|
45
|
+
<svg {...common}>
|
|
46
|
+
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
|
|
47
|
+
<path d="M9 18h6" />
|
|
48
|
+
<path d="M10 22h4" />
|
|
49
|
+
</svg>
|
|
50
|
+
);
|
|
51
|
+
case 'important':
|
|
52
|
+
return (
|
|
53
|
+
<svg {...common}>
|
|
54
|
+
<path d="M21 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
|
55
|
+
<path d="M12 8v4" />
|
|
56
|
+
<path d="M12 16h.01" />
|
|
57
|
+
</svg>
|
|
58
|
+
);
|
|
59
|
+
case 'warning':
|
|
60
|
+
return (
|
|
61
|
+
<svg {...common}>
|
|
62
|
+
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
|
63
|
+
<path d="M12 9v4" />
|
|
64
|
+
<path d="M12 17h.01" />
|
|
65
|
+
</svg>
|
|
66
|
+
);
|
|
67
|
+
case 'caution':
|
|
68
|
+
return (
|
|
69
|
+
<svg {...common}>
|
|
70
|
+
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
|
|
71
|
+
<path d="M12 8v4" />
|
|
72
|
+
<path d="M12 16h.01" />
|
|
73
|
+
</svg>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default function GithubAlert(props: GithubAlertProps) {
|
|
79
|
+
const raw = (props['data-alert-type'] ?? '').toLowerCase();
|
|
80
|
+
if (!KNOWN_TYPES.has(raw as AlertType)) {
|
|
81
|
+
// Defensive: an unrecognized type means the plugin matched but we're missing
|
|
82
|
+
// a mapping. Fall through to a plain blockquote so content still renders.
|
|
83
|
+
return <blockquote>{props.children}</blockquote>;
|
|
84
|
+
}
|
|
85
|
+
const type = raw as AlertType;
|
|
86
|
+
const customTitle = props['data-alert-title']?.trim();
|
|
87
|
+
const title = customTitle && customTitle.length > 0 ? customTitle : ALERT_LABELS[type];
|
|
88
|
+
return (
|
|
89
|
+
<aside className={`alert alert-${type}`} role="note" aria-label={title}>
|
|
90
|
+
<div className="alert-title">
|
|
91
|
+
<AlertIcon type={type} />
|
|
92
|
+
<span>{title}</span>
|
|
93
|
+
</div>
|
|
94
|
+
<div className="alert-body">{props.children}</div>
|
|
95
|
+
</aside>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import MarkdownRenderer from "./MarkdownRenderer";
|
|
4
|
+
import { renderAsync } from "@/test-utils/render";
|
|
4
5
|
|
|
5
6
|
describe("MarkdownRenderer", () => {
|
|
6
7
|
describe("image rendering", () => {
|
|
@@ -12,6 +13,9 @@ describe("MarkdownRenderer", () => {
|
|
|
12
13
|
expect(html).toContain('height="900"');
|
|
13
14
|
// style override ensures the image renders at its natural size
|
|
14
15
|
expect(html).toContain('width:100%');
|
|
16
|
+
// fetchpriority="low" prevents React 19 from auto-preloading local
|
|
17
|
+
// markdown images as LCP candidates (matches the external-image fix)
|
|
18
|
+
expect(html).toContain('fetchPriority="low"');
|
|
15
19
|
});
|
|
16
20
|
|
|
17
21
|
test("uses plain img for external images", () => {
|
|
@@ -24,9 +28,25 @@ describe("MarkdownRenderer", () => {
|
|
|
24
28
|
// images as LCP candidates, avoiding "preloaded but not used" warnings
|
|
25
29
|
expect(html).toContain('fetchPriority="low"');
|
|
26
30
|
});
|
|
31
|
+
|
|
32
|
+
test("bypasses optimization for local avif images", () => {
|
|
33
|
+
const content = "";
|
|
34
|
+
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
35
|
+
expect(html).toContain('src="/images/background-new-wave.avif"');
|
|
36
|
+
expect(html).not.toContain('nextImageExportOptimizer');
|
|
37
|
+
expect(html).not.toContain('background-image:url');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("bypasses optimization for local webp images", () => {
|
|
41
|
+
const content = "";
|
|
42
|
+
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
43
|
+
expect(html).toContain('src="/images/already-optimized.webp"');
|
|
44
|
+
expect(html).not.toContain('nextImageExportOptimizer');
|
|
45
|
+
expect(html).not.toContain('background-image:url');
|
|
46
|
+
});
|
|
27
47
|
});
|
|
28
48
|
|
|
29
|
-
test("adds horizontal overflow containment while preserving code scrolling", () => {
|
|
49
|
+
test("adds horizontal overflow containment while preserving code scrolling", async () => {
|
|
30
50
|
const content = [
|
|
31
51
|
"## Example",
|
|
32
52
|
"",
|
|
@@ -35,16 +55,22 @@ describe("MarkdownRenderer", () => {
|
|
|
35
55
|
"```",
|
|
36
56
|
].join("\n");
|
|
37
57
|
|
|
38
|
-
const html =
|
|
58
|
+
const html = await renderAsync(<MarkdownRenderer content={content} />);
|
|
39
59
|
|
|
40
60
|
expect(html).toContain("overflow-x-hidden");
|
|
41
61
|
expect(html).toContain("not-prose w-full min-w-0 max-w-full");
|
|
42
62
|
expect(html).toContain("overflow-x-auto");
|
|
63
|
+
// Shiki rendered the block — ensure the highlighted shell pass produced output.
|
|
64
|
+
expect(html).toContain('class="shiki');
|
|
43
65
|
});
|
|
44
66
|
|
|
45
|
-
test("wraps content in
|
|
67
|
+
test("wraps content in ArticleCopyCleaner so paste output is stripped of per-paragraph backgrounds", () => {
|
|
46
68
|
const content = "Hello world";
|
|
47
69
|
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
48
|
-
|
|
70
|
+
// The cleaner renders a bare wrapper div; the page background lives on body now,
|
|
71
|
+
// so the article HTML must not paint its own background (which is what caused
|
|
72
|
+
// Chromium's clipboard serializer to inline `background-color` on every <p>).
|
|
73
|
+
expect(html).not.toMatch(/class="[^"]*\bbg-background\b[^"]*"/);
|
|
74
|
+
expect(html).toContain('<p class="mb-4 leading-relaxed text-foreground">Hello world</p>');
|
|
49
75
|
});
|
|
50
76
|
});
|