@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.
Files changed (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +90 -219
  5. package/README.md +33 -1
  6. package/README.zh.md +33 -1
  7. package/TODO.md +10 -0
  8. package/bun.lock +205 -539
  9. package/content/books/sample-book/index.mdx +3 -0
  10. package/content/posts/code-block-features-showcase.mdx +223 -0
  11. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  12. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  13. package/content/series/rst-legacy/getting-started.rst +24 -0
  14. package/content/series/rst-legacy/index.rst +9 -0
  15. package/content/series/rst-readme/README.rst +9 -0
  16. package/content/series/rst-readme/readme-index-post.rst +10 -0
  17. package/content/series/rst-toctree/first-post.rst +6 -0
  18. package/content/series/rst-toctree/index.rst +10 -0
  19. package/content/series/rst-toctree/second-post.rst +6 -0
  20. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/index.rst +12 -0
  22. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  23. package/docs/ALERTS.md +112 -0
  24. package/docs/ARCHITECTURE.md +239 -8
  25. package/docs/CODE-BLOCKS.md +238 -0
  26. package/docs/CONTRIBUTING.md +36 -0
  27. package/docs/guides/README.md +11 -0
  28. package/docs/guides/importing-vuepress-books.md +178 -0
  29. package/eslint.config.mjs +20 -6
  30. package/next.config.ts +2 -2
  31. package/package.json +52 -24
  32. package/packages/create-amytis/package.json +1 -1
  33. package/packages/create-amytis/src/index.test.ts +43 -1
  34. package/packages/create-amytis/src/index.ts +64 -8
  35. package/public/next-image-export-optimizer-hashes.json +14 -73
  36. package/scripts/build-pagefind.ts +172 -0
  37. package/scripts/copy-assets.ts +246 -56
  38. package/scripts/generate-code-group-icons.ts +79 -0
  39. package/scripts/generate-knowledge-graph.ts +2 -1
  40. package/scripts/render-rst.py +923 -0
  41. package/scripts/run-with-rst-python.ts +42 -0
  42. package/scripts/sync-vuepress-book.ts +499 -0
  43. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  44. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  45. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  46. package/src/app/books/[slug]/page.tsx +67 -32
  47. package/src/app/globals.css +639 -94
  48. package/src/app/page.tsx +1 -1
  49. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  50. package/src/app/series/[slug]/page.tsx +11 -13
  51. package/src/app/series/page.tsx +3 -3
  52. package/src/app/sitemap.ts +3 -3
  53. package/src/components/ArticleCopyCleaner.tsx +64 -0
  54. package/src/components/AuthorCard.tsx +25 -16
  55. package/src/components/BookMobileNav.tsx +44 -50
  56. package/src/components/BookSidebar.tsx +0 -0
  57. package/src/components/CodeBlock.test.tsx +93 -8
  58. package/src/components/CodeBlock.tsx +39 -101
  59. package/src/components/CodeBlockToolbar.tsx +88 -0
  60. package/src/components/CodeGroup.tsx +81 -0
  61. package/src/components/CoverImage.tsx +6 -2
  62. package/src/components/ExternalLinkIcon.tsx +15 -0
  63. package/src/components/FeaturedStoriesSection.tsx +3 -3
  64. package/src/components/GithubAlert.tsx +97 -0
  65. package/src/components/MarkdownRenderer.test.tsx +30 -4
  66. package/src/components/MarkdownRenderer.tsx +148 -24
  67. package/src/components/Mermaid.tsx +32 -1
  68. package/src/components/PostList.tsx +1 -1
  69. package/src/components/PostNavigation.tsx +13 -2
  70. package/src/components/PostSidebar.tsx +13 -2
  71. package/src/components/RstRenderer.test.tsx +93 -0
  72. package/src/components/RstRenderer.tsx +157 -0
  73. package/src/components/Search.tsx +18 -4
  74. package/src/components/SeriesCatalog.tsx +1 -1
  75. package/src/components/ShareBar.tsx +5 -0
  76. package/src/components/TocPanel.tsx +10 -2
  77. package/src/i18n/translations.ts +2 -0
  78. package/src/layouts/BookLayout.tsx +35 -4
  79. package/src/layouts/PostLayout.tsx +10 -2
  80. package/src/layouts/SimpleLayout.tsx +10 -3
  81. package/src/lib/code-group-icons.test.ts +78 -0
  82. package/src/lib/code-group-icons.ts +148 -0
  83. package/src/lib/image-utils.test.ts +19 -0
  84. package/src/lib/image-utils.ts +11 -0
  85. package/src/lib/markdown.test.ts +195 -14
  86. package/src/lib/markdown.ts +928 -254
  87. package/src/lib/normalize-vuepress-math.ts +118 -0
  88. package/src/lib/rehype-fence-meta.ts +22 -0
  89. package/src/lib/rehype-image-metadata.ts +2 -2
  90. package/src/lib/remark-book-chapter-links.ts +106 -0
  91. package/src/lib/remark-code-group.ts +54 -0
  92. package/src/lib/remark-github-alerts.test.ts +83 -0
  93. package/src/lib/remark-github-alerts.ts +65 -0
  94. package/src/lib/remark-vuepress-containers.ts +130 -0
  95. package/src/lib/rst-renderer.test.ts +355 -0
  96. package/src/lib/rst-renderer.ts +629 -0
  97. package/src/lib/rst.test.ts +350 -0
  98. package/src/lib/rst.ts +674 -0
  99. package/src/lib/series-redirects.ts +42 -0
  100. package/src/lib/shiki-rst.ts +185 -0
  101. package/src/lib/shiki.test.ts +153 -0
  102. package/src/lib/shiki.ts +292 -0
  103. package/src/lib/urls.ts +57 -0
  104. package/src/test-utils/render.ts +23 -0
  105. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  106. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  107. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  108. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  109. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  110. package/tests/helpers/env.ts +19 -0
  111. package/tests/integration/book-chapter-links.test.ts +107 -0
  112. package/tests/integration/books-nested-toc.test.ts +176 -0
  113. package/tests/integration/books.test.ts +3 -2
  114. package/tests/integration/code-block-features.test.ts +188 -0
  115. package/tests/integration/code-group.test.ts +183 -0
  116. package/tests/integration/code-notation.test.ts +97 -0
  117. package/tests/integration/feed-utils.test.ts +13 -0
  118. package/tests/integration/github-alerts.test.ts +82 -0
  119. package/tests/integration/markdown-external-links.test.ts +103 -0
  120. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  121. package/tests/integration/reading-time-headings.test.ts +12 -14
  122. package/tests/integration/series-draft.test.ts +12 -5
  123. package/tests/integration/series.test.ts +93 -0
  124. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  125. package/tests/integration/vuepress-containers.test.ts +107 -0
  126. package/tests/tooling/build-pagefind.test.ts +66 -0
  127. package/tests/tooling/new-post.test.ts +1 -1
  128. package/tests/unit/static-params.test.ts +166 -13
@@ -1,114 +1,52 @@
1
- "use client";
2
-
3
- import React, { useState } from 'react';
4
- import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
5
- import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
6
- import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
7
- import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
8
- import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash';
9
- import markdown from 'react-syntax-highlighter/dist/esm/languages/prism/markdown';
10
- import json from 'react-syntax-highlighter/dist/esm/languages/prism/json';
11
- import css from 'react-syntax-highlighter/dist/esm/languages/prism/css';
12
- import python from 'react-syntax-highlighter/dist/esm/languages/prism/python';
13
- import rust from 'react-syntax-highlighter/dist/esm/languages/prism/rust';
14
- import go from 'react-syntax-highlighter/dist/esm/languages/prism/go';
15
- import c from 'react-syntax-highlighter/dist/esm/languages/prism/c';
16
- import cpp from 'react-syntax-highlighter/dist/esm/languages/prism/cpp';
17
- import java from 'react-syntax-highlighter/dist/esm/languages/prism/java';
18
- import ruby from 'react-syntax-highlighter/dist/esm/languages/prism/ruby';
19
- import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql';
20
- import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml';
21
- import diff from 'react-syntax-highlighter/dist/esm/languages/prism/diff';
22
- import markup from 'react-syntax-highlighter/dist/esm/languages/prism/markup';
23
-
24
- SyntaxHighlighter.registerLanguage('tsx', tsx);
25
- SyntaxHighlighter.registerLanguage('typescript', typescript);
26
- SyntaxHighlighter.registerLanguage('javascript', javascript);
27
- SyntaxHighlighter.registerLanguage('js', javascript);
28
- SyntaxHighlighter.registerLanguage('bash', bash);
29
- SyntaxHighlighter.registerLanguage('sh', bash);
30
- SyntaxHighlighter.registerLanguage('shell', bash);
31
- SyntaxHighlighter.registerLanguage('markdown', markdown);
32
- SyntaxHighlighter.registerLanguage('md', markdown);
33
- SyntaxHighlighter.registerLanguage('json', json);
34
- SyntaxHighlighter.registerLanguage('css', css);
35
- SyntaxHighlighter.registerLanguage('python', python);
36
- SyntaxHighlighter.registerLanguage('py', python);
37
- SyntaxHighlighter.registerLanguage('rust', rust);
38
- SyntaxHighlighter.registerLanguage('go', go);
39
- SyntaxHighlighter.registerLanguage('golang', go);
40
- SyntaxHighlighter.registerLanguage('c', c);
41
- SyntaxHighlighter.registerLanguage('cpp', cpp);
42
- SyntaxHighlighter.registerLanguage('c++', cpp);
43
- SyntaxHighlighter.registerLanguage('java', java);
44
- SyntaxHighlighter.registerLanguage('ruby', ruby);
45
- SyntaxHighlighter.registerLanguage('rb', ruby);
46
- SyntaxHighlighter.registerLanguage('sql', sql);
47
- SyntaxHighlighter.registerLanguage('yaml', yaml);
48
- SyntaxHighlighter.registerLanguage('yml', yaml);
49
- SyntaxHighlighter.registerLanguage('diff', diff);
50
- SyntaxHighlighter.registerLanguage('html', markup);
51
- SyntaxHighlighter.registerLanguage('xml', markup);
52
- SyntaxHighlighter.registerLanguage('svg', markup);
1
+ import { toHtml } from 'hast-util-to-html';
2
+ import CodeBlockToolbar from './CodeBlockToolbar';
3
+ import { getLanguageDisplayName, highlightToHast } from '@/lib/shiki';
53
4
 
54
5
  interface CodeBlockProps {
55
6
  language: string;
56
7
  children: string;
57
- [key: string]: unknown;
8
+ title?: string;
9
+ showLineNumbers?: boolean;
10
+ highlightLines?: number[];
58
11
  }
59
12
 
60
- export default function CodeBlock({ language, children, ...props }: CodeBlockProps) {
61
- const [copied, setCopied] = useState(false);
62
-
63
- const handleCopy = async () => {
64
- await navigator.clipboard.writeText(children);
65
- setCopied(true);
66
- setTimeout(() => setCopied(false), 2000);
67
- };
13
+ export default async function CodeBlock({
14
+ language,
15
+ children,
16
+ title,
17
+ showLineNumbers,
18
+ highlightLines,
19
+ }: CodeBlockProps) {
20
+ const hast = await highlightToHast(children, language, {
21
+ showLineNumbers,
22
+ highlightLines,
23
+ title,
24
+ });
25
+ const html = toHtml(hast);
26
+ const displayLang = getLanguageDisplayName(language || 'text');
68
27
 
69
28
  return (
70
- <div className="relative my-6 w-full min-w-0 max-w-full rounded-lg border border-muted/20 bg-background/50 overflow-hidden shadow-sm">
71
- <div className="flex items-center justify-between px-4 py-2 border-b border-muted/10 bg-muted/5">
72
- <span className="text-xs font-mono text-muted uppercase tracking-wider">
73
- {language || 'text'}
74
- </span>
75
- <button
76
- onClick={handleCopy}
77
- className="text-xs text-muted hover:text-accent transition-colors duration-200 flex items-center gap-1"
78
- aria-label="Copy code"
79
- >
80
- {copied ? (
81
- <>
82
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
83
- <span>Copied</span>
84
- </>
85
- ) : (
86
- <>
87
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
88
- <span>Copy</span>
89
- </>
29
+ <div
30
+ data-cb-root=""
31
+ className="cb-root relative my-6 w-full min-w-0 max-w-full rounded-lg border border-muted/20 bg-background/50 overflow-hidden shadow-sm"
32
+ >
33
+ <div className="cb-header flex items-center justify-between px-4 py-2 border-b border-muted/10 bg-muted/5 gap-3">
34
+ <div className="flex items-center gap-3 min-w-0">
35
+ <span className="cb-lang text-xs font-mono text-muted tracking-wider">
36
+ {displayLang}
37
+ </span>
38
+ {title && (
39
+ <span className="cb-title truncate text-xs text-foreground/80" title={title}>
40
+ {title}
41
+ </span>
90
42
  )}
91
- </button>
92
- </div>
93
- <div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden">
94
- <SyntaxHighlighter
95
- language={language || 'text'}
96
- PreTag="div"
97
- useInlineStyles={false}
98
- customStyle={{
99
- margin: 0,
100
- padding: '1.5rem',
101
- background: 'transparent',
102
- fontSize: '0.9rem',
103
- lineHeight: '1.6',
104
- maxWidth: '100%',
105
- boxSizing: 'border-box',
106
- }}
107
- {...props}
108
- >
109
- {children}
110
- </SyntaxHighlighter>
43
+ </div>
44
+ <CodeBlockToolbar code={children} />
111
45
  </div>
46
+ <div
47
+ className="cb-scroll w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden"
48
+ dangerouslySetInnerHTML={{ __html: html }}
49
+ />
112
50
  </div>
113
51
  );
114
52
  }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from 'react';
4
+
5
+ interface CodeBlockToolbarProps {
6
+ code: string;
7
+ }
8
+
9
+ export default function CodeBlockToolbar({ code }: CodeBlockToolbarProps) {
10
+ const [copied, setCopied] = useState(false);
11
+ const [wrapped, setWrapped] = useState(false);
12
+ const buttonRef = useRef<HTMLButtonElement | null>(null);
13
+
14
+ const handleCopy = async () => {
15
+ try {
16
+ await navigator.clipboard.writeText(code);
17
+ setCopied(true);
18
+ setTimeout(() => setCopied(false), 2000);
19
+ } catch {
20
+ // Clipboard may be unavailable (e.g. insecure context); fail silently.
21
+ }
22
+ };
23
+
24
+ const handleToggleWrap = () => {
25
+ const next = !wrapped;
26
+ setWrapped(next);
27
+ const root = buttonRef.current?.closest('[data-cb-root]') as HTMLElement | null;
28
+ if (root) {
29
+ if (next) {
30
+ root.setAttribute('data-wrap', 'true');
31
+ } else {
32
+ root.removeAttribute('data-wrap');
33
+ }
34
+ }
35
+ };
36
+
37
+ return (
38
+ <div className="flex items-center gap-3">
39
+ <button
40
+ ref={buttonRef}
41
+ onClick={handleToggleWrap}
42
+ className="text-xs text-muted hover:text-accent transition-colors duration-200 flex items-center gap-1"
43
+ aria-label={wrapped ? 'Disable word wrap' : 'Enable word wrap'}
44
+ aria-pressed={wrapped}
45
+ type="button"
46
+ >
47
+ {wrapped ? (
48
+ <>
49
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
50
+ <path d="M3 6h18" />
51
+ <path d="M3 12h15a3 3 0 0 1 0 6h-4" />
52
+ <path d="m16 16-2 2 2 2" />
53
+ <path d="M3 18h7" />
54
+ </svg>
55
+ <span>Wrap</span>
56
+ </>
57
+ ) : (
58
+ <>
59
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
60
+ <path d="M3 6h18" />
61
+ <path d="M3 12h18" />
62
+ <path d="M3 18h18" />
63
+ </svg>
64
+ <span>No wrap</span>
65
+ </>
66
+ )}
67
+ </button>
68
+ <button
69
+ onClick={handleCopy}
70
+ className="text-xs text-muted hover:text-accent transition-colors duration-200 flex items-center gap-1"
71
+ aria-label="Copy code"
72
+ type="button"
73
+ >
74
+ {copied ? (
75
+ <>
76
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
77
+ <span>Copied</span>
78
+ </>
79
+ ) : (
80
+ <>
81
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
82
+ <span>Copy</span>
83
+ </>
84
+ )}
85
+ </button>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,81 @@
1
+ import { Children, type ReactNode } from 'react';
2
+ import { resolveCodeGroupIcon } from '@/lib/code-group-icons';
3
+
4
+ interface CodeGroupProps {
5
+ 'data-labels'?: string;
6
+ 'data-group-id'?: string;
7
+ children?: ReactNode;
8
+ }
9
+
10
+ /**
11
+ * Tabbed code group widget. CSS-only — the radio + label sibling trick handles
12
+ * tab switching with zero JavaScript. The matching CSS in globals.css picks the
13
+ * checked input's matching panel via attribute selectors (input[data-idx=N] →
14
+ * .cg-panel[data-panel=N]).
15
+ *
16
+ * Children are already-highlighted CodeBlock server components from the
17
+ * outer MarkdownRenderer pipeline; this component just composes the tab UI
18
+ * around them.
19
+ */
20
+ export default function CodeGroup(props: CodeGroupProps) {
21
+ const labelsRaw = props['data-labels'] ?? '[]';
22
+ const groupId = props['data-group-id'] ?? 'cg-default';
23
+ let labels: string[];
24
+ try {
25
+ const parsed = JSON.parse(labelsRaw);
26
+ labels = Array.isArray(parsed) ? parsed.map(String) : [];
27
+ } catch {
28
+ labels = [];
29
+ }
30
+
31
+ // Drop whitespace text nodes that remark-rehype leaves between code children.
32
+ const panels = Children.toArray(props.children).filter((child) => {
33
+ if (typeof child === 'string') return child.trim().length > 0;
34
+ return true;
35
+ });
36
+ const tabs = labels.length > 0 ? labels : panels.map((_, i) => `Tab ${i + 1}`);
37
+
38
+ return (
39
+ <div className="code-group my-6" data-group-id={groupId}>
40
+ {tabs.map((_, i) => (
41
+ <input
42
+ key={`r-${i}`}
43
+ type="radio"
44
+ name={`cg-${groupId}`}
45
+ id={`cg-${groupId}-${i}`}
46
+ data-idx={String(i)}
47
+ defaultChecked={i === 0}
48
+ aria-controls={`cg-${groupId}-panel-${i}`}
49
+ tabIndex={i === 0 ? 0 : -1}
50
+ />
51
+ ))}
52
+ <div className="cg-tablist" role="tablist">
53
+ {tabs.map((label, i) => {
54
+ const icon = resolveCodeGroupIcon(label);
55
+ return (
56
+ <label
57
+ key={`l-${i}`}
58
+ htmlFor={`cg-${groupId}-${i}`}
59
+ className="cg-tab"
60
+ role="tab"
61
+ {...(icon ? { 'data-cg-icon': icon } : {})}
62
+ >
63
+ {label}
64
+ </label>
65
+ );
66
+ })}
67
+ </div>
68
+ {panels.map((child, i) => (
69
+ <div
70
+ key={`p-${i}`}
71
+ className="cg-panel"
72
+ data-panel={String(i)}
73
+ role="tabpanel"
74
+ id={`cg-${groupId}-panel-${i}`}
75
+ >
76
+ {child}
77
+ </div>
78
+ ))}
79
+ </div>
80
+ );
81
+ }
@@ -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={isDev || !!isCdn}
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
- readingTime: string;
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.readingTime}</span>
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.readingTime}</span>
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 = "![alt text](/images/background-new-wave.avif)";
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 = "![alt text](/images/already-optimized.webp)";
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 = renderToStaticMarkup(<MarkdownRenderer content={content} />);
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 a background container for copy-paste fidelity", () => {
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
- expect(html).toMatch(/class="[^"]*\bbg-background\b[^"]*"/);
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
  });