has no parent)
*/
const sanitizeChildren = (
parent: P5ParentNode,
config: SanitizerConfig,
parentIsBlock: boolean,
parentIsTop: boolean,
transform: FragmentUrlTransform | undefined
): void => {
/**
* Handle one child; returns the index to continue scanning from
* (0 after any structural mutation — janitor restarts the parent walk).
* @param index - position of the child in parent.childNodes
*/
const sanitizeChildAt = (index: number): number => {
const node = parent.childNodes[index];
if (isTextNode(node)) {
const strippable = node.value.trim() === ''
&& (isBlockElement(elementSibling(parent, index, -1)) || isBlockElement(elementSibling(parent, index, 1)));
if (!strippable) {
return index + 1;
}
parent.childNodes.splice(index, 1);
return 0;
}
if (node.nodeName === '#comment') {
parent.childNodes.splice(index, 1);
return 0;
}
if (!isElementNode(node)) {
return index + 1;
}
const tagName = node.tagName.toLowerCase();
const resolution = resolveRule(config[tagName], node);
const isInvalidInline = INLINE_ELEMENT_NAMES.has(tagName) && node.childNodes.some(isBlockElement);
const isNestedBlock = parentIsBlock && !parentIsTop && BLOCK_ELEMENT_NAMES.has(tagName);
if (resolution.action === 'unwrap' || isInvalidInline || isNestedBlock) {
// janitor drops SCRIPT/STYLE contents instead of hoisting them
const hoisted = tagName === 'script' || tagName === 'style' ? [] : node.childNodes;
for (const child of hoisted) {
child.parentNode = parent;
}
parent.childNodes.splice(index, 1, ...hoisted);
return 0;
}
filterAttributes(node, resolution.attrs);
applyUrlPolicy(node, transform);
if (isTemplateNode(node)) {
// Stricter than janitor (which cannot see template content via its
// TreeWalker): parse5 serializes template.content, so it must be
// sanitized or an allowed
would leak arbitrary markup.
sanitizeChildren(node.content, config, false, true, transform);
}
sanitizeChildren(node, config, BLOCK_ELEMENT_NAMES.has(tagName), false, transform);
return index + 1;
};
const scan = (index: number): void => {
if (index >= parent.childNodes.length) {
return;
}
scan(sanitizeChildAt(index));
};
scan(0);
};
/**
* Build the shared normalization policy's element view from a parse5 element.
* @param node - parse5 element to describe
*/
const inlineViewOf = (node: P5Element): InlineElementView => {
const hasVoidContentDescendant = (candidate: P5Element): boolean =>
candidate.childNodes.some(
(child) =>
isElementNode(child) &&
(VOID_CONTENT_TAGS.has(child.tagName.toUpperCase()) || hasVoidContentDescendant(child))
);
return {
tagName: node.tagName.toUpperCase(),
attributes: node.attrs.map((attr) => ({ name: attr.name, value: attr.value })),
styleDeclarations: parseStyleText(getAttr(node, 'style') ?? ''),
text: () => collectText(node),
hasVoidContentDescendant: () => hasVoidContentDescendant(node),
};
};
/**
* One normalization sweep over a parse5 subtree. Mirrors the DOM
* implementation in `src/components/utils/inline-normalization.ts` — the two
* share every decision via the policy module, and differ only in tree
* mechanics. Returns whether anything changed so the caller can run to a
* fixpoint.
*
* `` content is deliberately left alone: the DOM twin normalizes via
* `querySelectorAll`, which does not descend into template content either.
* @param parent - subtree to normalize in place
* @param ancestors - views of the enclosing wrappers, innermost first
*/
const normalizeInlineSweep = (parent: P5ParentNode, ancestors: InlineElementView[]): boolean => {
/**
* Hoist a redundant wrapper's children into its place.
* @param node - wrapper to unwrap
* @param index - its position among the parent's children
*/
const unwrapAt = (node: P5Element, index: number): void => {
for (const child of node.childNodes) {
child.parentNode = parent;
}
parent.childNodes.splice(index, 1, ...node.childNodes);
};
/**
* Walk the children once, unwrapping wrappers that decorate nothing or
* repeat an ancestor. Rescans from the same index after a mutation.
* @param index - child position to examine
* @returns whether anything was unwrapped from here on
*/
const unwrapFrom = (index: number): boolean => {
if (index >= parent.childNodes.length) {
return false;
}
const node = parent.childNodes[index];
if (!isElementNode(node) || !MERGEABLE_TAGS.has(node.tagName.toUpperCase())) {
return unwrapFrom(index + 1);
}
const view = inlineViewOf(node);
if (decoratesNothing(view) || duplicatesAncestor(view, ancestors)) {
unwrapAt(node, index);
unwrapFrom(index);
return true;
}
return unwrapFrom(index + 1);
};
/**
* Merge each child with the following sibling while the two express the
* same formatting.
* @param index - child position to examine
* @returns whether anything was merged from here on
*/
const mergeFrom = (index: number): boolean => {
if (index >= parent.childNodes.length - 1) {
return false;
}
const left = parent.childNodes[index];
const right = parent.childNodes[index + 1];
const mergeable =
isElementNode(left) && isElementNode(right) && areInterchangeable(inlineViewOf(left), inlineViewOf(right));
if (!mergeable) {
return mergeFrom(index + 1);
}
for (const child of right.childNodes) {
child.parentNode = left;
left.childNodes.push(child);
}
parent.childNodes.splice(index + 1, 1);
mergeFrom(index);
return true;
};
const unwrapped = unwrapFrom(0);
const descended = [...parent.childNodes]
.map((node) => isElementNode(node) && normalizeInlineSweep(node, [inlineViewOf(node), ...ancestors]))
.some(Boolean);
const merged = mergeFrom(0);
return unwrapped || descended || merged;
};
/**
* Collapse redundant inline markup in a parse5 fragment, in place.
* @param fragment - parsed fragment to normalize
*/
const normalizeInlineMarkupFragment = (fragment: P5ParentNode): void => {
/**
* @param remaining - sweeps left before the safety valve trips
*/
const runSweeps = (remaining: number): void => {
if (remaining === 0 || !normalizeInlineSweep(fragment, [])) {
return;
}
runSweeps(remaining - 1);
};
runSweeps(MAX_NORMALIZATION_SWEEPS);
};
/**
* The characters a parse-and-serialize round trip can change: `<`, `>` and `&`
* come back escaped, U+00A0 comes back as ` `, a CR becomes a newline and
* a NUL is dropped. A fragment holding none of them serializes to exactly what
* went in, so it is returned unparsed. Nothing else may be added here — a tab,
* a form feed, a lone surrogate and an astral emoji all survive the round trip
* unchanged and must keep taking the fast path.
*
* This is most fields of a prose document, and parse5 is a character-by-
* character tokenizer: the server package runs this on an interpreter, where
* one skipped parse per field is the difference between a long article
* rendering and exhausting the runtime's memory budget.
* @param html - fragment markup
*/
const needsSanitizing = (html: string): boolean => /[<>&\r\u0000\u00A0]/.test(html);
/**
* The every fragment is parsed in, to match the DOM pipeline (janitor
* parses into a detached
); parse5's default context is
, which
* treats table fragments differently. parse5 only reads the context element, so
* one is built once and shared — building it per call parses a second fragment
* for every inline field in the document.
*/
const DIV_CONTEXT = parseFragment('').childNodes[0] as P5Element;
/**
* Sanitize an HTML fragment string against a {@link SanitizerConfig},
* DOM-free. When the config is the {@link PLAINTEXT} sentinel the input is
* treated as literal text and returned entity-escaped.
* @param html - fragment markup to sanitize
* @param config - tag allowlist, or PLAINTEXT
* @param transform - optional URL rewrite hook applied before the unsafe-scheme strip
*/
export const sanitizeHtmlFragment = (
html: string,
config: SanitizerConfig | PlaintextRule,
transform?: FragmentUrlTransform
): string => {
if (config === PLAINTEXT) {
return escapeHtml(html);
}
if (html === '' || !needsSanitizing(html)) {
return html;
}
const contextElement = DIV_CONTEXT;
/**
* Retried on the input parse5 refuses outright: two adjacent low surrogates
* make it throw, and one such field must not cost the whole document. See
* `repairSurrogates`.
* @param source - fragment markup to parse in the div context
*/
const parseInContext = (source: string): DefaultTreeAdapterMap['documentFragment'] => {
try {
return parseFragment(contextElement, source, {});
} catch (error) {
if (!(error instanceof RangeError)) {
throw error;
}
return parseFragment(contextElement, repairSurrogates(source), {});
}
};
const fragment = parseInContext(html);
sanitizeChildren(fragment, config, true, true, transform);
normalizeInlineMarkupFragment(fragment);
return serialize(fragment);
};