@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.
- package/.claude/rules/immersive-reading.md +21 -0
- package/.claude/rules/rst.md +13 -0
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +89 -219
- package/bun.lock +185 -547
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +298 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +237 -0
- package/eslint.config.mjs +18 -6
- package/package.json +42 -20
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/render-rst.py +207 -3
- package/scripts/sync-vuepress-book.ts +710 -0
- package/site.config.example.ts +3 -3
- package/site.config.ts +3 -3
- package/src/app/[slug]/layout.tsx +30 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +85 -34
- package/src/app/globals.css +570 -123
- package/src/app/page.tsx +7 -1
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookReadingShell.tsx +145 -0
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CodeBlock.test.tsx +93 -8
- package/src/components/CodeBlock.tsx +39 -101
- package/src/components/CodeBlockToolbar.tsx +88 -0
- package/src/components/CodeGroup.tsx +81 -0
- package/src/components/CoverImage.tsx +1 -0
- package/src/components/CuratedSeriesSection.tsx +28 -10
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +44 -23
- package/src/components/Footer.tsx +1 -1
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/ImmersiveReader.tsx +130 -0
- package/src/components/ImmersiveReaderTopBar.tsx +106 -0
- package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
- package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
- package/src/components/ImmersiveReadingProvider.tsx +168 -0
- package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
- package/src/components/ImmersiveToggleButton.tsx +45 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +175 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/ReadingProgressBar.tsx +1 -1
- package/src/components/RstRenderer.test.tsx +15 -15
- package/src/components/RstRenderer.tsx +37 -2
- package/src/components/Search.tsx +18 -4
- package/src/components/SelectedBooksSection.tsx +27 -8
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +44 -0
- package/src/layouts/BookLayout.tsx +62 -74
- package/src/layouts/PostLayout.tsx +154 -111
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +217 -57
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/remark-book-chapter-links.ts +106 -0
- package/src/lib/remark-code-group.ts +54 -0
- package/src/lib/remark-github-alerts.test.ts +83 -0
- package/src/lib/remark-github-alerts.ts +65 -0
- package/src/lib/remark-vuepress-containers.ts +130 -0
- package/src/lib/rst-renderer.ts +19 -7
- package/src/lib/rst.test.ts +212 -2
- package/src/lib/rst.ts +217 -13
- package/src/lib/scroll-utils.ts +44 -6
- package/src/lib/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +62 -0
- package/src/test-utils/render.ts +23 -0
- package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
- package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
- package/tests/helpers/env.ts +19 -0
- package/tests/integration/book-chapter-links.test.ts +107 -0
- package/tests/integration/book-index-cta.test.ts +87 -0
- package/tests/integration/books-nested-toc.test.ts +176 -0
- package/tests/integration/books.test.ts +3 -2
- package/tests/integration/code-block-features.test.ts +188 -0
- package/tests/integration/code-group.test.ts +183 -0
- package/tests/integration/code-notation.test.ts +97 -0
- package/tests/integration/github-alerts.test.ts +82 -0
- package/tests/integration/markdown-external-links.test.ts +103 -0
- package/tests/integration/normalize-vuepress-math.test.ts +149 -0
- package/tests/integration/reading-time-headings.test.ts +8 -6
- package/tests/integration/series-draft.test.ts +6 -13
- package/tests/integration/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +443 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/tests/unit/static-params.test.ts +32 -19
- 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
|
|
94
|
+
<span
|
|
95
|
+
className="text-[10px] font-sans font-bold uppercase tracking-widest text-accent"
|
|
96
|
+
suppressHydrationWarning
|
|
97
|
+
>
|
|
91
98
|
{isCollectionContext ? t('collection') : t('series')}
|
|
92
99
|
</span>
|
|
93
100
|
<span className="text-[10px] font-mono text-muted/60">
|
|
@@ -172,6 +179,7 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, collection
|
|
|
172
179
|
<Link
|
|
173
180
|
href={`/series/${effectiveSlug}`}
|
|
174
181
|
className="text-xs font-sans text-muted hover:text-accent transition-colors no-underline flex items-center gap-1"
|
|
182
|
+
suppressHydrationWarning
|
|
175
183
|
>
|
|
176
184
|
{isCollectionContext ? t('view_full_collection') : t('view_full_series')}
|
|
177
185
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
@@ -185,7 +193,10 @@ export default function PostSidebar({ seriesSlug, seriesTitle, posts, collection
|
|
|
185
193
|
|
|
186
194
|
{shareUrl && siteConfig.share?.enabled && (
|
|
187
195
|
<div className="mt-6 pt-6 border-t border-muted/10">
|
|
188
|
-
<p
|
|
196
|
+
<p
|
|
197
|
+
className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-3"
|
|
198
|
+
suppressHydrationWarning
|
|
199
|
+
>
|
|
189
200
|
{t('share_post')}
|
|
190
201
|
</p>
|
|
191
202
|
<ShareBar url={shareUrl} title={shareTitle ?? ''} />
|
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
expect(html).toContain('
|
|
91
|
-
expect(html).toContain('
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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]);
|
package/src/i18n/translations.ts
CHANGED
|
@@ -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
|
|