@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,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
- const remarkPlugins: PluggableList = [remarkGfm];
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
- const rehypePlugins: PluggableList = [rehypeRaw, rehypeSlug, [rehypeImageMetadata, { slug, cdnBaseUrl }]];
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
- rehypePlugins.push(rehypeKatex);
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
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
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 (`[![alt](img)](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} className="text-accent no-underline hover:underline transition-colors duration-200" />;
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
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
83
- const { className, children, node: _node, ...rest } = props;
84
- const match = /language-(\w+)/.exec(className || '');
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 language={language} {...rest}>
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} {...rest}>
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 `![alt](src)`. 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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
162
- const allComponents = { ...components, 'rss-feed': () => <RssFeedWidget /> } as any;
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
- <div className="bg-background"> {/* Explicit background for better copy-paste fidelity */}
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
- {content}
304
+ {latex
305
+ ? normalizeVuepressBlockMath(normalizeVuepressContainerSyntax(content))
306
+ : normalizeVuepressContainerSyntax(content)}
183
307
  </ReactMarkdown>
184
308
  </div>
185
- </div>
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-8 p-4 md:p-8 rounded-lg border border-muted/20 bg-muted/5 overflow-x-auto shadow-sm">
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.readingTime}</span>
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 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted flex items-center gap-1.5">
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 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted flex items-center gap-1.5">
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 className="text-[10px] font-sans font-bold uppercase tracking-widest text-accent">
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 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-3">
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
+ });