// serialize-html.ts
import { Primitive } from '../../../core/types.js'
import { is_Primitive } from '../../../core/value-guards.js';
import { ELEM_TAG, EVERY_VSN, HSON_SYS_PREFIX, HTML_KEY_PREFIX, OBJ_TAG, ROOT_TAG, STR_TAG, VAL_TAG } from '../../../core/constants.js';
import { build_wire_attrs } from '../utils/html-utils/build-wire-attrs.js';
import { escape_html_text } from '../utils/html-utils/escape-html.js';
import { make_string } from '../../../core/stringify.js';
import { _snip } from '../utils/sys-utils/snip.utils.js';
import { is_Node } from '../../../core/node-guards.js';
import { assert_invariants } from '../../../core/assert-invariants.js';
import { collect_hson_node_quid_claims } from '../../../core/hson-node-quid.js';
import { clone_node } from '../../../core/clone-node.js';
import { HsonNode } from '../../../core/types.js';
import { _throw_transform_err } from '../utils/sys-utils/throw-transform-err.utils.js';
import { encode_html_key_tag } from '../utils/html-utils/encode-html-tag.js';
const RAWTEXT = new Set(["style", "script"]);
/**
* Collect raw textual content from a subtree without trimming or escaping.
*
* Behavior:
* - Walks a mixed list of `HsonNode | Primitive`.
* - For `_hson_str` nodes:
* - Takes the first `$_content` entry (if any),
* - Uses it as a string if already a string, otherwise stringifies it.
* - For other node types:
* - Recursively descends into their `$_content`.
* - For primitive leaves:
* - Appends `String(primitive)` directly.
*
* Notes:
* - Does not perform any HTML/XML escaping.
* - Does not collapse whitespace or remove newlines.
*
* Intended use:
* - Raw-text serialization for RAWTEXT elements like `` not in `EVERY_VSN`).
* - Throws when `_hson_str` / `_hson_val` shape is invalid.
*
* @param node - Node or primitive to serialize.
* @returns XML string representation of the node.
*/
function serialize_xml_node(node: HsonNode | Primitive | undefined): string {
if (is_Primitive(node)) return primitive_to_xml(node);
if (node === undefined) {
_throw_transform_err('undefined node received', 'serialize_html', node);
}
const {
$_tag: rawTag,
$_content: content = [],
} = node;
// NEW: wire-format tag for XML output
const tag = encode_html_key_tag(rawTag);
// correct origin label for error
if (
node.$_tag.startsWith(HSON_SYS_PREFIX) &&
!node.$_tag.startsWith(HTML_KEY_PREFIX) &&
!EVERY_VSN.includes(node.$_tag)
) {
_throw_transform_err(`unknown VSN-like tag: <${node.$_tag}>`, "serialize-hson");
}
switch (tag) {
// Explicit string transport is injective over empty, adjacent, and
// whitespace-sensitive text items.
case STR_TAG: {
return explicit_string_transport(node);
}
// keep <_hson_val> literal for round-trip typing
case VAL_TAG: {
if (!content || content.length !== 1) {
_throw_transform_err('<_hson_val> must contain exactly one value', 'serialize_html');
}
const v = content[0] as Primitive;
return `<${VAL_TAG}>${primitive_to_xml(v)}${VAL_TAG}>`;
}
// Melt ordinary element content when the text-node boundaries are already
// unambiguous. Otherwise retain an explicit transport cluster so adjacent,
// empty, sentinel-like, and boundary-whitespace strings remain injective.
case ELEM_TAG: {
const kids = content as (HsonNode | Primitive)[];
if (element_cluster_needs_explicit_transport(kids)) {
return `<${ELEM_TAG}>${kids.map(ch => serialize_xml_node(ch)).join("")}${ELEM_TAG}>`;
}
return kids.map(ch => {
if (is_Node(ch) && ch.$_tag === STR_TAG) {
const value = ch.$_content[0];
if (typeof value !== "string") {
_throw_transform_err('<_hson_str> must contain exactly one string', 'serialize_html');
}
return escape_html_text(value);
}
return serialize_xml_node(ch);
}).join('\n');
}
// melt _hson_root (must have exactly one cluster child)
case ROOT_TAG: {
const kids = content as HsonNode[];
if (kids.length !== 1) {
_throw_transform_err('_hson_root must have exactly one child', 'serialize_html');
}
const only = kids[0];
if (only.$_tag === STR_TAG || only.$_tag === VAL_TAG) {
return `<${OBJ_TAG}>${serialize_xml_node(only)}${OBJ_TAG}>`;
}
return serialize_xml_node(only);
}
// object cluster → each property becomes an HTML element
case OBJ_TAG: {
const props = (content as HsonNode[]) ?? [];
const inner = props.map(serialize_xml_node).join('\n');
return `<${OBJ_TAG}>\n${inner}\n${OBJ_TAG}>`;
}
}
// --------------- default path: literal tags (incl. _hson_arr/_hson_ii and normal HTML) ---------------
let openAttrs = `<${tag}`;
const attrs = build_wire_attrs(node);
if (tag === "svg") {
// ensure default SVG ns on the root svg element if not present
if (!("xmlns" in attrs)) attrs.xmlns = "http://www.w3.org/2000/svg";
}
for (const k of Object.keys(attrs).sort()) {
openAttrs += ` ${k}="${escape_attr(attrs[k])}"`;
}
const kids = (content as (HsonNode | Primitive)[]) ?? [];
// RAW-TEXT MODE: style/script → emit verbatim, no escaping/trim/collapse
let inner: string;
if (RAWTEXT.has(tag.toLowerCase())) {
const only = kids[0];
inner = kids.length === 1
&& is_Node(only)
&& only.$_tag === ELEM_TAG
&& element_cluster_needs_explicit_transport(only.$_content)
? serialize_xml_node(only)
: collect_raw_text(kids).replace(/<\/(style|script)/gi, "<\\/$1>"); // guard early close
} else {
inner = kids.map(ch => is_Node(ch) ? serialize_xml_node(ch as HsonNode)
: primitive_to_xml(ch as Primitive))
.join("");
}
return `${openAttrs}>${inner}${tag}>`;
}
/** Serialize one canonical graph value into its HTML transport spelling. */
export function serialize_xml(node: HsonNode | Primitive | undefined): string {
if (is_Node(node) && (node.$_tag === STR_TAG || node.$_tag === VAL_TAG)) {
return `<${OBJ_TAG}>${serialize_xml_node(node)}${OBJ_TAG}>`;
}
return serialize_xml_node(node);
}
/**
* Public HTML serializer for Hson trees (2.0 surface).
*
* Pipeline:
* 1. Clone & guard:
* - `clone_node($node)` to avoid mutating the original IR.
* - Require that the clone is a valid `HsonNode`, otherwise throw.
*
* 2. Invariant check:
* - `assert_invariants(clone, "serialize_html")` ensures that the
* internal Hson structure is well-formed before any emission.
*
* 3. XML stage:
* - Delegates to `serialize_xml(clone)` to produce an XML-like string
* that faithfully represents Hson semantics (including `_hson_val`, `_hson_obj`,
* `_hson_arr`, `_hson_ii`, etc.).
*
* 4. HTML normalization:
* - Converts boolean attributes from `key="key"` to `key` using a regex
* replacement; this matches standard HTML boolean attribute semantics.
*
* 5. Finalization:
* - Returns `htmlString.trim()` to remove leading/trailing whitespace.
*
* Characteristics:
* - `_hson_str` is explicit when required to preserve a transport boundary.
* - `_hson_val` uses a `<_hson_val>…` literal representation.
* - `_hson_elem` appears only when its transport boundary is required;
* `_hson_root` never appears as a tag.
* - `_hson_obj` and other clusters remain visible where necessary to preserve
* Hson’s JSON-mode structure.
*
* @param node - Root Hson node or primitive to serialize as HTML.
* @returns A trimmed HTML string ready for DOM insertion or inspection.
* @throws If invariants fail or if the input is not a valid HsonNode.
*/
export function serialize_html(node: HsonNode | Primitive): string {
if (typeof node === "object" && node !== null) {
// Serialization validates canonical value and placement only. Duplicate
// valid claims remain cold data and are emitted faithfully.
assert_invariants(node, "serialize_html");
collect_hson_node_quid_claims(node);
}
const clone = clone_node(node);
if (!is_Node(clone)) {
_throw_transform_err('input node cannot be undefined for node_to_html', 'serialize_html', make_string(node));
}
// tree assertions throw if structure is off
assert_invariants(clone, 'serialize_html');
const xmlString = serialize_xml(clone);
// HTML boolean attrs: key="key" → key
const htmlString = xmlString.replace(/\b([^\s=]+)="\1"/g, '$1');
return htmlString.trim();
}