Source: lib/compiler/parser/htmlTree.js

/**
 * @file htmlTree.js
 * @description The template tree parser and serializer.
 *
 * `parser/tokenizer.js` answers "where does this tag begin and end in the
 * source". This module answers the next question: what tree do those tags
 * describe. It was previously private to `ComponentParser`, which was fine
 * while the compiler had exactly one consumer for it. The render compiler
 * (`lib/compiler/render/`) is a second, and a second copy of an HTML parser is
 * how two halves of one compiler come to disagree about what a template says.
 *
 * It is deliberately not a spec-compliant HTML parser. Avenx templates are
 * authored rather than scraped, and by the time a template reaches here the
 * declaration tags are gone and the directives have been rewritten into
 * ordinary elements. What it guarantees is that `serializeHTML(parseHTML(x))`
 * round-trips a template the compiler itself produced.
 * @module lib/compiler/parser/htmlTree
 */

/**
 * The set of HTML tags that are void (self-closing / no children) by default.
 * @type {string[]}
 */
export const DEFAULT_VOID_TAGS = [
  'area',
  'base',
  'br',
  'col',
  'embed',
  'hr',
  'img',
  'input',
  'link',
  'meta',
  'param',
  'source',
  'track',
  'wbr',
];

/**
 * Builds the effective set of void tags for a parse or serialize pass.
 * @param {string[]} [customVoidTags] - Additional void tag names (lowercase).
 * @returns {Set<string>} The effective set.
 */
export function buildVoidTagsSet(customVoidTags = []) {
  return new Set([...DEFAULT_VOID_TAGS, ...customVoidTags]);
}

/**
 * A lightweight node representation for parsing HTML templates.
 */
export class HTMLNode {
  /**
   * Creates an instance of HTMLNode.
   * @param {string} type - The node type.
   * @param {string} [tagName] - The tag name.
   * @param {Object} [attrs] - The attribute map.
   * @param {boolean} [isSelfClosing] - Whether the tag is self-closing.
   */
  constructor(type, tagName = '', attrs = {}, isSelfClosing = false) {
    this.type = type;
    this.tagName = tagName;
    this.attrs = attrs;
    this.isSelfClosing = isSelfClosing;
    /**
     * The tag's attribute text exactly as written, for consumers that need the
     * source rather than the parsed map. Set by {@link parseHTML}; nodes built
     * by hand leave it empty.
     * @type {string}
     */
    this.rawAttrs = '';
    this.line = null;
    this.column = null;
    /** @type {HTMLNode[]} */
    this.children = [];
    this.content = '';
    /** @type {Set<string>} */
    this.contracts = new Set();
    this.initContracts();
  }

  /**
   * Initializes contracts attached to this node via tag names or attributes.
   */
  initContracts() {
    const valid = ['static', 'pure', 'deterministic', 'isolated'];
    if (this.tagName && this.tagName.startsWith('@')) {
      const contractTag = this.tagName.slice(1).toLowerCase();
      if (valid.includes(contractTag)) {
        this.contracts.add(contractTag);
      }
    }
    if (this.attrs && typeof this.attrs === 'object') {
      for (const c of valid) {
        if (this.attrs[c] !== undefined && this.attrs[c] !== 'false') {
          this.contracts.add(c);
        }
      }
      if (this.attrs['data-ax-contract']) {
        const list = this.attrs['data-ax-contract'].split(/\s+/).filter(Boolean);
        for (const item of list) {
          const lower = item.toLowerCase();
          if (valid.includes(lower)) {
            this.contracts.add(lower);
          }
        }
      }
    }
  }
}

/**
 * Parses an attribute string into a key-value object.
 *
 * Handles three attribute forms:
 *  - Quoted values: `name="value"` or `name='value'`. A backslash-escaped
 *    quote (`\"` or `\'`) inside the value is preserved verbatim rather than
 *    ending the value early, so expressions containing an apostrophe or a
 *    quote character (e.g. `@click='say(\'hi\')'`) parse correctly instead
 *    of being split into several bogus attributes.
 *  - Unquoted values: `name=value`, read up to the next whitespace or `>`.
 *  - Valueless boolean attributes: `disabled`, mapped to the string `'true'`
 *    (matching the `attr="true"` / `attr="false"` convention the runtime's
 *    boolean-attribute handling already expects, rather than `null`).
 * @param {string} attrStr
 * @returns {Object<string, string>}
 */
