import "./linq.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 formatDate(date, spec) { const pad = (n, l = 2) => String(n).padStart(l, "0"); const tokens = { yyyy: date.getFullYear(), yy: String(date.getFullYear()).slice(-2), MM: pad(date.getMonth() + 1), M: date.getMonth() + 1, dd: pad(date.getDate()), d: date.getDate(), HH: pad(date.getHours()), H: date.getHours(), hh: pad(date.getHours() % 12 || 12), h: date.getHours() % 12 || 12, mm: pad(date.getMinutes()), m: date.getMinutes(), ss: pad(date.getSeconds()), s: date.getSeconds(), fff: pad(date.getMilliseconds(), 3), }; let result = spec; for (const [tok, val] of Object.entries(tokens).sort( (a, b) => b[0].length - a[0].length, )) { result = result.replaceAll(tok, String(val)); } 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 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 }; } // ───────────────────────────────────────────────── // Parser — template string → instruction tree // ───────────────────────────────────────────────── // // Produces pure-data nodes. No DOM, no strings. // // { type: 'text', value } // { type: 'expr', expression, formatter } // { type: 'element', tag, attrs:[{name, parts}], children } // { type: 'for', varNames, expression, trackExpr, children, empty } // { type: 'if', expression, trueChildren, falseChildren } // { type: 'let', name, expression } 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({ type: "text", value: 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({ type: "expr", expression, formatter }); pos = end + 2; start = pos; continue; } pos++; } if (pos > start) nodes.push({ type: "text", value: 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: [{ type: "text", value: 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({ type: "text", value: 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({ type: "expr", expression, formatter }); pos = end + 2; start = pos; continue; } pos++; } if (pos > start) parts.push({ type: "text", value: 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) { while (pos < str.length && /\s/.test(str[pos])) pos++; if (str[pos] === ">") { pos++; break; } if (str[pos] === "/" && str[pos + 1] === ">") { pos += 2; return { node: { type: "element", tag, attrs, children: [] }, pos }; } // Attribute name — supports (event)="..." syntax let nameEnd = pos; while (nameEnd < str.length && !/[\s=\/>]/.test(str[nameEnd])) nameEnd++; const attrName = str.substring(pos, nameEnd); pos = nameEnd; while (pos < str.length && /\s/.test(str[pos])) pos++; if (str[pos] !== "=") { attrs.push({ name: attrName, parts: [] }); // boolean attr continue; } pos++; // skip = while (pos < str.length && /\s/.test(str[pos])) pos++; const r = parseAttrValue(str, pos); attrs.push({ name: attrName, parts: r.parts }); pos = r.pos; } if (VOID_ELEMENTS.has(tag.toLowerCase())) { return { node: { type: "element", tag, attrs, children: [] }, 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: { type: "element", 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: { type: "for", varNames, expression: 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: { type: "if", 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: { type: "let", name: decl.substring(0, eqIdx).trim(), expression: 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.type === "text") { 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 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) { switch (instr.type) { case "text": { parent.appendChild(document.createTextNode(instr.value)); break; } case "expr": { 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)); break; } case "element": { const el = document.createElement(instr.tag); for (const attr of instr.attrs) { const hasDynamic = attr.parts.some((p) => p.type === "expr"); if (!hasDynamic) { const val = attr.parts.length === 0 ? "" // boolean attr : 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.type === "expr") 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); break; } case "for": { 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)); break; } case "if": { 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)); break; } case "let": { bindings.push(new LetBinding(instr.name, instr.expression)); break; } } } } // ─── 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); } }