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

@@ -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");
}