@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.
Files changed (120) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +89 -219
  5. package/bun.lock +185 -547
  6. package/content/books/sample-book/index.mdx +3 -0
  7. package/content/posts/code-block-features-showcase.mdx +223 -0
  8. package/docs/ALERTS.md +112 -0
  9. package/docs/ARCHITECTURE.md +298 -5
  10. package/docs/CODE-BLOCKS.md +238 -0
  11. package/docs/CONTRIBUTING.md +25 -0
  12. package/docs/DIGITAL_GARDEN.md +1 -1
  13. package/docs/guides/README.md +11 -0
  14. package/docs/guides/importing-vuepress-books.md +237 -0
  15. package/eslint.config.mjs +18 -6
  16. package/package.json +42 -20
  17. package/scripts/generate-code-group-icons.ts +79 -0
  18. package/scripts/render-rst.py +207 -3
  19. package/scripts/sync-vuepress-book.ts +710 -0
  20. package/site.config.example.ts +3 -3
  21. package/site.config.ts +3 -3
  22. package/src/app/[slug]/layout.tsx +30 -0
  23. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  24. package/src/app/books/[slug]/layout.tsx +24 -0
  25. package/src/app/books/[slug]/page.tsx +85 -34
  26. package/src/app/globals.css +570 -123
  27. package/src/app/page.tsx +7 -1
  28. package/src/app/posts/layout.tsx +20 -0
  29. package/src/app/series/[slug]/page.tsx +33 -9
  30. package/src/app/sitemap.ts +3 -3
  31. package/src/components/ArticleCopyCleaner.tsx +64 -0
  32. package/src/components/BookMobileNav.tsx +44 -50
  33. package/src/components/BookReadingShell.tsx +145 -0
  34. package/src/components/BookSidebar.tsx +0 -0
  35. package/src/components/CodeBlock.test.tsx +93 -8
  36. package/src/components/CodeBlock.tsx +39 -101
  37. package/src/components/CodeBlockToolbar.tsx +88 -0
  38. package/src/components/CodeGroup.tsx +81 -0
  39. package/src/components/CoverImage.tsx +1 -0
  40. package/src/components/CuratedSeriesSection.tsx +28 -10
  41. package/src/components/ExternalLinkIcon.tsx +15 -0
  42. package/src/components/FeaturedStoriesSection.tsx +44 -23
  43. package/src/components/Footer.tsx +1 -1
  44. package/src/components/GithubAlert.tsx +97 -0
  45. package/src/components/ImmersiveReader.tsx +130 -0
  46. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  47. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  48. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  49. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  50. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  51. package/src/components/ImmersiveToggleButton.tsx +45 -0
  52. package/src/components/MarkdownRenderer.test.tsx +14 -4
  53. package/src/components/MarkdownRenderer.tsx +175 -23
  54. package/src/components/Mermaid.tsx +32 -1
  55. package/src/components/Navbar.tsx +3 -1
  56. package/src/components/PostList.tsx +1 -1
  57. package/src/components/PostNavigation.tsx +13 -2
  58. package/src/components/PostReadingShell.tsx +68 -0
  59. package/src/components/PostSidebar.tsx +13 -2
  60. package/src/components/ReadingProgressBar.tsx +1 -1
  61. package/src/components/RstRenderer.test.tsx +15 -15
  62. package/src/components/RstRenderer.tsx +37 -2
  63. package/src/components/Search.tsx +18 -4
  64. package/src/components/SelectedBooksSection.tsx +27 -8
  65. package/src/components/SeriesCatalog.tsx +1 -1
  66. package/src/components/ShareBar.tsx +5 -0
  67. package/src/components/TocPanel.tsx +10 -2
  68. package/src/hooks/useActiveHeading.ts +35 -13
  69. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  70. package/src/i18n/translations.ts +44 -0
  71. package/src/layouts/BookLayout.tsx +62 -74
  72. package/src/layouts/PostLayout.tsx +154 -111
  73. package/src/lib/code-group-icons.test.ts +78 -0
  74. package/src/lib/code-group-icons.ts +148 -0
  75. package/src/lib/immersive-reading-prefs.ts +104 -0
  76. package/src/lib/markdown.test.ts +56 -13
  77. package/src/lib/markdown.ts +217 -57
  78. package/src/lib/normalize-vuepress-math.ts +118 -0
  79. package/src/lib/rehype-fence-meta.ts +22 -0
  80. package/src/lib/remark-book-chapter-links.ts +106 -0
  81. package/src/lib/remark-code-group.ts +54 -0
  82. package/src/lib/remark-github-alerts.test.ts +83 -0
  83. package/src/lib/remark-github-alerts.ts +65 -0
  84. package/src/lib/remark-vuepress-containers.ts +130 -0
  85. package/src/lib/rst-renderer.ts +19 -7
  86. package/src/lib/rst.test.ts +212 -2
  87. package/src/lib/rst.ts +217 -13
  88. package/src/lib/scroll-utils.ts +44 -6
  89. package/src/lib/shiki-rst.ts +185 -0
  90. package/src/lib/shiki.test.ts +153 -0
  91. package/src/lib/shiki.ts +292 -0
  92. package/src/lib/shuffle.ts +15 -1
  93. package/src/lib/sort.ts +15 -0
  94. package/src/lib/urls.ts +62 -0
  95. package/src/test-utils/render.ts +23 -0
  96. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  97. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  98. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  99. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  100. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  101. package/tests/helpers/env.ts +19 -0
  102. package/tests/integration/book-chapter-links.test.ts +107 -0
  103. package/tests/integration/book-index-cta.test.ts +87 -0
  104. package/tests/integration/books-nested-toc.test.ts +176 -0
  105. package/tests/integration/books.test.ts +3 -2
  106. package/tests/integration/code-block-features.test.ts +188 -0
  107. package/tests/integration/code-group.test.ts +183 -0
  108. package/tests/integration/code-notation.test.ts +97 -0
  109. package/tests/integration/github-alerts.test.ts +82 -0
  110. package/tests/integration/markdown-external-links.test.ts +103 -0
  111. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  112. package/tests/integration/reading-time-headings.test.ts +8 -6
  113. package/tests/integration/series-draft.test.ts +6 -13
  114. package/tests/integration/series-index-cta.test.ts +88 -0
  115. package/tests/integration/sync-vuepress-book.test.ts +443 -0
  116. package/tests/integration/vuepress-containers.test.ts +107 -0
  117. package/tests/tooling/new-post.test.ts +1 -1
  118. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  119. package/tests/unit/static-params.test.ts +32 -19
  120. package/vercel.json +7 -0
