miki-template 1.3.3 → 1.3.6

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 (101) hide show
  1. package/.github/release-notes/v1.3.1.md +55 -55
  2. package/.github/release-notes/v1.3.3.md +77 -0
  3. package/.github/workflows/ci.yml +38 -54
  4. package/.github/workflows/release.yml +106 -0
  5. package/AGENT.md +71 -71
  6. package/API_REFERENCE.md +314 -314
  7. package/CHANGELOG.md +173 -169
  8. package/CODE_OF_CONDUCT.md +14 -14
  9. package/CONTRIBUTING.md +27 -27
  10. package/README.md +342 -321
  11. package/ROADMAP.md +40 -40
  12. package/benchmarks/report.json +16 -16
  13. package/benchmarks/run.js +49 -49
  14. package/benchmarks/stress.mjs +647 -647
  15. package/benchmarks/templates/large.dtpl +7 -7
  16. package/benchmarks/templates/medium.dtpl +3 -3
  17. package/benchmarks/templates/small.dtpl +7 -7
  18. package/context/component.md +109 -109
  19. package/context/prd.md +131 -131
  20. package/context/project-structure.md +33 -33
  21. package/dir/base.html +22 -22
  22. package/dir/cmpnt.html +10 -10
  23. package/dir/footer.html +2 -2
  24. package/dir/home.html +80 -80
  25. package/dir/index.html +80 -0
  26. package/dir/navbar.html +8 -8
  27. package/docs/README.md +18 -18
  28. package/docs/advanced_usage.md +71 -71
  29. package/docs/api.md +119 -119
  30. package/docs/filters.md +708 -708
  31. package/docs/installation.md +106 -106
  32. package/docs/overview.md +57 -57
  33. package/docs/partialdef.md +70 -70
  34. package/docs/security.md +27 -27
  35. package/docs/tags.md +673 -673
  36. package/docs/usage.md +646 -646
  37. package/eslint.config.mjs +42 -42
  38. package/ex.mjs +32 -32
  39. package/miki-template-extension/.github/workflows/ci.yml +116 -116
  40. package/miki-template-extension/.vscodeignore +7 -7
  41. package/miki-template-extension/CHANGELOG.md +99 -99
  42. package/miki-template-extension/LICENSE +21 -21
  43. package/miki-template-extension/README.md +273 -273
  44. package/miki-template-extension/extension.js +1013 -1013
  45. package/miki-template-extension/icon.svg +10 -10
  46. package/miki-template-extension/package.json +280 -280
  47. package/miki-template-extension/snippets/miki-template.json +717 -717
  48. package/miki-template-extension/syntaxes/language-configuration.json +114 -114
  49. package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +355 -355
  50. package/miki-template-extension/tests/grammar-tests.json +162 -162
  51. package/miki-template-extension/tests/run-grammar-tests.js +82 -82
  52. package/package.json +37 -34
  53. package/sample-app/package-lock.json +901 -0
  54. package/sample-app/package.json +9 -0
  55. package/sample-app/server.js +14 -0
  56. package/sample-app/views/index.html +1 -0
  57. package/scripts/build-vsix.js +129 -129
  58. package/scripts/build-vsix.ps1 +15 -15
  59. package/snippets/miki-template.json +177 -177
  60. package/src/asyncRender.js +20 -20
  61. package/src/cache.js +80 -80
  62. package/src/context.js +126 -126
  63. package/src/context_processors.js +48 -48
  64. package/src/esm.mjs +84 -84
  65. package/src/filters.js +975 -975
  66. package/src/i18n.js +171 -171
  67. package/src/index.js +974 -940
  68. package/src/lexer.js +114 -114
  69. package/src/libraries.js +371 -371
  70. package/src/parser.js +270 -270
  71. package/src/security.js +53 -53
  72. package/src/tags/control.js +719 -719
  73. package/src/tags/extra.js +154 -154
  74. package/src/tags/helpers.js +26 -26
  75. package/src/tags/i18n.js +256 -256
  76. package/src/tags/inheritance.js +335 -335
  77. package/src/tags/registry.js +18 -18
  78. package/src/tags/util.js +400 -400
  79. package/src/types.d.ts +107 -107
  80. package/syntaxes/language-configuration.json +26 -26
  81. package/syntaxes/miki-template.tmLanguage.json +146 -146
  82. package/tests/asyncRender.test.js +17 -17
  83. package/tests/base.html +6 -6
  84. package/tests/child.html +3 -3
  85. package/tests/context_processors.test.js +13 -13
  86. package/tests/esm.test.mjs +61 -61
  87. package/tests/filters.test.js +254 -254
  88. package/tests/include_security.test.js +9 -9
  89. package/tests/integration/README.md +32 -32
  90. package/tests/integration/features.test.cjs +1681 -1681
  91. package/tests/integration/features.test.mjs +1697 -1697
  92. package/tests/integration/templates/base.miki +6 -6
  93. package/tests/integration/templates/child.miki +6 -6
  94. package/tests/integration/templates/index.html +17 -17
  95. package/tests/lexer.test.js +45 -45
  96. package/tests/parser.test.js +57 -57
  97. package/tests/partial.html +1 -1
  98. package/tests/partialdef.test.js +79 -79
  99. package/tests/production_checks.js +57 -57
  100. package/tests/security.test.js +28 -28
  101. package/tests/tags.test.js +233 -233
