import { expect, test } from "bun:test"; import { renderHintsHtml, renderLessonHtml, resolveHref, sanitizeHtml } from "../src/web/markdown"; const tracks = [{ id: "js", label: "JavaScript" }, { id: "scala", label: "Scala 3" }, { id: "fsharp", label: "F#" }]; const opts = { courseId: "pure-snake", baseFile: "course/01-purity.md", tracks, lessonFiles: { "course/02-data.md": "m02-text" } }; const variants = `Intro **JavaScript** \`\`\`js export const range = (n) => ... \`\`\` **Scala** \`\`\`scala def range(n: Int): List[Int] \`\`\` **F#** \`\`\`fsharp let range (n: int) : int list \`\`\` Tests: after. `; test("track switcher keeps only the chosen variant and its label", () => { const scala = renderLessonHtml(variants, { ...opts, track: "scala" }); expect(scala).toContain("def range"); expect(scala).not.toContain("export const range"); expect(scala).not.toContain("let range"); expect(scala).toContain("Scala"); expect(scala).not.toContain("JavaScript"); expect(scala).toContain("Tests: after."); const fs = renderLessonHtml(variants, { ...opts, track: "fsharp" }); expect(fs).toContain("let range"); expect(fs).not.toContain("def range"); }); test("a lone code block in one track stays visible for other tracks", () => { const md = "Text\n\n```js\nexport const pipe = 1\n```\n\nMore"; expect(renderLessonHtml(md, { ...opts, track: "scala" })).toContain("export const pipe"); }); test("a variant group without the chosen track keeps every variant", () => { const md = "```js\nA\n```\n\n```scala\nB\n```\n"; const html = renderLessonHtml(md, { ...opts, track: "fsharp" }); expect(html).toContain("A"); expect(html).toContain("B"); }); test("headings get GitHub ids and the first heading can be dropped", () => { const html = renderLessonHtml("# Module 1\n\n## Why this module\n\n### Exercise 1.1 — `range`\n\n## Why this module\n", { ...opts, stripFirstHeading: true }); expect(html).not.toContain("Module 1"); expect(html).toContain('

'); expect(html).toContain('

'); expect(html).toContain('

'); }); test("details become folds; consecutive hints become ordered tiers", () => { const md = `
Think about it first, then open the explanation All segments move **at once**.
## Hints
Exercise 1.1 (range) — nudge N1
Exercise 1.1 (range) — stronger hint S1
Exercise 1.2 (dropLast) — nudge N2
`; const html = renderLessonHtml(md, opts); expect(html).toContain('
'); expect(html).toContain("at once"); expect((html.match(/data-tier="1" data-tiers="2"/g) ?? []).length).toBe(1); expect(html).toContain('data-tier="2" data-tiers="2"'); expect(html).toContain("1 of 2"); // the 1.2 hint starts a new group of its own expect(html).toMatch(/
[\s\S]*Exercise 1\.2 \(dropLast\)/); expect((html.match(/<\/div><\/details>/g) ?? []).length).toBe(4); }); test("systemic-design hint numbering forms one group", () => { const md = "
Hint 1 — nudge\n\na\n\n
\n
Hint 2 — concept\n\nb\n\n
\n
Hint 3 — pseudo-code\n\nc\n\n
\n"; const html = renderLessonHtml(md, opts); expect(html).toContain('data-tier="3" data-tiers="3"'); }); test("raw HTML is reduced to safe tags", () => { expect(sanitizeHtml('x
')).toBe('<script>alert(1)</script>x
'); const html = renderLessonHtml('
hi
\n\n[x](javascript:alert(1)) s', opts); expect(html).toContain("
hi
"); expect(html).not.toContain("onclick"); expect(html).not.toContain("javascript:"); expect(html).not.toContain("style="); }); test("links and images resolve inside the course", () => { expect(resolveHref("02-data.md#state", opts, "link")).toEqual({ url: "/c/pure-snake/m02-text#state", external: false }); expect(resolveHref("../fixtures/board 1.json", opts, "link")).toEqual({ url: "/api/media/pure-snake/fixtures/board%201.json", external: false }); expect(resolveHref("../../etc/passwd", opts, "link")).toBeNull(); expect(resolveHref("https://example.com", opts, "link")).toEqual({ url: "https://example.com", external: true }); expect(resolveHref("img/a.png", opts, "image")).toEqual({ url: "/api/media/pure-snake/course/img/a.png", external: false }); const html = renderLessonHtml("![board](img/board.png) and [ext](https://x.org)", opts); expect(html).toContain('board'); expect(html).toContain('target="_blank" rel="noopener noreferrer"'); }); test("exercise hints render as one tier group", () => { const html = renderHintsHtml([{ label: "nudge", body: "Which **operator**?" }, { label: "near-solution", body: "`a + b`" }], { courseId: "sample", baseFile: null }); expect(html).toContain("Nudge"); expect(html).toContain('data-tier="2" data-tiers="2"'); expect(html).toContain("operator"); });