Source: lib/compiler/ir/build.js

/**
 * @file build.js
 * @description Builds the template IR from template source.
 *
 * This runs on the template as the developer wrote it -- after declarations and
 * imports are removed and scoped-CSS classes are applied, but *before* any
 * directive rewriting. That ordering is the whole point: reading `<@for item in
 * items>` directly is what lets the compiler record that it is a loop over
 * `items` binding `item`, instead of rewriting it into a `<template>` element
 * and asking the runtime to work that out again.
 *
 * ## What it refuses
 *
 * A construct the IR does not model yet aborts the build of that template with
 * a {@link IRRefusal} naming the construct. The caller keeps the legacy path
 * for that component and the build reports it. There is no partial IR, for the
 * same reason there is no partial render program: two descriptions of one
 * template disagree eventually, and the disagreement is unattributable.
 * @module lib/compiler/ir/build
 */

import { parseHTML } from '../parser/htmlTree.js';
import { createInterpolationRegex } from '../../core/utils/templateUtils.js';
import { isBooleanAttribute } from '../../core/renderer/constants.js';
import {
  BindingKind,
  IRRefusal,
  RefusalReason,
  comment,
  component,
  conditional,
  element,
  fragment,
  deferred,
  interpolation,
  iteration,
  slot,
  text,
} from './nodes.js';

/**
 * Directive tags the IR does not model yet, mapped to the reason reported.
 * @type {Map<string, string>}
 */
const UNMODELLED_TAGS = new Map([
  ['@suspense', RefusalReason.SUSPENSE],
  ['@fallback', RefusalReason.SUSPENSE],
  ['@errorboundary', RefusalReason.ERROR_BOUNDARY],
  ['@deadlock', RefusalReason.DEADLOCK],
  ['transition', RefusalReason.TRANSITION],
  ['resource', RefusalReason.RESOURCE],
  ['virtuallist', RefusalReason.VIRTUAL_LIST],
  ['routerview', RefusalReason.ROUTER_VIEW],
]);

/**
 * Attributes whose presence means the element needs machinery the IR does not
 * model, mapped to the reason reported.
 * @type {Array<string[]>}
 */
const UNMODELLED_ATTRS = [
  ['data-ax-ref', RefusalReason.REF],
  ['data-ax-validate', RefusalReason.VALIDATION],
  ['data-ax-router-view', RefusalReason.ROUTER_VIEW],
  ['data-ax-dyn-attrs', RefusalReason.DYNAMIC_ATTR],
  ['data-avenx-comp-dynamic', RefusalReason.DYNAMIC_COMPONENT],
  ['data-ax-transition', RefusalReason.TRANSITION],
];

/**
 * Directive attributes that become bindings rather than markup.
 * @type {Map<string, string>}
 */
const DIRECTIVE_BINDINGS = new Map([
  ['data-ax-show', BindingKind.SHOW],
  ['data-ax-class', BindingKind.CLASS],
  ['data-ax-html', BindingKind.HTML],
  ['data-ax-style', BindingKind.STYLE],
]);

/**
 * Whether a string contains a template interpolation.
 * @param {string} value - The text to test.
 * @returns {boolean} True when it contains `{{ }}` or `{{{ }}}`.
 */
function hasInterpolation(value) {
  return typeof value === 'string' && value.includes('{{');
}

/**
 * Splits text into literal and expression segments, in order.
 * @param {string} source - The source text.
 * @returns {Array<{expr: string|null, raw: boolean, value: string}>} The segments.
 */
export function splitInterpolations(source) {
  const segments = [];
  const regex = createInterpolationRegex();
  let last = 0;
  let match;

  while ((match = regex.exec(source)) !== null) {
    if (match.index > last) {
      segments.push({ expr: null, raw: false, value: source.slice(last, match.index) });
    }
    const raw = match[1] !== undefined;
    segments.push({ expr: (raw ? match[1] : match[2]).trim(), raw, value: '' });
    last = regex.lastIndex;
  }

  if (last < source.length) {
    segments.push({ expr: null, raw: false, value: source.slice(last) });
  }
  return segments;
}

/**
 * Finds the offset of a top-level occurrence of a keyword in an expression.
 *
 * "Top level" means outside quotes and outside every bracket pair, so the `in`
 * of `<@for k in Object.keys(map)>` is found and the `in` of an `x in y`
 * written inside a call's arguments is not.
 * @param {string} source - The text to scan.
 * @param {string} word - The keyword to find.
 * @returns {number} The offset, or -1.
 */
