import { safeHref, safeImageSrc } from '../components/utils/sanitize-url';
/**
* Block-level raw HTML in a Markdown document (e.g. a README's centred-logo
* `
![]()
`) is rendered through a strict allowlist rather
* than escaped, so common layout markup survives while script-bearing tags do
* not. Inline raw HTML stays escaped at the caller — balancing split open/close
* tag fragments through a sanitizer is unsafe.
*/
const ALLOWED_TAGS = [
'p', 'div', 'span', 'a', 'img', 'picture', 'source',
'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins', 'mark', 'small', 'abbr',
'code', 'pre', 'kbd', 'samp', 'br', 'hr', 'sub', 'sup',
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
'figure', 'figcaption', 'details', 'summary',
];
const ALLOWED_ATTR = [
'href', 'src', 'alt', 'title', 'align', 'width', 'height',
'colspan', 'rowspan', 'start', 'open',
];
interface PurifyHookNode {
tagName?: string;
getAttribute(name: string): string | null;
setAttribute(name: string, value: string): void;
removeAttribute(name: string): void;
hasAttribute(name: string): boolean;
}
interface PurifyLike {
sanitize(dirty: string, config: Record): string;
addHook(entryPoint: string, cb: (node: PurifyHookNode) => void): void;
}
/** Resolve a possibly-relative URL against the document's base, if known. */
function resolveUrl(url: string, base: string | undefined): string {
if (base === undefined || base === '') {
return url;
}
try {
return new URL(url, base).href;
} catch {
return url;
}
}
// `current.base` is read by the synchronous afterSanitizeAttributes hook. Because
// DOMPurify.sanitize runs synchronously, setting it immediately before each call
// is race-free even across concurrent renders. A const holder lets the hook share
// the active base without module-level reassignment. `current.purify` memoizes the
// loaded DOMPurify instance so the dynamic import and hook registration happen once.
const current: { base: string | undefined; purify: Promise | null } = {
base: undefined,
purify: null,
};
/** Harden a sanitized anchor: resolve its href, drop unsafe schemes, prevent tab-nabbing. */
function hardenAnchor(node: PurifyHookNode): void {
if (node.tagName !== 'A' || !node.hasAttribute('href')) {
return;
}
const safe = safeHref(resolveUrl(node.getAttribute('href') ?? '', current.base));
if (safe === null) {
node.removeAttribute('href');
return;
}
node.setAttribute('href', safe);
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer nofollow');
}
/** Harden a sanitized image: strip srcset and resolve/validate its src. */
function hardenImage(node: PurifyHookNode): void {
if (node.tagName !== 'IMG') {
return;
}
node.removeAttribute('srcset');
if (!node.hasAttribute('src')) {
return;
}
const safe = safeImageSrc(resolveUrl(node.getAttribute('src') ?? '', current.base));
if (safe === null) {
node.removeAttribute('src');
} else {
node.setAttribute('src', safe);
}
}
async function getPurify(): Promise {
if (current.purify === null) {
current.purify = import('dompurify').then(({ default: DOMPurify }) => {
const purify = DOMPurify as unknown as PurifyLike;
// Re-apply Blok's URL policy after sanitizing: resolve relative URLs to
// absolute, drop unsafe schemes, and harden anchors against tab-nabbing.
purify.addHook('afterSanitizeAttributes', (node) => {
hardenAnchor(node);
hardenImage(node);
});
return purify;
});
}
return current.purify;
}
/**
* Sanitize a block of raw HTML to a safe subset. Relative `href`/`src` values
* are resolved against `baseUrl` (the source document's URL) so README-relative
* assets load. Script-capable tags/attributes and unsafe URL schemes are
* stripped.
*/
export async function sanitizeBlockHtml(raw: string, baseUrl?: string): Promise {
const purify = await getPurify();
current.base = baseUrl;
return purify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR,
FORBID_ATTR: ['style'],
ALLOW_DATA_ATTR: false,
});
}