export function parseAttributes(attrStr) {
  const attrs = {};
  if (!attrStr) return attrs;

  const len = attrStr.length;
  const isWhitespace = (ch) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
  const isNameChar = (ch) => /[@\w:.\-[\]]/.test(ch);

  let i = 0;
  while (i < len) {
    // Skip whitespace between attributes.
    while (i < len && isWhitespace(attrStr[i])) i++;
    if (i >= len) break;

    // Read the attribute name.
    const nameStart = i;
    while (i < len && isNameChar(attrStr[i])) i++;
    if (i === nameStart) {
      // Stray character that isn't part of a valid attribute name; skip it
      // so a malformed fragment can't stall the scan in an infinite loop.
      i++;
      continue;
    }
    const name = attrStr.slice(nameStart, i);

    // Look ahead (past whitespace) for an '=' sign.
    let lookahead = i;
    while (lookahead < len && isWhitespace(attrStr[lookahead])) lookahead++;

    if (attrStr[lookahead] === '=') {
      i = lookahead + 1;
      while (i < len && isWhitespace(attrStr[i])) i++;

      const quote = attrStr[i];
      if (quote === '"' || quote === "'") {
        i++;
        let value = '';
        while (i < len) {
          const ch = attrStr[i];
          if (ch === '\\' && i + 1 < len) {
            value += ch + attrStr[i + 1];
            i += 2;
            continue;
          }
          if (ch === quote) {
            i++;
            break;
          }
          value += ch;
          i++;
        }
        attrs[name] = value;
      } else {
        // Unquoted value: read until whitespace or the tag's closing '>'.
        const valueStart = i;
        while (i < len && !isWhitespace(attrStr[i]) && attrStr[i] !== '>') i++;
        attrs[name] = attrStr.slice(valueStart, i);
      }
    } else {
      // Valueless boolean attribute, e.g. `disabled`.
      attrs[name] = 'true';
    }
  }

  return attrs;
}

/**
 * Calculates 1-based line and column numbers for a character offset in a source string.
 * @param {string} source - The original source code string.
 * @param {number} offset - The zero-based character index.
 * @returns {{ line: number, column: number }}
 */
export function getLineAndColumn(source, offset) {
  let line = 1;
  let lastNewline = -1;
  for (let i = 0; i < offset && i < source.length; i++) {
    if (source[i] === '\n') {
      line++;
      lastNewline = i;
    }
  }
  const column = offset - lastNewline;
  return { line, column };
}

/**
 * The arms of a conditional chain.
 *
 * They are parsed as siblings rather than as a nest, which is what lets
 * `</@if>` terminate the chain and what lets a consumer read the arms as an
 * ordered list instead of unwinding a ladder.
 * @type {Set<string>}
 */
const IF_CHAIN_TAGS = new Set(['@if', '@elseif', '@elif', '@else']);

/**
 * The arms that continue a chain, and therefore end the arm before them.
 * @type {Set<string>}
 */
const IF_CONTINUATION_TAGS = new Set(['@elseif', '@elif', '@else']);

/**
 * Finds the offset of the `>` that ends the tag opening at `start`.
 *
 * Quoting is honoured for every tag, because a `>` inside `title="a > b"` has
 * never been the end of a tag. Bracket depth is honoured only for `@`-prefixed
 * directive tags, and that exception is the point of this function.
 *
 * A directive header carries an expression rather than attributes:
 *
 * ```html
 * <@for row in rows.filter(r => r.score > 90)>
 * ```
 *
 * Scanning for the first unquoted `>` ends that tag at `r.score `, which is why
 * the previous implementation truncated the list expression to
 * `rows.filter(r =` and reported it as a malformed template expression. The
 * expression is not malformed; the scan was. Inside `(`, `[` or `{` a `>` is a
 * comparison or an arrow, never a tag end, so the scan tracks depth and only
 * accepts a `>` at depth zero.
 *
 * The exception is deliberately not extended to ordinary elements. `<div
 * data-x=a(b>c)>` is not markup anyone writes, and widening the rule would
 * change how existing templates parse for no gain.
 * @param {string} html - The full template source.
 * @param {number} start - Offset of the `<` that opens the tag.
 * @returns {number} Offset of the closing `>`, or -1 when the tag is unterminated.
 */
