@hutusi/amytis 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +89 -219
  5. package/bun.lock +185 -547
  6. package/content/books/sample-book/index.mdx +3 -0
  7. package/content/posts/code-block-features-showcase.mdx +223 -0
  8. package/docs/ALERTS.md +112 -0
  9. package/docs/ARCHITECTURE.md +298 -5
  10. package/docs/CODE-BLOCKS.md +238 -0
  11. package/docs/CONTRIBUTING.md +25 -0
  12. package/docs/DIGITAL_GARDEN.md +1 -1
  13. package/docs/guides/README.md +11 -0
  14. package/docs/guides/importing-vuepress-books.md +237 -0
  15. package/eslint.config.mjs +18 -6
  16. package/package.json +42 -20
  17. package/scripts/generate-code-group-icons.ts +79 -0
  18. package/scripts/render-rst.py +207 -3
  19. package/scripts/sync-vuepress-book.ts +710 -0
  20. package/site.config.example.ts +3 -3
  21. package/site.config.ts +3 -3
  22. package/src/app/[slug]/layout.tsx +30 -0
  23. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  24. package/src/app/books/[slug]/layout.tsx +24 -0
  25. package/src/app/books/[slug]/page.tsx +85 -34
  26. package/src/app/globals.css +570 -123
  27. package/src/app/page.tsx +7 -1
  28. package/src/app/posts/layout.tsx +20 -0
  29. package/src/app/series/[slug]/page.tsx +33 -9
  30. package/src/app/sitemap.ts +3 -3
  31. package/src/components/ArticleCopyCleaner.tsx +64 -0
  32. package/src/components/BookMobileNav.tsx +44 -50
  33. package/src/components/BookReadingShell.tsx +145 -0
  34. package/src/components/BookSidebar.tsx +0 -0
  35. package/src/components/CodeBlock.test.tsx +93 -8
  36. package/src/components/CodeBlock.tsx +39 -101
  37. package/src/components/CodeBlockToolbar.tsx +88 -0
  38. package/src/components/CodeGroup.tsx +81 -0
  39. package/src/components/CoverImage.tsx +1 -0
  40. package/src/components/CuratedSeriesSection.tsx +28 -10
  41. package/src/components/ExternalLinkIcon.tsx +15 -0
  42. package/src/components/FeaturedStoriesSection.tsx +44 -23
  43. package/src/components/Footer.tsx +1 -1
  44. package/src/components/GithubAlert.tsx +97 -0
  45. package/src/components/ImmersiveReader.tsx +130 -0
  46. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  47. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  48. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  49. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  50. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  51. package/src/components/ImmersiveToggleButton.tsx +45 -0
  52. package/src/components/MarkdownRenderer.test.tsx +14 -4
  53. package/src/components/MarkdownRenderer.tsx +175 -23
  54. package/src/components/Mermaid.tsx +32 -1
  55. package/src/components/Navbar.tsx +3 -1
  56. package/src/components/PostList.tsx +1 -1
  57. package/src/components/PostNavigation.tsx +13 -2
  58. package/src/components/PostReadingShell.tsx +68 -0
  59. package/src/components/PostSidebar.tsx +13 -2
  60. package/src/components/ReadingProgressBar.tsx +1 -1
  61. package/src/components/RstRenderer.test.tsx +15 -15
  62. package/src/components/RstRenderer.tsx +37 -2
  63. package/src/components/Search.tsx +18 -4
  64. package/src/components/SelectedBooksSection.tsx +27 -8
  65. package/src/components/SeriesCatalog.tsx +1 -1
  66. package/src/components/ShareBar.tsx +5 -0
  67. package/src/components/TocPanel.tsx +10 -2
  68. package/src/hooks/useActiveHeading.ts +35 -13
  69. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  70. package/src/i18n/translations.ts +44 -0
  71. package/src/layouts/BookLayout.tsx +62 -74
  72. package/src/layouts/PostLayout.tsx +154 -111
  73. package/src/lib/code-group-icons.test.ts +78 -0
  74. package/src/lib/code-group-icons.ts +148 -0
  75. package/src/lib/immersive-reading-prefs.ts +104 -0
  76. package/src/lib/markdown.test.ts +56 -13
  77. package/src/lib/markdown.ts +217 -57
  78. package/src/lib/normalize-vuepress-math.ts +118 -0
  79. package/src/lib/rehype-fence-meta.ts +22 -0
  80. package/src/lib/remark-book-chapter-links.ts +106 -0
  81. package/src/lib/remark-code-group.ts +54 -0
  82. package/src/lib/remark-github-alerts.test.ts +83 -0
  83. package/src/lib/remark-github-alerts.ts +65 -0
  84. package/src/lib/remark-vuepress-containers.ts +130 -0
  85. package/src/lib/rst-renderer.ts +19 -7
  86. package/src/lib/rst.test.ts +212 -2
  87. package/src/lib/rst.ts +217 -13
  88. package/src/lib/scroll-utils.ts +44 -6
  89. package/src/lib/shiki-rst.ts +185 -0
  90. package/src/lib/shiki.test.ts +153 -0
  91. package/src/lib/shiki.ts +292 -0
  92. package/src/lib/shuffle.ts +15 -1
  93. package/src/lib/sort.ts +15 -0
  94. package/src/lib/urls.ts +62 -0
  95. package/src/test-utils/render.ts +23 -0
  96. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  97. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  98. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  99. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  100. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  101. package/tests/helpers/env.ts +19 -0
  102. package/tests/integration/book-chapter-links.test.ts +107 -0
  103. package/tests/integration/book-index-cta.test.ts +87 -0
  104. package/tests/integration/books-nested-toc.test.ts +176 -0
  105. package/tests/integration/books.test.ts +3 -2
  106. package/tests/integration/code-block-features.test.ts +188 -0
  107. package/tests/integration/code-group.test.ts +183 -0
  108. package/tests/integration/code-notation.test.ts +97 -0
  109. package/tests/integration/github-alerts.test.ts +82 -0
  110. package/tests/integration/markdown-external-links.test.ts +103 -0
  111. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  112. package/tests/integration/reading-time-headings.test.ts +8 -6
  113. package/tests/integration/series-draft.test.ts +6 -13
  114. package/tests/integration/series-index-cta.test.ts +88 -0
  115. package/tests/integration/sync-vuepress-book.test.ts +443 -0
  116. package/tests/integration/vuepress-containers.test.ts +107 -0
  117. package/tests/tooling/new-post.test.ts +1 -1
  118. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  119. package/tests/unit/static-params.test.ts +32 -19
  120. package/vercel.json +7 -0
