@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,68 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
5
+ import ImmersiveReader from '@/components/ImmersiveReader';
6
+ import ImmersiveSeriesSidebar from '@/components/ImmersiveSeriesSidebar';
7
+ import { getSeriesUrl } from '@/lib/urls';
8
+ import type { CollectionContext, Heading, PostData } from '@/lib/markdown';
9
+
10
+ interface PostReadingShellProps {
11
+ post: { slug: string; title: string; series?: string; headings?: Heading[] };
12
+ seriesSlug?: string;
13
+ seriesTitle?: string;
14
+ seriesPosts?: PostData[];
15
+ collectionContexts?: CollectionContext[];
16
+ /** Slim article subtree to render inside the overlay (header + body + nav).
17
+ * Pre-built in PostLayout so the heavy MarkdownRenderer/RstRenderer is the
18
+ * same ReactElement reference as in `children` — only one of the two ever
19
+ * mounts, so the body renders exactly once. */
20
+ overlayArticle: ReactNode;
21
+ /** Full normal-mode layout subtree (sidebar + article + comments + nav +
22
+ * related etc.). Rendered when immersive is off OR the post isn't in a
23
+ * series. */
24
+ children: ReactNode;
25
+ }
26
+
27
+ /**
28
+ * Post-side analog of BookReadingShell. Branches on the immersive `enabled`
29
+ * flag AND whether the post belongs to a series — without a series there's no
30
+ * meaningful TOC for the overlay sidebar, so the toggle is a no-op and we
31
+ * just render the normal layout.
32
+ */
33
+ export default function PostReadingShell({
34
+ post,
35
+ seriesSlug,
36
+ seriesTitle,
37
+ seriesPosts,
38
+ collectionContexts,
39
+ overlayArticle,
40
+ children,
41
+ }: PostReadingShellProps) {
42
+ const { enabled } = useImmersiveReading();
43
+ const inSeries = !!(seriesSlug && seriesPosts && seriesPosts.length > 0);
44
+
45
+ if (!enabled || !inSeries) {
46
+ return <>{children}</>;
47
+ }
48
+
49
+ return (
50
+ <ImmersiveReader
51
+ rootHref={getSeriesUrl(seriesSlug)}
52
+ rootTitle={seriesTitle ?? seriesSlug}
53
+ currentTitle={post.title}
54
+ sidebar={
55
+ <ImmersiveSeriesSidebar
56
+ seriesSlug={seriesSlug}
57
+ seriesTitle={seriesTitle ?? seriesSlug}
58
+ posts={seriesPosts}
59
+ collectionContexts={collectionContexts}
60
+ currentSlug={post.slug}
61
+ headings={post.headings}
62
+ />
63
+ }
64
+ >
65
+ {overlayArticle}
66
+ </ImmersiveReader>
67
+ );
68
+ }
@@ -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 ?? ''} />
@@ -12,7 +12,7 @@ export default function ReadingProgressBar() {
12
12
  if (progress <= 0) return null;
13
13
 
14
14
  return (
15
- <div className="fixed top-16 left-0 w-full h-0.5 z-50 bg-muted/10">
15
+ <div data-reading-progress className="fixed top-16 left-0 w-full h-0.5 z-50 bg-muted/10">
16
16
  <div
17
17
  className="h-full bg-accent/70 transition-[width] duration-150 ease-out"
18
18
  style={{ width: `${progress}%` }}
@@ -1,10 +1,10 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { renderToStaticMarkup } from 'react-dom/server';
3
2
  import RstRenderer from './RstRenderer';
3
+ import { renderAsync } from '@/test-utils/render';
4
4
 
5
5
  describe('RstRenderer', () => {
6
- test('renders pre-rendered html when available', () => {
7
- const html = renderToStaticMarkup(
6
+ test('renders pre-rendered html when available', async () => {
7
+ const html = await renderAsync(
8
8
  <RstRenderer
9
9
  content="Fallback body"
10
10
  html={
@@ -28,8 +28,8 @@ describe('RstRenderer', () => {
28
28
  expect(html).not.toContain('javascript:alert(4)');
29
29
  });
30
30
 
31
- test('blocks data urls on images', () => {
32
- const html = renderToStaticMarkup(
31
+ test('blocks data urls on images', async () => {
32
+ const html = await renderAsync(
33
33
  <RstRenderer
34
34
  content="Fallback body"
35
35
  html={'<p><img src="data:image/svg+xml,<svg onload=alert(1)>" alt="Bad" /></p>'}
@@ -40,8 +40,8 @@ describe('RstRenderer', () => {
40
40
  expect(html).not.toContain('data:image');
41
41
  });
42
42
 
43
- test('preserves MathML elements', () => {
44
- const html = renderToStaticMarkup(
43
+ test('preserves MathML elements', async () => {
44
+ const html = await renderAsync(
45
45
  <RstRenderer
46
46
  content="Fallback body"
47
47
  html={'<math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mi>x</mi><mo>=</mo><mn>2</mn></mrow></math>'}
@@ -53,8 +53,8 @@ describe('RstRenderer', () => {
53
53
  expect(html).toContain('<mi>x</mi>');
54
54
  });
55
55
 
56
- test('wraps rendered rst tables with the same scroll container pattern as markdown', () => {
57
- const html = renderToStaticMarkup(
56
+ test('wraps rendered rst tables with the same scroll container pattern as markdown', async () => {
57
+ const html = await renderAsync(
58
58
  <RstRenderer
59
59
  content="Fallback body"
60
60
  html={'<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>B</td></tr></tbody></table>'}
@@ -67,8 +67,8 @@ describe('RstRenderer', () => {
67
67
  expect(html).toContain('<td>B</td>');
68
68
  });
69
69
 
70
- test('renders converted headings, links, and code blocks through the markdown renderer', () => {
71
- const html = renderToStaticMarkup(
70
+ test('renders converted headings, links, and code blocks through the markdown renderer', async () => {
71
+ const html = await renderAsync(
72
72
  <RstRenderer
73
73
  content={[
74
74
  'Section',
@@ -85,9 +85,9 @@ describe('RstRenderer', () => {
85
85
 
86
86
  expect(html).toContain('Section');
87
87
  expect(html).toContain('https://example.com');
88
- expect(html).toContain('language-ts');
89
- expect(html).toContain('<code class="language-ts"');
90
- expect(html).toContain('token keyword');
91
- expect(html).toContain('token number');
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
92
  });
93
93
  });
@@ -2,6 +2,7 @@ import MarkdownRenderer from '@/components/MarkdownRenderer';
2
2
  import KatexStyles from '@/components/KatexStyles';
3
3
  import type { SlugRegistryEntry } from '@/lib/markdown';
4
4
  import { rstToMarkdown } from '@/lib/rst';
5
+ import { applyShikiToRstHtml } from '@/lib/shiki-rst';
5
6
  import sanitizeHtml from 'sanitize-html';
6
7
 
7
8
  interface RstRendererProps {
@@ -30,6 +31,11 @@ const allowedTags = [
30
31
  'figure',
31
32
  'figcaption',
32
33
  'aside',
34
+ // Tabbed code groups (CSS-only via radio + label). Without these on the
35
+ // allowlist, the rST path drops to stacked code blocks with no tabs.
36
+ // transformTags below restricts `input` to type="radio" only.
37
+ 'input',
38
+ 'label',
33
39
  'math',
34
40
  'annotation',
35
41
  'annotation-xml',
@@ -64,6 +70,13 @@ const allowedTags = [
64
70
  'semantics',
65
71
  ];
66
72
 
73
+ // Shiki emits inline `style="--shiki-light:#...; --shiki-dark:#..."` CSS vars on
74
+ // every token <span> when running in dual-theme mode, plus our custom transformers
75
+ // add `data-language`, `data-line-numbers`, `data-highlighted-line`, and `data-title`
76
+ // to <pre>/<span>. Stripping any of these silently kills syntax highlighting in rST
77
+ // output while leaving Markdown unaffected — covered by RstRenderer.test.tsx.
78
+ const codeBlockAttrs = ['style', 'data-language', 'data-line', 'data-line-numbers', 'data-highlighted-line', 'data-title', 'tabindex'];
79
+
67
80
  const allowedAttributes: sanitizeHtml.IOptions['allowedAttributes'] = {
68
81
  ...sanitizeHtml.defaults.allowedAttributes,
69
82
  '*': ['id', 'class', 'title', 'lang', 'dir', 'role', 'aria-label', 'aria-hidden'],
@@ -77,6 +90,15 @@ const allowedAttributes: sanitizeHtml.IOptions['allowedAttributes'] = {
77
90
  math: ['display', 'xmlns'],
78
91
  annotation: ['encoding'],
79
92
  'annotation-xml': ['encoding'],
93
+ pre: ['class', 'style', ...codeBlockAttrs],
94
+ code: ['class', 'style', ...codeBlockAttrs],
95
+ span: ['class', 'style', ...codeBlockAttrs],
96
+ div: ['class', 'style', 'data-group-id', 'data-panel', ...codeBlockAttrs],
97
+ // Tabbed code groups: input is restricted to type=radio via transformTags.
98
+ // Defense-in-depth: even if an unexpected attr slips in, the CSS-only tab
99
+ // mechanism can't do anything dangerous with a stray radio button.
100
+ input: ['type', 'name', 'id', 'checked', 'data-idx', 'aria-controls', 'tabindex', 'class'],
101
+ label: ['for', 'class', 'role', 'aria-controls', 'tabindex', 'data-cg-icon'],
80
102
  };
81
103
 
82
104
  function sanitizeRenderedHtml(html: string): string {
@@ -88,12 +110,25 @@ function sanitizeRenderedHtml(html: string): string {
88
110
  img: ['http', 'https'],
89
111
  },
90
112
  allowProtocolRelative: false,
113
+ transformTags: {
114
+ // Restrict <input> to type="radio" only. Anything else gets stripped.
115
+ // Prevents an rST author from injecting password/file/etc. inputs.
116
+ input: (tagName, attribs) => {
117
+ if (attribs.type !== 'radio') {
118
+ return { tagName: 'span', attribs: {} };
119
+ }
120
+ return { tagName, attribs };
121
+ },
122
+ },
91
123
  });
92
124
  }
93
125
 
94
- export default function RstRenderer({ content, html, latex = false, slug, slugRegistry }: RstRendererProps) {
126
+ export default async function RstRenderer({ content, html, latex = false, slug, slugRegistry }: RstRendererProps) {
95
127
  if (html) {
96
- const sanitizedHtml = sanitizeRenderedHtml(html).replace(
128
+ // The docutils pass emits opaque <pre data-amytis-code> markers; run them through
129
+ // Shiki here (server-side, build-time for SSG) before sanitizing.
130
+ const highlighted = await applyShikiToRstHtml(html);
131
+ const sanitizedHtml = sanitizeRenderedHtml(highlighted).replace(
97
132
  /<table\b([^>]*)>/g,
98
133
  '<div class="rst-table-wrapper"><table$1>'
99
134
  ).replace(/<\/table>/g, '</table></div>');
@@ -130,8 +130,11 @@ export default function Search() {
130
130
  // True while debounce is pending — suppress "no results" flash
131
131
  const isTyping = query.length > 0 && query !== debouncedQuery;
132
132
 
133
- // Load recent searches on mount
133
+ // Load recent searches on mount. localStorage is unavailable during SSR,
134
+ // so this can't be hoisted into useState's initializer without breaking
135
+ // hydration — the mount-only effect is the documented React pattern.
134
136
  useEffect(() => {
137
+ // eslint-disable-next-line react-hooks/set-state-in-effect
135
138
  setRecentSearches(loadRecentSearches());
136
139
  }, []);
137
140
 
@@ -142,16 +145,22 @@ export default function Search() {
142
145
  }
143
146
  }, [isOpen]);
144
147
 
145
- // Debounce query
148
+ // Debounce query. The sync reset when `query` is empty is intentional:
149
+ // skipping it would leave stale results visible for DEBOUNCE_MS after the
150
+ // user clears the input.
146
151
  useEffect(() => {
152
+ // eslint-disable-next-line react-hooks/set-state-in-effect
147
153
  if (!query) { setDebouncedQuery(''); return; }
148
154
  const timer = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS);
149
155
  return () => clearTimeout(timer);
150
156
  }, [query]);
151
157
 
152
- // Run Pagefind search on debounced query
158
+ // Run Pagefind search on debounced query. Synchronous resets when the
159
+ // query becomes empty are the simplest way to clear results state without
160
+ // threading conditional renders through every consumer of allResults.
153
161
  useEffect(() => {
154
162
  if (!debouncedQuery) {
163
+ // eslint-disable-next-line react-hooks/set-state-in-effect
155
164
  setAllResults([]);
156
165
  setActiveIndex(-1);
157
166
  setActiveType('All');
@@ -214,17 +223,22 @@ export default function Search() {
214
223
  return () => window.removeEventListener('keydown', handleKeyDown);
215
224
  }, []);
216
225
 
217
- // Focus on open; full reset on close
226
+ // Focus on open; full reset on close. The 6 resets are batched into a
227
+ // single React render — the rule's "cascading renders" warning doesn't
228
+ // apply when state changes are batched as siblings, only when one update
229
+ // triggers the next.
218
230
  useEffect(() => {
219
231
  if (isOpen) {
220
232
  setTimeout(() => inputRef.current?.focus(), 100);
221
233
  } else {
234
+ /* eslint-disable react-hooks/set-state-in-effect */
222
235
  setQuery('');
223
236
  setDebouncedQuery('');
224
237
  setAllResults([]);
225
238
  setActiveIndex(-1);
226
239
  setActiveType('All');
227
240
  setIsFetching(false);
241
+ /* eslint-enable react-hooks/set-state-in-effect */
228
242
  }
229
243
  }, [isOpen]);
230
244
 
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useState, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback } from 'react';
4
4
  import Link from 'next/link';
5
5
  import CoverImage from './CoverImage';
6
6
  import HorizontalScroll from './HorizontalScroll';
7
7
  import { useLanguage } from './LanguageProvider';
8
- import { shuffle, shuffleSeeded } from '@/lib/shuffle';
8
+ import { shuffle } from '@/lib/shuffle';
9
+ import { byDateAsc, byDateDesc } from '@/lib/sort';
9
10
  import { getBooksListUrl, getBookUrl, getBookChapterUrl } from '@/lib/urls';
10
11
 
11
12
  export interface BookItem {
@@ -16,19 +17,37 @@ export interface BookItem {
16
17
  authors: string[];
17
18
  chapterCount: number;
18
19
  firstChapter?: string;
20
+ date: string;
19
21
  }
20
22
 
23
+ type BookOrder = 'shuffle' | 'date-desc' | 'date-asc';
24
+
21
25
  interface SelectedBooksSectionProps {
22
26
  books: BookItem[];
23
27
  maxItems?: number;
28
+ order?: BookOrder;
29
+ }
30
+
31
+ function canonicalOrder(books: BookItem[], order: BookOrder): BookItem[] {
32
+ if (order === 'date-desc') return [...books].sort(byDateDesc);
33
+ if (order === 'date-asc') return [...books].sort(byDateAsc);
34
+ // For 'shuffle': SSR-stable canonical order (input is already date-desc from getAllBooks).
35
+ // The post-mount useEffect swaps to a random permutation on the client.
36
+ return books;
24
37
  }
25
38
 
26
- export default function SelectedBooksSection({ books, maxItems = 4 }: SelectedBooksSectionProps) {
39
+ export default function SelectedBooksSection({ books, maxItems = 4, order = 'shuffle' }: SelectedBooksSectionProps) {
27
40
  const { t } = useLanguage();
28
- const [displayed, setDisplayed] = useState(() => {
29
- const dailySeed = Math.floor(Date.now() / 86400000);
30
- return shuffleSeeded(books, dailySeed).slice(0, maxItems);
31
- });
41
+ const [displayed, setDisplayed] = useState(() => canonicalOrder(books, order).slice(0, maxItems));
42
+
43
+ // Shuffle on mount so every reload re-rolls. SSR's canonical render is stable; the
44
+ // post-hydration swap is the intentional client-only behaviour, not a sync issue.
45
+ useEffect(() => {
46
+ if (order === 'shuffle') {
47
+ // eslint-disable-next-line react-hooks/set-state-in-effect
48
+ setDisplayed(shuffle(books).slice(0, maxItems));
49
+ }
50
+ }, [books, maxItems, order]);
32
51
 
33
52
  const handleShuffle = useCallback(() => {
34
53
  setDisplayed(shuffle(books).slice(0, maxItems));
@@ -41,7 +60,7 @@ export default function SelectedBooksSection({ books, maxItems = 4 }: SelectedBo
41
60
  <div className="flex items-center justify-between mb-8">
42
61
  <h2 className="text-2xl sm:text-3xl font-serif font-bold text-heading">{t('selected_books')}</h2>
43
62
  <div className="flex items-center gap-4">
44
- {books.length > maxItems && (
63
+ {order === 'shuffle' && books.length > maxItems && (
45
64
  <button
46
65
  onClick={handleShuffle}
47
66
  className="rounded-sm text-sm text-muted transition-colors hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2"
@@ -68,7 +68,7 @@ export default function SeriesCatalog({ posts, startIndex = 0, totalPosts, colle
68
68
  <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted mb-3">
69
69
  <span>{post.date}</span>
70
70
  <span className="hidden sm:inline">•</span>
71
- <span className="text-accent/80">{post.readingTime}</span>
71
+ <span className="text-accent/80">{post.readingMinutes} {t('reading_time')}</span>
72
72
  {post.category && (
73
73
  <>
74
74
  <span className="hidden sm:inline">•</span>
@@ -77,6 +77,10 @@ export default function ShareBar({ url, title, className = '' }: ShareBarProps)
77
77
  const btnClass = 'inline-flex items-center justify-center w-8 h-8 rounded text-muted hover:text-accent hover:bg-muted/10 transition-colors';
78
78
 
79
79
  return (
80
+ // suppressHydrationWarning on locale-bound nodes is a band-aid for the
81
+ // known static-export + client-i18n drift: SSR renders defaultLocale,
82
+ // `useLanguage()` hook serves the user's saved locale on hydration. The
83
+ // real fix is per-locale URL routing, tracked as a separate refactor.
80
84
  <div className={`flex flex-row flex-wrap gap-1 ${className}`}>
81
85
  {platforms.map((platform) => {
82
86
  const { label, Icon } = PLATFORM_META[platform];
@@ -90,6 +94,7 @@ export default function ShareBar({ url, title, className = '' }: ShareBarProps)
90
94
  title={copyLabel}
91
95
  aria-label={copyLabel}
92
96
  className={`${btnClass} ${copied ? 'text-accent' : ''}`}
97
+ suppressHydrationWarning
93
98
  >
94
99
  {copied ? <LuCheck size={16} /> : <Icon size={16} />}
95
100
  </button>
@@ -19,9 +19,16 @@ export default function TocPanel({ headings, className = '' }: TocPanelProps) {
19
19
  if (headings.length === 0) return null;
20
20
 
21
21
  return (
22
- <nav aria-label={t('on_this_page')} className={className}>
22
+ // suppressHydrationWarning on locale-bound nodes is a band-aid for the
23
+ // known static-export + client-i18n drift: SSR renders defaultLocale,
24
+ // `useLanguage()` hook serves the user's saved locale on hydration. The
25
+ // real fix is per-locale URL routing, tracked as a separate refactor.
26
+ <nav aria-label={t('on_this_page')} className={className} suppressHydrationWarning>
23
27
  <div className="flex items-center justify-between mb-3">
24
- <span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted">
28
+ <span
29
+ className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted"
30
+ suppressHydrationWarning
31
+ >
25
32
  {t('on_this_page')}
26
33
  </span>
27
34
  <button
@@ -30,6 +37,7 @@ export default function TocPanel({ headings, className = '' }: TocPanelProps) {
30
37
  className="text-muted hover:text-foreground transition-colors"
31
38
  aria-expanded={!collapsed}
32
39
  aria-label={collapsed ? t('toc_expand') : t('toc_collapse')}
40
+ suppressHydrationWarning
33
41
  >
34
42
  <svg
35
43
  className={`w-3.5 h-3.5 transition-transform duration-200 ${collapsed ? '' : 'rotate-180'}`}
@@ -2,11 +2,12 @@
2
2
 
3
3
  import { useState, useEffect } from 'react';
4
4
  import type { Heading } from '@/lib/markdown';
5
- import { useScrollY } from './useScrollY';
5
+ import { getScrollableAncestor } from '@/lib/scroll-utils';
6
+
7
+ const ACTIVATION_LINE_PX = 100;
6
8
 
7
9
  export function useActiveHeading(headings: Heading[], enabled = true): string {
8
10
  const [activeId, setActiveId] = useState('');
9
- const scrollY = useScrollY();
10
11
 
11
12
  useEffect(() => {
12
13
  if (!enabled || headings.length === 0) return;
@@ -14,19 +15,40 @@ export function useActiveHeading(headings: Heading[], enabled = true): string {
14
15
  const elements = headings
15
16
  .map(h => document.getElementById(h.id))
16
17
  .filter(Boolean) as HTMLElement[];
17
-
18
18
  if (elements.length === 0) return;
19
19
 
20
- const scrollPosition = scrollY + 100;
21
- let current = elements[0];
22
- for (const el of elements) {
23
- if (el.offsetTop <= scrollPosition) current = el;
24
- else break;
25
- }
26
-
27
- const rafId = requestAnimationFrame(() => { if (current) setActiveId(current.id); });
28
- return () => cancelAnimationFrame(rafId);
29
- }, [scrollY, headings, enabled]);
20
+ // In immersive reading mode the chapter scrolls inside the overlay's
21
+ // <main>, not the window, so subscribe to whichever ancestor is doing
22
+ // the scrolling. `getScrollableAncestor` returns null for normal pages.
23
+ const container = getScrollableAncestor(elements[0]);
24
+ const target: HTMLElement | Window = container ?? window;
25
+
26
+ let rafId = 0;
27
+ const compute = () => {
28
+ const containerTop = container
29
+ ? container.getBoundingClientRect().top
30
+ : 0;
31
+ let current = elements[0];
32
+ for (const el of elements) {
33
+ const top = el.getBoundingClientRect().top - containerTop;
34
+ if (top <= ACTIVATION_LINE_PX) current = el;
35
+ else break;
36
+ }
37
+ setActiveId(current.id);
38
+ };
39
+
40
+ const onScroll = () => {
41
+ cancelAnimationFrame(rafId);
42
+ rafId = requestAnimationFrame(compute);
43
+ };
44
+
45
+ compute();
46
+ target.addEventListener('scroll', onScroll, { passive: true });
47
+ return () => {
48
+ cancelAnimationFrame(rafId);
49
+ target.removeEventListener('scroll', onScroll);
50
+ };
51
+ }, [enabled, headings]);
30
52
 
31
53
  return activeId;
32
54
  }
@@ -1,18 +1,42 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, type RefObject } from 'react';
3
+ import { useEffect, useRef, type RefObject } from 'react';
4
4
 
5
+ /**
6
+ * Scrolls the current sidebar item into view when `dep` changes.
7
+ *
8
+ * - **First run** (sidebar just mounted — e.g. the reader landed on a page
9
+ * mid-TOC): centres the current item so there's context above and below.
10
+ * - **Subsequent runs** (the reader clicked another sidebar link to
11
+ * navigate): only scrolls if the new current item is out of view, and
12
+ * only enough to bring it on-screen. Crucially, if the target was
13
+ * already visible, the sidebar's scroll position does **not** change —
14
+ * clicking a chapter right in front of you no longer makes the sidebar
15
+ * jump.
16
+ *
17
+ * The previous implementation always hard-centred via `scrollTop = ...`
18
+ * regardless of visibility, which on long book TOCs visibly snapped the
19
+ * sidebar back toward the top whenever the new current chapter was in
20
+ * the upper half. `scrollIntoView({ block: 'nearest' })` handles the
21
+ * "skip if already visible" case natively. The `sidebarRef` first
22
+ * parameter is kept for API stability (all callers still pass it); the
23
+ * new implementation doesn't need it because `scrollIntoView` walks up
24
+ * to the nearest scrollable ancestor on its own.
25
+ */
5
26
  export function useSidebarAutoScroll(
6
- sidebarRef: RefObject<HTMLElement | null>,
27
+ _sidebarRef: RefObject<HTMLElement | null>,
7
28
  itemRef: RefObject<HTMLElement | null>,
8
29
  dep: unknown,
9
30
  ): void {
31
+ const hasRunRef = useRef(false);
10
32
  useEffect(() => {
11
- if (itemRef.current && sidebarRef.current) {
12
- const item = itemRef.current;
13
- const sidebar = sidebarRef.current;
14
- sidebar.scrollTop = item.offsetTop - sidebar.clientHeight / 2 + item.offsetHeight / 2;
15
- }
33
+ const item = itemRef.current;
34
+ if (!item) return;
35
+ item.scrollIntoView({
36
+ block: hasRunRef.current ? 'nearest' : 'center',
37
+ inline: 'nearest',
38
+ });
39
+ hasRunRef.current = true;
16
40
  // refs are stable; only re-run when dep changes
17
41
  // eslint-disable-next-line react-hooks/exhaustive-deps
18
42
  }, [dep]);
@@ -14,6 +14,7 @@ export const translations = {
14
14
  view_archive: "View Archive",
15
15
  written_by: "Written by",
16
16
  reading_time: "min read",
17
+ words: "words",
17
18
  next_page: "Next",
18
19
  prev_page: "Prev",
19
20
  back_to_home: "Back to Home",
@@ -144,6 +145,27 @@ export const translations = {
144
145
  archive_description: "A complete chronological archive of all articles.",
145
146
  tags_description: "Explore topics spanning all articles and flow notes.",
146
147
  posts_description: "Browse all articles.",
148
+ immersive_reading: "Immersive reading",
149
+ exit_reading_mode: "Exit reading mode",
150
+ reading_preferences: "Reading preferences",
151
+ font_size: "Font size",
152
+ reading_theme: "Theme",
153
+ column_width: "Width",
154
+ theme_auto: "Auto",
155
+ theme_light: "Light",
156
+ theme_sepia: "Sepia",
157
+ theme_dark: "Dark",
158
+ size_small: "S",
159
+ size_medium: "M",
160
+ size_large: "L",
161
+ size_xl: "XL",
162
+ width_narrow: "Narrow",
163
+ width_medium: "Medium",
164
+ width_wide: "Wide",
165
+ width_full: "Full",
166
+ collapse_sidebar: "Collapse sidebar",
167
+ expand_sidebar: "Expand sidebar",
168
+ reset_to_defaults: "Reset to defaults",
147
169
  },
148
170
  zh: {
149
171
  home: "首页",
@@ -160,6 +182,7 @@ export const translations = {
160
182
  view_archive: "查看归档",
161
183
  written_by: "作者",
162
184
  reading_time: "分钟阅读",
185
+ words: "字",
163
186
  next_page: "下一页",
164
187
  prev_page: "上一页",
165
188
  back_to_home: "返回首页",
@@ -290,6 +313,27 @@ export const translations = {
290
313
  archive_description: "全部文章的时间轴归档。",
291
314
  tags_description: "浏览全部文章与随笔的主题标签。",
292
315
  posts_description: "浏览全部文章。",
316
+ immersive_reading: "沉浸式阅读",
317
+ exit_reading_mode: "退出阅读模式",
318
+ reading_preferences: "阅读设置",
319
+ font_size: "字号",
320
+ reading_theme: "主题",
321
+ column_width: "宽度",
322
+ theme_auto: "自动",
323
+ theme_light: "浅色",
324
+ theme_sepia: "护眼",
325
+ theme_dark: "深色",
326
+ size_small: "小",
327
+ size_medium: "中",
328
+ size_large: "大",
329
+ size_xl: "特大",
330
+ width_narrow: "窄",
331
+ width_medium: "中",
332
+ width_wide: "宽",
333
+ width_full: "全宽",
334
+ collapse_sidebar: "收起目录",
335
+ expand_sidebar: "展开目录",
336
+ reset_to_defaults: "恢复默认",
293
337
  },
294
338
  };
295
339