@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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +90 -219
- package/README.md +33 -1
- package/README.zh.md +33 -1
- package/TODO.md +10 -0
- package/bun.lock +205 -539
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
- package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
- package/content/series/rst-legacy/getting-started.rst +24 -0
- package/content/series/rst-legacy/index.rst +9 -0
- package/content/series/rst-readme/README.rst +9 -0
- package/content/series/rst-readme/readme-index-post.rst +10 -0
- package/content/series/rst-toctree/first-post.rst +6 -0
- package/content/series/rst-toctree/index.rst +10 -0
- package/content/series/rst-toctree/second-post.rst +6 -0
- package/content/series/rst-toctree-precedence/first-post.rst +6 -0
- package/content/series/rst-toctree-precedence/index.rst +12 -0
- package/content/series/rst-toctree-precedence/second-post.rst +6 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +239 -8
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +36 -0
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +178 -0
- package/eslint.config.mjs +20 -6
- package/next.config.ts +2 -2
- package/package.json +52 -24
- package/packages/create-amytis/package.json +1 -1
- package/packages/create-amytis/src/index.test.ts +43 -1
- package/packages/create-amytis/src/index.ts +64 -8
- package/public/next-image-export-optimizer-hashes.json +14 -73
- package/scripts/build-pagefind.ts +172 -0
- package/scripts/copy-assets.ts +246 -56
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/generate-knowledge-graph.ts +2 -1
- package/scripts/render-rst.py +923 -0
- package/scripts/run-with-rst-python.ts +42 -0
- package/scripts/sync-vuepress-book.ts +499 -0
- package/src/app/[slug]/[postSlug]/page.tsx +20 -10
- package/src/app/[slug]/page/[page]/page.tsx +15 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/page.tsx +67 -32
- package/src/app/globals.css +639 -94
- package/src/app/page.tsx +1 -1
- package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
- package/src/app/series/[slug]/page.tsx +11 -13
- package/src/app/series/page.tsx +3 -3
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/AuthorCard.tsx +25 -16
- package/src/components/BookMobileNav.tsx +44 -50
- 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 +6 -2
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +3 -3
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/MarkdownRenderer.test.tsx +30 -4
- package/src/components/MarkdownRenderer.tsx +148 -24
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/RstRenderer.test.tsx +93 -0
- package/src/components/RstRenderer.tsx +157 -0
- package/src/components/Search.tsx +18 -4
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/i18n/translations.ts +2 -0
- package/src/layouts/BookLayout.tsx +35 -4
- package/src/layouts/PostLayout.tsx +10 -2
- package/src/layouts/SimpleLayout.tsx +10 -3
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/image-utils.test.ts +19 -0
- package/src/lib/image-utils.ts +11 -0
- package/src/lib/markdown.test.ts +195 -14
- package/src/lib/markdown.ts +928 -254
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/rehype-image-metadata.ts +2 -2
- 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.test.ts +355 -0
- package/src/lib/rst-renderer.ts +629 -0
- package/src/lib/rst.test.ts +350 -0
- package/src/lib/rst.ts +674 -0
- package/src/lib/series-redirects.ts +42 -0
- 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/urls.ts +57 -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/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/feed-utils.test.ts +13 -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 +12 -14
- package/tests/integration/series-draft.test.ts +12 -5
- package/tests/integration/series.test.ts +93 -0
- package/tests/integration/sync-vuepress-book.test.ts +240 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/build-pagefind.test.ts +66 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/static-params.test.ts +166 -13
package/src/app/page.tsx
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
99
|
+
const slug = safeDecodeParam(rawSlug);
|
|
102
100
|
const currentPath = `/series/${slug}`;
|
|
103
101
|
|
|
104
102
|
const redirect = findSeriesByRedirectFrom(currentPath);
|
package/src/app/series/page.tsx
CHANGED
|
@@ -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 =
|
|
24
|
-
const latestB =
|
|
23
|
+
const latestA = getSeriesLatestPostDate(a);
|
|
24
|
+
const latestB = getSeriesLatestPostDate(b);
|
|
25
25
|
return latestB.localeCompare(latestA);
|
|
26
26
|
});
|
|
27
27
|
|
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
|
+
}
|
|
@@ -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={
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
<
|
|
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
|
)}
|
|
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
|
});
|