import { SVG_TAGS } from "../../../core/all-html-tags.js"; import { HSON_META_MARKUP_PREFIX } from "../../../core/constants.js"; import { clone_node } from "../../../core/clone-node.js"; import { canonical_public_attrs_equal, decode_public_attrs, decode_public_attr_value, is_public_attr_name, } from "../../../core/public-attrs.js"; import { canonical_attr_is_flag, plan_public_attr_drop, plan_public_attr_set, plan_public_attrs_clear, plan_public_attrs_drop_many, plan_public_attrs_replace, plan_public_attrs_set_many, plan_public_flags_clear, plan_public_flags_set, } from "../../../core/public-attr-transitions.js"; import type { CanonicalPublicAttrs, CanonicalPublicAttrValue, HsonNode, } from "../../../core/types.js"; import type { AttrHandle, FlagHandle } from "../../../types/attrs.types.js"; import type { SvgTag } from "../../../types/livetree.types.js"; import { canonical_svg_attr_name, } from "../../transform/utils/html-utils/parse_html_attrs.js"; import { LIVETREE_ATTRIBUTE_NOT_FOUND_ERROR_CODE, LIVETREE_INVALID_ATTRIBUTE_NAME_ERROR_CODE, LIVETREE_INVALID_ATTRIBUTE_VALUE_ERROR_CODE, LIVETREE_PROTECTED_ATTRIBUTE_ERROR_CODE, LiveTreeAttributeError, } from "../livetree.error.js"; import type { LiveTree } from "../livetree.js"; import { get_el_for_node } from "../utils/node-map-helpers.js"; import { document_binding_for_node } from "../lifecycle/document-binding-state.js"; import { get_quid } from "../quid/data-quid.js"; import { runtime_for_tree } from "../runtime/livetree-runtime.js"; import { lower_browser_attribute_value } from "../../../internal/browser-realization/browser-realization-plan.js"; const UNASSIGNED_QUID_DIAGNOSTIC = ""; function canonical_attr_key(tree: TTree, name: string): string { const lower = name.toLowerCase(); return tree.svg.inScope() ? canonical_svg_attr_name(name) : lower; } function svg_attr_key_from_node_tag(node: HsonNode, name: string): string { const lower = name.toLowerCase(); return SVG_TAGS.includes(node.$_tag as SvgTag) ? canonical_svg_attr_name(name) : lower; } export function attr_handle(tree: TTree): AttrHandle { const must = Object.freeze({ get: (name: string): CanonicalPublicAttrValue => { const key = normalize_livetree_attr_name(tree, name, "must.get"); const value = read_canonical_attr(tree, key, "must.get"); if (value === undefined) { throw new LiveTreeAttributeError( LIVETREE_ATTRIBUTE_NOT_FOUND_ERROR_CODE, "must.get", diagnostic_quid(tree), `ordinary attribute ${JSON.stringify(key)} is absent`, { attributeName: key }, ); } return value; }, }); return Object.freeze({ get: (name) => { const key = normalize_livetree_attr_name(tree, name, "get"); return read_canonical_attr(tree, key, "get"); }, must, has: (name) => { const key = normalize_livetree_attr_name(tree, name, "has"); return has_attr(tree.node, key); }, keys: () => Object.freeze(read_attr_keys(tree.node)), set: (name, value) => { const key = normalize_livetree_attr_name(tree, name, "set"); const decoded = normalize_livetree_attr_value(tree, key, value, "set"); const binding = document_binding_for_node(tree.node); if (binding !== undefined) { binding.delegateAttrs({ kind: "set", name: key, value: decoded }); return tree; } const current = read_attrs(tree, "set"); return apply_attrs_replacement(tree, current, plan_public_attr_set(current, key, decoded)); }, setMany: (values) => { const additions = normalize_livetree_attrs_input(tree, values, "setMany"); const binding = document_binding_for_node(tree.node); if (binding !== undefined) { binding.delegateAttrs({ kind: "setMany", values: additions }); return tree; } const current = read_attrs(tree, "setMany"); return apply_attrs_replacement( tree, current, plan_public_attrs_set_many(current, additions), ); }, drop: (name) => { const key = normalize_livetree_attr_name(tree, name, "drop"); const binding = document_binding_for_node(tree.node); if (binding !== undefined) { binding.delegateAttrs({ kind: "drop", name: key }); return tree; } const current = read_attrs(tree, "drop"); return apply_attrs_replacement(tree, current, plan_public_attr_drop(current, key)); }, dropMany: (names) => { const normalized = normalize_livetree_drop_names(tree, names, "dropMany"); const binding = document_binding_for_node(tree.node); if (binding !== undefined) { binding.delegateAttrs({ kind: "dropMany", names: normalized }); return tree; } const current = read_attrs(tree, "dropMany"); return apply_attrs_replacement(tree, current, plan_public_attrs_drop_many(current, normalized)); }, clear: () => { const binding = document_binding_for_node(tree.node); if (binding !== undefined) { binding.delegateAttrs({ kind: "clear" }); return tree; } const current = read_attrs(tree, "clear"); return apply_attrs_replacement(tree, current, plan_public_attrs_clear()); }, replace: (values) => { const next = normalize_livetree_attrs_input(tree, values, "replace"); const binding = document_binding_for_node(tree.node); if (binding !== undefined) { binding.delegateAttrs({ kind: "replace", values: next }); return tree; } const current = read_attrs(tree, "replace"); return apply_attrs_replacement(tree, current, plan_public_attrs_replace(next)); }, }); } /** Apply one canonical attrs final state without consulting public binding delegation. */ export function apply_projected_attrs_replacement( node: HsonNode, values: CanonicalPublicAttrs, ): void { const next = decode_public_attrs(values); const current = decode_public_attrs(node.$_attrs ?? {}); if (next === undefined || current === undefined) { throw new Error("LiveTree projected attrs replacement requires canonical attrs."); } if (canonical_public_attrs_equal(current, next)) return; if (Object.keys(next).length === 0) delete node.$_attrs; else node.$_attrs = clone_node(next); project_attrs_replacement(node, current, next); } export function flag_handle(tree: TTree): FlagHandle { return Object.freeze({ has: (name) => { const key = normalize_livetree_attr_name(tree, name, "flags.has"); const current = read_attrs(tree, "flags.has"); return canonical_attr_is_flag(current, key); }, set: (...names): TTree => { const normalized = normalize_livetree_flag_names(tree, names, "flags.set", true); const binding = document_binding_for_node(tree.node); if (binding !== undefined) { binding.delegateAttrs({ kind: "setMany", values: livetree_flag_values(normalized) }); return tree; } const current = read_attrs(tree, "flags.set"); return apply_attrs_replacement(tree, current, plan_public_flags_set(current, normalized)); }, clear: (...names): TTree => { const normalized = normalize_livetree_flag_names(tree, names, "flags.clear", true); const binding = document_binding_for_node(tree.node); if (binding !== undefined) { binding.delegateAttrs({ kind: "transform", apply: (current) => ({ kind: "dropMany", names: normalized.filter((name) => canonical_attr_is_flag(current, name)), }), }); return tree; } const current = read_attrs(tree, "flags.clear"); const next = plan_public_flags_clear(current, normalized); return apply_attrs_replacement(tree, current, next); }, }); } /** Canonical single-value writer retained for existing internal managers. */ export function setAttrsImpl( tree: TTree, name: string, value: CanonicalPublicAttrValue, ): TTree; export function setAttrsImpl(tree: TTree, values: CanonicalPublicAttrs): TTree; export function setAttrsImpl( tree: TTree, nameOrValues: string | CanonicalPublicAttrs, value?: CanonicalPublicAttrValue, ): TTree { if (typeof nameOrValues === "string") { return Reflect.apply(tree.attrs.set, tree.attrs, [nameOrValues, value]); } return tree.attrs.setMany(nameOrValues); } export function removeAttrImpl(tree: TTree, name: string): TTree { return tree.attrs.drop(name); } export function getAttrImpl(tree: LiveTree, name: string): CanonicalPublicAttrValue | undefined { return tree.attrs.get(name); } export function readAttrFromNode( node: HsonNode, name: string, ): CanonicalPublicAttrValue | undefined { const key = svg_attr_key_from_node_tag(node, name); if (!has_attr(node, key)) return undefined; return decode_public_attr_value(key, node.$_attrs?.[key]); } export function hasAttrImpl(tree: LiveTree, name: string): boolean { return tree.attrs.has(name); } function read_canonical_attr( tree: LiveTree, key: string, operation: string, ): CanonicalPublicAttrValue | undefined { if (!has_attr(tree.node, key)) return undefined; const decoded = decode_public_attr_value(key, tree.node.$_attrs?.[key]); if (decoded !== undefined) return decoded; throw attr_error(tree, LIVETREE_INVALID_ATTRIBUTE_VALUE_ERROR_CODE, operation, key, "stored value is not canonical"); } function has_attr(node: HsonNode, key: string): boolean { const attrs = node.$_attrs; return attrs !== undefined && Object.prototype.hasOwnProperty.call(attrs, key); } function read_attr_keys(node: HsonNode): string[] { return Object.keys(node.$_attrs ?? {}) .filter(is_public_attr_name) .sort(); } function read_attrs(tree: LiveTree, operation: string): CanonicalPublicAttrs { const input: Record = {}; for (const name of read_attr_keys(tree.node)) input[name] = tree.node.$_attrs?.[name]; const attrs = decode_public_attrs(input); if (attrs !== undefined) return attrs; throw attr_error(tree, LIVETREE_INVALID_ATTRIBUTE_VALUE_ERROR_CODE, operation, undefined, "stored attrs are not canonical"); } /** @internal Shared exact document-authoring normalization. */ export function normalize_livetree_attr_name(tree: LiveTree, input: unknown, operation: string): string { if (typeof input !== "string") { throw attr_error(tree, LIVETREE_INVALID_ATTRIBUTE_NAME_ERROR_CODE, operation, undefined, "name must be a string"); } const key = canonical_attr_key(tree, input); if (key.startsWith(HSON_META_MARKUP_PREFIX)) { throw attr_error(tree, LIVETREE_PROTECTED_ATTRIBUTE_ERROR_CODE, operation, key, "system metadata is protected"); } if (!is_public_attr_name(key)) { throw attr_error(tree, LIVETREE_INVALID_ATTRIBUTE_NAME_ERROR_CODE, operation, key, "name is not a canonical bare Hson name"); } return key; } /** @internal Shared exact document-authoring normalization. */ export function normalize_livetree_attr_value( tree: LiveTree, name: string, input: unknown, operation: string, ): CanonicalPublicAttrValue { const value = decode_public_attr_value(name, input); if (value !== undefined) return value; throw attr_error(tree, LIVETREE_INVALID_ATTRIBUTE_VALUE_ERROR_CODE, operation, name, "value is not canonical"); } /** @internal Shared exact document-authoring normalization. */ export function normalize_livetree_attrs_input(tree: LiveTree, input: unknown, operation: string): CanonicalPublicAttrs { if (!is_plain_record(input)) { throw attr_error(tree, LIVETREE_INVALID_ATTRIBUTE_VALUE_ERROR_CODE, operation, undefined, "values must be an ordinary-attribute bag"); } const normalized: Record = {}; const entries = Object.entries(input); for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]; if (entry === undefined) continue; const [inputName, inputValue] = entry; try { const name = normalize_livetree_attr_name(tree, inputName, operation); normalized[name] = normalize_livetree_attr_value(tree, name, inputValue, operation); } catch (cause) { if (cause instanceof LiveTreeAttributeError) { throw new LiveTreeAttributeError(cause.code, operation, cause.quid, cause.reason, { attributeName: cause.attributeName, inputIndex: index, }); } throw cause; } } return normalize_attrs_result(normalized); } function normalize_attrs_result(input: Readonly>): CanonicalPublicAttrs { const attrs = decode_public_attrs(input); if (attrs === undefined) throw new Error("LiveTree attrs planner produced an invalid canonical bag"); return attrs; } /** @internal Shared exact document-authoring normalization. */ export function normalize_livetree_drop_names(tree: LiveTree, input: unknown, operation: string): readonly string[] { if (!Array.isArray(input)) { throw attr_error(tree, LIVETREE_INVALID_ATTRIBUTE_NAME_ERROR_CODE, operation, undefined, "names must be an array"); } return Object.freeze(input.map((name, index) => { try { return normalize_livetree_attr_name(tree, name, operation); } catch (cause) { if (cause instanceof LiveTreeAttributeError) { throw new LiveTreeAttributeError(cause.code, operation, cause.quid, cause.reason, { attributeName: cause.attributeName, inputIndex: index, }); } throw cause; } })); } function apply_attrs_replacement( tree: TTree, current: CanonicalPublicAttrs, next: CanonicalPublicAttrs, ): TTree { if (canonical_public_attrs_equal(current, next)) return tree; const node = tree.node; if (Object.keys(next).length === 0) delete node.$_attrs; else node.$_attrs = clone_node(next); project_attrs_replacement(node, current, next); return tree; } function project_attrs_replacement( node: HsonNode, current: CanonicalPublicAttrs, next: CanonicalPublicAttrs, ): void { const element = get_el_for_node(node); if (element === undefined) return; const namespace = element.namespaceURI === "http://www.w3.org/2000/svg" ? "svg" : "html"; for (const [name, value] of Object.entries(current)) { if (!Object.prototype.hasOwnProperty.call(next, name)) { element.removeAttribute(lower_browser_attribute_value(name, value, namespace)?.name ?? name); } } for (const [name, value] of Object.entries(next)) { const lowered = lower_browser_attribute_value(name, value, namespace); if (lowered === undefined) element.removeAttribute(name); else element.setAttribute(lowered.name, lowered.value); } realize_changed_form_properties(element, current, next); } function realize_changed_form_properties( element: Element, current: CanonicalPublicAttrs, next: CanonicalPublicAttrs, ): void { const tagName = (element as { tagName?: unknown }).tagName; if (typeof tagName !== "string") return; const tag = tagName.toLowerCase(); if ((tag === "input" || tag === "textarea" || tag === "select") && current.value !== next.value) { const value = next.value; (element as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).value = value == null ? "" : String(value); } if (tag === "input" && current.checked !== next.checked) { const checked = next.checked; (element as HTMLInputElement).checked = typeof checked === "boolean" ? checked : typeof checked === "string" ? checked === "true" : typeof checked === "number" ? checked !== 0 : false; } } /** @internal Shared exact document-authoring normalization. */ export function normalize_livetree_flag_names( tree: LiveTree, names: readonly string[], operation: string, rejectStyle: boolean, ): readonly string[] { const normalized = names.map((name, index) => { try { const key = normalize_livetree_attr_name(tree, name, operation); if (rejectStyle && key === "style") { throw attr_error( tree, LIVETREE_INVALID_ATTRIBUTE_NAME_ERROR_CODE, operation, key, "style is not a semantic flag", ); } return key; } catch (cause) { if (cause instanceof LiveTreeAttributeError) { throw new LiveTreeAttributeError(cause.code, operation, cause.quid, cause.reason, { attributeName: cause.attributeName, inputIndex: index, }); } throw cause; } }); return Object.freeze(normalized); } /** @internal Shared exact document-authoring normalization. */ export function livetree_flag_values(names: readonly string[]): CanonicalPublicAttrs { const values: Record = {}; for (const name of names) values[name] = name; return normalize_attrs_result(values); } function attr_error( tree: LiveTree, code: ConstructorParameters[0], operation: string, attributeName: string | undefined, reason: string, ): LiveTreeAttributeError { return new LiveTreeAttributeError(code, operation, diagnostic_quid(tree), reason, { attributeName }); } function diagnostic_quid(tree: LiveTree): string { return get_quid(tree.node, runtime_for_tree(tree)) ?? UNASSIGNED_QUID_DIAGNOSTIC; } function is_plain_record(value: unknown): value is Readonly> { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; }