import { Marked } from "marked";
/** Escape the five characters that can break out of text or an attribute value. */
function escapeHtml(value: unknown): string {
return (
String(value ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
// Single quotes too, so the escaper is self-sufficient. Every attribute
// here is double-quoted today, which makes this redundant — but that is a
// formatting convention enforced nowhere, and the day someone writes
// title='...' it becomes the difference between escaping and a breakout.
.replace(/'/g, "'")
);
}
/**
* URL allowlist. Blocklists lose: `javascript:` alone misses `JaVaScRiPt:`,
* the entity-encoded `javascript:`, `vbscript:` and `data:text/html`.
* Permitting only what we understand is both shorter and complete.
*/
const SAFE_SCHEME = /^(https?:|mailto:)/i;
function safeUrl(raw: unknown): string | null {
const url = String(raw ?? "").trim();
if (url === "") {
return null;
}
// Protocol-relative. Harmless over https, but the Part 3b standalone export
// is opened via file://, where //host/path is a UNC path — on Windows that
// opens an SMB connection and leaks an NTLM hash, and images need no click.
// Both separators must be rejected: the URL spec treats /\ exactly like //.
if (/^[/\\]{2}/.test(url)) {
return null;
}
if (url.startsWith("#") || url.startsWith("/") || url.startsWith("./") || url.startsWith("../")) {
return url;
}
return SAFE_SCHEME.test(url) ? url : null;
}
const renderer = new Marked({
renderer: {
// Raw HTML in the source is shown as text, never rendered.
html({ text }) {
return escapeHtml(text);
},
link({ href, title, tokens }) {
// `tokens`, not `text` — `text` is the raw markdown source, so returning
// it would emit author HTML unescaped and swallow inline formatting.
const inner = this.parser.parseInline(tokens);
const safe = safeUrl(href);
if (safe === null) {
return inner;
}
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return `${inner}`;
},
image({ href, title, text }) {
const safe = safeUrl(href);
if (safe === null) {
return escapeHtml(text);
}
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return `
`;
},
},
});
/** Render a note's markdown to HTML with all author-supplied HTML escaped. */
export function renderNote(markdown: string | null): string {
if (markdown === null || markdown.trim() === "") {
return "";
}
return renderer.parse(markdown) as string;
}