1034 lines
28 KiB
JavaScript
1034 lines
28 KiB
JavaScript
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)) {
|
|
const end = str.indexOf("-->", 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);
|
|
}
|
|
}
|