Source: lib/core/renderer/program/bindings.js

/**
 * @file bindings.js
 * @description What each render-program op does to the DOM.
 *
 * One function per op kind, each writing to exactly one node. These are the
 * leaves of the new architecture: everything above them exists to arrange for
 * the right one of these to run with the right value, and nothing below them
 * touches the DOM.
 *
 * ## Matching the string renderer's semantics
 *
 * These replace a pipeline that escaped a value into HTML, parsed that HTML,
 * and diffed the result into the document. Anything the round trip did
 * incidentally is behaviour applications now depend on, so it is reproduced
 * here deliberately rather than rediscovered as a bug report:
 *
 * - **Escaping.** The old path escaped a value and the parser unescaped it, so
 *   the text that reached the document was the value verbatim. Writing
 *   `node.data` directly produces the same string and cannot be mis-escaped,
 *   because it never becomes markup at all.
 * - **`null` renders as nothing**, not as the string "null".
 * - **`SafeHtml` in a `{{ }}` interpolation is inserted as markup.** The old
 *   path skipped escaping for it; a text write would have shown the tags. Text
 *   ops therefore check for it and hand over to the raw path.
 * - **Boolean attributes.** `disabled="false"` removed the attribute and set
 *   the property; anything else set both.
 * - **URL attributes** were sanitised on every parsed tree, so they are
 *   sanitised on every write here.
 *
 * ## Where the security boundary sits
 *
 * Nowhere in this file is a value turned into markup unless the op is `raw` or
 * the value is a `SafeHtml` -- the same two doors the string renderer had, and
 * no new ones. Expressions are still evaluated by the AST evaluator through the
 * callback these functions are given; this module receives values, never source.
 * @module lib/core/renderer/program/bindings
 */

import { isBooleanAttribute } from '../constants.js';
import { HtmlEscaper, SafeHtml } from '../../security/escapeHtml.js';
import { sanitizeUrlAttribute, isUrlAttribute } from '../../security/urlPolicy.js';
import { tracer } from '../../trace/tracer.js';
import { traceDomOp, clampDomValue } from '../../trace/dom.js';
import { logger } from '../../runtime/AvenxLogger.js';
import { AvenxErrorCodes, formatMessage } from '../../runtime/AvenxError.js';

/**
 * The escaper the string renderer used, reused rather than reimplemented.
 *
 * `data-ax-html` escapes a plain value and only lets a `SafeHtml` through. A
 * second escaping implementation here could drift from that one, and the
 * direction it would drift in is "escapes less".
 * @type {HtmlEscaper}
 */
const escaper = new HtmlEscaper();

/**
 * Values that mean "this boolean attribute is off".
 *
 * Deliberately not JavaScript falsiness. The string renderer decided by looking
 * at the rendered attribute text, where the only off value was the literal
 * "false" -- so `0` was on. Matching that keeps `disabled="{{ count }}"` behaving
 * as it does today. Empty and null are added because they reached the old path
 * as an empty attribute value, which read as *on*: an attribute bound to
 * nothing being present is a bug rather than a semantic worth carrying forward.
 * @param {any} value - The evaluated value.
 * @returns {boolean} True when the attribute should be absent.
 */
function isBooleanOff(value) {
  return value === false || value === 'false' || value === null || value === undefined || value === '';
}

/**
 * Elements whose `value` attribute stops driving their value once a user has
 * typed into them.
 * @type {Set<string>}
 */
const VALUE_PROPERTY_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);

/**
 * Mirrors an attribute onto the DOM property where the two can diverge.
 *
 * For a form control the `value` attribute is the *default* value: once the
 * user has typed, the property and the attribute are independent, and setting
 * the attribute alone leaves what the user sees unchanged. Two-way binding
 * depends on this -- a state change made in code has to reach the control the
 * user has already touched. The string renderer did the same thing in its
 * attribute patch, for the same reason.
 * @param {Element} element - The bound element.
 * @param {string} name - The attribute just written.
 * @param {string} value - The value written.
 */
function syncValueProperty(element, name, value) {
  if (name !== 'value' || !VALUE_PROPERTY_TAGS.has(element.nodeName)) {
    return;
  }
  if (element.value !== value) {
    element.value = value;
  }
}

/**
 * Converts an evaluated value to the text it renders as.
 * @param {any} value - The evaluated value.
 * @returns {string} The text, with null and undefined rendering as nothing.
 */
function asText(value) {
  return value === null || value === undefined ? '' : String(value);
}