@@ -0,0 +1,149 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { normalizeVuepressBlockMath } from "../../src/lib/normalize-vuepress-math";
3
+ import MarkdownRenderer from "@/components/MarkdownRenderer";
4
+ import { renderAsync } from "@/test-utils/render";
5
+
6
+ describe("Integration: normalizeVuepressBlockMath", () => {
7
+ test("splits an inline-style $$ opener+closer onto their own lines", () => {
8
+ const src = [
9
+ "$$ \\mathbf{A} = \\begin{bmatrix}",
10
+ "a & b \\\\",
11
+ "c & d",
12
+ "\\end{bmatrix} $$",
13
+ ].join("\n");
14
+ const out = normalizeVuepressBlockMath(src);
15
+ expect(out.split("\n")).toEqual([
16
+ "$$",
17
+ "\\mathbf{A} = \\begin{bmatrix}",
18
+ "a & b \\\\",
19
+ "c & d",
20
+ "\\end{bmatrix}",
21
+ "$$",
22
+ ]);
23
+ });
24
+
25
+ test("expands single-line $$ x $$ onto three lines so remark-math treats it as block", () => {
26
+ // micromark-extension-math requires `$$` on its own line; single-line
27
+ // collapses to inline (no katex-display, no centering, no block margin).
28
+ const src = "$$ x^2 + y^2 = 1 $$";
29
+ expect(normalizeVuepressBlockMath(src).split("\n")).toEqual([
30
+ "$$",
31
+ "x^2 + y^2 = 1",
32
+ "$$",
33
+ ]);
34
+ });
35
+
36
+ test("leaves degenerate empty $$$$ alone", () => {
37
+ expect(normalizeVuepressBlockMath("$$$$")).toBe("$$$$");
38
+ expect(normalizeVuepressBlockMath("$$ $$")).toBe("$$ $$");
39
+ });
40
+
41
+ test("preserves the opener indent when expanding a single-line block", () => {
42
+ const src = " $$ y = mx + b $$";
43
+ expect(normalizeVuepressBlockMath(src).split("\n")).toEqual([
44
+ " $$",
45
+ " y = mx + b",
46
+ " $$",
47
+ ]);
48
+ });
49
+
50
+ test("does not touch inline $...$ math", () => {
51
+ const src = "An equation: $x = 1$ in the middle of a paragraph.";
52
+ expect(normalizeVuepressBlockMath(src)).toBe(src);
53
+ });
54
+
55
+ test("is idempotent — already-normalized blocks pass through unchanged", () => {
56
+ const src = ["$$", "x = 1", "$$"].join("\n");
57
+ expect(normalizeVuepressBlockMath(src)).toBe(src);
58
+ expect(normalizeVuepressBlockMath(normalizeVuepressBlockMath(src))).toBe(src);
59
+ });
60
+
61
+ test("skips $$ inside fenced code blocks (doc examples)", () => {
62
+ const src = [
63
+ "Here is the source:",
64
+ "",
65
+ "```",
66
+ "$$ \\mathbf{A} = \\begin{bmatrix} a \\end{bmatrix} $$",
67
+ "```",
68
+ "",
69
+ "Real math follows:",
70
+ "",
71
+ "$$ y = mx + b $$",
72
+ ].join("\n");
73
+ const out = normalizeVuepressBlockMath(src);
74
+ // The code-block example is preserved verbatim — no split.
75
+ expect(out).toContain("$$ \\mathbf{A} = \\begin{bmatrix} a \\end{bmatrix} $$");
76
+ // The real single-line block math after the fence is expanded onto its
77
+ // own three lines so remark-math recognizes it as block.
78
+ expect(out).toContain("$$\ny = mx + b\n$$");
79
+ expect(out).not.toContain("$$ y = mx + b $$");
80
+ });
81
+
82
+ test("preserves opener indent on split lines for list-nested block math", () => {
83
+ // A 4-space-indented block inside a bullet item. Without indent
84
+ // preservation, the split body lines drop out of the list and the
85
+ // following inline math gets parsed as one big malformed math span.
86
+ const src = [
87
+ "- Item with embedded math:",
88
+ "",
89
+ " $$\\mathbf{A} = \\begin{bmatrix}",
90
+ " a & b",
91
+ " \\end{bmatrix}$$",
92
+ "",
93
+ "- Next item with inline math: $\\mathbf{X}$, comma here.",
94
+ ].join("\n");
95
+ const out = normalizeVuepressBlockMath(src);
96
+ // Synthetic opener line carries the original 4-space indent so it stays
97
+ // inside the list item.
98
+ expect(out).toContain(" $$\n \\mathbf{A}");
99
+ // Closer's `$$` likewise stays indented.
100
+ expect(out).toContain(" \\end{bmatrix}\n $$");
101
+ });
102
+
103
+ test("handles multiple block-math runs in the same source", () => {
104
+ const src = [
105
+ "$$ a = 1",
106
+ "b = 2 $$",
107
+ "",
108
+ "Some prose.",
109
+ "",
110
+ "$$ c = 3",
111
+ "d = 4 $$",
112
+ ].join("\n");
113
+ const out = normalizeVuepressBlockMath(src);
114
+ // Both runs split, prose preserved.
115
+ expect(out.split(/^\$\$$/m).length).toBeGreaterThanOrEqual(5);
116
+ expect(out).toContain("Some prose.");
117
+ });
118
+ });
119
+
120
+ describe("Integration: end-to-end LaTeX rendering for VuePress-style block math", () => {
121
+ test("a multi-line bmatrix block renders as a katex-display, not katex-error", async () => {
122
+ const html = await renderAsync(
123
+ MarkdownRenderer({
124
+ content: [
125
+ "$$ \\mathbf{A} = \\begin{bmatrix}",
126
+ "a & b \\\\",
127
+ "c & d",
128
+ "\\end{bmatrix} $$",
129
+ ].join("\n"),
130
+ latex: true,
131
+ }),
132
+ );
133
+ expect(html).toContain("katex-display");
134
+ expect(html).not.toContain("katex-error");
135
+ });
136
+
137
+ test("normalization only runs when latex is true (idempotent so this is a perf hint)", async () => {
138
+ // With latex disabled, the math fences are passed through unchanged
139
+ // to ReactMarkdown — same input the engine would have always seen.
140
+ // We just verify the page renders without crashing.
141
+ const html = await renderAsync(
142
+ MarkdownRenderer({
143
+ content: "$$ x $$",
144
+ latex: false,
145
+ }),
146
+ );
147
+ expect(html).toContain("$$"); // not turned into math because latex was off
148
+ });
149
+ });
@@ -2,22 +2,24 @@ import { describe, expect, test } from "bun:test";
2
2
  import { getAllPosts, getPostBySlug } from "../../src/lib/markdown";
