@hutusi/amytis 1.15.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/CLAUDE.md +90 -219
- package/bun.lock +185 -547
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +217 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +178 -0
- package/eslint.config.mjs +18 -6
- package/package.json +42 -20
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/render-rst.py +207 -3
- package/scripts/sync-vuepress-book.ts +499 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/page.tsx +67 -32
- package/src/app/globals.css +503 -123
- package/src/app/page.tsx +1 -1
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CodeBlock.test.tsx +93 -8
- package/src/components/CodeBlock.tsx +39 -101
- package/src/components/CodeBlockToolbar.tsx +88 -0
- package/src/components/CodeGroup.tsx +81 -0
- package/src/components/CoverImage.tsx +1 -0
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +3 -3
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +144 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/RstRenderer.test.tsx +15 -15
- package/src/components/RstRenderer.tsx +37 -2
- package/src/components/Search.tsx +18 -4
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/i18n/translations.ts +2 -0
- package/src/layouts/BookLayout.tsx +35 -4
- package/src/layouts/PostLayout.tsx +5 -1
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +203 -50
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/remark-book-chapter-links.ts +106 -0
- package/src/lib/remark-code-group.ts +54 -0
- package/src/lib/remark-github-alerts.test.ts +83 -0
- package/src/lib/remark-github-alerts.ts +65 -0
- package/src/lib/remark-vuepress-containers.ts +130 -0
- package/src/lib/rst-renderer.ts +19 -7
- package/src/lib/rst.test.ts +212 -2
- package/src/lib/rst.ts +217 -13
- package/src/lib/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/urls.ts +57 -0
- package/src/test-utils/render.ts +23 -0
- package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
- package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
- package/tests/helpers/env.ts +19 -0
- package/tests/integration/book-chapter-links.test.ts +107 -0
- package/tests/integration/books-nested-toc.test.ts +176 -0
- package/tests/integration/books.test.ts +3 -2
- package/tests/integration/code-block-features.test.ts +188 -0
- package/tests/integration/code-group.test.ts +183 -0
- package/tests/integration/code-notation.test.ts +97 -0
- package/tests/integration/github-alerts.test.ts +82 -0
- package/tests/integration/markdown-external-links.test.ts +103 -0
- package/tests/integration/normalize-vuepress-math.test.ts +149 -0
- package/tests/integration/reading-time-headings.test.ts +8 -6
- package/tests/integration/series-draft.test.ts +6 -13
- package/tests/integration/sync-vuepress-book.test.ts +240 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/static-params.test.ts +32 -19
|
@@ -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", () => {
|
|
@@ -42,7 +46,7 @@ describe("MarkdownRenderer", () => {
|
|
|
42
46
|
});
|
|
43
47
|
});
|
|
44
48
|
|
|
45
|
-
test("adds horizontal overflow containment while preserving code scrolling", () => {
|
|
49
|
+
test("adds horizontal overflow containment while preserving code scrolling", async () => {
|
|
46
50
|
const content = [
|
|
47
51
|
"## Example",
|
|
48
52
|
"",
|
|
@@ -51,16 +55,22 @@ describe("MarkdownRenderer", () => {
|
|
|
51
55
|
"```",
|
|
52
56
|
].join("\n");
|
|
53
57
|
|
|
54
|
-
const html =
|
|
58
|
+
const html = await renderAsync(<MarkdownRenderer content={content} />);
|
|
55
59
|
|
|
56
60
|
expect(html).toContain("overflow-x-hidden");
|
|
57
61
|
expect(html).toContain("not-prose w-full min-w-0 max-w-full");
|
|
58
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');
|
|
59
65
|
});
|
|
60
66
|
|
|
61
|
-
test("wraps content in
|
|
67
|
+
test("wraps content in ArticleCopyCleaner so paste output is stripped of per-paragraph backgrounds", () => {
|
|
62
68
|
const content = "Hello world";
|
|
63
69
|
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
64
|
-
|
|
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>');
|
|
65
75
|
});
|
|
66
76
|
});
|
|
@@ -1,20 +1,34 @@
|
|
|
1
|
+
import React from 'react';
|
|
1
2
|
import ReactMarkdown, { Components, ExtraProps } from 'react-markdown';
|
|
2
3
|
import RssFeedWidget from '@/components/RssFeedWidget';
|
|
3
4
|
import Mermaid from '@/components/Mermaid';
|
|
4
5
|
import CodeBlock from '@/components/CodeBlock';
|
|
6
|
+
import CodeGroup from '@/components/CodeGroup';
|
|
7
|
+
import GithubAlert from '@/components/GithubAlert';
|
|
5
8
|
import KatexStyles from '@/components/KatexStyles';
|
|
9
|
+
import ExternalLinkIcon from '@/components/ExternalLinkIcon';
|
|
10
|
+
import ArticleCopyCleaner from '@/components/ArticleCopyCleaner';
|
|
6
11
|
import remarkGfm from 'remark-gfm';
|
|
12
|
+
import remarkDirective from 'remark-directive';
|
|
13
|
+
import remarkCodeGroup from '@/lib/remark-code-group';
|
|
14
|
+
import remarkGithubAlerts from '@/lib/remark-github-alerts';
|
|
15
|
+
import remarkVuepressContainers, { normalizeVuepressContainerSyntax } from '@/lib/remark-vuepress-containers';
|
|
16
|
+
import { normalizeVuepressBlockMath } from '@/lib/normalize-vuepress-math';
|
|
17
|
+
import remarkBookChapterLinks, { type BookChapterLinksOptions } from '@/lib/remark-book-chapter-links';
|
|
7
18
|
import rehypeRaw from 'rehype-raw';
|
|
8
19
|
import remarkMath from 'remark-math';
|
|
9
20
|
import rehypeKatex from 'rehype-katex';
|
|
10
21
|
import rehypeSlug from 'rehype-slug';
|
|
11
22
|
import rehypeImageMetadata from '@/lib/rehype-image-metadata';
|
|
23
|
+
import rehypeFenceMeta from '@/lib/rehype-fence-meta';
|
|
12
24
|
import { siteConfig } from '../../site.config';
|
|
13
25
|
import remarkWikilinks from '@/lib/remark-wikilinks';
|
|
14
26
|
import ExportedImage from 'next-image-export-optimizer';
|
|
15
27
|
import { PluggableList } from 'unified';
|
|
16
28
|
import type { SlugRegistryEntry } from '@/lib/markdown';
|
|
17
29
|
import { shouldBypassImageOptimization } from '@/lib/image-utils';
|
|
30
|
+
import { parseFenceMeta } from '@/lib/shiki';
|
|
31
|
+
import { isExternalUrl } from '@/lib/urls';
|
|
18
32
|
|
|
19
33
|
|
|
20
34
|
interface MarkdownRendererProps {
|
|
@@ -22,12 +36,33 @@ interface MarkdownRendererProps {
|
|
|
22
36
|
latex?: boolean;
|
|
23
37
|
slug?: string;
|
|
24
38
|
slugRegistry?: Map<string, SlugRegistryEntry>;
|
|
39
|
+
/**
|
|
40
|
+
* Set when rendering a book chapter. Enables inter-chapter `.md` link
|
|
41
|
+
* rewriting and `:::container` → GitHub Alert conversion (the latter runs
|
|
42
|
+
* for everyone, but the link rewriter needs source-path context).
|
|
43
|
+
*/
|
|
44
|
+
bookContext?: BookChapterLinksOptions;
|
|
25
45
|
}
|
|
26
46
|
|
|
27
|
-
export default function MarkdownRenderer({ content, latex = false, slug, slugRegistry }: MarkdownRendererProps) {
|
|
28
|
-
|
|
47
|
+
export default function MarkdownRenderer({ content, latex = false, slug, slugRegistry, bookContext }: MarkdownRendererProps) {
|
|
48
|
+
// remark-directive must precede remark-code-group AND remark-vuepress-containers
|
|
49
|
+
// so they see parsed containerDirective nodes. Order vs remark-gfm doesn't matter
|
|
50
|
+
// — they touch disjoint node types.
|
|
51
|
+
const remarkPlugins: PluggableList = [
|
|
52
|
+
remarkGfm,
|
|
53
|
+
remarkGithubAlerts,
|
|
54
|
+
remarkDirective,
|
|
55
|
+
remarkCodeGroup,
|
|
56
|
+
remarkVuepressContainers,
|
|
57
|
+
];
|
|
58
|
+
if (bookContext) {
|
|
59
|
+
remarkPlugins.push([remarkBookChapterLinks, bookContext]);
|
|
60
|
+
}
|
|
29
61
|
const cdnBaseUrl = siteConfig.images?.cdnBaseUrl ?? '';
|
|
30
|
-
|
|
62
|
+
// rehypeFenceMeta must run BEFORE rehypeRaw — rehypeRaw round-trips through HTML
|
|
63
|
+
// serialization, which drops node.data.meta (a non-HTML field). Copying meta to a
|
|
64
|
+
// real data-meta attribute first lets it survive the round trip.
|
|
65
|
+
const rehypePlugins: PluggableList = [rehypeFenceMeta, rehypeRaw, rehypeSlug, [rehypeImageMetadata, { slug, cdnBaseUrl }]];
|
|
31
66
|
|
|
32
67
|
if (slugRegistry && slugRegistry.size > 0) {
|
|
33
68
|
remarkPlugins.push([remarkWikilinks, { slugRegistry }]);
|
|
@@ -35,7 +70,15 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
35
70
|
|
|
36
71
|
if (latex) {
|
|
37
72
|
remarkPlugins.push(remarkMath);
|
|
38
|
-
|
|
73
|
+
// Silence only KaTeX's `unicodeTextInMathMode` warnings — Chinese-language
|
|
74
|
+
// books routinely write math like `$输入$` or `$h_{隐藏状态}$` and KaTeX
|
|
75
|
+
// renders the CJK characters fine; the warning is pure noise (one log per
|
|
76
|
+
// character per chapter view). A bare `strict: 'ignore'` would silence
|
|
77
|
+
// *every* KaTeX strict check including genuinely broken math, so use a
|
|
78
|
+
// predicate that targets just this transgression.
|
|
79
|
+
rehypePlugins.push([rehypeKatex, {
|
|
80
|
+
strict: (code: string) => (code === 'unicodeTextInMathMode' ? 'ignore' : 'warn'),
|
|
81
|
+
}]);
|
|
39
82
|
}
|
|
40
83
|
|
|
41
84
|
const components: Components = {
|
|
@@ -70,37 +113,68 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
70
113
|
pre: ({ children }) => <div className="not-prose w-full min-w-0 max-w-full">{children}</div>,
|
|
71
114
|
// Style links individually to avoid hover-all issue
|
|
72
115
|
a: (props) => {
|
|
73
|
-
|
|
74
|
-
const { node: _node, className, ...rest } = props as React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
|
|
116
|
+
const { node, className, children, href, target, rel, ...rest } = props as React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
|
|
75
117
|
// Preserve wikilink classes injected by remark-wikilinks — they have their own CSS styling
|
|
76
118
|
if (className?.includes('wikilink')) {
|
|
77
|
-
return <a {...rest} className={className}
|
|
119
|
+
return <a {...rest} href={href} target={target} rel={rel} className={className}>{children}</a>;
|
|
120
|
+
}
|
|
121
|
+
const linkClass = "text-accent no-underline hover:underline transition-colors duration-200";
|
|
122
|
+
if (isExternalUrl(href)) {
|
|
123
|
+
// Image-as-link (`[](href)`): an inline arrow after the
|
|
124
|
+
// image looks like a glyph, not a hint. The HAST `node` exposes the
|
|
125
|
+
// pre-override children so we can spot an `<img>` child reliably —
|
|
126
|
+
// by the time react-markdown passes `children` to us, our own `img`
|
|
127
|
+
// override has already replaced the raw <img> with a component.
|
|
128
|
+
const hastChildren = (node && 'children' in node) ? node.children : [];
|
|
129
|
+
const isImageLink = hastChildren.length === 1 && 'tagName' in hastChildren[0] && hastChildren[0].tagName === 'img';
|
|
130
|
+
return (
|
|
131
|
+
<a
|
|
132
|
+
{...rest}
|
|
133
|
+
href={href}
|
|
134
|
+
target={target ?? '_blank'}
|
|
135
|
+
rel={rel ?? 'noopener noreferrer'}
|
|
136
|
+
className={linkClass}
|
|
137
|
+
>
|
|
138
|
+
{children}
|
|
139
|
+
{!isImageLink && <ExternalLinkIcon />}
|
|
140
|
+
</a>
|
|
141
|
+
);
|
|
78
142
|
}
|
|
79
|
-
return <a {...rest}
|
|
143
|
+
return <a {...rest} href={href} target={target} rel={rel} className={linkClass}>{children}</a>;
|
|
80
144
|
},
|
|
81
145
|
// Custom code renderer: handles 'mermaid' blocks and syntax highlighting
|
|
82
146
|
code(props: React.ClassAttributes<HTMLElement> & React.HTMLAttributes<HTMLElement> & ExtraProps) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
147
|
+
const { className, children } = props;
|
|
148
|
+
// [^\s]+ rather than \w+ so fences like ```c++ or ```objective-c++ are detected
|
|
149
|
+
// as `c++` / `objective-c++` and not truncated to `c` at the punctuation boundary.
|
|
150
|
+
const match = /language-([^\s]+)/.exec(className || '');
|
|
86
151
|
const language = match ? match[1] : '';
|
|
87
152
|
const isMultiLine = String(children).includes('\n');
|
|
88
|
-
|
|
89
|
-
// In react-markdown v10, 'inline' prop is removed.
|
|
153
|
+
|
|
154
|
+
// In react-markdown v10, 'inline' prop is removed.
|
|
90
155
|
// We use className presence (e.g. language-js) or newline presence to detect code blocks.
|
|
91
156
|
if (match || isMultiLine) {
|
|
92
157
|
if (language === 'mermaid') {
|
|
93
158
|
return <Mermaid chart={String(children).replace(/\n$/, '')} />;
|
|
94
159
|
}
|
|
160
|
+
// react-markdown v10 strips node.data before invoking overrides, so the
|
|
161
|
+
// fence meta is surfaced as a real `data-meta` attribute by rehypeFenceMeta.
|
|
162
|
+
const meta = (props as unknown as Record<string, unknown>)['data-meta'];
|
|
163
|
+
const parsedMeta = parseFenceMeta(typeof meta === 'string' ? meta : undefined);
|
|
95
164
|
return (
|
|
96
|
-
<CodeBlock
|
|
165
|
+
<CodeBlock
|
|
166
|
+
language={language}
|
|
167
|
+
title={parsedMeta.title}
|
|
168
|
+
showLineNumbers={parsedMeta.showLineNumbers}
|
|
169
|
+
highlightLines={parsedMeta.highlightLines}
|
|
170
|
+
>
|
|
97
171
|
{String(children).replace(/\n$/, '')}
|
|
98
172
|
</CodeBlock>
|
|
99
173
|
);
|
|
100
174
|
}
|
|
101
175
|
|
|
102
176
|
return (
|
|
103
|
-
<code className={className}
|
|
177
|
+
<code className={className}>
|
|
104
178
|
{children}
|
|
105
179
|
</code>
|
|
106
180
|
);
|
|
@@ -134,11 +208,36 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
134
208
|
// In development mode, use unoptimized images since WebP versions don't exist yet
|
|
135
209
|
img: (props: React.ClassAttributes<HTMLImageElement> & React.ImgHTMLAttributes<HTMLImageElement> & ExtraProps) => {
|
|
136
210
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
137
|
-
const { src, alt, width, height, node: _node, ...rest } = props;
|
|
211
|
+
const { src, alt, width, height, node: _node, style, ...rest } = props;
|
|
138
212
|
const isDev = process.env.NODE_ENV === 'development';
|
|
139
213
|
const imageSrc = src as string;
|
|
140
214
|
const isExternal = imageSrc?.startsWith('http') || imageSrc?.startsWith('//');
|
|
141
215
|
|
|
216
|
+
// Author-supplied inline `style` is a strong signal the <img> came from
|
|
217
|
+
// raw HTML inside the markdown (typically inline icons like social-media
|
|
218
|
+
// badges) rather than from a markdown ``. Markdown images
|
|
219
|
+
// never carry a style attribute. Preserve the author's styling and
|
|
220
|
+
// skip optimization for these — wrapping a 22px icon in <ExportedImage>
|
|
221
|
+
// strips the style and renders it at its natural 500px size.
|
|
222
|
+
if (style) {
|
|
223
|
+
// width / height were destructured out of `rest` above, so re-apply
|
|
224
|
+
// them here. Mixed author markup like `<img src="..." width="120"
|
|
225
|
+
// style="border-radius:4px">` should keep its explicit sizing rather
|
|
226
|
+
// than render at the SVG's natural dimensions.
|
|
227
|
+
return (
|
|
228
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
229
|
+
<img
|
|
230
|
+
src={imageSrc}
|
|
231
|
+
alt={alt || ''}
|
|
232
|
+
width={width}
|
|
233
|
+
height={height}
|
|
234
|
+
style={style}
|
|
235
|
+
{...rest}
|
|
236
|
+
fetchPriority="low"
|
|
237
|
+
/>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
142
241
|
if (!isExternal) {
|
|
143
242
|
const shouldBypassOptimization = shouldBypassImageOptimization(imageSrc);
|
|
144
243
|
return (
|
|
@@ -152,6 +251,7 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
152
251
|
unoptimized={isDev || shouldBypassOptimization}
|
|
153
252
|
placeholder={shouldBypassOptimization ? 'empty' : 'blur'}
|
|
154
253
|
style={(!width || !height) ? { width: '100%', height: 'auto' } : undefined}
|
|
254
|
+
fetchPriority="low"
|
|
155
255
|
/>
|
|
156
256
|
);
|
|
157
257
|
}
|
|
@@ -160,16 +260,35 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
160
260
|
},
|
|
161
261
|
};
|
|
162
262
|
|
|
163
|
-
// Merge custom HTML elements not in the Components type (e.g. web components used in MDX
|
|
164
|
-
//
|
|
165
|
-
|
|
263
|
+
// Merge custom HTML elements not in the Components type (e.g. web components used in MDX,
|
|
264
|
+
// and the synthetic <code-group> / <github-alert> tagNames emitted by our remark plugins).
|
|
265
|
+
//
|
|
266
|
+
// VuePress component pass-throughs: imported VuePress books may use Vue
|
|
267
|
+
// components like <Swiper>/<Slide> (image carousel), <ClientOnly>, <HomeHero>,
|
|
268
|
+
// <ChatDemo>, <GlobalTOC>. hast/React lowercases these tags, and without a
|
|
269
|
+
// handler React logs "The tag <swiper> is unrecognized in this browser". Map
|
|
270
|
+
// each one to a passive renderer so the warnings go away and inner content
|
|
271
|
+
// (where it makes sense) still appears as a graceful degradation.
|
|
272
|
+
const allComponents = {
|
|
273
|
+
...components,
|
|
274
|
+
'rss-feed': () => <RssFeedWidget />,
|
|
275
|
+
'code-group': CodeGroup,
|
|
276
|
+
'github-alert': GithubAlert,
|
|
277
|
+
swiper: ({ children }: { children?: React.ReactNode }) => <div className="my-6 space-y-4">{children}</div>,
|
|
278
|
+
slide: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
|
279
|
+
clientonly: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
280
|
+
globaltoc: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
281
|
+
homehero: () => null,
|
|
282
|
+
chatdemo: () => null,
|
|
283
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
284
|
+
} as any;
|
|
166
285
|
|
|
167
286
|
return (
|
|
168
287
|
<>
|
|
169
288
|
{latex && <KatexStyles />}
|
|
170
|
-
<
|
|
289
|
+
<ArticleCopyCleaner>
|
|
171
290
|
<div className="prose prose-lg max-w-none min-w-0 overflow-x-hidden text-foreground
|
|
172
|
-
prose-headings:font-serif prose-headings:text-heading
|
|
291
|
+
prose-headings:font-serif prose-headings:text-heading
|
|
173
292
|
prose-p:text-foreground prose-p:leading-loose
|
|
174
293
|
prose-strong:text-heading prose-strong:font-semibold
|
|
175
294
|
prose-code:bg-muted/15 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:border prose-code:border-muted/20 prose-code:text-[0.9em] prose-code:font-medium
|
|
@@ -182,10 +301,12 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
182
301
|
rehypePlugins={rehypePlugins}
|
|
183
302
|
components={allComponents}
|
|
184
303
|
>
|
|
185
|
-
{
|
|
304
|
+
{latex
|
|
305
|
+
? normalizeVuepressBlockMath(normalizeVuepressContainerSyntax(content))
|
|
306
|
+
: normalizeVuepressContainerSyntax(content)}
|
|
186
307
|
</ReactMarkdown>
|
|
187
308
|
</div>
|
|
188
|
-
</
|
|
309
|
+
</ArticleCopyCleaner>
|
|
189
310
|
</>
|
|
190
311
|
);
|
|
191
312
|
}
|
|
@@ -4,6 +4,26 @@ import React, { useEffect, useRef, useState } from "react";
|
|
|
4
4
|
import mermaid from "mermaid";
|
|
5
5
|
import { useTheme } from "next-themes";
|
|
6
6
|
|
|
7
|
+
// Mermaid bundles its own KaTeX and invokes it with `{throwOnError: true,
|
|
8
|
+
// displayMode: true, output: 'mathml'}` — no `strict` option, so KaTeX
|
|
9
|
+
// defaults to `'warn'` and floods the console with one warning per CJK
|
|
10
|
+
// character in math labels (e.g. `S["$$解码器状态:s_{t-1}$$"]`). There is
|
|
11
|
+
// no `mermaid.initialize()` setting to override this. Filter the very
|
|
12
|
+
// specific KaTeX warning template at the console layer; everything else
|
|
13
|
+
// passes through. Idempotent under HMR.
|
|
14
|
+
let consoleWarnFilterInstalled = false;
|
|
15
|
+
function installConsoleWarnFilter(): void {
|
|
16
|
+
if (consoleWarnFilterInstalled || typeof window === "undefined") return;
|
|
17
|
+
consoleWarnFilterInstalled = true;
|
|
18
|
+
const originalWarn = console.warn.bind(console);
|
|
19
|
+
console.warn = (...args: unknown[]) => {
|
|
20
|
+
if (typeof args[0] === "string" && args[0].includes("[unicodeTextInMathMode]")) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
originalWarn(...args);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
interface MermaidProps {
|
|
8
28
|
chart: string;
|
|
9
29
|
}
|
|
@@ -28,6 +48,7 @@ const Mermaid: React.FC<MermaidProps> = ({ chart }) => {
|
|
|
28
48
|
|
|
29
49
|
useEffect(() => {
|
|
30
50
|
if (ref.current && chart && mounted) {
|
|
51
|
+
installConsoleWarnFilter();
|
|
31
52
|
const currentTheme = theme === 'system' ? systemTheme : theme;
|
|
32
53
|
const isDark = currentTheme === 'dark';
|
|
33
54
|
|
|
@@ -76,11 +97,21 @@ const Mermaid: React.FC<MermaidProps> = ({ chart }) => {
|
|
|
76
97
|
}, [chart, theme, systemTheme, mounted]);
|
|
77
98
|
|
|
78
99
|
return (
|
|
79
|
-
<div className="my-
|
|
100
|
+
<div className="my-6 overflow-x-auto">
|
|
101
|
+
{/*
|
|
102
|
+
suppressHydrationWarning is intentional: Mermaid runs client-side
|
|
103
|
+
in `useEffect`, injects its SVG via `dangerouslySetInnerHTML`, and
|
|
104
|
+
then mutates the DOM further (adding `data-processed="true"` on
|
|
105
|
+
this wrapper). React's virtual DOM has no record of those
|
|
106
|
+
mutations, so any HMR-triggered re-render in dev flags the drift
|
|
107
|
+
as a hydration mismatch. Telling React this div is
|
|
108
|
+
intentionally-mutated terrain is the blessed escape hatch.
|
|
109
|
+
*/}
|
|
80
110
|
<div
|
|
81
111
|
className="mermaid w-full flex justify-center"
|
|
82
112
|
dangerouslySetInnerHTML={{ __html: svg }}
|
|
83
113
|
ref={ref}
|
|
114
|
+
suppressHydrationWarning
|
|
84
115
|
/>
|
|
85
116
|
</div>
|
|
86
117
|
);
|
|
@@ -61,7 +61,7 @@ export default function PostList({
|
|
|
61
61
|
<span className="shrink-0">·</span>
|
|
62
62
|
</>
|
|
63
63
|
)}
|
|
64
|
-
<span className="shrink-0 hidden sm:inline">{post.
|
|
64
|
+
<span className="shrink-0 hidden sm:inline">{post.readingMinutes} {t('reading_time')}</span>
|
|
65
65
|
<span className="shrink-0 hidden sm:inline">·</span>
|
|
66
66
|
<span className="shrink-0 whitespace-nowrap">{post.date}</span>
|
|
67
67
|
{post.draft && (
|
|
@@ -37,16 +37,24 @@ export default function PostNavigation({ prev, next, currentSlug, collectionCont
|
|
|
37
37
|
if (!effectivePrev && !effectiveNext) return null;
|
|
38
38
|
|
|
39
39
|
return (
|
|
40
|
+
// suppressHydrationWarning on locale-bound nodes is a band-aid for the
|
|
41
|
+
// known static-export + client-i18n drift: SSR renders defaultLocale,
|
|
42
|
+
// `useLanguage()` hook serves the user's saved locale on hydration. The
|
|
43
|
+
// real fix is per-locale URL routing, tracked as a separate refactor.
|
|
40
44
|
<nav
|
|
41
45
|
className="mt-12 pt-12 border-t border-muted/20 grid grid-cols-1 sm:grid-cols-2 gap-3"
|
|
42
46
|
aria-label={t('post_navigation')}
|
|
47
|
+
suppressHydrationWarning
|
|
43
48
|
>
|
|
44
49
|
{effectivePrev && (
|
|
45
50
|
<Link
|
|
46
51
|
href={postHref(effectivePrev)}
|
|
47
52
|
className="group flex flex-col gap-1.5 p-4 rounded-xl border border-muted/15 hover:border-accent/30 hover:bg-accent/5 transition-all no-underline"
|
|
48
53
|
>
|
|
49
|
-
<span
|
|
54
|
+
<span
|
|
55
|
+
className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted flex items-center gap-1.5"
|
|
56
|
+
suppressHydrationWarning
|
|
57
|
+
>
|
|
50
58
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
51
59
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
52
60
|
</svg>
|
|
@@ -64,7 +72,10 @@ export default function PostNavigation({ prev, next, currentSlug, collectionCont
|
|
|
64
72
|
href={postHref(effectiveNext)}
|
|
65
73
|
className={`group flex flex-col gap-1.5 p-4 rounded-xl border border-muted/15 hover:border-accent/30 hover:bg-accent/5 transition-all no-underline sm:items-end sm:text-right${!effectivePrev ? ' sm:col-start-2' : ''}`}
|
|
66
74
|
>
|
|
67
|
-
<span
|
|
75
|
+
<span
|
|
76
|
+
className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted flex items-center gap-1.5"
|
|
77
|
+
suppressHydrationWarning
|
|
78
|
+
>
|
|
68
79
|
{t('next')}
|
|
69
80
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
70
81
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
@@ -70,6 +70,10 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, collection
|
|
|
70
70
|
useSidebarAutoScroll(sidebarRef, currentItemRef, currentSlug);
|
|
71
71
|
|
|
72
72
|
return (
|
|
73
|
+
// suppressHydrationWarning on locale-bound nodes is a band-aid for the
|
|
74
|
+
// known static-export + client-i18n drift: SSR renders defaultLocale,
|
|
75
|
+
// `useLanguage()` hook serves the user's saved locale on hydration. The
|
|
76
|
+
// real fix is per-locale URL routing, tracked as a separate refactor.
|
|
73
77
|
<aside
|
|
74
78
|
ref={sidebarRef}
|
|
75
79
|
data-testid="post-sidebar"
|
|
@@ -87,7 +91,10 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, collection
|
|
|
87
91
|
{/* Header — always visible */}
|
|
88
92
|
<div className="mb-3">
|
|
89
93
|
<div className="flex items-center justify-between mb-1">
|
|
90
|
-
<span
|
|
94
|
+
<span
|
|
95
|
+
className="text-[10px] font-sans font-bold uppercase tracking-widest text-accent"
|
|
96
|
+
suppressHydrationWarning
|
|
97
|
+
>
|
|
91
98
|
{isCollectionContext ? t('collection') : t('series')}
|
|
92
99
|
</span>
|
|
93
100
|
<span className="text-[10px] font-mono text-muted/60">
|
|
@@ -172,6 +179,7 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, collection
|
|
|
172
179
|
<Link
|
|
173
180
|
href={`/series/${effectiveSlug}`}
|
|
174
181
|
className="text-xs font-sans text-muted hover:text-accent transition-colors no-underline flex items-center gap-1"
|
|
182
|
+
suppressHydrationWarning
|
|
175
183
|
>
|
|
176
184
|
{isCollectionContext ? t('view_full_collection') : t('view_full_series')}
|
|
177
185
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
@@ -185,7 +193,10 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, collection
|
|
|
185
193
|
|
|
186
194
|
{shareUrl && siteConfig.share?.enabled && (
|
|
187
195
|
<div className="mt-6 pt-6 border-t border-muted/10">
|
|
188
|
-
<p
|
|
196
|
+
<p
|
|
197
|
+
className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-3"
|
|
198
|
+
suppressHydrationWarning
|
|
199
|
+
>
|
|
189
200
|
{t('share_post')}
|
|
190
201
|
</p>
|
|
191
202
|
<ShareBar url={shareUrl} title={shareTitle ?? ''} />
|