/**
* 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 `
\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 ``;
}
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 `