/** * DOM-free plain-text extraction from an HTML fragment string — the view * renderer's replacement for `element.textContent`. parse5 decodes entities, * so `a < b` comes back as `a < b`; `
` becomes a newline (matching how * text with soft breaks reads). * * parse5 imports are confined to `src/view/` — never import this module from * the editor bundle graph. */ import { parseFragment } from 'parse5'; import type { DefaultTreeAdapterMap } from 'parse5'; import { EQUATION_SOURCE_ATTR } from '../shared/equation-mark'; type P5ChildNode = DefaultTreeAdapterMap['childNode']; /** * Collect the concatenated text of parse5 child nodes. * @param nodes - nodes to walk */ const collect = (nodes: P5ChildNode[]): string => { return nodes.map((node) => { if (node.nodeName === '#text') { return (node as DefaultTreeAdapterMap['textNode']).value; } if (node.nodeName === 'br') { return '\n'; } /** * An inline equation reads as its SOURCE, never as its children: those are * a KaTeX rendering cache, and documents saved before that cache was * stripped hold the concatenated text of KaTeX's MathML, annotation and * HTML layers. See the law in `src/shared/equation-mark.ts`. */ if ('attrs' in node) { const source = node.attrs.find((attr) => attr.name === EQUATION_SOURCE_ATTR); if (source !== undefined) { return source.value; } } return 'childNodes' in node ? collect(node.childNodes) : ''; }).join(''); }; /** * The characters HTML5 input-stream preprocessing acts on: `<` opens a tag, `&` * may open a character reference, a CR (alone or in a CRLF) becomes a newline, * and a NUL is dropped. Every other character — tab, form feed, U+00A0, a lone * surrogate, an astral emoji, a bare `>` — comes back out of the tokenizer * exactly as it went in. */ const NEEDS_TOKENIZING = /[<&\r\u0000]/; /** * Whether a field has to go through parse5 at all. A field that does not is * returned verbatim by every reader here, which is what the tokenizer would * have produced — and parse5 is a character-by-character HTML5 tokenizer, so * skipping it is most of the cost of reading a document that is mostly prose. * This matters far more than it looks: the server package runs this code on an * interpreter, where the tokenizer costs orders of magnitude more than it does * in a browser. * @param html - fragment markup */ export const needsTokenizing = (html: string): boolean => NEEDS_TOKENIZING.test(html); /** * A valid surrogate pair, or a single surrogate. Ordered so the pair wins: * whatever the alternation matches with length 1 was unpaired. */ const SURROGATE = /[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g; /** * Parse a fragment, repairing input parse5 refuses to read at all. Two adjacent * low surrogates make it throw `RangeError` — the shape a truncated UTF-16 * paste leaves behind — and that would cost a whole document over one field. * The server runtime reads STORED articles, where an abort means an article * nobody can read again, not a request someone can retry. * * HTML5 replaces an unpaired surrogate with U+FFFD anyway, so doing it here * loses nothing that was not already broken, and keeps the field's markup. * @param html - fragment markup */ export const repairSurrogates = (html: string): string => html.replace(SURROGATE, (match) => (match.length === 2 ? match : '\uFFFD')); /** * Parse an inline fragment, retrying once on the input parse5 refuses. * @param html - fragment markup */ export const parseInlineFragment = (html: string): DefaultTreeAdapterMap['documentFragment'] => { try { return parseFragment(html); } catch (error) { if (!(error instanceof RangeError)) { throw error; } return parseFragment(repairSurrogates(html)); } }; /** * Extract the plain text of an HTML fragment. * @param html - fragment markup */ export const htmlTextContent = (html: string): string => { if (html === '' || !needsTokenizing(html)) { return html; } return collect(parseInlineFragment(html).childNodes); };