diff --git a/package.json b/package.json index 23b04ea..d76bf13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.28.0", + "version": "0.28.1", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/template.ts b/src/template.ts index 9f0220d..68aec2f 100644 --- a/src/template.ts +++ b/src/template.ts @@ -7,13 +7,54 @@ export class TemplateError extends BadRequestError { } export function findTemplateVars(html: string): Record { const variables = new Set(); + const excluded = new Set(['true', 'false', 'null', 'undefined']); + + // Extract & exclude loop variables + for (const loop of matchAll(html, /\{\{\s*?\*\s*?(.+?)\s+in\s+(.+?)\s*?}}/g)) { + const [element, index = 'index'] = loop[1].replaceAll(/[()\s]/g, '').split(','); + excluded.add(element); + excluded.add(index); + // Add the array reference (but exclude if it's a loop variable) + const arrayRef = loop[2].trim(); + const arrayVar = arrayRef.split('.')[0].match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/)?.[0]; + if (arrayVar && !excluded.has(arrayVar)) variables.add(arrayVar); + } + + // Extract variables from if/else-if conditions + for (const ifStmt of matchAll(html, /\{\{\s*?[!]?\?\s*?([^}]+?)\s*?}}/g)) { + const code = ifStmt[1].replace(/["'`][^"'`]*["'`]/g, ''); // Remove string literals + const vars = code.match(/[a-zA-Z_$][a-zA-Z0-9_$.]+/g) || []; + for (const v of vars) { + const root = v.split('.')[0]; + if (!excluded.has(root)) variables.add(root); + } + } + + // Extract variables from content within if blocks + for (const ifBlock of matchAll(html, /\{\{\s*?\?\s*?.+?\s*?}}([\s\S]*?)\{\{\s*?\/\?\s*?}}/g)) { + const content = ifBlock[1]; + const regex = /\{\{\s*([^<>\*\?!/}\s][^}]*?)\s*}}/g; + let match; + while ((match = regex.exec(content)) !== null) { + const code = match[1].trim().replace(/["'`][^"'`]*["'`]/g, ''); + const vars = code.match(/[a-zA-Z_$][a-zA-Z0-9_$.]+/g) || []; + for (const v of vars) { + const root = v.split('.')[0]; + if (!excluded.has(root)) variables.add(v); + } + } + } + + // Extract variables from regular interpolations 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 code = match[1].trim().replace(/["'`][^"'`]*["'`]/g, ''); + const vars = code.match(/[a-zA-Z_$][a-zA-Z0-9_$.]+/g) || []; + for (const v of vars) { + const root = v.split('.')[0]; + if (!excluded.has(root)) variables.add(v); + } } const result: Record = {}; diff --git a/tests/templates.spec.ts b/tests/templates.spec.ts index 33b41a9..f60988d 100644 --- a/tests/templates.spec.ts +++ b/tests/templates.spec.ts @@ -1,4 +1,87 @@ -import { renderTemplate, TemplateError } from '../src'; +import {findTemplateVars, renderTemplate, TemplateError} from '../src'; + +describe('findTemplateVars', () => { + test('extracts simple variables', () => { + const result = findTemplateVars('Hello {{ name }}!'); + expect(result).toEqual({ name: '' }); + }); + + test('extracts nested object paths', () => { + const result = findTemplateVars('{{ user.name }} is {{ user.age }}'); + expect(result).toEqual({ user: { name: '', age: '' } }); + }); + + test('extracts variables from if statements', () => { + const result = findTemplateVars('{{ ? active }}{{ message }}{{ /? }}'); + expect(result).toEqual({ active: '', message: '' }); + }); + + test('extracts variables from else-if conditions', () => { + const result = findTemplateVars('{{ ? status == "paid" }}PAID{{ !? status == "pending" }}{{ value }}{{ /? }}'); + expect(result).toEqual({ status: '', value: '' }); + }); + + test('extracts array reference from loops', () => { + const result = findTemplateVars('{{ * item in items }}{{ item }}{{ /* }}'); + expect(result).toEqual({ items: '' }); + }); + + test('excludes loop element variable', () => { + const result = findTemplateVars('{{ * item in items }}{{ item.name }}{{ /* }}'); + expect(result).toEqual({ items: '' }); + expect(result).not.toHaveProperty('item'); + }); + + test('excludes loop index variable', () => { + const result = findTemplateVars('{{ * (item, i) in items }}{{ i }}:{{ item }}{{ /* }}'); + expect(result).toEqual({ items: '' }); + expect(result).not.toHaveProperty('item'); + expect(result).not.toHaveProperty('i'); + }); + + test('extracts external vars used inside loops', () => { + const result = findTemplateVars('{{ * item in items }}{{ item }}-{{ prefix }}{{ /* }}'); + expect(result).toEqual({ items: '', prefix: '' }); + }); + + test('handles nested loops', () => { + const result = findTemplateVars('{{ * row in rows }}{{ * col in row.cols }}{{ col }}{{ /* }}{{ /* }}'); + expect(result).toEqual({ rows: '' }); + expect(result).not.toHaveProperty('row'); + expect(result).not.toHaveProperty('col'); + }); + + test('extracts from complex nested template', () => { + const tpl = ` +{{ ? items.length > 0 }} +{{ * (item, i) in items }} +{{ i }}. {{ item.name }}: {{ currency }}{{ item.price }} +{{ /* }} +Total: {{ total }} +{{ !? }} +{{ emptyMessage }} +{{ /? }}`; + const result = findTemplateVars(tpl); + expect(result).toEqual({ + items: '', + currency: '', + total: '', + emptyMessage: '' + }); + expect(result).not.toHaveProperty('item'); + expect(result).not.toHaveProperty('i'); + }); + + test('ignores template control syntax', () => { + const result = findTemplateVars('{{ < header.html }}{{ > layout.html:content }}content{{ /> }}'); + expect(result).toEqual({}); + }); + + test('handles multiple variables in expressions', () => { + const result = findTemplateVars('{{ firstName + " " + lastName }}'); + expect(result).toEqual({ firstName: '', lastName: '' }); + }); +}); describe('renderTemplate', () => { test('basic variable interpolation', async () => {