67 lines
2.4 KiB
TypeScript
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;
|
|
}
|