From 1352e69895abf1b630b358f74abcde926afa993d Mon Sep 17 00:00:00 2001 From: ztimson Date: Mon, 8 Dec 2025 10:03:38 -0500 Subject: [PATCH] Added templating tests --- package.json | 2 +- src/template.ts | 85 ++++++++++++++++++-------- tests/templates.spec.ts | 132 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 26 deletions(-) create mode 100644 tests/templates.spec.ts diff --git a/package.json b/package.json index 46d9ff6..3ddd0ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.27.19", + "version": "0.27.20", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/template.ts b/src/template.ts index ca2b65c..be1838e 100644 --- a/src/template.ts +++ b/src/template.ts @@ -5,6 +5,34 @@ import {formatDate} from './time.ts'; export class TemplateError extends BadRequestError { } +function findTemplateVars(html: string): Record { + const variables = new Set(); + const regex = /\{\{\s*([^<>\*\?!/}\s][^}]*?)\s*}}/g; + let match; + + while ((match = regex.exec(html)) !== null) { + const code = match[1].trim(); + const varMatch = code.match(/^([a-zA-Z_$][a-zA-Z0-9_$.]*)/); + if (varMatch) variables.add(varMatch[1]); + } + + const result: Record = {}; + for (const path of variables) { + const parts = path.split('.'); + let current = result; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (i === parts.length - 1) { + current[part] = ''; + } else { + current[part] = current[part] || {}; + current = current[part]; + } + } + } + return result; +} + export async function renderTemplate(template: string, data: any, fetch?: (file: string) => Promise) { let content = template, found: any; const now = new Date(), d = { @@ -17,11 +45,7 @@ export async function renderTemplate(template: string, data: any, fetch?: (file: }, ...(data || {}), }; - - if(!fetch) fetch = (file) => { - throw new TemplateError(`Unable to fetch template: ${file}`); - } - + if(!fetch) fetch = (file) => { throw new TemplateError(`Unable to fetch template: ${file}`); } const evaluate = (code: string, data: object, fatal = true) => { try { return Function('data', `with(data) { return ${code}; }`)(data); @@ -31,17 +55,33 @@ export async function renderTemplate(template: string, data: any, fetch?: (file: } } - // If Statements - Optimize what we render: `{{ ? javascript }} TRUE CONTENT {{ !? }} FALSE CONTENT {{ /? }}` - while(!!(found = /\{\{\s*?\?\s*?(.+?)\s*?}}([\s\S]*?)(?:\{\{\s*?!\?\s*?}}([\s\S]*?))?\{\{\s*?\/\?\s*?}}/g.exec(content))) { + // If Statements - Optimize what we render: `{{ ? javascript }} IF TRUE CONTENT {{ !? javascript }} ELSE-IF CONTENT {{ !? }} ELSE FALSE CONTENT {{ /? }}` + while(!!(found = /\{\{\s*?\?\s*?(.+?)\s*?}}([\s\S]*?)\{\{\s*?\/\?\s*?}}/g.exec(content))) { const nested = matchAll(found[0], /\{\{\s*?\?.+?}}/g).slice(-1)?.[0]?.index; - if(nested != 0) - found = /\{\{\s*?\?\s*?(.+?)\s*?}}([\s\S]*?)(?:\{\{\s*?!\?\s*?}}([\s\S]*?))?\{\{\s*?\/\?\s*?}}/g.exec(content.slice(found.index + nested)) - content = content.replace(found[0], (evaluate(found[1], d, false) ? found[2] : found[3]) || ''); + if(nested != 0) found = /\{\{\s*?\?\s*?(.+?)\s*?}}([\s\S]*?)\{\{\s*?\/\?\s*?}}/g.exec(content.slice(found.index + nested)) + const parts = found[2].split(/\{\{\s*?!\?\s*?/); + let result = evaluate(found[1], d, false) ? parts[0] : ''; + if (!result) { + for (let i = 1; i < parts.length; i++) { + const [cond, body] = parts[i].split(/}}/); + if (!cond.trim()) { + result = body || ''; + break; + } + if (evaluate(cond, d, false)) { + result = body || ''; + break; + } + } + } + content = content.replace(found[0], result); } // Imports - We render bottom up: `{{ < file.html }}` while(!!(found = /\{\{\s*?<\s*?(.+?)\s*?}}/g.exec(content))) { - content = content.replace(found[0], await renderTemplate(await fetch(found[1].trim()), data, fetch)); + const t = await fetch(found[1].trim()); + if(!t) throw new TemplateError(`Unknown imported template: ${found[1].trim()}`); + content = content.replace(found[0], await renderTemplate(t, d, fetch)); } // For Loops: `{{ * (row, index) in invoice }} CONTENT {{ /* }}` @@ -49,30 +89,25 @@ export async function renderTemplate(template: string, data: any, fetch?: (file: const split = found[1].replaceAll(/[()\s]/g, '').split(','); const element = split[0]; const index = split[1] || 'index'; - const array: any[] = dotNotation(data, found[2]); - if(!array || typeof array != 'object') - throw new TemplateError(`Cannot iterate: ${found[2]}`); - + const array: any[] = dotNotation(d, found[2]); + if(!array || typeof array != 'object') throw new TemplateError(`Cannot iterate: ${found[2]}`); let compiled = []; - for(let i = 0; i < array.length; i++) { - compiled.push(renderTemplate(found[3], { - ...d, - [element]: array[i], - [index]: i - }, fetch)) - } + for(let i = 0; i < array.length; i++) + compiled.push(await renderTemplate(found[3], {...d, [element]: array[i], [index]: i}, fetch)); content = content.replace(found[0], compiled.join('\n')); } // Evaluate whatever is left - Should come last: `{{ javascript }}` while(!!(found = /\{\{\s*([^<>\*\?!/}\s][^}]*?)\s*}}/g.exec(content))) { - content = content.replace(found[0], evaluate(found[1].trim(), d) || ''); + content = content.replace(found[0], evaluate(found[1].trim(), d) ?? ''); } // Extends: `{{ > file.html:property }} CONTENT {{ /> }}` while(!!(found = /\{\{\s*?>\s*?(.+?):(.+?)\s*?}}([\s\S]*?)\{\{\s*?\/>\s*?}}/g.exec(content))) { - content = content.replace(found[0], await renderTemplate(await fetch(found[1].trim()), { - ...data, + const t = await fetch(found[1].trim()); + if(!t) throw new TemplateError(`Unknown extended templated: ${found[1].trim()}`); + content = content.replace(found[0], await renderTemplate(t, { + ...d, [found[2].trim()]: found[3], }, fetch)); } diff --git a/tests/templates.spec.ts b/tests/templates.spec.ts new file mode 100644 index 0000000..33b41a9 --- /dev/null +++ b/tests/templates.spec.ts @@ -0,0 +1,132 @@ +import { renderTemplate, TemplateError } from '../src'; + +describe('renderTemplate', () => { + test('basic variable interpolation', async () => { + const result = await renderTemplate('Hello {{ name }}!', { name: 'World' }); + expect(result).toBe('Hello World!'); + }); + + test('nested object access', async () => { + const result = await renderTemplate('{{ user.name }} is {{ user.age }}', { + user: { name: 'Alice', age: 25 } + }); + expect(result).toBe('Alice is 25'); + }); + + test('method calls', async () => { + const result = await renderTemplate('{{ price.toFixed(2) }}', { price: 9.5 }); + expect(result).toBe('9.50'); + }); + + test('if statement true', async () => { + const result = await renderTemplate('{{ ? active }}YES{{ /? }}', { active: true }); + expect(result).toBe('YES'); + }); + + test('if statement false', async () => { + const result = await renderTemplate('{{ ? active }}YES{{ /? }}', { active: false }); + expect(result).toBe(''); + }); + + test('if-else', async () => { + const result = await renderTemplate('{{ ? active }}YES{{ !? }}NO{{ /? }}', { active: false }); + expect(result).toBe('NO'); + }); + + test('if-elseif-else', async () => { + const tpl = '{{ ? status == "paid" }}PAID{{ !? status == "pending" }}PENDING{{ !? }}OTHER{{ /? }}'; + expect(await renderTemplate(tpl, { status: 'paid' })).toBe('PAID'); + expect(await renderTemplate(tpl, { status: 'pending' })).toBe('PENDING'); + expect(await renderTemplate(tpl, { status: 'failed' })).toBe('OTHER'); + }); + + test('nested if statements', async () => { + const tpl = '{{ ? a }}A{{ ? b }}B{{ /? }}{{ /? }}'; + expect(await renderTemplate(tpl, { a: true, b: true })).toBe('AB'); + expect(await renderTemplate(tpl, { a: true, b: false })).toBe('A'); + expect(await renderTemplate(tpl, { a: false, b: true })).toBe(''); + }); + + test('for loop', async () => { + const tpl = '{{ * item in items }}{{ item }}{{ /* }}'; + const result = await renderTemplate(tpl, { items: ['a', 'b', 'c'] }); + expect(result).toBe('a\nb\nc'); + }); + + test('for loop with index', async () => { + const tpl = '{{ * (item, i) in items }}{{ i }}:{{ item }}{{ /* }}'; + const result = await renderTemplate(tpl, { items: ['a', 'b'] }); + expect(result).toBe('0:a\n1:b'); + }); + + test('for loop with objects', async () => { + const tpl = '{{ * user in users }}{{ user.name }}{{ /* }}'; + const result = await renderTemplate(tpl, { + users: [{ name: 'Alice' }, { name: 'Bob' }] + }); + expect(result).toBe('Alice\nBob'); + }); + + test('for loop error on non-array', async () => { + await expect( + renderTemplate('{{ * x in notArray }}{{ x }}{{ /* }}', { notArray: 'string' }) + ).rejects.toThrow(TemplateError); + }); + + test('import template', async () => { + const fetch = async (file: string) => { + if (file === 'header.html') return 'HEADER: {{ title }}'; + throw new Error('Not found'); + }; + const result = await renderTemplate('{{ < header.html }}', { title: 'Test' }, fetch); + expect(result).toBe('HEADER: Test'); + }); + + test('import template error', async () => { + await expect( + renderTemplate('{{ < missing.html }}', {}) + ).rejects.toThrow(TemplateError); + }); + + test('extend template', async () => { + const fetch = async (file: string) => { + if (file === 'layout.html') return '
{{ content }}
'; + throw new Error('Not found'); + }; + const result = await renderTemplate('{{ > layout.html:content }}Hello{{ /> }}', {}, fetch); + expect(result).toBe('
Hello
'); + }); + + test('date object', async () => { + const result = await renderTemplate('{{ date.year }}', {}); + expect(result).toBe(new Date().getFullYear().toString()); + }); + + test('evaluation error', async () => { + await expect( + renderTemplate('{{ undefined.property }}', {}) + ).rejects.toThrow(TemplateError); + }); + + test('complex nested example', async () => { + const tpl = ` +{{ ? items.length > 0 }} +{{ * (item, i) in items }} +{{ i + 1 }}. {{ item.name }}: \${{ item.price.toFixed(2) }} +{{ /* }} +Total: \${{ total.toFixed(2) }} +{{ !? }} +No items +{{ /? }}`; + const result = await renderTemplate(tpl, { + items: [ + { name: 'A', price: 10 }, + { name: 'B', price: 20 } + ], + total: 30 + }); + expect(result.trim()).toContain('1. A: $10.00'); + expect(result.trim()).toContain('2. B: $20.00'); + expect(result.trim()).toContain('Total: $30.00'); + }); +});