function findTopLevelWord(source, word) {
  let quote = null;
  let depth = 0;

  for (let i = 0; i < source.length; i++) {
    const ch = source[i];

    if (quote) {
      if (ch === '\\') i++;
      else if (ch === quote) quote = null;
      continue;
    }
    if (ch === '"' || ch === "'" || ch === '`') {
      quote = ch;
      continue;
    }
    if (ch === '(' || ch === '[' || ch === '{') {
      depth++;
      continue;
    }
    if (ch === ')' || ch === ']' || ch === '}') {
      if (depth > 0) depth--;
      continue;
    }
    if (depth !== 0) continue;

    if (source.startsWith(word, i)) {
      const before = i === 0 ? ' ' : source[i - 1];
      const after = source[i + word.length] === undefined ? ' ' : source[i + word.length];
      if (/\s/.test(before) && /\s/.test(after)) {
        return i;
      }
    }
  }
  return -1;
}

/**
 * Strips a trailing `key="..."` clause from a `<@for>` header.
 * @param {string} header - The header text after the list expression.
 * @returns {{list: string, key: string|null}} The list source and the key source.
 */
function splitListAndKey(header) {
  const match = header.match(/\skey\s*=\s*("([^"]*)"|'([^']*)')\s*$/);
  if (!match) {
    return { list: header.trim(), key: null };
  }
  const key = match[2] !== undefined ? match[2] : match[3];
  return { list: header.slice(0, match.index).trim(), key: key.trim() || null };
}

/**
 * Parses a `<@for>` header into its parts.
 *
 * Two binding forms, both pre-existing and both preserved exactly:
 *
 * - `item in list` binds each element to `item`.
 * - `[a, b] in pairs` destructures each *element* -- which is an array -- into
 *   `a` and `b`. It does not mean `(item, index)`, however much it looks like
 *   the JavaScript it resembles.
 *
 * Either form also binds `index` implicitly, which is how the loop index has
 * always been reached. Both facts are load-bearing for existing applications,
 * so they are read out of the previous implementation rather than redesigned
 * here; changing either belongs in its own change, with its own migration.
 * @param {string} header - The raw header text.
 * @returns {{item: string|null, destructure: string[]|null, list: string, key: string|null}} The parts.
 * @throws {IRRefusal} When the header is not a loop header.
 */
export function parseForHeader(header) {
  const source = (header || '').trim();
  const inAt = findTopLevelWord(source, 'in');
  if (inAt === -1) {
    throw new IRRefusal(RefusalReason.MALFORMED, `<@for ${source}> has no "in" clause`);
  }

  const binding = source.slice(0, inAt).trim();
  const { list, key } = splitListAndKey(source.slice(inAt + 2));

  if (list === '') {
    throw new IRRefusal(RefusalReason.MALFORMED, `<@for ${source}> iterates nothing`);
  }

  const destructured = binding.match(/^\[\s*([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*)\s*)?\]$/);
  if (destructured) {
    return {
      item: null,
      destructure: destructured[2] ? [destructured[1], destructured[2]] : [destructured[1]],
      list,
      key,
    };
  }

  if (!/^[A-Za-z_$][\w$]*$/.test(binding)) {
    throw new IRRefusal(RefusalReason.MALFORMED, `<@for ${source}> does not bind a name`);
  }
  return { item: binding, destructure: null, list, key };
}

/**
 * Parses an event attribute name into its event and modifiers.
 * @param {string} name - The attribute name, including the leading `@`.
 * @returns {{event: string, modifiers: string[]}} The parsed parts.
 */
function parseEventName(name) {
  const [event, ...modifiers] = name.slice(1).split('.');
  return { event, modifiers };
}

/**
 * Reads a directive attribute's expression, with or without `{{ }}` around it.
 *
 * Both spellings are documented and both appear in the wild:
 *
 * ```html
 * <p data-ax-show="isOpen">
 * <p data-ax-style="{{ { fontWeight: bold ? '700' : '400' } }}">
 * ```
 *
 * The braces are how an author writes an object literal without it looking
 * like an attribute value, so they are stripped here rather than handed to the
 * expression parser, which would read `{{` as the start of a block.
 * @param {string} value - The attribute value as written.
 * @returns {string} The expression source.
 */
function unwrapDirectiveValue(value) {
  const segments = splitInterpolations(value);
  if (segments.length === 1 && segments[0].expr !== null) {
    return segments[0].expr;
  }
  return value;
}

