@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
package/src/app/page.tsx
CHANGED
|
@@ -34,6 +34,7 @@ type HomepageSection = {
|
|
|
34
34
|
enabled?: boolean;
|
|
35
35
|
weight: number;
|
|
36
36
|
maxItems?: number;
|
|
37
|
+
order?: 'shuffle' | 'date-desc' | 'date-asc';
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
export default function Home() {
|
|
@@ -76,6 +77,7 @@ export default function Home() {
|
|
|
76
77
|
url: `/series/${slug}`,
|
|
77
78
|
postCount: seriesPosts.length,
|
|
78
79
|
topPosts: seriesPosts.slice(0, 3).map(p => ({ slug: p.slug, title: p.title })),
|
|
80
|
+
date: seriesData?.date ?? seriesPosts[0]?.date ?? '',
|
|
79
81
|
};
|
|
80
82
|
})
|
|
81
83
|
: [];
|
|
@@ -89,6 +91,7 @@ export default function Home() {
|
|
|
89
91
|
authors: b.authors,
|
|
90
92
|
chapterCount: b.chapters.length,
|
|
91
93
|
firstChapter: b.chapters[0]?.id,
|
|
94
|
+
date: b.date,
|
|
92
95
|
}))
|
|
93
96
|
: [];
|
|
94
97
|
|
|
@@ -109,7 +112,7 @@ export default function Home() {
|
|
|
109
112
|
excerpt: p.excerpt,
|
|
110
113
|
date: p.date,
|
|
111
114
|
category: p.category,
|
|
112
|
-
|
|
115
|
+
readingMinutes: p.readingMinutes,
|
|
113
116
|
coverImage: p.coverImage,
|
|
114
117
|
series: p.series,
|
|
115
118
|
pinned: p.pinned,
|
|
@@ -133,6 +136,7 @@ export default function Home() {
|
|
|
133
136
|
key="featured-series"
|
|
134
137
|
allSeries={seriesItems}
|
|
135
138
|
maxItems={section.maxItems ?? 6}
|
|
139
|
+
order={section.order ?? 'shuffle'}
|
|
136
140
|
/>
|
|
137
141
|
);
|
|
138
142
|
case 'featured-books':
|
|
@@ -142,6 +146,7 @@ export default function Home() {
|
|
|
142
146
|
key="featured-books"
|
|
143
147
|
books={bookItems}
|
|
144
148
|
maxItems={section.maxItems ?? 4}
|
|
149
|
+
order={section.order ?? 'shuffle'}
|
|
145
150
|
/>
|
|
146
151
|
);
|
|
147
152
|
case 'featured-posts':
|
|
@@ -151,6 +156,7 @@ export default function Home() {
|
|
|
151
156
|
key="featured-posts"
|
|
152
157
|
allFeatured={featuredItems}
|
|
153
158
|
maxItems={section.maxItems ?? 4}
|
|
159
|
+
order={section.order ?? 'shuffle'}
|
|
154
160
|
/>
|
|
155
161
|
);
|
|
156
162
|
case 'latest-posts':
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Suspense, type ReactNode } from 'react';
|
|
2
|
+
import { ImmersiveReadingProvider } from '@/components/ImmersiveReadingProvider';
|
|
3
|
+
import ImmersiveReadingFlagHandler from '@/components/ImmersiveReadingFlagHandler';
|
|
4
|
+
|
|
5
|
+
// Mirror of `src/app/[slug]/layout.tsx` for the default-path post URL
|
|
6
|
+
// (`/posts/<slug>`). Series posts can live under either URL pattern
|
|
7
|
+
// depending on the `series.autoPaths` setting in site.config.ts — having the
|
|
8
|
+
// provider mounted at both layout boundaries means immersive state persists
|
|
9
|
+
// across in-series navigation regardless of which pattern the user's site
|
|
10
|
+
// uses. Same Suspense isolation around the flag handler.
|
|
11
|
+
export default function PostsLayout({ children }: { children: ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<ImmersiveReadingProvider>
|
|
14
|
+
<Suspense fallback={null}>
|
|
15
|
+
<ImmersiveReadingFlagHandler />
|
|
16
|
+
</Suspense>
|
|
17
|
+
{children}
|
|
18
|
+
</ImmersiveReadingProvider>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -156,16 +156,40 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
|
|
|
156
156
|
const firstPost = (seriesData?.sort === 'date-asc' || seriesData?.sort === 'manual' || isCollection)
|
|
157
157
|
? allPosts[0]
|
|
158
158
|
: allPosts[allPosts.length - 1];
|
|
159
|
+
const primaryHref = isCollection ? getPostUrlInCollection(firstPost, slug) : getPostUrl(firstPost);
|
|
160
|
+
// primaryHref already carries `?collection=<slug>` for collection
|
|
161
|
+
// contexts (see getPostUrlInCollection), so naïvely appending
|
|
162
|
+
// `?immersive=1` would produce an invalid double-`?` URL and the
|
|
163
|
+
// flag handler would never fire. Use the right separator.
|
|
164
|
+
const immersiveHref = `${primaryHref}${primaryHref.includes('?') ? '&' : '?'}immersive=1`;
|
|
159
165
|
return (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
|
167
|
+
<Link
|
|
168
|
+
href={primaryHref}
|
|
169
|
+
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors no-underline"
|
|
170
|
+
>
|
|
171
|
+
{t('start_reading')}
|
|
172
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
173
|
+
<polyline points="9 18 15 12 9 6" />
|
|
174
|
+
</svg>
|
|
175
|
+
</Link>
|
|
176
|
+
{/* Secondary CTA — opens the first post of the series in immersive mode.
|
|
177
|
+
The `?immersive=1` query param is read by ImmersiveReadingProvider
|
|
178
|
+
on mount, which calls enter() then strips the flag from the URL
|
|
179
|
+
so back-navigation doesn't re-trigger it. */}
|
|
180
|
+
<Link
|
|
181
|
+
href={immersiveHref}
|
|
182
|
+
className="inline-flex items-center gap-2 px-5 py-2.5 border border-muted/30 text-foreground/80 hover:text-accent hover:border-accent/50 rounded-full text-sm font-medium no-underline transition-colors"
|
|
183
|
+
>
|
|
184
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
185
|
+
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
|
186
|
+
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
|
187
|
+
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
|
188
|
+
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
|
189
|
+
</svg>
|
|
190
|
+
{t('immersive_reading')}
|
|
191
|
+
</Link>
|
|
192
|
+
</div>
|
|
169
193
|
);
|
|
170
194
|
})()}
|
|
171
195
|
{authors.length > 0 && (
|
package/src/app/sitemap.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MetadataRoute } from 'next';
|
|
2
2
|
import { getAllPosts, getAllPages, getAllBooks, getAllFlows } from '@/lib/markdown';
|
|
3
3
|
import { siteConfig } from '../../site.config';
|
|
4
|
-
import { getPostUrl } from '@/lib/urls';
|
|
4
|
+
import { getPostUrl, getBookUrl, getBookChapterUrl } from '@/lib/urls';
|
|
5
5
|
|
|
6
6
|
export const dynamic = 'force-static';
|
|
7
7
|
|
|
@@ -29,13 +29,13 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|
|
29
29
|
|
|
30
30
|
const bookUrls = books.flatMap((book) => [
|
|
31
31
|
{
|
|
32
|
-
url: `${baseUrl}
|
|
32
|
+
url: `${baseUrl}${getBookUrl(book.slug)}`,
|
|
33
33
|
lastModified: book.date,
|
|
34
34
|
changeFrequency: 'monthly' as const,
|
|
35
35
|
priority: 0.8,
|
|
36
36
|
},
|
|
37
37
|
...book.chapters.map((ch) => ({
|
|
38
|
-
url: `${baseUrl}
|
|
38
|
+
url: `${baseUrl}${getBookChapterUrl(book.slug, ch.id)}`,
|
|
39
39
|
lastModified: book.date,
|
|
40
40
|
changeFrequency: 'monthly' as const,
|
|
41
41
|
priority: 0.7,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
const KEEP_BG_SELECTOR = 'pre, code, blockquote, .admonition, [class*="admonition"]';
|
|
6
|
+
const STRIP_BG_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, ul, ol, li, div, span, td, th, tr, article, section';
|
|
7
|
+
|
|
8
|
+
function isMeaningfulBg(value: string): boolean {
|
|
9
|
+
if (!value) return false;
|
|
10
|
+
const v = value.trim().toLowerCase();
|
|
11
|
+
if (v === 'transparent' || v === 'rgba(0, 0, 0, 0)' || v === 'rgb(0, 0, 0, 0)') return false;
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function ArticleCopyCleaner({ children }: { children: ReactNode }) {
|
|
16
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const root = rootRef.current;
|
|
20
|
+
if (!root) return;
|
|
21
|
+
|
|
22
|
+
const handleCopy = (event: ClipboardEvent) => {
|
|
23
|
+
const selection = window.getSelection();
|
|
24
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
|
|
25
|
+
|
|
26
|
+
const range = selection.getRangeAt(0);
|
|
27
|
+
const anchor = range.commonAncestorContainer;
|
|
28
|
+
if (!(anchor instanceof Node) || !root.contains(anchor)) return;
|
|
29
|
+
|
|
30
|
+
const sandbox = document.createElement('div');
|
|
31
|
+
sandbox.setAttribute('aria-hidden', 'true');
|
|
32
|
+
sandbox.style.cssText = 'position:fixed;left:-99999px;top:0;visibility:hidden;pointer-events:none;';
|
|
33
|
+
sandbox.appendChild(range.cloneContents());
|
|
34
|
+
root.appendChild(sandbox);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
sandbox.querySelectorAll<HTMLElement>(KEEP_BG_SELECTOR).forEach((el) => {
|
|
38
|
+
const bg = getComputedStyle(el).backgroundColor;
|
|
39
|
+
if (isMeaningfulBg(bg)) el.style.backgroundColor = bg;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
sandbox.querySelectorAll<HTMLElement>(STRIP_BG_SELECTOR).forEach((el) => {
|
|
43
|
+
if (el.matches(KEEP_BG_SELECTOR)) return;
|
|
44
|
+
el.style.removeProperty('background-color');
|
|
45
|
+
el.style.removeProperty('background');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const clipboard = event.clipboardData;
|
|
49
|
+
if (!clipboard) return;
|
|
50
|
+
|
|
51
|
+
clipboard.setData('text/html', sandbox.innerHTML);
|
|
52
|
+
clipboard.setData('text/plain', selection.toString());
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
} finally {
|
|
55
|
+
sandbox.remove();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
root.addEventListener('copy', handleCopy);
|
|
60
|
+
return () => root.removeEventListener('copy', handleCopy);
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
return <div ref={rootRef}>{children}</div>;
|
|
64
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
|
-
import { BookTocItem, BookChapterEntry } from '@/lib/markdown';
|
|
5
|
+
import { BookTocItem, BookTocSection, BookChapterRef, BookChapterEntry } from '@/lib/markdown';
|
|
6
6
|
import { useLanguage } from './LanguageProvider';
|
|
7
7
|
import PrevNextNav from './PrevNextNav';
|
|
8
8
|
import { getBookChapterUrl } from '@/lib/urls';
|
|
@@ -23,6 +23,42 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
|
|
|
23
23
|
const prevChapter = currentIndex > 0 ? chapters[currentIndex - 1] : null;
|
|
24
24
|
const nextChapter = currentIndex < chapters.length - 1 ? chapters[currentIndex + 1] : null;
|
|
25
25
|
|
|
26
|
+
const renderChapterRow = (ch: BookChapterRef, key: string) => {
|
|
27
|
+
const isCurrent = ch.id === currentChapter;
|
|
28
|
+
const chIdx = chapters.findIndex(c => c.id === ch.id);
|
|
29
|
+
const isPast = chIdx >= 0 && chIdx < currentIndex;
|
|
30
|
+
return isCurrent ? (
|
|
31
|
+
<div key={key} className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-accent/5">
|
|
32
|
+
<span className="text-sm font-semibold text-accent truncate">{ch.title}</span>
|
|
33
|
+
</div>
|
|
34
|
+
) : (
|
|
35
|
+
<Link
|
|
36
|
+
key={key}
|
|
37
|
+
href={getBookChapterUrl(bookSlug, ch.id)}
|
|
38
|
+
className={`block py-1.5 px-2 rounded-lg text-sm no-underline hover:bg-muted/5 transition-colors ${
|
|
39
|
+
isPast ? 'text-foreground/70 hover:text-foreground' : 'text-muted hover:text-foreground'
|
|
40
|
+
}`}
|
|
41
|
+
>
|
|
42
|
+
{ch.title}
|
|
43
|
+
</Link>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const renderSection = (section: BookTocSection, key: string) => (
|
|
48
|
+
<div key={key}>
|
|
49
|
+
<div className="text-[10px] font-sans font-bold uppercase tracking-wider text-muted px-2 py-1.5">
|
|
50
|
+
{section.section}
|
|
51
|
+
</div>
|
|
52
|
+
<div className="space-y-1 pl-2">
|
|
53
|
+
{section.items.map((child, idx) =>
|
|
54
|
+
'section' in child
|
|
55
|
+
? renderSection(child, `${key}-${idx}`)
|
|
56
|
+
: renderChapterRow(child, `${key}-${child.id}`)
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
|
|
26
62
|
return (
|
|
27
63
|
<div className="lg:hidden p-5 bg-muted/5 rounded-xl border border-muted/20">
|
|
28
64
|
{/* Header */}
|
|
@@ -85,58 +121,16 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
|
|
|
85
121
|
<div className="text-[10px] font-sans font-bold uppercase tracking-wider text-muted px-2 py-1.5">
|
|
86
122
|
{item.part}
|
|
87
123
|
</div>
|
|
88
|
-
<
|
|
89
|
-
{item.chapters.map(ch => {
|
|
90
|
-
|
|
91
|
-
const chIdx = chapters.findIndex(c => c.id === ch.id);
|
|
92
|
-
const isPast = chIdx < currentIndex;
|
|
93
|
-
|
|
94
|
-
return (
|
|
95
|
-
<li key={ch.id}>
|
|
96
|
-
{isCurrent ? (
|
|
97
|
-
<div className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-accent/5">
|
|
98
|
-
<span className="text-sm font-semibold text-accent truncate">{ch.title}</span>
|
|
99
|
-
</div>
|
|
100
|
-
) : (
|
|
101
|
-
<Link
|
|
102
|
-
href={getBookChapterUrl(bookSlug, ch.id)}
|
|
103
|
-
className={`block py-1.5 px-2 rounded-lg text-sm no-underline hover:bg-muted/5 transition-colors ${
|
|
104
|
-
isPast ? 'text-foreground/70 hover:text-foreground' : 'text-muted hover:text-foreground'
|
|
105
|
-
}`}
|
|
106
|
-
>
|
|
107
|
-
{ch.title}
|
|
108
|
-
</Link>
|
|
109
|
-
)}
|
|
110
|
-
</li>
|
|
111
|
-
);
|
|
112
|
-
})}
|
|
113
|
-
</ol>
|
|
114
|
-
</div>
|
|
115
|
-
);
|
|
116
|
-
} else {
|
|
117
|
-
const isCurrent = item.id === currentChapter;
|
|
118
|
-
const chIdx = chapters.findIndex(c => c.id === item.id);
|
|
119
|
-
const isPast = chIdx < currentIndex;
|
|
120
|
-
|
|
121
|
-
return (
|
|
122
|
-
<div key={item.id}>
|
|
123
|
-
{isCurrent ? (
|
|
124
|
-
<div className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-accent/5">
|
|
125
|
-
<span className="text-sm font-semibold text-accent truncate">{item.title}</span>
|
|
126
|
-
</div>
|
|
127
|
-
) : (
|
|
128
|
-
<Link
|
|
129
|
-
href={`/books/${bookSlug}/${item.id}`}
|
|
130
|
-
className={`block py-1.5 px-2 rounded-lg text-sm no-underline hover:bg-muted/5 transition-colors ${
|
|
131
|
-
isPast ? 'text-foreground/70 hover:text-foreground' : 'text-muted hover:text-foreground'
|
|
132
|
-
}`}
|
|
133
|
-
>
|
|
134
|
-
{item.title}
|
|
135
|
-
</Link>
|
|
136
|
-
)}
|
|
124
|
+
<div className="space-y-1">
|
|
125
|
+
{item.chapters.map(ch => renderChapterRow(ch, `part-${tocIdx}-${ch.id}`))}
|
|
126
|
+
</div>
|
|
137
127
|
</div>
|
|
138
128
|
);
|
|
139
129
|
}
|
|
130
|
+
if ('section' in item) {
|
|
131
|
+
return renderSection(item, `section-${tocIdx}`);
|
|
132
|
+
}
|
|
133
|
+
return renderChapterRow(item, `chapter-${item.id}`);
|
|
140
134
|
})}
|
|
141
135
|
</div>
|
|
142
136
|
)}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import BookSidebar from '@/components/BookSidebar';
|
|
5
|
+
import BookMobileNav from '@/components/BookMobileNav';
|
|
6
|
+
import PrevNextNav from '@/components/PrevNextNav';
|
|
7
|
+
import ReadingProgressBar from '@/components/ReadingProgressBar';
|
|
8
|
+
import Comments from '@/components/Comments';
|
|
9
|
+
import { useLanguage } from '@/components/LanguageProvider';
|
|
10
|
+
import { useImmersiveReading } from '@/components/ImmersiveReadingProvider';
|
|
11
|
+
import ImmersiveReader from '@/components/ImmersiveReader';
|
|
12
|
+
import ImmersiveToggleButton from '@/components/ImmersiveToggleButton';
|
|
13
|
+
import type { BookTocItem, BookChapterEntry, Heading } from '@/lib/markdown';
|
|
14
|
+
import { getBookUrl } from '@/lib/urls';
|
|
15
|
+
|
|
16
|
+
interface BookReadingShellProps {
|
|
17
|
+
book: {
|
|
18
|
+
slug: string;
|
|
19
|
+
title: string;
|
|
20
|
+
toc: BookTocItem[];
|
|
21
|
+
chapters: BookChapterEntry[];
|
|
22
|
+
showChapterExcerpt: boolean;
|
|
23
|
+
};
|
|
24
|
+
chapter: {
|
|
25
|
+
slug: string;
|
|
26
|
+
title: string;
|
|
27
|
+
wordCount: number;
|
|
28
|
+
readingMinutes: number;
|
|
29
|
+
excerpt?: string;
|
|
30
|
+
headings: Heading[];
|
|
31
|
+
};
|
|
32
|
+
prev: { href: string; title: string } | null;
|
|
33
|
+
next: { href: string; title: string } | null;
|
|
34
|
+
comments: { slug: string; postUrl: string } | null;
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function BookReadingShell({
|
|
39
|
+
book,
|
|
40
|
+
chapter,
|
|
41
|
+
prev,
|
|
42
|
+
next,
|
|
43
|
+
comments,
|
|
44
|
+
children,
|
|
45
|
+
}: BookReadingShellProps) {
|
|
46
|
+
const { t } = useLanguage();
|
|
47
|
+
const { enabled } = useImmersiveReading();
|
|
48
|
+
|
|
49
|
+
const chapterHeader = (
|
|
50
|
+
<header className="mb-12 pb-8 border-b border-muted/10">
|
|
51
|
+
<div className="flex items-center gap-3 text-xs font-sans text-muted mb-4">
|
|
52
|
+
<span className="uppercase tracking-widest font-semibold text-accent">
|
|
53
|
+
{t('chapter')}
|
|
54
|
+
</span>
|
|
55
|
+
<span className="w-1 h-1 rounded-full bg-muted/30" />
|
|
56
|
+
<span className="font-mono">
|
|
57
|
+
{chapter.wordCount.toLocaleString()} {t('words')}
|
|
58
|
+
</span>
|
|
59
|
+
<span className="w-1 h-1 rounded-full bg-muted/30" />
|
|
60
|
+
<span className="font-mono text-muted/70">
|
|
61
|
+
{chapter.readingMinutes} {t('reading_time')}
|
|
62
|
+
</span>
|
|
63
|
+
{/* ImmersiveToggleButton hides itself when enabled — no outer wrap
|
|
64
|
+
needed beyond the layout positioning. */}
|
|
65
|
+
<span className="ml-auto">
|
|
66
|
+
<ImmersiveToggleButton />
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<h1 className="text-3xl md:text-4xl font-serif font-bold text-heading leading-tight mb-4">
|
|
71
|
+
{chapter.title}
|
|
72
|
+
</h1>
|
|
73
|
+
|
|
74
|
+
{book.showChapterExcerpt && chapter.excerpt && (
|
|
75
|
+
<p className="text-lg text-muted font-serif italic leading-relaxed">
|
|
76
|
+
{chapter.excerpt}
|
|
77
|
+
</p>
|
|
78
|
+
)}
|
|
79
|
+
</header>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const prevNext = (
|
|
83
|
+
<div className="mt-16 pt-8 border-t border-muted/10">
|
|
84
|
+
<PrevNextNav prev={prev} next={next} size="lg" />
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (enabled) {
|
|
89
|
+
return (
|
|
90
|
+
<ImmersiveReader
|
|
91
|
+
rootHref={getBookUrl(book.slug)}
|
|
92
|
+
rootTitle={book.title}
|
|
93
|
+
currentTitle={chapter.title}
|
|
94
|
+
sidebar={
|
|
95
|
+
<BookSidebar
|
|
96
|
+
mode="fill"
|
|
97
|
+
bookSlug={book.slug}
|
|
98
|
+
bookTitle={book.title}
|
|
99
|
+
toc={book.toc}
|
|
100
|
+
chapters={book.chapters}
|
|
101
|
+
currentChapter={chapter.slug}
|
|
102
|
+
headings={chapter.headings}
|
|
103
|
+
/>
|
|
104
|
+
}
|
|
105
|
+
>
|
|
106
|
+
{chapterHeader}
|
|
107
|
+
{children}
|
|
108
|
+
{prevNext}
|
|
109
|
+
</ImmersiveReader>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="layout-container">
|
|
115
|
+
<ReadingProgressBar />
|
|
116
|
+
<div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start">
|
|
117
|
+
<BookSidebar
|
|
118
|
+
bookSlug={book.slug}
|
|
119
|
+
bookTitle={book.title}
|
|
120
|
+
toc={book.toc}
|
|
121
|
+
chapters={book.chapters}
|
|
122
|
+
currentChapter={chapter.slug}
|
|
123
|
+
headings={chapter.headings}
|
|
124
|
+
/>
|
|
125
|
+
|
|
126
|
+
<article className="min-w-0 w-full max-w-3xl mx-auto overflow-x-hidden">
|
|
127
|
+
<div className="lg:hidden mb-8">
|
|
128
|
+
<BookMobileNav
|
|
129
|
+
bookSlug={book.slug}
|
|
130
|
+
bookTitle={book.title}
|
|
131
|
+
toc={book.toc}
|
|
132
|
+
chapters={book.chapters}
|
|
133
|
+
currentChapter={chapter.slug}
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{chapterHeader}
|
|
138
|
+
{children}
|
|
139
|
+
{comments && <Comments slug={comments.slug} postUrl={comments.postUrl} />}
|
|
140
|
+
{prevNext}
|
|
141
|
+
</article>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
Binary file
|
|
@@ -2,18 +2,103 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import CodeBlock from "./CodeBlock";
|
|
4
4
|
|
|
5
|
+
async function renderCodeBlock(element: Awaited<ReturnType<typeof CodeBlock>>): Promise<string> {
|
|
6
|
+
return renderToStaticMarkup(element);
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
describe("CodeBlock", () => {
|
|
6
|
-
test("keeps code scrolling inside its own container", () => {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
);
|
|
10
|
+
test("keeps code scrolling inside its own container", async () => {
|
|
11
|
+
const element = await CodeBlock({
|
|
12
|
+
language: "typescript",
|
|
13
|
+
children: "const veryLongLine = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';",
|
|
14
|
+
});
|
|
15
|
+
const html = await renderCodeBlock(element);
|
|
12
16
|
|
|
13
17
|
expect(html).toContain("relative my-6 w-full min-w-0 max-w-full");
|
|
14
18
|
expect(html).toContain("overflow-x-auto");
|
|
15
19
|
expect(html).toContain("overflow-y-hidden");
|
|
16
|
-
expect(html).toContain("
|
|
17
|
-
expect(html).toContain("
|
|
20
|
+
expect(html).toContain("cb-root");
|
|
21
|
+
expect(html).toContain('class="shiki');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("renders title bar when title prop is set", async () => {
|
|
25
|
+
const element = await CodeBlock({
|
|
26
|
+
language: "ts",
|
|
27
|
+
title: "src/app.ts",
|
|
28
|
+
children: "export const x = 1;",
|
|
29
|
+
});
|
|
30
|
+
const html = await renderCodeBlock(element);
|
|
31
|
+
|
|
32
|
+
expect(html).toContain("cb-title");
|
|
33
|
+
expect(html).toContain("src/app.ts");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("flags <pre> with data-line-numbers when showLineNumbers is true", async () => {
|
|
37
|
+
const element = await CodeBlock({
|
|
38
|
+
language: "js",
|
|
39
|
+
showLineNumbers: true,
|
|
40
|
+
children: "const x = 1;\nconst y = 2;",
|
|
41
|
+
});
|
|
42
|
+
const html = await renderCodeBlock(element);
|
|
43
|
+
|
|
44
|
+
expect(html).toContain('data-line-numbers="true"');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("marks highlighted lines from highlightLines prop", async () => {
|
|
48
|
+
const element = await CodeBlock({
|
|
49
|
+
language: "ts",
|
|
50
|
+
highlightLines: [2, 4],
|
|
51
|
+
children: "const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;",
|
|
52
|
+
});
|
|
53
|
+
const html = await renderCodeBlock(element);
|
|
54
|
+
|
|
55
|
+
expect(html).toContain('data-highlighted-line="2"');
|
|
56
|
+
expect(html).toContain('data-highlighted-line="4"');
|
|
57
|
+
expect(html).not.toContain('data-highlighted-line="1"');
|
|
58
|
+
expect(html).not.toContain('data-highlighted-line="3"');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("applies diff add/remove classes for +/- lines in diff fences", async () => {
|
|
62
|
+
const element = await CodeBlock({
|
|
63
|
+
language: "diff",
|
|
64
|
+
children: "-removed\n+added\n unchanged",
|
|
65
|
+
});
|
|
66
|
+
const html = await renderCodeBlock(element);
|
|
67
|
+
|
|
68
|
+
expect(html).toContain("diff add");
|
|
69
|
+
expect(html).toContain("diff remove");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("renders unknown languages as plaintext + emits a warn (warn-and-degrade)", async () => {
|
|
73
|
+
// Production deploys can't fail on a single unknown fence — render as
|
|
74
|
+
// plaintext and emit a build-time warn instead. CLAUDE.md's strict-build
|
|
75
|
+
// principle still applies for frontmatter/slugs/redirects, but not here.
|
|
76
|
+
const element = await CodeBlock({ language: "totally-made-up", children: "x" });
|
|
77
|
+
const html = await renderCodeBlock(element);
|
|
78
|
+
expect(html).toContain('class="shiki');
|
|
79
|
+
expect(html).toContain("totally-made-up");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("renders plaintext when explicitly requested via `plaintext`/`text` alias", async () => {
|
|
83
|
+
const element = await CodeBlock({
|
|
84
|
+
language: "plaintext",
|
|
85
|
+
children: "no highlighting wanted here",
|
|
86
|
+
});
|
|
87
|
+
const html = await renderCodeBlock(element);
|
|
88
|
+
|
|
89
|
+
expect(html).toContain('class="shiki');
|
|
90
|
+
expect(html).toContain("no highlighting wanted here");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("emits no client highlighter script tags", async () => {
|
|
94
|
+
const element = await CodeBlock({
|
|
95
|
+
language: "javascript",
|
|
96
|
+
children: "const x = 1;",
|
|
97
|
+
});
|
|
98
|
+
const html = await renderCodeBlock(element);
|
|
99
|
+
|
|
100
|
+
expect(html).not.toContain("<script");
|
|
101
|
+
expect(html).not.toContain("react-syntax-highlighter");
|
|
102
|
+
expect(html).not.toContain("token keyword");
|
|
18
103
|
});
|
|
19
104
|
});
|