// parse_html_attrs.ts import { HSON_META_MARKUP_PREFIX, HSON_META_QUID, HSON_META_TRANSIT_PREFIX, _TRANSIT_PREFIX, } from "../../../../core/constants.js"; import { admit_hson_metadata_markup, } from "../../../../core/hson-metadata.js"; import { HsonAttrs, HsonMeta } from "../../../../core/types.js"; import { normalize_attr_ws } from "../attrs-utils/normalize_attrs_ws.js"; import { parse_style_string } from "../attrs-utils/parse-style.js"; import { decode_ordinary_attr_transit_name, is_ordinary_attr_transit_name, } from "../html-preflights/ordinary-attribute-transit.js"; import { decode_hson_metadata_transit_name, is_hson_metadata_transit_name, } from "../html-preflights/hson-metadata-transit.js"; import { _throw_transform_err } from "../sys-utils/throw-transform-err.utils.js"; import { is_valid_hson_attribute_name } from "../../../../core/hson-name.js"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const isSvgElement = (el: Element): boolean => el.namespaceURI === SVG_NAMESPACE; const isNamespaceNoise = (name: string): boolean => { const lower = name.toLowerCase(); return lower === "xmlns" || lower.startsWith("xmlns:") || lower.startsWith("xml:"); }; const attrKeyForElement = (el: Element, name: string): string => { // changed: SVG attrs are case-sensitive in practice; preserve DOM/authored spelling. if (isSvgElement(el)) return name; // HTML attrs remain normalized to lowercase for Hson stability. return name.toLowerCase(); }; const isPresenceAttr = (key: string, name: string, value: string): boolean => { return value === "" || value === name || value === key; }; /** * Extract Hson-facing attributes from a live DOM `Element`. * * Rules: * - Returns `attrs` for user-visible attributes, optional `meta` for reserved * structural metadata, and protected QUID input separately for canonical * attachment by the caller. * - Decodes dedicated Hson metadata transit names only for the string parser * path and rejects externally authored private names. * - Decodes generic ordinary-attribute transit names only for the string parser * path and rejects externally authored private names. * - Normalizes style into a structured object via `parse_style_string`. * - Ignores XML namespace noise (`xmlns`, `xmlns:*`, `xml:*`) so HTML/SVG/XML * sources don’t leak parser plumbing into Hson. * - Preserves SVG attribute names exactly as the DOM reports them. This keeps * case-sensitive SVG names such as `viewBox`, `stdDeviation`, and * `preserveAspectRatio` intact instead of relying on an incomplete repair map. * - Lowercases HTML attribute names for stable HTML/Hson behavior. * - For SVG, maps `xlink:href` → `href` only if `href` is not already present, * so downstream code can treat links uniformly. * - Canonicalizes boolean/presence flags so `disabled`, `disabled=""`, and * `disabled="disabled"` become `disabled="disabled"` in `attrs`; SVG presence * attributes preserve the SVG key spelling. * - Normalizes other attribute values with `normalize_attr_ws` to collapse * whitespace (but does not apply this to `style`, which is parsed separately). * * @param el - The DOM element to read attributes from. * @returns Parsed ordinary attributes plus optional structural metadata and a * separately captured protected QUID value. */ export function parse_html_attrs( el: Element, nodeTag: string, options: Readonly<{ allowHsonTransit?: boolean; allowOrdinaryTransit?: boolean; }> = {}, ): { attrs: HsonAttrs; meta?: HsonMeta; quid?: string; } { const attrs: HsonAttrs = {}; let meta: HsonMeta | undefined; let quid: string | undefined; const svg = isSvgElement(el); const admittedKeys = new Set(); // walk all DOM attributes verbatim for (const a of Array.from(el.attributes)) { const parserName = a.name; let name = parserName; if (is_ordinary_attr_transit_name(parserName)) { if (!options.allowOrdinaryTransit) { _throw_transform_err( `externally authored private ordinary-attribute transit name "${parserName}" is forbidden`, "parse-html-attrs", ); } const decoded = decode_ordinary_attr_transit_name(parserName); if (decoded === undefined) { _throw_transform_err( `malformed private ordinary-attribute transit name "${parserName}"`, "parse-html-attrs", ); } const decodedLower = decoded.toLowerCase(); if ( decodedLower.startsWith(HSON_META_MARKUP_PREFIX) || decodedLower.startsWith(HSON_META_TRANSIT_PREFIX) || decodedLower.startsWith(_TRANSIT_PREFIX) ) { _throw_transform_err( `private ordinary-attribute transit decoded to reserved name "${decoded}"`, "parse-html-attrs", ); } name = decoded; } const key = attrKeyForElement(el, name); const v = a.value ?? ""; // A) decode dedicated Hson metadata transit or admit a literal DOM name. let metadataMarkupName: string | undefined; if (is_hson_metadata_transit_name(parserName)) { if (!options.allowHsonTransit) { _throw_transform_err( `externally authored private Hson metadata transit name "${parserName}" is forbidden`, "parse-html-attrs", ); } metadataMarkupName = decode_hson_metadata_transit_name(parserName); if (metadataMarkupName === undefined) { _throw_transform_err( `malformed private Hson metadata transit name "${parserName}"`, "parse-html-attrs", ); } } else if (key.startsWith(HSON_META_MARKUP_PREFIX)) { metadataMarkupName = key; } if (metadataMarkupName !== undefined) { const admission = admit_hson_metadata_markup( nodeTag, metadataMarkupName, v, ); if (!admission.valid) { _throw_transform_err( admission.reason, "parse-html-attrs", ); } if (admission.key === HSON_META_QUID) quid = admission.value; else (meta ??= {})[admission.key] = admission.value; continue; } // B) private parser names may never be admitted as ordinary attributes. const lowerName = name.toLowerCase(); if (lowerName.startsWith(_TRANSIT_PREFIX)) { _throw_transform_err( `externally authored private ordinary-attribute transit name "${name}" is forbidden`, "parse-html-attrs", ); } if (lowerName.startsWith(HSON_META_TRANSIT_PREFIX)) { _throw_transform_err( `externally authored private Hson metadata transit name "${name}" is forbidden`, "parse-html-attrs", ); } if (!is_valid_hson_attribute_name(name)) { _throw_transform_err( `invalid Hson attribute name "${name}"`, "parse-html-attrs", ); } // C) style → structured object if (key === "style") { (attrs as any).style = parse_style_string(v); continue; } // D) ignore xmlns / xml:* noise if (isNamespaceNoise(name)) continue; // E) svg alias normalize if (svg && name.toLowerCase() === "xlink:href") { if (!el.hasAttribute("href")) { if (admittedKeys.has("href")) { _throw_transform_err( `attribute name collision after canonicalization: "${name}"`, "parse-html-attrs", ); } admittedKeys.add("href"); (attrs as any).href = v; } continue; } if (admittedKeys.has(key)) { _throw_transform_err( `attribute name collision after transit decoding: "${name}"`, "parse-html-attrs", ); } admittedKeys.add(key); // F) presence-only flags canonicalized as key="key" if (isPresenceAttr(key, name, v)) { (attrs as any)[key] = key; continue; } // G) default: normalized user attribute value, preserving the chosen key (attrs as any)[key] = normalize_attr_ws(v); } return { attrs, ...(meta === undefined ? {} : { meta }), ...(quid === undefined ? {} : { quid }), }; } /** * Legacy SVG attr repair map. * * This is intentionally no longer used by `parse_html_attrs()` for live SVG DOM * reads. SVG attributes are now preserved exactly at the namespace boundary. * Keep the map available for older parse/repair paths that may need to recover * case-sensitive SVG attr names from already-lowercased legacy input. */ export const SVG_ATTR_CASE_MAP: Record = { viewbox: "viewBox", preserveaspectratio: "preserveAspectRatio", markerwidth: "markerWidth", markerheight: "markerHeight", gradientunits: "gradientUnits", gradienttransform: "gradientTransform", patternunits: "patternUnits", patterncontentunits: "patternContentUnits", patterntransform: "patternTransform", clippathunits: "clipPathUnits", filterunits: "filterUnits", primitiveunits: "primitiveUnits", kernelunitlength: "kernelUnitLength", strokewidth: "strokeWidth", vectoreffect: "vectorEffect", stddeviation: "stdDeviation", basefrequency: "baseFrequency", diffuseconstant: "diffuseConstant", specularconstant: "specularConstant", specularexponent: "specularExponent", surfacescale: "surfaceScale", limitingconeangle: "limitingConeAngle", }; export function canonical_svg_attr_name(name: string): string { const lower = name.toLowerCase(); return SVG_ATTR_CASE_MAP[lower] ?? name; }