import { expect, test } from "bun:test"; import { renderHintsHtml, renderLessonHtml, sanitizeHtml } from "../src/web/markdown"; const opts = { courseId: "c", baseFile: "course/l.md", tracks: [], lessonFiles: {} }; const decode = (s: string) => s.replace(/&#x([0-9a-f]+);?/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))) .replace(/&#(\d+);?/g, (_, d) => String.fromCodePoint(Number(d))).replace(/:/gi, ":").replace(/&tab;/gi, "\t").replace(/&newline;/gi, "\n").replace(/&/g, "&"); /** Fails when real tags in `html` carry event handlers, style, a dangerous URL, or are script-capable elements. */ function expectSafe(html: string) { const tags = html.match(/<[a-zA-Z][^>]*>/g) ?? []; for (const t of tags) { expect(t).not.toMatch(/\son[a-z]+\s*=/i); expect(t).not.toMatch(/\sstyle\s*=/i); expect(t).not.toMatch(/^<(script|style|svg|math|iframe|object|embed|form|input|button|textarea|select|link|meta|base|frame|frameset)\b/i); for (const m of t.matchAll(/\s(?:href|src|action|formaction|xlink:href)\s*=\s*"([^"]*)"/gi)) { const url = decode(m[1]!).replace(/[\u0000- \u007f-\u009f]/g, "").toLowerCase(); expect(url).not.toMatch(/^(javascript|vbscript|data|file):/); } } // An unterminated tag must never swallow following markup into its attributes. expect(html).not.toMatch(/]*

or

", () => { for (const wrapper of ["details", "div"]) { const md = `<${wrapper}>\nThink first\n { for (const md of [ "bold", "
a\n\n", "ok'); }); test("
keeps the fold but drops the handler", () => { const html = renderLessonHtml('
Hint 1 — nudge\n\ntext\n\n
', opts); expectSafe(html); expect(html).toContain('
", body: " { const html = renderLessonHtml("A value of type IO or Stream.", opts); expect(html).toContain("IO<A>"); expect(html).toContain("Stream<Action>"); });