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; } }