import "./linq.js"; import { formatDate } from "./utils.js"; // ─── Expression evaluation ─── function evaluate(data, expression) { if (!expression) return data; try { return new Function("$data", `with($data) { return (${expression}); }`)( data, ); } catch { return undefined; } } // ─── Scope ─── class ScopeDict { constructor(initial = {}) { this._entries = { ...initial }; } set(name, value) { if (name.startsWith("$")) throw new Error(`'${name}' reserved — cannot use @let`); this._entries[name] = value; } setBuiltin(name, value) { this._entries[name] = value; } has(name) { return name in this._entries; } get(name) { return this._entries[name]; } clone() { return new ScopeDict(this._entries); } } function scopeProxy(data, scope) { if (data === null || data === undefined || typeof data !== "object") { scope.setBuiltin("$this", data); return new Proxy( {}, { get(_, p) { return p === "$this" ? data : scope.has(p) ? scope.get(p) : undefined; }, has(_, p) { return p === "$this" || scope.has(p); }, }, ); } scope.setBuiltin("$this", data); return new Proxy(data, { get(t, p) { return p === "$this" ? data : scope.has(p) ? scope.get(p) : t[p]; }, has(t, p) { return p === "$this" || scope.has(p) || p in t; }, }); } // ─── Formatting ─── function formatNumber(value, spec) { const hasThousands = spec.includes(","); const dotIdx = spec.indexOf("."); let decimals = dotIdx !== -1 ? spec.length - dotIdx - 1 : 0; let result = value.toFixed(decimals); if (hasThousands) { const parts = result.split("."); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); result = parts.join("."); } if (!spec.includes(".") && !hasThousands) { const padLen = spec.replace(/[^0]/g, "").length; if (padLen > 0) { const neg = value < 0; let abs = Math.abs(Math.round(value)).toString().padStart(padLen, "0"); result = neg ? "-" + abs : abs; } } return result; } function createFormatter(spec) { return (v) => { if (v == null) return ""; if (v instanceof Date) return formatDate(v, spec); if (typeof v === "number") return formatNumber(v, spec); return String(v); }; } // ─── Dependency extraction ─── const RESERVED = new Set([ "true", "false", "null", "undefined", "typeof", "instanceof", "new", "delete", "void", "in", "of", "if", "else", "return", "this", "event", "Math", "Date", "Array", "Object", "String", "Number", "Boolean", "JSON", "console", "window", "document", "parseInt", "parseFloat", "NaN", "Infinity", ]); function extractDeps(expression) { const cleaned = expression.replace( /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g, "", ); const deps = new Set(); for (const m of cleaned.matchAll(/\b([a-zA-Z_$][\w$]*)\b/g)) { if (!RESERVED.has(m[1]) && !m[1].startsWith("$")) deps.add(m[1]); } return deps; } // ─── Binding graph ─── class BindingGraph { constructor() { this._map = new Map(); } register(binding, deps) { for (const d of deps) { if (!this._map.has(d)) this._map.set(d, new Set()); this._map.get(d).add(binding); } } getAffected(props) { const out = new Set(); for (const p of props) { const bs = this._map.get(p); if (bs) for (const b of bs) out.add(b); } return out; } } // ─── String helpers ─── function skipWhitespace(str, pos) { while (pos < str.length && /\s/.test(str[pos])) pos++; return pos; } function skipString(str, i) { const q = str[i]; i++; while (i < str.length && str[i] !== q) { if (str[i] === "\\") i++; i++; } return i; } function findClosingParen(str, open) { let depth = 1, i = open + 1; while (i < str.length && depth > 0) { if (str[i] === "'" || str[i] === '"' || str[i] === "`") { i = skipString(str, i) + 1; continue; } if (str[i] === "(") depth++; else if (str[i] === ")") depth--; if (depth > 0) i++; } if (depth !== 0) throw new Error("Unclosed ("); return i; } function findClosingBrace(str, open) { let depth = 1, i = open + 1; while (i < str.length && depth > 0) { if (str[i] === "'" || str[i] === '"' || str[i] === "`") { i = skipString(str, i) + 1; continue; } if (str[i] === "{" && str[i + 1] === "{") { const end = str.indexOf("}}", i + 2); if (end === -1) throw new Error("Unclosed {{ }}"); i = end + 2; continue; } if (str[i] === "{") depth++; else if (str[i] === "}") depth--; if (depth > 0) i++; } if (depth !== 0) throw new Error("Unclosed {"); return i; } // ─── Parse expression with optional format spec ─── function parseExpression(content) { const colonIdx = content.lastIndexOf(":"); if (colonIdx > 0) { const fmt = content.substring(colonIdx + 1).trim(); if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(fmt)) { return { expression: content.substring(0, colonIdx).trim(), formatter: createFormatter(fmt), }; } } return { expression: content, formatter: null }; } // ───────────────────────────────────────────────── // Instruction classes — parser output // ───────────────────────────────────────────────── class TextInstr { constructor(value) { this.value = value; } } class ExprInstr { constructor(expression, formatter) { this.expression = expression; this.formatter = formatter; } } class ElementInstr { constructor(tag, attrs, children) { this.tag = tag; this.attrs = attrs; this.children = children; } } class AttrInstr { constructor(name, parts, bracket = null) { this.name = name; this.parts = parts; this.bracket = bracket; // null | "[]" | "[()]" } } class ForInstr { constructor(varNames, expression, trackExpr, children, empty) { this.varNames = varNames; this.expression = expression; this.trackExpr = trackExpr; this.children = children; this.empty = empty; } } class IfInstr { constructor(expression, trueChildren, falseChildren) { this.expression = expression; this.trueChildren = trueChildren; this.falseChildren = falseChildren; } } class LetInstr { constructor(name, expression) { this.name = name; this.expression = expression; } } // ───────────────────────────────────────────────── // Parser — template string → instruction tree // ───────────────────────────────────────────────── const VOID_ELEMENTS = new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", ]); function parse(str) { return parseChildren(str, 0).children; } function parseChildren(str, pos) { const children = []; while (pos < str.length) { // Closing tag → return to parent if (str[pos] === "<" && str[pos + 1] === "/") break; // HTML comment (skip) if (str.startsWith("", pos + 4); if (end === -1) throw new Error("Unclosed HTML comment"); pos = end + 3; continue; } // Opening tag if (str[pos] === "<" && /[a-zA-Z]/.test(str[pos + 1])) { const r = parseElement(str, pos); children.push(r.node); pos = r.pos; continue; } // @for if (str.startsWith("@for(", pos)) { const r = parseFor(str, pos); children.push(r.node); pos = r.pos; continue; } // @if if (str.startsWith("@if(", pos)) { const r = parseIf(str, pos); children.push(r.node); pos = r.pos; continue; } // @let if (str.startsWith("@let ", pos)) { const r = parseLet(str, pos); children.push(r.node); pos = r.pos; continue; } // Text / expressions const r = parseText(str, pos); children.push(...r.nodes); pos = r.pos; } return { children, pos }; } function parseText(str, pos) { const nodes = []; let start = pos; while (pos < str.length) { if (str[pos] === "<") break; if ( str[pos] === "@" && (str.startsWith("@for(", pos) || str.startsWith("@if(", pos) || str.startsWith("@let ", pos)) ) break; if (str[pos] === "{" && str[pos + 1] === "{") { if (pos > start) nodes.push(new TextInstr(str.substring(start, pos))); const end = str.indexOf("}}", pos + 2); if (end === -1) throw new Error("Unclosed {{ }}"); const { expression, formatter } = parseExpression( str.substring(pos + 2, end).trim(), ); nodes.push(new ExprInstr(expression, formatter)); pos = end + 2; start = pos; continue; } pos++; } if (pos > start) nodes.push(new TextInstr(str.substring(start, pos))); return { nodes, pos }; } function parseAttrValue(str, pos) { const quote = str[pos]; if (quote !== '"' && quote !== "'") { let end = pos; while (end < str.length && !/[\s\/>]/.test(str[end])) end++; return { parts: [new TextInstr(str.substring(pos, end))], pos: end, }; } pos++; const parts = []; let start = pos; while (pos < str.length && str[pos] !== quote) { if (str[pos] === "{" && str[pos + 1] === "{") { if (pos > start) parts.push(new TextInstr(str.substring(start, pos))); const end = str.indexOf("}}", pos + 2); if (end === -1) throw new Error("Unclosed {{ }} in attribute"); const { expression, formatter } = parseExpression( str.substring(pos + 2, end).trim(), ); parts.push(new ExprInstr(expression, formatter)); pos = end + 2; start = pos; continue; } pos++; } if (pos > start) parts.push(new TextInstr(str.substring(start, pos))); pos++; // closing quote return { parts, pos }; } function parseElement(str, pos) { pos++; // skip < let tagEnd = pos; while (tagEnd < str.length && /[\w\-]/.test(str[tagEnd])) tagEnd++; const tag = str.substring(pos, tagEnd); pos = tagEnd; const attrs = []; while (pos < str.length) { pos = skipWhitespace(str, pos); if (str[pos] === ">") { pos++; break; } if (str[pos] === "/" && str[pos + 1] === ">") { pos += 2; return { node: new ElementInstr(tag, attrs, []), pos }; } // Attribute name — supports (event)="...", [attr]="...", [(attr)]="..." syntax let nameEnd = pos; while (nameEnd < str.length && !/[\s=\/>]/.test(str[nameEnd])) nameEnd++; const rawAttrName = str.substring(pos, nameEnd); pos = nameEnd; // [attr]="expr" or [(attr)]="expr" — entire value is a single expression const bracketMatch = rawAttrName.match(/^\[(\(?\w[\w.\-]*\)?)]/); const isBracketExpr = bracketMatch !== null; const attrName = isBracketExpr ? bracketMatch[1] : rawAttrName; const bracket = isBracketExpr ? (attrName.startsWith("(") && attrName.endsWith(")") ? "[()]" : "[]") : null; pos = skipWhitespace(str, pos); if (str[pos] !== "=") { attrs.push(new AttrInstr(attrName, [])); // boolean attr continue; } pos++; // skip = pos = skipWhitespace(str, pos); if (isBracketExpr) { const quote = str[pos]; if (quote !== '"' && quote !== "'") throw new Error(`[${attrName}] requires a quoted value`); pos++; // skip opening quote const closeQuote = str.indexOf(quote, pos); if (closeQuote === -1) throw new Error(`Unclosed quote for [${attrName}]`); const { expression, formatter } = parseExpression(str.substring(pos, closeQuote).trim()); attrs.push(new AttrInstr(attrName, [new ExprInstr(expression, formatter)], bracket)); pos = closeQuote + 1; } else { const r = parseAttrValue(str, pos); attrs.push(new AttrInstr(attrName, r.parts)); pos = r.pos; } } if (VOID_ELEMENTS.has(tag.toLowerCase())) { return { node: new ElementInstr(tag, attrs, []), pos }; } const { children, pos: childEnd } = parseChildren(str, pos); pos = childEnd; // Consume closing tag if (str[pos] === "<" && str[pos + 1] === "/") { const closeEnd = str.indexOf(">", pos + 2); if (closeEnd !== -1) pos = closeEnd + 1; } return { node: new ElementInstr(tag, attrs, children), pos }; } function parseFor(str, pos) { const parenOpen = pos + 4; const parenClose = findClosingParen(str, parenOpen); const clause = str.substring(parenOpen + 1, parenClose).trim(); const ofIdx = clause.indexOf(" of "); if (ofIdx === -1) throw new Error('@for missing "of"'); const varNames = clause .substring(0, ofIdx) .split(",") .map((v) => v.trim()); let rest = clause.substring(ofIdx + 4).trim(); let trackExpr = null; const trackIdx = rest.indexOf("; track "); if (trackIdx !== -1) { trackExpr = rest.substring(trackIdx + 8).trim(); rest = rest.substring(0, trackIdx).trim(); } const braceOpen = str.indexOf("{", parenClose + 1); if (braceOpen === -1) throw new Error("@for missing block"); const braceClose = findClosingBrace(str, braceOpen); const children = parse(str.substring(braceOpen + 1, braceClose)); let empty = null; let afterBlock = braceClose + 1; const remaining = str.substring(afterBlock); const emptyMatch = remaining.match(/^\s*@empty\s*\{/); if (emptyMatch) { const emptyBraceOpen = afterBlock + emptyMatch[0].length - 1; const emptyBraceClose = findClosingBrace(str, emptyBraceOpen); empty = parse(str.substring(emptyBraceOpen + 1, emptyBraceClose)); afterBlock = emptyBraceClose + 1; } return { node: new ForInstr(varNames, rest, trackExpr, children, empty), pos: afterBlock, }; } function parseIf(str, pos) { const parenOpen = pos + 3; const parenClose = findClosingParen(str, parenOpen); const expression = str.substring(parenOpen + 1, parenClose).trim(); const trueBraceOpen = str.indexOf("{", parenClose + 1); if (trueBraceOpen === -1) throw new Error("@if missing block"); const trueBraceClose = findClosingBrace(str, trueBraceOpen); const trueChildren = parse(str.substring(trueBraceOpen + 1, trueBraceClose)); let falseChildren = null; let afterBlock = trueBraceClose + 1; const remaining = str.substring(afterBlock); const elseMatch = remaining.match(/^\s*@else\s*\{/); if (elseMatch) { const falseBraceOpen = afterBlock + elseMatch[0].length - 1; const falseBraceClose = findClosingBrace(str, falseBraceOpen); falseChildren = parse(str.substring(falseBraceOpen + 1, falseBraceClose)); afterBlock = falseBraceClose + 1; } return { node: new IfInstr(expression, trueChildren, falseChildren), pos: afterBlock, }; } function parseLet(str, pos) { const lineEnd = str.indexOf("\n", pos); const end = lineEnd === -1 ? str.length : lineEnd; const decl = str.substring(pos + 5, end).trim(); const eqIdx = decl.indexOf("="); if (eqIdx === -1) throw new Error("@let missing ="); return { node: new LetInstr( decl.substring(0, eqIdx).trim(), decl.substring(eqIdx + 1).trim(), ), pos: end + 1, }; } // ───────────────────────────────────────────────── // Bindings — live references to DOM nodes // ───────────────────────────────────────────────── class TextBinding { constructor(node, expression, formatter) { this.node = node; this.expression = expression; this.formatter = formatter; } update(ctx) { const raw = evaluate(ctx, this.expression); const val = this.formatter ? this.formatter(raw) : raw; const text = val == null ? "" : String(val); if (this.node.textContent !== text) this.node.textContent = text; } } class AttrBinding { constructor(element, attrName, parts) { this.element = element; this.attrName = attrName; this.parts = parts; } update(ctx) { let val = ""; for (const p of this.parts) { if (p instanceof TextInstr) { val += p.value; } else { const raw = evaluate(ctx, p.expression); const v = p.formatter ? p.formatter(raw) : raw; val += v == null ? "" : String(v); } } if (this.element.getAttribute(this.attrName) !== val) { this.element.setAttribute(this.attrName, val); } } } class AttrListBinding { constructor(element, attrName, attrListPart, expression) { this.element = element; this.attrName = attrName; this.attrListPart = attrListPart.trim(); this.expression = expression; this.pattern = new RegExp(`\\b${attrListPart}\\b`); } update(ctx) { const val = evaluate(ctx, this.expression); const attrList = (this.element.getAttribute(this.attrName) ?? "").trim(); const match = this.pattern.exec(attrList); if (val && !match) { // works because upper trim() removes all whitespace, so empty string means no attributes this.element.setAttribute(this.attrName, attrList.length === 0 ? this.attrListPart : `${attrList} ${this.attrListPart}`); } else if (!val && match) { const start = match.index; const end = match.index + this.attrListPart.length; const newAttrList = attrList.slice(0, start) + attrList.slice(end); this.element.setAttribute(this.attrName, newAttrList.trim()); } } } class LetBinding { constructor(name, expression) { this.name = name; this.expression = expression; } declare(scope, ctx) { scope.set(this.name, evaluate(ctx, this.expression)); } update() { } } class ForBinding { constructor(startMarker, endMarker, instr) { this.startMarker = startMarker; this.endMarker = endMarker; this.varNames = instr.varNames; this.expression = instr.expression; this.trackExpr = instr.trackExpr; this.childInstructions = instr.children; this.emptyInstructions = instr.empty; this.entries = new Map(); // key → { nodes, result } this.emptyNodes = []; this.emptyResult = null; } update(ctx) { const list = evaluate(ctx, this.expression); const arr = Array.isArray(list) ? list : list ? Array.from(list) : []; const count = arr.length; if (count === 0) { this._clearItems(); if (this.emptyInstructions && this.emptyNodes.length === 0) { const r = instantiate(this.emptyInstructions, ctx); this.emptyNodes = Array.from(r.fragment.childNodes); this.emptyResult = r; this.endMarker.parentNode.insertBefore(r.fragment, this.endMarker); } return; } this._clearEmpty(); const newKeys = arr.map((item, i) => this._trackKey(item, i, ctx)); const newKeySet = new Set(newKeys); // Remove stale entries for (const [key, entry] of this.entries) { if (!newKeySet.has(key)) { for (const n of entry.nodes) n.remove(); entry.result.destroy(); this.entries.delete(key); } } // Rebuild in correct order const newEntries = new Map(); for (let i = 0; i < count; i++) { const item = arr[i]; const key = newKeys[i]; const scope = this._makeScope(item, i, count); const proxy = scopeProxy(ctx, scope); let entry; if (this.entries.has(key)) { entry = this.entries.get(key); entry.result.update(proxy); } else { const result = instantiate(this.childInstructions, proxy); entry = { nodes: Array.from(result.fragment.childNodes), result }; } for (const n of entry.nodes) { this.endMarker.parentNode.insertBefore(n, this.endMarker); } newEntries.set(key, entry); } this.entries = newEntries; } _trackKey(item, index, ctx) { if (!this.trackExpr) return item; const scope = new ScopeDict(); if (this.varNames.length === 1) { scope.setBuiltin(this.varNames[0], item); } else { for (let v = 0; v < this.varNames.length; v++) scope.setBuiltin(this.varNames[v], item[v]); } scope.setBuiltin("$index", index); return evaluate(scopeProxy(ctx, scope), this.trackExpr); } _makeScope(item, index, count) { const scope = new ScopeDict(); if (this.varNames.length === 1) { scope.setBuiltin(this.varNames[0], item); } else { for (let v = 0; v < this.varNames.length; v++) scope.setBuiltin(this.varNames[v], item[v]); } scope.setBuiltin("$index", index); scope.setBuiltin("$first", index === 0); scope.setBuiltin("$last", index === count - 1); scope.setBuiltin("$even", index % 2 === 0); scope.setBuiltin("$odd", index % 2 === 1); scope.setBuiltin("$count", count); return scope; } _clearItems() { for (const [, entry] of this.entries) { for (const n of entry.nodes) n.remove(); entry.result.destroy(); } this.entries.clear(); } _clearEmpty() { for (const n of this.emptyNodes) n.remove(); this.emptyNodes = []; if (this.emptyResult) { this.emptyResult.destroy(); this.emptyResult = null; } } destroy() { this._clearItems(); this._clearEmpty(); } } class IfBinding { constructor(startMarker, endMarker, instr) { this.startMarker = startMarker; this.endMarker = endMarker; this.expression = instr.expression; this.trueInstructions = instr.trueChildren; this.falseInstructions = instr.falseChildren; this.currentBranch = null; this.currentResult = null; this.currentNodes = []; } update(ctx) { const branch = evaluate(ctx, this.expression) ? "true" : "false"; if (branch === this.currentBranch) { if (this.currentResult) this.currentResult.update(ctx); return; } this._clear(); this.currentBranch = branch; const instructions = branch === "true" ? this.trueInstructions : this.falseInstructions; if (!instructions) return; this.currentResult = instantiate(instructions, ctx); this.currentNodes = Array.from(this.currentResult.fragment.childNodes); this.endMarker.parentNode.insertBefore( this.currentResult.fragment, this.endMarker, ); } _clear() { for (const n of this.currentNodes) n.remove(); if (this.currentResult) this.currentResult.destroy(); this.currentNodes = []; this.currentResult = null; } destroy() { this._clear(); } } // ───────────────────────────────────────────────── // Instantiate — instruction tree → DOM + bindings // ───────────────────────────────────────────────── // Walks instruction tree once, calls createElement / createTextNode, // stores live node references in Binding objects. function instantiate(instructions, ctx, parentScope) { const fragment = document.createDocumentFragment(); const graph = new BindingGraph(); const bindings = []; const scope = parentScope ? parentScope.clone() : new ScopeDict(); const proxy = scopeProxy(ctx, scope); buildNodes(instructions, fragment, proxy, bindings, graph); // Declare @let, then evaluate all bindings once for (const b of bindings) { if (b instanceof LetBinding) b.declare(scope, proxy); } for (const b of bindings) { if (!(b instanceof LetBinding)) b.update(proxy); } return new RenderResult(fragment, bindings, graph, scope); } function buildNodes(instructions, parent, ctx, bindings, graph) { for (const instr of instructions) { if (instr instanceof TextInstr) { parent.appendChild(document.createTextNode(instr.value)); } else if (instr instanceof ExprInstr) { const node = document.createTextNode(""); parent.appendChild(node); const b = new TextBinding(node, instr.expression, instr.formatter); bindings.push(b); graph.register(b, extractDeps(instr.expression)); } else if (instr instanceof ElementInstr) { const el = document.createElement(instr.tag); for (const attr of instr.attrs) { // Bracket attrs ([]/[()]) → stash as metadata, not real DOM attrs if (attr.bracket) { if (!el.__templateMeta) el.__templateMeta = []; const expr = attr.parts.length === 1 && attr.parts[0] instanceof ExprInstr ? attr.parts[0].expression : null; el.__templateMeta.push({ name: attr.name, bracket: attr.bracket, expression: expr }); // Still create bindings for [] attrs that affect rendering if (attr.bracket === "[]") { const deps = new Set(); for (const p of attr.parts) { if (p instanceof ExprInstr) { for (const d of extractDeps(p.expression)) deps.add(d); } } if (attr.name.includes(".") && attr.parts.length === 1 && attr.parts[0] instanceof ExprInstr) { const [attrName, attrListPart] = attr.name.split("."); const b = new AttrListBinding(el, attrName, attrListPart, attr.parts[0].expression); bindings.push(b); graph.register(b, deps); } else { const b = new AttrBinding(el, attr.name, attr.parts); bindings.push(b); graph.register(b, deps); } } continue; } const hasDynamic = attr.parts.some((p) => p instanceof ExprInstr); if (!hasDynamic) { const val = attr.parts.length === 0 ? "" : attr.parts.map((p) => p.value).join(""); el.setAttribute(attr.name, val); } else { const deps = new Set(); for (const p of attr.parts) { if (p instanceof ExprInstr) { for (const d of extractDeps(p.expression)) deps.add(d); } } const b = new AttrBinding(el, attr.name, attr.parts); bindings.push(b); graph.register(b, deps); } } buildNodes(instr.children, el, ctx, bindings, graph); parent.appendChild(el); } else if (instr instanceof ForInstr) { const start = document.createComment("for"); const end = document.createComment("/for"); parent.appendChild(start); parent.appendChild(end); const b = new ForBinding(start, end, instr); bindings.push(b); graph.register(b, extractDeps(instr.expression)); } else if (instr instanceof IfInstr) { const start = document.createComment("if"); const end = document.createComment("/if"); parent.appendChild(start); parent.appendChild(end); const b = new IfBinding(start, end, instr); bindings.push(b); graph.register(b, extractDeps(instr.expression)); } else if (instr instanceof LetInstr) { bindings.push(new LetBinding(instr.name, instr.expression)); } } } // ─── RenderResult ─── export class RenderResult { constructor(fragment, bindings, graph, scope) { this.fragment = fragment; this.bindings = bindings; this.graph = graph; this._scope = scope; } /** * Re-evaluate bindings with new data. * @param {object} data * @param {string[]} [changedProps] — only update bindings for these props */ update(data, changedProps) { const scope = this._scope.clone(); const proxy = scopeProxy(data, scope); for (const b of this.bindings) { if (b instanceof LetBinding) b.declare(scope, proxy); } if (changedProps) { for (const b of this.graph.getAffected(changedProps)) { if (!(b instanceof LetBinding)) b.update(proxy); } } else { for (const b of this.bindings) { if (!(b instanceof LetBinding)) b.update(proxy); } } } destroy() { for (const b of this.bindings) { if (b.destroy) b.destroy(); } } } // ─── Public API ─── export class NodeTemplate { constructor(templateString) { this._instructions = templateString != null ? parse(templateString) : null; } /** * Build DOM once from template. * Returns RenderResult with .fragment, .update(), .destroy(). */ render(data, parentScope) { if (!this._instructions) throw new Error("No template"); return instantiate(this._instructions, data, parentScope); } }