Files
backnotprop__plannotator/packages/ui/utils/sanitizeHtml.ts
Michael Ramos ec551c83af fix(ui): stop unclosed <video>/<picture> from swallowing the document
An unclosed or self-closing <video>/<picture> ran the HTML-block scanner off
the end of the document, funneling every following heading/table/code block
into one opaque html block (gone from the TOC, unannotatable). The scanner now
only extends the block when it actually finds the matching close tag; otherwise
it keeps the block to the opening line. Also fixes multi-line <img> (was
truncated to a bare "<img" fragment) and allowlists srcset/media/sizes so
<picture>/<source> and responsive <img> render instead of rendering inert.

Reachable via GitHub PR description/comment media (#981) and plannotator
annotate on any .md file.
2026-07-01 10:57:38 -07:00

46 lines
2.0 KiB
TypeScript

import DOMPurify from 'dompurify';
import { marked } from 'marked';
const ALLOWED_TAGS = [
'sub', 'sup', 'b', 'i', 'em', 'strong', 'br', 'hr', 'p', 'span',
'del', 'ins', 'mark', 'small', 'abbr', 'kbd', 'var', 'samp',
'details', 'summary', 'blockquote', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'a', 'img', 'div', 'section', 'article', 'aside', 'header', 'footer',
'video', 'source', 'picture',
];
const ALLOWED_ATTR = [
'href', 'src', 'alt', 'title', 'rel', 'target', 'width', 'height', 'align',
'open', // preserve <details open> default-expanded state
// <video>/<source> — deliberately no `autoplay`, so embedded clips don't play
// on their own when a comment/description scrolls into view.
'controls', 'poster', 'muted', 'loop', 'playsinline', 'type',
// <picture>/<source> and responsive <img> — without these the alternate-format
// selection is inert and <source> renders as a no-op.
'srcset', 'media', 'sizes',
];
/**
* Render and sanitize the content of a raw HTML block for injection via
* innerHTML. Content is first run through `marked` so that markdown nested
* between HTML tags (e.g. `**bun**` inside `<details>…</details>`) renders
* as real `<strong>`, matching GitHub's flavored behavior. Then DOMPurify
* strips anything outside the allowlist — no event handlers, no inline
* styles, no scripts.
*/
export function sanitizeBlockHtml(html: string): string {
const rendered = marked.parse(html, { async: false, gfm: true, breaks: false }) as string;
return DOMPurify.sanitize(rendered, { ALLOWED_TAGS, ALLOWED_ATTR });
}
/**
* Sanitize inline HTML that is already HTML (no markdown pass) — same allowlist
* as {@link sanitizeBlockHtml}. Used for inline GitHub content (e.g. PR comment
* spans) so the allowlist lives in exactly one place.
*/
export function sanitizeInlineHtml(html: string): string {
return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR });
}