/**
 * Writes a value into a dynamic text node.
 * @param {Text} node - The text node the compiler reserved.
 * @param {any} value - The evaluated value.
 * @param {object} binding - Per-binding state, used to manage raw ranges.
 */
export function applyText(node, value, binding) {
  if (value instanceof SafeHtml) {
    applyRaw(node, value, binding);
    return;
  }

  // A binding that previously held raw markup and now holds text has to clear
  // the nodes it inserted, or the old markup would sit beside the new text.
  if (binding.rawNodes) {
    clearRawRange(binding);
  }

  const next = asText(value);
  if (node.data === next) {
    return;
  }
  const previous = node.data;
  node.data = next;

  if (tracer.on) {
    traceDomOp('text', node, { from: clampDomValue(previous), to: clampDomValue(next) });
  }
}

/**
 * Replaces the markup a raw binding owns.
 *
 * A raw binding owns a *range* rather than a node: one expression can produce
 * any number of elements. The compiler's text marker stays in the document as
 * an anchor, and the nodes the binding inserted are tracked so the next
 * evaluation can remove exactly those and nothing else. Clearing by emptying
 * the parent would take siblings that belong to other bindings.
 * @param {Text} anchor - The anchor node the compiler reserved.
 * @param {any} value - The evaluated value.
 * @param {object} binding - Per-binding state holding the current range.
 */
export function applyRaw(anchor, value, binding) {
  const markup = value === null || value === undefined ? '' : String(value);
  if (binding.rawHtml === markup) {
    return;
  }
  binding.rawHtml = markup;

  clearRawRange(binding);

  const parent = anchor.parentNode;
  if (!parent || markup === '') {
    return;
  }

  const host = document.createElement('template');
  let source;
  if (host && 'content' in host) {
    host.innerHTML = markup;
    source = host.content;
  } else {
    const holder = document.createElement('div');
    holder.innerHTML = markup;
    source = holder;
  }

  // Collected first, then inserted in order after the anchor. Inserting
  // straight from the fragment would work too, but reading `firstChild` while
  // mutating the same list is the shape that produces reversed output when the
  // insertion point is recomputed, and this is not the place to be clever.
  const inserted = [];
  while (source.firstChild) {
    inserted.push(source.removeChild(source.firstChild));
  }

  let cursor = anchor;
  for (const node of inserted) {
    parent.insertBefore(node, cursor.nextSibling);
    cursor = node;
  }

  binding.rawNodes = inserted;
  if (tracer.on) {
    traceDomOp('html', anchor, { to: clampDomValue(markup) });
  }
}

/**
 * Removes the nodes a raw binding previously inserted.
 * @param {object} binding - Per-binding state.
 */
function clearRawRange(binding) {
  if (!binding.rawNodes) return;
  for (const node of binding.rawNodes) {
    if (node.parentNode) {
      node.parentNode.removeChild(node);
    }
  }
  binding.rawNodes = null;
}

/**
 * Sets an attribute from a whole-value expression.
 * @param {Element} element - The bound element.
 * @param {string} name - The attribute name.
 * @param {any} value - The evaluated value.
 */
export function applyAttribute(element, name, value) {
  // `null` becomes an empty attribute, not a removed one.
  //
  // Removing it would be the better behaviour -- `href="{{ maybe }}"` resolving
  // to `href=""` links to the current page, and every other framework removes
  // it -- and it is deliberately not done here. The string renderer produces
  // `title=""` for a null interpolation, because the interpolation contributes
  // nothing to the attribute value, and both renderers will be in service
  // together for as long as `<@for>` and `<slot>` fall back. Two renderers that
  // disagree about one attribute is a worse problem than one attribute with an
  // unfortunate value, and the parity test that found this divergence is the
  // reason it is written down rather than fixed quietly.
  //
  // Changing it is a behaviour change for both paths at once, not a property of
  // this one.
  const raw = value === null || value === undefined ? '' : String(value);
  const next = isUrlAttribute(name) ? sanitizeUrlAttribute(name, raw) : raw;

  if (element.getAttribute(name) !== next) {
    element.setAttribute(name, next);
    if (tracer.on) {
      traceDomOp('attr', element, { name, to: clampDomValue(next) });
    }
  }

  // Outside the attribute comparison: the attribute can already hold this value
  // while the property holds what the user typed, which is exactly the case
  // two-way binding has to correct.
  syncValueProperty(element, name, next);
}

/**
 * Sets an attribute assembled from literal and expression parts.
 * @param {Element} element - The bound element.
 * @param {string} name - The attribute name.
 * @param {string} value - The already-joined value.
 */
