2026-04-13 20:53:26 +02:00

959 lines
25 KiB
JavaScript

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)) {
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({ 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);
}
}