// Patch notes come from the site (or its cached copy) and are untrusted: // Markdown is rendered with marked, then every byte of HTML goes through // DOMPurify with a small allowlist before it reaches {@html}. import { Marked, type Token, type TokensList } from "marked"; import DOMPurify from "dompurify"; const marked = new Marked({ gfm: true, breaks: false, async: false }); const ALLOWED_TAGS = [ "h1", "h2", "h3", "h4", "h5", "h6", "p", "br", "hr", "blockquote", "ul", "ol", "li", "strong", "em", "b", "i", "del", "s", "code", "pre", "kbd", "sub", "sup", "a", "table", "thead", "tbody", "tr", "th", "td", "input", // GFM task-list checkboxes (forced disabled below) ]; const ALLOWED_ATTR = ["href", "title", "align", "start", "type", "checked", "disabled"]; const SAFE_HREF = /^(https?:|mailto:|#)/i; DOMPurify.addHook("afterSanitizeAttributes", (node) => { if (node.tagName === "A") { const href = node.getAttribute("href") ?? ""; if (!SAFE_HREF.test(href.trim())) node.removeAttribute("href"); node.removeAttribute("target"); node.setAttribute("rel", "noopener noreferrer"); if (href && !node.getAttribute("title")) node.setAttribute("title", href); } if (node.tagName === "INPUT") { if (node.getAttribute("type") !== "checkbox") node.remove(); else node.setAttribute("disabled", ""); } }); function sanitize(html: string): string { return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR, ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false, }); } function render(tokens: Token[], links: TokensList["links"]): string { const list = Object.assign(tokens, { links }) as TokensList; return sanitize(marked.parser(list)); } function isRoadmapHeading(token: Token): boolean { return token.type === "heading" && token.depth === 2 && /^roadmap$/i.test(token.text.trim()); } export interface RenderedNotes { /** Everything except the Roadmap section. */ notes: string; /** Contents under `## Roadmap` (heading excluded), or null when absent/empty. */ roadmap: string | null; } export function renderNotes(markdown: string): RenderedNotes { const tokens = marked.lexer(markdown); const notes: Token[] = []; const roadmap: Token[] = []; let inRoadmap = false; let sawRoadmap = false; for (const token of tokens) { if (isRoadmapHeading(token)) { inRoadmap = true; sawRoadmap = true; continue; } if (inRoadmap && token.type === "heading" && token.depth <= 2) inRoadmap = false; (inRoadmap ? roadmap : notes).push(token); } const hasRoadmapContent = roadmap.some((t) => t.type !== "space"); return { notes: render(notes, tokens.links), roadmap: sawRoadmap && hasRoadmapContent ? render(roadmap, tokens.links) : null, }; }