export function applyAttributeParts(element, name, value) {
  const next = isUrlAttribute(name) ? sanitizeUrlAttribute(name, value) : value;
  if (element.getAttribute(name) !== next) {
    element.setAttribute(name, next);
    if (tracer.on) {
      traceDomOp('attr', element, { name, to: clampDomValue(next) });
    }
  }
  syncValueProperty(element, name, next);
}

/**
 * Sets or removes a boolean attribute, and mirrors it onto the property.
 * @param {Element} element - The bound element.
 * @param {string} name - The attribute name.
 * @param {any} value - The evaluated value.
 */
export function applyBoolean(element, name, value) {
  const off = isBooleanOff(value);

  if (off) {
    if (element.hasAttribute(name)) {
      element.removeAttribute(name);
    }
  } else if (element.getAttribute(name) !== 'true') {
    element.setAttribute(name, 'true');
  }

  // The property is what the browser acts on; the attribute is what a test or
  // a stylesheet reads. The string renderer set both, so both are set here.
  if (isBooleanAttribute(name)) {
    element[name] = !off;
  }

  if (tracer.on) {
    traceDomOp('attr', element, { name, to: off ? null : 'true' });
  }
}

/**
 * Toggles an element's visibility, preserving its authored display value.
 * @param {Element} element - The bound element.
 * @param {any} value - The evaluated value; truthiness decides.
 * @param {object} binding - Per-binding state, holding the authored display.
 */
export function applyShow(element, value, binding) {
  const visible = !!value;

  if (binding.originalDisplay === undefined) {
    // Read once, before the first hide, so a later show restores what the
    // author wrote rather than the empty string a hidden element reports.
    binding.originalDisplay = (element.style && element.style.display) || '';
    element.__originalDisplay = binding.originalDisplay;
  }

  const next = visible ? binding.originalDisplay : 'none';
  if (element.style && element.style.display !== next) {
    element.style.display = next;
  }
}

/**
 * Applies a class binding, removing only the classes it previously added.
 * @param {Element} element - The bound element.
 * @param {any} value - A string of class names, or an object of name to flag.
 * @param {object} binding - Per-binding state, holding the previous class list.
 */
export function applyClass(element, value, binding) {
  const next = [];
  if (typeof value === 'string') {
    for (const name of value.split(/\s+/)) {
      if (name) next.push(name);
    }
  } else if (value && typeof value === 'object') {
    for (const [name, enabled] of Object.entries(value)) {
      if (enabled) next.push(name);
    }
  }

  const previous = binding.classes || [];

  // Remove only what this binding added. The element's authored classes and the
  // component's scoped class share the same attribute and must survive.
  for (const name of previous) {
    if (!next.includes(name)) {
      element.classList.remove(name);
    }
  }
  for (const name of next) {
    if (!previous.includes(name)) {
      element.classList.add(name);
    }
  }

  binding.classes = next;
  element.__lastAxClasses = next;
}

/**
 * Replaces an element's inner HTML from an expression.
 * @param {Element} element - The bound element.
 * @param {any} value - The evaluated value.
 */
export function applyHtml(element, value) {
  let markup;
  if (value instanceof SafeHtml) {
    markup = String(value);
  } else if (value === null || value === undefined) {
    markup = '';
  } else {
    // Matches the string renderer: a plain value in `data-ax-html` is escaped,
    // so only a SafeHtml can introduce markup. Removing that check would turn
    // every `data-ax-html` in every application into an injection point.
    markup = escaper.escape(value);
  }

  if (element.innerHTML !== markup) {
    element.innerHTML = markup;
  }
}

/**
 * Stores an evaluated prop for a child component mounted at this element.
 *
 * The value is held on the element rather than pushed straight into the child,
 * because at the moment a prop op first runs the child does not exist yet --
 * the parent's tree is still detached. The owner reads these when it mounts or
 * refreshes its children.
 *
 * Values are kept as values. The string path carried props through
 * `data-props-*` attributes and re-evaluated them from source on every render
 * of the parent, which meant every prop of every child was re-evaluated
 * whenever anything in the parent changed.
 * @param {Element} element - The child's mount point.
 * @param {string} name - The prop name.
 * @param {any} value - The evaluated value.
 * @returns {boolean} True when the stored value changed.
 */
export function applyProp(element, name, value) {
  let store = element.__axProps;
  if (!store) {
    store = {};
    element.__axProps = store;
  }
  if (Object.prototype.hasOwnProperty.call(store, name) && store[name] === value) {
    return false;
  }
  store[name] = value;
  return true;
}

