Files
STCourses/packages/course-format/src/markdown.ts
T

67 lines
2.4 KiB
TypeScript

/** GitHub-style heading slug: lowercase, markdown stripped, punctuation dropped, spaces to hyphens. */
export function slugify(heading: string): string {
return heading
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // links and images keep their text
.replace(/[`*~]/g, "") // code spans and emphasis markers
.replace(/<[^>]+>/g, "") // inline html
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, "")
.replace(/\s/g, "-");
}
interface Heading {
line: number;
level: number;
text: string;
}
function headings(lines: string[]): Heading[] {
const out: Heading[] = [];
let fence: string | null = null;
lines.forEach((line, i) => {
const f = /^ {0,3}(`{3,}|~{3,})/.exec(line);
if (f) {
const marker = f[1]![0]!;
if (fence === null) fence = marker;
else if (fence === marker) fence = null;
return;
}
if (fence !== null) return;
const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
if (h) out.push({ line: i, level: h[1]!.length, text: h[2]! });
});
return out;
}
/** Heading line plus everything up to the next heading of the same or a higher level. */
export function extractSection(markdown: string, anchor: string): string | null {
const lines = markdown.split(/\r?\n/);
const hs = headings(lines);
const seen = new Map<string, number>();
for (let i = 0; i < hs.length; i++) {
const h = hs[i]!;
const base = slugify(h.text);
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
const slug = n === 0 ? base : `${base}-${n}`; // GitHub numbers duplicates
if (slug !== anchor) continue;
const next = hs.slice(i + 1).find((x) => x.level <= h.level);
return lines.slice(h.line, next ? next.line : lines.length).join("\n").trimEnd();
}
return null;
}
/** `<details><summary>…</summary>…</details>` blocks whose summary starts with `match`, in document order. */
export function extractHints(markdown: string, match: string): { label: string; body: string }[] {
const out: { label: string; body: string }[] = [];
const re = /<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/g;
for (const m of markdown.matchAll(re)) {
const summary = m[1]!.trim();
if (!summary.startsWith(match)) continue;
const label = summary.slice(match.length).replace(/^\s*(?:—|-|\()\s*/, "").trim();
out.push({ label, body: m[2]!.trim() });
}
return out;
}