// parse-html.ts import { HsonNode } from "../../../core/types.js"; import { ROOT_TAG, ELEM_TAG, STR_TAG, EVERY_VSN, VAL_TAG, OBJ_TAG, ARR_TAG, II_TAG, HSON_SYS_PREFIX, HTML_KEY_PREFIX } from "../../../core/constants.js"; import { CREATE_NODE } from "../../../core/factories.js"; import { is_Primitive, is_string } from "../../../core/value-guards.js"; import { _snip } from "../utils/sys-utils/snip.utils.js"; import { _throw_transform_err } from "../utils/sys-utils/throw-transform-err.utils.js"; import { parse_html_attrs } from "../utils/html-utils/parse_html_attrs.js"; import { coerce } from "../utils/primitive-utils/coerce-string.utils.js"; import { assert_invariants } from "../../../core/assert-invariants.js"; import { expand_entities } from "../utils/html-preflights/expand-entities.js"; import { expand_flags } from "../utils/html-preflights/expand-flags.js"; import { expand_void_tags } from "../utils/html-preflights/expand-self-closing.js"; import { escape_text } from "../utils/html-preflights/escape-text.js"; import { strip_html_comments } from "../utils/html-preflights/strip-html-comments.js"; import { wrap_cdata } from "../../../safety/wrap-cdata.js"; import { optional_endtag_preflight } from "../utils/html-preflights/optional-endtag.js"; import { escape_attr_angles } from "../../../safety/escape_angles.js"; import { quote_unquoted_attrs } from "../utils/html-preflights/quoted-unquoted.js"; import { normalize_html_source_attributes } from "../utils/html-preflights/ordinary-attribute-transit.js"; import { encode_hson_metadata_transit } from "../utils/html-preflights/hson-metadata-transit.js"; import { namespace_svg } from "../utils/html-preflights/namespace-svg.js"; import { is_indexed } from "../../../core/node-guards.js"; import { Primitive } from "../../../core/types.js"; import { should_try_optional_endtags, should_try_void_expand } from "../utils/html-preflights/preflight-helpers.js"; import { decode_html_key_tag } from "../utils/html-utils/encode-html-tag.js"; import { esc_attrs_quoted_angles } from "../utils/html-preflights/preflight-attrs-escaping.js"; import { assign_ingested_hson_node_quid, scan_ingested_hson_node_quids, } from "../utils/hson-utils/quid-ingress.js"; import { normalize_hson_array_index_order } from "../../../core/hson-array-indexes.js"; import { classify_ordinary_hson_structure } from "../../../core/hson-structural-mode.js"; import { decode_html_string_transport } from "../utils/html-utils/decode-html-string-transport.js"; /** *DEBUG - remove when clear **/ function find_invalid_xml_char(s: string): { index: number; code: number } | null { for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); // XML 1.0 valid chars: // #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] if ( c === 0x9 || c === 0xA || c === 0xD || (c >= 0x20 && c <= 0xD7FF) || (c >= 0xE000 && c <= 0xFFFD) ) { continue; } return { index: i, code: c }; } return null; } function snip_context(s: string, at: number, radius = 80): string { const start = Math.max(0, at - radius); const end = Math.min(s.length, at + radius); return s.slice(start, end) .replace(/\r/g, '\\r') .replace(/\n/g, '\\n'); } /** * Parse HTML/XML (trusted or pre-sanitized) into a rooted `HsonNode` tree. * * Input forms: * - `string`: * - Runs a preflight pipeline to coerce HTML into XML-safe markup * before parsing. * - `Element`: * - Skips string preprocessing and converts the element subtree * directly via `convert`. * * String pipeline (high level): * 1. Strip HTML comments (`strip_html_comments`). * 2. Expand boolean/flag attributes (`expand_flags`). * 3. Escape text + expand entities (`escape_text`, `expand_entities`). * 4. Add required SVG namespace handling (`namespace_svg`). * 5. Normalize unquoted attribute values (`quote_unquoted_attrs`). * 6. Apply duplicate policy and encode XML-hostile ordinary names. * 7. Encode syntactic `hson:*` names with the dedicated private transit codec. * 8. Attempt XML parse via `DOMParser("application/xml")`. * 9. On parse errors, apply gated repairs in order: * - Patch bare ampersands (`amp_fix`) for entity errors. * - Quote unquoted attrs (`quote_unquoted_attrs`) and re-amp-fix. * - Escape literal `<` inside attrs (`escape_attr_angles`). * - Expand void tags (`expand_void_tags`) and re-amp-fix. * - Balance optional end tags (`optional_endtag_preflight`). * - If the error is “extra content”, wrap in `<_hson_root>…` and retry, * optionally re-running void expansion on the wrapped source. * 10. If parsing still fails, throw a transform error with context. * 11. Enumerate attributes, decode Hson transit names, and construct the graph. * 12. Wrap as `_hson_root` and validate with `assert_invariants`. * * @param input - Raw HTML/XML string or an existing `Element` subtree. * @returns A `_hson_root`-wrapped `HsonNode` tree ready for downstream use. * @see convert * @see wrap_as_root * @see assert_invariants */ type ElementProvenanceRecorder = (node: HsonNode, element: Element) => void; type ParsedHtmlElementProvenance = Readonly<{ root: HsonNode; elements: ReadonlyMap; }>; export function parse_html(input: string | Element): HsonNode { return parse_html_internal(input); } /** Parse one realized Element while retaining exact ordinary-node provenance. @internal */ export function parse_html_with_element_provenance( input: Element, ): ParsedHtmlElementProvenance { const elements = new Map(); const root = parse_html_internal(input, (node, element) => { elements.set(node, element); }); return Object.freeze({ root, elements }); } function parse_html_internal( input: string | Element, recordElement?: ElementProvenanceRecorder, ): HsonNode { let inputElement: Element; const allowHsonTransit = typeof input === "string"; if (typeof input === "string") { const stripped = strip_html_comments(input); const bools = expand_flags(stripped); const safe = escape_text(bools); const ents = expand_entities(safe); // Normalize the XML-hostile parts of HTML before the first XML parse. // These are safe structural preflights, not speculative repairs: // - quote unquoted attribute values:
→
// - mangle illegal XML attribute names after values are quoted // - expand void HTML tags: → const svgSafe = namespace_svg(ents); const quoted = quote_unquoted_attrs(svgSafe); const ordinaryTransitSafe = normalize_html_source_attributes( quoted, { encodeTransit: true }, ); const hsonTransitSafe = encode_hson_metadata_transit(ordinaryTransitSafe); const voidSafe = expand_void_tags(hsonTransitSafe); // Make quoted attribute values XML-safe before the first parse. // Firefox rejects bare `&` in quoted attrs such as url('a&b.png'), even // when Chrome's XML parser appears to tolerate it. let xmlSrc = esc_attrs_quoted_angles(voidSafe); const parser = new DOMParser(); let parsed = parser.parseFromString(xmlSrc, "application/xml"); let err = parsed.querySelector("parsererror"); const errText = () => (err?.textContent ?? ""); const hasErr = () => Boolean(err); // single helper to reduce drift + ensure we re-read parsererror each time const tryParse = (candidate: string, label: string) => { // commit the candidate to xmlSrc *before* parsing, so later steps repair the current text xmlSrc = candidate; parsed = parser.parseFromString(xmlSrc, "application/xml"); err = parsed.querySelector("parsererror"); // trace label // console.log(`tryParse:${label} err=${!!err}`); }; // amp-fix is dangerous; only apply when parser error looks entity-related const amp_fix = (src: string) => src.replace( /&(?!(?:#\d+|#x[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]{1,31});)/g, "&" ); // ---- Repair pass (gated, ordered, no duplicates) ---- if (hasErr()) { const msg = errText(); // 0) Invalid XML control chars: bail early with a good message (your existing logic) const bad = find_invalid_xml_char(xmlSrc); if (bad) { const hex = `0x${bad.code.toString(16).toUpperCase()}`; const ctx = snip_context(xmlSrc, bad.index); _throw_transform_err( `XML parse failed: invalid control character ${hex} at index ${bad.index}\n` + `Context: “…${ctx}…”`, "parse-html" ); } // 1) Entity / ampersand problems: only then amp-fix // (Patterns vary by engine; keep loose but still clearly entity-related.) if (/Entity|reference to entity|The entity name must immediately follow the '&' in the entity reference/i.test(msg)) { tryParse(amp_fix(xmlSrc), "amp_fix(entity)"); } } if (hasErr()) { const msg = errText(); // 2) Unquoted attribute values: only then quote them (and re-run amp fix once) // IMPORTANT: do NOT run this for tag mismatch errors; it's a regex and can worsen things. if ( /AttValue|attribute value|expected ['"]|quotation mark|not well-formed/i.test(msg) && /=([^\s"'=<>`]+)/.test(xmlSrc) ) { const quoted = quote_unquoted_attrs(xmlSrc); // quoting can introduce bare &, so do amp_fix once immediately after tryParse(amp_fix(quoted), "quote_unquoted_attrs(+amp_fix)"); } } if (hasErr()) { // 3) Literal '<' inside attribute values // DOMParser messages vary; keep it narrow-ish but not brittle. const msg = errText(); // raw < inside a quoted attribute value if (/[A-Za-z_:][\w:.-]*\s*=\s*"(?:[^"]*<[^"]*)"/.test(xmlSrc) || /[A-Za-z_:][\w:.-]*\s*=\s*'(?:[^']*<[^']*)'/.test(xmlSrc)) { tryParse(escape_attr_angles(xmlSrc), "escape_attr_angles(< in attr)"); } } if (hasErr()) { const msg = errText(); // 4) Void tags left unclosed: only then expand void tags (and re-run amp_fix once) if (should_try_void_expand(msg)) { const voidFixed = expand_void_tags(xmlSrc); tryParse(amp_fix(voidFixed), "expand_void_tags(+amp_fix)"); } } if (hasErr()) { const msg = errText(); // 5) Optional end tags (
  • ,

    ) only: THEN run optional_endtag_preflight if (should_try_optional_endtags(msg)) { const balanced = optional_endtag_preflight(xmlSrc); tryParse(balanced, "optional_endtag_preflight(li/p only)"); } } if (hasErr()) { const msg = errText(); // 6) Multiple top-level nodes ("extra content"): wrap a root and retry. if (/extra content|junk after document element|no root element found|Unexpected token:\s*\n${xmlSrc}\n`; // optional endtag pass (safe-ish) on the wrapped source. wrapped = optional_endtag_preflight(wrapped); // Attempt parse of the wrapped source. tryParse(wrapped, "wrap_root(+optional_endtag_preflight)"); // If wrapping exposed a void-tag mismatch (embed/input/meta/etc), // apply expand_void_tags to the *wrapped* string (NOT the old xmlSrc) and retry. if (hasErr()) { const msg2 = errText(); if (should_try_void_expand(msg2)) { const voidFixedWrapped = expand_void_tags(wrapped); // quoting/void expansion can surface bare '&' in some broken inputs → amp_fix once. tryParse(amp_fix(voidFixedWrapped), "wrap_root(+optional_endtag_preflight+void_expand)"); } } // OPTIONAL: give optional_endtag_preflight a second shot after void-fix: /* if (hasErr()) { const msg3 = errText(); if (should_try_optional_endtags(msg3)) { const balanced2 = optional_endtag_preflight(wrapped); tryParse(balanced2, "wrap_root(+optional_endtag_preflight#2)"); } } */ } } if (hasErr()) { const msg = errText(); _throw_transform_err( `XML parse failed:\n${msg}\n` + `Snippet:\n${snip_context(xmlSrc, 0)}`, "parse-html" ); } inputElement = parsed.documentElement!; } else { inputElement = input; } const actualContentRootNode = convert(inputElement, undefined, allowHsonTransit, recordElement); const final = normalize_hson_array_index_order( wrap_as_root(actualContentRootNode), "parse-html", ); scan_ingested_hson_node_quids(final, "parse-html"); assert_invariants(final, "parse-html"); return final; } // --- recursive conversion function --- /** * Recursively convert a DOM `Element` subtree into a `HsonNode` subtree. * * Responsibilities: * - Validate tag semantics and lower reserved transport VSNs before ordinary * HTML element construction. * - Reject unknown tags starting with `_` that are not recognized VSNs. * - Parse attributes and meta via `parse_html_attrs`. * - Handle special raw-text elements (`