/**
 * Reports a binding that threw, without taking the rest of the update with it.
 * @param {object} op - The op that failed.
 * @param {Error} error - What went wrong.
 */
export function reportBindingError(op, error) {
  logger.warn(formatMessage(AvenxErrorCodes.TEMPLATE_RENDER_ERROR, op.x || op.a || op.k, error));
}

/**
 * Applies an inline style binding, removing only the properties it set.
 *
 * `data-ax-style` has been documented public API for as long as the directive
 * has existed, and applied nothing: the string renderer never implemented it,
 * so the E2E suite pinned it as a known gap. It is implemented here because the
 * compiled path is where a directive's behaviour now lives, and leaving it
 * unimplemented would have carried a documented no-op into the new
 * architecture.
 *
 * Only the properties this binding wrote are cleared on change, so a style the
 * author wrote in the template's own `style` attribute survives.
 * @param {Element} element - The bound element.
 * @param {any} value - A CSS text string, or an object of property to value.
 * @param {object} binding - Per-binding state, holding the previous properties.
 */
export function applyStyle(element, value, binding) {
  if (!element || !element.style) return;

  /** @type {Object<string, string>} */
  const next = {};

  if (typeof value === 'string') {
    for (const declaration of value.split(';')) {
      const at = declaration.indexOf(':');
      if (at === -1) continue;
      const property = declaration.slice(0, at).trim();
      if (property) next[property] = declaration.slice(at + 1).trim();
    }
  } else if (value && typeof value === 'object') {
    for (const [property, entry] of Object.entries(value)) {
      if (entry === null || entry === undefined || entry === false) continue;
      next[property] = String(entry);
    }
  }

  const previous = binding.styleProperties || {};
  for (const property of Object.keys(previous)) {
    if (next[property] === undefined) {
      element.style.removeProperty(toCssProperty(property));
    }
  }

  for (const [property, entry] of Object.entries(next)) {
    if (previous[property] === entry) continue;
    // `setProperty` takes hyphenated names; assigning through `style` takes
    // camelCase. Normalising to the former means an author can write either.
    element.style.setProperty(toCssProperty(property), entry);
  }

  binding.styleProperties = next;
}

/**
 * Converts a camelCase style property name to its CSS spelling.
 * @param {string} name - The property name as written.
 * @returns {string} The hyphenated name.
 */
function toCssProperty(name) {
  return name.includes('-') ? name : name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
}

/**
 * Attaches a declared event handler to an element.
 *
 * The string renderer re-read `data-ax-event` off the DOM and re-bound every
 * handler on every update, because a diff could have replaced the node the
 * listener was on. A compiled element is created once and never replaced, so
 * one `addEventListener` at mount is both correct and the whole cost.
 *
 * Modifiers are applied here rather than inside the handler body so the
 * compiled statement stays exactly what the author wrote.
 * @param {Element} element - The element carrying the handler.
 * @param {object} op - The event op, with its name and modifiers.
 * @param {function(Event): any} run - Runs the compiled handler.
 */
export function attachEvent(element, op, run) {
  if (!element || typeof element.addEventListener !== 'function') return;

  const modifiers = op.m || [];
  const passive = modifiers.includes('passive');
  const once = modifiers.includes('once');

  const listener = (event) => {
    if (modifiers.includes('self') && event.target !== event.currentTarget) return;
    // A passive listener may not cancel the event; calling preventDefault on
    // one is a console warning in every engine and cancels nothing.
    if (modifiers.includes('prevent') && !passive) event.preventDefault();
    if (modifiers.includes('stop')) event.stopPropagation();

    if (KEY_MODIFIERS.size > 0) {
      for (const modifier of modifiers) {
        const expected = KEY_MODIFIERS.get(modifier);
        if (expected !== undefined && event.key !== expected) return;
      }
    }

    run(event);
  };

  element.addEventListener(op.n, listener, { passive, once });
}

/**
 * Key names the `@keydown.enter` family of modifiers filters on.
 * @type {Map<string, string>}
 */
const KEY_MODIFIERS = new Map([
  ['enter', 'Enter'],
  ['escape', 'Escape'],
  ['esc', 'Escape'],
  ['tab', 'Tab'],
  ['space', ' '],
  ['up', 'ArrowUp'],
  ['down', 'ArrowDown'],
  ['left', 'ArrowLeft'],
  ['right', 'ArrowRight'],
  ['delete', 'Delete'],
  ['backspace', 'Backspace'],
]);