@@ -0,0 +1,143 @@
1
+ 'use client';
2
+
3
+ import { useRef } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import Link from 'next/link';
6
+ import type { PostData, CollectionContext, Heading } from '@/lib/markdown';
7
+ import { useLanguage } from './LanguageProvider';
8
+ import { useImmersiveReading } from './ImmersiveReadingProvider';
9
+ import { useSidebarAutoScroll } from '@/hooks/useSidebarAutoScroll';
10
+ import InlineBookToc from './InlineBookToc';
11
+ import { getPostUrl, getPostUrlInCollection, getSeriesListUrl, getSeriesUrl } from '@/lib/urls';
12
+
13
+ // Dedicated TOC sidebar for the immersive reader on a series post. Visually
14
+ // mirrors BookSidebar's `mode="fill"` shape (clean numbered list, left-
15
+ // border accent on the current item, inlined headings under the current
16
+ // post, footer pointing at the listing page) rather than the page-mode
17
+ // SeriesList card — see PR #95 review feedback.
18
+
19
+ interface ImmersiveSeriesSidebarProps {
20
+ seriesSlug: string;
21
+ seriesTitle: string;
22
+ posts: PostData[];
23
+ /** When the post is in a collection, the sidebar can render in that
24
+ * collection's scope by appending `?collection=<slug>` to the URL. Same
25
+ * resolution logic as SeriesList. */
26
+ collectionContexts?: CollectionContext[];
27
+ currentSlug: string;
28
+ /** h2/h3 headings for the current post — rendered as an inline TOC under
29
+ * the current post's row via the shared InlineBookToc component. */
30
+ headings?: Heading[];
31
+ }
32
+
33
+ export default function ImmersiveSeriesSidebar({
34
+ seriesSlug,
35
+ seriesTitle,
36
+ posts,
37
+ collectionContexts,
38
+ currentSlug,
39
+ headings = [],
40
+ }: ImmersiveSeriesSidebarProps) {
41
+ const { t } = useLanguage();
42
+ const { enabled: immersiveEnabled } = useImmersiveReading();
43
+ const searchParams = useSearchParams();
44
+ const collectionParam = searchParams.get('collection');
45
+ const activeCollection = collectionParam
46
+ ? (collectionContexts ?? []).find(c => c.slug === collectionParam) ?? null
47
+ : null;
48
+
49
+ const effectiveSlug = activeCollection?.slug ?? seriesSlug;
50
+ const effectiveTitle = activeCollection?.title ?? seriesTitle;
51
+ const effectivePosts = activeCollection?.posts ?? posts;
52
+ const isCollectionContext = !!activeCollection;
53
+
54
+ // Collections mix posts from different layout segments (`/posts/[slug]` vs
55
+ // `/[series]/[slug]`). When clicking across that boundary, the
56
+ // ImmersiveReadingProvider remounts with `enabled=false` and the overlay
57
+ // closes. Appending `?immersive=1` lets the destination layout's
58
+ // ImmersiveReadingFlagHandler re-enter the reader and strip the flag.
59
+ const postHref = (post: PostData) => {
60
+ const base = activeCollection
61
+ ? getPostUrlInCollection(post, activeCollection.slug)
62
+ : getPostUrl(post);
63
+ if (!immersiveEnabled) return base;
64
+ const sep = base.includes('?') ? '&' : '?';
65
+ return `${base}${sep}immersive=1`;
66
+ };
67
+
68
+ const currentIndex = effectivePosts.findIndex(p => p.slug === currentSlug);
69
+ const currentItemRef = useRef<HTMLLIElement>(null);
70
+ const sidebarRef = useRef<HTMLElement>(null);
71
+ useSidebarAutoScroll(sidebarRef, currentItemRef, currentSlug);
72
+
73
+ if (effectivePosts.length === 0) return null;
74
+
75
+ return (
76
+ <aside
77
+ ref={sidebarRef}
78
+ className="block w-full h-full overflow-y-auto px-4 py-6 scrollbar-hide hover:scrollbar-thin"
79
+ >
80
+ {/* Header — series / collection label + post count + title link */}
81
+ <div className="mb-6 pb-4 border-b border-muted/10">
82
+ <div className="flex items-center justify-between mb-2">
83
+ <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-accent">
84
+ {isCollectionContext ? t('collection') : t('series')}
85
+ </span>
86
+ <span className="text-xs font-mono text-muted whitespace-nowrap">
87
+ {currentIndex + 1}/{effectivePosts.length}
88
+ </span>
89
+ </div>
90
+ <Link href={getSeriesUrl(effectiveSlug)} className="group block no-underline">
91
+ <h3 className="font-serif font-bold text-heading text-lg leading-snug group-hover:text-accent transition-colors">
92
+ {effectiveTitle}
93
+ </h3>
94
+ </Link>
95
+ </div>
96
+
97
+ {/* Posts list — flat, current with left-border + accent (same treatment
98
+ as BookSidebar's chapter link). Past posts dimmed less than future
99
+ posts, also matching BookSidebar. */}
100
+ <nav aria-label={isCollectionContext ? t('collection') : t('series')}>
101
+ <ul className="space-y-1">
102
+ {effectivePosts.map((post, index) => {
103
+ const isCurrent = post.slug === currentSlug;
104
+ const isPast = index < currentIndex;
105
+ return (
106
+ <li key={post.slug} ref={isCurrent ? currentItemRef : undefined}>
107
+ <Link
108
+ href={postHref(post)}
109
+ className={`block py-2 px-3 rounded-lg text-sm no-underline transition-all duration-200 ${
110
+ isCurrent
111
+ ? 'bg-accent/10 text-accent font-semibold border-l-2 border-accent'
112
+ : isPast
113
+ ? 'text-foreground/70 hover:text-foreground hover:bg-muted/5'
114
+ : 'text-muted hover:text-foreground hover:bg-muted/5'
115
+ }`}
116
+ aria-current={isCurrent ? 'page' : undefined}
117
+ >
118
+ {post.title}
119
+ </Link>
120
+ {isCurrent && <InlineBookToc headings={headings} />}
121
+ </li>
122
+ );
123
+ })}
124
+ </ul>
125
+ </nav>
126
+
127
+ {/* Footer — points at the series listing (not back to the current
128
+ series detail, which the header already links to). Matches
129
+ BookSidebar's "All Books" footer pattern. */}
130
+ <div className="mt-6 pt-4 border-t border-muted/10">
131
+ <Link
132
+ href={getSeriesListUrl()}
133
+ className="text-xs font-sans text-muted hover:text-accent transition-colors no-underline flex items-center gap-1"
134
+ >
135
+ {t('all_series')}
136
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
137
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
138
+ </svg>
139
+ </Link>
140
+ </div>
141
+ </aside>
142
+ );
143
+ }
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
4
+ import { useLanguage } from '@/components/LanguageProvider';
5
+
6
+ export default function ImmersiveToggleButton() {
7
+ const { enabled, toggle } = useImmersiveReading();
8
+ const { t } = useLanguage();
9
+ // The button is the "enter" affordance; in immersive mode the top bar's
10
+ // exit (✕) is the only way out, so the inline button hides to avoid
11
+ // duplicating the exit and reading "Exit reading mode" next to it. Owning
12
+ // the visibility here means callers (PostLayout's article header, etc.)
13
+ // don't need to gate it with `{!enabled && ...}` separately.
14
+ if (enabled) return null;
15
+ const label = t('immersive_reading');
16
+
17
+ return (
18
+ <button
19
+ type="button"
20
+ onClick={toggle}
21
+ title={label}
22
+ aria-label={label}
23
+ className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-sans text-muted hover:text-accent hover:bg-muted/10 transition-colors border border-transparent hover:border-muted/20 select-none"
24
+ >
25
+ <svg
26
+ width="14"
27
+ height="14"
28
+ viewBox="0 0 24 24"
29
+ fill="none"
30
+ stroke="currentColor"
31
+ strokeWidth="2"
32
+ strokeLinecap="round"
33
+ strokeLinejoin="round"
34
+ aria-hidden="true"
35
+ >
36
+ <path d="M3 7V5a2 2 0 0 1 2-2h2" />
37
+ <path d="M17 3h2a2 2 0 0 1 2 2v2" />
38
+ <path d="M21 17v2a2 2 0 0 1-2 2h-2" />
39
+ <path d="M7 21H5a2 2 0 0 1-2-2v-2" />
40
+ <path d="M8 12h8" />
41
+ </svg>
42
+ <span className="hidden sm:inline">{label}</span>
43
+ </button>
44
+ );
45
+ }
@@ -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 = renderToStaticMarkup(<MarkdownRenderer content={content} />);
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 a background container for copy-paste fidelity", () => {
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
- expect(html).toMatch(/class="[^"]*\bbg-background\b[^"]*"/);
70
+ // The cleaner renders a bare wrapper div; the page background lives on body now,
71
+ // so the article HTML must not paint its own background (which is what caused
72
+ // Chromium's clipboard serializer to inline `background-color` on every <p>).
73
+ expect(html).not.toMatch(/class="[^"]*\bbg-background\b[^"]*"/);
74
+ expect(html).toContain('<p class="mb-4 leading-relaxed text-foreground">Hello world</p>');
65
75
  });
