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:
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
/** 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");
|
||||
}
|
||||
Reference in new issue
Block a user