/**
* @file lower.js
* @description Lowers the template IR to a render program.
*
* ## What a program is now
*
* A skeleton, a list of ops, and a list of **blocks**. A block has the same
* shape as the root -- skeleton, ops, marker counts -- and control flow refers
* to blocks by index. `<@if>` becomes one op naming its arms; `<@for>` becomes
* one op naming its body block, its list expression and its key.
*
* That is the whole difference from the previous design, and it is what lets a
* list compile at all. Before, a loop body had no representation: the compiler
* rewrote it into a `<template data-ax-for>` element and the runtime parsed the
* body's markup again on every pass. A block is that body, parsed once and
* cloned per item, with its own ops addressing its own nodes.
*
* ## Expressions are indices, not source
*
* An op carries `x: 3`, not `x: "cart.total"`. The sources are interned into an
* array this function returns *beside* the program, the caller compiles them
* positionally into the component's expression table, and the array itself
* never reaches the bundle.
*
* The previous design keyed the compiled table by the expression's source text,
* so every expression shipped twice -- once as a key and once as compiled code
* -- and the contract between compiler and runtime was string identity, which
* nothing could check. An index is checkable, smaller, and means a program is
* self-contained: the runtime never needs the template source to work out what
* a binding meant.
*
* ## Scope inside a block
*
* A loop body reads names the enclosing component does not declare. The op
* records the binding names (`as`, `ix`); the runtime derives a scope holding
* them and evaluates the block's expressions against it. The compiler does not
* rewrite `item.qty` into anything else, because the name is what the author
* wrote and what every diagnostic, Atlas edge and trace entry has to keep
* calling it.
* @module lib/compiler/ir/lower
*/
import { serializeHTML } from '../parser/htmlTree.js';
import { IRKind, IRRefusal, RefusalReason } from './nodes.js';
import { OpKind, PROGRAM_VERSION } from '../render/program.js';
/**
* Attribute the runtime resolves bound elements by, before it is stripped.
* @type {string}
*/
const ELEMENT_MARKER = 'data-axb';
/**
* `<@defer when="...">` values that name a subscription rather than a condition.
*
* Read from the previous implementation rather than redesigned: `timer(1500)`
* and `1500ms` are both documented and both in use. Anything else compiles as
* an expression, so `<@defer when="rows.length">` works without new syntax.
* @type {Set<string>}
*/
const RESERVED_TRIGGERS = new Set(['idle', 'visible', 'hover', 'click', 'interaction']);
/**
* Whether a trigger names a subscription rather than a reactive condition.
* @param {string} when - The trigger as written.
* @returns {boolean} True when the runtime subscribes rather than evaluates.
*/
function isSubscriptionTrigger(when) {
const normalised = String(when).toLowerCase().trim();
return RESERVED_TRIGGERS.has(normalised) || normalised.startsWith('timer(') || normalised.endsWith('ms');
}
/**
* Interns strings, returning a stable index per distinct value.
*
* Two bindings that read `cart.total` share one compiled closure and one entry,
* which is the same de-duplication the source-keyed table got for free from
* being an object. Nothing depends on the de-duplication for correctness; it
* exists so a template that reads one value in ten places costs one closure.
*/
class Interner {
/**
* Creates an empty intern table.
*/
constructor() {
/** @type {Map<string, number>} */
this.index = new Map();
/** @type {string[]} */
this.values = [];
}
/**
* Returns the index for a source string, adding it when new.
* @param {string} source - The expression or statement source.
* @returns {number} Its index.
*/
intern(source) {
const key = String(source);
const existing = this.index.get(key);
if (existing !== undefined) return existing;
const id = this.values.length;
this.index.set(key, id);
this.values.push(key);
return id;
}
}
/**
* Builds one block: a skeleton, its ops, and its marker counts.
*
* A block is created for the root fragment and for every fragment control flow
* owns. Markers are numbered per block because each block is parsed and cloned
* independently, so ids only have to be unique within one skeleton.
*/
class BlockBuilder {
/**
* @param {object} context - The shared lowering context.
*/
constructor(context) {
this.context = context;
/** @type {object[]} */
this.ops = [];
this.elements = 0;
this.texts = 0;
}
/**
* Allocates an element marker on a skeleton node.
* @param {object} node - The skeleton node being built.
* @returns {number} The marker id.
*/
markElement(node) {
if (node.attrs[ELEMENT_MARKER] === undefined) {
node.attrs[ELEMENT_MARKER] = String(this.elements++);
}
return Number(node.attrs[ELEMENT_MARKER]);
}
/**
* Allocates a text marker and returns the skeleton comment that holds it.
* @returns {{id: number, node: object}} The marker id and its skeleton node.
*/
markText() {
const id = this.texts++;
return { id, node: { type: 'comment', content: `axt:${id}`, attrs: {}, children: [] } };
}
}
/**
* Lowers an IR tree to a render program.
* @param {object} ir - The root fragment from {@link module:lib/compiler/ir/build}.
* @param {object} [options] - Lowering options.
* @param {string[]} [options.voidTags] - Project-specific void tag names.
* @returns {{program: object|null, expressions: string[], statements: string[],
* refusal: {reason: string, detail: string}|null}}
* The program plus the sources its indices refer to, or the refusal.
*/
export function lowerToProgram(ir, options = {}) {
const voidTags = options.voidTags || [];
const expressions = new Interner();
const statements = new Interner();
/** @type {object[]} */
const blocks = [];
const context = { expressions, statements, blocks, voidTags };
/**
* Lowers a fragment into a new block and returns its index.
* @param {object} irFragment - The fragment to lower.
* @returns {number} The block's index in `blocks`.
*/
function lowerBlock(irFragment) {
// Reserved before the body is lowered, so a block nested inside this one
// gets a later index and the array stays append-only. Without the
// placeholder a nested block would take this block's slot.
const slotIndex = blocks.length;
blocks.push(null);
const builder = new BlockBuilder(context);
const skeleton = lowerNodes(irFragment.children, builder);
blocks[slotIndex] = {
html: serializeHTML(skeleton, voidTags),
ops: builder.ops,
elements: builder.elements,
texts: builder.texts,
};
return slotIndex;
}
/**
* Lowers a list of IR nodes into skeleton nodes, emitting ops into `builder`.
* @param {object[]} nodes - IR nodes.
* @param {BlockBuilder} builder - The block being built.
* @returns {object[]} Skeleton nodes.
*/
function lowerNodes(nodes, builder) {
const out = [];
for (const node of nodes) {
switch (node.kind) {
case IRKind.TEXT:
out.push({ type: 'text', content: node.value, attrs: {}, children: [] });
break;
case IRKind.COMMENT:
out.push({ type: 'comment', content: node.value, attrs: {}, children: [] });
break;
case IRKind.INTERPOLATION: {
const marker = builder.markText();
builder.ops.push({
k: node.raw ? OpKind.RAW : OpKind.TEXT,
t: marker.id,
x: expressions.intern(node.expr),
});
out.push(marker.node);
break;
}
case IRKind.ELEMENT:
out.push(lowerElement(node, builder));
break;
case IRKind.COMPONENT:
out.push(lowerComponent(node, builder));
break;
case IRKind.IF:
out.push(lowerConditional(node, builder));
break;
case IRKind.FOR:
out.push(lowerIteration(node, builder));
break;
case IRKind.SLOT:
out.push(lowerSlot(node, builder));
break;
case IRKind.DEFER:
out.push(lowerDefer(node, builder));
break;
default:
throw new IRRefusal(RefusalReason.UNKNOWN_DIRECTIVE, `IR node "${node.kind}"`);
}
}
return out;
}
/**
* Emits the ops for one element's bindings and events.
* @param {object} node - The IR element.
* @param {object} skeleton - Its skeleton node.
* @param {BlockBuilder} builder - The block being built.
*/
function lowerBindings(node, skeleton, builder) {
for (const binding of node.bindings) {
const element = builder.markElement(skeleton);
if (binding.type === 'attrParts') {
builder.ops.push({
k: OpKind.ATTR_PARTS,
e: element,
a: binding.name,
p: binding.parts.map((part) =>
typeof part === 'string' ? part : { x: expressions.intern(part.expr) },
),
});
continue;
}
const kind = {
attr: OpKind.ATTR,
bool: OpKind.BOOL,
show: OpKind.SHOW,
class: OpKind.CLASS,
html: OpKind.HTML,
style: OpKind.STYLE,
}[binding.type];
if (!kind) {
throw new IRRefusal(RefusalReason.UNKNOWN_DIRECTIVE, `binding "${binding.type}"`);
}
const op = { k: kind, e: element, x: expressions.intern(binding.expr) };
if (kind === OpKind.ATTR || kind === OpKind.BOOL) {
op.a = binding.name;
}
builder.ops.push(op);
}
for (const event of node.events) {
builder.ops.push({
k: OpKind.EVENT,
e: builder.markElement(skeleton),
n: event.event,
m: event.modifiers.length > 0 ? event.modifiers : undefined,
x: statements.intern(event.expr),
});
}
}
/**
* Lowers an element.
* @param {object} node - The IR element.
* @param {BlockBuilder} builder - The block being built.
* @returns {object} The skeleton node.
*/
function lowerElement(node, builder) {
const skeleton = {
type: 'element',
tagName: node.tag,
attrs: { ...node.attrs },
isSelfClosing: node.selfClosing,
children: [],
content: '',
};
// A subtree the compiler proved static can never produce an op, so it is
// emitted whole and never descended into. This is where a large static
// template stops costing anything: no ops, no markers, no walk.
if (node.isStatic && node.bindings.length === 0 && node.events.length === 0 && isPurelyStatic(node.children)) {
skeleton.children = lowerStatic(node.children);
return skeleton;
}
lowerBindings(node, skeleton, builder);
skeleton.children = lowerNodes(node.children, builder);
return skeleton;
}
/**
* Emits a provably static subtree verbatim.
* @param {object[]} nodes - IR nodes known to contain nothing dynamic.
* @returns {object[]} Skeleton nodes.
*/
function lowerStatic(nodes) {
return nodes.map((node) => {
if (node.kind === IRKind.TEXT) {
return { type: 'text', content: node.value, attrs: {}, children: [] };
}
if (node.kind === IRKind.COMMENT) {
return { type: 'comment', content: node.value, attrs: {}, children: [] };
}
if (node.kind === IRKind.ELEMENT) {
return {
type: 'element',
tagName: node.tag,
attrs: { ...node.attrs },
isSelfClosing: node.selfClosing,
children: lowerStatic(node.children),
content: '',
};
}
// A static mark that turns out to contain something dynamic is a bug in
// whatever applied the mark, not something to render around.
throw new IRRefusal(RefusalReason.MALFORMED, `"${node.kind}" inside a subtree marked static`);
});
}
/**
* Lowers a child component to its mount placeholder plus prop ops.
*
* The placeholder keeps the `data-avenx-comp` shape the component mounter
* already looks for, so composition works through one mechanism rather than
* two during the migration.
* @param {object} node - The IR component.
* @param {BlockBuilder} builder - The block being built.
* @returns {object} The skeleton node.
*/
function lowerComponent(node, builder) {
const skeleton = {
type: 'element',
tagName: 'div',
attrs: { 'data-avenx-comp': node.name },
isSelfClosing: false,
children: [],
content: '',
};
for (const prop of node.props) {
if (prop.kind === 'event') {
const { event, modifiers } = {
event: prop.name.slice(1).split('.')[0],
modifiers: prop.name.slice(1).split('.').slice(1),
};
builder.ops.push({
k: OpKind.EVENT,
e: builder.markElement(skeleton),
n: event,
m: modifiers.length > 0 ? modifiers : undefined,
x: statements.intern(prop.expr),
});
continue;
}
// A literal prop is an op too, rather than a `data-props-*` attribute
// holding its own source. The attribute form required the mounter to
// evaluate that source through the component's source-keyed expression
// table, which is the one thing a compiled component does not have -- and
// it was the last place the compiled path still read an expression out of
// the DOM.
const expr =
prop.kind === 'static'
? literalPropExpression(prop.value)
: prop.kind === 'parts'
? prop.parts
.map((part) => (typeof part === 'string' ? JSON.stringify(part) : `(${part.expr})`))
.join(' + ')
: prop.expr;
builder.ops.push({
k: OpKind.PROP,
e: builder.markElement(skeleton),
n: prop.name,
x: expressions.intern(expr),
});
}
skeleton.children = lowerNodes(node.children, builder);
return skeleton;
}
/**
* Lowers a conditional to an anchor and one op naming its arms.
* @param {object} node - The IR conditional.
* @param {BlockBuilder} builder - The block being built.
* @returns {object} The anchor skeleton node.
*/
function lowerConditional(node, builder) {
const marker = builder.markText();
const arms = node.branches.map((branch) => ({
x: branch.test === null ? null : expressions.intern(branch.test),
b: lowerBlock(branch.body),
}));
builder.ops.push({ k: OpKind.IF, t: marker.id, arms });
return marker.node;
}
/**
* Lowers an iteration to an anchor and one op naming its body block.
* @param {object} node - The IR iteration.
* @param {BlockBuilder} builder - The block being built.
* @returns {object} The anchor skeleton node.
*/
function lowerIteration(node, builder) {
const marker = builder.markText();
const op = {
k: OpKind.FOR,
t: marker.id,
x: expressions.intern(node.list),
b: lowerBlock(node.body),
};
if (node.item) op.as = node.item;
// `[a, b] in pairs` destructures each element. Recorded as the names, in
// order, so the runtime binds `a` to `item[0]` without re-reading a header.
if (node.destructure) op.ds = node.destructure;
if (node.key) op.key = expressions.intern(node.key);
if (node.empty) op.emp = lowerBlock(node.empty);
builder.ops.push(op);
return marker.node;
}
/**
* Lowers a deferred block to an anchor and one op.
*
* A trigger that names a condition becomes an expression index, so the op's
* own effect re-evaluates it; every other trigger is a subscription the
* runtime makes once and is carried as the literal the author wrote.
* @param {object} node - The IR defer node.
* @param {BlockBuilder} builder - The block being built.
* @returns {object} The anchor skeleton node.
*/
function lowerDefer(node, builder) {
const marker = builder.markText();
const op = { k: OpKind.DEFER, t: marker.id, b: lowerBlock(node.body) };
if (node.placeholder) op.ph = lowerBlock(node.placeholder);
if (isSubscriptionTrigger(node.when)) {
op.when = node.when;
} else {
op.x = expressions.intern(node.when);
}
builder.ops.push(op);
return marker.node;
}
/**
* Lowers a slot outlet.
*
* A slot stays a real `<slot>` element in the skeleton, with its fallback as
* its children, because transclusion is owned by the component mounter: the
* parent knows what it passed down, the child does not, and the mounter is
* where the two meet. An anchor and a block here would give the outlet a
* second owner that cannot see the content it is supposed to render.
*
* The fallback is lowered into this block rather than its own, so a binding
* inside fallback content is an ordinary op of the enclosing skeleton and
* updates without the slot having to re-render.
* @param {object} node - The IR slot.
* @param {BlockBuilder} builder - The block being built.
* @returns {object} The skeleton node.
*/
function lowerSlot(node, builder) {
return {
type: 'element',
tagName: 'slot',
attrs: node.name && node.name !== 'default' ? { name: node.name } : {},
isSelfClosing: false,
children: node.fallback ? lowerNodes(node.fallback.children, builder) : [],
content: '',
};
}
try {
const rootBuilder = new BlockBuilder(context);
const skeleton = lowerNodes(ir.children, rootBuilder);
const program = {
v: PROGRAM_VERSION,
html: serializeHTML(skeleton, voidTags),
ops: rootBuilder.ops,
elements: rootBuilder.elements,
texts: rootBuilder.texts,
};
// Omitted when empty rather than emitted as `[]`. A template with no
// control flow is the common case, and `programBlocks` already treats an
// absent array as none.
if (blocks.length > 0) {
program.blocks = blocks;
}
return {
program,
expressions: expressions.values,
statements: statements.values,
refusal: null,
};
} catch (error) {
if (error instanceof IRRefusal) {
return { program: null, expressions: [], statements: [], refusal: { reason: error.reason, detail: error.detail } };
}
throw error;
}
}
/**
* Whether a subtree contains nothing that needs an op.
*
* The static mark is applied by an earlier pass, and a mark that turns out to
* wrap control flow is a disagreement between two passes rather than something
* to render around. Checking here means the disagreement costs an ordinary
* compile of that subtree instead of refusing the whole template.
* @param {object[]} nodes - IR nodes.
* @returns {boolean} True when every node is literal markup.
*/
function isPurelyStatic(nodes) {
return nodes.every((node) => {
if (node.kind === IRKind.TEXT || node.kind === IRKind.COMMENT) return true;
if (node.kind !== IRKind.ELEMENT) return false;
if (node.bindings.length > 0 || node.events.length > 0) return false;
return isPurelyStatic(node.children);
});
}
/**
* Renders a literal prop value as the expression source the mounter expects.
*
* The component mounter evaluates `data-props-*` as an expression, so a literal
* has to arrive quoted -- except for the four literals that mean themselves.
* @param {string} value - The attribute value as written.
* @returns {string} An expression source.
*/
function literalPropExpression(value) {
const trimmed = String(value).trim();
if (
trimmed === 'true' ||
trimmed === 'false' ||
trimmed === 'null' ||
(trimmed !== '' && !Number.isNaN(Number(trimmed)))
) {
return trimmed;
}
return `'${trimmed.replace(/'/g, "\\'")}'`;
}