66
76
  });
@@ -1,33 +1,84 @@
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
 
34
+ // Flatten an arbitrary React children tree to its text content. Used by the
35
+ // raw-HTML <mermaid> handler below — react-markdown hands us the mermaid
36
+ // source as a tree of text nodes (possibly nested through whitespace-only
37
+ // wrappers) and the Mermaid component expects a single string.
38
+ function flattenChildrenToText(node: React.ReactNode): string {
39
+ if (node == null || typeof node === 'boolean') return '';
40
+ if (typeof node === 'string') return node;
41
+ if (typeof node === 'number') return String(node);
42
+ if (Array.isArray(node)) return node.map(flattenChildrenToText).join('');
43
+ if (React.isValidElement(node)) {
44
+ const children = (node.props as { children?: React.ReactNode }).children;
45
+ return flattenChildrenToText(children);
46
+ }
47
+ return '';
48
+ }
49
+
20
50
  interface MarkdownRendererProps {
21
51
  content: string;
22
52
  latex?: boolean;
23
53
  slug?: string;
24
54
  slugRegistry?: Map<string, SlugRegistryEntry>;
55
+ /**
56
+ * Set when rendering a book chapter. Enables inter-chapter `.md` link
57
+ * rewriting and `:::container` → GitHub Alert conversion (the latter runs
58
+ * for everyone, but the link rewriter needs source-path context).
59
+ */
60
+ bookContext?: BookChapterLinksOptions;
25
61
  }
