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
@@ -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=="],
|
||||
|
||||
@@ -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, "&").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");
|
||||
}
|
||||
@@ -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("<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><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(" 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>");
|
||||
});
|
||||
Reference in new issue
Block a user