456 lines
15 KiB
JavaScript
456 lines
15 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 findTopLevelComma(str) {
|
|
let depth = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
|
|
i = skipString(str, i);
|
|
continue;
|
|
}
|
|
if (str[i] === '(') depth++;
|
|
else if (str[i] === ')') depth--;
|
|
else if (str[i] === ',' && depth === 0) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|