/** * Markdown → sanitized HTML for the preview. Every top-level block is wrapped * in an element carrying its 0-based source line (`data-line`) for scroll * sync; images keep their source in `data-src` and are loaded by the preview * (local files through the backend, as the CSP blocks file URLs). Mermaid * blocks become `[data-mermaid]` placeholders, drawn by `./mermaid`. YAML front matter is shown * as a table of its top-level fields. */ import { Marked, type Token, type TokensList } from "marked"; import DOMPurify from "dompurify"; const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); const marked = new Marked({ gfm: true, breaks: false, renderer: { code({ text, lang }) { if (lang?.trim().split(/\s+/)[0]?.toLowerCase() !== "mermaid") return false; return `
${escapeHtml(text)}
\n`; }, }, }); // Own instance: hooks must not leak into the hover / documentation renderer. let purifier: ReturnType | null = null; function purify(): ReturnType { if (purifier) return purifier; const p = DOMPurify(window); p.addHook("afterSanitizeAttributes", (node) => { if (node.tagName === "IMG") { const src = node.getAttribute("src"); node.removeAttribute("src"); if (src) node.setAttribute("data-src", src); } if (node.tagName === "A" && node.getAttribute("href")) node.setAttribute("rel", "noopener noreferrer"); }); purifier = p; return p; } /** * Heading ids carry GitHub's prefix: bare ids like `title` would clobber * `document` properties, so the sanitizer drops them. */ export const ANCHOR_PREFIX = "user-content-"; /** GitHub-style heading anchor: lower case, punctuation dropped, spaces → dashes. */ export function slugify(text: string): string { return text .trim() .toLowerCase() .replace(/<[^>]*>/g, "") .replace(/[^\p{L}\p{N}\s_-]/gu, "") .replace(/\s/g, "-"); } const countLines = (s: string) => { let n = 0; for (let i = s.indexOf("\n"); i >= 0; i = s.indexOf("\n", i + 1)) n++; return n; }; /** The YAML front matter at the start of `text`: its top-level fields and how many lines it spans. */ export function frontMatter(text: string): { fields: [string, string][]; lines: number } | null { const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/.exec(text); if (!m) return null; const fields: [string, string][] = []; for (const raw of m[1]!.split(/\r?\n/)) { const field = /^([^\s#:-][^:]*?):(?:[ \t]+(.*))?$/.exec(raw); if (field) fields.push([field[1]!.trim(), (field[2] ?? "").trim()]); else if (raw.trim() && !raw.trimStart().startsWith("#")) { // A nested mapping or a list belongs to the field above it; anything else isn't front matter. const last = fields.at(-1); if (!last || !/^[\s-]/.test(raw)) return null; last[1] = last[1] ? `${last[1]}\n${raw}` : raw; } } return fields.length ? { fields, lines: countLines(m[0]) + (m[0].endsWith("\n") ? 0 : 1) } : null; } /** `text` without the indentation all its lines share. */ function dedent(text: string): string { const lines = text.split("\n"); const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => /^ */.exec(l)![0].length)); return lines.map((l) => l.slice(indent)).join("\n"); } function frontMatterTable(fields: readonly [string, string][]): string { const rows = fields.map(([key, value]) => { // Nested values keep their YAML layout. const shown = value.includes("\n") ? `
${escapeHtml(dedent(value.replace(/^\n+/, "")))}
` : escapeHtml(value); return `${escapeHtml(key)}${shown}`; }); return `${rows.join("")}
`; } export function renderMarkdown(text: string): string { const front = frontMatter(text); const body = front ? text.split("\n").slice(front.lines).join("\n") : text; const tokens = marked.lexer(body); const slugs = new Map(); let line = front?.lines ?? 0; let html = front ? `
${frontMatterTable(front.fields)}
` : ""; for (const token of tokens) { const start = line; line += countLines(token.raw); if (token.type === "space") continue; const list = Object.assign([token], { links: tokens.links }) as TokensList; let body = marked.parser(list); if (token.type === "heading") { const base = slugify((token as Token & { text: string }).text); const n = slugs.get(base) ?? 0; slugs.set(base, n + 1); body = body.replace(/^/, ``); } html += `
${body}
`; } // The preview shares the IDE's document: a `