@hutusi/amytis 1.14.0 → 1.16.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 (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +90 -219
  5. package/README.md +33 -1
  6. package/README.zh.md +33 -1
  7. package/TODO.md +10 -0
  8. package/bun.lock +205 -539
  9. package/content/books/sample-book/index.mdx +3 -0
  10. package/content/posts/code-block-features-showcase.mdx +223 -0
  11. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  12. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  13. package/content/series/rst-legacy/getting-started.rst +24 -0
  14. package/content/series/rst-legacy/index.rst +9 -0
  15. package/content/series/rst-readme/README.rst +9 -0
  16. package/content/series/rst-readme/readme-index-post.rst +10 -0
  17. package/content/series/rst-toctree/first-post.rst +6 -0
  18. package/content/series/rst-toctree/index.rst +10 -0
  19. package/content/series/rst-toctree/second-post.rst +6 -0
  20. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/index.rst +12 -0
  22. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  23. package/docs/ALERTS.md +112 -0
  24. package/docs/ARCHITECTURE.md +239 -8
  25. package/docs/CODE-BLOCKS.md +238 -0
  26. package/docs/CONTRIBUTING.md +36 -0
  27. package/docs/guides/README.md +11 -0
  28. package/docs/guides/importing-vuepress-books.md +178 -0
  29. package/eslint.config.mjs +20 -6
  30. package/next.config.ts +2 -2
  31. package/package.json +52 -24
  32. package/packages/create-amytis/package.json +1 -1
  33. package/packages/create-amytis/src/index.test.ts +43 -1
  34. package/packages/create-amytis/src/index.ts +64 -8
  35. package/public/next-image-export-optimizer-hashes.json +14 -73
  36. package/scripts/build-pagefind.ts +172 -0
  37. package/scripts/copy-assets.ts +246 -56
  38. package/scripts/generate-code-group-icons.ts +79 -0
  39. package/scripts/generate-knowledge-graph.ts +2 -1
  40. package/scripts/render-rst.py +923 -0
  41. package/scripts/run-with-rst-python.ts +42 -0
  42. package/scripts/sync-vuepress-book.ts +499 -0
  43. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  44. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  45. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  46. package/src/app/books/[slug]/page.tsx +67 -32
  47. package/src/app/globals.css +639 -94
  48. package/src/app/page.tsx +1 -1
  49. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  50. package/src/app/series/[slug]/page.tsx +11 -13
  51. package/src/app/series/page.tsx +3 -3
  52. package/src/app/sitemap.ts +3 -3
  53. package/src/components/ArticleCopyCleaner.tsx +64 -0
  54. package/src/components/AuthorCard.tsx +25 -16
  55. package/src/components/BookMobileNav.tsx +44 -50
  56. package/src/components/BookSidebar.tsx +0 -0
  57. package/src/components/CodeBlock.test.tsx +93 -8
  58. package/src/components/CodeBlock.tsx +39 -101
  59. package/src/components/CodeBlockToolbar.tsx +88 -0
  60. package/src/components/CodeGroup.tsx +81 -0
  61. package/src/components/CoverImage.tsx +6 -2
  62. package/src/components/ExternalLinkIcon.tsx +15 -0
  63. package/src/components/FeaturedStoriesSection.tsx +3 -3
  64. package/src/components/GithubAlert.tsx +97 -0
  65. package/src/components/MarkdownRenderer.test.tsx +30 -4
  66. package/src/components/MarkdownRenderer.tsx +148 -24
  67. package/src/components/Mermaid.tsx +32 -1
  68. package/src/components/PostList.tsx +1 -1
  69. package/src/components/PostNavigation.tsx +13 -2
  70. package/src/components/PostSidebar.tsx +13 -2
  71. package/src/components/RstRenderer.test.tsx +93 -0
  72. package/src/components/RstRenderer.tsx +157 -0
  73. package/src/components/Search.tsx +18 -4
  74. package/src/components/SeriesCatalog.tsx +1 -1
  75. package/src/components/ShareBar.tsx +5 -0
  76. package/src/components/TocPanel.tsx +10 -2
  77. package/src/i18n/translations.ts +2 -0
  78. package/src/layouts/BookLayout.tsx +35 -4
  79. package/src/layouts/PostLayout.tsx +10 -2
  80. package/src/layouts/SimpleLayout.tsx +10 -3
  81. package/src/lib/code-group-icons.test.ts +78 -0
  82. package/src/lib/code-group-icons.ts +148 -0
  83. package/src/lib/image-utils.test.ts +19 -0
  84. package/src/lib/image-utils.ts +11 -0
  85. package/src/lib/markdown.test.ts +195 -14
  86. package/src/lib/markdown.ts +928 -254
  87. package/src/lib/normalize-vuepress-math.ts +118 -0
  88. package/src/lib/rehype-fence-meta.ts +22 -0
  89. package/src/lib/rehype-image-metadata.ts +2 -2
  90. package/src/lib/remark-book-chapter-links.ts +106 -0
  91. package/src/lib/remark-code-group.ts +54 -0
  92. package/src/lib/remark-github-alerts.test.ts +83 -0
  93. package/src/lib/remark-github-alerts.ts +65 -0
  94. package/src/lib/remark-vuepress-containers.ts +130 -0
  95. package/src/lib/rst-renderer.test.ts +355 -0
  96. package/src/lib/rst-renderer.ts +629 -0
  97. package/src/lib/rst.test.ts +350 -0
  98. package/src/lib/rst.ts +674 -0
  99. package/src/lib/series-redirects.ts +42 -0
  100. package/src/lib/shiki-rst.ts +185 -0
  101. package/src/lib/shiki.test.ts +153 -0
  102. package/src/lib/shiki.ts +292 -0
  103. package/src/lib/urls.ts +57 -0
  104. package/src/test-utils/render.ts +23 -0
  105. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  106. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  107. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  108. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  109. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  110. package/tests/helpers/env.ts +19 -0
  111. package/tests/integration/book-chapter-links.test.ts +107 -0
  112. package/tests/integration/books-nested-toc.test.ts +176 -0
  113. package/tests/integration/books.test.ts +3 -2
  114. package/tests/integration/code-block-features.test.ts +188 -0
  115. package/tests/integration/code-group.test.ts +183 -0
  116. package/tests/integration/code-notation.test.ts +97 -0
  117. package/tests/integration/feed-utils.test.ts +13 -0
  118. package/tests/integration/github-alerts.test.ts +82 -0
  119. package/tests/integration/markdown-external-links.test.ts +103 -0
  120. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  121. package/tests/integration/reading-time-headings.test.ts +12 -14
  122. package/tests/integration/series-draft.test.ts +12 -5
  123. package/tests/integration/series.test.ts +93 -0
  124. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  125. package/tests/integration/vuepress-containers.test.ts +107 -0
  126. package/tests/tooling/build-pagefind.test.ts +66 -0
  127. package/tests/tooling/new-post.test.ts +1 -1
  128. package/tests/unit/static-params.test.ts +166 -13
package/src/app/page.tsx CHANGED
@@ -109,7 +109,7 @@ export default function Home() {
109
109
  excerpt: p.excerpt,
110
110
  date: p.date,
111
111
  category: p.category,
112
- readingTime: p.readingTime,
112
+ readingMinutes: p.readingMinutes,
113
113
  coverImage: p.coverImage,
114
114
  series: p.series,
115
115
  pinned: p.pinned,
@@ -7,22 +7,74 @@ import { siteConfig } from '../../../../../../site.config';
7
7
  import CoverImage from '@/components/CoverImage';
8
8
  import Link from 'next/link';
9
9
  import { t, resolveLocale, tWith } from '@/lib/i18n';
10
+ import { getSeriesListUrl } from '@/lib/urls';
11
+ import RedirectPage from '@/components/RedirectPage';
12
+ import { findSeriesByRedirectFrom, safeDecodeParam } from '@/lib/series-redirects';
10
13
 
11
14
  const PAGE_SIZE = siteConfig.pagination.series;
12
15
 
13
16
  export async function generateStaticParams() {
14
17
  const allSeries = getAllSeries();
18
+ const seriesBasePath = getSeriesListUrl();
19
+ const seen = new Set<string>();
20
+ const reservedSlugs = new Set(Object.keys(allSeries));
21
+ const claimedAliases = new Map<string, string>();
15
22
  const params: { slug: string; page: string }[] = [];
16
-
23
+ const pushParam = (slug: string, page: string) => {
24
+ const key = `${slug}:${page}`;
25
+ if (seen.has(key)) return;
26
+ seen.add(key);
27
+ params.push({ slug, page });
28
+ };
29
+
17
30
  Object.keys(allSeries).forEach(slug => {
18
31
  const posts = allSeries[slug];
19
32
  const totalPages = Math.ceil(posts.length / PAGE_SIZE);
20
33
  if (totalPages > 1) {
21
- for (let i = 2; i <= totalPages; i++) {
22
- params.push({ slug, page: i.toString() });
23
- }
34
+ for (let i = 2; i <= totalPages; i++) {
35
+ pushParam(slug, i.toString());
36
+ }
37
+ }
38
+
39
+ const data = getSeriesData(slug);
40
+ for (const from of data?.redirectFrom ?? []) {
41
+ const segments = from.split('/').filter(Boolean);
42
+ const expectedBase = seriesBasePath.replace(/^\/+|\/+$/g, '');
43
+ if (segments.length !== 2 || segments[0] !== expectedBase) continue;
44
+ const aliasSlug = segments[1];
45
+ if (aliasSlug === slug || totalPages <= 1) continue;
46
+ const claimedBy = claimedAliases.get(aliasSlug);
47
+ if (claimedBy && claimedBy !== slug) {
48
+ throw new Error(
49
+ `[amytis] series redirectFrom alias "${from}" is claimed by both "${claimedBy}" and "${slug}".`
50
+ );
51
+ }
52
+ if (!claimedBy && reservedSlugs.has(aliasSlug)) {
53
+ throw new Error(
54
+ `[amytis] series redirectFrom alias "${from}" for "${slug}" conflicts with an existing series slug.`
55
+ );
56
+ }
57
+ claimedAliases.set(aliasSlug, slug);
58
+ reservedSlugs.add(aliasSlug);
59
+ for (let i = 2; i <= totalPages; i++) {
60
+ pushParam(aliasSlug, i.toString());
61
+ }
24
62
  }
25
63
  });
64
+
65
+ if (process.env.NODE_ENV !== 'production') {
66
+ const encodedParams = params
67
+ .filter(param => encodeURIComponent(param.slug) !== param.slug)
68
+ .map(param => ({ ...param, slug: encodeURIComponent(param.slug) }))
69
+ .filter(param => {
70
+ const key = `${param.slug}:${param.page}`;
71
+ if (seen.has(key)) return false;
72
+ seen.add(key);
73
+ return true;
74
+ });
75
+ params.push(...encodedParams);
76
+ }
77
+
26
78
  if (params.length === 0) return [{ slug: '_', page: '2' }];
27
79
  return params;
28
80
  }
@@ -31,7 +83,17 @@ export const dynamicParams = false;
31
83
 
32
84
  export async function generateMetadata({ params }: { params: Promise<{ slug: string; page: string }> }): Promise<Metadata> {
33
85
  const { slug: rawSlug, page } = await params;
34
- const slug = decodeURIComponent(rawSlug);
86
+ const slug = safeDecodeParam(rawSlug);
87
+ const currentPath = `${getSeriesListUrl()}/${slug}`;
88
+ const redirect = findSeriesByRedirectFrom(currentPath);
89
+ if (redirect) {
90
+ const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
91
+ return {
92
+ title: redirect.data.title,
93
+ alternates: { canonical: `${siteUrl}${getSeriesListUrl()}/${redirect.slug}/page/${page}` },
94
+ };
95
+ }
96
+
35
97
  const seriesData = getSeriesData(slug);
36
98
  const title = seriesData?.title || slug;
37
99
  const allPosts = seriesData?.type === 'collection' ? getCollectionPosts(slug) : getSeriesPosts(slug);
@@ -43,8 +105,14 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
43
105
 
44
106
  export default async function SeriesPage({ params }: { params: Promise<{ slug: string; page: string }> }) {
45
107
  const { slug: rawSlug, page: pageStr } = await params;
46
- const slug = decodeURIComponent(rawSlug);
108
+ const slug = safeDecodeParam(rawSlug);
47
109
  const page = parseInt(pageStr);
110
+ const currentPath = `${getSeriesListUrl()}/${slug}`;
111
+ const redirect = findSeriesByRedirectFrom(currentPath);
112
+ if (redirect) {
113
+ return <RedirectPage to={`${getSeriesListUrl()}/${redirect.slug}/page/${page}`} />;
114
+ }
115
+
48
116
  const seriesData = getSeriesData(slug);
49
117
  const isCollection = seriesData?.type === 'collection';
50
118
  const allPosts = isCollection ? getCollectionPosts(slug) : getSeriesPosts(slug);
@@ -9,20 +9,10 @@ import Link from 'next/link';
9
9
  import { t, resolveLocale } from '@/lib/i18n';
10
10
  import { getPostUrl, getPostUrlInCollection } from '@/lib/urls';
11
11
  import RedirectPage from '@/components/RedirectPage';
12
+ import { findSeriesByRedirectFrom, safeDecodeParam } from '@/lib/series-redirects';
12
13
 
13
14
  const PAGE_SIZE = siteConfig.pagination.series;
14
15
 
15
- /** Returns the series whose index.mdx lists `path` in its redirectFrom array, or null. */
16
- function findSeriesByRedirectFrom(path: string) {
17
- for (const seriesSlug of Object.keys(getAllSeries())) {
18
- const data = getSeriesData(seriesSlug);
19
- if (data?.redirectFrom?.includes(path)) {
20
- return { slug: seriesSlug, data };
21
- }
22
- }
23
- return null;
24
- }
25
-
26
16
  export async function generateStaticParams() {
27
17
  const allSeries = getAllSeries();
28
18
  const slugs = new Set(Object.keys(allSeries));
@@ -38,6 +28,14 @@ export async function generateStaticParams() {
38
28
  }
39
29
  }
40
30
 
31
+ // Work around Next dev static-param checks for percent-encoded Unicode paths
32
+ // under `output: "export"` — dev server may receive encoded forms of Unicode slugs.
33
+ if (process.env.NODE_ENV !== 'production') {
34
+ for (const slug of [...slugs]) {
35
+ slugs.add(encodeURIComponent(slug));
36
+ }
37
+ }
38
+
41
39
  if (slugs.size === 0) return [{ slug: '_' }];
42
40
  return Array.from(slugs).map((slug) => ({ slug }));
43
41
  }
@@ -46,7 +44,7 @@ export const dynamicParams = false;
46
44
 
47
45
  export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
48
46
  const { slug: rawSlug } = await params;
49
- const slug = decodeURIComponent(rawSlug);
47
+ const slug = safeDecodeParam(rawSlug);
50
48
  const currentPath = `/series/${slug}`;
51
49
 
52
50
  const redirect = findSeriesByRedirectFrom(currentPath);
@@ -98,7 +96,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
98
96
 
99
97
  export default async function SeriesPage({ params }: { params: Promise<{ slug: string }> }) {
100
98
  const { slug: rawSlug } = await params;
101
- const slug = decodeURIComponent(rawSlug);
99
+ const slug = safeDecodeParam(rawSlug);
102
100
  const currentPath = `/series/${slug}`;
103
101
 
104
102
  const redirect = findSeriesByRedirectFrom(currentPath);
@@ -1,4 +1,4 @@
1
- import { getAllSeries, getSeriesData, resolveSeriesAuthors } from '@/lib/markdown';
1
+ import { getAllSeries, getSeriesData, getSeriesLatestPostDate, resolveSeriesAuthors } from '@/lib/markdown';
2
2
  import Link from 'next/link';
3
3
  import { siteConfig } from '../../../site.config';
4
4
  import { Metadata } from 'next';
@@ -20,8 +20,8 @@ export default function SeriesIndexPage() {
20
20
 
21
21
  // Sort by most recent post date (active series first)
22
22
  const seriesSlugs = Object.keys(allSeries).sort((a, b) => {
23
- const latestA = allSeries[a][0]?.date || '';
24
- const latestB = allSeries[b][0]?.date || '';
23
+ const latestA = getSeriesLatestPostDate(a);
24
+ const latestB = getSeriesLatestPostDate(b);
25
25
  return latestB.localeCompare(latestA);
26
26
  });
27
27
 
@@ -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}/books/${book.slug}`,
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}/books/${book.slug}/${ch.id}`,
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
+ }
@@ -3,6 +3,7 @@ import ExportedImage from 'next-image-export-optimizer';
3
3
  import { getAuthorSlug } from '@/lib/markdown';
4
4
  import { siteConfig } from '../../site.config';
5
5
  import { t } from '@/lib/i18n';
6
+ import { shouldBypassImageOptimization } from '@/lib/image-utils';
6
7
 
7
8
  const isDev = process.env.NODE_ENV === 'development';
8
9
  const isExternal = (src: string) => src.startsWith('http') || src.startsWith('//');
@@ -16,6 +17,9 @@ export default function AuthorCard({ authors }: { authors: string[] }) {
16
17
  const slug = getAuthorSlug(author);
17
18
  const profile = siteConfig.authors?.[author];
18
19
  const hasSocial = profile?.social && profile.social.length > 0;
20
+ const avatarBypassOptimization = Boolean(
21
+ profile?.avatar && (isDev || isExternal(profile.avatar) || shouldBypassImageOptimization(profile.avatar))
22
+ );
19
23
 
20
24
  return (
21
25
  <div
@@ -31,7 +35,8 @@ export default function AuthorCard({ authors }: { authors: string[] }) {
31
35
  width={56}
32
36
  height={56}
33
37
  className="w-14 h-14 rounded-full object-cover flex-shrink-0 ring-2 ring-muted/20"
34
- unoptimized={isDev || isExternal(profile.avatar)}
38
+ unoptimized={avatarBypassOptimization}
39
+ placeholder={avatarBypassOptimization ? 'empty' : 'blur'}
35
40
  />
36
41
  ) : (
37
42
  <div className="w-14 h-14 rounded-full bg-accent/10 flex items-center justify-center flex-shrink-0 text-accent font-serif font-bold text-2xl select-none">
@@ -60,21 +65,25 @@ export default function AuthorCard({ authors }: { authors: string[] }) {
60
65
  {/* Right — social images (e.g. QR codes) */}
61
66
  {hasSocial && (
62
67
  <div className="flex justify-center gap-5 flex-shrink-0 border-t border-muted/15 pt-4 sm:border-t-0 sm:border-l sm:pt-0 sm:pl-6 sm:justify-start">
63
- {profile.social!.map((item, index) => (
64
- <figure key={index} className="flex flex-col items-center gap-1.5">
65
- <ExportedImage
66
- src={item.image}
67
- alt={item.description}
68
- width={72}
69
- height={72}
70
- className="w-[72px] h-[72px] object-contain rounded-lg bg-white p-0.5"
71
- unoptimized={isDev || isExternal(item.image)}
72
- />
73
- <figcaption className="text-[10px] font-sans text-muted text-center leading-tight max-w-[72px]">
74
- {item.description}
75
- </figcaption>
76
- </figure>
77
- ))}
68
+ {profile.social!.map((item, index) => {
69
+ const socialImageBypassOptimization = isDev || isExternal(item.image) || shouldBypassImageOptimization(item.image);
70
+ return (
71
+ <figure key={index} className="flex flex-col items-center gap-1.5">
72
+ <ExportedImage
73
+ src={item.image}
74
+ alt={item.description}
75
+ width={72}
76
+ height={72}
77
+ className="w-[72px] h-[72px] object-contain rounded-lg bg-white p-0.5"
78
+ unoptimized={socialImageBypassOptimization}
79
+ placeholder={socialImageBypassOptimization ? 'empty' : 'blur'}
80
+ />
81
+ <figcaption className="text-[10px] font-sans text-muted text-center leading-tight max-w-[72px]">
82
+ {item.description}
83
+ </figcaption>
84
+ </figure>
85
+ );
86
+ })}
78
87
  </div>
79
88
  )}
80
89
  </div>
@@ -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
- <ol className="space-y-1">
89
- {item.chapters.map(ch => {
90
- const isCurrent = ch.id === currentChapter;
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
  )}
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 html = renderToStaticMarkup(
8
- <CodeBlock language="typescript">
9
- {"const veryLongLine = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';"}
10
- </CodeBlock>
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("max-width:100%");
17
- expect(html).toContain("box-sizing:border-box");
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
  });