26
62
 
27
- export default function MarkdownRenderer({ content, latex = false, slug, slugRegistry }: MarkdownRendererProps) {
28
- const remarkPlugins: PluggableList = [remarkGfm];
63
+ export default function MarkdownRenderer({ content, latex = false, slug, slugRegistry, bookContext }: MarkdownRendererProps) {
64
+ // remark-directive must precede remark-code-group AND remark-vuepress-containers
65
+ // so they see parsed containerDirective nodes. Order vs remark-gfm doesn't matter
66
+ // — they touch disjoint node types.
67
+ const remarkPlugins: PluggableList = [
68
+ remarkGfm,
69
+ remarkGithubAlerts,
70
+ remarkDirective,
71
+ remarkCodeGroup,
72
+ remarkVuepressContainers,
73
+ ];
74
+ if (bookContext) {
75
+ remarkPlugins.push([remarkBookChapterLinks, bookContext]);
76
+ }
29
77
  const cdnBaseUrl = siteConfig.images?.cdnBaseUrl ?? '';
30
- const rehypePlugins: PluggableList = [rehypeRaw, rehypeSlug, [rehypeImageMetadata, { slug, cdnBaseUrl }]];
78
+ // rehypeFenceMeta must run BEFORE rehypeRaw rehypeRaw round-trips through HTML
79
+ // serialization, which drops node.data.meta (a non-HTML field). Copying meta to a
80
+ // real data-meta attribute first lets it survive the round trip.
81
+ const rehypePlugins: PluggableList = [rehypeFenceMeta, rehypeRaw, rehypeSlug, [rehypeImageMetadata, { slug, cdnBaseUrl }]];
31
82
 
32
83
  if (slugRegistry && slugRegistry.size > 0) {
33
84
  remarkPlugins.push([remarkWikilinks, { slugRegistry }]);
@@ -35,7 +86,15 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
35
86
 
36
87
  if (latex) {
37
88
  remarkPlugins.push(remarkMath);
38
- rehypePlugins.push(rehypeKatex);
89
+ // Silence only KaTeX's `unicodeTextInMathMode` warnings — Chinese-language
90
+ // books routinely write math like `$输入$` or `$h_{隐藏状态}$` and KaTeX
91
+ // renders the CJK characters fine; the warning is pure noise (one log per
92
+ // character per chapter view). A bare `strict: 'ignore'` would silence
93
+ // *every* KaTeX strict check including genuinely broken math, so use a
94
+ // predicate that targets just this transgression.
95
+ rehypePlugins.push([rehypeKatex, {
96
+ strict: (code: string) => (code === 'unicodeTextInMathMode' ? 'ignore' : 'warn'),
97
+ }]);
39
98
  }
40
99
 
41
100
  const components: Components = {
@@ -70,37 +129,68 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
70
129
  pre: ({ children }) => <div className="not-prose w-full min-w-0 max-w-full">{children}</div>,
71
130
  // Style links individually to avoid hover-all issue
72
131
  a: (props) => {
73
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
74
- const { node: _node, className, ...rest } = props as React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
132
+ const { node, className, children, href, target, rel, ...rest } = props as React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
75
133
  // Preserve wikilink classes injected by remark-wikilinks — they have their own CSS styling
76
134
  if (className?.includes('wikilink')) {
77
- return <a {...rest} className={className} />;
135
+ return <a {...rest} href={href} target={target} rel={rel} className={className}>{children}</a>;
136
+ }
137
+ const linkClass = "text-accent no-underline hover:underline transition-colors duration-200";
138
+ if (isExternalUrl(href)) {
139
+ // Image-as-link (`[![alt](img)](href)`): an inline arrow after the
140
+ // image looks like a glyph, not a hint. The HAST `node` exposes the
141
+ // pre-override children so we can spot an `<img>` child reliably —
142
+ // by the time react-markdown passes `children` to us, our own `img`
143
+ // override has already replaced the raw <img> with a component.
144
+ const hastChildren = (node && 'children' in node) ? node.children : [];
145
+ const isImageLink = hastChildren.length === 1 && 'tagName' in hastChildren[0] && hastChildren[0].tagName === 'img';
146
+ return (
147
+ <a
148
+ {...rest}
149
+ href={href}
150
+ target={target ?? '_blank'}
151
+ rel={rel ?? 'noopener noreferrer'}
152
+ className={linkClass}
153
+ >
154
+ {children}
155
+ {!isImageLink && <ExternalLinkIcon />}
156
+ </a>
157
+ );
78
158
  }
79
- return <a {...rest} className="text-accent no-underline hover:underline transition-colors duration-200" />;
159
+ return <a {...rest} href={href} target={target} rel={rel} className={linkClass}>{children}</a>;
80
160
  },
81
161
  // Custom code renderer: handles 'mermaid' blocks and syntax highlighting
82
162
  code(props: React.ClassAttributes<HTMLElement> & React.HTMLAttributes<HTMLElement> & ExtraProps) {
83
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
84
- const { className, children, node: _node, ...rest } = props;
85
- const match = /language-(\w+)/.exec(className || '');
163
+ const { className, children } = props;
164
+ // [^\s]+ rather than \w+ so fences like ```c++ or ```objective-c++ are detected
165
+ // as `c++` / `objective-c++` and not truncated to `c` at the punctuation boundary.
166
+ const match = /language-([^\s]+)/.exec(className || '');
86
167
  const language = match ? match[1] : '';
87
168
  const isMultiLine = String(children).includes('\n');
88
-
89
- // In react-markdown v10, 'inline' prop is removed.
169
+
170
+ // In react-markdown v10, 'inline' prop is removed.
90
171
  // We use className presence (e.g. language-js) or newline presence to detect code blocks.
91
172
  if (match || isMultiLine) {
92
173
  if (language === 'mermaid') {
93
174
  return <Mermaid chart={String(children).replace(/\n$/, '')} />;
94
175
  }
176
+ // react-markdown v10 strips node.data before invoking overrides, so the
177
+ // fence meta is surfaced as a real `data-meta` attribute by rehypeFenceMeta.
178
+ const meta = (props as unknown as Record<string, unknown>)['data-meta'];
179
+ const parsedMeta = parseFenceMeta(typeof meta === 'string' ? meta : undefined);
95
180
  return (
96
- <CodeBlock language={language} {...rest}>
181
+ <CodeBlock
182
+ language={language}
183
+ title={parsedMeta.title}
184
+ showLineNumbers={parsedMeta.showLineNumbers}
185
+ highlightLines={parsedMeta.highlightLines}
186
+ >
97
187
  {String(children).replace(/\n$/, '')}
98
188
  </CodeBlock>
99
189
  );
100
190
  }
101
191
 
102
192
  return (
103
- <code className={className} {...rest}>
193
+ <code className={className}>
104
194
  {children}
105
195
  </code>
106
196
  );
@@ -134,11 +224,36 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
134
224
  // In development mode, use unoptimized images since WebP versions don't exist yet
135
225
  img: (props: React.ClassAttributes<HTMLImageElement> & React.ImgHTMLAttributes<HTMLImageElement> & ExtraProps) => {
136
226
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
137
- const { src, alt, width, height, node: _node, ...rest } = props;
227
+ const { src, alt, width, height, node: _node, style, ...rest } = props;
138
228
  const isDev = process.env.NODE_ENV === 'development';
139
229
  const imageSrc = src as string;
140
230
  const isExternal = imageSrc?.startsWith('http') || imageSrc?.startsWith('//');
141
231
 
232
+ // Author-supplied inline `style` is a strong signal the <img> came from
233
+ // raw HTML inside the markdown (typically inline icons like social-media
234
+ // badges) rather than from a markdown `![alt](src)`. Markdown images
235
+ // never carry a style attribute. Preserve the author's styling and
236
+ // skip optimization for these — wrapping a 22px icon in <ExportedImage>
237
+ // strips the style and renders it at its natural 500px size.
238
+ if (style) {
239
+ // width / height were destructured out of `rest` above, so re-apply
240
+ // them here. Mixed author markup like `<img src="..." width="120"
241
+ // style="border-radius:4px">` should keep its explicit sizing rather
242
+ // than render at the SVG's natural dimensions.
243
+ return (
244
+ // eslint-disable-next-line @next/next/no-img-element
245
+ <img
246
+ src={imageSrc}
247
+ alt={alt || ''}
248
+ width={width}
249
+ height={height}
250
+ style={style}
251
+ {...rest}
252
+ fetchPriority="low"
253
+ />
254
+ );
255
+ }
256
+
142
257
  if (!isExternal) {
143
258
  const shouldBypassOptimization = shouldBypassImageOptimization(imageSrc);
144
259
  return (
@@ -152,6 +267,7 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
152
267
  unoptimized={isDev || shouldBypassOptimization}
153
268
  placeholder={shouldBypassOptimization ? 'empty' : 'blur'}
154
269
  style={(!width || !height) ? { width: '100%', height: 'auto' } : undefined}
270
+ fetchPriority="low"
155
271
  />
156
272
  );
157
273
  }
@@ -160,16 +276,50 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
160
276
  },
161
277
  };
162
278
 
163
- // Merge custom HTML elements not in the Components type (e.g. web components used in MDX)
164
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
165
- const allComponents = { ...components, 'rss-feed': () => <RssFeedWidget /> } as any;
279
+ // Merge custom HTML elements not in the Components type (e.g. web components used in MDX,
280
+ // and the synthetic <code-group> / <github-alert> tagNames emitted by our remark plugins).
281
+ //
282
+ // VuePress component pass-throughs: imported VuePress books may use Vue
283
+ // components like <Swiper>/<Slide> (image carousel), <ClientOnly>, <HomeHero>,
284
+ // <ChatDemo>, <GlobalTOC>. hast/React lowercases these tags, and without a
285
+ // handler React logs "The tag <swiper> is unrecognized in this browser". Map
286
+ // each one to a passive renderer so the warnings go away and inner content
287
+ // (where it makes sense) still appears as a graceful degradation.
288
+ const allComponents = {
289
+ ...components,
290
+ 'rss-feed': () => <RssFeedWidget />,
291
+ 'code-group': CodeGroup,
292
+ 'github-alert': GithubAlert,
293
+ swiper: ({ children }: { children?: React.ReactNode }) => <div className="my-6 space-y-4">{children}</div>,
294
+ slide: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
295
+ clientonly: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
296
+ globaltoc: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
297
+ homehero: () => null,
298
+ chatdemo: () => null,
299
+ // <mermaid>...graph syntax...</mermaid> is the VuePress inline form. We
300
+ // already handle ```mermaid fenced blocks via the `code` renderer above;
301
+ // route the raw-HTML form to the same Mermaid component by flattening
302
+ // the children to a string.
303
+ mermaid: ({ children }: { children?: React.ReactNode }) => (
304
+ <Mermaid chart={flattenChildrenToText(children).trim()} />
305
+ ),
306
+ // <GitHubWrapper>...</GitHubWrapper> wraps GitHub project links / cards
307
+ // in the fenix VuePress book. Pass children through unchanged — they're
308
+ // usually a paragraph or an <a>/<img> the author wants to display.
309
+ githubwrapper: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
310
+ // <words type='span' chapter='/' /> is a VuePress word-count widget that
311
+ // we can't reproduce without the upstream counter. Render nothing — the
312
+ // surrounding prose ("全文合计 X 字") degrades to "全文合计 字".
313
+ words: () => null,
314
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
315
+ } as any;
166
316
 
167
317
  return (
168
318
  <>
169
319
  {latex && <KatexStyles />}
170
- <div className="bg-background"> {/* Explicit background for better copy-paste fidelity */}
320
+ <ArticleCopyCleaner>
171
321
  <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
322
+ prose-headings:font-serif prose-headings:text-heading
173
323
  prose-p:text-foreground prose-p:leading-loose
174
324
  prose-strong:text-heading prose-strong:font-semibold
175
325
  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 +332,12 @@ export default function MarkdownRenderer({ content, latex = false, slug, slugReg
182
332
  rehypePlugins={rehypePlugins}
183
333
  components={allComponents}
184
334
  >
185
- {content}
335
+ {latex
336
+ ? normalizeVuepressBlockMath(normalizeVuepressContainerSyntax(content))
337
+ : normalizeVuepressContainerSyntax(content)}
186
338
  </ReactMarkdown>
187
339
  </div>
188
- </div>
340
+ </ArticleCopyCleaner>
189
341
  </>
190
342
  );
191
343
  }
@@ -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
  );
@@ -91,7 +91,9 @@ export default function Navbar({ seriesList = [], booksList = [] }: NavbarProps)
91
91
  }, [isMenuOpen]);
92
92
 
93
93
  return (
94
- <nav className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 select-none ${
94
+ <nav
95
+ data-site-nav
96
+ className={`fixed top-0 left-0 w-full z-50 border-b transition-all duration-300 select-none ${
95
97
  isScrolled
96
98
  ? 'border-muted/10 bg-background/90 backdrop-blur-md shadow-sm'
97
99
  : 'border-transparent bg-transparent'
@@ -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" />