/**
 * Builds an element's bindings and events from its attribute map.
 * @param {object} node - The parsed HTML node.
 * @returns {{attrs: object, bindings: object[], events: object[]}} The element parts.
 * @throws {IRRefusal} When an attribute needs machinery the IR does not model.
 */
function buildAttributes(node) {
  const source = node.attrs || {};
  const attrs = {};
  const bindings = [];
  const events = [];

  for (const [name, reason] of UNMODELLED_ATTRS) {
    if (source[name] !== undefined) {
      throw new IRRefusal(reason, `<${node.tagName} ${name}>`);
    }
  }

  for (const [name, value] of Object.entries(source)) {
    if (name === 'data-ax-static') continue;

    if (name.startsWith('@')) {
      const { event, modifiers } = parseEventName(name);
      events.push({ event, modifiers, expr: value });
      continue;
    }

    // `:prop="expr"` binds a value; `:[expr]="v"` binds an attribute *name*,
    // which has no fixed attribute for a program to address.
    if (name.startsWith(':')) {
      if (name.includes('[')) {
        throw new IRRefusal(RefusalReason.DYNAMIC_ATTR, `<${node.tagName} ${name}>`);
      }
      bindings.push({ type: BindingKind.ATTR, name: name.slice(1), expr: value });
      continue;
    }

    if (hasInterpolation(name)) {
      throw new IRRefusal(RefusalReason.DYNAMIC_ATTR, `<${node.tagName}> interpolated attribute name`);
    }

    const directive = DIRECTIVE_BINDINGS.get(name);
    if (directive) {
      bindings.push({ type: directive, name, expr: unwrapDirectiveValue(value) });
      continue;
    }

    if (!hasInterpolation(value)) {
      attrs[name] = value;
      continue;
    }

    const segments = splitInterpolations(value);
    if (segments.length === 1 && segments[0].expr !== null) {
      // One whole expression, so the value keeps its type: a boolean attribute
      // can be toggled and null can remove the attribute, neither of which
      // survives concatenation into a string.
      bindings.push({
        type: isBooleanAttribute(name) ? BindingKind.BOOL : BindingKind.ATTR,
        name,
        expr: segments[0].expr,
      });
    } else {
      bindings.push({
        type: BindingKind.ATTR_PARTS,
        name,
        parts: segments.map((segment) => (segment.expr === null ? segment.value : { expr: segment.expr })),
      });
    }
  }

  return { attrs, bindings, events };
}

/**
 * Builds the props of a child component from its attribute map.
 * @param {object} node - The parsed HTML node.
 * @returns {object[]} Prop descriptors.
 */
function buildProps(node) {
  const props = [];
  for (const [name, value] of Object.entries(node.attrs || {})) {
    if (name.startsWith('@')) {
      props.push({ name, kind: 'event', expr: value });
      continue;
    }
    const bound = name.startsWith(':');
    const propName = bound ? name.slice(1) : name;
    if (bound) {
      props.push({ name: propName, kind: 'bound', expr: value });
    } else if (hasInterpolation(value)) {
      const segments = splitInterpolations(value);
      if (segments.length === 1 && segments[0].expr !== null) {
        props.push({ name: propName, kind: 'bound', expr: segments[0].expr });
      } else {
        props.push({
          name: propName,
          kind: 'parts',
          parts: segments.map((s) => (s.expr === null ? s.value : { expr: s.expr })),
        });
      }
    } else {
      props.push({ name: propName, kind: 'static', value });
    }
  }
  return props;
}

/**
 * Whether a tag name refers to a child component rather than an element.
 * @param {string} tag - The tag name as written.
 * @returns {boolean} True for a PascalCase reference.
 */
function isComponentTag(tag) {
  return /^[A-Z][A-Za-z0-9_]*$/.test(tag);
}

/**
 * Rejects a conditional header that the tag scan cut in half.
 *
 * `<@if count > 3>` cannot be read unambiguously: the `>` that means "greater
 * than" and the `>` that means "end of tag" are the same character, and nothing
 * in the surrounding text distinguishes them. Bracket depth rescues
 * `<@for x in xs.filter(a => a.n > 1)>` because the comparison is inside a call;
 * a bare comparison has nothing to hide behind.
 *
 * So the compiler does not guess. A header that was truncated leaves the rest
 * of the expression as the first text node of the branch body -- `" 3>"` above
 * -- and that shape is what this detects. The author gets the parenthesised
 * form, which the scanner reads correctly, rather than a condition that
 * silently tests the wrong thing.
 * @param {object} node - The parsed directive node.
 * @param {string} test - The header text as scanned.
 * @throws {IRRefusal} When the header was cut at a comparison.
 */