3
3
 
4
4
  describe("Integration: Reading Time & Headings", () => {
5
- test("posts have readingTime matching expected format", () => {
5
+ test("posts have a positive whole-minute readingMinutes", () => {
6
6
  const posts = getAllPosts();
7
7
  expect(posts.length).toBeGreaterThan(0);
8
8
 
9
9
  posts.forEach((post) => {
10
- expect(post.readingTime).toMatch(/^\d+ min read$/);
10
+ expect(Number.isInteger(post.readingMinutes)).toBe(true);
11
+ expect(post.readingMinutes).toBeGreaterThanOrEqual(1);
11
12
  });
12
13
  });
13
14
 
14
- test("kitchen-sink post has readingTime in correct format", () => {
15
+ test("kitchen-sink post has a positive readingMinutes", () => {
15
16
  const post = getPostBySlug("kitchen-sink");
16
17
  if (!post) {
17
18
  console.warn("Skipping: kitchen-sink post not found");
18
19
  return;
19
20
  }
20
- expect(post.readingTime).toMatch(/^\d+ min read$/);
21
+ expect(Number.isInteger(post.readingMinutes)).toBe(true);
22
+ expect(post.readingMinutes).toBeGreaterThanOrEqual(1);
21
23
  });
22
24
 
23
25
  test("headings on real posts have correct structure", () => {
@@ -49,13 +51,13 @@ describe("Integration: Reading Time & Headings", () => {
49
51
  });
50
52
  });
51
53
 
52
- test("short posts have 1 min read", () => {
54
+ test("short posts have readingMinutes === 1 (floor)", () => {
53
55
  const shortPost = getPostBySlug("legacy-markdown");
54
56
  expect(shortPost).toBeDefined();
55
57
  if (!shortPost) {
56
58
  throw new Error("fixture 'legacy-markdown' not found");
57
59
  }
58
- expect(shortPost.readingTime).toBe("1 min read");
60
+ expect(shortPost.readingMinutes).toBe(1);
59
61
  });
60
62
 
61
63
  test("multilingual post has headings with correct IDs", () => {
@@ -1,13 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { getSeriesData, getAllSeries } from "../../src/lib/markdown";
3
-
4
- function restoreEnvVar(key: string, value: string | undefined): void {
5
- if (value === undefined) {
6
- delete process.env[key];
7
- } else {
8
- process.env[key] = value;
9
- }
10
- }
3
+ import { setEnvVar, restoreEnvVar } from "../helpers/env";
11
4
 
12
5
  describe("Integration: Series Draft Support", () => {
13
6
  test("all series are included when NODE_ENV is not production", () => {
@@ -28,8 +21,8 @@ describe("Integration: Series Draft Support", () => {
28
21
  const originalEnv = process.env.NODE_ENV;
29
22
  const originalPythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
30
23
  try {
31
- process.env.NODE_ENV = "production";
32
- process.env.AMYTIS_ENABLE_PYTHON_RST = "0";
24
+ setEnvVar("NODE_ENV", "production");
25
+ setEnvVar("AMYTIS_ENABLE_PYTHON_RST", "0");
33
26
  // This should not throw; draft series are simply excluded
34
27
  const series = getAllSeries();
35
28
  expect(typeof series).toBe("object");
@@ -43,10 +36,10 @@ describe("Integration: Series Draft Support", () => {
43
36
  const originalEnv = process.env.NODE_ENV;
44
37
  const originalPythonRst = process.env.AMYTIS_ENABLE_PYTHON_RST;
45
38
  try {
46
- process.env.NODE_ENV = "production";
47
- process.env.AMYTIS_ENABLE_PYTHON_RST = "0";
39
+ setEnvVar("NODE_ENV", "production");
40
+ setEnvVar("AMYTIS_ENABLE_PYTHON_RST", "0");
48
41
  const allSeries = getAllSeries();
49
-
42
+
50
43
  // Verify that every series returned has draft: false (or undefined which defaults to false)
51
44
  Object.keys(allSeries).forEach(slug => {
52
45
  const seriesData = getSeriesData(slug);
@@ -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
+ });