feat(learner): lesson markdown pipeline with track variants, folds, hint tiers and safe HTML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:29:14 +01:00
1 parent 9ff5862382
commit 99c0e4929f
4 files changed
+390 -6

No files matched your search

-5
View File
@@ -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=="],
-1
View File
@@ -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"
}
@@ -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.
* - `<details>` 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<string, string>;
/** 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
/** Keeps attribute-free allowlisted tags (and `<details open>`), escapes every other tag and comment. */
export function sanitizeHtml(html: string): string {
return html.replace(/<!--[\s\S]*?-->|<\/?[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<string, string[]> = {
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*<details(\s+open)?\s*>\s*(?:<summary>([\s\S]*?)<\/summary>)?/i;
interface FoldPlan { hint: boolean; group: string | null; tier: number; tiers: number }
/** Splits html tokens so each `<details>` opening and `</details>` 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>)|(?=<details[\s>])/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 `<details>` html tokens and groups consecutive hint folds into tiers. */
function planFolds(tokens: Token[]): Map<Token, FoldPlan> {
const plans = new Map<Token, FoldPlan>();
type Item = { tok: Token; hint: boolean; prefix: string | null; gapBefore: boolean };
const items: Item[] = [];
let sinceClose = true; // non-space content since the last closing </details>
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 = '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3.5 2l3 3-3 3"/></svg>';
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<string, number>();
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 `<h${depth} id="${escapeHtml(id)}">${this.parser.parseInline(inl)}</h${depth}>\n`;
},
code({ text, lang }: Tokens.Code) {
const l = (lang ?? "").trim().split(/\s+/)[0] ?? "";
const t = trackForLang(l, tracks);
return `<pre class="code"${l ? ` data-lang="${escapeHtml(l)}"` : ""}${t ? ` data-t="${escapeHtml(t)}"` : ""}><code>${escapeHtml(text)}</code></pre>\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 `<a href="${escapeHtml(r.url)}"${t}${r.external ? ' target="_blank" rel="noopener noreferrer"' : ""}>${inner}</a>`;
},
image({ href, title, text }: Tokens.Image) {
const r = resolveHref(href, opts, "image");
if (!r) return escapeHtml(text);
return `<img src="${escapeHtml(r.url)}" alt="${escapeHtml(text)}"${title ? ` title="${escapeHtml(title)}"` : ""} loading="lazy">`;
},
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 ? `<span class="tier">${plan.tier} of ${plan.tiers}</span>` : "";
const head = `<details class="fold"${attrs}><summary>${CHEVRON}<span class="sum">${summary}</span>${tier}</summary><div class="in">`;
text = head + sanitizeHtml(text.slice(m[0].length)).replace(/<\/details>/gi, "</div></details>");
return text;
}
if (/^\s*<\/details>/i.test(text)) return sanitizeHtml(text).replace(/<\/details>/i, "</div></details>");
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 ? `<span class="tier">${i + 1} of ${n}</span>` : "";
const attrs = n > 1 ? ` data-hint="1" data-group="ex" data-tier="${i + 1}" data-tiers="${n}"` : ' data-hint="1"';
return `<details class="fold"${attrs}><summary>${CHEVRON}<span class="sum">${escapeHtml(label)}</span>${tier}</summary><div class="in">${renderLessonHtml(h.body, opts)}</div></details>`;
}).join("\n");
}
@@ -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("<strong>Scala</strong>");
expect(scala).not.toContain("<strong>JavaScript</strong>");
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('<h2 id="why-this-module">');
expect(html).toContain('<h3 id="exercise-11--range">');
expect(html).toContain('<h2 id="why-this-module-1">');
});
test("details become folds; consecutive hints become ordered tiers", () => {
const md = `<details><summary>Think about it first, then open the explanation</summary>
All segments move **at once**.
</details>
## Hints
<details><summary>Exercise 1.1 (range) — nudge</summary>
N1
</details>
<details><summary>Exercise 1.1 (range) — stronger hint</summary>
S1
</details>
<details><summary>Exercise 1.2 (dropLast) — nudge</summary>
N2
</details>
`;
const html = renderLessonHtml(md, opts);
expect(html).toContain('<details class="fold"><summary>');
expect(html).toContain("<strong>at once</strong>");
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(/<details class="fold" data-hint="1"><summary>[\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 = "<details><summary>Hint 1 — nudge</summary>\n\na\n\n</details>\n<details><summary>Hint 2 — concept</summary>\n\nb\n\n</details>\n<details><summary>Hint 3 — pseudo-code</summary>\n\nc\n\n</details>\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('<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><details open>')).toBe("&lt;script&gt;alert(1)&lt;/script&gt;<kbd>x</kbd>&lt;img src=x onerror=alert(1)&gt;<details open>");
const html = renderLessonHtml('<div onclick="x()">hi</div>\n\n[x](javascript:alert(1)) <span style="color:red">s</span>', opts);
expect(html).not.toContain("<div");
expect(html).not.toContain("javascript:");
expect(html).not.toContain('<span 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('<img src="/api/media/pure-snake/course/img/board.png" alt="board" loading="lazy">');
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("<strong>operator</strong>");
});