@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
@@ -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
- export async function generateMetadata({ params }: { params: Promise<{ slug: string; chapter: string }> }): Promise<Metadata> {
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 = decodeURIComponent(rawSlug);
36
- const chapterSlug = decodeURIComponent(rawChapter);
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: Promise<{ slug: string; chapter: string }> }) {
91
+ export default async function BookChapterPage({ params }: { params: ChapterPageParams }) {
70
92
  const { slug: rawSlug, chapter: rawChapter } = await params;
71
- const slug = decodeURIComponent(rawSlug);
72
- const chapterSlug = decodeURIComponent(rawChapter);
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(decodeURIComponent(slug));
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}/books/${slug}`,
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 = decodeURIComponent(rawSlug);
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 CTA */}
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={`/books/${book.slug}/${firstChapter.id}`}
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>