Better template variable extraction
All checks were successful
Build / Publish Docs (push) Successful in 47s
Build / Build NPM Project (push) Successful in 1m9s
Build / Tag Version (push) Successful in 9s

This commit is contained in:
2025-12-08 10:42:02 -05:00
parent 38207eb618
commit cabfc93773
3 changed files with 130 additions and 6 deletions

View File

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

View File

@@ -7,13 +7,54 @@ 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 excluded = new Set<string>(['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; 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(); const code = match[1].trim().replace(/["'`][^"'`]*["'`]/g, '');
const varMatch = code.match(/^([a-zA-Z_$][a-zA-Z0-9_$.]*)/); const vars = code.match(/[a-zA-Z_$][a-zA-Z0-9_$.]+/g) || [];
if (varMatch) variables.add(varMatch[1]); for (const v of vars) {
const root = v.split('.')[0];
if (!excluded.has(root)) variables.add(v);
}
} }
const result: Record<string, any> = {}; const result: Record<string, any> = {};

View File

@@ -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', () => { describe('renderTemplate', () => {
test('basic variable interpolation', async () => { test('basic variable interpolation', async () => {