UNPKG

multiconf

Version:

Work with JSON configs

132 lines (116 loc) 3.78 kB
/** * 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, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); } } module.exports = Template;