Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49959f3060 | |||
| cabfc93773 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ztimson/utils",
|
"name": "@ztimson/utils",
|
||||||
"version": "0.28.0",
|
"version": "0.28.2",
|
||||||
"description": "Utility library",
|
"description": "Utility library",
|
||||||
"author": "Zak Timson",
|
"author": "Zak Timson",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -7,13 +7,50 @@ 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']);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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, '');
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract from if block content & 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 cleaned = code.replace(/([a-zA-Z_$][a-zA-Z0-9_$.]*)\s*\(/g, (_, v) => {
|
||||||
if (varMatch) variables.add(varMatch[1]);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: Record<string, any> = {};
|
const result: Record<string, any> = {};
|
||||||
@@ -23,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];
|
||||||
|
|||||||
@@ -1,4 +1,143 @@
|
|||||||
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: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
||||||
test('basic variable interpolation', async () => {
|
test('basic variable interpolation', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user