template-sluz 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +237 -0
  2. package/package.json +30 -0
  3. package/src/sluz.js +716 -0
package/README.md ADDED
@@ -0,0 +1,237 @@
1
+ # ⚡ Sluz templating system
2
+
3
+ A minimalistic JavaScript templating engine with Smarty-like syntax. Zero dependencies, ESM-only.
4
+
5
+ ## 📦 Installation
6
+
7
+ ```bash
8
+ npm install template-sluz
9
+ ```
10
+
11
+ ## 🚀 Quick Start
12
+
13
+ ```js
14
+ import Sluz from 'template-sluz';
15
+
16
+ const sluz = new Sluz();
17
+ sluz.assign('name', 'Scott');
18
+ sluz.assign('user', { first: 'Jason', last: 'Doolis', age: 43 });
19
+
20
+ console.log(sluz.parse('Hello {$name}')); // Hello Scott
21
+ console.log(sluz.parse('{$user.first} {$user.last}')); // Jason Doolis
22
+ ```
23
+
24
+ ---
25
+
26
+ ## 🌐 Browser Usage
27
+
28
+ Load Sluz in the browser with `<script type="module">`.
29
+
30
+ ```html
31
+ <script type="module">
32
+ import Sluz from './path/to/sluz.js';
33
+ const sluz = new Sluz();
34
+
35
+ sluz.assign('user', { name: 'Alice', role: 'admin' });
36
+ document.getElementById('body').innerHTML = sluz.parse("Welcome {$user.name} you are {$user.role}");
37
+ </script>
38
+ ```
39
+
40
+ ---
41
+
42
+ ## 📝 Variables
43
+
44
+ Variables are inserted with `{$varname}`. Dotted paths resolve nested objects and arrays.
45
+
46
+ ```js
47
+ sluz.assign('person', { name: { first: 'Jane' }, colors: ['red', 'green'] });
48
+
49
+ sluz.parse('{$person.name.first}'); // Jane
50
+ sluz.parse('{$person.colors.0}'); // red
51
+ sluz.parse('{$missing}'); // '' (empty string)
52
+ ```
53
+
54
+ ### assign()
55
+
56
+ Accepts key/value pairs or a single object:
57
+
58
+ ```js
59
+ sluz.assign('color', 'blue'); // Scalar
60
+ sluz.assign('size', ['small', 'medium', 'large']); // Array
61
+ sluz.assign('info', { color: 'yellow', age: 43 }); // Hash
62
+ ```
63
+
64
+ ---
65
+
66
+ ## 📖 API Reference
67
+
68
+ ### `new Sluz()`
69
+
70
+ Creates a new template engine instance.
71
+
72
+ ### `assign(key, value)` / `assign(object)`
73
+
74
+ Sets template variables. Accepts:
75
+ - Key/value pairs: `sluz.assign('name', 'Scott')`
76
+ - A single object: `sluz.assign('info', { name: 'Scott', age: 43 })`
77
+
78
+ ### `parse(string)`
79
+
80
+ Parses a template string with the current variables and returns the rendered output.
81
+
82
+ ### `registerModifier(name, fn)`
83
+
84
+ Registers a custom modifier function. The function receives the variable value as the first argument, followed by any user-supplied arguments from the template.
85
+
86
+ ```js
87
+ sluz.registerModifier('truncate', (s, n) => String(s).slice(0, n));
88
+ // Template: {$name|truncate:3}
89
+ ```
90
+
91
+ ---
92
+
93
+ ## 🔧 Modifiers
94
+
95
+ Modifiers transform variable output using pipe (`|`) syntax. Arguments follow a colon (`:`), multiple arguments are comma-separated.
96
+
97
+ ### Built-in modifiers
98
+
99
+ | Modifier | Description | Example |
100
+ |------------|------------------------------------------|----------------------------------------------|
101
+ | `upper` | Uppercase string | `{$name\|upper}` |
102
+ | `lower` | Lowercase string | `{$name\|lower}` |
103
+ | `ucfirst` | Capitalize first character | `{$name\|ucfirst}` |
104
+ | `trim` | Trim whitespace | `{$name\|trim}` |
105
+ | `length` | String length | `{$name\|length}` |
106
+ | `substr` | Substring `(start[, length])` | `{$name\|substr:0,3}` |
107
+ | `replace` | Replace all occurrences | `{$name\|replace:"old","new"}` |
108
+ | `join` | Join array with separator | `{$items\|join:", "}` |
109
+ | `count` | Count array keys / object keys / truthy | `{$items\|count}` |
110
+ | `first` | First element of array / first character | `{$items\|first}` |
111
+ | `last` | Last element of array / last character | `{$items\|last}` |
112
+
113
+ ### Default values
114
+
115
+ The `default:` modifier returns a fallback when the variable is empty (undefined, null, or empty string):
116
+
117
+ ```js
118
+ sluz.parse('{$name|default:"N/A"}'); // Scott (unchanged)
119
+ sluz.parse('{$zero|default:"123"}'); // 0 (zero is not empty)
120
+ sluz.parse('{$missing|default:"N/A"}'); // N/A
121
+ ```
122
+
123
+ ### Chained modifiers
124
+
125
+ ```js
126
+ sluz.parse('{$name|upper|substr:0,2}'); // SC
127
+ ```
128
+
129
+ ### Custom modifiers
130
+
131
+ ```js
132
+ sluz.registerModifier('greet', name => `Howdy, ${name}!`);
133
+ sluz.parse('{$name|greet}'); // Howdy, Scott!
134
+ ```
135
+
136
+ ---
137
+
138
+ ## 🔢 Expressions & Math
139
+
140
+ Wrap any JavaScript expression in braces for evaluation:
141
+
142
+ ```js
143
+ sluz.parse('{$count + 10}'); // 17
144
+ sluz.parse('{($count * 3) - 5}'); // 16
145
+ sluz.parse('{$count > 5}'); // true
146
+ ```
147
+
148
+ ---
149
+
150
+ ## 🔀 Conditionals: `{if}` / `{elseif}` / `{else}` / `{/if}`
151
+
152
+ ```js
153
+ sluz.parse('{if $admin}Welcome admin{/if}');
154
+ sluz.parse('{if $count > 5}Big{else}Small{/if}');
155
+ sluz.parse('{if $age < 21}Minor{elseif $age < 65}Adult{else}Senior{/if}');
156
+ ```
157
+
158
+ Supports `&&`, `||`, `!`, parentheses, and comparison operators (`==`, `!=`, `<`, `>`, `<=`, `>=`).
159
+
160
+ ---
161
+
162
+ ## 🔄 Loops: `{foreach}` / `{/foreach}`
163
+
164
+ ```js
165
+ // Simple iteration
166
+ {foreach $items as $x}{$x} {/foreach}
167
+
168
+ // Key/value iteration (index => value for arrays, key => value for objects)
169
+ {foreach $items as $idx => $x}[{$idx}]: {$x} {/foreach}
170
+
171
+ // Works with objects
172
+ {foreach $user as $key => $val}{$key}: {$val} {/foreach}
173
+ ```
174
+
175
+ ### Foreach magic variables
176
+
177
+ Available inside loops:
178
+
179
+ | Variable | Description |
180
+ |----------------------|----------------------|
181
+ | `$__FOREACH_FIRST` | 1 on first iteration |
182
+ | `$__FOREACH_LAST` | 1 on last iteration |
183
+ | `$__FOREACH_INDEX` | 0-based index |
184
+
185
+ ```js
186
+ {foreach $items as $x}
187
+ {if $__FOREACH_FIRST}>>> {/if}
188
+ {$x}
189
+ {if $__FOREACH_LAST} <<<{/if}
190
+ {/foreach}
191
+ ```
192
+
193
+ ---
194
+
195
+ ## 📄 Literal Blocks
196
+
197
+ `{literal}...{/literal}` bypasses template parsing, outputting content verbatim:
198
+
199
+ ```js
200
+ sluz.parse('{literal}function foo() { .. }{/literal}');
201
+ ```
202
+
203
+ ---
204
+
205
+ ## 💬 Comments
206
+
207
+ `{* ... *}` comments are stripped from output.
208
+
209
+ ```js
210
+ sluz.parse('Kitten{* favorite animal *}'); // Kitten
211
+ ```
212
+
213
+ ---
214
+
215
+ ## ⚠️ Error Handling
216
+
217
+ Syntax errors throw `SluzError` with a descriptive message and error code:
218
+
219
+ ```js
220
+ import Sluz, { SluzError } from 'template-sluz';
221
+
222
+ try {
223
+ sluz.parse('{foo');
224
+ } catch (e) {
225
+ console.log(e.code); // 45821
226
+ console.log(e.message); // Template::Sluz error #45821: Unclosed tag ...
227
+ }
228
+ ```
229
+
230
+ | Error Code | Description |
231
+ |-----------|--------------------------------|
232
+ | `45821` | Unclosed tag |
233
+ | `48724` | Missing comment close `*}` |
234
+ | `73467` | Unknown block type |
235
+ | `18933` | Unknown tag / invalid eval |
236
+ | `47204` | Unknown modifier function |
237
+ | `95320` | If/else parsing error |
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "template-sluz",
3
+ "version": "0.9.2",
4
+ "description": "A minimalistic JavaScript templating engine with Smarty-like syntax",
5
+ "type": "module",
6
+ "main": "./src/sluz.js",
7
+ "exports": {
8
+ ".": "./src/sluz.js"
9
+ },
10
+ "scripts": {
11
+ "test": "vitest run",
12
+ "test:watch": "vitest"
13
+ },
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "keywords": [
18
+ "template",
19
+ "smarty",
20
+ "templating",
21
+ "sluz"
22
+ ],
23
+ "license": "GPL-3.0-or-later",
24
+ "devDependencies": {
25
+ "vitest": "^3.2.6"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ }
30
+ }
package/src/sluz.js ADDED
@@ -0,0 +1,716 @@
1
+ class SluzError extends Error {
2
+ constructor(msg, code) {
3
+ super(`Template::Sluz error #${code}: ${msg}`);
4
+ this.code = code;
5
+ }
6
+ }
7
+
8
+ const ESCAPE_RE = {
9
+ '\\': '\\\\', '.': '\\.', '*': '\\*', '+': '\\+', '?': '\\?',
10
+ '(': '\\(', ')': '\\)', '[': '\\[', ']': '\\]', '{': '\\{', '}': '\\}',
11
+ '|': '\\|', '^': '\\^', '$': '\\$',
12
+ };
13
+
14
+ function escapeRegex(s) {
15
+ return s.replace(/[\\.*+?()\[\]{}|^$]/g, ch => ESCAPE_RE[ch]);
16
+ }
17
+
18
+ export default class Sluz {
19
+ constructor() {
20
+ this.tplVars = {};
21
+ this.varPrefix = 'sluz_pfx';
22
+ this.modifiers = new Map();
23
+ this.charPos = -1;
24
+ this._sourceStr = '';
25
+
26
+ this._registerBuiltins();
27
+ }
28
+
29
+ _registerBuiltins() {
30
+ this.modifiers.set('count', v => {
31
+ if (Array.isArray(v)) return v.length;
32
+ if (v && typeof v === 'object') return Object.keys(v).length;
33
+ return v != null ? 1 : 0;
34
+ });
35
+ this.modifiers.set('ucfirst', s => {
36
+ s = String(s);
37
+ return s.charAt(0).toUpperCase() + s.slice(1);
38
+ });
39
+ this.modifiers.set('upper', s => String(s).toUpperCase());
40
+ this.modifiers.set('lower', s => String(s).toLowerCase());
41
+ this.modifiers.set('substr', (s, start, len) => {
42
+ s = String(s);
43
+ start = Number(start);
44
+ return len !== undefined ? s.slice(start, start + Number(len)) : s.slice(start);
45
+ });
46
+ this.modifiers.set('trim', s => String(s).trim());
47
+ this.modifiers.set('replace', (s, search, replacement) => String(s).replaceAll(search, replacement));
48
+ this.modifiers.set('length', s => String(s).length);
49
+ this.modifiers.set('join', (arr, sep = ',') => Array.prototype.join.call(arr, sep));
50
+ this.modifiers.set('first', arr => Array.isArray(arr) ? arr[0] : String(arr)[0]);
51
+ this.modifiers.set('last', arr => Array.isArray(arr) ? arr[arr.length - 1] : String(arr).slice(-1));
52
+ }
53
+
54
+ /**
55
+ * @param {string|Object} first
56
+ * @param {*} [second]
57
+ */
58
+ assign(first, second) {
59
+ // Batch-assign: pass a single object to assign all its keys at once
60
+ if (arguments.length === 1 && typeof first === 'object' && !Array.isArray(first) && first !== null) {
61
+ for (const [k, v] of Object.entries(first)) {
62
+ this.tplVars[k] = v;
63
+ }
64
+ // Key-value pair: assign('name', 'value')
65
+ } else if (arguments.length % 2 === 0) {
66
+ for (let i = 0; i < arguments.length; i += 2) {
67
+ this.tplVars[arguments[i]] = arguments[i + 1];
68
+ }
69
+ }
70
+ }
71
+
72
+ registerModifier(name, fn) {
73
+ this.modifiers.set(name, fn);
74
+ }
75
+
76
+ // This is where all the real work is done.
77
+ // We break the input string into blocks based on { }
78
+ // Then we loop through those blocks doing variable replacement as needed
79
+ parse(str) {
80
+ this._sourceStr = str;
81
+ const blocks = this._getBlocks(str);
82
+ return this._processBlocks(blocks);
83
+ }
84
+
85
+ // -------------------------------------------------------------------
86
+ // Tokenizer
87
+ // -------------------------------------------------------------------
88
+
89
+ /**
90
+ * @param {string} str
91
+ * @returns {Array<[string, number]>}
92
+ */
93
+ // Split a template string into an array of [text, endIndex] blocks,
94
+ // separating literal HTML from {tags} and handling nested if/foreach/literal.
95
+ _getBlocks(str) {
96
+ const slen = str.length;
97
+ let start = 0;
98
+ let i;
99
+ const blocks = [];
100
+
101
+ // Fast-forward to the first '{' so we don't scan plain text char-by-char
102
+ let z = str.indexOf('{');
103
+ if (z < 0) z = slen;
104
+
105
+ for (i = z; i < slen; i++) {
106
+ const char = str[i];
107
+ let isOpen = char === '{';
108
+ const isClosed = char === '}';
109
+
110
+ // Skip plain-text runs in one jump instead of character-by-character
111
+ if (!isOpen && !isClosed) {
112
+ const nextOpen = str.indexOf('{', i);
113
+ const nextClose = str.indexOf('}', i);
114
+ const nOpen = nextOpen < 0 ? slen : nextOpen;
115
+ const nClose = nextClose < 0 ? slen : nextClose;
116
+ i = (nOpen < nClose ? nOpen : nClose) - 1;
117
+ continue;
118
+ }
119
+
120
+ const hasLen = start !== i;
121
+ let isComment = false;
122
+
123
+ // Disambiguate '{' used in template tags from literal '{' surrounded by whitespace
124
+ if (isOpen) {
125
+ const prevC = i > 0 ? str[i - 1] : ' ';
126
+ const nextC = i + 1 < slen ? str[i + 1] : ' ';
127
+ const chk = prevC + char + nextC;
128
+ if (/^\s[\{\}]\s$/.test(chk)) isOpen = false;
129
+ if (nextC === '*') isComment = true;
130
+ }
131
+
132
+ // Push the text before this opening tag as a literal block
133
+ if (isOpen && hasLen) {
134
+ blocks.push([str.slice(start, i), i]);
135
+ start = i;
136
+ } else if (isClosed) {
137
+ // Find the full tag (or block) from start to the matching '}'
138
+ const len = i - start + 1;
139
+ let block = str.slice(start, start + len);
140
+ const openTagMatch = block.match(/^\{(if|foreach|literal)\b/);
141
+ // For block tags (if/foreach/literal), scan for the matching close tag
142
+ if (openTagMatch) {
143
+ const ot = openTagMatch[1];
144
+ const closeTag = `{/${ot}}`;
145
+ for (let j = i + 1; j < slen; j++) {
146
+ if (str[j] === '}') {
147
+ const tmp = str.slice(start, j + 1);
148
+ const oc = (tmp.match(new RegExp(`\\{${escapeRegex(ot)}\\b`, 'g')) || []).length;
149
+ const cc = (tmp.match(new RegExp(`\\{\\/${escapeRegex(ot)}\\}`, 'g')) || []).length;
150
+ if (oc === cc) {
151
+ block = tmp;
152
+ break;
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ if (block.length) blocks.push([block, i]);
159
+ start += block.length;
160
+ i = start;
161
+ }
162
+
163
+ // Handle {* comment *} — swallow everything up to the closing *}
164
+ if (isComment) {
165
+ const end = this._findEndingTag(str.slice(start), '{*', '*}');
166
+ if (end < 0) {
167
+ const [line, col] = this._getCharLocation(i);
168
+ throw new SluzError(`Missing closing <code>*}</code> for comment on line #${line}`, 48724);
169
+ }
170
+ start += end + 2;
171
+ i = start;
172
+ }
173
+ }
174
+
175
+ // Push any remaining text after the last tag as a literal block
176
+ if (start < slen) {
177
+ blocks.push([str.slice(start), i]);
178
+ }
179
+
180
+ // Strip the leading newline from blocks that follow {if}/{for} so the
181
+ // rendered HTML doesn't have an extra blank line after control tags
182
+ let prevIsIf = false;
183
+ for (let bi = 0; bi < blocks.length; bi++) {
184
+ const bstr = blocks[bi][0];
185
+ const curIsIf = /^\{if\b/.test(bstr) || /^\{for/.test(bstr);
186
+ if (prevIsIf) {
187
+ let shouldStrip = 1;
188
+ const foreachMatch = blocks[bi - 1][0].match(/^\{foreach .+?\}([\s\S]*)\{\/foreach\}$/);
189
+ if (foreachMatch) {
190
+ shouldStrip = foreachMatch[1].endsWith('\n') ? 1 : 0;
191
+ }
192
+ if (shouldStrip) {
193
+ blocks[bi][0] = this._ltrimOne(bstr, '\n');
194
+ }
195
+ }
196
+ prevIsIf = curIsIf;
197
+ }
198
+
199
+ return blocks;
200
+ }
201
+
202
+ // Walk the parsed blocks and reassemble the final HTML.
203
+ // Blocks starting with '{' are template tags processed by _processBlock;
204
+ // everything else is literal text appended as-is.
205
+ _processBlocks(blocks) {
206
+ let html = '';
207
+ for (const x of blocks) {
208
+ const block = x[0];
209
+ if (!block.length) continue;
210
+ if (block[0] === '{') {
211
+ html += this._processBlock(block, x[1]);
212
+ } else {
213
+ html += block;
214
+ }
215
+ }
216
+ return html;
217
+ }
218
+
219
+ // Dispatch a single {tag} string to the appropriate handler.
220
+ // Tries each known tag type in order: variable, if, foreach, literal,
221
+ // expression, then falls back to an error for unclosed tags.
222
+ _processBlock(str, charPos) {
223
+ this.charPos = charPos;
224
+
225
+ // {$var} or {$var|modifier:param} or {$var|modifier:$param}
226
+ let varMatch;
227
+ if (str.includes('|')) {
228
+ // Variable with modifiers - allow $ in content for variable parameters
229
+ varMatch = str.match(/^\{\$([\w|.'";\t :,!@#%^&*?_\-/$]+)\}$/);
230
+ } else {
231
+ // Simple variable - don't allow $ to avoid matching expressions like {$a * $b}
232
+ varMatch = str.match(/^\{\$([\w|.'";\t :,!@#%^&*?_\-/]+)\}$/);
233
+ }
234
+ if (str.startsWith('{$') && varMatch) {
235
+ return this._variableBlock(varMatch[1]);
236
+ }
237
+
238
+ // {if condition}...{/if}
239
+ if (str.startsWith('{if ') && str.endsWith('{/if}')) {
240
+ return this._ifBlock(str);
241
+ }
242
+
243
+ // {foreach $array as $key => $value}...{/foreach}
244
+ const foreachMatch = str.match(/^\{foreach (\$\w[\w.]*) as \$(\w+)(?: => \$(\w+))?\}([\s\S]*)\{\/foreach\}$/);
245
+ if (str.startsWith('{foreach ') && foreachMatch) {
246
+ return this._foreachBlock(foreachMatch[1], foreachMatch[2], foreachMatch[3], foreachMatch[4]);
247
+ }
248
+
249
+ // {literal}raw content{/literal} — returned verbatim
250
+ if (str.startsWith('{literal}')) {
251
+ const m = str.match(/^\{literal\}([\s\S]*)\{\/literal\}$/);
252
+ if (m) return m[1];
253
+ }
254
+
255
+ // { foo } — whitespace-padded content without expression markers, return verbatim
256
+ if (/^\{\s+.*\s+\}$/.test(str) && !/["\d\$\(]/.test(str)) {
257
+ return str;
258
+ }
259
+
260
+ // Fallback: treat anything inside { } as an expression
261
+ const exprMatch = str.match(/^\{(.+)}$/s);
262
+ if (exprMatch) {
263
+ return this._expressionBlock(str, exprMatch[1]);
264
+ }
265
+
266
+ // No closing brace — this is a parse error
267
+ if (!str.endsWith('}')) {
268
+ const [line, col] = this._getCharLocation(this.charPos);
269
+ throw new SluzError(`Unclosed tag <code>${str}</code> on line #${line}`, 45821);
270
+ }
271
+
272
+ return str;
273
+ }
274
+
275
+ // -------------------------------------------------------------------
276
+ // Block handlers
277
+ // -------------------------------------------------------------------
278
+
279
+ _variableBlock(str) {
280
+ const pipeParts = this._splitRespectingQuotes(str, '|');
281
+ const key = pipeParts[0];
282
+ const modStr = pipeParts.slice(1).join('|');
283
+
284
+ if (modStr) {
285
+ const tmp = this._arrayDive(key, this.tplVars);
286
+ const isDefault = modStr.includes('default:');
287
+ const isNothing = this._isNothing(tmp);
288
+
289
+ if (isNothing && isDefault) {
290
+ const dval = modStr.replace(/^.*?default:/, '');
291
+ const [ret] = this._peval(dval);
292
+ if (ret !== undefined) return ret;
293
+ return '';
294
+ } else if (!isNothing && isDefault) {
295
+ return String(this._arrayDive(key, this.tplVars) ?? '');
296
+ } else {
297
+ let pre = this._arrayDive(key, this.tplVars) ?? '';
298
+ const parts = this._splitRespectingQuotes(modStr, '|');
299
+ for (const p of parts) {
300
+ const colonIdx = this._findFirstColonOutsideQuotes(p);
301
+ const func = colonIdx >= 0 ? p.slice(0, colonIdx) : p;
302
+ const paramStr = colonIdx >= 0 ? p.slice(colonIdx + 1) : '';
303
+ const params = [pre];
304
+
305
+ if (paramStr.length) {
306
+ const commaLimbs = this._splitRespectingQuotes(paramStr, ',');
307
+ for (const limb of commaLimbs) {
308
+ const [v] = this._peval(limb);
309
+ params.push(v);
310
+ }
311
+ }
312
+
313
+ const fn = this.modifiers.get(func);
314
+ if (!fn) {
315
+ const [line, col] = this._getCharLocation(this.charPos);
316
+ throw new SluzError(`Unknown function call <code>${func}</code> on line #${line}`, 47204);
317
+ }
318
+ pre = fn(...params);
319
+ }
320
+ return pre;
321
+ }
322
+ }
323
+
324
+ const ret = this._arrayDive(str, this.tplVars);
325
+ if (Array.isArray(ret)) return 'ARRAY';
326
+ if (ret && typeof ret === 'object') return 'HASH';
327
+ if (ret != null) return ret;
328
+ return '';
329
+ }
330
+
331
+ // Evaluate an {if}/{elseif}/{else} chain.
332
+ // Simple blocks (no {else}) use a fast regex path; complex ones go through
333
+ // tokenization. The first matching condition renders its payload.
334
+ _ifBlock(str) {
335
+ // True when the block has no {else} or {elseif}, so we can use a simple regex
336
+ const isSimple = !str.includes('{else', 7);
337
+ let rules = [];
338
+
339
+ if (isSimple) {
340
+ const m = str.match(/\{if (.+?)\}([\s\S]*)\{\/if\}/s);
341
+ if (m) {
342
+ rules = [[m[1], this._ltrimOne(m[2], '\n')]];
343
+ }
344
+ } else {
345
+ const toks = this._getTokens(str);
346
+ rules = this._ifRulesFromTokens(toks);
347
+ }
348
+
349
+ let ret = '';
350
+ for (const [cond, payload] of rules) {
351
+ const test = this._convertVars(cond);
352
+ const [res] = this._peval(test);
353
+ if (res) {
354
+ const inBlocks = this._getBlocks(payload);
355
+ ret += this._processBlocks(inBlocks);
356
+ break;
357
+ }
358
+ }
359
+ return ret;
360
+ }
361
+
362
+ _foreachBlock(srcExpr, keyVar, valVar, payload) {
363
+ const convSrc = this._convertVars(srcExpr);
364
+ payload = this._ltrimOne(payload, '\n');
365
+ const blocks = this._getBlocks(payload);
366
+
367
+ const [src] = this._peval(convSrc);
368
+
369
+ let iterable;
370
+ if (src == null) {
371
+ iterable = [];
372
+ } else if (Array.isArray(src) || (typeof src === 'object' && src !== null)) {
373
+ iterable = src;
374
+ } else {
375
+ iterable = [src];
376
+ }
377
+
378
+ const save = { ...this.tplVars };
379
+ let ret = '';
380
+ let idx = 0;
381
+
382
+ if (Array.isArray(iterable)) {
383
+ const last = iterable.length - 1;
384
+ for (let i = 0; i <= last; i++) {
385
+ this.tplVars.__FOREACH_FIRST = idx === 0 ? 1 : 0;
386
+ this.tplVars.__FOREACH_LAST = idx === last ? 1 : 0;
387
+ this.tplVars.__FOREACH_INDEX = idx;
388
+ if (valVar !== undefined) {
389
+ this.tplVars[keyVar] = i;
390
+ this.tplVars[valVar] = iterable[i];
391
+ } else {
392
+ this.tplVars[keyVar] = iterable[i];
393
+ }
394
+ ret += this._processBlocks(blocks);
395
+ idx++;
396
+ }
397
+ } else if (typeof iterable === 'object' && iterable !== null) {
398
+ const keys = Object.keys(iterable);
399
+ const last = keys.length - 1;
400
+ for (let i = 0; i <= last; i++) {
401
+ const k = keys[i];
402
+ this.tplVars.__FOREACH_FIRST = idx === 0 ? 1 : 0;
403
+ this.tplVars.__FOREACH_LAST = idx === last ? 1 : 0;
404
+ this.tplVars.__FOREACH_INDEX = idx;
405
+ if (valVar !== undefined) {
406
+ this.tplVars[keyVar] = k;
407
+ this.tplVars[valVar] = iterable[k];
408
+ } else {
409
+ this.tplVars[keyVar] = iterable[k];
410
+ }
411
+ ret += this._processBlocks(blocks);
412
+ idx++;
413
+ }
414
+ }
415
+
416
+ this.tplVars = save;
417
+ return ret;
418
+ }
419
+
420
+ _expressionBlock(str, inner) {
421
+ if (!/["\d\$\(]/.test(str)) {
422
+ const [line, col] = this._getCharLocation(this.charPos);
423
+ throw new SluzError(`Unknown block type <code>${str}</code> on line #${line}`, 73467);
424
+ }
425
+
426
+ const after = this._convertVars(inner);
427
+ const [ret, err] = this._peval(after);
428
+
429
+ const valid = ret !== undefined && ret !== null && typeof ret !== 'object';
430
+
431
+ if (err || !valid) {
432
+ const [line, col] = this._getCharLocation(this.charPos);
433
+ throw new SluzError(`Unknown tag <code>${str}</code> on line #${line}`, 18933);
434
+ }
435
+
436
+ return ret;
437
+ }
438
+
439
+ // -------------------------------------------------------------------
440
+ // Variable / eval engine
441
+ // -------------------------------------------------------------------
442
+
443
+ _convertVars(str) {
444
+ str = String(str);
445
+ if (!str.includes('$')) return str;
446
+ return str.replace(/\$\w[\w.]*/g, match => {
447
+ const parts = match.slice(1).split('.');
448
+ const first = parts.shift();
449
+ let res = `__S.sluz_pfx_${first}`;
450
+ for (const p of parts) {
451
+ res += /^\d+$/.test(p) ? `[${p}]` : `.${p}`;
452
+ }
453
+ return res;
454
+ });
455
+ }
456
+
457
+ _microOptimize(str) {
458
+ if (/^-?\d+(?:\.\d+)?$/.test(str)) return str;
459
+ if (!str.length) return undefined;
460
+
461
+ const first = str[0];
462
+ const last = str[str.length - 1];
463
+
464
+ if (first === "'" && last === "'") {
465
+ const tmp = str.slice(1, -1);
466
+ if (!tmp.includes("'")) return tmp;
467
+ }
468
+
469
+ if (first === '"' && last === '"') {
470
+ const tmp = str.slice(1, -1);
471
+ if (!tmp.includes('$') && !tmp.includes('"')) return tmp;
472
+ }
473
+
474
+ if (/^\w+$/.test(str) && Object.prototype.hasOwnProperty.call(this.tplVars, str)) {
475
+ return this.tplVars[str];
476
+ }
477
+
478
+ const bareNot = str.match(/^!(\w+)$/);
479
+ if (bareNot && Object.prototype.hasOwnProperty.call(this.tplVars, bareNot[1])) {
480
+ return !this.tplVars[bareNot[1]];
481
+ }
482
+
483
+ return undefined;
484
+ }
485
+
486
+ // Safely evaluate a template expression string at runtime.
487
+ // Returns [value, 0] on success or [undefined, -1] on error.
488
+ _peval(str) {
489
+ // Smarty uses === for equality but JS triple-equals would reject
490
+ // different types, so soften it to == for template compatibility
491
+ str = str.replace(/===/g, '==');
492
+ // Quick path: if the expression is a plain literal or simple reference,
493
+ // resolve it without invoking the Function constructor
494
+ const opt = this._microOptimize(str);
495
+ if (opt !== undefined) return [opt, 0];
496
+
497
+ // Convert template variable references ($foo) to __S_prefix_foo lookups
498
+ const code = this._convertVars(str);
499
+ // Build a Function that receives the variable scope object and any
500
+ // registered custom modifier functions as parameters
501
+ const fnNames = [...this.modifiers.keys()];
502
+ const fn = new Function('__S', ...fnNames, `"use strict"; return (${code})`);
503
+
504
+ // Build the scope object with prefixed keys so $foo maps to __S_foo
505
+ const __S = {};
506
+ for (const [k, v] of Object.entries(this.tplVars)) {
507
+ __S[`${this.varPrefix}_${k}`] = v;
508
+ }
509
+
510
+ const fns = fnNames.map(n => this.modifiers.get(n));
511
+ try {
512
+ return [fn(__S, ...fns), 0];
513
+ } catch {
514
+ return [undefined, -1];
515
+ }
516
+ }
517
+
518
+ // -------------------------------------------------------------------
519
+ // Helpers
520
+ // -------------------------------------------------------------------
521
+
522
+ _arrayDive(needle, haystack) {
523
+ if (needle == null || haystack == null) return undefined;
524
+
525
+ if (Object.prototype.hasOwnProperty.call(haystack, needle)) {
526
+ return haystack[needle];
527
+ }
528
+
529
+ const parts = needle.split('.');
530
+ let arr = haystack;
531
+
532
+ for (const elem of parts) {
533
+ if (arr == null) return undefined;
534
+ if (Array.isArray(arr)) {
535
+ if (!/^\d+$/.test(elem) || elem >= arr.length) return undefined;
536
+ arr = arr[elem];
537
+ } else if (typeof arr === 'object') {
538
+ if (!Object.prototype.hasOwnProperty.call(arr, elem)) return undefined;
539
+ arr = arr[elem];
540
+ } else {
541
+ return undefined;
542
+ }
543
+ }
544
+ return arr;
545
+ }
546
+
547
+ _ltrimOne(str, char) {
548
+ if (str && str[0] === char) return str.slice(1);
549
+ return str;
550
+ }
551
+
552
+ _findEndingTag(haystack, openTag, closeTag) {
553
+ let pos = haystack.indexOf(closeTag);
554
+ if (pos < 0) return -1;
555
+
556
+ let substr = haystack.slice(0, pos);
557
+ const openRe = new RegExp(escapeRegex(openTag), 'g');
558
+ let openCount = (substr.match(openRe) || []).length;
559
+ if (openCount === 1) return pos;
560
+
561
+ const closeLen = closeTag.length;
562
+ let offset = pos + closeLen;
563
+
564
+ for (let n = 0; n < 5; n++) {
565
+ pos = haystack.indexOf(closeTag, offset);
566
+ if (pos < 0) return -1;
567
+ substr = haystack.slice(0, pos + 2);
568
+ openCount = (substr.match(openRe) || []).length;
569
+ const closeCount = (substr.match(new RegExp(escapeRegex(closeTag), 'g')) || []).length;
570
+ if (openCount === closeCount) return pos;
571
+ offset = pos + closeLen;
572
+ }
573
+
574
+ return -1;
575
+ }
576
+
577
+ _getTokens(str) {
578
+ return str.split(/({[^}]+})/).filter(t => t.length);
579
+ }
580
+
581
+ _isIfToken(str) {
582
+ if (str === '{else}') return 1;
583
+ if (str === '{/if}') return 1;
584
+ const m = str.match(/^\{(?:if|elseif)\s+(.+?)\}$/);
585
+ if (m) return m[1];
586
+ return '';
587
+ }
588
+
589
+ _ifRulesFromTokens(toks) {
590
+ const num = toks.length;
591
+ let nested = 0;
592
+ const tmp = new Array(num);
593
+
594
+ for (let i = 0; i < num; i++) {
595
+ const item = toks[i];
596
+ if (/^\{if\b/.test(item)) nested++;
597
+ if (item === '{/if}') nested--;
598
+
599
+ let yes = 0;
600
+ if (nested === 1) {
601
+ yes = this._isIfToken(item) || 0;
602
+ if (item === '{/if}') yes = 0;
603
+ }
604
+ tmp[i] = yes;
605
+ }
606
+
607
+ tmp[num - 1] = 1;
608
+
609
+ const conds = [];
610
+ for (let i = 0; i < num; i++) {
611
+ if (tmp[i]) {
612
+ const test = this._isIfToken(toks[i]);
613
+ if (i !== num - 1) conds.push(test);
614
+ }
615
+ }
616
+
617
+ let str = '';
618
+ const payloads = [];
619
+ let first = true;
620
+ for (let i = 0; i < num; i++) {
621
+ if (tmp[i]) {
622
+ if (!first) payloads.push(str);
623
+ first = false;
624
+ str = '';
625
+ } else {
626
+ str += toks[i];
627
+ }
628
+ }
629
+
630
+ if (conds.length !== payloads.length) {
631
+ throw new SluzError(`Error parsing {if} conditions`, 95320);
632
+ }
633
+
634
+ const ret = [];
635
+ for (let i = 0; i < conds.length; i++) {
636
+ ret.push([conds[i], payloads[i]]);
637
+ }
638
+ return ret;
639
+ }
640
+
641
+ // -------------------------------------------------------------------
642
+ // Quote-aware splitting
643
+ // -------------------------------------------------------------------
644
+
645
+ _splitRespectingQuotes(str, delimiter) {
646
+ const parts = [];
647
+ let current = '';
648
+ let inQuote = null;
649
+
650
+ for (let i = 0; i < str.length; i++) {
651
+ const ch = str[i];
652
+
653
+ if (inQuote) {
654
+ current += ch;
655
+ if (ch === inQuote) inQuote = null;
656
+ } else if (ch === "'" || ch === '"') {
657
+ current += ch;
658
+ inQuote = ch;
659
+ } else if (ch === delimiter) {
660
+ parts.push(current);
661
+ current = '';
662
+ } else {
663
+ current += ch;
664
+ }
665
+ }
666
+
667
+ if (current.length) parts.push(current);
668
+ return parts;
669
+ }
670
+
671
+ _findFirstColonOutsideQuotes(str) {
672
+ let inQuote = null;
673
+ for (let i = 0; i < str.length; i++) {
674
+ const ch = str[i];
675
+ if ((ch === "'" || ch === '"') && inQuote === null) {
676
+ inQuote = ch;
677
+ } else if (ch === inQuote) {
678
+ inQuote = null;
679
+ } else if (ch === ':' && inQuote === null) {
680
+ return i;
681
+ }
682
+ }
683
+ return -1;
684
+ }
685
+
686
+ _isNothing(v) {
687
+ if (v === undefined || v === null) return true;
688
+ if (typeof v === 'object') return false;
689
+ return String(v).length === 0 && v !== '0';
690
+ }
691
+
692
+ // -------------------------------------------------------------------
693
+ // Error handling
694
+ // -------------------------------------------------------------------
695
+
696
+ _getCharLocation(pos) {
697
+ const str = this._sourceStr;
698
+ if (pos < 0 || !str) return [-1, -1];
699
+
700
+ let line = 1;
701
+ let col = 0;
702
+ for (let i = 0; i < str.length; i++) {
703
+ col++;
704
+ if (str[i] === '\n') {
705
+ line++;
706
+ col = 0;
707
+ }
708
+ if (pos === i) return [line, col];
709
+ }
710
+
711
+ if (pos === str.length) return [line, col];
712
+ return [-1, -1];
713
+ }
714
+ }
715
+
716
+ export { SluzError };