export function scanTagEnd(html, start) {
  const directive = html[start + 1] === '@';
  let quote = null;
  let depth = 0;

  for (let i = start + 1; i < html.length; i++) {
    const ch = html[i];

    if (quote) {
      if (ch === '\\') {
        i++;
      } else if (ch === quote) {
        quote = null;
      }
      continue;
    }

    if (ch === '"' || ch === "'") {
      quote = ch;
      continue;
    }

    if (directive) {
      if (ch === '(' || ch === '[' || ch === '{') {
        depth++;
        continue;
      }
      if (ch === ')' || ch === ']' || ch === '}') {
        if (depth > 0) depth--;
        continue;
      }
      // `=>` is an arrow, not a comparison that could be a tag end.
      if (ch === '=' && html[i + 1] === '>') {
        i++;
        continue;
      }
      if (ch === '>' && depth > 0) {
        continue;
      }
    }

    if (ch === '>') {
      return i;
    }
  }

  return -1;
}

/**
 * Parses an HTML string into a tree of HTMLNode elements with positional metadata.
 * @param {string} html
 * @param {string[]} [customVoidTags]
 * @returns {HTMLNode[]}
 */
export function parseHTML(html, customVoidTags = []) {
  const root = new HTMLNode('element', 'root');
  const stack = [root];
  let i = 0;

  const voidTags = buildVoidTagsSet(customVoidTags);

  while (i < html.length) {
    // 1. Check for comment
    if (html.startsWith('<!--', i)) {
      const pos = getLineAndColumn(html, i);
      const endIdx = html.indexOf('-->', i + 4);
      if (endIdx === -1) {
        const node = new HTMLNode('comment');
        node.content = html.substring(i + 4);
        node.line = pos.line;
        node.column = pos.column;
        stack[stack.length - 1].children.push(node);
        break;
      } else {
        const node = new HTMLNode('comment');
        node.content = html.substring(i + 4, endIdx);
        node.line = pos.line;
        node.column = pos.column;
        stack[stack.length - 1].children.push(node);
        i = endIdx + 3;
        continue;
      }
    }

    // 2. Check for closing tag
    if (html.startsWith('</', i)) {
      const pos = getLineAndColumn(html, i);
      const endIdx = html.indexOf('>', i + 2);
      if (endIdx === -1) {
        const textNode = new HTMLNode('text');
        textNode.content = html.substring(i);
        textNode.line = pos.line;
        textNode.column = pos.column;
        stack[stack.length - 1].children.push(textNode);
        break;
      } else {
        const rawTagName = html.substring(i + 2, endIdx).trim();
        const tagName = rawTagName.replace(/\s+/g, '');
        const wanted = tagName.toLowerCase();
        let foundIdx = -1;
        for (let j = stack.length - 1; j > 0; j--) {
          const open = stack[j].tagName.toLowerCase();
          // `</@if>` ends the chain it opened, whichever arm is currently open.
          // The arms are siblings (see the implied-end-tag rule below), so the
          // one on the stack when the close arrives is `<@else>` far more often
          // than `<@if>`.
          if (open === wanted || (wanted === '@if' && IF_CHAIN_TAGS.has(open))) {
            foundIdx = j;
            break;
          }
        }
        if (foundIdx !== -1) {
          while (stack.length > foundIdx) {
            stack.pop();
          }
        }
        // If foundIdx === -1, silently skip unmatched closing tag
        // to maintain backward compatibility with permissive template transforms
        i = endIdx + 1;
        continue;
      }
    }

    // 3. Check for opening/self-closing tag
    if (html[i] === '<') {
      const pos = getLineAndColumn(html, i);
      const tagEndIdx = scanTagEnd(html, i);

      if (tagEndIdx !== -1) {
        const tagContent = html.substring(i + 1, tagEndIdx).trim();
        const isSelfClosing = tagContent.endsWith('/');
        const cleanContent = isSelfClosing ? tagContent.slice(0, -1).trim() : tagContent;

        const spaceIdx = cleanContent.search(/\s/);
        const tagName = spaceIdx === -1 ? cleanContent : cleanContent.substring(0, spaceIdx);
        const attrsStr = spaceIdx === -1 ? '' : cleanContent.substring(spaceIdx).trim();

        if (/^[a-zA-Z0-9@:._-]+$/.test(tagName)) {
          const attrs = parseAttributes(attrsStr);
          const isVoid = voidTags.has(tagName.toLowerCase());
          const node = new HTMLNode('element', tagName, attrs, isSelfClosing || isVoid);
          // The text between the tag name and the tag's end, verbatim.
          //
          // A directive header is not attribute syntax: `<@for item in
          // items.filter(i => i.n > 2)>` has one header expression, not four
          // valueless attributes. `attrs` is still produced for every tag so
          // ordinary elements are unaffected, but a directive parser needs the
          // source it was written in, and reconstructing it from `attrs` is
          // lossy. Keeping it here means exactly one scan decides where a tag
          // ends, and everything downstream agrees with that decision.
          node.rawAttrs = attrsStr;
          node.line = pos.line;
          node.column = pos.column;

          // Implied end tag. `<@elseif>` and `<@else>` continue the chain
          // rather than nesting inside the arm before them, exactly as `<li>`
          // ends the previous `<li>`. Without this the arms parse as a ladder
          // three levels deep and every consumer has to un-nest it again.
          if (IF_CONTINUATION_TAGS.has(tagName.toLowerCase())) {
            while (
              stack.length > 1 &&
              IF_CHAIN_TAGS.has(stack[stack.length - 1].tagName.toLowerCase())
            ) {
              stack.pop();
            }
          }

          stack[stack.length - 1].children.push(node);

          if (!isSelfClosing && !isVoid) {
            stack.push(node);
          }
          i = tagEndIdx + 1;
          continue;
        }
      }
    }

    // 4. Text node
    const textPos = getLineAndColumn(html, i);
    let nextTagIdx = html.indexOf('<', i + 1);
    if (nextTagIdx === -1) {
      nextTagIdx = html.length;
    }
    const text = html.substring(i, nextTagIdx);
    if (text) {
      const parentNode = stack[stack.length - 1];
      const parts = text.split(/(\{\{\{[\s\S]*?\}\}\}|\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\})/g);
      for (const part of parts) {
        if (!part) continue;
        const isDynamic = part.includes('{{') || part.includes('{%');
        const lastChild = parentNode.children[parentNode.children.length - 1];
        if (
          lastChild &&
          lastChild.type === 'text' &&
          !isDynamic &&
          !(lastChild.content.includes('{{') || lastChild.content.includes('{%'))
        ) {
          lastChild.content += part;
        } else {
          const textNode = new HTMLNode('text');
          textNode.content = part;
          textNode.line = textPos.line;
          textNode.column = textPos.column;
          parentNode.children.push(textNode);
        }
      }
    }
    i = nextTagIdx;
  }

  return root.children;
}

