diff --git a/package.json b/package.json index d76bf13..0baf591 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.28.1", + "version": "0.28.2", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/template.ts b/src/template.ts index 68aec2f..ecba278 100644 --- a/src/template.ts +++ b/src/template.ts @@ -7,50 +7,46 @@ export class TemplateError extends BadRequestError { } export function findTemplateVars(html: string): Record { const variables = new Set(); + const arrays = new Set(); const excluded = new Set(['true', 'false', 'null', 'undefined']); - // Extract & exclude loop variables + // Extract & exclude loop variables, mark arrays 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); + const arrayVar = loop[2].trim(); + const root = arrayVar.split('.')[0]; + if (!excluded.has(root)) { + variables.add(arrayVar); + arrays.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) || []; + const code = ifStmt[1].replace(/["'`][^"'`]*["'`]/g, ''); + const cleaned = code.replace(/([a-zA-Z_$][a-zA-Z0-9_$.]*)\s*\(/g, (_, v) => { + const parts = v.split('.'); + return parts.length > 1 ? parts.slice(0, -1).join('.') + ' ' : ''; + }); + const vars = cleaned.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); + if (!excluded.has(root)) variables.add(v); } } - // 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 + // Extract from if block content & regular interpolations const regex = /\{\{\s*([^<>\*\?!/}\s][^}]*?)\s*}}/g; let match; while ((match = regex.exec(html)) !== null) { const code = match[1].trim().replace(/["'`][^"'`]*["'`]/g, ''); - const vars = code.match(/[a-zA-Z_$][a-zA-Z0-9_$.]+/g) || []; + const cleaned = code.replace(/([a-zA-Z_$][a-zA-Z0-9_$.]*)\s*\(/g, (_, v) => { + const parts = v.split('.'); + return parts.length > 1 ? parts.slice(0, -1).join('.') + ' ' : ''; + }); + const vars = cleaned.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); @@ -64,7 +60,8 @@ export function findTemplateVars(html: string): Record { for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (i === parts.length - 1) { - current[part] = ''; + const fullPath = parts.slice(0, i + 1).join('.'); + current[part] = arrays.has(fullPath) ? [] : ''; } else { current[part] = current[part] || {}; current = current[part]; diff --git a/tests/templates.spec.ts b/tests/templates.spec.ts index f60988d..e73f3ce 100644 --- a/tests/templates.spec.ts +++ b/tests/templates.spec.ts @@ -23,30 +23,30 @@ describe('findTemplateVars', () => { test('extracts array reference from loops', () => { const result = findTemplateVars('{{ * item in items }}{{ item }}{{ /* }}'); - expect(result).toEqual({ items: '' }); + expect(result).toEqual({ items: [] }); }); test('excludes loop element variable', () => { const result = findTemplateVars('{{ * item in items }}{{ item.name }}{{ /* }}'); - expect(result).toEqual({ items: '' }); + 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).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: '' }); + 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).toEqual({ rows: [] }); expect(result).not.toHaveProperty('row'); expect(result).not.toHaveProperty('col'); }); @@ -63,7 +63,7 @@ Total: {{ total }} {{ /? }}`; const result = findTemplateVars(tpl); expect(result).toEqual({ - items: '', + items: [], currency: '', total: '', emptyMessage: '' @@ -81,6 +81,62 @@ Total: {{ total }} const result = findTemplateVars('{{ firstName + " " + lastName }}'); expect(result).toEqual({ firstName: '', lastName: '' }); }); + + test('creates arrays for loop variables', () => { + const result = findTemplateVars('{{ * item in items }}{{ item }}{{ /* }}'); + expect(result).toEqual({ items: [] }); + }); + + test('creates nested arrays', () => { + const result = findTemplateVars('{{ * row in data.rows }}{{ row }}{{ /* }}'); + expect(result).toEqual({ data: { rows: [] } }); + }); + + test('creates multiple arrays', () => { + const result = findTemplateVars('{{ * user in users }}{{ user }}{{ /* }}{{ * post in posts }}{{ post }}{{ /* }}'); + expect(result).toEqual({ users: [], posts: [] }); + }); + + test('excludes function calls', () => { + const result = findTemplateVars('{{ value.toFixed(2) }}'); + expect(result).toEqual({ value: '' }); + expect(result.value).not.toBe('toFixed'); + }); + + test('excludes method chains', () => { + const result = findTemplateVars('{{ text.replaceAll("\\n", "
") }}'); + expect(result).toEqual({ text: '' }); + }); + + test('handles mix of arrays and regular variables', () => { + const result = findTemplateVars('{{ * item in cart }}{{ item.name }}{{ /* }}{{ total }}'); + expect(result).toEqual({ cart: [], total: '' }); + }); + + test('real world invoice template', () => { + const tpl = ` +{{ settings.title }} +{{ * (row, index) in transaction.cart }} + {{ row.quantity }} x {{ row.name }} = \${{ row.cost.toFixed(2) }} +{{ /* }} +Total: \${{ transaction.total.toFixed(2) }} +{{ ? transaction.discount }} + Discount: {{ transaction.discount.value }} +{{ /? }} +`; + const result = findTemplateVars(tpl); + expect(result).toEqual({ + settings: { title: '' }, + transaction: { + cart: [], + total: '', + discount: { value: '' } + } + }); + expect(result).not.toHaveProperty('row'); + expect(result).not.toHaveProperty('index'); + expect(result.transaction.cart).toEqual([]); + }); }); describe('renderTemplate', () => {