@hutusi/amytis 1.15.0 → 1.17.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/.claude/rules/immersive-reading.md +21 -0
- package/.claude/rules/rst.md +13 -0
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +89 -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 +298 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +237 -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 +710 -0
- package/site.config.example.ts +3 -3
- package/site.config.ts +3 -3
- package/src/app/[slug]/layout.tsx +30 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +85 -34
- package/src/app/globals.css +570 -123
- package/src/app/page.tsx +7 -1
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookReadingShell.tsx +145 -0
- 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/CuratedSeriesSection.tsx +28 -10
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +44 -23
- package/src/components/Footer.tsx +1 -1
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/ImmersiveReader.tsx +130 -0
- package/src/components/ImmersiveReaderTopBar.tsx +106 -0
- package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
- package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
- package/src/components/ImmersiveReadingProvider.tsx +168 -0
- package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
- package/src/components/ImmersiveToggleButton.tsx +45 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +175 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/ReadingProgressBar.tsx +1 -1
- 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/SelectedBooksSection.tsx +27 -8
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +44 -0
- package/src/layouts/BookLayout.tsx +62 -74
- package/src/layouts/PostLayout.tsx +154 -111
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +217 -57
- 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/scroll-utils.ts +44 -6
- 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/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +62 -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/book-index-cta.test.ts +87 -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/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +443 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/tests/unit/static-params.test.ts +32 -19
- package/vercel.json +7 -0
|
@@ -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
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import HorizontalScroll from './HorizontalScroll';
|
|
6
6
|
import CoverImage from './CoverImage';
|
|
7
7
|
import { useLanguage } from './LanguageProvider';
|
|
8
|
-
import { shuffle
|
|
8
|
+
import { shuffle } from '@/lib/shuffle';
|
|
9
|
+
import { byDateAsc, byDateDesc } from '@/lib/sort';
|
|
9
10
|
import { getPostUrl, getSeriesListUrl } from '@/lib/urls';
|
|
10
11
|
|
|
11
12
|
export interface SeriesItem {
|
|
@@ -16,21 +17,38 @@ export interface SeriesItem {
|
|
|
16
17
|
url: string;
|
|
17
18
|
postCount: number;
|
|
18
19
|
topPosts: { slug: string; title: string }[];
|
|
20
|
+
date: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
type SeriesOrder = 'shuffle' | 'date-desc' | 'date-asc';
|
|
24
|
+
|
|
21
25
|
interface CuratedSeriesSectionProps {
|
|
22
26
|
allSeries: SeriesItem[];
|
|
23
27
|
maxItems: number;
|
|
28
|
+
order?: SeriesOrder;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function canonicalOrder(series: SeriesItem[], order: SeriesOrder): SeriesItem[] {
|
|
32
|
+
if (order === 'date-desc') return [...series].sort(byDateDesc);
|
|
33
|
+
if (order === 'date-asc') return [...series].sort(byDateAsc);
|
|
34
|
+
return series;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
|
-
export default function CuratedSeriesSection({ allSeries, maxItems }: CuratedSeriesSectionProps) {
|
|
37
|
+
export default function CuratedSeriesSection({ allSeries, maxItems, order = 'shuffle' }: CuratedSeriesSectionProps) {
|
|
27
38
|
const { t } = useLanguage();
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
// SSR renders the canonical input order so server and client agree on first paint.
|
|
40
|
+
// For 'shuffle', the post-mount useEffect swaps to a fresh random permutation,
|
|
41
|
+
// so every reload re-rolls without any hydration mismatch.
|
|
42
|
+
const [displayed, setDisplayed] = useState(() => canonicalOrder(allSeries, order).slice(0, maxItems));
|
|
43
|
+
|
|
44
|
+
// Shuffle on mount so every reload re-rolls. SSR's canonical render is stable; the
|
|
45
|
+
// post-hydration swap is the intentional client-only behaviour, not a sync issue.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (order === 'shuffle') {
|
|
48
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
49
|
+
setDisplayed(shuffle(allSeries).slice(0, maxItems));
|
|
50
|
+
}
|
|
51
|
+
}, [allSeries, maxItems, order]);
|
|
34
52
|
|
|
35
53
|
const handleShuffle = useCallback(() => {
|
|
36
54
|
setDisplayed(shuffle(allSeries).slice(0, maxItems));
|
|
@@ -43,7 +61,7 @@ export default function CuratedSeriesSection({ allSeries, maxItems }: CuratedSer
|
|
|
43
61
|
<div className="flex items-center justify-between mb-8">
|
|
44
62
|
<h2 className="text-2xl sm:text-3xl font-serif font-bold text-heading">{t('curated_series')}</h2>
|
|
45
63
|
<div className="flex items-center gap-4">
|
|
46
|
-
{allSeries.length > maxItems && (
|
|
64
|
+
{order === 'shuffle' && allSeries.length > maxItems && (
|
|
47
65
|
<button
|
|
48
66
|
onClick={handleShuffle}
|
|
49
67
|
className="rounded-sm text-sm text-muted transition-colors hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2"
|
|
@@ -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
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import CoverImage from './CoverImage';
|
|
6
6
|
import { useLanguage } from './LanguageProvider';
|
|
7
|
-
import { shuffle
|
|
7
|
+
import { shuffle } from '@/lib/shuffle';
|
|
8
|
+
import { byDateAsc, byDateDesc } from '@/lib/sort';
|
|
8
9
|
import { getPostUrl } from '@/lib/urls';
|
|
9
10
|
|
|
10
11
|
export interface FeaturedPost {
|
|
@@ -14,62 +15,82 @@ export interface FeaturedPost {
|
|
|
14
15
|
excerpt: string;
|
|
15
16
|
date: string;
|
|
16
17
|
category: string;
|
|
17
|
-
|
|
18
|
+
readingMinutes: number;
|
|
18
19
|
coverImage?: string;
|
|
19
20
|
series?: string;
|
|
20
21
|
pinned?: boolean;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
type PostOrder = 'shuffle' | 'date-desc' | 'date-asc';
|
|
25
|
+
|
|
23
26
|
interface FeaturedStoriesSectionProps {
|
|
24
27
|
allFeatured: FeaturedPost[];
|
|
25
28
|
maxItems: number;
|
|
29
|
+
order?: PostOrder;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canonicalOrder(posts: FeaturedPost[], order: PostOrder): FeaturedPost[] {
|
|
33
|
+
if (order === 'date-desc') return [...posts].sort(byDateDesc);
|
|
34
|
+
if (order === 'date-asc') return [...posts].sort(byDateAsc);
|
|
35
|
+
return posts;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
|
-
function buildDisplayed(allFeatured: FeaturedPost[], maxItems: number,
|
|
38
|
+
function buildDisplayed(allFeatured: FeaturedPost[], maxItems: number, orderedNonPinned: FeaturedPost[]): FeaturedPost[] {
|
|
29
39
|
const pinned = allFeatured.filter(p => p.pinned);
|
|
30
|
-
const nonPinned = allFeatured.filter(p => !p.pinned);
|
|
31
40
|
|
|
32
|
-
const hero = pinned[0] ??
|
|
41
|
+
const hero = pinned[0] ?? orderedNonPinned[0];
|
|
33
42
|
if (!hero) return [];
|
|
34
43
|
|
|
35
44
|
const maxSecondaries = maxItems - 1;
|
|
36
45
|
const fixedSecondaries = pinned.slice(1, maxSecondaries + 1); // cap to available secondary slots
|
|
37
|
-
const
|
|
46
|
+
const fillSlots = Math.max(0, maxSecondaries - fixedSecondaries.length);
|
|
38
47
|
|
|
39
48
|
// Non-pinned pool excludes the hero if the hero is non-pinned
|
|
40
49
|
const heroIsNonPinned = !hero.pinned;
|
|
41
|
-
const
|
|
42
|
-
const
|
|
50
|
+
const fillPool = heroIsNonPinned ? orderedNonPinned.filter(p => p.slug !== hero.slug) : orderedNonPinned;
|
|
51
|
+
const fillSlice = fillPool.slice(0, fillSlots);
|
|
43
52
|
|
|
44
|
-
return [hero, ...fixedSecondaries, ...
|
|
53
|
+
return [hero, ...fixedSecondaries, ...fillSlice];
|
|
45
54
|
}
|
|
46
55
|
|
|
47
|
-
export default function FeaturedStoriesSection({ allFeatured, maxItems }: FeaturedStoriesSectionProps) {
|
|
56
|
+
export default function FeaturedStoriesSection({ allFeatured, maxItems, order = 'shuffle' }: FeaturedStoriesSectionProps) {
|
|
48
57
|
const { t } = useLanguage();
|
|
49
58
|
|
|
50
59
|
const nonPinned = allFeatured.filter(p => !p.pinned);
|
|
51
60
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
// SSR renders the canonical input order so server and client agree on first paint.
|
|
62
|
+
// For 'shuffle', the post-mount useEffect swaps to a fresh random permutation,
|
|
63
|
+
// so every reload re-rolls without any hydration mismatch.
|
|
64
|
+
const [orderedNonPinned, setOrderedNonPinned] = useState<FeaturedPost[]>(() => canonicalOrder(nonPinned, order));
|
|
65
|
+
|
|
66
|
+
// Shuffle on mount so every reload re-rolls. SSR's canonical render is stable; the
|
|
67
|
+
// post-hydration swap is the intentional client-only behaviour, not a sync issue.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (order === 'shuffle') {
|
|
70
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
71
|
+
setOrderedNonPinned(shuffle(nonPinned));
|
|
72
|
+
}
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [allFeatured, order]);
|
|
58
75
|
|
|
59
76
|
const handleShuffle = useCallback(() => {
|
|
60
|
-
|
|
77
|
+
setOrderedNonPinned(shuffle(nonPinned));
|
|
61
78
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
62
79
|
}, [allFeatured]);
|
|
63
80
|
|
|
64
|
-
const displayed = buildDisplayed(allFeatured, maxItems,
|
|
81
|
+
const displayed = buildDisplayed(allFeatured, maxItems, orderedNonPinned);
|
|
65
82
|
|
|
66
83
|
if (displayed.length === 0) return null;
|
|
67
84
|
|
|
68
|
-
// Show shuffle button only when there
|
|
85
|
+
// Show shuffle button only when shuffling AND there's at least one non-pinned slot
|
|
86
|
+
// AND there are more non-pinned posts than available slots
|
|
69
87
|
const pinned = allFeatured.filter(p => p.pinned);
|
|
70
88
|
const fixedCount = 1 + Math.min(pinned.slice(1).length, maxItems - 1);
|
|
71
89
|
const shuffleSlots = Math.max(0, maxItems - fixedCount);
|
|
72
|
-
const canShuffle =
|
|
90
|
+
const canShuffle =
|
|
91
|
+
order === 'shuffle'
|
|
92
|
+
&& shuffleSlots > 0
|
|
93
|
+
&& nonPinned.length > shuffleSlots + (pinned.length === 0 ? 1 : 0);
|
|
73
94
|
|
|
74
95
|
const [hero, ...secondary] = displayed;
|
|
75
96
|
|
|
@@ -110,7 +131,7 @@ export default function FeaturedStoriesSection({ allFeatured, maxItems }: Featur
|
|
|
110
131
|
<div className="flex items-center gap-2 text-xs font-mono text-white/60 mb-3 overflow-hidden">
|
|
111
132
|
<span className="text-accent uppercase tracking-wider truncate min-w-0">{hero.category}</span>
|
|
112
133
|
<span className="shrink-0">·</span>
|
|
113
|
-
<span className="shrink-0 whitespace-nowrap">{hero.
|
|
134
|
+
<span className="shrink-0 whitespace-nowrap">{hero.readingMinutes} {t('reading_time')}</span>
|
|
114
135
|
<span className="shrink-0">·</span>
|
|
115
136
|
<span className="shrink-0 whitespace-nowrap">{hero.date}</span>
|
|
116
137
|
</div>
|
|
@@ -141,7 +162,7 @@ export default function FeaturedStoriesSection({ allFeatured, maxItems }: Featur
|
|
|
141
162
|
<div className="flex items-center gap-2 text-xs font-mono text-muted mb-2">
|
|
142
163
|
<span className="text-accent uppercase tracking-wider truncate max-w-[4rem]">{post.category}</span>
|
|
143
164
|
<span className="shrink-0 hidden sm:inline">·</span>
|
|
144
|
-
<span className="shrink-0 hidden sm:inline">{post.
|
|
165
|
+
<span className="shrink-0 hidden sm:inline">{post.readingMinutes} {t('reading_time')}</span>
|
|
145
166
|
<span className="shrink-0">·</span>
|
|
146
167
|
<span className="shrink-0">{post.date}</span>
|
|
147
168
|
</div>
|
|
@@ -11,7 +11,7 @@ export default function Footer() {
|
|
|
11
11
|
const { t, language } = useLanguage();
|
|
12
12
|
|
|
13
13
|
return (
|
|
14
|
-
<footer className="bg-muted/5 border-t border-muted/10 mt-auto select-none">
|
|
14
|
+
<footer data-site-footer className="bg-muted/5 border-t border-muted/10 mt-auto select-none">
|
|
15
15
|
<div className="max-w-6xl mx-auto px-6 py-10 lg:py-16">
|
|
16
16
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12 mb-10 lg:mb-12">
|
|
17
17
|
{/* Brand */}
|
|
@@ -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
|
+
}
|