multiconf
Version:
Work with JSON configs
132 lines (116 loc) • 3.78 kB
JavaScript
/**
* Supports text blocks and three tag forms:
* - `<% code %>` for execution
* - `<%= expression %>` for escaped interpolation
* - `<%- expression %>` for raw interpolation
*/
class Template {
/**
* @param {object} [options] Template options.
* @param {string[]} [options.tags] Opening and closing tag delimiters.
*/
constructor(options = {}) {
this.options = {
tags: ['<%', '%>'],
...options,
};
}
/**
* Render a template string against the provided context.
*
* @param {string} template Template source.
* @param {object} [context] Values available to template expressions.
* @returns {string} Rendered output.
*/
renderString(template, context = {}) {
if (typeof template !== 'string') {
return '';
}
const render = this.#compile(template);
return render(context);
}
/**
* Compile a template into a render function.
* @private
* @param {string} template Template source.
* @returns {(context: object) => string} Render function.
*/
#compile(template) {
const tokens = this.#tokenize(template);
const source = [
'let __output = "";',
'const __append = (value) => {',
' if (value !== undefined && value !== null) {',
' __output += String(value);',
' }',
'};',
'with (context || {}) {',
];
for (const token of tokens) {
if (token.type === 'text') {
source.push(`__append(${JSON.stringify(token.value)});`);
} else if (token.type === 'escape') {
source.push(`__append(escapeHtml(${token.value}));`);
} else if (token.type === 'raw') {
source.push(`__append(${token.value});`);
} else if (token.type === 'code') {
source.push(token.value);
}
}
source.push('}', 'return __output;');
const renderer = new Function('context', 'escapeHtml', source.join('\n'));
return (context) => renderer(context, Template.#escapeHtml);
}
/**
* Tokenize a template using the configured tag delimiters.
* @private
* @param {string} template Template source.
* @returns {Array<{type: 'text'|'code'|'escape'|'raw', value: string}>} Token list.
*/
#tokenize(template) {
const [openTag, closeTag] = this.options.tags;
const tokens = [];
let cursor = 0;
while (cursor < template.length) {
const openIndex = template.indexOf(openTag, cursor);
if (openIndex === -1) {
tokens.push({ type: 'text', value: template.slice(cursor) });
break;
}
if (openIndex > cursor) {
tokens.push({ type: 'text', value: template.slice(cursor, openIndex) });
}
const tagStart = openIndex + openTag.length;
const closeIndex = template.indexOf(closeTag, tagStart);
if (closeIndex === -1) {
throw new Error('Unclosed template tag.');
}
const tagBody = template.slice(tagStart, closeIndex).trim();
if (tagBody.startsWith('=')) {
tokens.push({ type: 'escape', value: tagBody.slice(1).trim() });
} else if (tagBody.startsWith('-')) {
tokens.push({ type: 'raw', value: tagBody.slice(1).trim() });
} else if (tagBody.length > 0) {
tokens.push({ type: 'code', value: tagBody });
}
cursor = closeIndex + closeTag.length;
}
return tokens;
}
/**
* Escape HTML-sensitive characters in interpolated output.
* @private
* @param {*} value Value to escape.
* @returns {string} Escaped string.
*/
static #escapeHtml(value) {
const stringValue = String(value);
return stringValue
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
}
module.exports = Template;