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

477 lines
13 KiB
JavaScript

import "./linq.js";
function selectPath(data, expression) {
if (!expression) return data;
try {
return new Function("$data", `with($data) { return (${expression}); }`)(
data,
);
} catch {
console.error(`Error evaluating Template expression: ${expression}`);
return undefined;
}
}
class ScopeDict {
constructor(initial = {}) {
this._entries = { ...initial };
}
set(name, value) {
if (name.startsWith("$")) {
throw new Error(
`Cannot declare '${name}' with @let. Variables starting with '$' are reserved.`,
);
}
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 createScopeProxy(data, scope) {
if (data === null || data === undefined || typeof data !== "object") {
scope.setBuiltin("$this", data);
return new Proxy(
{},
{
get(_target, prop) {
if (prop === "$this") return data;
if (scope.has(prop)) return scope.get(prop);
return undefined;
},
has(_target, prop) {
return prop === "$this" || scope.has(prop);
},
},
);
}
scope.setBuiltin("$this", data);
return new Proxy(data, {
get(target, prop) {
if (prop === "$this") return data;
if (scope.has(prop)) return scope.get(prop);
return target[prop];
},
has(target, prop) {
return prop === "$this" || scope.has(prop) || prop in target;
},
});
}
function skipString(str, i) {
const quote = str[i];
i++;
while (i < str.length && str[i] !== quote) {
if (str[i] === "\\") i++;
i++;
}
return i;
}
function findClosingParen(str, openPos) {
let depth = 1;
let i = openPos + 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 ( in template");
return i;
}
function findClosingBrace(str, openPos) {
let depth = 1;
let i = openPos + 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 {{ }} in template");
i = end + 2;
continue;
}
if (str[i] === "{") depth++;
else if (str[i] === "}") depth--;
if (depth > 0) i++;
}
if (depth !== 0) throw new Error("Unclosed { in template");
return i;
}
function formatNumber(value, spec) {
const hasThousands = spec.includes(",");
const dotIdx = spec.indexOf(".");
let decimals = 0;
if (dotIdx !== -1) {
decimals = spec.length - dotIdx - 1;
}
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 padLength = spec.replace(/[^0]/g, "").length;
if (padLength > 0) {
const isNeg = value < 0;
let abs = Math.abs(Math.round(value)).toString();
abs = abs.padStart(padLength, "0");
result = isNeg ? "-" + abs : abs;
}
}
return result;
}
function formatDate(date, spec) {
const pad = (n, len = 2) => String(n).padStart(len, "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 [token, value] of Object.entries(tokens).sort(
(a, b) => b[0].length - a[0].length,
)) {
result = result.replaceAll(token, String(value));
}
return result;
}
function createFormatter(formatSpec) {
return (value) => {
if (value == null) return "";
if (value instanceof Date) return formatDate(value, formatSpec);
if (typeof value === "number") return formatNumber(value, formatSpec);
return String(value);
};
}
class StaticTemplatePart {
constructor(text) {
this.text = text;
}
render(_data) {
return this.text;
}
}
class VariableTemplatePart {
constructor(contextPath, formatter) {
this.contextPath = contextPath;
this.formatter = formatter || ((x) => x);
}
render(data) {
return this.formatter(selectPath(data, this.contextPath));
}
}
class LetTemplatePart {
constructor(name, expression) {
this.name = name;
this.expression = expression;
}
declareScoped(scope, data) {
scope.set(this.name, selectPath(data, this.expression));
}
render(_data) {
return "";
}
}
class ListTemplatePart {
constructor(varNames, contextPath, innerTemplate, emptyTemplate, formatter) {
this.varNames = varNames;
this.contextPath = contextPath;
this.formatter = formatter || ((x) => x);
this.innerTemplate = innerTemplate;
this.emptyTemplate = emptyTemplate;
}
render(data) {
const list = this.formatter(selectPath(data, this.contextPath));
const arr = Array.isArray(list) ? list : Array.from(list);
if (arr.length === 0) {
return this.emptyTemplate ? this.emptyTemplate.render(data) : "";
}
let result = "";
const count = arr.length;
for (let index = 0; index < count; index++) {
const scope = new ScopeDict();
const item = arr[index];
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);
result += this.innerTemplate.render(data, scope);
}
return result;
}
}
class ConditionalTemplatePart {
constructor(contextPath, condition, trueTemplate, falseTemplate, formatter) {
this.contextPath = contextPath;
this.formatter = formatter || ((x) => x);
this.condition = condition;
this.trueTemplate = trueTemplate;
this.falseTemplate = falseTemplate;
}
render(data) {
const selectedData = this.formatter(selectPath(data, this.contextPath));
if (this.condition(selectedData)) {
return this.trueTemplate ? this.trueTemplate.render(data) : "";
} else {
return this.falseTemplate ? this.falseTemplate.render(data) : "";
}
}
}
export class Template {
constructor(templateString) {
if (templateString != null) {
this.parts = this._parseBlock(templateString);
}
}
_parseBlock(str) {
const parts = [];
let i = 0;
while (i < str.length) {
// {{ expression }} or {{ expression:format }}
if (str[i] === "{" && str[i + 1] === "{") {
const end = str.indexOf("}}", i + 2);
if (end === -1) throw new Error("Unclosed {{ }}");
const content = str.substring(i + 2, end).trim();
let expression,
formatter = null;
const colonIdx = content.lastIndexOf(":");
if (colonIdx > 0) {
const possibleFormat = content.substring(colonIdx + 1).trim();
if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(possibleFormat)) {
expression = content.substring(0, colonIdx).trim();
formatter = createFormatter(possibleFormat);
} else {
expression = content;
}
} else {
expression = content;
}
parts.push(new VariableTemplatePart(expression, formatter));
i = end + 2;
continue;
}
// @for(item of expression){...} @empty{...}
if (str.startsWith("@for(", i)) {
const parenOpen = i + 4;
const parenClose = findClosingParen(str, parenOpen);
const forClause = str.substring(parenOpen + 1, parenClose).trim();
const ofIdx = forClause.indexOf(" of ");
if (ofIdx === -1)
throw new Error('@for missing "of": @for(item of expression)');
const varNames = forClause
.substring(0, ofIdx)
.split(",")
.map((v) => v.trim());
const expression = forClause.substring(ofIdx + 4).trim();
const braceOpen = str.indexOf("{", parenClose + 1);
if (braceOpen === -1) throw new Error("@for missing block");
const braceClose = findClosingBrace(str, braceOpen);
const blockContent = str.substring(braceOpen + 1, braceClose);
const innerTemplate = new Template(null);
innerTemplate.parts = this._parseBlock(blockContent);
let emptyTemplate = 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);
const emptyContent = str.substring(
emptyBraceOpen + 1,
emptyBraceClose,
);
emptyTemplate = new Template(null);
emptyTemplate.parts = this._parseBlock(emptyContent);
afterBlock = emptyBraceClose + 1;
}
parts.push(
new ListTemplatePart(
varNames,
expression,
innerTemplate,
emptyTemplate,
),
);
i = afterBlock;
continue;
}
// @if(expression){...}@else{...}
if (str.startsWith("@if(", i)) {
const parenOpen = i + 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 trueContent = str.substring(trueBraceOpen + 1, trueBraceClose);
const trueTemplate = new Template(null);
trueTemplate.parts = this._parseBlock(trueContent);
let falseTemplate = 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);
const falseContent = str.substring(
falseBraceOpen + 1,
falseBraceClose,
);
falseTemplate = new Template(null);
falseTemplate.parts = this._parseBlock(falseContent);
afterBlock = falseBraceClose + 1;
}
parts.push(
new ConditionalTemplatePart(
expression,
(val) => !!val,
trueTemplate,
falseTemplate,
),
);
i = afterBlock;
continue;
}
// @let name = expression (ends at newline or end of string)
if (str.startsWith("@let ", i)) {
const lineEnd = str.indexOf("\n", i);
const end = lineEnd === -1 ? str.length : lineEnd;
const declaration = str.substring(i + 5, end).trim();
const eqIdx = declaration.indexOf("=");
if (eqIdx === -1) throw new Error("Invalid @let: missing =");
const name = declaration.substring(0, eqIdx).trim();
const expression = declaration.substring(eqIdx + 1).trim();
parts.push(new LetTemplatePart(name, expression));
i = end + 1;
continue;
}
// Static text — collect until next special token
let end = i;
while (end < str.length) {
if (str[end] === "{" && str[end + 1] === "{") break;
if (
str[end] === "@" &&
(str.startsWith("@for(", end) ||
str.startsWith("@if(", end) ||
str.startsWith("@let ", end))
)
break;
end++;
}
if (end > i) {
parts.push(new StaticTemplatePart(str.substring(i, end)));
i = end;
}
}
return parts;
}
render(data, parentScope = new ScopeDict()) {
const scope = parentScope.clone();
const proxy = createScopeProxy(data, scope);
let result = "";
for (const part of this.parts) {
part.declareScoped?.(scope, proxy);
result += part.render(proxy);
}
return result;
}
}