function assertHeaderNotTruncated(node, test) {
  const first = (node.children || [])[0];
  if (!first || first.type !== 'text') return;

  // A leftover fragment reaches the next `>` without opening an element first.
  const leftover = first.content.match(/^([^<]*?)>/);
  if (!leftover) return;

  const tail = leftover[1].trim();
  if (tail === '') return;

  throw new IRRefusal(
    RefusalReason.MALFORMED,
    `<${node.tagName} ${test}> was cut at a ">" -- write it as ` +
      `<${node.tagName} (${test} > ${tail})> so the comparison is inside brackets`,
  );
}

/**
 * Collects an `<@if>` chain starting at `index`, consuming its continuations.
 * @param {object[]} siblings - The sibling list being walked.
 * @param {number} index - Index of the `<@if>` node.
 * @param {function(object[]): object[]} buildChildren - Recursive child builder.
 * @returns {{node: object, next: number}} The conditional node and the index to resume at.
 */
function buildConditional(siblings, index, buildChildren) {
  const branches = [];
  let i = index;
  let sawElse = false;

  while (i < siblings.length) {
    const node = siblings[i];

    // Whitespace between `</@if>` and `<@elseif>` is formatting, not content.
    // Breaking the chain on it would make an author's indentation decide
    // whether their `<@else>` belongs to the `<@if>` above it.
    if (node && node.type === 'text' && node.content.trim() === '' && branches.length > 0) {
      const following = siblings[i + 1];
      const followingTag = following && following.type === 'element' ? (following.tagName || '').toLowerCase() : '';
      if (followingTag === '@elseif' || followingTag === '@elif' || followingTag === '@else') {
        i++;
        continue;
      }
      break;
    }

    if (!node || node.type !== 'element') break;
    const tag = (node.tagName || '').toLowerCase();

    if (tag === '@if' && i !== index) break;

    if (tag === '@if' || tag === '@elseif' || tag === '@elif') {
      if (sawElse) {
        throw new IRRefusal(RefusalReason.MALFORMED, `<${node.tagName}> follows <@else>`);
      }
      const test = (node.rawAttrs || '').trim();
      if (test === '') {
        throw new IRRefusal(RefusalReason.MALFORMED, `<${node.tagName}> has no condition`);
      }
      assertHeaderNotTruncated(node, test);
      branches.push({ test, body: fragment(buildChildren(node.children || [])) });
      i++;
      continue;
    }

    if (tag === '@else') {
      if (sawElse) {
        throw new IRRefusal(RefusalReason.MALFORMED, 'two <@else> branches in one chain');
      }
      sawElse = true;
      branches.push({ test: null, body: fragment(buildChildren(node.children || [])) });
      i++;
      continue;
    }

    break;
  }

  return { node: conditional(branches), next: i };
}

/**
 * Builds an iteration node from a `<@for>` element.
 * @param {object} node - The parsed `<@for>` node.
 * @param {function(object[]): object[]} buildChildren - Recursive child builder.
 * @returns {object} The iteration node.
 */
function buildIteration(node, buildChildren) {
  const parts = parseForHeader(node.rawAttrs);

  const body = [];
  let empty = null;
  for (const child of node.children || []) {
    if (child.type === 'element' && (child.tagName || '').toLowerCase() === '@empty') {
      if (empty) {
        throw new IRRefusal(RefusalReason.MALFORMED, 'two <@empty> blocks in one <@for>');
      }
      empty = fragment(buildChildren(child.children || []));
      continue;
    }
    body.push(child);
  }

  return iteration({
    list: parts.list,
    item: parts.item,
    destructure: parts.destructure,
    key: parts.key,
    body: fragment(buildChildren(body)),
    empty,
  });
}

/**
 * Builds a deferred block from a `<@defer>` element.
 *
 * `<@placeholder>` is a sibling of the deferred content rather than a wrapper
 * around it, so it is separated out here the same way `<@empty>` is separated
 * from a loop body.
 * @param {object} node - The parsed `<@defer>` node.
 * @param {string} when - The resolved trigger.
 * @param {function(object[]): object[]} buildChildren - Recursive child builder.
 * @returns {object} The defer node.
 */
function buildDefer(node, when, buildChildren) {
  const body = [];
  let placeholder = null;

  for (const child of node.children || []) {
    if (child.type === 'element' && (child.tagName || '').toLowerCase() === '@placeholder') {
      if (placeholder) {
        throw new IRRefusal(RefusalReason.MALFORMED, 'two <@placeholder> blocks in one <@defer>');
      }
      placeholder = fragment(buildChildren(child.children || []));
      continue;
    }
    body.push(child);
  }

  return deferred(when, fragment(buildChildren(body)), placeholder);
}

