/**
* @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'],
]);