@hutusi/amytis 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/immersive-reading.md +21 -0
- package/.claude/rules/rst.md +13 -0
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +89 -219
- package/bun.lock +185 -547
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +298 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +237 -0
- package/eslint.config.mjs +18 -6
- package/package.json +42 -20
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/render-rst.py +207 -3
- package/scripts/sync-vuepress-book.ts +710 -0
- package/site.config.example.ts +3 -3
- package/site.config.ts +3 -3
- package/src/app/[slug]/layout.tsx +30 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +85 -34
- package/src/app/globals.css +570 -123
- package/src/app/page.tsx +7 -1
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookReadingShell.tsx +145 -0
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CodeBlock.test.tsx +93 -8
- package/src/components/CodeBlock.tsx +39 -101
- package/src/components/CodeBlockToolbar.tsx +88 -0
- package/src/components/CodeGroup.tsx +81 -0
- package/src/components/CoverImage.tsx +1 -0
- package/src/components/CuratedSeriesSection.tsx +28 -10
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +44 -23
- package/src/components/Footer.tsx +1 -1
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/ImmersiveReader.tsx +130 -0
- package/src/components/ImmersiveReaderTopBar.tsx +106 -0
- package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
- package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
- package/src/components/ImmersiveReadingProvider.tsx +168 -0
- package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
- package/src/components/ImmersiveToggleButton.tsx +45 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +175 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/ReadingProgressBar.tsx +1 -1
- package/src/components/RstRenderer.test.tsx +15 -15
- package/src/components/RstRenderer.tsx +37 -2
- package/src/components/Search.tsx +18 -4
- package/src/components/SelectedBooksSection.tsx +27 -8
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +44 -0
- package/src/layouts/BookLayout.tsx +62 -74
- package/src/layouts/PostLayout.tsx +154 -111
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +217 -57
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/remark-book-chapter-links.ts +106 -0
- package/src/lib/remark-code-group.ts +54 -0
- package/src/lib/remark-github-alerts.test.ts +83 -0
- package/src/lib/remark-github-alerts.ts +65 -0
- package/src/lib/remark-vuepress-containers.ts +130 -0
- package/src/lib/rst-renderer.ts +19 -7
- package/src/lib/rst.test.ts +212 -2
- package/src/lib/rst.ts +217 -13
- package/src/lib/scroll-utils.ts +44 -6
- package/src/lib/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +62 -0
- package/src/test-utils/render.ts +23 -0
- package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
- package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
- package/tests/helpers/env.ts +19 -0
- package/tests/integration/book-chapter-links.test.ts +107 -0
- package/tests/integration/book-index-cta.test.ts +87 -0
- package/tests/integration/books-nested-toc.test.ts +176 -0
- package/tests/integration/books.test.ts +3 -2
- package/tests/integration/code-block-features.test.ts +188 -0
- package/tests/integration/code-group.test.ts +183 -0
- package/tests/integration/code-notation.test.ts +97 -0
- package/tests/integration/github-alerts.test.ts +82 -0
- package/tests/integration/markdown-external-links.test.ts +103 -0
- package/tests/integration/normalize-vuepress-math.test.ts +149 -0
- package/tests/integration/reading-time-headings.test.ts +8 -6
- package/tests/integration/series-draft.test.ts +6 -13
- package/tests/integration/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +443 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/tests/unit/static-params.test.ts +32 -19
- package/vercel.json +7 -0
package/src/lib/urls.ts
CHANGED
|
@@ -81,6 +81,11 @@ export function getBooksListUrl(): string {
|
|
|
81
81
|
return '/books';
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Returns the canonical URL path for a series landing page. */
|
|
85
|
+
export function getSeriesUrl(slug: string): string {
|
|
86
|
+
return `/series/${slug}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
84
89
|
/** Returns the canonical URL path for a book landing page. */
|
|
85
90
|
export function getBookUrl(slug: string): string {
|
|
86
91
|
return `/books/${slug}`;
|
|
@@ -105,3 +110,60 @@ export function getStaticPageUrl(slug: string): string {
|
|
|
105
110
|
export function getPostUrlInCollection(post: { slug: string; series?: string }, collectionSlug: string): string {
|
|
106
111
|
return `${getPostUrl(post)}?${new URLSearchParams({ collection: collectionSlug }).toString()}`;
|
|
107
112
|
}
|
|
113
|
+
|
|
114
|
+
let cachedSiteHost: string | null | undefined;
|
|
115
|
+
|
|
116
|
+
function getSiteHost(): string | null {
|
|
117
|
+
if (cachedSiteHost !== undefined) return cachedSiteHost;
|
|
118
|
+
try {
|
|
119
|
+
cachedSiteHost = new URL(siteConfig.baseUrl).host;
|
|
120
|
+
} catch {
|
|
121
|
+
cachedSiteHost = null;
|
|
122
|
+
}
|
|
123
|
+
return cachedSiteHost;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* True when `href` points to a different host than `siteConfig.baseUrl`.
|
|
128
|
+
*
|
|
129
|
+
* - `http://` / `https://` absolute URLs and protocol-relative `//host/...`
|
|
130
|
+
* are tested by host comparison.
|
|
131
|
+
* - `mailto:`, `tel:`, `sms:`, `ftp:`, `javascript:` and other non-http
|
|
132
|
+
* schemes return false — they're "external" in spirit but have different
|
|
133
|
+
* click semantics and don't warrant an outward-arrow indicator.
|
|
134
|
+
* - Hash-only (`#foo`), query-only (`?foo`), relative paths (`/foo`,
|
|
135
|
+
* `foo.md`), empty strings → false.
|
|
136
|
+
* - Malformed URLs → false (defensive — don't decorate something we can't parse).
|
|
137
|
+
*/
|
|
138
|
+
export function isExternalUrl(href: string | undefined | null): boolean {
|
|
139
|
+
if (!href) return false;
|
|
140
|
+
if (href.startsWith('#') || href.startsWith('?')) return false;
|
|
141
|
+
if (/^(mailto|tel|sms|ftp|javascript):/i.test(href)) return false;
|
|
142
|
+
|
|
143
|
+
const siteHost = getSiteHost();
|
|
144
|
+
if (!siteHost) return false;
|
|
145
|
+
|
|
146
|
+
if (href.startsWith('//')) {
|
|
147
|
+
// Prefix a dummy scheme so the URL parser handles auth (`//user:pass@host`),
|
|
148
|
+
// port-only (`//:80`), and IPv6 forms correctly — substring splitting on
|
|
149
|
+
// `/` is too coarse for any of those.
|
|
150
|
+
try {
|
|
151
|
+
return new URL(`https:${href}`).host !== siteHost;
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (/^https?:\/\//i.test(href)) {
|
|
157
|
+
try {
|
|
158
|
+
return new URL(href).host !== siteHost;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Test-only: drop the cached site host so a test can re-read `siteConfig.baseUrl`. */
|
|
167
|
+
export function resetSiteHostCacheForTests(): void {
|
|
168
|
+
cachedSiteHost = undefined;
|
|
169
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import { renderToReadableStream } from 'react-dom/server';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders a React tree (including async server components) to a full HTML string.
|
|
6
|
+
* Use this in tests where the tree contains async components — sync renderers like
|
|
7
|
+
* renderToStaticMarkup throw "A component suspended" for async server components.
|
|
8
|
+
*/
|
|
9
|
+
export async function renderAsync(element: ReactElement): Promise<string> {
|
|
10
|
+
const stream = await renderToReadableStream(element);
|
|
11
|
+
await stream.allReady;
|
|
12
|
+
const reader = stream.getReader();
|
|
13
|
+
const decoder = new TextDecoder();
|
|
14
|
+
let html = '';
|
|
15
|
+
while (true) {
|
|
16
|
+
const { done, value } = await reader.read();
|
|
17
|
+
if (done) break;
|
|
18
|
+
if (value) html += decoder.decode(value, { stream: !done });
|
|
19
|
+
}
|
|
20
|
+
// Flush any trailing buffered bytes from incomplete sequences (no-op for well-formed UTF-8).
|
|
21
|
+
html += decoder.decode();
|
|
22
|
+
return html;
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Minimal VuePress 2 config used by the sync-vuepress-book integration test.
|
|
2
|
+
// Mimics the structural shape of a real dmla-like config: a `theme(...)`
|
|
3
|
+
// wrapper around an options object whose `sidebar` property is the literal
|
|
4
|
+
// the importer needs to find.
|
|
5
|
+
|
|
6
|
+
import dmlaTheme from './fake-theme.js'
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
lang: 'zh-CN',
|
|
10
|
+
title: 'Fixture Book',
|
|
11
|
+
description: 'A tiny VuePress book used in tests',
|
|
12
|
+
|
|
13
|
+
theme: dmlaTheme({
|
|
14
|
+
sidebar: [
|
|
15
|
+
{
|
|
16
|
+
text: 'Intro',
|
|
17
|
+
collapsible: false,
|
|
18
|
+
link: '/intro/welcome',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
text: 'Maths',
|
|
22
|
+
collapsible: false,
|
|
23
|
+
children: [
|
|
24
|
+
{
|
|
25
|
+
text: 'Linear Algebra',
|
|
26
|
+
collapsible: false,
|
|
27
|
+
children: [
|
|
28
|
+
{ text: 'Vectors', link: '/maths/linear/vectors' },
|
|
29
|
+
{ text: 'Matrices', link: '/maths/linear/matrices' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
// Empty section — simulates the dmla config's placeholder sections
|
|
36
|
+
// ("alignment", "reasoning") so the importer warns instead of throws.
|
|
37
|
+
text: 'TBD',
|
|
38
|
+
collapsible: false,
|
|
39
|
+
children: [],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
}),
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
stub binary image placeholder
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @types/node 25+ types specific env vars (NODE_ENV, etc.) as readonly on
|
|
2
|
+
// `process.env`. Test code that wants to mutate them for the duration of a
|
|
3
|
+
// scenario should go through these helpers — they widen the property type so
|
|
4
|
+
// TS allows the write while still working at runtime exactly like
|
|
5
|
+
// `process.env[key] = ...` always has.
|
|
6
|
+
|
|
7
|
+
type MutableEnv = Record<string, string | undefined>;
|
|
8
|
+
|
|
9
|
+
export function setEnvVar(key: string, value: string): void {
|
|
10
|
+
(process.env as MutableEnv)[key] = value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function restoreEnvVar(key: string, value: string | undefined): void {
|
|
14
|
+
if (value === undefined) {
|
|
15
|
+
delete (process.env as MutableEnv)[key];
|
|
16
|
+
} else {
|
|
17
|
+
(process.env as MutableEnv)[key] = value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import MarkdownRenderer from "@/components/MarkdownRenderer";
|
|
3
|
+
import { renderAsync } from "@/test-utils/render";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
const fixtureBookDir = path.resolve("tests/fixtures/book-chapter-links");
|
|
7
|
+
const chapterSourcePath = path.join(fixtureBookDir, "maths/linear/introduction.md");
|
|
8
|
+
|
|
9
|
+
const bookContext = {
|
|
10
|
+
bookSlug: "dmla",
|
|
11
|
+
bookDir: fixtureBookDir,
|
|
12
|
+
chapterSourcePath,
|
|
13
|
+
validChapterIds: new Set([
|
|
14
|
+
"maths/linear/introduction",
|
|
15
|
+
"maths/linear/vectors",
|
|
16
|
+
"maths/linear/matrices",
|
|
17
|
+
"deep-learning/perceptron",
|
|
18
|
+
]),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("Integration: remark-book-chapter-links", () => {
|
|
22
|
+
test("rewrites a relative sibling .md link to its canonical chapter URL", async () => {
|
|
23
|
+
const html = await renderAsync(
|
|
24
|
+
MarkdownRenderer({
|
|
25
|
+
content: "See [vectors](vectors.md) for details.",
|
|
26
|
+
bookContext,
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
expect(html).toContain('href="/books/dmla/maths/linear/vectors"');
|
|
30
|
+
expect(html).not.toContain('href="vectors.md"');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("preserves fragment anchors when rewriting", async () => {
|
|
34
|
+
const html = await renderAsync(
|
|
35
|
+
MarkdownRenderer({
|
|
36
|
+
content: "See [tensors](matrices.md#tensors).",
|
|
37
|
+
bookContext,
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
expect(html).toContain('href="/books/dmla/maths/linear/matrices#tensors"');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rewrites a parent-directory .md link", async () => {
|
|
44
|
+
const html = await renderAsync(
|
|
45
|
+
MarkdownRenderer({
|
|
46
|
+
content: "See [perceptron](../../deep-learning/perceptron.md).",
|
|
47
|
+
bookContext,
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
expect(html).toContain('href="/books/dmla/deep-learning/perceptron"');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("leaves external http links untouched", async () => {
|
|
54
|
+
const html = await renderAsync(
|
|
55
|
+
MarkdownRenderer({
|
|
56
|
+
content: "See [Wiki](https://en.wikipedia.org/wiki/Vector_space).",
|
|
57
|
+
bookContext,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
expect(html).toContain('href="https://en.wikipedia.org/wiki/Vector_space"');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("leaves hash-only links untouched", async () => {
|
|
64
|
+
const html = await renderAsync(
|
|
65
|
+
MarkdownRenderer({
|
|
66
|
+
content: "[Top](#top)",
|
|
67
|
+
bookContext,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
expect(html).toContain('href="#top"');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("warns and leaves the link unrewritten when target is not in the TOC", async () => {
|
|
74
|
+
const html = await renderAsync(
|
|
75
|
+
MarkdownRenderer({
|
|
76
|
+
content: "Broken [link](nonexistent.md).",
|
|
77
|
+
bookContext,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
// The unmatched link is kept as-is — it will 404 if clicked, but doesn't
|
|
81
|
+
// block the build. Matches the Shiki "unknown language → warn" precedent.
|
|
82
|
+
expect(html).toContain('href="nonexistent.md"');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("malformed percent-encoding in a link does not crash the render", async () => {
|
|
86
|
+
// `%E0%A4%A` is a truncated UTF-8 sequence — bare decodeURIComponent throws
|
|
87
|
+
// URIError on this. The plugin must swallow that and not blow up the build.
|
|
88
|
+
const html = await renderAsync(
|
|
89
|
+
MarkdownRenderer({
|
|
90
|
+
content: "Sketchy [link](%E0%A4%A.md).",
|
|
91
|
+
bookContext,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
// Either the link survives as-is or it's silently dropped — what matters
|
|
95
|
+
// is that we don't get an unhandled URIError tearing down the render.
|
|
96
|
+
expect(html).toContain("Sketchy");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("non-book content (no bookContext) is not rewritten", async () => {
|
|
100
|
+
const html = await renderAsync(
|
|
101
|
+
MarkdownRenderer({
|
|
102
|
+
content: "See [vectors](vectors.md).",
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
expect(html).toContain('href="vectors.md"');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import BookLandingPage from "@/app/books/[slug]/page";
|
|
3
|
+
import { renderAsync } from "@/test-utils/render";
|
|
4
|
+
import { getBookData } from "@/lib/markdown";
|
|
5
|
+
import { t } from "@/lib/i18n";
|
|
6
|
+
import { getBookChapterUrl } from "@/lib/urls";
|
|
7
|
+
|
|
8
|
+
// Renders the actual book landing page server component for a real fixture
|
|
9
|
+
// book under content/books/. The page is async (calls getBookData internally)
|
|
10
|
+
// so we use the project's renderAsync helper — same pattern as
|
|
11
|
+
// tests/integration/book-chapter-links.test.ts. Catches the regression
|
|
12
|
+
// modes that pure-helper tests can't: someone removes either CTA from the
|
|
13
|
+
// JSX, or changes the href format and drops ?immersive=1.
|
|
14
|
+
|
|
15
|
+
const FIXTURE_BOOK_SLUG = "sample-book";
|
|
16
|
+
|
|
17
|
+
function escapeRegex(s: string): string {
|
|
18
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Returns the inner HTML of the smallest <a> element on the page whose href
|
|
22
|
+
// attribute equals the given value, or null if no such anchor exists. We
|
|
23
|
+
// match the anchor body rather than just `href="..."` so the label-text
|
|
24
|
+
// assertion is bound to the same element — otherwise an unrelated link with
|
|
25
|
+
// the same href (e.g. a TOC chapter row pointing at the first chapter) would
|
|
26
|
+
// satisfy a naïve `toContain('href="..."')` check and let an accidentally
|
|
27
|
+
// deleted CTA pass.
|
|
28
|
+
function findAnchorBodyByHref(html: string, href: string): string | null {
|
|
29
|
+
const re = new RegExp(`<a[^>]*\\bhref="${escapeRegex(href)}"[^>]*>([\\s\\S]*?)</a>`);
|
|
30
|
+
const m = html.match(re);
|
|
31
|
+
return m ? m[1] : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("Integration: book index Immersive reading CTA", () => {
|
|
35
|
+
test("renders an Immersive reading CTA linking to the first chapter with ?immersive=1", async () => {
|
|
36
|
+
const book = getBookData(FIXTURE_BOOK_SLUG);
|
|
37
|
+
if (!book || book.chapters.length === 0) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Fixture book "${FIXTURE_BOOK_SLUG}" not found or has no chapters — this test depends on it`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const firstChapter = book.chapters[0];
|
|
43
|
+
const expectedHref = `${getBookChapterUrl(book.slug, firstChapter.id)}?immersive=1`;
|
|
44
|
+
|
|
45
|
+
const html = await renderAsync(
|
|
46
|
+
BookLandingPage({ params: Promise.resolve({ slug: FIXTURE_BOOK_SLUG }) }),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const body = findAnchorBodyByHref(html, expectedHref);
|
|
50
|
+
expect(body).not.toBeNull();
|
|
51
|
+
// Label text is rendered inside the same anchor — guards against the
|
|
52
|
+
// anchor existing but having been repurposed to a different CTA.
|
|
53
|
+
expect(body).toContain(t("immersive_reading"));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("primary 'Start reading' CTA still renders alongside the immersive CTA", async () => {
|
|
57
|
+
const book = getBookData(FIXTURE_BOOK_SLUG);
|
|
58
|
+
if (!book || book.chapters.length === 0) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Fixture book "${FIXTURE_BOOK_SLUG}" not found or has no chapters`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const firstChapter = book.chapters[0];
|
|
64
|
+
const primaryHref = getBookChapterUrl(book.slug, firstChapter.id);
|
|
65
|
+
|
|
66
|
+
const html = await renderAsync(
|
|
67
|
+
BookLandingPage({ params: Promise.resolve({ slug: FIXTURE_BOOK_SLUG }) }),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Multiple links may share this href (TOC rows also point at the first
|
|
71
|
+
// chapter), so iterate over all matches and confirm at least one of them
|
|
72
|
+
// is the CTA — the one containing the start_reading label text.
|
|
73
|
+
const re = new RegExp(
|
|
74
|
+
`<a[^>]*\\bhref="${escapeRegex(primaryHref)}"[^>]*>([\\s\\S]*?)</a>`,
|
|
75
|
+
"g",
|
|
76
|
+
);
|
|
77
|
+
const label = t("start_reading");
|
|
78
|
+
let foundCta = false;
|
|
79
|
+
for (const match of html.matchAll(re)) {
|
|
80
|
+
if (match[1].includes(label)) {
|
|
81
|
+
foundCta = true;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
expect(foundCta).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
flattenBookChapters,
|
|
4
|
+
BookSchema,
|
|
5
|
+
type BookTocItem,
|
|
6
|
+
} from "../../src/lib/markdown";
|
|
7
|
+
|
|
8
|
+
describe("Integration: Books nested TOC", () => {
|
|
9
|
+
test("schema accepts the legacy { part, chapters } shape", () => {
|
|
10
|
+
const result = BookSchema.safeParse({
|
|
11
|
+
title: "Legacy Book",
|
|
12
|
+
date: "2026-01-01",
|
|
13
|
+
chapters: [
|
|
14
|
+
{
|
|
15
|
+
part: "Part I",
|
|
16
|
+
chapters: [
|
|
17
|
+
{ title: "Intro", id: "intro" },
|
|
18
|
+
{ title: "Setup", id: "setup" },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
part: "Part II",
|
|
23
|
+
chapters: [{ title: "Outro", id: "outro" }],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
expect(result.success).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("schema accepts the new { section, items } shape with arbitrary nesting", () => {
|
|
31
|
+
const result = BookSchema.safeParse({
|
|
32
|
+
title: "VuePress Book",
|
|
33
|
+
date: "2026-01-01",
|
|
34
|
+
chapters: [
|
|
35
|
+
{
|
|
36
|
+
section: "Maths",
|
|
37
|
+
items: [
|
|
38
|
+
{
|
|
39
|
+
section: "Linear Algebra",
|
|
40
|
+
items: [
|
|
41
|
+
{ title: "Intro", id: "maths/linear/introduction" },
|
|
42
|
+
{ title: "Vectors", id: "maths/linear/vectors" },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
section: "Calculus",
|
|
47
|
+
items: [{ title: "Derivative", id: "maths/calculus/derivative" }],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
expect(result.success).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("schema accepts bare chapter refs at the top level", () => {
|
|
57
|
+
const result = BookSchema.safeParse({
|
|
58
|
+
title: "Flat Book",
|
|
59
|
+
date: "2026-01-01",
|
|
60
|
+
chapters: [
|
|
61
|
+
{ title: "Chapter 1", id: "ch1" },
|
|
62
|
+
{ title: "Chapter 2", id: "ch2" },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
expect(result.success).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("schema accepts a mixed TOC (legacy parts + new sections + bare refs)", () => {
|
|
69
|
+
const result = BookSchema.safeParse({
|
|
70
|
+
title: "Mixed Book",
|
|
71
|
+
date: "2026-01-01",
|
|
72
|
+
chapters: [
|
|
73
|
+
{ part: "Part A", chapters: [{ title: "A1", id: "a1" }] },
|
|
74
|
+
{
|
|
75
|
+
section: "Section B",
|
|
76
|
+
items: [{ title: "B1", id: "section-b/b1" }],
|
|
77
|
+
},
|
|
78
|
+
{ title: "Standalone", id: "standalone" },
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
expect(result.success).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("schema rejects a section with neither items nor a section title", () => {
|
|
85
|
+
const result = BookSchema.safeParse({
|
|
86
|
+
title: "Bad Book",
|
|
87
|
+
date: "2026-01-01",
|
|
88
|
+
chapters: [{ section: "No items" } as unknown],
|
|
89
|
+
});
|
|
90
|
+
expect(result.success).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("flattenBookChapters preserves order across legacy parts", () => {
|
|
94
|
+
const toc: BookTocItem[] = [
|
|
95
|
+
{
|
|
96
|
+
part: "Part I",
|
|
97
|
+
chapters: [
|
|
98
|
+
{ title: "Intro", id: "intro" },
|
|
99
|
+
{ title: "Setup", id: "setup" },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
{ part: "Part II", chapters: [{ title: "Outro", id: "outro" }] },
|
|
103
|
+
];
|
|
104
|
+
const flat = flattenBookChapters(toc);
|
|
105
|
+
expect(flat.map((c) => c.id)).toEqual(["intro", "setup", "outro"]);
|
|
106
|
+
expect(flat[0].part).toBe("Part I");
|
|
107
|
+
expect(flat[1].part).toBe("Part I");
|
|
108
|
+
expect(flat[2].part).toBe("Part II");
|
|
109
|
+
expect(flat[0].section).toBeUndefined();
|
|
110
|
+
expect(flat[0].sectionPath).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("flattenBookChapters walks nested sections in source order", () => {
|
|
114
|
+
const toc: BookTocItem[] = [
|
|
115
|
+
{
|
|
116
|
+
section: "Maths",
|
|
117
|
+
items: [
|
|
118
|
+
{
|
|
119
|
+
section: "Linear Algebra",
|
|
120
|
+
items: [
|
|
121
|
+
{ title: "Intro", id: "maths/linear/introduction" },
|
|
122
|
+
{ title: "Vectors", id: "maths/linear/vectors" },
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
section: "Calculus",
|
|
127
|
+
items: [{ title: "Derivative", id: "maths/calculus/derivative" }],
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
const flat = flattenBookChapters(toc);
|
|
133
|
+
expect(flat.map((c) => c.id)).toEqual([
|
|
134
|
+
"maths/linear/introduction",
|
|
135
|
+
"maths/linear/vectors",
|
|
136
|
+
"maths/calculus/derivative",
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("flattenBookChapters annotates section + sectionPath for nested chapters", () => {
|
|
141
|
+
const toc: BookTocItem[] = [
|
|
142
|
+
{
|
|
143
|
+
section: "Maths",
|
|
144
|
+
items: [
|
|
145
|
+
{
|
|
146
|
+
section: "Linear Algebra",
|
|
147
|
+
items: [{ title: "Intro", id: "maths/linear/introduction" }],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
const flat = flattenBookChapters(toc);
|
|
153
|
+
expect(flat).toHaveLength(1);
|
|
154
|
+
expect(flat[0].section).toBe("Linear Algebra");
|
|
155
|
+
expect(flat[0].sectionPath).toEqual(["Maths", "Linear Algebra"]);
|
|
156
|
+
expect(flat[0].part).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("flattenBookChapters handles mixed legacy + new + bare entries", () => {
|
|
160
|
+
const toc: BookTocItem[] = [
|
|
161
|
+
{ part: "Part A", chapters: [{ title: "A1", id: "a1" }] },
|
|
162
|
+
{
|
|
163
|
+
section: "Section B",
|
|
164
|
+
items: [{ title: "B1", id: "section-b/b1" }],
|
|
165
|
+
},
|
|
166
|
+
{ title: "Standalone", id: "standalone" },
|
|
167
|
+
];
|
|
168
|
+
const flat = flattenBookChapters(toc);
|
|
169
|
+
expect(flat.map((c) => c.id)).toEqual(["a1", "section-b/b1", "standalone"]);
|
|
170
|
+
expect(flat[0].part).toBe("Part A");
|
|
171
|
+
expect(flat[1].section).toBe("Section B");
|
|
172
|
+
expect(flat[1].sectionPath).toEqual(["Section B"]);
|
|
173
|
+
expect(flat[2].part).toBeUndefined();
|
|
174
|
+
expect(flat[2].section).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { getAllBooks, getFeaturedBooks } from "../../src/lib/markdown";
|
|
3
|
+
import { setEnvVar, restoreEnvVar } from "../helpers/env";
|
|
3
4
|
|
|
4
5
|
describe("Integration: Books", () => {
|
|
5
6
|
test("getAllBooks returns an array", () => {
|
|
@@ -48,14 +49,14 @@ describe("Integration: Books", () => {
|
|
|
48
49
|
|
|
49
50
|
test("getAllBooks excludes drafts in production", () => {
|
|
50
51
|
const prev = process.env.NODE_ENV;
|
|
51
|
-
|
|
52
|
+
setEnvVar("NODE_ENV", "production");
|
|
52
53
|
try {
|
|
53
54
|
const books = getAllBooks();
|
|
54
55
|
books.forEach((book) => {
|
|
55
56
|
expect(book.draft).toBe(false);
|
|
56
57
|
});
|
|
57
58
|
} finally {
|
|
58
|
-
|
|
59
|
+
restoreEnvVar("NODE_ENV", prev);
|
|
59
60
|
}
|
|
60
61
|
});
|
|
61
62
|
});
|