Source: lib/core/trace/dom.js

/**
 * @file dom.js
 * @description Stable, replayable descriptions of DOM nodes.
 *
 * A trace refers to elements in two very different situations: when a human
 * reads `avenx trace view` and wants to recognise the button they clicked, and
 * when replay has to find that same button again in a freshly mounted
 * application. The second requirement is the strict one — a description that
 * reads well but cannot be resolved makes a trace unreplayable.
 *
 * So every reference carries both a selector and the node's index among that
 * selector's matches. Replay can then resolve `button.qty-inc` unambiguously
 * even when the page has twelve of them, without depending on ids the
 * application does not have.
 * @module lib/core/trace/dom
 */

import { tracer } from './tracer.js';
import { TraceNodeType } from './schema.js';

/**
 * How many class names are folded into a selector.
 *
 * Scoped-CSS class hashes make Avenx elements class-heavy; taking every class
 * would produce selectors that are long to read and brittle against an
 * unrelated style change.
 * @type {number}
 */
const MAX_SELECTOR_CLASSES = 2;

/**
 * Attributes Avenx adds for its own bookkeeping, which must never end up in a
 * selector: they are re-derived on every render and would not survive replay.
 * @type {RegExp}
 */
const INTERNAL_ATTR = /^(data-ax-|data-avenx-)/;

/**
 * How much of a changed attribute or text value a trace keeps.
 * @type {number}
 */
const MAX_DOM_VALUE = 120;

/**
 * Builds a CSS selector for an element.
 * @param {Element} el - The element.
 * @returns {string} A selector, or the tag name when nothing else is available.
 */
function selectorFor(el) {
  const tag = (el.tagName || 'unknown').toLowerCase();

  if (el.id && !INTERNAL_ATTR.test(el.id)) {
    return `#${el.id}`;
  }

  const className = typeof el.className === 'string' ? el.className : '';
  const classes = className
    .split(/\s+/)
    .filter((name) => name && !INTERNAL_ATTR.test(name))
    .slice(0, MAX_SELECTOR_CLASSES);

  return classes.length > 0 ? `${tag}.${classes.join('.')}` : tag;
}

/**
 * Finds the Avenx component instance an element belongs to.
 * @param {Node|null} node - The starting node.
 * @returns {object|null} The owning component instance, if any.
 */
export function ownerComponent(node) {
  let current = node;
  while (current) {
    if (current.__avenx_comp_instance) {
      return current.__avenx_comp_instance;
    }
    current = current.parentNode;
  }
  return null;
}

/**
 * Describes a DOM node well enough to display it and to find it again.
 * @param {Node|null} node - The node to describe.
 * @returns {{selector: string, nth: number, component: string, uid: number}|null}
 *   A reference, or null when there is no element to describe.
 */
export function describeNode(node) {
  if (!node) {
    return null;
  }

  // Text nodes are addressed through the element that contains them: a text
  // node has no selector of its own, and the containing element is what a
  // reader recognises anyway.
  const el = node.nodeType === 3 ? node.parentElement : node;
  if (!el || el.nodeType !== 1) {
    return null;
  }

  const selector = selectorFor(el);
  let nth = 0;

  // The index disambiguates the selector. A missing document (a detached
  // subtree, a headless mount) simply yields index 0, which resolve() below
  // treats as "the first match".
  try {
    const root = el.ownerDocument;
    if (root && typeof root.querySelectorAll === 'function') {
      const matches = root.querySelectorAll(selector);
      for (let i = 0; i < matches.length; i++) {
        if (matches[i] === el) {
          nth = i;
          break;
        }
      }
    }
  } catch {
    // An exotic selector (an id starting with a digit, for instance) can make
    // querySelectorAll throw. Falling back to index 0 keeps the description
    // usable for display even when it is not precise enough to replay.
  }

  const ref = { selector, nth };
  const owner = ownerComponent(el);
  if (owner) {
    ref.component = owner.constructor && owner.constructor.name;
    ref.uid = owner.uid;
  }
  return ref;
}

/**
 * Resolves a reference produced by {@link describeNode} back to an element.
 * @param {object|null} ref - The reference.
 * @param {Document|Element} [root] - Where to search. Defaults to the document.
 * @returns {Element|null} The element, or null when it cannot be found.
 */
export function resolveNode(ref, root) {
  if (!ref || !ref.selector) {
    return null;
  }
  const scope = root || (typeof document !== 'undefined' ? document : null);
  if (!scope || typeof scope.querySelectorAll !== 'function') {
    return null;
  }
  try {
    const matches = scope.querySelectorAll(ref.selector);
    return matches[ref.nth || 0] || null;
  } catch {
    return null;
  }
}

/**
 * Records a DOM mutation the patcher applied, attributed to whatever caused it.
 *
 * Called from the existing patch operations rather than from a second diffing
 * pass or a MutationObserver: the point of the trace is to say which *state
 * change* produced a DOM change, and only the patcher knows both halves.
 *
 * Values are truncated. A trace records that `.total` went from `"$24.00"` to
 * `"$36.00"`, not the innerHTML of the subtree around it.
 * @param {string} op - The operation: `text`, `attr`, `remove-attr`, `insert`, `remove`, `replace`.
 * @param {Node} node - The node that changed, or its parent for structural ops.
 * @param {object} [fields] - Operation-specific detail (`name`, `from`, `to`).
 */
export function traceDomOp(op, node, fields = {}) {
  if (!tracer.sink) {
    return;
  }
  const ref = describeNode(node);
  if (!ref) {
    return;
  }
  tracer.record(TraceNodeType.DOM, {
    op,
    target: { selector: ref.selector, nth: ref.nth },
    component: ref.component,
    ...fields,
  });
}

/**
 * Clamps a DOM value so a trace records the change rather than the document.
 * @param {any} value - The raw attribute or text value.
 * @returns {string|null} A bounded string, or null for an absent value.
 */
export function clampDomValue(value) {
  if (value === null || value === undefined) {
    return null;
  }
  const text = String(value);
  return text.length > MAX_DOM_VALUE ? `${text.slice(0, MAX_DOM_VALUE)}…` : text;
}

/**
 * Renders a node reference for display in `avenx trace view`.
 * @param {object|null} ref - The reference.
 * @returns {string} A short human-readable form.
 */
export function formatNodeRef(ref) {
  if (!ref || !ref.selector) {
    return '<unknown>';
  }
  const suffix = ref.nth > 0 ? `[${ref.nth}]` : '';
  return `<${ref.selector}${suffix}>`;
}