feat(course-format): markdown extraction, discovery, loading, repository
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7a68800f6f
commit
c78c7ec8d8
7 files changed
+558
-2
No files matched your search
@@ -0,0 +1,66 @@
|
||||
/** 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;
|
||||
}
|
||||
Reference in new issue
Block a user