/**
 * Builds the IR for a template.
 * @param {string} template - The template source, before directive rewriting.
 * @param {object} [options] - Build options.
 * @param {string[]} [options.voidTags] - Project-specific void tag names.
 * @returns {{ir: object|null, refusal: {reason: string, detail: string}|null}}
 *   The root fragment, or the reason the template could not be represented.
 */
export function buildTemplateIR(template, options = {}) {
  if (typeof template !== 'string' || template.trim() === '') {
    return { ir: null, refusal: { reason: RefusalReason.MALFORMED, detail: 'empty template' } };
  }

  const voidTags = options.voidTags || [];
  let parsed;
  try {
    parsed = parseHTML(template, voidTags);
  } catch (error) {
    return { ir: null, refusal: { reason: RefusalReason.MALFORMED, detail: `template did not parse: ${error.message}` } };
  }

  /**
   * Builds IR for a sibling list.
   * @param {object[]} siblings - Parsed sibling nodes.
   * @returns {object[]} IR nodes.
   */
  function buildChildren(siblings) {
    const out = [];

    for (let i = 0; i < siblings.length; i++) {
      const node = siblings[i];

      if (node.type === 'text') {
        for (const segment of splitInterpolations(node.content)) {
          if (segment.expr === null) {
            if (segment.value !== '') out.push(text(segment.value));
          } else {
            out.push(interpolation(segment.expr, segment.raw));
          }
        }
        continue;
      }

      if (node.type === 'comment') {
        if (hasInterpolation(node.content)) {
          throw new IRRefusal(RefusalReason.UNKNOWN_DIRECTIVE, 'an interpolation inside an HTML comment');
        }
        out.push(comment(node.content));
        continue;
      }

      const tag = (node.tagName || '').toLowerCase();

      const unmodelled = UNMODELLED_TAGS.get(tag);
      if (unmodelled) {
        throw new IRRefusal(unmodelled, `<${node.tagName}>`);
      }

      if (tag === '@if') {
        const built = buildConditional(siblings, i, buildChildren);
        out.push(built.node);
        i = built.next - 1;
        continue;
      }

      if (tag === '@elseif' || tag === '@elif' || tag === '@else') {
        throw new IRRefusal(RefusalReason.MALFORMED, `<${node.tagName}> without a preceding <@if>`);
      }

      if (tag === '@for') {
        out.push(buildIteration(node, buildChildren));
        continue;
      }

      if (tag === '@defer') {
        // `when` is the documented spelling; a bare header is accepted too so
        // `<@defer visible>` reads the way the other directives do.
        const when = ((node.attrs && node.attrs.when) || (node.rawAttrs || '').trim() || 'idle').trim();
        out.push(buildDefer(node, when, buildChildren));
        continue;
      }

      if (tag === '@empty') {
        throw new IRRefusal(RefusalReason.MALFORMED, '<@empty> outside a <@for>');
      }

      if (tag === '@placeholder') {
        throw new IRRefusal(RefusalReason.MALFORMED, '<@placeholder> outside a <@defer>');
      }

      if (tag.startsWith('@')) {
        throw new IRRefusal(RefusalReason.UNKNOWN_DIRECTIVE, `<${node.tagName}>`);
      }

      if (tag === 'slot') {
        const name = (node.attrs && node.attrs.name) || 'default';
        const fallbackChildren = buildChildren(node.children || []);
        out.push(slot(name, fallbackChildren.length > 0 ? fragment(fallbackChildren) : null));
        continue;
      }

      if (isComponentTag(node.tagName)) {
        out.push(
          component(node.tagName, {
            props: buildProps(node),
            children: buildChildren(node.children || []),
          }),
        );
        continue;
      }

      const { attrs, bindings, events } = buildAttributes(node);
      out.push(
        element(node.tagName, {
          attrs,
          bindings,
          events,
          children: buildChildren(node.children || []),
          selfClosing: node.isSelfClosing === true,
          isStatic: node.attrs && node.attrs['data-ax-static'] !== undefined,
        }),
      );
    }

    return out;
  }

  try {
    return { ir: fragment(buildChildren(parsed)), refusal: null };
  } catch (error) {
    if (error instanceof IRRefusal) {
      return { ir: null, refusal: { reason: error.reason, detail: error.detail } };
    }
    throw error;
  }
}