/** Markup that is already safe: built by `html`, or rendered by markdown-it with raw HTML off. */ export class Html { constructor(readonly value: string) {} toString(): string { return this.value; } } export type Part = Html | string | number | null | undefined | false | Part[]; const ENTITIES: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; /** The one escaping helper (ยง18): every interpolated value goes through it unless it is `Html`. */ export function escapeHtml(text: string): string { return text.replace(/[&<>"']/g, (c) => ENTITIES[c] as string); } function render(part: Part): string { if (part instanceof Html) return part.value; if (Array.isArray(part)) return part.map(render).join(""); if (part === null || part === undefined || part === false) return ""; return escapeHtml(String(part)); } export function html(strings: TemplateStringsArray, ...values: Part[]): Html { let out = strings[0] as string; values.forEach((value, i) => { out += render(value) + strings[i + 1]; }); return new Html(out); } /** Trusts markup produced elsewhere; only for markdown-it output. */ export function trusted(markup: string): Html { return new Html(markup); }