@hutusi/amytis 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +90 -219
- package/README.md +33 -1
- package/README.zh.md +33 -1
- package/TODO.md +10 -0
- package/bun.lock +205 -539
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
- package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
- package/content/series/rst-legacy/getting-started.rst +24 -0
- package/content/series/rst-legacy/index.rst +9 -0
- package/content/series/rst-readme/README.rst +9 -0
- package/content/series/rst-readme/readme-index-post.rst +10 -0
- package/content/series/rst-toctree/first-post.rst +6 -0
- package/content/series/rst-toctree/index.rst +10 -0
- package/content/series/rst-toctree/second-post.rst +6 -0
- package/content/series/rst-toctree-precedence/first-post.rst +6 -0
- package/content/series/rst-toctree-precedence/index.rst +12 -0
- package/content/series/rst-toctree-precedence/second-post.rst +6 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +239 -8
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +36 -0
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +178 -0
- package/eslint.config.mjs +20 -6
- package/next.config.ts +2 -2
- package/package.json +52 -24
- package/packages/create-amytis/package.json +1 -1
- package/packages/create-amytis/src/index.test.ts +43 -1
- package/packages/create-amytis/src/index.ts +64 -8
- package/public/next-image-export-optimizer-hashes.json +14 -73
- package/scripts/build-pagefind.ts +172 -0
- package/scripts/copy-assets.ts +246 -56
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/generate-knowledge-graph.ts +2 -1
- package/scripts/render-rst.py +923 -0
- package/scripts/run-with-rst-python.ts +42 -0
- package/scripts/sync-vuepress-book.ts +499 -0
- package/src/app/[slug]/[postSlug]/page.tsx +20 -10
- package/src/app/[slug]/page/[page]/page.tsx +15 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/page.tsx +67 -32
- package/src/app/globals.css +639 -94
- package/src/app/page.tsx +1 -1
- package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
- package/src/app/series/[slug]/page.tsx +11 -13
- package/src/app/series/page.tsx +3 -3
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/AuthorCard.tsx +25 -16
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CodeBlock.test.tsx +93 -8
- package/src/components/CodeBlock.tsx +39 -101
- package/src/components/CodeBlockToolbar.tsx +88 -0
- package/src/components/CodeGroup.tsx +81 -0
- package/src/components/CoverImage.tsx +6 -2
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +3 -3
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/MarkdownRenderer.test.tsx +30 -4
- package/src/components/MarkdownRenderer.tsx +148 -24
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/RstRenderer.test.tsx +93 -0
- package/src/components/RstRenderer.tsx +157 -0
- package/src/components/Search.tsx +18 -4
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/i18n/translations.ts +2 -0
- package/src/layouts/BookLayout.tsx +35 -4
- package/src/layouts/PostLayout.tsx +10 -2
- package/src/layouts/SimpleLayout.tsx +10 -3
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/image-utils.test.ts +19 -0
- package/src/lib/image-utils.ts +11 -0
- package/src/lib/markdown.test.ts +195 -14
- package/src/lib/markdown.ts +928 -254
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/rehype-image-metadata.ts +2 -2
- package/src/lib/remark-book-chapter-links.ts +106 -0
- package/src/lib/remark-code-group.ts +54 -0
- package/src/lib/remark-github-alerts.test.ts +83 -0
- package/src/lib/remark-github-alerts.ts +65 -0
- package/src/lib/remark-vuepress-containers.ts +130 -0
- package/src/lib/rst-renderer.test.ts +355 -0
- package/src/lib/rst-renderer.ts +629 -0
- package/src/lib/rst.test.ts +350 -0
- package/src/lib/rst.ts +674 -0
- package/src/lib/series-redirects.ts +42 -0
- package/src/lib/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/urls.ts +57 -0
- package/src/test-utils/render.ts +23 -0
- package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
- package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
- package/tests/helpers/env.ts +19 -0
- package/tests/integration/book-chapter-links.test.ts +107 -0
- package/tests/integration/books-nested-toc.test.ts +176 -0
- package/tests/integration/books.test.ts +3 -2
- package/tests/integration/code-block-features.test.ts +188 -0
- package/tests/integration/code-group.test.ts +183 -0
- package/tests/integration/code-notation.test.ts +97 -0
- package/tests/integration/feed-utils.test.ts +13 -0
- package/tests/integration/github-alerts.test.ts +82 -0
- package/tests/integration/markdown-external-links.test.ts +103 -0
- package/tests/integration/normalize-vuepress-math.test.ts +149 -0
- package/tests/integration/reading-time-headings.test.ts +12 -14
- package/tests/integration/series-draft.test.ts +12 -5
- package/tests/integration/series.test.ts +93 -0
- package/tests/integration/sync-vuepress-book.test.ts +240 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/build-pagefind.test.ts +66 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/static-params.test.ts +166 -13
|
@@ -1,19 +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';
|
|
29
|
+
import { shouldBypassImageOptimization } from '@/lib/image-utils';
|
|
30
|
+
import { parseFenceMeta } from '@/lib/shiki';
|
|
31
|
+
import { isExternalUrl } from '@/lib/urls';
|
|
17
32
|
|
|
18
33
|
|
|
19
34
|
interface MarkdownRendererProps {
|
|
@@ -21,12 +36,33 @@ interface MarkdownRendererProps {
|
|
|
21
36
|
latex?: boolean;
|
|
22
37
|
slug?: string;
|
|
23
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;
|
|
24
45
|
}
|
|
25
46
|
|
|
26
|
-
export default function MarkdownRenderer({ content, latex = false, slug, slugRegistry }: MarkdownRendererProps) {
|
|
27
|
-
|
|
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
|
+
}
|
|
28
61
|
const cdnBaseUrl = siteConfig.images?.cdnBaseUrl ?? '';
|
|
29
|
-
|
|
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 }]];
|
|
30
66
|
|
|
31
67
|
if (slugRegistry && slugRegistry.size > 0) {
|
|
32
68
|
remarkPlugins.push([remarkWikilinks, { slugRegistry }]);
|
|
@@ -34,7 +70,15 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
34
70
|
|
|
35
71
|
if (latex) {
|
|
36
72
|
remarkPlugins.push(remarkMath);
|
|
37
|
-
|
|
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
|
+
}]);
|
|
38
82
|
}
|
|
39
83
|
|
|
40
84
|
const components: Components = {
|
|
@@ -69,37 +113,68 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
69
113
|
pre: ({ children }) => <div className="not-prose w-full min-w-0 max-w-full">{children}</div>,
|
|
70
114
|
// Style links individually to avoid hover-all issue
|
|
71
115
|
a: (props) => {
|
|
72
|
-
|
|
73
|
-
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;
|
|
74
117
|
// Preserve wikilink classes injected by remark-wikilinks — they have their own CSS styling
|
|
75
118
|
if (className?.includes('wikilink')) {
|
|
76
|
-
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
|
+
);
|
|
77
142
|
}
|
|
78
|
-
return <a {...rest}
|
|
143
|
+
return <a {...rest} href={href} target={target} rel={rel} className={linkClass}>{children}</a>;
|
|
79
144
|
},
|
|
80
145
|
// Custom code renderer: handles 'mermaid' blocks and syntax highlighting
|
|
81
146
|
code(props: React.ClassAttributes<HTMLElement> & React.HTMLAttributes<HTMLElement> & ExtraProps) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 || '');
|
|
85
151
|
const language = match ? match[1] : '';
|
|
86
152
|
const isMultiLine = String(children).includes('\n');
|
|
87
|
-
|
|
88
|
-
// In react-markdown v10, 'inline' prop is removed.
|
|
153
|
+
|
|
154
|
+
// In react-markdown v10, 'inline' prop is removed.
|
|
89
155
|
// We use className presence (e.g. language-js) or newline presence to detect code blocks.
|
|
90
156
|
if (match || isMultiLine) {
|
|
91
157
|
if (language === 'mermaid') {
|
|
92
158
|
return <Mermaid chart={String(children).replace(/\n$/, '')} />;
|
|
93
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);
|
|
94
164
|
return (
|
|
95
|
-
<CodeBlock
|
|
165
|
+
<CodeBlock
|
|
166
|
+
language={language}
|
|
167
|
+
title={parsedMeta.title}
|
|
168
|
+
showLineNumbers={parsedMeta.showLineNumbers}
|
|
169
|
+
highlightLines={parsedMeta.highlightLines}
|
|
170
|
+
>
|
|
96
171
|
{String(children).replace(/\n$/, '')}
|
|
97
172
|
</CodeBlock>
|
|
98
173
|
);
|
|
99
174
|
}
|
|
100
175
|
|
|
101
176
|
return (
|
|
102
|
-
<code className={className}
|
|
177
|
+
<code className={className}>
|
|
103
178
|
{children}
|
|
104
179
|
</code>
|
|
105
180
|
);
|
|
@@ -133,12 +208,38 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
133
208
|
// In development mode, use unoptimized images since WebP versions don't exist yet
|
|
134
209
|
img: (props: React.ClassAttributes<HTMLImageElement> & React.ImgHTMLAttributes<HTMLImageElement> & ExtraProps) => {
|
|
135
210
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
136
|
-
const { src, alt, width, height, node: _node, ...rest } = props;
|
|
211
|
+
const { src, alt, width, height, node: _node, style, ...rest } = props;
|
|
137
212
|
const isDev = process.env.NODE_ENV === 'development';
|
|
138
213
|
const imageSrc = src as string;
|
|
139
214
|
const isExternal = imageSrc?.startsWith('http') || imageSrc?.startsWith('//');
|
|
140
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
|
+
|
|
141
241
|
if (!isExternal) {
|
|
242
|
+
const shouldBypassOptimization = shouldBypassImageOptimization(imageSrc);
|
|
142
243
|
return (
|
|
143
244
|
<ExportedImage
|
|
144
245
|
src={imageSrc || ''}
|
|
@@ -147,8 +248,10 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
147
248
|
height={height ? Number(height) : 900}
|
|
148
249
|
className="max-w-full h-auto rounded-lg my-4"
|
|
149
250
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 70vw"
|
|
150
|
-
unoptimized={isDev}
|
|
251
|
+
unoptimized={isDev || shouldBypassOptimization}
|
|
252
|
+
placeholder={shouldBypassOptimization ? 'empty' : 'blur'}
|
|
151
253
|
style={(!width || !height) ? { width: '100%', height: 'auto' } : undefined}
|
|
254
|
+
fetchPriority="low"
|
|
152
255
|
/>
|
|
153
256
|
);
|
|
154
257
|
}
|
|
@@ -157,16 +260,35 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
157
260
|
},
|
|
158
261
|
};
|
|
159
262
|
|
|
160
|
-
// Merge custom HTML elements not in the Components type (e.g. web components used in MDX
|
|
161
|
-
//
|
|
162
|
-
|
|
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;
|
|
163
285
|
|
|
164
286
|
return (
|
|
165
287
|
<>
|
|
166
288
|
{latex && <KatexStyles />}
|
|
167
|
-
<
|
|
289
|
+
<ArticleCopyCleaner>
|
|
168
290
|
<div className="prose prose-lg max-w-none min-w-0 overflow-x-hidden text-foreground
|
|
169
|
-
prose-headings:font-serif prose-headings:text-heading
|
|
291
|
+
prose-headings:font-serif prose-headings:text-heading
|
|
170
292
|
prose-p:text-foreground prose-p:leading-loose
|
|
171
293
|
prose-strong:text-heading prose-strong:font-semibold
|
|
172
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
|
|
@@ -179,10 +301,12 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
|
|
|
179
301
|
rehypePlugins={rehypePlugins}
|
|
180
302
|
components={allComponents}
|
|
181
303
|
>
|
|
182
|
-
{
|
|
304
|
+
{latex
|
|
305
|
+
? normalizeVuepressBlockMath(normalizeVuepressContainerSyntax(content))
|
|
306
|
+
: normalizeVuepressContainerSyntax(content)}
|
|
183
307
|
</ReactMarkdown>
|
|
184
308
|
</div>
|
|
185
|
-
</
|
|
309
|
+
</ArticleCopyCleaner>
|
|
186
310
|
</>
|
|
187
311
|
);
|
|
188
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 ?? ''} />
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import RstRenderer from './RstRenderer';
|
|
3
|
+
import { renderAsync } from '@/test-utils/render';
|
|
4
|
+
|
|
5
|
+
describe('RstRenderer', () => {
|
|
6
|
+
test('renders pre-rendered html when available', async () => {
|
|
7
|
+
const html = await renderAsync(
|
|
8
|
+
<RstRenderer
|
|
9
|
+
content="Fallback body"
|
|
10
|
+
html={
|
|
11
|
+
'<section><h2 id="intro">Intro</h2><figure class="docutils"><img src="/posts/demo/test.png" alt="Test" onerror="alert(2)" /><figcaption>Caption</figcaption></figure><aside class="admonition note"><p class="admonition-title">Note</p><p>Keep me</p></aside><p><a href="/demo" onclick="alert(3)">Link</a></p><p><a href="javascript:alert(4)">Bad link</a></p><script>alert(1)</script><iframe src="https://example.com/embed"></iframe></section>'
|
|
12
|
+
}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
expect(html).toContain('rst-rendered');
|
|
17
|
+
expect(html).toContain('id="intro"');
|
|
18
|
+
expect(html).toContain('<figure');
|
|
19
|
+
expect(html).toContain('<figcaption>Caption</figcaption>');
|
|
20
|
+
expect(html).toContain('admonition-title');
|
|
21
|
+
expect(html).toContain('/posts/demo/test.png');
|
|
22
|
+
expect(html).toContain('href="/demo"');
|
|
23
|
+
expect(html).not.toContain('alert(1)');
|
|
24
|
+
expect(html).not.toContain('<script');
|
|
25
|
+
expect(html).not.toContain('<iframe');
|
|
26
|
+
expect(html).not.toContain('onclick');
|
|
27
|
+
expect(html).not.toContain('onerror');
|
|
28
|
+
expect(html).not.toContain('javascript:alert(4)');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('blocks data urls on images', async () => {
|
|
32
|
+
const html = await renderAsync(
|
|
33
|
+
<RstRenderer
|
|
34
|
+
content="Fallback body"
|
|
35
|
+
html={'<p><img src="data:image/svg+xml,<svg onload=alert(1)>" alt="Bad" /></p>'}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(html).toContain('<img');
|
|
40
|
+
expect(html).not.toContain('data:image');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('preserves MathML elements', async () => {
|
|
44
|
+
const html = await renderAsync(
|
|
45
|
+
<RstRenderer
|
|
46
|
+
content="Fallback body"
|
|
47
|
+
html={'<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>2</mn></mrow></math>'}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(html).toContain('<math');
|
|
52
|
+
expect(html).toContain('<mrow');
|
|
53
|
+
expect(html).toContain('<mi>x</mi>');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('wraps rendered rst tables with the same scroll container pattern as markdown', async () => {
|
|
57
|
+
const html = await renderAsync(
|
|
58
|
+
<RstRenderer
|
|
59
|
+
content="Fallback body"
|
|
60
|
+
html={'<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>B</td></tr></tbody></table>'}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(html).toContain('class="rst-table-wrapper"');
|
|
65
|
+
expect(html).toContain('<table>');
|
|
66
|
+
expect(html).toContain('<th>A</th>');
|
|
67
|
+
expect(html).toContain('<td>B</td>');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('renders converted headings, links, and code blocks through the markdown renderer', async () => {
|
|
71
|
+
const html = await renderAsync(
|
|
72
|
+
<RstRenderer
|
|
73
|
+
content={[
|
|
74
|
+
'Section',
|
|
75
|
+
'-------',
|
|
76
|
+
'',
|
|
77
|
+
'Paragraph with `Example <https://example.com>`_.',
|
|
78
|
+
'',
|
|
79
|
+
'.. code-block:: ts',
|
|
80
|
+
'',
|
|
81
|
+
' export const value = 1;',
|
|
82
|
+
].join('\n')}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(html).toContain('Section');
|
|
87
|
+
expect(html).toContain('https://example.com');
|
|
88
|
+
// Shiki produces a .shiki container with language-aware token spans, not Prism's
|
|
89
|
+
// legacy class="language-ts" + token markup. Assert the new highlighter ran.
|
|
90
|
+
expect(html).toContain('class="shiki');
|
|
91
|
+
expect(html).toContain('export');
|
|
92
|
+
});
|
|
93
|
+
});
|