diff --git a/bun.lock b/bun.lock index fd7707b..a0be2cc 100644 --- a/bun.lock +++ b/bun.lock @@ -60,7 +60,6 @@ "dependencies": { "@better-auth/sso": "^1.7.14", "better-auth": "^1.7.14", - "dompurify": "^3.4.17", "marked": "^18.1.2", "pdfjs-dist": "^6.4.299", }, @@ -165,8 +164,6 @@ "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.9.12", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="], @@ -183,8 +180,6 @@ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], - "dompurify": ["dompurify@3.4.17", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], diff --git a/packages/feature-learner/package.json b/packages/feature-learner/package.json index 9238993..5da2328 100644 --- a/packages/feature-learner/package.json +++ b/packages/feature-learner/package.json @@ -7,7 +7,6 @@ "dependencies": { "@better-auth/sso": "^1.7.14", "better-auth": "^1.7.14", - "dompurify": "^3.4.17", "marked": "^18.1.2", "pdfjs-dist": "^6.4.299" } diff --git a/packages/feature-learner/src/web/markdown.ts b/packages/feature-learner/src/web/markdown.ts new file mode 100644 index 0000000..7f51718 --- /dev/null +++ b/packages/feature-learner/src/web/markdown.ts @@ -0,0 +1,264 @@ +import { Marked, type Token, type Tokens } from "marked"; +import { slugify } from "@stc/course-format"; +import { mediaUrl } from "../shared/types"; + +/** + * Lesson Markdown → HTML for the reader. + * + * - Raw HTML is reduced to an allowlist of attribute-free tags (`details`, `summary`, `kbd`, …); every other + * tag is shown as text, so course files cannot inject script or styles. Link and image URLs are limited to + * http(s), mailto, in-page anchors and course-relative paths. + * - Consecutive code blocks in two or more track languages (and their bold "**Scala**" labels) form a + * variant group; only the chosen track's variant is kept. A group without that track keeps every variant. + * - `
` become folds. Consecutive hint folds ("… — nudge", "Hint 2 — concept") become one tier group + * (`data-tier`, `data-tiers`) that the reader opens in order. + * - Relative links to another lesson file become reader links; other relative paths go to `/api/media`. + */ + +export interface RenderOptions { + courseId: string; + baseFile: string | null; + tracks?: { id: string; label: string }[]; + track?: string | null; + lessonFiles?: Record; + /** Drop a leading heading (the page shows the lesson title itself). */ + stripFirstHeading?: boolean; +} + +const ALLOWED = new Set(["details", "summary", "br", "kbd", "sub", "sup", "em", "strong", "b", "i", "u", "mark", "small", "code", "s", "del", "ins", "abbr", "hr", "p", "span", "ul", "ol", "li", "blockquote", "pre", "dl", "dt", "dd", "table", "thead", "tbody", "tr", "th", "td"]); + +const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +/** Keeps attribute-free allowlisted tags (and `
`), escapes every other tag and comment. */ +export function sanitizeHtml(html: string): string { + return html.replace(/|<\/?[A-Za-z][^>]*>|<(?![A-Za-z/!])/g, (tag) => { + const m = /^<(\/?)([A-Za-z][A-Za-z0-9]*)\s*(open)?\s*(\/?)>$/.exec(tag); + if (m && ALLOWED.has(m[2]!.toLowerCase()) && (!m[3] || m[2]!.toLowerCase() === "details")) return tag.toLowerCase().replace(/\s*\/>$/, ">"); + return escapeHtml(tag); + }); +} + +const LANG_ALIASES: Record = { + js: ["js", "javascript", "mjs", "cjs", "jsx", "node"], + ts: ["ts", "typescript", "tsx"], + scala: ["scala", "scala3", "sc"], + fsharp: ["fsharp", "fs", "f#", "fsx"], + rust: ["rust", "rs"], + python: ["python", "py"], + go: ["go", "golang"], + java: ["java"], + kotlin: ["kotlin", "kt"], + csharp: ["csharp", "cs", "c#"], +}; + +/** Track id for a code fence language, when the course has that track. */ +export function trackForLang(lang: string | undefined, tracks: { id: string; label: string }[]): string | null { + if (!lang) return null; + const l = lang.trim().split(/\s+/)[0]!.toLowerCase(); + for (const t of tracks) { + const aliases = new Set([t.id.toLowerCase(), t.label.toLowerCase(), ...(LANG_ALIASES[t.id.toLowerCase()] ?? [])]); + for (const [k, v] of Object.entries(LANG_ALIASES)) if (v.includes(t.id.toLowerCase()) || v.includes(t.label.toLowerCase())) { aliases.add(k); for (const x of v) aliases.add(x); } + if (aliases.has(l)) return t.id; + } + return null; +} + +/** Track id for a paragraph that is only a bold track label, such as "**Scala**" or "**F#**". */ +function trackForLabel(tok: Token, tracks: { id: string; label: string }[]): string | null { + if (tok.type !== "paragraph") return null; + const p = tok as Tokens.Paragraph; + if (p.tokens?.length !== 1 || p.tokens[0]!.type !== "strong") return null; + const text = (p.tokens[0] as Tokens.Strong).text.trim().replace(/:$/, "").toLowerCase(); + for (const t of tracks) { + const label = t.label.toLowerCase(); + if (text === label || label.startsWith(text + " ") || text === t.id.toLowerCase() || trackForLang(text, [t]) === t.id) return t.id; + } + return null; +} + +function trackOf(tok: Token, tracks: { id: string; label: string }[]): string | null { + if (tok.type === "code") return trackForLang((tok as Tokens.Code).lang, tracks); + return trackForLabel(tok, tracks); +} + +/** Removes the other tracks' variants from groups of consecutive track-tagged blocks. */ +export function selectTrack(tokens: Token[], tracks: { id: string; label: string }[], track: string | null | undefined): Token[] { + if (tracks.length < 2 || !track) return tokens; + const out: Token[] = []; + let i = 0; + while (i < tokens.length) { + if (trackOf(tokens[i]!, tracks) === null) { out.push(tokens[i]!); i++; continue; } + let j = i; + const run: Token[] = []; + while (j < tokens.length && (tokens[j]!.type === "space" || trackOf(tokens[j]!, tracks) !== null)) run.push(tokens[j++]!); + while (run.length && run[run.length - 1]!.type === "space") { run.pop(); j--; } + const present = new Set(run.map((t) => trackOf(t, tracks)).filter(Boolean)); + if (present.size >= 2 && present.has(track)) out.push(...run.filter((t) => t.type === "space" || trackOf(t, tracks) === track)); + else out.push(...run); + i = j; + } + return out; +} + +const HINT = /\b(hint|nudge|stronger|near[- ]?solution|solution)\b/i; +const OPEN_DETAILS = /^\s*\s*(?:([\s\S]*?)<\/summary>)?/i; + +interface FoldPlan { hint: boolean; group: string | null; tier: number; tiers: number } + +/** Splits html tokens so each `
` opening and `
` closing sits at the start of its own token. */ +function splitHtmlTokens(tokens: Token[]): Token[] { + const out: Token[] = []; + for (const tok of tokens) { + if (tok.type !== "html") { out.push(tok); continue; } + const parts = (tok as Tokens.HTML).text.split(/(?=<\/details>)|(?<=<\/details>)|(?=])/i).filter((p) => p.trim() !== ""); + if (parts.length <= 1) { out.push(tok); continue; } + for (const p of parts) out.push({ ...(tok as Tokens.HTML), raw: p, text: p } as Token); + } + return out; +} + +/** Plans folds for top-level `
` html tokens and groups consecutive hint folds into tiers. */ +function planFolds(tokens: Token[]): Map { + const plans = new Map(); + type Item = { tok: Token; hint: boolean; prefix: string | null; gapBefore: boolean }; + const items: Item[] = []; + let sinceClose = true; // non-space content since the last closing
+ for (const tok of tokens) { + if (tok.type === "html" && OPEN_DETAILS.test((tok as Tokens.HTML).text)) { + const summary = (OPEN_DETAILS.exec((tok as Tokens.HTML).text)![2] ?? "").replace(/<[^>]+>/g, "").trim(); + const dash = summary.split(/\s+[—–-]\s+/); + const prefix = dash.length > 1 ? dash[0]!.replace(/\s*\d+$/, "").trim().toLowerCase() : null; + items.push({ tok, hint: HINT.test(summary), prefix, gapBefore: sinceClose }); + sinceClose = false; + if (/<\/details>\s*$/i.test((tok as Tokens.HTML).text)) sinceClose = false; + continue; + } + if (tok.type === "html" && /^\s*<\/details>/i.test((tok as Tokens.HTML).text)) { sinceClose = false; continue; } + if (tok.type !== "space") sinceClose = true; + } + let g = 0; + let current: Item[] = []; + const flush = () => { + const id = `h${g++}`; + current.forEach((it, k) => plans.set(it.tok, { hint: true, group: current.length > 1 ? id : null, tier: k + 1, tiers: current.length })); + current = []; + }; + for (const it of items) { + if (!it.hint) { if (current.length) flush(); plans.set(it.tok, { hint: false, group: null, tier: 0, tiers: 0 }); continue; } + const prev = current[current.length - 1]; + if (prev && (it.gapBefore || (it.prefix !== null && prev.prefix !== null && it.prefix !== prev.prefix))) flush(); + current.push(it); + } + if (current.length) flush(); + return plans; +} + +const CHEVRON = ''; + +function dirOf(file: string | null): string { + if (!file) return ""; + const i = file.lastIndexOf("/"); + return i < 0 ? "" : file.slice(0, i + 1); +} + +/** Resolves `rel` against `base` (both course-relative), or null if it climbs above the course root. */ +export function joinPath(base: string, rel: string): string | null { + const parts = (base + rel).split("/"); + const out: string[] = []; + for (const p of parts) { + if (p === "" || p === ".") continue; + if (p === "..") { if (!out.length) return null; out.pop(); } else out.push(p); + } + return out.join("/"); +} + +function safeDecode(s: string): string { + try { return decodeURI(s); } catch { return s; } +} + +export function resolveHref(href: string, opts: RenderOptions, kind: "link" | "image"): { url: string; external: boolean } | null { + const h = href.trim(); + if (/^https?:\/\//i.test(h)) return { url: h, external: true }; + if (kind === "link" && /^mailto:/i.test(h)) return { url: h, external: true }; + if (kind === "image" && /^data:image\/(png|jpe?g|gif|webp);/i.test(h)) return { url: h, external: false }; + if (h.startsWith("#")) return kind === "link" ? { url: h, external: false } : null; + if (/^[a-z][a-z0-9+.-]*:/i.test(h) || h.startsWith("//")) return null; // javascript:, file:, protocol-relative … + const [pathPart, hash] = h.split("#", 2) as [string, string | undefined]; + const rel = h.startsWith("/") ? joinPath("", safeDecode(pathPart)) : joinPath(dirOf(opts.baseFile), safeDecode(pathPart)); + if (rel === null) return null; + const lessonId = kind === "link" ? opts.lessonFiles?.[rel] : undefined; + if (lessonId) return { url: `/c/${encodeURIComponent(opts.courseId)}/${encodeURIComponent(lessonId)}${hash ? `#${hash}` : ""}`, external: false }; + return { url: mediaUrl(opts.courseId, rel) + (hash ? `#${hash}` : ""), external: false }; +} + +export function renderLessonHtml(md: string, opts: RenderOptions): string { + const tracks = opts.tracks ?? []; + const marked = new Marked({ gfm: true }); + let tokens: Token[] = marked.lexer(md); + if (opts.stripFirstHeading) { + const first = tokens.findIndex((t) => t.type !== "space"); + if (first >= 0 && tokens[first]!.type === "heading") tokens = [...tokens.slice(0, first), ...tokens.slice(first + 1)]; + } + tokens = splitHtmlTokens(selectTrack(tokens, tracks, opts.track ?? tracks[0]?.id)); + const folds = planFolds(tokens); + const slugs = new Map(); + + const renderer = { + heading(this: { parser: { parseInline(t: Token[]): string } }, { tokens: inl, depth, text }: Tokens.Heading) { + const base = slugify(text); + const n = slugs.get(base) ?? 0; + slugs.set(base, n + 1); + const id = n === 0 ? base : `${base}-${n}`; + return `${this.parser.parseInline(inl)}\n`; + }, + code({ text, lang }: Tokens.Code) { + const l = (lang ?? "").trim().split(/\s+/)[0] ?? ""; + const t = trackForLang(l, tracks); + return `
${escapeHtml(text)}
\n`; + }, + link(this: { parser: { parseInline(t: Token[]): string } }, { href, title, tokens: inl }: Tokens.Link) { + const inner = this.parser.parseInline(inl); + const r = resolveHref(href, opts, "link"); + if (!r) return inner; + const t = title ? ` title="${escapeHtml(title)}"` : ""; + return `${inner}`; + }, + image({ href, title, text }: Tokens.Image) { + const r = resolveHref(href, opts, "image"); + if (!r) return escapeHtml(text); + return `${escapeHtml(text)}`; + }, + html(token: Tokens.HTML | Tokens.Tag) { + const plan = folds.get(token as Token); + let text = token.text; + if (plan) { + const m = OPEN_DETAILS.exec(text)!; + const summary = sanitizeHtml(m[2] ?? (plan.hint ? "Hint" : "Details")); + const attrs = plan.hint + ? ` data-hint="1"${plan.group ? ` data-group="${plan.group}" data-tier="${plan.tier}" data-tiers="${plan.tiers}"` : ""}` + : ""; + const tier = plan.group ? `${plan.tier} of ${plan.tiers}` : ""; + const head = `
${CHEVRON}${summary}${tier}
`; + text = head + sanitizeHtml(text.slice(m[0].length)).replace(/<\/details>/gi, "
"); + return text; + } + if (/^\s*<\/details>/i.test(text)) return sanitizeHtml(text).replace(/<\/details>/i, "
"); + return sanitizeHtml(text); + }, + }; + marked.use({ renderer: renderer as never }); + return marked.parser(tokens) as string; +} + +const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s); + +/** Exercise hints (already matched by the server) as one tier group of folds. */ +export function renderHintsHtml(hints: { label: string; body: string }[], opts: RenderOptions): string { + const n = hints.length; + return hints.map((h, i) => { + const label = h.label ? cap(h.label) : `Hint ${i + 1}`; + const tier = n > 1 ? `${i + 1} of ${n}` : ""; + const attrs = n > 1 ? ` data-hint="1" data-group="ex" data-tier="${i + 1}" data-tiers="${n}"` : ' data-hint="1"'; + return `
${CHEVRON}${escapeHtml(label)}${tier}
${renderLessonHtml(h.body, opts)}
`; + }).join("\n"); +} diff --git a/packages/feature-learner/test/markdown.test.ts b/packages/feature-learner/test/markdown.test.ts new file mode 100644 index 0000000..604b444 --- /dev/null +++ b/packages/feature-learner/test/markdown.test.ts @@ -0,0 +1,126 @@ +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<img src=x onerror=alert(1)>
"); + const html = renderLessonHtml('
hi
\n\n[x](javascript:alert(1)) s', opts); + expect(html).not.toContain(" { + 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"); +});