/**
 * Serializes an HTMLNode tree back to an HTML string.
 * @param {HTMLNode[]} nodes
 * @param {string[]} [customVoidTags] - Additional project-specific void tag
 *   names (lowercase), loaded from `avenx.config.json`. Should match what
 *   was passed to {@link parseHTML} for the same template so a custom void
 *   tag round-trips consistently.
 * @returns {string}
 */
export function serializeHTML(nodes, customVoidTags = []) {
  let result = '';
  const voidTags = buildVoidTagsSet(customVoidTags);
  for (const node of nodes) {
    if (node.type === 'text') {
      result += node.content;
    } else if (node.type === 'comment') {
      result += `<!--${node.content}-->`;
    } else if (node.type === 'element') {
      let attrsStr = '';
      // A directive header is an expression, and the attribute map is a lossy
      // reading of it: `item in items` parses as three valueless attributes and
      // serialises back as `item="true" in="true" items="true"`, which is not
      // the same template. Writing the header source back verbatim is what
      // makes `serializeHTML(parseHTML(x))` round-trip a directive, which the
      // static-subtree pass relies on before the IR is built.
      if (node.tagName.startsWith('@') && node.rawAttrs) {
        attrsStr = ` ${node.rawAttrs}`;
      } else {
        for (const [name, val] of Object.entries(node.attrs)) {
          if (val === null || val === undefined) {
            attrsStr += ` ${name}`;
          } else {
            const escapedVal = String(val).replace(/"/g, '&quot;');
            attrsStr += ` ${name}="${escapedVal}"`;
          }
        }
      }
      if (voidTags.has(node.tagName.toLowerCase()) || node.isSelfClosing) {
        result += `<${node.tagName}${attrsStr} />`;
      } else {
        result += `<${node.tagName}${attrsStr}>${serializeHTML(node.children, customVoidTags)}</${node.tagName}>`;
      }
    }
  }
  return result;
}