Template variable extraction ignores functions
All checks were successful
Build / Publish Docs (push) Successful in 38s
Build / Build NPM Project (push) Successful in 52s
Build / Tag Version (push) Successful in 10s

This commit is contained in:
2025-12-08 19:26:55 -05:00
parent cabfc93773
commit 49959f3060
3 changed files with 86 additions and 33 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/utils", "name": "@ztimson/utils",
"version": "0.28.1", "version": "0.28.2",
"description": "Utility library", "description": "Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",

View File

@@ -7,50 +7,46 @@ export class TemplateError extends BadRequestError { }
export function findTemplateVars(html: string): Record<string, any> { export function findTemplateVars(html: string): Record<string, any> {
const variables = new Set<string>(); const variables = new Set<string>();
const arrays = new Set<string>();
const excluded = new Set<string>(['true', 'false', 'null', 'undefined']); const excluded = new Set<string>(['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)) { for (const loop of matchAll(html, /\{\{\s*?\*\s*?(.+?)\s+in\s+(.+?)\s*?}}/g)) {
const [element, index = 'index'] = loop[1].replaceAll(/[()\s]/g, '').split(','); const [element, index = 'index'] = loop[1].replaceAll(/[()\s]/g, '').split(',');
excluded.add(element); excluded.add(element);
excluded.add(index); excluded.add(index);
// Add the array reference (but exclude if it's a loop variable) const arrayVar = loop[2].trim();
const arrayRef = loop[2].trim(); const root = arrayVar.split('.')[0];
const arrayVar = arrayRef.split('.')[0].match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/)?.[0]; if (!excluded.has(root)) {
if (arrayVar && !excluded.has(arrayVar)) variables.add(arrayVar); variables.add(arrayVar);
arrays.add(arrayVar);
}
} }
// Extract variables from if/else-if conditions // Extract variables from if/else-if conditions
for (const ifStmt of matchAll(html, /\{\{\s*?[!]?\?\s*?([^}]+?)\s*?}}/g)) { for (const ifStmt of matchAll(html, /\{\{\s*?[!]?\?\s*?([^}]+?)\s*?}}/g)) {
const code = ifStmt[1].replace(/["'`][^"'`]*["'`]/g, ''); // Remove string literals const code = ifStmt[1].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) { for (const v of vars) {
const root = v.split('.')[0]; 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 // Extract from if block content & regular interpolations
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; const regex = /\{\{\s*([^<>\*\?!/}\s][^}]*?)\s*}}/g;
let match; let match;
while ((match = regex.exec(html)) !== null) { while ((match = regex.exec(html)) !== null) {
const code = match[1].trim().replace(/["'`][^"'`]*["'`]/g, ''); 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) { for (const v of vars) {
const root = v.split('.')[0]; const root = v.split('.')[0];
if (!excluded.has(root)) variables.add(v); if (!excluded.has(root)) variables.add(v);
@@ -64,7 +60,8 @@ export function findTemplateVars(html: string): Record<string, any> {
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const part = parts[i]; const part = parts[i];
if (i === parts.length - 1) { if (i === parts.length - 1) {
current[part] = ''; const fullPath = parts.slice(0, i + 1).join('.');
current[part] = arrays.has(fullPath) ? [] : '';
} else { } else {
current[part] = current[part] || {}; current[part] = current[part] || {};
current = current[part]; current = current[part];

View File

@@ -23,30 +23,30 @@ describe('findTemplateVars', () => {
test('extracts array reference from loops', () => { test('extracts array reference from loops', () => {
const result = findTemplateVars('{{ * item in items }}{{ item }}{{ /* }}'); const result = findTemplateVars('{{ * item in items }}{{ item }}{{ /* }}');
expect(result).toEqual({ items: '' }); expect(result).toEqual({ items: [] });
}); });
test('excludes loop element variable', () => { test('excludes loop element variable', () => {
const result = findTemplateVars('{{ * item in items }}{{ item.name }}{{ /* }}'); const result = findTemplateVars('{{ * item in items }}{{ item.name }}{{ /* }}');
expect(result).toEqual({ items: '' }); expect(result).toEqual({ items: [] });
expect(result).not.toHaveProperty('item'); expect(result).not.toHaveProperty('item');
}); });
test('excludes loop index variable', () => { test('excludes loop index variable', () => {
const result = findTemplateVars('{{ * (item, i) in items }}{{ i }}:{{ item }}{{ /* }}'); 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('item');
expect(result).not.toHaveProperty('i'); expect(result).not.toHaveProperty('i');
}); });
test('extracts external vars used inside loops', () => { test('extracts external vars used inside loops', () => {
const result = findTemplateVars('{{ * item in items }}{{ item }}-{{ prefix }}{{ /* }}'); const result = findTemplateVars('{{ * item in items }}{{ item }}-{{ prefix }}{{ /* }}');
expect(result).toEqual({ items: '', prefix: '' }); expect(result).toEqual({ items: [], prefix: '' });
}); });
test('handles nested loops', () => { test('handles nested loops', () => {
const result = findTemplateVars('{{ * row in rows }}{{ * col in row.cols }}{{ col }}{{ /* }}{{ /* }}'); 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('row');
expect(result).not.toHaveProperty('col'); expect(result).not.toHaveProperty('col');
}); });
@@ -63,7 +63,7 @@ Total: {{ total }}
{{ /? }}`; {{ /? }}`;
const result = findTemplateVars(tpl); const result = findTemplateVars(tpl);
expect(result).toEqual({ expect(result).toEqual({
items: '', items: [],
currency: '', currency: '',
total: '', total: '',
emptyMessage: '' emptyMessage: ''
@@ -81,6 +81,62 @@ Total: {{ total }}
const result = findTemplateVars('{{ firstName + " " + lastName }}'); const result = findTemplateVars('{{ firstName + " " + lastName }}');
expect(result).toEqual({ 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", "<br>") }}');
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', () => { describe('renderTemplate', () => {