@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.
Files changed (51) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +16 -0
  4. package/CLAUDE.md +10 -11
  5. package/docs/ARCHITECTURE.md +81 -0
  6. package/docs/DIGITAL_GARDEN.md +1 -1
  7. package/docs/guides/importing-vuepress-books.md +95 -36
  8. package/package.json +1 -1
  9. package/scripts/sync-vuepress-book.ts +277 -66
  10. package/site.config.example.ts +3 -3
  11. package/site.config.ts +3 -3
  12. package/src/app/[slug]/layout.tsx +30 -0
  13. package/src/app/books/[slug]/layout.tsx +24 -0
  14. package/src/app/books/[slug]/page.tsx +18 -2
  15. package/src/app/globals.css +67 -0
  16. package/src/app/page.tsx +6 -0
  17. package/src/app/posts/layout.tsx +20 -0
  18. package/src/app/series/[slug]/page.tsx +33 -9
  19. package/src/components/BookReadingShell.tsx +145 -0
  20. package/src/components/BookSidebar.tsx +0 -0
  21. package/src/components/CuratedSeriesSection.tsx +28 -10
  22. package/src/components/FeaturedStoriesSection.tsx +41 -20
  23. package/src/components/Footer.tsx +1 -1
  24. package/src/components/ImmersiveReader.tsx +130 -0
  25. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  26. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  27. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  28. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  29. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  30. package/src/components/ImmersiveToggleButton.tsx +45 -0
  31. package/src/components/MarkdownRenderer.tsx +31 -0
  32. package/src/components/Navbar.tsx +3 -1
  33. package/src/components/PostReadingShell.tsx +68 -0
  34. package/src/components/ReadingProgressBar.tsx +1 -1
  35. package/src/components/SelectedBooksSection.tsx +27 -8
  36. package/src/hooks/useActiveHeading.ts +35 -13
  37. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  38. package/src/i18n/translations.ts +42 -0
  39. package/src/layouts/BookLayout.tsx +46 -89
  40. package/src/layouts/PostLayout.tsx +154 -115
  41. package/src/lib/immersive-reading-prefs.ts +104 -0
  42. package/src/lib/markdown.ts +18 -11
  43. package/src/lib/scroll-utils.ts +44 -6
  44. package/src/lib/shuffle.ts +15 -1
  45. package/src/lib/sort.ts +15 -0
  46. package/src/lib/urls.ts +5 -0
  47. package/tests/integration/book-index-cta.test.ts +87 -0
  48. package/tests/integration/series-index-cta.test.ts +88 -0
  49. package/tests/integration/sync-vuepress-book.test.ts +205 -2
  50. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  51. package/vercel.json +7 -0
@@ -1,11 +1,49 @@
1
1
  import type React from 'react';
2
2
 
3
- export function scrollToHeading(e: React.MouseEvent<HTMLAnchorElement>, id: string): void {
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
- e.preventDefault();
7
- const elementPosition = element.getBoundingClientRect().top + window.scrollY;
8
- window.scrollTo({ top: elementPosition - 80, behavior: 'smooth' });
9
- history.pushState(null, '', `#${id}`);
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
  }
@@ -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
- let s = seed || 1;
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;
@@ -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
+ });
package/vercel.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "framework": "nextjs",
4
+ "buildCommand": "bun run build",
5
+ "installCommand": "bun install --frozen-lockfile",
6
+ "outputDirectory": "out"
7
+ }