@hutusi/amytis 1.16.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 +16 -0
- package/CLAUDE.md +10 -11
- package/docs/ARCHITECTURE.md +81 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/importing-vuepress-books.md +95 -36
- package/package.json +1 -1
- package/scripts/sync-vuepress-book.ts +277 -66
- 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]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +18 -2
- package/src/app/globals.css +67 -0
- package/src/app/page.tsx +6 -0
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/components/BookReadingShell.tsx +145 -0
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CuratedSeriesSection.tsx +28 -10
- package/src/components/FeaturedStoriesSection.tsx +41 -20
- package/src/components/Footer.tsx +1 -1
- 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.tsx +31 -0
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/ReadingProgressBar.tsx +1 -1
- package/src/components/SelectedBooksSection.tsx +27 -8
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +42 -0
- package/src/layouts/BookLayout.tsx +46 -89
- package/src/layouts/PostLayout.tsx +154 -115
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.ts +18 -11
- package/src/lib/scroll-utils.ts +44 -6
- package/src/lib/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +5 -0
- package/tests/integration/book-index-cta.test.ts +87 -0
- package/tests/integration/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +205 -2
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/vercel.json +7 -0
package/src/lib/scroll-utils.ts
CHANGED
|
@@ -1,11 +1,49 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const HEADING_OFFSET_PX = 80;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Walks up from `el` looking for the closest ancestor that actually scrolls
|
|
7
|
+
* vertically. Returns `null` if the document/window is the scroll container —
|
|
8
|
+
* that's the normal-page case; immersive reading mode is the case where this
|
|
9
|
+
* returns the overlay's `<main>` element. The `scrollHeight > clientHeight`
|
|
10
|
+
* guard skips `overflow:auto` boxes that aren't currently overflowing (e.g.
|
|
11
|
+
* the overlay's sidebar `<aside>` or the article wrapper).
|
|
12
|
+
*/
|
|
13
|
+
export function getScrollableAncestor(el: HTMLElement): HTMLElement | null {
|
|
14
|
+
let cur: HTMLElement | null = el.parentElement;
|
|
15
|
+
while (cur && cur !== document.documentElement) {
|
|
16
|
+
const { overflowY } = window.getComputedStyle(cur);
|
|
17
|
+
if (
|
|
18
|
+
(overflowY === 'auto' || overflowY === 'scroll') &&
|
|
19
|
+
cur.scrollHeight > cur.clientHeight
|
|
20
|
+
) {
|
|
21
|
+
return cur;
|
|
22
|
+
}
|
|
23
|
+
cur = cur.parentElement;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function scrollToHeading(
|
|
29
|
+
e: React.MouseEvent<HTMLAnchorElement>,
|
|
30
|
+
id: string,
|
|
31
|
+
): void {
|
|
4
32
|
const element = document.getElementById(id);
|
|
5
|
-
if (element)
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
33
|
+
if (!element) return;
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
|
|
36
|
+
const container = getScrollableAncestor(element);
|
|
37
|
+
if (container) {
|
|
38
|
+
const elRect = element.getBoundingClientRect();
|
|
39
|
+
const containerRect = container.getBoundingClientRect();
|
|
40
|
+
const top =
|
|
41
|
+
elRect.top - containerRect.top + container.scrollTop - HEADING_OFFSET_PX;
|
|
42
|
+
container.scrollTo({ top, behavior: 'smooth' });
|
|
43
|
+
} else {
|
|
44
|
+
const top =
|
|
45
|
+
element.getBoundingClientRect().top + window.scrollY - HEADING_OFFSET_PX;
|
|
46
|
+
window.scrollTo({ top, behavior: 'smooth' });
|
|
10
47
|
}
|
|
48
|
+
history.pushState(null, '', `#${id}`);
|
|
11
49
|
}
|
package/src/lib/shuffle.ts
CHANGED
|
@@ -10,6 +10,18 @@ export function shuffle<T>(array: T[]): T[] {
|
|
|
10
10
|
return shuffled;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* splitmix32 finalizer: decorrelates small consecutive integer seeds before
|
|
15
|
+
* they feed xorshift32. Without this, seeds like day-index 20608 / 20609 / 20610
|
|
16
|
+
* land on the same permutation for short arrays, making "daily rotation" invisible.
|
|
17
|
+
*/
|
|
18
|
+
function mixSeed(z: number): number {
|
|
19
|
+
z = (z + 0x9e3779b9) | 0;
|
|
20
|
+
z = Math.imul(z ^ (z >>> 16), 0x85ebca6b);
|
|
21
|
+
z = Math.imul(z ^ (z >>> 13), 0xc2b2ae35);
|
|
22
|
+
return (z ^ (z >>> 16)) >>> 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
/**
|
|
14
26
|
* Deterministic Fisher-Yates shuffle using a seeded xorshift32 PRNG.
|
|
15
27
|
* Produces the same order for the same seed on both server and client,
|
|
@@ -17,7 +29,9 @@ export function shuffle<T>(array: T[]): T[] {
|
|
|
17
29
|
*/
|
|
18
30
|
export function shuffleSeeded<T>(array: T[], seed: number): T[] {
|
|
19
31
|
const shuffled = [...array];
|
|
20
|
-
|
|
32
|
+
// splitmix32 is a bijection on 32-bit ints, so exactly one input maps to 0.
|
|
33
|
+
// Guard against that one input since xorshift32 locks at the all-zero state.
|
|
34
|
+
let s = mixSeed(seed || 1) || 1;
|
|
21
35
|
const rand = () => {
|
|
22
36
|
s ^= s << 13;
|
|
23
37
|
s ^= s >> 17;
|
package/src/lib/sort.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable date comparators that return 0 on ties so equal-date items preserve
|
|
3
|
+
* insertion order under V8's TimSort. Centralised here so every "newest-first"
|
|
4
|
+
* or "oldest-first" sort in the codebase uses the same antisymmetric comparator.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function byDateDesc<T extends { date: string }>(a: T, b: T): number {
|
|
8
|
+
if (a.date === b.date) return 0;
|
|
9
|
+
return a.date < b.date ? 1 : -1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function byDateAsc<T extends { date: string }>(a: T, b: T): number {
|
|
13
|
+
if (a.date === b.date) return 0;
|
|
14
|
+
return a.date > b.date ? 1 : -1;
|
|
15
|
+
}
|
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}`;
|
|
@@ -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,88 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import SeriesPage from "@/app/series/[slug]/page";
|
|
3
|
+
import { renderAsync } from "@/test-utils/render";
|
|
4
|
+
import { getSeriesData, getSeriesPosts } from "@/lib/markdown";
|
|
5
|
+
import { t } from "@/lib/i18n";
|
|
6
|
+
import { getPostUrl } from "@/lib/urls";
|
|
7
|
+
|
|
8
|
+
// Renders the actual series landing page server component for a real fixture
|
|
9
|
+
// series under content/series/. Same pattern as book-index-cta.test.ts —
|
|
10
|
+
// catches accidental removal of either CTA or a broken ?immersive=1 href
|
|
11
|
+
// without needing component-rendering test infrastructure.
|
|
12
|
+
|
|
13
|
+
const FIXTURE_SERIES_SLUG = "digital-garden";
|
|
14
|
+
|
|
15
|
+
function escapeRegex(s: string): string {
|
|
16
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Returns the inner HTML of the smallest <a> element on the page whose href
|
|
20
|
+
// attribute equals the given value, or null if no such anchor exists. We
|
|
21
|
+
// match the anchor body rather than just `href="..."` so the label-text
|
|
22
|
+
// assertion is bound to the same element — otherwise an unrelated link with
|
|
23
|
+
// the same href (e.g. a post-row in the series list) would satisfy a naïve
|
|
24
|
+
// `toContain('href="..."')` check and let an accidentally deleted CTA pass.
|
|
25
|
+
function findAnchorBodyByHref(html: string, href: string): string | null {
|
|
26
|
+
const re = new RegExp(`<a[^>]*\\bhref="${escapeRegex(href)}"[^>]*>([\\s\\S]*?)</a>`);
|
|
27
|
+
const m = html.match(re);
|
|
28
|
+
return m ? m[1] : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Picks the first installment respecting series sort order, mirroring the
|
|
32
|
+
// inline logic in src/app/series/[slug]/page.tsx (the test would otherwise
|
|
33
|
+
// have to assume a particular sort).
|
|
34
|
+
function pickFirstPostHref(slug: string): string {
|
|
35
|
+
const posts = getSeriesPosts(slug);
|
|
36
|
+
if (posts.length === 0) {
|
|
37
|
+
throw new Error(`Fixture series "${slug}" has no posts`);
|
|
38
|
+
}
|
|
39
|
+
const data = getSeriesData(slug) as (Record<string, unknown> | null);
|
|
40
|
+
// Series sort lives on the index frontmatter — top-level on the resolved
|
|
41
|
+
// PostData blob, same access the page uses. Treated as unknown here to
|
|
42
|
+
// avoid leaning on internal PostData shape.
|
|
43
|
+
const sort = typeof data?.sort === 'string' ? data.sort : undefined;
|
|
44
|
+
const firstPost = sort === 'date-asc' || sort === 'manual' ? posts[0] : posts[posts.length - 1];
|
|
45
|
+
return getPostUrl(firstPost);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("Integration: series index Immersive reading CTA", () => {
|
|
49
|
+
test("renders an Immersive reading CTA linking to the first post with ?immersive=1", async () => {
|
|
50
|
+
const primaryHref = pickFirstPostHref(FIXTURE_SERIES_SLUG);
|
|
51
|
+
const expectedHref = `${primaryHref}?immersive=1`;
|
|
52
|
+
|
|
53
|
+
const html = await renderAsync(
|
|
54
|
+
SeriesPage({ params: Promise.resolve({ slug: FIXTURE_SERIES_SLUG }) }),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const body = findAnchorBodyByHref(html, expectedHref);
|
|
58
|
+
expect(body).not.toBeNull();
|
|
59
|
+
// Label text inside the same anchor — guards against the anchor existing
|
|
60
|
+
// but having been repurposed to a different CTA.
|
|
61
|
+
expect(body).toContain(t("immersive_reading"));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("primary 'Start reading' CTA still renders alongside the immersive CTA", async () => {
|
|
65
|
+
const primaryHref = pickFirstPostHref(FIXTURE_SERIES_SLUG);
|
|
66
|
+
|
|
67
|
+
const html = await renderAsync(
|
|
68
|
+
SeriesPage({ params: Promise.resolve({ slug: FIXTURE_SERIES_SLUG }) }),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Multiple links may share this href (the series posts list also points
|
|
72
|
+
// at the first post), so iterate over all matches and confirm at least
|
|
73
|
+
// one of them is the CTA — the one containing the start_reading label.
|
|
74
|
+
const re = new RegExp(
|
|
75
|
+
`<a[^>]*\\bhref="${escapeRegex(primaryHref)}"[^>]*>([\\s\\S]*?)</a>`,
|
|
76
|
+
"g",
|
|
77
|
+
);
|
|
78
|
+
const label = t("start_reading");
|
|
79
|
+
let foundCta = false;
|
|
80
|
+
for (const match of html.matchAll(re)) {
|
|
81
|
+
if (match[1].includes(label)) {
|
|
82
|
+
foundCta = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
expect(foundCta).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -11,10 +11,10 @@ const FIXTURE_SOURCE = path.resolve("tests/fixtures/sync-vuepress-book/docs");
|
|
|
11
11
|
// `--source` / `--dest` flags so the test exercises everything a real user does:
|
|
12
12
|
// the package.json script wiring + the CLI argv parser, not just the inner sync
|
|
13
13
|
// pipeline.
|
|
14
|
-
function runSync(source: string, dest: string) {
|
|
14
|
+
function runSync(source: string, dest: string, extraArgs: string[] = []) {
|
|
15
15
|
return spawnSync(
|
|
16
16
|
"bun",
|
|
17
|
-
["run", "sync-vuepress-book", "--source", source, "--dest", dest],
|
|
17
|
+
["run", "sync-vuepress-book", "--source", source, "--dest", dest, ...extraArgs],
|
|
18
18
|
{
|
|
19
19
|
encoding: "utf8",
|
|
20
20
|
cwd: process.cwd(),
|
|
@@ -89,6 +89,35 @@ describe("Integration: sync-vuepress-book script", () => {
|
|
|
89
89
|
expect(Array.isArray(refreshed.chapters)).toBe(true);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
test("re-sync only touches the chapters field — never re-applies first-sync defaults", () => {
|
|
93
|
+
// First run creates index.mdx with bootstrap defaults (title, date,
|
|
94
|
+
// draft, featured).
|
|
95
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
96
|
+
|
|
97
|
+
// Author strips the script's bootstrap defaults to validate that
|
|
98
|
+
// re-sync does not auto-fill them again. `featured` is explicitly
|
|
99
|
+
// removed; `date` is left as an empty string (which the old behavior
|
|
100
|
+
// would have stomped with today's date because `!data.date` is true
|
|
101
|
+
// for `""`).
|
|
102
|
+
const indexPath = path.join(dest, "index.mdx");
|
|
103
|
+
const parsed = matter(readFileSync(indexPath, "utf8")) as unknown as { data: Record<string, unknown>; content: string };
|
|
104
|
+
delete parsed.data.featured;
|
|
105
|
+
parsed.data.date = "";
|
|
106
|
+
parsed.data.draft = true;
|
|
107
|
+
writeFileSync(indexPath, matter.stringify(parsed.content, parsed.data));
|
|
108
|
+
|
|
109
|
+
// Re-run.
|
|
110
|
+
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
111
|
+
const refreshed = matter(readFileSync(indexPath, "utf8")) as unknown as { data: Record<string, unknown> };
|
|
112
|
+
|
|
113
|
+
// `chapters:` refreshed; everything else byte-equivalent to what the
|
|
114
|
+
// author wrote.
|
|
115
|
+
expect(Array.isArray(refreshed.data.chapters)).toBe(true);
|
|
116
|
+
expect(refreshed.data.featured).toBeUndefined();
|
|
117
|
+
expect(refreshed.data.date).toBe("");
|
|
118
|
+
expect(refreshed.data.draft).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
92
121
|
test("prunes dest files removed upstream (mirror semantics)", () => {
|
|
93
122
|
// First sync — dest now has every fixture file.
|
|
94
123
|
expect(runSync(FIXTURE_SOURCE, dest).status).toBe(0);
|
|
@@ -219,6 +248,180 @@ describe("Integration: sync-vuepress-book script", () => {
|
|
|
219
248
|
}
|
|
220
249
|
});
|
|
221
250
|
|
|
251
|
+
test("imports a VuePress 1.x sidebar (title/path/collapsable, bare-string children, README promotion, SUMMARY drop)", () => {
|
|
252
|
+
// VP1 uses a different vocabulary than VP2: `title` instead of `text`,
|
|
253
|
+
// `collapsable` instead of `collapsible`, plain string paths as children,
|
|
254
|
+
// and sections that carry both `path` (the section's README) and
|
|
255
|
+
// `children` (sub-chapters). This fixture exercises all four variants
|
|
256
|
+
// plus the GitBook-style SUMMARY.md drop.
|
|
257
|
+
const vp1 = mkdtempSync(path.join(tmpdir(), "sync-vp1-"));
|
|
258
|
+
try {
|
|
259
|
+
const docs = path.join(vp1, "docs");
|
|
260
|
+
const vp = path.join(docs, ".vuepress");
|
|
261
|
+
mkdirSync(vp, { recursive: true });
|
|
262
|
+
writeFileSync(
|
|
263
|
+
path.join(vp, "config.js"),
|
|
264
|
+
`module.exports = {
|
|
265
|
+
title: 'VP1 Fixture',
|
|
266
|
+
themeConfig: {
|
|
267
|
+
sidebar: [
|
|
268
|
+
{ title: '目录', collapsable: false, path: '/SUMMARY.md' },
|
|
269
|
+
{
|
|
270
|
+
title: 'Preface',
|
|
271
|
+
collapsable: false,
|
|
272
|
+
children: [
|
|
273
|
+
'/intro/about-me',
|
|
274
|
+
'/intro/about-book',
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
title: 'Architecture',
|
|
279
|
+
collapsable: false,
|
|
280
|
+
children: [
|
|
281
|
+
{
|
|
282
|
+
title: 'History',
|
|
283
|
+
path: '/arch/history/',
|
|
284
|
+
collapsable: false,
|
|
285
|
+
children: [
|
|
286
|
+
'/arch/history/monolithic',
|
|
287
|
+
'/arch/history/microservices',
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
'/arch/standalone-note',
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
title: 'Misc',
|
|
295
|
+
collapsable: false,
|
|
296
|
+
children: [
|
|
297
|
+
'/CHANGELOG.md',
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
`,
|
|
304
|
+
);
|
|
305
|
+
// Source files matching every sidebar reference. Titles come from
|
|
306
|
+
// frontmatter or H1 — the importer should pick them up for bare-string
|
|
307
|
+
// children that have no inline title.
|
|
308
|
+
writeFileSync(path.join(docs, "SUMMARY.md"), "# Summary\n- placeholder\n");
|
|
309
|
+
mkdirSync(path.join(docs, "intro"), { recursive: true });
|
|
310
|
+
writeFileSync(path.join(docs, "intro", "about-me.md"), "---\ntitle: About the Author\n---\n# About\n");
|
|
311
|
+
writeFileSync(path.join(docs, "intro", "about-book.md"), "# About this Book\n\nBody.\n");
|
|
312
|
+
mkdirSync(path.join(docs, "arch", "history"), { recursive: true });
|
|
313
|
+
writeFileSync(path.join(docs, "arch", "history", "README.md"), "# History of Architecture\n");
|
|
314
|
+
writeFileSync(path.join(docs, "arch", "history", "monolithic.md"), "# Monolithic\n");
|
|
315
|
+
writeFileSync(path.join(docs, "arch", "history", "microservices.md"), "# Microservices\n");
|
|
316
|
+
writeFileSync(path.join(docs, "arch", "standalone-note.md"), "# Standalone Note\n");
|
|
317
|
+
writeFileSync(path.join(docs, "CHANGELOG.md"), "# Changelog\n");
|
|
318
|
+
|
|
319
|
+
const res = runSync(docs, dest);
|
|
320
|
+
expect(res.status).toBe(0);
|
|
321
|
+
|
|
322
|
+
const { data } = matter(readFileSync(path.join(dest, "index.mdx"), "utf8")) as unknown as { data: Record<string, unknown> };
|
|
323
|
+
const chapters = data.chapters as Array<Record<string, unknown>>;
|
|
324
|
+
|
|
325
|
+
// SUMMARY.md dropped — TOC starts with the Preface section.
|
|
326
|
+
expect(chapters[0]).toMatchObject({ section: "Preface", collapsible: false });
|
|
327
|
+
|
|
328
|
+
// Bare-string children get their titles from the source files
|
|
329
|
+
// (frontmatter wins over first H1).
|
|
330
|
+
const prefaceItems = chapters[0].items as Array<Record<string, unknown>>;
|
|
331
|
+
expect(prefaceItems).toEqual([
|
|
332
|
+
{ title: "About the Author", id: "intro/about-me" },
|
|
333
|
+
{ title: "About this Book", id: "intro/about-book" },
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
// Architecture > History promotes the section's README as the first
|
|
337
|
+
// chapter (id `arch/history`, title from the README's H1), then
|
|
338
|
+
// appends the bare-string children.
|
|
339
|
+
const arch = chapters[1] as Record<string, unknown>;
|
|
340
|
+
expect(arch.section).toBe("Architecture");
|
|
341
|
+
const archItems = arch.items as Array<Record<string, unknown>>;
|
|
342
|
+
const history = archItems[0] as Record<string, unknown>;
|
|
343
|
+
expect(history.section).toBe("History");
|
|
344
|
+
expect(history.items).toEqual([
|
|
345
|
+
{ title: "History of Architecture", id: "arch/history" },
|
|
346
|
+
{ title: "Monolithic", id: "arch/history/monolithic" },
|
|
347
|
+
{ title: "Microservices", id: "arch/history/microservices" },
|
|
348
|
+
]);
|
|
349
|
+
// Standalone bare-string sibling of the History section.
|
|
350
|
+
expect(archItems[1]).toMatchObject({ title: "Standalone Note", id: "arch/standalone-note" });
|
|
351
|
+
|
|
352
|
+
// `/CHANGELOG.md` keeps its `.md` extension stripped — id is `CHANGELOG`.
|
|
353
|
+
const misc = chapters[2] as Record<string, unknown>;
|
|
354
|
+
expect((misc.items as Array<{ id: string }>)[0].id).toBe("CHANGELOG");
|
|
355
|
+
|
|
356
|
+
// SUMMARY drop is reported in stdout (same channel as the existing
|
|
357
|
+
// `contents` drop).
|
|
358
|
+
expect(res.stdout).toMatch(/SUMMARY/i);
|
|
359
|
+
|
|
360
|
+
// No "unsupported sidebar entries" warning — every VP1 shape was
|
|
361
|
+
// recognized.
|
|
362
|
+
expect(res.stderr).not.toMatch(/Skipped unsupported sidebar entries/);
|
|
363
|
+
} finally {
|
|
364
|
+
rmSync(vp1, { recursive: true, force: true });
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("skips common build manifests by default, honors --skip and --no-skip-common", () => {
|
|
369
|
+
// Set up a minimal VuePress book where the source root has package.json
|
|
370
|
+
// (junk), package-lock.json (junk), a custom .bak (skipped via --skip),
|
|
371
|
+
// and a Real.md (always copied).
|
|
372
|
+
const junky = mkdtempSync(path.join(tmpdir(), "sync-junky-"));
|
|
373
|
+
try {
|
|
374
|
+
const docs = path.join(junky, "docs");
|
|
375
|
+
const vp = path.join(docs, ".vuepress");
|
|
376
|
+
mkdirSync(vp, { recursive: true });
|
|
377
|
+
writeFileSync(
|
|
378
|
+
path.join(vp, "config.js"),
|
|
379
|
+
`export default {
|
|
380
|
+
title: 'Junky Book',
|
|
381
|
+
theme: { sidebar: [{ text: 'Real', link: '/real' }] },
|
|
382
|
+
}
|
|
383
|
+
`,
|
|
384
|
+
);
|
|
385
|
+
writeFileSync(path.join(docs, "real.md"), "---\ntitle: Real\n---\n# Real\n");
|
|
386
|
+
writeFileSync(path.join(docs, "package.json"), '{"name":"junky"}');
|
|
387
|
+
writeFileSync(path.join(docs, "package-lock.json"), '{"lockfileVersion":3}');
|
|
388
|
+
writeFileSync(path.join(docs, "bun.lockb"), "binary-ish");
|
|
389
|
+
writeFileSync(path.join(docs, "draft.bak"), "wip");
|
|
390
|
+
|
|
391
|
+
// 1. Defaults: --skip-common is on, --skip empty. Lockfiles dropped,
|
|
392
|
+
// bak file kept (it matches no rule).
|
|
393
|
+
let res = runSync(docs, dest);
|
|
394
|
+
expect(res.status).toBe(0);
|
|
395
|
+
expect(existsSync(path.join(dest, "real.md"))).toBe(true);
|
|
396
|
+
expect(existsSync(path.join(dest, "package.json"))).toBe(false);
|
|
397
|
+
expect(existsSync(path.join(dest, "package-lock.json"))).toBe(false);
|
|
398
|
+
expect(existsSync(path.join(dest, "bun.lockb"))).toBe(false);
|
|
399
|
+
expect(existsSync(path.join(dest, "draft.bak"))).toBe(true);
|
|
400
|
+
// Skip summary is printed so the user knows what got dropped.
|
|
401
|
+
expect(res.stdout).toMatch(/Skipped \d+ files? matching skip rules/);
|
|
402
|
+
|
|
403
|
+
// 2. --skip '*.bak' adds the bak pattern to the defaults.
|
|
404
|
+
rmSync(dest, { recursive: true, force: true });
|
|
405
|
+
mkdirSync(dest, { recursive: true });
|
|
406
|
+
res = runSync(docs, dest, ["--skip", "*.bak"]);
|
|
407
|
+
expect(res.status).toBe(0);
|
|
408
|
+
expect(existsSync(path.join(dest, "real.md"))).toBe(true);
|
|
409
|
+
expect(existsSync(path.join(dest, "package.json"))).toBe(false);
|
|
410
|
+
expect(existsSync(path.join(dest, "draft.bak"))).toBe(false);
|
|
411
|
+
|
|
412
|
+
// 3. --no-skip-common disables the default list; lockfiles come back.
|
|
413
|
+
rmSync(dest, { recursive: true, force: true });
|
|
414
|
+
mkdirSync(dest, { recursive: true });
|
|
415
|
+
res = runSync(docs, dest, ["--no-skip-common"]);
|
|
416
|
+
expect(res.status).toBe(0);
|
|
417
|
+
expect(existsSync(path.join(dest, "package.json"))).toBe(true);
|
|
418
|
+
expect(existsSync(path.join(dest, "package-lock.json"))).toBe(true);
|
|
419
|
+
expect(existsSync(path.join(dest, "bun.lockb"))).toBe(true);
|
|
420
|
+
} finally {
|
|
421
|
+
rmSync(junky, { recursive: true, force: true });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
222
425
|
test("exits with an error when a sidebar leaf points to a missing source file", () => {
|
|
223
426
|
// Create a corrupt config with a broken link.
|
|
224
427
|
const broken = mkdtempSync(path.join(tmpdir(), "sync-broken-"));
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PREFS,
|
|
4
|
+
STORAGE_KEY,
|
|
5
|
+
readStoredPrefs,
|
|
6
|
+
writeStoredPrefs,
|
|
7
|
+
type StoredPrefs,
|
|
8
|
+
} from '../../src/lib/immersive-reading-prefs';
|
|
9
|
+
|
|
10
|
+
// Minimal in-memory mock matching the Storage interface subsets the helpers
|
|
11
|
+
// accept. Per-test instance keeps state isolated and avoids touching
|
|
12
|
+
// globalThis.localStorage. Optional setItem override lets us simulate
|
|
13
|
+
// private-browsing / quota-exceeded throws.
|
|
14
|
+
function makeMockStorage(
|
|
15
|
+
initial?: Record<string, string>,
|
|
16
|
+
opts: { setItemThrows?: boolean } = {},
|
|
17
|
+
) {
|
|
18
|
+
const store: Record<string, string> = { ...(initial ?? {}) };
|
|
19
|
+
return {
|
|
20
|
+
getItem(key: string): string | null {
|
|
21
|
+
return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null;
|
|
22
|
+
},
|
|
23
|
+
setItem(key: string, value: string): void {
|
|
24
|
+
if (opts.setItemThrows) throw new Error('quota exceeded');
|
|
25
|
+
store[key] = value;
|
|
26
|
+
},
|
|
27
|
+
_store: store,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('readStoredPrefs', () => {
|
|
32
|
+
test('returns defaults when storage is empty', () => {
|
|
33
|
+
expect(readStoredPrefs(makeMockStorage())).toEqual(DEFAULT_PREFS);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('returns defaults when no storage is available', () => {
|
|
37
|
+
// Passing a storage whose getItem always returns null mirrors the
|
|
38
|
+
// "globalThis.localStorage missing" path. Production callers can also
|
|
39
|
+
// pass undefined and rely on the lazy default — that path is exercised
|
|
40
|
+
// by the provider in the browser, not by unit tests.
|
|
41
|
+
const empty = { getItem: () => null };
|
|
42
|
+
expect(readStoredPrefs(empty)).toEqual(DEFAULT_PREFS);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('returns defaults when stored JSON is invalid', () => {
|
|
46
|
+
expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: 'not-json{' }))).toEqual(DEFAULT_PREFS);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('returns defaults when stored value is not an object', () => {
|
|
50
|
+
expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: '"a string"' }))).toEqual(DEFAULT_PREFS);
|
|
51
|
+
expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: 'null' }))).toEqual(DEFAULT_PREFS);
|
|
52
|
+
expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: '42' }))).toEqual(DEFAULT_PREFS);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('returns defaults when storage entry is missing for the key', () => {
|
|
56
|
+
expect(readStoredPrefs(makeMockStorage({ 'unrelated-key': 'whatever' }))).toEqual(DEFAULT_PREFS);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('round-trips a fully valid prefs blob', () => {
|
|
60
|
+
const blob: StoredPrefs = {
|
|
61
|
+
fontSize: 'xl',
|
|
62
|
+
readingTheme: 'sepia',
|
|
63
|
+
columnWidth: 'narrow',
|
|
64
|
+
sidebarOpen: false,
|
|
65
|
+
};
|
|
66
|
+
expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: JSON.stringify(blob) }))).toEqual(blob);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// The schema-drift case the helpers exist for: one corrupt key must not
|
|
70
|
+
// discard the whole blob — other valid keys still apply, the bad one
|
|
71
|
+
// falls back to its default.
|
|
72
|
+
test('per-key fallback: bad fontSize, others survive', () => {
|
|
73
|
+
const stored = JSON.stringify({
|
|
74
|
+
fontSize: 'banana',
|
|
75
|
+
readingTheme: 'dark',
|
|
76
|
+
columnWidth: 'full',
|
|
77
|
+
sidebarOpen: false,
|
|
78
|
+
});
|
|
79
|
+
expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: stored }))).toEqual({
|
|
80
|
+
fontSize: DEFAULT_PREFS.fontSize, // fell back
|
|
81
|
+
readingTheme: 'dark',
|
|
82
|
+
columnWidth: 'full',
|
|
83
|
+
sidebarOpen: false,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('per-key fallback: bad readingTheme + columnWidth, others survive', () => {
|
|
88
|
+
const stored = JSON.stringify({
|
|
89
|
+
fontSize: 's',
|
|
90
|
+
readingTheme: 'neon',
|
|
91
|
+
columnWidth: 'ultra-wide',
|
|
92
|
+
sidebarOpen: true,
|
|
93
|
+
});
|
|
94
|
+
expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: stored }))).toEqual({
|
|
95
|
+
fontSize: 's',
|
|
96
|
+
readingTheme: DEFAULT_PREFS.readingTheme,
|
|
97
|
+
columnWidth: DEFAULT_PREFS.columnWidth,
|
|
98
|
+
sidebarOpen: true,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('sidebarOpen is strict-boolean — string "true" does not count', () => {
|
|
103
|
+
const stored = JSON.stringify({
|
|
104
|
+
fontSize: 'm',
|
|
105
|
+
readingTheme: 'auto',
|
|
106
|
+
columnWidth: 'wide',
|
|
107
|
+
sidebarOpen: 'true',
|
|
108
|
+
});
|
|
109
|
+
expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: stored })).sidebarOpen).toBe(
|
|
110
|
+
DEFAULT_PREFS.sidebarOpen,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('returns defaults when all keys are missing from a valid object', () => {
|
|
115
|
+
expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: '{}' }))).toEqual(DEFAULT_PREFS);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('writeStoredPrefs', () => {
|
|
120
|
+
test('writes the full blob under STORAGE_KEY as JSON', () => {
|
|
121
|
+
const storage = makeMockStorage();
|
|
122
|
+
const blob: StoredPrefs = {
|
|
123
|
+
fontSize: 'l',
|
|
124
|
+
readingTheme: 'dark',
|
|
125
|
+
columnWidth: 'medium',
|
|
126
|
+
sidebarOpen: false,
|
|
127
|
+
};
|
|
128
|
+
writeStoredPrefs(blob, storage);
|
|
129
|
+
expect(JSON.parse(storage._store[STORAGE_KEY])).toEqual(blob);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('swallows throws (private browsing / quota exceeded)', () => {
|
|
133
|
+
const storage = makeMockStorage(undefined, { setItemThrows: true });
|
|
134
|
+
// Must not crash — the production caller relies on this silence so the
|
|
135
|
+
// reader still works in private browsing.
|
|
136
|
+
expect(() => writeStoredPrefs(DEFAULT_PREFS, storage)).not.toThrow();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('does nothing when no storage is available', () => {
|
|
140
|
+
// Same as above but exercising the no-storage path. The "no storage"
|
|
141
|
+
// call site in production happens during SSR.
|
|
142
|
+
expect(() => writeStoredPrefs(DEFAULT_PREFS, undefined)).not.toThrow();
|
|
143
|
+
});
|
|
144
|
+
});
|