@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
|
@@ -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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
+
});
|