@@ -1,720 +1,720 @@
1
- /**
2
- * Control flow template tags: if, for, with, cycle, comment, with, firstof.
3
- */
4
- const { parseVariableExpression } = require('../parser');
5
-
6
- /**
7
- * Helper to parse a string into an array of tokens.
8
- * Handles quoted strings, operators, and identifiers.
9
- */
10
- function tokenizeExpr(exprStr) {
11
- const tokens = [];
12
- let i = 0;
13
- const len = exprStr.length;
14
-
15
- while (i < len) {
16
- const ch = exprStr[i];
17
-
18
- if (/\s/.test(ch)) {
19
- i++;
20
- continue;
21
- }
22
-
23
- if (ch === '"' || ch === '\'') {
24
- const quote = ch;
25
- let str = '';
26
- i++;
27
- while (i < len && exprStr[i] !== quote) {
28
- if (exprStr[i] === '\\' && i + 1 < len) {
29
- i++;
30
- }
31
- str += exprStr[i];
32
- i++;
33
- }
34
- i++;
35
- tokens.push(quote + str + quote);
36
- continue;
37
- }
38
-
39
- if (exprStr.slice(i, i + 7).toLowerCase() === 'not in') {
40
- tokens.push('not in');
41
- i += 7;
42
- continue;
43
- }
44
- if (exprStr.slice(i, i + 2).toLowerCase() === 'in') {
45
- tokens.push('in');
46
- i += 2;
47
- continue;
48
- }
49
- if (exprStr.slice(i, i + 3).toLowerCase() === 'and') {
50
- tokens.push('and');
51
- i += 3;
52
- continue;
53
- }
54
- if (exprStr.slice(i, i + 2).toLowerCase() === 'or') {
55
- tokens.push('or');
56
- i += 2;
57
- continue;
58
- }
59
- if (exprStr.slice(i, i + 3).toLowerCase() === 'not') {
60
- tokens.push('not');
61
- i += 3;
62
- continue;
63
- }
64
-
65
- if (exprStr.slice(i, i + 2) === '==') { tokens.push('=='); i += 2; continue; }
66
- if (exprStr.slice(i, i + 2) === '!=') { tokens.push('!='); i += 2; continue; }
67
- if (exprStr.slice(i, i + 2) === '<=') { tokens.push('<='); i += 2; continue; }
68
- if (exprStr.slice(i, i + 2) === '>=') { tokens.push('>='); i += 2; continue; }
69
- if (ch === '<') { tokens.push('<'); i++; continue; }
70
- if (ch === '>') { tokens.push('>'); i++; continue; }
71
-
72
- let word = '';
73
- while (i < len && !/[\s"'<>!=]/.test(exprStr[i])) {
74
- word += exprStr[i];
75
- i++;
76
- }
77
- if (word) tokens.push(word);
78
- }
79
-
80
- return tokens;
81
- }
82
-
83
- /**
84
- * Helper to resolve a token to its runtime value.
85
- */
86
- function resolveValue(token, context) {
87
- if (!token) return '';
88
- if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith('\'') && token.endsWith('\''))) {
89
- return token.slice(1, -1);
90
- }
91
- if (!isNaN(token) && token !== '') {
92
- return Number(token);
93
- }
94
- if (token === 'true' || token === 'True') return true;
95
- if (token === 'false' || token === 'False') return false;
96
- if (token === 'none' || token === 'None' || token === 'null') return null;
97
- return context.get(token);
98
- }
99
-
100
- /**
101
- * Shunting-yard style expression evaluator supporting:
102
- * == != < <= > >= in not in and or not
103
- */
104
- function evaluateCondition(exprStr, context) {
105
- const tokens = tokenizeExpr(exprStr);
106
- if (tokens.length === 0) return false;
107
-
108
- const ops = [];
109
- const vals = [];
110
-
111
- function applyBinaryOp(op) {
112
- const b = vals.pop();
113
- const a = vals.pop();
114
- let result;
115
- switch (op) {
116
- case '==': result = a == b; break;
117
- case '!=': result = a != b; break;
118
- case '<': result = a < b; break;
119
- case '<=': result = a <= b; break;
120
- case '>': result = a > b; break;
121
- case '>=': result = a >= b; break;
122
- case 'and': result = !!(a && b); break;
123
- case 'or': result = !!(a || b); break;
124
- case 'in': result = Array.isArray(b) || typeof b === 'string' ? b.includes(a) : false; break;
125
- case 'not in': result = !(Array.isArray(b) || typeof b === 'string' ? b.includes(a) : false); break;
126
- default: result = false;
127
- }
128
- vals.push(result);
129
- }
130
-
131
- function applyUnaryNot() {
132
- const a = vals.pop();
133
- vals.push(!a);
134
- }
135
-
136
- const precedence = { 'or': 1, 'and': 2, 'not': 3, '==': 4, '!=': 4, '<': 5, '<=': 5, '>': 5, '>=': 5, 'in': 6, 'not in': 6 };
137
-
138
- for (let i = 0; i < tokens.length; i++) {
139
- const t = tokens[i];
140
-
141
- if (t === 'not') {
142
- if (tokens[i + 1] === 'not' || ['and', 'or', '==', '!=', '<', '<=', '>', '>=', 'in', 'not in', '==='].includes(tokens[i + 1])) {
143
- ops.push('not');
144
- } else {
145
- ops.push('not');
146
- }
147
- continue;
148
- }
149
-
150
- if (['and', 'or', '==', '!=', '<', '<=', '>', '>=', 'in', 'not in'].includes(t)) {
151
- while (ops.length > 0 && ops[ops.length - 1] !== 'not' && precedence[ops[ops.length - 1]] <= precedence[t]) {
152
- if (ops[ops.length - 1] === 'not') {
153
- applyUnaryNot();
154
- ops.pop();
155
- } else {
156
- applyBinaryOp(ops.pop());
157
- }
158
- }
159
- ops.push(t);
160
- continue;
161
- }
162
-
163
- vals.push(resolveValue(t, context));
164
- }
165
-
166
- while (ops.length > 0) {
167
- const op = ops.pop();
168
- if (op === 'not') {
169
- applyUnaryNot();
170
- } else {
171
- applyBinaryOp(op);
172
- }
173
- }
174
-
175
- return vals.length > 0 ? !!vals[0] : false;
176
- }
177
-
178
- class IfNode {
179
- constructor(conditionStr, body, elifBranches, elseBody) {
180
- this.conditionStr = conditionStr;
181
- this.body = body;
182
- this.elifBranches = elifBranches;
183
- this.elseBody = elseBody;
184
- }
185
-
186
- render(context) {
187
- if (evaluateCondition(this.conditionStr, context)) {
188
- return this.body.map(n => n.render(context)).join('');
189
- }
190
- for (const branch of this.elifBranches) {
191
- if (evaluateCondition(branch.conditionStr, context)) {
192
- return branch.body.map(n => n.render(context)).join('');
193
- }
194
- }
195
- if (this.elseBody) {
196
- return this.elseBody.map(n => n.render(context)).join('');
197
- }
198
- return '';
199
- }
200
- }
201
-
202
- class ForNode {
203
- constructor(loopVars, iterablePath, body, emptyBody, filters = []) {
204
- this.loopVars = loopVars;
205
- this.iterablePath = iterablePath;
206
- this.filters = filters;
207
- this.body = body;
208
- this.emptyBody = emptyBody;
209
- }
210
-
211
- render(context) {
212
- let rawItems = context.get(this.iterablePath);
213
-
214
- // Apply any filters on the iterable path (e.g. items|regroup:"category")
215
- const { getFilter } = require('../filters');
216
- for (const filterInfo of this.filters) {
217
- const filterFn = getFilter(filterInfo.name);
218
- if (!filterFn) {
219
- throw new Error(`Unknown filter: '${filterInfo.name}'`);
220
- }
221
- let argVal = undefined;
222
- if (filterInfo.arg) {
223
- if (filterInfo.arg.type === 'literal') {
224
- argVal = filterInfo.arg.value;
225
- } else if (filterInfo.arg.type === 'variable') {
226
- argVal = context.get(filterInfo.arg.value);
227
- }
228
- }
229
- rawItems = filterFn(rawItems, argVal);
230
- }
231
-
232
- let items = [];
233
-
234
- if (Array.isArray(rawItems)) {
235
- items = rawItems.map(item => [null, item]);
236
- } else if (rawItems && typeof rawItems === 'object' && !(rawItems instanceof Date)) {
237
- Object.entries(rawItems).forEach(([key, val]) => {
238
- items.push([key, val]);
239
- });
240
- }
241
-
242
- if (items.length === 0) {
243
- return this.emptyBody ? this.emptyBody.map(n => n.render(context)).join('') : '';
244
- }
245
-
246
- let output = '';
247
- const length = items.length;
248
- const parentLoop = context.get('forloop');
249
- const parentLoopObj = (parentLoop && typeof parentLoop === 'object' && parentLoop !== '') ? parentLoop : null;
250
-
251
- for (let i = 0; i < length; i++) {
252
- const [key, val] = items[i];
253
-
254
- const forloop = {
255
- counter: i + 1,
256
- counter0: i,
257
- revcounter: length - i,
258
- revcounter0: length - i - 1,
259
- first: i === 0,
260
- last: i === length - 1,
261
- parentloop: parentLoopObj
262
- };
263
-
264
- const loopContext = { forloop };
265
-
266
- if (this.loopVars.length === 1) {
267
- loopContext[this.loopVars[0]] = val;
268
- } else if (this.loopVars.length === 2) {
269
- loopContext[this.loopVars[0]] = key;
270
- loopContext[this.loopVars[1]] = val;
271
- } else {
272
- loopContext[this.loopVars[0]] = [key, val];
273
- }
274
-
275
- context.push(loopContext);
276
- output += this.body.map(n => n.render(context)).join('');
277
- context.pop();
278
- }
279
-
280
- return output;
281
- }
282
- }
283
-
284
- class WithNode {
285
- constructor(mappings, body, aliasName = null) {
286
- this.mappings = mappings;
287
- this.body = body;
288
- this.aliasName = aliasName;
289
- }
290
-
291
- render(context) {
292
- const scope = {};
293
- for (const mapping of this.mappings) {
294
- // The valPath may be a literal string (e.g. "Hello" with quotes) or
295
- // a variable lookup (e.g. user.name). Parse quotes here.
296
- const raw = mapping.valPath;
297
- let value;
298
- if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('\'') && raw.endsWith('\''))) {
299
- value = raw.slice(1, -1);
300
- } else if (raw === 'true' || raw === 'True') {
301
- value = true;
302
- } else if (raw === 'false' || raw === 'False') {
303
- value = false;
304
- } else if (raw === 'none' || raw === 'None' || raw === 'null') {
305
- value = null;
306
- } else if (raw !== '' && !isNaN(Number(raw))) {
307
- value = Number(raw);
308
- } else {
309
- value = context.get(raw);
310
- }
311
- scope[mapping.name] = value;
312
- }
313
- // If an `as alias` was used after key=value pairs, alias the last
314
- // key to the alias name. This supports `{% with a=5 as answ %}` →
315
- // `answ` holds the same value as `a`.
316
- if (this.aliasName && this.mappings.length > 0) {
317
- const last = this.mappings[this.mappings.length - 1];
318
- scope[this.aliasName] = scope[last.name];
319
- }
320
- context.push(scope);
321
- const result = this.body.map(n => n.render(context)).join('');
322
- context.pop();
323
- return result;
324
- }
325
- }
326
-
327
- class CycleNode {
328
- constructor(args, asName) {
329
- this.args = args;
330
- this.asName = asName;
331
- }
332
-
333
- render(context) {
334
- const key = this.args.join(',');
335
- let idx = context.cycleStates.get(key) || 0;
336
- const token = this.args[idx % this.args.length];
337
- const val = resolveValue(token, context);
338
- context.cycleStates.set(key, idx + 1);
339
-
340
- if (this.asName) {
341
- context.scopes[0][this.asName] = val;
342
- // Django semantics: {% cycle "x" "y" as v %} produces NO output;
343
- // the value is stored in `v` for later use (e.g. {{ v }}).
344
- return '';
345
- }
346
- return String(val);
347
- }
348
- }
349
-
350
- class FirstofNode {
351
- constructor(args) {
352
- this.args = args;
353
- }
354
-
355
- render(context) {
356
- for (const arg of this.args) {
357
- const val = resolveValue(arg, context);
358
- if (val && val !== '' && val !== null && val !== undefined) {
359
- return String(val);
360
- }
361
- }
362
- return '';
363
- }
364
- }
365
-
366
- class CommentNode {
367
- constructor(body) {
368
- this.body = body;
369
- }
370
-
371
- render(_context) {
372
- return '';
373
- }
374
- }
375
-
376
- class AutoescapeNode {
377
- constructor(setting, body) {
378
- this.setting = setting;
379
- this.body = body;
380
- }
381
-
382
- render(context) {
383
- const oldEscape = context.autoescape;
384
- context.autoescape = this.setting === 'on';
385
- const output = this.body.map(n => n.render(context)).join('');
386
- context.autoescape = oldEscape;
387
- return output;
388
- }
389
- }
390
-
391
- /* --- Partial definition support --- */
392
-
393
- class PartialDefNode {
394
- constructor(name, body, inline = false) {
395
- this.name = name;
396
- this.body = body;
397
- this.inline = inline;
398
- }
399
-
400
- render(context) {
401
- context.registerPartial(this.name, this);
402
- if (this.inline) {
403
- return this.body.map(n => n.render(context)).join('');
404
- }
405
- return '';
406
- }
407
- }
408
-
409
- class PartialNode {
410
- constructor(name, extraMappings = []) {
411
- this.name = name;
412
- this.extraMappings = extraMappings;
413
- }
414
-
415
- render(context) {
416
- const partial = context.getPartial(this.name);
417
- if (!partial) {
418
- throw new Error(`Partial '${this.name}' not found`);
419
- }
420
- // If the partial reference provided `with k=v ...`, push those
421
- // values onto the scope before rendering the partial body so the
422
- // body sees the overrides.
423
- if (this.extraMappings && this.extraMappings.length > 0) {
424
- const scope = {};
425
- for (const m of this.extraMappings) {
426
- const raw = m.valPath;
427
- let value;
428
- if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('\'') && raw.endsWith('\''))) {
429
- value = raw.slice(1, -1);
430
- } else if (raw === 'true' || raw === 'True') {
431
- value = true;
432
- } else if (raw === 'false' || raw === 'False') {
433
- value = false;
434
- } else if (raw === 'none' || raw === 'None' || raw === 'null') {
435
- value = null;
436
- } else if (raw !== '' && !isNaN(Number(raw))) {
437
- value = Number(raw);
438
- } else {
439
- value = context.get(raw);
440
- }
441
- scope[m.name] = value;
442
- }
443
- context.push(scope);
444
- const out = partial.body.map(n => n.render(context)).join('');
445
- context.pop();
446
- return out;
447
- }
448
- return partial.body.map(n => n.render(context)).join('');
449
- }
450
- }
451
-
452
- function parsePartialDef(tagContent, parser) {
453
- const content = tagContent.slice(10).trim();
454
- const nameMatch = content.match(/^(".*?"|'.*?'|\S+)/);
455
- if (!nameMatch) {
456
- throw new Error('partialdef tag requires a name');
457
- }
458
- let name = nameMatch[1];
459
- if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith('\'') && name.endsWith('\''))) {
460
- name = name.slice(1, -1);
461
- }
462
- // Recognize `inline` or `inline=true` as a keyword (case-insensitive)
463
- const inline = /\binline\b/.test(content);
464
- const body = parser.parse(['endpartialdef']);
465
- const next = parser.peek();
466
- if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endpartialdef') {
467
- parser.advance();
468
- }
469
- return new PartialDefNode(name, body, inline);
470
- }
471
-
472
- function parsePartial(tagContent, _parser) {
473
- // {% partial "name" %}
474
- // {% partial "name" with k1=v1 k2=v2 %}
475
- const content = tagContent.slice(7).trim();
476
- const nameMatch = content.match(/^(".*?"|'.*?'|\S+)/);
477
- if (!nameMatch) {
478
- throw new Error('partial tag requires a name');
479
- }
480
- let name = nameMatch[1];
481
- if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith('\'') && name.endsWith('\''))) {
482
- name = name.slice(1, -1);
483
- }
484
-
485
- // Optional `with k=v k=v` clause
486
- let extraMappings = [];
487
- const remaining = content.slice(nameMatch[0].length).trim();
488
- const withMatch = remaining.match(/^with\s+(.+)$/s);
489
- if (withMatch) {
490
- extraMappings = parseKeyValuePairs(withMatch[1]);
491
- }
492
-
493
- return new PartialNode(name, extraMappings);
494
- }
495
-
496
- /* --- Tag Registry Parsers --- */
497
-
498
- function parseAutoescape(tagContent, parser) {
499
- const setting = tagContent.slice(10).trim();
500
- if (setting !== 'on' && setting !== 'off') {
501
- throw new Error(`Invalid autoescape setting: '${setting}'`);
502
- }
503
- const body = parser.parse(['endautoescape']);
504
- const next = parser.peek();
505
- if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endautoescape') {
506
- parser.advance();
507
- }
508
- return new AutoescapeNode(setting, body);
509
- }
510
-
511
- function parseComment(tagContent, parser) {
512
- const body = parser.parse(['endcomment']);
513
- const next = parser.peek();
514
- if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endcomment') {
515
- parser.advance();
516
- }
517
- return new CommentNode(body);
518
- }
519
-
520
- function parseIf(tagContent, parser) {
521
- const conditionStr = tagContent.slice(2).trim();
522
- const body = parser.parse(['elif', 'else', 'endif']);
523
- const elifBranches = [];
524
- let elseBody = null;
525
-
526
- while (true) {
527
- const next = parser.peek();
528
- if (!next || next.type !== 'block') break;
529
- const tagName = next.content.split(/\s+/)[0];
530
- if (tagName === 'elif') {
531
- parser.advance();
532
- const branchCond = next.content.slice(4).trim();
533
- const branchBody = parser.parse(['elif', 'else', 'endif']);
534
- elifBranches.push({ conditionStr: branchCond, body: branchBody });
535
- } else if (tagName === 'else') {
536
- parser.advance();
537
- elseBody = parser.parse(['endif']);
538
- } else if (tagName === 'endif') {
539
- parser.advance();
540
- break;
541
- } else {
542
- break;
543
- }
544
- }
545
-
546
- // Validate that the if block was properly closed
547
- // (for/with/endfor etc already consume their own closing tags)
548
- // Note: parseIf is called via parser.parse() which already stopped at elif/else/endif
549
- // If we reach here and the next block is not an endif, the if is unclosed
550
-
551
- return new IfNode(conditionStr, body, elifBranches, elseBody);
552
- }
553
-
554
- function parseFor(tagContent, parser) {
555
- const match = tagContent.match(/^for\s+(.+?)\s+in\s+(.+)$/);
556
- if (!match) {
557
- throw new Error(`Invalid for tag format: '${tagContent}'`);
558
- }
559
- const loopVars = match[1].split(',').map(s => s.trim()).filter(Boolean);
560
- const iterableExpr = match[2].trim();
561
- const body = parser.parse(['empty', 'endfor']);
562
- let emptyBody = null;
563
-
564
- let next = parser.peek();
565
- if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'empty') {
566
- parser.advance();
567
- emptyBody = parser.parse(['endfor']);
568
- }
569
-
570
- next = parser.peek();
571
- if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endfor') {
572
- parser.advance();
573
- }
574
-
575
- // Parse the iterable expression to support filters: items|regroup:"category"
576
- const parsedIterable = parseVariableExpression(iterableExpr);
577
- return new ForNode(loopVars, parsedIterable.varPath, body, emptyBody, parsedIterable.filters);
578
- }
579
-
580
- function parseWith(tagContent, parser) {
581
- const mappings = [];
582
- const content = tagContent.slice(4).trim();
583
- let aliasName = null;
584
-
585
- // Support three forms (matching Django plus common extensions):
586
- // 1. {% with x as y %} — alias a single expression
587
- // 2. {% with a=1, b=2 %} — bind multiple key=value pairs
588
- // 3. {% with a=1, b=2 as ans %} — bind pairs then alias the last
589
- // (the `as` introduces an extra alias for the last key)
590
- if (content.includes(' as ')) {
591
- const asIdx = content.indexOf(' as ');
592
- const left = content.slice(0, asIdx).trim();
593
- aliasName = content.slice(asIdx + 4).trim();
594
- if (left.includes('=')) {
595
- // Form 3: pairs followed by `as alias`
596
- const pairMappings = parseKeyValuePairs(left);
597
- mappings.push(...pairMappings);
598
- } else {
599
- // Form 1: simple alias — left is an expression (literal or var)
600
- mappings.push({ name: aliasName, valPath: left });
601
- aliasName = null; // already used as the mapping name
602
- }
603
- } else {
604
- // Form 2: key=value pairs only
605
- const pairMappings = parseKeyValuePairs(content);
606
- mappings.push(...pairMappings);
607
- }
608
-
609
- const body = parser.parse(['endwith']);
610
- const next = parser.peek();
611
- if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endwith') {
612
- parser.advance();
613
- }
614
-
615
- return new WithNode(mappings, body, aliasName);
616
- }
617
-
618
- /**
619
- * Parse a comma-separated list of `key=value` pairs. Values may be
620
- * quoted (single or double quotes) and may contain commas. Unquoted
621
- * values are read up to the next comma or whitespace.
622
- */
623
- function parseKeyValuePairs(content) {
624
- const tokens = [];
625
- let i = 0;
626
- while (i < content.length) {
627
- while (i < content.length && /\s/.test(content[i])) i++;
628
- if (i >= content.length) break;
629
-
630
- // Read identifier (key)
631
- let key = '';
632
- while (i < content.length && /[a-zA-Z0-9_]/.test(content[i])) {
633
- key += content[i++];
634
- }
635
- if (!key) {
636
- // Not a key — skip a char to avoid infinite loop
637
- i++;
638
- continue;
639
- }
640
- while (i < content.length && /\s/.test(content[i])) i++;
641
-
642
- if (content[i] !== '=') {
643
- // Malformed pair; skip to next comma
644
- while (i < content.length && content[i] !== ',') i++;
645
- if (content[i] === ',') i++;
646
- continue;
647
- }
648
- i++; // consume '='
649
- while (i < content.length && /\s/.test(content[i])) i++;
650
-
651
- let val = '';
652
- if (content[i] === '"' || content[i] === '\'') {
653
- const quote = content[i++];
654
- while (i < content.length && content[i] !== quote) {
655
- if (content[i] === '\\' && i + 1 < content.length) i++;
656
- val += content[i++];
657
- }
658
- if (content[i] === quote) i++;
659
- // Keep the quotes around the literal so WithNode can recognize it
660
- val = quote + val + quote;
661
- } else {
662
- while (i < content.length && content[i] !== ',' && !/\s/.test(content[i])) {
663
- val += content[i++];
664
- }
665
- }
666
- tokens.push({ name: key, valPath: val });
667
- while (i < content.length && /\s/.test(content[i])) i++;
668
- if (content[i] === ',') i++;
669
- }
670
- return tokens;
671
- }
672
-
673
- function parseCycle(tagContent, _parser) {
674
- const content = tagContent.slice(5).trim();
675
- const argRegex = /(".*?"|'.*?'|[^\s]+)/g;
676
- const matches = content.match(argRegex) || [];
677
- let asName = null;
678
- const args = [];
679
-
680
- for (let i = 0; i < matches.length; i++) {
681
- if (matches[i] === 'as' && i < matches.length - 1) {
682
- asName = matches[i + 1];
683
- break;
684
- }
685
- args.push(matches[i]);
686
- }
687
-
688
- return new CycleNode(args, asName);
689
- }
690
-
691
- function parseFirstof(tagContent, _parser) {
692
- const content = tagContent.slice(8).trim();
693
- const argRegex = /(".*?"|'.*?'|[^\s]+)/g;
694
- const args = (content.match(argRegex) || []).map(a => a.trim()).filter(Boolean);
695
- return new FirstofNode(args);
696
- }
697
-
698
- module.exports = {
699
- IfNode,
700
- ForNode,
701
- WithNode,
702
- CycleNode,
703
- FirstofNode,
704
- CommentNode,
705
- AutoescapeNode,
706
- PartialDefNode,
707
- PartialNode,
708
- evaluateCondition,
709
- parsers: {
710
- if: parseIf,
711
- for: parseFor,
712
- with: parseWith,
713
- cycle: parseCycle,
714
- autoescape: parseAutoescape,
715
- comment: parseComment,
716
- partialdef: parsePartialDef,
717
- partial: parsePartial,
718
- firstof: parseFirstof
719
- }
1
+ /**
2
+ * Control flow template tags: if, for, with, cycle, comment, with, firstof.
3
+ */
4
+ const { parseVariableExpression } = require('../parser');
5
+
6
+ /**
7
+ * Helper to parse a string into an array of tokens.
8
+ * Handles quoted strings, operators, and identifiers.
9
+ */
10
+ function tokenizeExpr(exprStr) {
11
+ const tokens = [];
12
+ let i = 0;
13
+ const len = exprStr.length;
14
+
15
+ while (i < len) {
16
+ const ch = exprStr[i];
17
+
18
+ if (/\s/.test(ch)) {
19
+ i++;
20
+ continue;
21
+ }
22
+
23
+ if (ch === '"' || ch === '\'') {
24
+ const quote = ch;
25
+ let str = '';
26
+ i++;
27
+ while (i < len && exprStr[i] !== quote) {
28
+ if (exprStr[i] === '\\' && i + 1 < len) {
29
+ i++;
30
+ }
31
+ str += exprStr[i];
32
+ i++;
33
+ }
34
+ i++;
35
+ tokens.push(quote + str + quote);
36
+ continue;
37
+ }
38
+
39
+ if (exprStr.slice(i, i + 7).toLowerCase() === 'not in') {
40
+ tokens.push('not in');
41
+ i += 7;
42
+ continue;
43
+ }
44
+ if (exprStr.slice(i, i + 2).toLowerCase() === 'in') {
45
+ tokens.push('in');
46
+ i += 2;
47
+ continue;
48
+ }
49
+ if (exprStr.slice(i, i + 3).toLowerCase() === 'and') {
50
+ tokens.push('and');
51
+ i += 3;
52
+ continue;
53
+ }
54
+ if (exprStr.slice(i, i + 2).toLowerCase() === 'or') {
55
+ tokens.push('or');
56
+ i += 2;
57
+ continue;
58
+ }
59
+ if (exprStr.slice(i, i + 3).toLowerCase() === 'not') {
60
+ tokens.push('not');
61
+ i += 3;
62
+ continue;
63
+ }
64
+
65
+ if (exprStr.slice(i, i + 2) === '==') { tokens.push('=='); i += 2; continue; }
66
+ if (exprStr.slice(i, i + 2) === '!=') { tokens.push('!='); i += 2; continue; }
67
+ if (exprStr.slice(i, i + 2) === '<=') { tokens.push('<='); i += 2; continue; }
68
+ if (exprStr.slice(i, i + 2) === '>=') { tokens.push('>='); i += 2; continue; }
69
+ if (ch === '<') { tokens.push('<'); i++; continue; }
70
+ if (ch === '>') { tokens.push('>'); i++; continue; }
71
+
72
+ let word = '';
73
+ while (i < len && !/[\s"'<>!=]/.test(exprStr[i])) {
74
+ word += exprStr[i];
75
+ i++;
76
+ }
77
+ if (word) tokens.push(word);
78
+ }
79
+
80
+ return tokens;
81
+ }
82
+
83
+ /**
84
+ * Helper to resolve a token to its runtime value.
85
+ */
86
+ function resolveValue(token, context) {
87
+ if (!token) return '';
88
+ if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith('\'') && token.endsWith('\''))) {
89
+ return token.slice(1, -1);
90
+ }
91
+ if (!isNaN(token) && token !== '') {
92
+ return Number(token);
93
+ }
94
+ if (token === 'true' || token === 'True') return true;
95
+ if (token === 'false' || token === 'False') return false;
96
+ if (token === 'none' || token === 'None' || token === 'null') return null;
97
+ return context.get(token);
98
+ }
99
+
100
+ /**
101
+ * Shunting-yard style expression evaluator supporting:
102
+ * == != < <= > >= in not in and or not
103
+ */
104
+ function evaluateCondition(exprStr, context) {
105
+ const tokens = tokenizeExpr(exprStr);
106
+ if (tokens.length === 0) return false;
107
+
108
+ const ops = [];
109
+ const vals = [];
110
+
111
+ function applyBinaryOp(op) {
112
+ const b = vals.pop();
113
+ const a = vals.pop();
114
+ let result;
115
+ switch (op) {
116
+ case '==': result = a == b; break;
117
+ case '!=': result = a != b; break;
118
+ case '<': result = a < b; break;
119
+ case '<=': result = a <= b; break;
120
+ case '>': result = a > b; break;
121
+ case '>=': result = a >= b; break;
122
+ case 'and': result = !!(a && b); break;
123
+ case 'or': result = !!(a || b); break;
124
+ case 'in': result = Array.isArray(b) || typeof b === 'string' ? b.includes(a) : false; break;
125
+ case 'not in': result = !(Array.isArray(b) || typeof b === 'string' ? b.includes(a) : false); break;
126
+ default: result = false;
127
+ }
128
+ vals.push(result);
129
+ }
130
+
131
+ function applyUnaryNot() {
132
+ const a = vals.pop();
133
+ vals.push(!a);
134
+ }
135
+
136
+ const precedence = { 'or': 1, 'and': 2, 'not': 3, '==': 4, '!=': 4, '<': 5, '<=': 5, '>': 5, '>=': 5, 'in': 6, 'not in': 6 };
137
+
138
+ for (let i = 0; i < tokens.length; i++) {
139
+ const t = tokens[i];
140
+
141
+ if (t === 'not') {
142
+ if (tokens[i + 1] === 'not' || ['and', 'or', '==', '!=', '<', '<=', '>', '>=', 'in', 'not in', '==='].includes(tokens[i + 1])) {
143
+ ops.push('not');
144
+ } else {
145
+ ops.push('not');
146
+ }
147
+ continue;
148
+ }
149
+
150
+ if (['and', 'or', '==', '!=', '<', '<=', '>', '>=', 'in', 'not in'].includes(t)) {
151
+ while (ops.length > 0 && ops[ops.length - 1] !== 'not' && precedence[ops[ops.length - 1]] <= precedence[t]) {
152
+ if (ops[ops.length - 1] === 'not') {
153
+ applyUnaryNot();
154
+ ops.pop();
155
+ } else {
156
+ applyBinaryOp(ops.pop());
157
+ }
158
+ }
159
+ ops.push(t);
160
+ continue;
161
+ }
162
+
163
+ vals.push(resolveValue(t, context));
164
+ }
165
+
166
+ while (ops.length > 0) {
167
+ const op = ops.pop();
168
+ if (op === 'not') {
169
+ applyUnaryNot();
170
+ } else {
171
+ applyBinaryOp(op);
172
+ }
173
+ }
174
+
175
+ return vals.length > 0 ? !!vals[0] : false;
176
+ }
177
+
178
+ class IfNode {
179
+ constructor(conditionStr, body, elifBranches, elseBody) {
180
+ this.conditionStr = conditionStr;
181
+ this.body = body;
182
+ this.elifBranches = elifBranches;
183
+ this.elseBody = elseBody;
184
+ }
185
+
186
+ render(context) {
187
+ if (evaluateCondition(this.conditionStr, context)) {
188
+ return this.body.map(n => n.render(context)).join('');
189
+ }
190
+ for (const branch of this.elifBranches) {
191
+ if (evaluateCondition(branch.conditionStr, context)) {
192
+ return branch.body.map(n => n.render(context)).join('');
193
+ }
194
+ }
195
+ if (this.elseBody) {
196
+ return this.elseBody.map(n => n.render(context)).join('');
197
+ }
198
+ return '';
199
+ }
200
+ }
201
+
202
+ class ForNode {
203
+ constructor(loopVars, iterablePath, body, emptyBody, filters = []) {
204
+ this.loopVars = loopVars;
205
+ this.iterablePath = iterablePath;
206
+ this.filters = filters;
207
+ this.body = body;
208
+ this.emptyBody = emptyBody;
209
+ }
210
+
211
+ render(context) {
212
+ let rawItems = context.get(this.iterablePath);
213
+
214
+ // Apply any filters on the iterable path (e.g. items|regroup:"category")
215
+ const { getFilter } = require('../filters');
216
+ for (const filterInfo of this.filters) {
217
+ const filterFn = getFilter(filterInfo.name);
218
+ if (!filterFn) {
219
+ throw new Error(`Unknown filter: '${filterInfo.name}'`);
220
+ }
221
+ let argVal = undefined;
222
+ if (filterInfo.arg) {
223
+ if (filterInfo.arg.type === 'literal') {
224
+ argVal = filterInfo.arg.value;
225
+ } else if (filterInfo.arg.type === 'variable') {
226
+ argVal = context.get(filterInfo.arg.value);
227
+ }
228
+ }
229
+ rawItems = filterFn(rawItems, argVal);
230
+ }
231
+
232
+ let items = [];
233
+
234
+ if (Array.isArray(rawItems)) {
235
+ items = rawItems.map(item => [null, item]);
236
+ } else if (rawItems && typeof rawItems === 'object' && !(rawItems instanceof Date)) {
237
+ Object.entries(rawItems).forEach(([key, val]) => {
238
+ items.push([key, val]);
239
+ });
240
+ }
241
+
242
+ if (items.length === 0) {
243
+ return this.emptyBody ? this.emptyBody.map(n => n.render(context)).join('') : '';
244
+ }
245
+
246
+ let output = '';
247
+ const length = items.length;
248
+ const parentLoop = context.get('forloop');
249
+ const parentLoopObj = (parentLoop && typeof parentLoop === 'object' && parentLoop !== '') ? parentLoop : null;
250
+
251
+ for (let i = 0; i < length; i++) {
252
+ const [key, val] = items[i];
253
+
254
+ const forloop = {
255
+ counter: i + 1,
256
+ counter0: i,
257
+ revcounter: length - i,
258
+ revcounter0: length - i - 1,
259
+ first: i === 0,
260
+ last: i === length - 1,
261
+ parentloop: parentLoopObj
262
+ };
263
+
264
+ const loopContext = { forloop };
265
+
266
+ if (this.loopVars.length === 1) {
267
+ loopContext[this.loopVars[0]] = val;
268
+ } else if (this.loopVars.length === 2) {
269
+ loopContext[this.loopVars[0]] = key;
270
+ loopContext[this.loopVars[1]] = val;
271
+ } else {
272
+ loopContext[this.loopVars[0]] = [key, val];
273
+ }
274
+
275
+ context.push(loopContext);
276
+ output += this.body.map(n => n.render(context)).join('');
277
+ context.pop();
278
+ }
279
+
280
+ return output;
281
+ }
282
+ }
283
+
284
+ class WithNode {
285
+ constructor(mappings, body, aliasName = null) {
286
+ this.mappings = mappings;
287
+ this.body = body;
288
+ this.aliasName = aliasName;
289
+ }
290
+
291
+ render(context) {
292
+ const scope = {};
293
+ for (const mapping of this.mappings) {
294
+ // The valPath may be a literal string (e.g. "Hello" with quotes) or
295
+ // a variable lookup (e.g. user.name). Parse quotes here.
296
+ const raw = mapping.valPath;
297
+ let value;
298
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('\'') && raw.endsWith('\''))) {
299
+ value = raw.slice(1, -1);
300
+ } else if (raw === 'true' || raw === 'True') {
301
+ value = true;
302
+ } else if (raw === 'false' || raw === 'False') {
303
+ value = false;
304
+ } else if (raw === 'none' || raw === 'None' || raw === 'null') {
305
+ value = null;
306
+ } else if (raw !== '' && !isNaN(Number(raw))) {
307
+ value = Number(raw);
308
+ } else {
309
+ value = context.get(raw);
310
+ }
311
+ scope[mapping.name] = value;
312
+ }
313
+ // If an `as alias` was used after key=value pairs, alias the last
314
+ // key to the alias name. This supports `{% with a=5 as answ %}` →
315
+ // `answ` holds the same value as `a`.
316
+ if (this.aliasName && this.mappings.length > 0) {
317
+ const last = this.mappings[this.mappings.length - 1];
318
+ scope[this.aliasName] = scope[last.name];
319
+ }
320
+ context.push(scope);
321
+ const result = this.body.map(n => n.render(context)).join('');
322
+ context.pop();
323
+ return result;
324
+ }
325
+ }
326
+
327
+ class CycleNode {
328
+ constructor(args, asName) {
329
+ this.args = args;
330
+ this.asName = asName;
331
+ }
332
+
333
+ render(context) {
334
+ const key = this.args.join(',');
335
+ let idx = context.cycleStates.get(key) || 0;
336
+ const token = this.args[idx % this.args.length];
337
+ const val = resolveValue(token, context);
338
+ context.cycleStates.set(key, idx + 1);
339
+
340
+ if (this.asName) {
341
+ context.scopes[0][this.asName] = val;
342
+ // Django semantics: {% cycle "x" "y" as v %} produces NO output;
343
+ // the value is stored in `v` for later use (e.g. {{ v }}).
344
+ return '';
345
+ }
346
+ return String(val);
347
+ }
348
+ }
349
+
350
+ class FirstofNode {
351
+ constructor(args) {
352
+ this.args = args;
353
+ }
354
+
355
+ render(context) {
356
+ for (const arg of this.args) {
357
+ const val = resolveValue(arg, context);
358
+ if (val && val !== '' && val !== null && val !== undefined) {
359
+ return String(val);
360
+ }
361
+ }
362
+ return '';
363
+ }
364
+ }
365
+
366
+ class CommentNode {
367
+ constructor(body) {
368
+ this.body = body;
369
+ }
370
+
371
+ render(_context) {
372
+ return '';
373
+ }
374
+ }
375
+
376
+ class AutoescapeNode {
377
+ constructor(setting, body) {
378
+ this.setting = setting;
379
+ this.body = body;
380
+ }
381
+
382
+ render(context) {
383
+ const oldEscape = context.autoescape;
384
+ context.autoescape = this.setting === 'on';
385
+ const output = this.body.map(n => n.render(context)).join('');
386
+ context.autoescape = oldEscape;
387
+ return output;
388
+ }
389
+ }
390
+
391
+ /* --- Partial definition support --- */
392
+
393
+ class PartialDefNode {
394
+ constructor(name, body, inline = false) {
395
+ this.name = name;
396
+ this.body = body;
397
+ this.inline = inline;
398
+ }
399
+
400
+ render(context) {
401
+ context.registerPartial(this.name, this);
402
+ if (this.inline) {
403
+ return this.body.map(n => n.render(context)).join('');
404
+ }
405
+ return '';
406
+ }
407
+ }
408
+
409
+ class PartialNode {
410
+ constructor(name, extraMappings = []) {
411
+ this.name = name;
412
+ this.extraMappings = extraMappings;
413
+ }
414
+
415
+ render(context) {
416
+ const partial = context.getPartial(this.name);
417
+ if (!partial) {
418
+ throw new Error(`Partial '${this.name}' not found`);
419
+ }
420
+ // If the partial reference provided `with k=v ...`, push those
421
+ // values onto the scope before rendering the partial body so the
422
+ // body sees the overrides.
423
+ if (this.extraMappings && this.extraMappings.length > 0) {
424
+ const scope = {};
425
+ for (const m of this.extraMappings) {
426
+ const raw = m.valPath;
427
+ let value;
428
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith('\'') && raw.endsWith('\''))) {
429
+ value = raw.slice(1, -1);
430
+ } else if (raw === 'true' || raw === 'True') {
431
+ value = true;
432
+ } else if (raw === 'false' || raw === 'False') {
433
+ value = false;
434
+ } else if (raw === 'none' || raw === 'None' || raw === 'null') {
435
+ value = null;
436
+ } else if (raw !== '' && !isNaN(Number(raw))) {
437
+ value = Number(raw);
438
+ } else {
439
+ value = context.get(raw);
440
+ }
441
+ scope[m.name] = value;
442
+ }
443
+ context.push(scope);
444
+ const out = partial.body.map(n => n.render(context)).join('');
445
+ context.pop();
446
+ return out;
447
+ }
448
+ return partial.body.map(n => n.render(context)).join('');
449
+ }
450
+ }
451
+
452
+ function parsePartialDef(tagContent, parser) {
453
+ const content = tagContent.slice(10).trim();
454
+ const nameMatch = content.match(/^(".*?"|'.*?'|\S+)/);
455
+ if (!nameMatch) {
456
+ throw new Error('partialdef tag requires a name');
457
+ }
458
+ let name = nameMatch[1];
459
+ if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith('\'') && name.endsWith('\''))) {
460
+ name = name.slice(1, -1);
461
+ }
462
+ // Recognize `inline` or `inline=true` as a keyword (case-insensitive)
463
+ const inline = /\binline\b/.test(content);
464
+ const body = parser.parse(['endpartialdef']);
465
+ const next = parser.peek();
466
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endpartialdef') {
467
+ parser.advance();
468
+ }
469
+ return new PartialDefNode(name, body, inline);
470
+ }
471
+
472
+ function parsePartial(tagContent, _parser) {
473
+ // {% partial "name" %}
474
+ // {% partial "name" with k1=v1 k2=v2 %}
475
+ const content = tagContent.slice(7).trim();
476
+ const nameMatch = content.match(/^(".*?"|'.*?'|\S+)/);
477
+ if (!nameMatch) {
478
+ throw new Error('partial tag requires a name');
479
+ }
480
+ let name = nameMatch[1];
481
+ if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith('\'') && name.endsWith('\''))) {
482
+ name = name.slice(1, -1);
483
+ }
484
+
485
+ // Optional `with k=v k=v` clause
486
+ let extraMappings = [];
487
+ const remaining = content.slice(nameMatch[0].length).trim();
488
+ const withMatch = remaining.match(/^with\s+(.+)$/s);
489
+ if (withMatch) {
490
+ extraMappings = parseKeyValuePairs(withMatch[1]);
491
+ }
492
+
493
+ return new PartialNode(name, extraMappings);
494
+ }
495
+
496
+ /* --- Tag Registry Parsers --- */
497
+
498
+ function parseAutoescape(tagContent, parser) {
499
+ const setting = tagContent.slice(10).trim();
500
+ if (setting !== 'on' && setting !== 'off') {
501
+ throw new Error(`Invalid autoescape setting: '${setting}'`);
502
+ }
503
+ const body = parser.parse(['endautoescape']);
504
+ const next = parser.peek();
505
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endautoescape') {
506
+ parser.advance();
507
+ }
508
+ return new AutoescapeNode(setting, body);
509
+ }
510
+
511
+ function parseComment(tagContent, parser) {
512
+ const body = parser.parse(['endcomment']);
513
+ const next = parser.peek();
514
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endcomment') {
515
+ parser.advance();
516
+ }
517
+ return new CommentNode(body);
518
+ }
519
+
520
+ function parseIf(tagContent, parser) {
521
+ const conditionStr = tagContent.slice(2).trim();
522
+ const body = parser.parse(['elif', 'else', 'endif']);
523
+ const elifBranches = [];
524
+ let elseBody = null;
525
+
526
+ while (true) {
527
+ const next = parser.peek();
528
+ if (!next || next.type !== 'block') break;
529
+ const tagName = next.content.split(/\s+/)[0];
530
+ if (tagName === 'elif') {
531
+ parser.advance();
532
+ const branchCond = next.content.slice(4).trim();
533
+ const branchBody = parser.parse(['elif', 'else', 'endif']);
534
+ elifBranches.push({ conditionStr: branchCond, body: branchBody });
535
+ } else if (tagName === 'else') {
536
+ parser.advance();
537
+ elseBody = parser.parse(['endif']);
538
+ } else if (tagName === 'endif') {
539
+ parser.advance();
540
+ break;
541
+ } else {
542
+ break;
543
+ }
544
+ }
545
+
546
+ // Validate that the if block was properly closed
547
+ // (for/with/endfor etc already consume their own closing tags)
548
+ // Note: parseIf is called via parser.parse() which already stopped at elif/else/endif
549
+ // If we reach here and the next block is not an endif, the if is unclosed
550
+
551
+ return new IfNode(conditionStr, body, elifBranches, elseBody);
552
+ }
553
+
554
+ function parseFor(tagContent, parser) {
555
+ const match = tagContent.match(/^for\s+(.+?)\s+in\s+(.+)$/);
556
+ if (!match) {
557
+ throw new Error(`Invalid for tag format: '${tagContent}'`);
558
+ }
559
+ const loopVars = match[1].split(',').map(s => s.trim()).filter(Boolean);
560
+ const iterableExpr = match[2].trim();
561
+ const body = parser.parse(['empty', 'endfor']);
562
+ let emptyBody = null;
563
+
564
+ let next = parser.peek();
565
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'empty') {
566
+ parser.advance();
567
+ emptyBody = parser.parse(['endfor']);
568
+ }
569
+
570
+ next = parser.peek();
571
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endfor') {
572
+ parser.advance();
573
+ }
574
+
575
+ // Parse the iterable expression to support filters: items|regroup:"category"
576
+ const parsedIterable = parseVariableExpression(iterableExpr);
577
+ return new ForNode(loopVars, parsedIterable.varPath, body, emptyBody, parsedIterable.filters);
578
+ }
579
+
580
+ function parseWith(tagContent, parser) {
581
+ const mappings = [];
582
+ const content = tagContent.slice(4).trim();
583
+ let aliasName = null;
584
+
585
+ // Support three forms (matching Django plus common extensions):
586
+ // 1. {% with x as y %} — alias a single expression
587
+ // 2. {% with a=1, b=2 %} — bind multiple key=value pairs
588
+ // 3. {% with a=1, b=2 as ans %} — bind pairs then alias the last
589
+ // (the `as` introduces an extra alias for the last key)
590
+ if (content.includes(' as ')) {
591
+ const asIdx = content.indexOf(' as ');
592
+ const left = content.slice(0, asIdx).trim();
593
+ aliasName = content.slice(asIdx + 4).trim();
594
+ if (left.includes('=')) {
595
+ // Form 3: pairs followed by `as alias`
596
+ const pairMappings = parseKeyValuePairs(left);
597
+ mappings.push(...pairMappings);
598
+ } else {
599
+ // Form 1: simple alias — left is an expression (literal or var)
600
+ mappings.push({ name: aliasName, valPath: left });
601
+ aliasName = null; // already used as the mapping name
602
+ }
603
+ } else {
604
+ // Form 2: key=value pairs only
605
+ const pairMappings = parseKeyValuePairs(content);
606
+ mappings.push(...pairMappings);
607
+ }
608
+
609
+ const body = parser.parse(['endwith']);
610
+ const next = parser.peek();
611
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endwith') {
612
+ parser.advance();
613
+ }
614
+
615
+ return new WithNode(mappings, body, aliasName);
616
+ }
617
+
618
+ /**
619
+ * Parse a comma-separated list of `key=value` pairs. Values may be
620
+ * quoted (single or double quotes) and may contain commas. Unquoted
621
+ * values are read up to the next comma or whitespace.
622
+ */
623
+ function parseKeyValuePairs(content) {
624
+ const tokens = [];
625
+ let i = 0;
626
+ while (i < content.length) {
627
+ while (i < content.length && /\s/.test(content[i])) i++;
628
+ if (i >= content.length) break;
629
+
630
+ // Read identifier (key)
631
+ let key = '';
632
+ while (i < content.length && /[a-zA-Z0-9_]/.test(content[i])) {
633
+ key += content[i++];
634
+ }
635
+ if (!key) {
636
+ // Not a key — skip a char to avoid infinite loop
637
+ i++;
638
+ continue;
639
+ }
640
+ while (i < content.length && /\s/.test(content[i])) i++;
641
+
642
+ if (content[i] !== '=') {
643
+ // Malformed pair; skip to next comma
644
+ while (i < content.length && content[i] !== ',') i++;
645
+ if (content[i] === ',') i++;
646
+ continue;
647
+ }
648
+ i++; // consume '='
649
+ while (i < content.length && /\s/.test(content[i])) i++;
650
+
651
+ let val = '';
652
+ if (content[i] === '"' || content[i] === '\'') {
653
+ const quote = content[i++];
654
+ while (i < content.length && content[i] !== quote) {
655
+ if (content[i] === '\\' && i + 1 < content.length) i++;
656
+ val += content[i++];
657
+ }
658
+ if (content[i] === quote) i++;
659
+ // Keep the quotes around the literal so WithNode can recognize it
660
+ val = quote + val + quote;
661
+ } else {
662
+ while (i < content.length && content[i] !== ',' && !/\s/.test(content[i])) {
663
+ val += content[i++];
664
+ }
665
+ }
666
+ tokens.push({ name: key, valPath: val });
667
+ while (i < content.length && /\s/.test(content[i])) i++;
668
+ if (content[i] === ',') i++;
669
+ }
670
+ return tokens;
671
+ }
672
+
673
+ function parseCycle(tagContent, _parser) {
674
+ const content = tagContent.slice(5).trim();
675
+ const argRegex = /(".*?"|'.*?'|[^\s]+)/g;
676
+ const matches = content.match(argRegex) || [];
677
+ let asName = null;
678
+ const args = [];
679
+
680
+ for (let i = 0; i < matches.length; i++) {
681
+ if (matches[i] === 'as' && i < matches.length - 1) {
682
+ asName = matches[i + 1];
683
+ break;
684
+ }
685
+ args.push(matches[i]);
686
+ }
687
+
688
+ return new CycleNode(args, asName);
689
+ }
690
+
691
+ function parseFirstof(tagContent, _parser) {
692
+ const content = tagContent.slice(8).trim();
693
+ const argRegex = /(".*?"|'.*?'|[^\s]+)/g;
694
+ const args = (content.match(argRegex) || []).map(a => a.trim()).filter(Boolean);
695
+ return new FirstofNode(args);
696
+ }
697
+
698
+ module.exports = {
699
+ IfNode,
700
+ ForNode,
701
+ WithNode,
702
+ CycleNode,
703
+ FirstofNode,
704
+ CommentNode,
705
+ AutoescapeNode,
706
+ PartialDefNode,
707
+ PartialNode,
708
+ evaluateCondition,
709
+ parsers: {
710
+ if: parseIf,
711
+ for: parseFor,
712
+ with: parseWith,
713
+ cycle: parseCycle,
714
+ autoescape: parseAutoescape,
715
+ comment: parseComment,
716
+ partialdef: parsePartialDef,
717
+ partial: parsePartial,
718
+ firstof: parseFirstof
719
+ }
720
720
  };