import { AvenxErrorCodes, formatMessage } from '../runtime/AvenxError.js';
import { HtmlEscaper, SafeHtml } from '../security/escapeHtml.js';
import { logger } from '../runtime/AvenxLogger.js';
import { LruCache } from '../utils/LruCache.js';
import { createInterpolationRegex } from '../utils/templateUtils.js';
const templateEscaper = new HtmlEscaper();
const DEFAULT_TEMPLATE_CACHE_CAPACITY = 500;
/**
* Segment structure for parsed template AST:
* @typedef {object} TemplateSegment
* @property {boolean} isExpression - True if this segment is an interpolation expression.
* @property {string} [value] - Static text content when isExpression is false.
* @property {string} [expression] - Expression source code when isExpression is true.
* @property {boolean} [isRaw] - True if raw interpolation {{{ ... }}} was used.
*/
/**
* Handles the rendering of HTML templates by resolving interpolation expressions.
* Uses an LruCache to cache parsed template AST segments with bounded capacity.
*/
export class TemplateRenderer {
/**
* Constructs the TemplateRenderer with a configurable LRU cache capacity.
* @param {number|object} [capacityOrConfig] - Maximum LRU cache capacity or configuration object.
*/
constructor(capacityOrConfig = DEFAULT_TEMPLATE_CACHE_CAPACITY) {
let capacity = DEFAULT_TEMPLATE_CACHE_CAPACITY;
if (typeof capacityOrConfig === 'number' && capacityOrConfig > 0) {
capacity = capacityOrConfig;
} else if (capacityOrConfig && typeof capacityOrConfig === 'object') {
if (typeof capacityOrConfig.templateCacheCapacity === 'number' && capacityOrConfig.templateCacheCapacity > 0) {
capacity = capacityOrConfig.templateCacheCapacity;
} else if (typeof capacityOrConfig.capacity === 'number' && capacityOrConfig.capacity > 0) {
capacity = capacityOrConfig.capacity;
}
}
/**
* Maximum capacity of the LRU cache.
* @type {number}
*/
this.capacity = capacity;
/**
* LRU Cache storing parsed template AST segments.
* @type {LruCache}
*/
this.cache = new LruCache(capacity);
}
/**
* Parses a raw HTML template string into tokenized AST segments.
* @param {string} template - The HTML template string.
* @returns {TemplateSegment[]} Array of template segments.
*/
parseTemplate(template) {
const segments = [];
const regex = createInterpolationRegex();
let lastIndex = 0;
let match;
while ((match = regex.exec(template)) !== null) {
if (match.index > lastIndex) {
segments.push({
isExpression: false,
value: template.substring(lastIndex, match.index),
});
}
const isRaw = match[1] !== undefined;
const expression = isRaw ? match[1] : match[2];
segments.push({
isExpression: true,
expression,
isRaw,
});
lastIndex = regex.lastIndex;
}
if (lastIndex < template.length) {
segments.push({
isExpression: false,
value: template.substring(lastIndex),
});
}
return segments;
}
/**
* Clears the template LRU cache.
*/
clearCache() {
this.cache.clear();
}
/**
* Renders the template by replacing {{ expression }} and {{{ expression }}} with evaluated values.
* @param {string} template - The HTML template string.
* @param {function(string): any} resolveExpression - Function to evaluate expressions.
* @returns {string} The rendered HTML string.
*/
render(template, resolveExpression) {
if (!template) return '';
let segments = this.cache.get(template);
if (!segments) {
segments = this.parseTemplate(template);
this.cache.set(template, segments);
}
let result = '';
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (!seg.isExpression) {
result += seg.value;
} else {
const expression = seg.expression;
const isRaw = seg.isRaw;
try {
const value = resolveExpression(expression);
if (value == null) {
continue;
}
if (isRaw || value instanceof SafeHtml) {
result += String(value);
} else {
result += templateEscaper.escape(value);
}
} catch (error) {
if (error instanceof Promise) {
throw error; // Suspense: bubble up the promise without logging
}
if (error && error.code === AvenxErrorCodes.STATE_MUTATION_IN_UPDATE) {
throw error;
}
logger.warn(formatMessage(AvenxErrorCodes.TEMPLATE_RENDER_ERROR, expression, error));
throw error;
}
}
}
return this.resolveDynamicAttributes(result, resolveExpression);
}
/**
* Resolves dynamic attribute name binding syntax (:[attrName]="attrValue") in HTML string.
* @param {string} html - The HTML string.
* @param {function(string): any} resolveExpression - Function to evaluate expressions.
* @returns {string} The HTML string with dynamic attributes resolved.
*/
resolveDynamicAttributes(html, resolveExpression) {
if (!html || typeof html !== 'string' || !html.includes(':[')) {
return html;
}
const tagRegex = /<([a-zA-Z0-9@/!-][^>]*?)>/g;
return html.replace(tagRegex, (fullTag, tagInner) => {
if (tagInner.startsWith('/') || tagInner.startsWith('!') || tagInner.startsWith('?')) {
return fullTag;
}
const dynAttrRegex = /:\[(.*?)\]\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
if (!dynAttrRegex.test(tagInner)) {
return fullTag;
}
dynAttrRegex.lastIndex = 0;
const activeDynAttrs = [];
const parts = [];
let lastIdx = 0;
let match;
while ((match = dynAttrRegex.exec(tagInner)) !== null) {
parts.push(tagInner.substring(lastIdx, match.index));
const nameExpr = match[1];
const valExpr = match[2] !== undefined ? match[2] : (match[3] !== undefined ? match[3] : match[4]);
let resolvedName = null;
try {
resolvedName = resolveExpression(nameExpr);
} catch (error) {
logger.warn(formatMessage(AvenxErrorCodes.TEMPLATE_RENDER_ERROR, nameExpr, error));
}
if (resolvedName != null && String(resolvedName).trim() !== '') {
const attrName = String(resolvedName).trim();
let resolvedVal = null;
if (valExpr !== undefined && valExpr !== null) {
try {
resolvedVal = resolveExpression(valExpr);
} catch (error) {
logger.warn(formatMessage(AvenxErrorCodes.TEMPLATE_RENDER_ERROR, valExpr, error));
}
}
if (resolvedVal !== false && resolvedVal != null) {
activeDynAttrs.push(attrName);
if (resolvedVal === true) {
parts.push(`${attrName}="true"`);
} else {
const escapedVal = templateEscaper.escape(String(resolvedVal));
parts.push(`${attrName}="${escapedVal}"`);
}
} else {
activeDynAttrs.push(attrName);
}
}
lastIdx = dynAttrRegex.lastIndex;
}
parts.push(tagInner.substring(lastIdx));
let newTagInner = parts.join('');
if (activeDynAttrs.length > 0) {
newTagInner += ` data-ax-dyn-attrs="${activeDynAttrs.join(',')}"`;
}
return `<${newTagInner}>`;
});
}
}