@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/site.config.example.ts
CHANGED
|
@@ -140,11 +140,11 @@ export const siteConfig = {
|
|
|
140
140
|
homepage: {
|
|
141
141
|
sections: [
|
|
142
142
|
{ id: 'hero', enabled: true, weight: 1 },
|
|
143
|
-
{ id: 'featured-posts', enabled: true, weight: 2, maxItems: 4 },
|
|
143
|
+
{ id: 'featured-posts', enabled: true, weight: 2, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
144
144
|
{ id: 'latest-posts', enabled: true, weight: 3, maxItems: 3 },
|
|
145
145
|
{ id: 'recent-flows', enabled: false, weight: 4, maxItems: 8 },
|
|
146
|
-
{ id: 'featured-series', enabled: true, weight: 5, maxItems: 6 },
|
|
147
|
-
{ id: 'featured-books', enabled: false, weight: 6, maxItems: 4 },
|
|
146
|
+
{ id: 'featured-series', enabled: true, weight: 5, maxItems: 6, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
147
|
+
{ id: 'featured-books', enabled: false, weight: 6, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
148
148
|
],
|
|
149
149
|
},
|
|
150
150
|
|
package/site.config.ts
CHANGED
|
@@ -139,11 +139,11 @@ export const siteConfig = {
|
|
|
139
139
|
homepage: {
|
|
140
140
|
sections: [
|
|
141
141
|
{ id: 'hero', enabled: true, weight: 1 },
|
|
142
|
-
{ id: 'featured-posts', enabled: true, weight: 2, maxItems: 4 },
|
|
142
|
+
{ id: 'featured-posts', enabled: true, weight: 2, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
143
143
|
{ id: 'latest-posts', enabled: true, weight: 3, maxItems: 4 },
|
|
144
144
|
{ id: 'recent-flows', enabled: true, weight: 4, maxItems: 7 },
|
|
145
|
-
{ id: 'featured-series', enabled: true, weight: 5, maxItems: 6 },
|
|
146
|
-
{ id: 'featured-books', enabled: true, weight: 6, maxItems: 4 },
|
|
145
|
+
{ id: 'featured-series', enabled: true, weight: 5, maxItems: 6, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
146
|
+
{ id: 'featured-books', enabled: true, weight: 6, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
147
147
|
],
|
|
148
148
|
},
|
|
149
149
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Suspense, type ReactNode } from 'react';
|
|
2
|
+
import { ImmersiveReadingProvider } from '@/components/ImmersiveReadingProvider';
|
|
3
|
+
import ImmersiveReadingFlagHandler from '@/components/ImmersiveReadingFlagHandler';
|
|
4
|
+
|
|
5
|
+
// Mounts the immersive-reading state above the series-prefixed post route
|
|
6
|
+
// (`/<series-slug>/<post>` when series.autoPaths is enabled, which is the
|
|
7
|
+
// default). This is what lets immersive mode persist across client-side
|
|
8
|
+
// navigation between sibling posts in the same series — without it, the
|
|
9
|
+
// provider would remount on every post navigation and reader state would
|
|
10
|
+
// reset.
|
|
11
|
+
//
|
|
12
|
+
// Note this layout wraps ALL single-segment routes under `/`, not just series
|
|
13
|
+
// posts (also redirectFrom aliases, custom-path posts, etc.). The provider
|
|
14
|
+
// only activates when the toggle is clicked, and the toggle is gated on
|
|
15
|
+
// `post.series`, so non-series routes pay only the mount cost.
|
|
16
|
+
//
|
|
17
|
+
// The flag handler reads `?immersive=1` from the URL (set by the CTA on the
|
|
18
|
+
// series index page) and enters the reader. It's wrapped in <Suspense> on its
|
|
19
|
+
// own so its `useSearchParams` bailout doesn't drag {children} out of static
|
|
20
|
+
// prerender.
|
|
21
|
+
export default function SlugLayout({ children }: { children: ReactNode }) {
|
|
22
|
+
return (
|
|
23
|
+
<ImmersiveReadingProvider>
|
|
24
|
+
<Suspense fallback={null}>
|
|
25
|
+
<ImmersiveReadingFlagHandler />
|
|
26
|
+
</Suspense>
|
|
27
|
+
{children}
|
|
28
|
+
</ImmersiveReadingProvider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -6,11 +6,31 @@ import BookLayout from '@/layouts/BookLayout';
|
|
|
6
6
|
import { resolveLocale } from '@/lib/i18n';
|
|
7
7
|
import { buildBookChapterJsonLd, serializeJsonLd } from '@/lib/json-ld';
|
|
8
8
|
import { getBookUrl, getBookChapterUrl } from '@/lib/urls';
|
|
9
|
+
import { safeDecodeParam } from '@/lib/series-redirects';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The chapter route is a catch-all (`[...chapter]`) so that nested chapter ids
|
|
13
|
+
* like `maths/linear/introduction` can be served at `/books/<slug>/maths/linear/introduction`
|
|
14
|
+
* — mapping VuePress-style nested folder paths to URLs 1:1. Single-segment legacy
|
|
15
|
+
* ids continue to work since catch-all matches one-or-more segments.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function chapterIdFromParams(rawChapter: string | string[] | undefined): string {
|
|
19
|
+
if (!rawChapter) return '';
|
|
20
|
+
if (Array.isArray(rawChapter)) {
|
|
21
|
+
return rawChapter.map(safeDecodeParam).join('/');
|
|
22
|
+
}
|
|
23
|
+
return safeDecodeParam(rawChapter);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function chapterIdToParamSegments(chapterId: string): string[] {
|
|
27
|
+
return chapterId.split('/').filter(Boolean);
|
|
28
|
+
}
|
|
9
29
|
|
|
10
30
|
export async function generateStaticParams() {
|
|
11
31
|
const books = getAllBooks();
|
|
12
|
-
if (books.length === 0) return [{ slug: '_', chapter: '_' }];
|
|
13
|
-
const params: { slug: string; chapter: string }[] = [];
|
|
32
|
+
if (books.length === 0) return [{ slug: '_', chapter: ['_'] }];
|
|
33
|
+
const params: { slug: string; chapter: string[] }[] = [];
|
|
14
34
|
|
|
15
35
|
for (const book of books) {
|
|
16
36
|
for (const ch of book.chapters) {
|
|
@@ -19,21 +39,23 @@ export async function generateStaticParams() {
|
|
|
19
39
|
// frontmatter) would cause notFound() at render time, which in
|
|
20
40
|
// output:export dev mode surfaces as a confusing "missing param" 500.
|
|
21
41
|
if (getBookChapter(book.slug, ch.id) !== null) {
|
|
22
|
-
params.push({ slug: book.slug, chapter: ch.id });
|
|
42
|
+
params.push({ slug: book.slug, chapter: chapterIdToParamSegments(ch.id) });
|
|
23
43
|
}
|
|
24
44
|
}
|
|
25
45
|
}
|
|
26
46
|
|
|
27
47
|
// Ensure we never return an empty array with output: export
|
|
28
|
-
return params.length > 0 ? params : [{ slug: '_', chapter: '_' }];
|
|
48
|
+
return params.length > 0 ? params : [{ slug: '_', chapter: ['_'] }];
|
|
29
49
|
}
|
|
30
50
|
|
|
31
51
|
export const dynamicParams = false;
|
|
32
52
|
|
|
33
|
-
|
|
53
|
+
type ChapterPageParams = Promise<{ slug: string; chapter: string[] }>;
|
|
54
|
+
|
|
55
|
+
export async function generateMetadata({ params }: { params: ChapterPageParams }): Promise<Metadata> {
|
|
34
56
|
const { slug: rawSlug, chapter: rawChapter } = await params;
|
|
35
|
-
const slug =
|
|
36
|
-
const chapterSlug =
|
|
57
|
+
const slug = safeDecodeParam(rawSlug);
|
|
58
|
+
const chapterSlug = chapterIdFromParams(rawChapter);
|
|
37
59
|
|
|
38
60
|
const book = getBookData(slug);
|
|
39
61
|
const chapter = getBookChapter(slug, chapterSlug);
|
|
@@ -66,10 +88,10 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|
|
66
88
|
};
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
export default async function BookChapterPage({ params }: { params:
|
|
91
|
+
export default async function BookChapterPage({ params }: { params: ChapterPageParams }) {
|
|
70
92
|
const { slug: rawSlug, chapter: rawChapter } = await params;
|
|
71
|
-
const slug =
|
|
72
|
-
const chapterSlug =
|
|
93
|
+
const slug = safeDecodeParam(rawSlug);
|
|
94
|
+
const chapterSlug = chapterIdFromParams(rawChapter);
|
|
73
95
|
|
|
74
96
|
const book = getBookData(slug);
|
|
75
97
|
const chapter = getBookChapter(slug, chapterSlug);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Suspense, type ReactNode } from 'react';
|
|
2
|
+
import { ImmersiveReadingProvider } from '@/components/ImmersiveReadingProvider';
|
|
3
|
+
import ImmersiveReadingFlagHandler from '@/components/ImmersiveReadingFlagHandler';
|
|
4
|
+
|
|
5
|
+
// Mounts the immersive-reading state above the chapter route. This is what
|
|
6
|
+
// lets immersive mode persist across client-side navigation between chapters
|
|
7
|
+
// of the same book (state would otherwise reset on every chapter unmount).
|
|
8
|
+
// State is in-memory only — a hard refresh or navigating to a different book
|
|
9
|
+
// resets it.
|
|
10
|
+
//
|
|
11
|
+
// The flag handler reads `?immersive=1` from the URL (set by the CTA on the
|
|
12
|
+
// book index page) and enters the reader. It's wrapped in <Suspense> on its
|
|
13
|
+
// own so its `useSearchParams` bailout doesn't drag {children} (the chapter
|
|
14
|
+
// page) out of static prerender.
|
|
15
|
+
export default function BookSlugLayout({ children }: { children: ReactNode }) {
|
|
16
|
+
return (
|
|
17
|
+
<ImmersiveReadingProvider>
|
|
18
|
+
<Suspense fallback={null}>
|
|
19
|
+
<ImmersiveReadingFlagHandler />
|
|
20
|
+
</Suspense>
|
|
21
|
+
{children}
|
|
22
|
+
</ImmersiveReadingProvider>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getBookData, getAllBooks, getAuthorSlug } from '@/lib/markdown';
|
|
1
|
+
import { getBookData, getAllBooks, getAuthorSlug, type BookTocSection, type BookChapterRef } from '@/lib/markdown';
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
import { Metadata } from 'next';
|
|
4
4
|
import { siteConfig } from '../../../../site.config';
|
|
@@ -7,7 +7,52 @@ import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
|
7
7
|
import Link from 'next/link';
|
|
8
8
|
import { t, resolveLocale } from '@/lib/i18n';
|
|
9
9
|
import { buildBookJsonLd, serializeJsonLd } from '@/lib/json-ld';
|
|
10
|
-
import { getBookUrl } from '@/lib/urls';
|
|
10
|
+
import { getBookUrl, getBookChapterUrl } from '@/lib/urls';
|
|
11
|
+
import { safeDecodeParam } from '@/lib/series-redirects';
|
|
12
|
+
|
|
13
|
+
// Visual depth limit for nested-section headings. After the first two levels
|
|
14
|
+
// we keep nesting structurally but stop bumping the heading style so deeply
|
|
15
|
+
// nested books don't degrade into tiny text.
|
|
16
|
+
const MAX_HEADING_DEPTH = 2;
|
|
17
|
+
|
|
18
|
+
function chapterRow(ref: BookChapterRef, slug: string, key: string) {
|
|
19
|
+
return (
|
|
20
|
+
<li key={key}>
|
|
21
|
+
<Link
|
|
22
|
+
href={getBookChapterUrl(slug, ref.id)}
|
|
23
|
+
className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
|
|
24
|
+
>
|
|
25
|
+
<svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
26
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
27
|
+
</svg>
|
|
28
|
+
<span className="text-base">{ref.title}</span>
|
|
29
|
+
</Link>
|
|
30
|
+
</li>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderTocSection(section: BookTocSection, slug: string, keyPrefix: string, depth: number): React.ReactNode {
|
|
35
|
+
const headingDepth = Math.min(depth, MAX_HEADING_DEPTH);
|
|
36
|
+
const headingClass =
|
|
37
|
+
headingDepth === 0
|
|
38
|
+
? 'text-lg font-serif font-bold text-heading mb-3'
|
|
39
|
+
: headingDepth === 1
|
|
40
|
+
? 'text-sm font-sans font-bold uppercase tracking-wider text-muted mb-3'
|
|
41
|
+
: 'text-xs font-sans font-semibold uppercase tracking-wider text-muted/80 mb-2';
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div key={keyPrefix} className={depth === 0 ? '' : 'mt-3'}>
|
|
45
|
+
<h3 className={headingClass}>{section.section}</h3>
|
|
46
|
+
<ol className="space-y-2 pl-4 border-l-2 border-muted/10">
|
|
47
|
+
{section.items.map((child, idx) =>
|
|
48
|
+
'section' in child
|
|
49
|
+
? <li key={`${keyPrefix}-${idx}`}>{renderTocSection(child, slug, `${keyPrefix}-${idx}`, depth + 1)}</li>
|
|
50
|
+
: chapterRow(child, slug, `${keyPrefix}-${child.id}`)
|
|
51
|
+
)}
|
|
52
|
+
</ol>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
11
56
|
|
|
12
57
|
export async function generateStaticParams() {
|
|
13
58
|
const books = getAllBooks();
|
|
@@ -19,7 +64,7 @@ export const dynamicParams = false;
|
|
|
19
64
|
|
|
20
65
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
|
21
66
|
const { slug } = await params;
|
|
22
|
-
const book = getBookData(
|
|
67
|
+
const book = getBookData(safeDecodeParam(slug));
|
|
23
68
|
|
|
24
69
|
if (!book) {
|
|
25
70
|
return { title: 'Book Not Found' };
|
|
@@ -36,7 +81,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|
|
36
81
|
title: book.title,
|
|
37
82
|
description: book.excerpt,
|
|
38
83
|
type: 'website',
|
|
39
|
-
url: `${siteConfig.baseUrl}
|
|
84
|
+
url: `${siteConfig.baseUrl}${getBookUrl(book.slug)}`,
|
|
40
85
|
siteName: resolveLocale(siteConfig.title),
|
|
41
86
|
images: [{ url: ogImage, width: 1200, height: 630, alt: book.title }],
|
|
42
87
|
},
|
|
@@ -51,7 +96,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|
|
51
96
|
|
|
52
97
|
export default async function BookLandingPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
53
98
|
const { slug: rawSlug } = await params;
|
|
54
|
-
const slug =
|
|
99
|
+
const slug = safeDecodeParam(rawSlug);
|
|
55
100
|
const book = getBookData(slug);
|
|
56
101
|
|
|
57
102
|
if (!book || (process.env.NODE_ENV === 'production' && book.draft)) {
|
|
@@ -114,11 +159,11 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
|
|
|
114
159
|
</p>
|
|
115
160
|
)}
|
|
116
161
|
|
|
117
|
-
{/* Start Reading
|
|
162
|
+
{/* Start Reading CTAs */}
|
|
118
163
|
{firstChapter && (
|
|
119
|
-
<div className="mt-8">
|
|
164
|
+
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
120
165
|
<Link
|
|
121
|
-
href={
|
|
166
|
+
href={getBookChapterUrl(book.slug, firstChapter.id)}
|
|
122
167
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-white rounded-xl font-sans font-medium text-sm hover:bg-accent/90 no-underline transition-colors shadow-lg shadow-accent/20"
|
|
123
168
|
>
|
|
124
169
|
{t('start_reading')}
|
|
@@ -126,6 +171,22 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
|
|
|
126
171
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
127
172
|
</svg>
|
|
128
173
|
</Link>
|
|
174
|
+
{/* Secondary CTA — opens the first chapter in immersive mode.
|
|
175
|
+
The `?immersive=1` query param is read by ImmersiveReadingProvider
|
|
176
|
+
on mount, which calls enter() then strips the flag from the URL
|
|
177
|
+
so back-navigation doesn't re-trigger it. */}
|
|
178
|
+
<Link
|
|
179
|
+
href={`${getBookChapterUrl(book.slug, firstChapter.id)}?immersive=1`}
|
|
180
|
+
className="inline-flex items-center gap-2 px-5 py-3 border border-muted/30 text-foreground/80 hover:text-accent hover:border-accent/50 rounded-xl font-sans font-medium text-sm no-underline transition-colors"
|
|
181
|
+
>
|
|
182
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
183
|
+
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
|
184
|
+
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
|
185
|
+
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
|
186
|
+
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
|
187
|
+
</svg>
|
|
188
|
+
{t('immersive_reading')}
|
|
189
|
+
</Link>
|
|
129
190
|
</div>
|
|
130
191
|
)}
|
|
131
192
|
</div>
|
|
@@ -143,36 +204,26 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
|
|
|
143
204
|
{item.part}
|
|
144
205
|
</h3>
|
|
145
206
|
<ol className="space-y-2 pl-4 border-l-2 border-muted/10">
|
|
146
|
-
{item.chapters.map(ch => (
|
|
147
|
-
<li key={ch.id}>
|
|
148
|
-
<Link
|
|
149
|
-
href={`/books/${book.slug}/${ch.id}`}
|
|
150
|
-
className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
|
|
151
|
-
>
|
|
152
|
-
<svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
153
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
154
|
-
</svg>
|
|
155
|
-
<span className="text-base">{ch.title}</span>
|
|
156
|
-
</Link>
|
|
157
|
-
</li>
|
|
158
|
-
))}
|
|
207
|
+
{item.chapters.map(ch => chapterRow(ch, book.slug, ch.id))}
|
|
159
208
|
</ol>
|
|
160
209
|
</div>
|
|
161
210
|
);
|
|
162
|
-
} else {
|
|
163
|
-
return (
|
|
164
|
-
<Link
|
|
165
|
-
key={item.id}
|
|
166
|
-
href={`/books/${book.slug}/${item.id}`}
|
|
167
|
-
className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
|
|
168
|
-
>
|
|
169
|
-
<svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
170
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
171
|
-
</svg>
|
|
172
|
-
<span className="text-base">{item.title}</span>
|
|
173
|
-
</Link>
|
|
174
|
-
);
|
|
175
211
|
}
|
|
212
|
+
if ('section' in item) {
|
|
213
|
+
return renderTocSection(item, book.slug, `section-${idx}`, 0);
|
|
214
|
+
}
|
|
215
|
+
return (
|
|
216
|
+
<Link
|
|
217
|
+
key={item.id}
|
|
218
|
+
href={getBookChapterUrl(book.slug, item.id)}
|
|
219
|
+
className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
|
|
220
|
+
>
|
|
221
|
+
<svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
222
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
223
|
+
</svg>
|
|
224
|
+
<span className="text-base">{item.title}</span>
|
|
225
|
+
</Link>
|
|
226
|
+
);
|
|
176
227
|
})}
|
|
177
228
|
</div>
|
|
178
229
|
</section>
|