miki-template 1.2.0

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 (73) hide show
  1. package/.github/workflows/ci.yml +54 -0
  2. package/AGENT.md +71 -0
  3. package/API_REFERENCE.md +314 -0
  4. package/CHANGELOG.md +97 -0
  5. package/CODE_OF_CONDUCT.md +14 -0
  6. package/CONTRIBUTING.md +27 -0
  7. package/README.md +304 -0
  8. package/ROADMAP.md +40 -0
  9. package/benchmarks/report.json +17 -0
  10. package/benchmarks/run.js +49 -0
  11. package/benchmarks/templates/large.dtpl +7 -0
  12. package/benchmarks/templates/medium.dtpl +3 -0
  13. package/benchmarks/templates/small.dtpl +7 -0
  14. package/context/component.md +109 -0
  15. package/context/prd.md +131 -0
  16. package/context/project-structure.md +33 -0
  17. package/docs/README.md +18 -0
  18. package/docs/advanced_usage.md +71 -0
  19. package/docs/api.md +102 -0
  20. package/docs/filters.md +540 -0
  21. package/docs/installation.md +106 -0
  22. package/docs/overview.md +57 -0
  23. package/docs/partialdef.md +41 -0
  24. package/docs/security.md +27 -0
  25. package/docs/tags.md +610 -0
  26. package/docs/usage.md +599 -0
  27. package/eslint.config.mjs +34 -0
  28. package/miki-template-1.2.0.vsix +0 -0
  29. package/miki-template-extension/LICENSE +21 -0
  30. package/miki-template-extension/README.md +82 -0
  31. package/miki-template-extension/icon.png +0 -0
  32. package/miki-template-extension/icon.svg +10 -0
  33. package/miki-template-extension/package.json +46 -0
  34. package/miki-template-extension/snippets/miki-template.json +177 -0
  35. package/miki-template-extension/syntaxes/language-configuration.json +26 -0
  36. package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +146 -0
  37. package/package.json +31 -0
  38. package/snippets/miki-template.json +177 -0
  39. package/src/asyncRender.js +21 -0
  40. package/src/cache.js +41 -0
  41. package/src/context.js +122 -0
  42. package/src/context_processors.js +41 -0
  43. package/src/esm.mjs +72 -0
  44. package/src/filters.js +527 -0
  45. package/src/i18n.js +171 -0
  46. package/src/index.js +454 -0
  47. package/src/lexer.js +92 -0
  48. package/src/libraries.js +240 -0
  49. package/src/parser.js +250 -0
  50. package/src/security.js +51 -0
  51. package/src/tags/control.js +591 -0
  52. package/src/tags/helpers.js +27 -0
  53. package/src/tags/i18n.js +230 -0
  54. package/src/tags/inheritance.js +216 -0
  55. package/src/tags/registry.js +18 -0
  56. package/src/tags/util.js +322 -0
  57. package/src/types.d.ts +107 -0
  58. package/syntaxes/language-configuration.json +26 -0
  59. package/syntaxes/miki-template.tmLanguage.json +146 -0
  60. package/tests/asyncRender.test.js +17 -0
  61. package/tests/base.html +6 -0
  62. package/tests/child.html +3 -0
  63. package/tests/context_processors.test.js +13 -0
  64. package/tests/esm.test.mjs +26 -0
  65. package/tests/filters.test.js +99 -0
  66. package/tests/include_security.test.js +9 -0
  67. package/tests/lexer.test.js +45 -0
  68. package/tests/parser.test.js +55 -0
  69. package/tests/partial.html +1 -0
  70. package/tests/partialdef.test.js +40 -0
  71. package/tests/production_checks.js +57 -0
  72. package/tests/security.test.js +28 -0
  73. package/tests/tags.test.js +203 -0
@@ -0,0 +1,591 @@
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) {
286
+ this.mappings = mappings;
287
+ this.body = body;
288
+ }
289
+
290
+ render(context) {
291
+ const scope = {};
292
+ for (const mapping of this.mappings) {
293
+ scope[mapping.name] = context.get(mapping.valPath);
294
+ }
295
+ context.push(scope);
296
+ const result = this.body.map(n => n.render(context)).join('');
297
+ context.pop();
298
+ return result;
299
+ }
300
+ }
301
+
302
+ class CycleNode {
303
+ constructor(args, asName) {
304
+ this.args = args;
305
+ this.asName = asName;
306
+ }
307
+
308
+ render(context) {
309
+ const key = this.args.join(',');
310
+ let idx = context.cycleStates.get(key) || 0;
311
+ const token = this.args[idx % this.args.length];
312
+ const val = resolveValue(token, context);
313
+ context.cycleStates.set(key, idx + 1);
314
+
315
+ if (this.asName) {
316
+ context.scopes[0][this.asName] = val;
317
+ }
318
+ return String(val);
319
+ }
320
+ }
321
+
322
+ class FirstofNode {
323
+ constructor(args) {
324
+ this.args = args;
325
+ }
326
+
327
+ render(context) {
328
+ for (const arg of this.args) {
329
+ const val = resolveValue(arg, context);
330
+ if (val && val !== '' && val !== null && val !== undefined) {
331
+ return String(val);
332
+ }
333
+ }
334
+ return '';
335
+ }
336
+ }
337
+
338
+ class CommentNode {
339
+ constructor(body) {
340
+ this.body = body;
341
+ }
342
+
343
+ render(_context) {
344
+ return '';
345
+ }
346
+ }
347
+
348
+ class AutoescapeNode {
349
+ constructor(setting, body) {
350
+ this.setting = setting;
351
+ this.body = body;
352
+ }
353
+
354
+ render(context) {
355
+ const oldEscape = context.autoescape;
356
+ context.autoescape = this.setting === 'on';
357
+ const output = this.body.map(n => n.render(context)).join('');
358
+ context.autoescape = oldEscape;
359
+ return output;
360
+ }
361
+ }
362
+
363
+ /* --- Partial definition support --- */
364
+
365
+ class PartialDefNode {
366
+ constructor(name, body, inline = false) {
367
+ this.name = name;
368
+ this.body = body;
369
+ this.inline = inline;
370
+ }
371
+
372
+ render(context) {
373
+ context.registerPartial(this.name, this);
374
+ if (this.inline) {
375
+ return this.body.map(n => n.render(context)).join('');
376
+ }
377
+ return '';
378
+ }
379
+ }
380
+
381
+ class PartialNode {
382
+ constructor(name) {
383
+ this.name = name;
384
+ }
385
+
386
+ render(context) {
387
+ const partial = context.getPartial(this.name);
388
+ if (!partial) {
389
+ throw new Error(`Partial '${this.name}' not found`);
390
+ }
391
+ return partial.body.map(n => n.render(context)).join('');
392
+ }
393
+ }
394
+
395
+ function parsePartialDef(tagContent, parser) {
396
+ const content = tagContent.slice(10).trim();
397
+ const nameMatch = content.match(/^(".*?"|'.*?'|\S+)/);
398
+ if (!nameMatch) {
399
+ throw new Error('partialdef tag requires a name');
400
+ }
401
+ let name = nameMatch[1];
402
+ if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith('\'') && name.endsWith('\''))) {
403
+ name = name.slice(1, -1);
404
+ }
405
+ const inline = content.includes('inline');
406
+ const body = parser.parse(['endpartialdef']);
407
+ const next = parser.peek();
408
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endpartialdef') {
409
+ parser.advance();
410
+ }
411
+ return new PartialDefNode(name, body, inline);
412
+ }
413
+
414
+ function parsePartial(tagContent, _parser) {
415
+ const content = tagContent.slice(7).trim();
416
+ const nameMatch = content.match(/^(".*?"|'.*?'|\S+)/);
417
+ if (!nameMatch) {
418
+ throw new Error('partial tag requires a name');
419
+ }
420
+ let name = nameMatch[1];
421
+ if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith('\'') && name.endsWith('\''))) {
422
+ name = name.slice(1, -1);
423
+ }
424
+ return new PartialNode(name);
425
+ }
426
+
427
+ /* --- Tag Registry Parsers --- */
428
+
429
+ function parseAutoescape(tagContent, parser) {
430
+ const setting = tagContent.slice(10).trim();
431
+ if (setting !== 'on' && setting !== 'off') {
432
+ throw new Error(`Invalid autoescape setting: '${setting}'`);
433
+ }
434
+ const body = parser.parse(['endautoescape']);
435
+ const next = parser.peek();
436
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endautoescape') {
437
+ parser.advance();
438
+ }
439
+ return new AutoescapeNode(setting, body);
440
+ }
441
+
442
+ function parseComment(tagContent, parser) {
443
+ const body = parser.parse(['endcomment']);
444
+ const next = parser.peek();
445
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endcomment') {
446
+ parser.advance();
447
+ }
448
+ return new CommentNode(body);
449
+ }
450
+
451
+ function parseIf(tagContent, parser) {
452
+ const conditionStr = tagContent.slice(2).trim();
453
+ const body = parser.parse(['elif', 'else', 'endif']);
454
+ const elifBranches = [];
455
+ let elseBody = null;
456
+
457
+ while (true) {
458
+ const next = parser.peek();
459
+ if (!next || next.type !== 'block') break;
460
+ const tagName = next.content.split(/\s+/)[0];
461
+ if (tagName === 'elif') {
462
+ parser.advance();
463
+ const branchCond = next.content.slice(4).trim();
464
+ const branchBody = parser.parse(['elif', 'else', 'endif']);
465
+ elifBranches.push({ conditionStr: branchCond, body: branchBody });
466
+ } else if (tagName === 'else') {
467
+ parser.advance();
468
+ elseBody = parser.parse(['endif']);
469
+ } else if (tagName === 'endif') {
470
+ parser.advance();
471
+ break;
472
+ } else {
473
+ break;
474
+ }
475
+ }
476
+
477
+ // Validate that the if block was properly closed
478
+ // (for/with/endfor etc already consume their own closing tags)
479
+ // Note: parseIf is called via parser.parse() which already stopped at elif/else/endif
480
+ // If we reach here and the next block is not an endif, the if is unclosed
481
+
482
+ return new IfNode(conditionStr, body, elifBranches, elseBody);
483
+ }
484
+
485
+ function parseFor(tagContent, parser) {
486
+ const match = tagContent.match(/^for\s+(.+?)\s+in\s+(.+)$/);
487
+ if (!match) {
488
+ throw new Error(`Invalid for tag format: '${tagContent}'`);
489
+ }
490
+ const loopVars = match[1].split(',').map(s => s.trim()).filter(Boolean);
491
+ const iterableExpr = match[2].trim();
492
+ const body = parser.parse(['empty', 'endfor']);
493
+ let emptyBody = null;
494
+
495
+ let next = parser.peek();
496
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'empty') {
497
+ parser.advance();
498
+ emptyBody = parser.parse(['endfor']);
499
+ }
500
+
501
+ next = parser.peek();
502
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endfor') {
503
+ parser.advance();
504
+ }
505
+
506
+ // Parse the iterable expression to support filters: items|regroup:"category"
507
+ const parsedIterable = parseVariableExpression(iterableExpr);
508
+ return new ForNode(loopVars, parsedIterable.varPath, body, emptyBody, parsedIterable.filters);
509
+ }
510
+
511
+ function parseWith(tagContent, parser) {
512
+ const mappings = [];
513
+ const content = tagContent.slice(4).trim();
514
+
515
+ if (content.includes(' as ')) {
516
+ const asIdx = content.indexOf(' as ');
517
+ const valPath = content.slice(0, asIdx).trim();
518
+ const name = content.slice(asIdx + 4).trim();
519
+ mappings.push({ name, valPath });
520
+ } else {
521
+ // Parse key=value pairs, handling quoted values
522
+ const tokens = content.match(/(?:[a-zA-Z_][a-zA-Z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+))+/g) || [];
523
+ for (const token of tokens) {
524
+ const eqIdx = token.indexOf('=');
525
+ if (eqIdx === -1) continue;
526
+ const name = token.slice(0, eqIdx).trim();
527
+ let valPath = token.slice(eqIdx + 1).trim();
528
+ if ((valPath.startsWith('"') && valPath.endsWith('"')) || (valPath.startsWith('\'') && valPath.endsWith('\''))) {
529
+ valPath = valPath.slice(1, -1);
530
+ }
531
+ mappings.push({ name, valPath });
532
+ }
533
+ }
534
+
535
+ const body = parser.parse(['endwith']);
536
+ const next = parser.peek();
537
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endwith') {
538
+ parser.advance();
539
+ }
540
+
541
+ return new WithNode(mappings, body);
542
+ }
543
+
544
+ function parseCycle(tagContent, _parser) {
545
+ const content = tagContent.slice(5).trim();
546
+ const argRegex = /(".*?"|'.*?'|[^\s]+)/g;
547
+ const matches = content.match(argRegex) || [];
548
+ let asName = null;
549
+ const args = [];
550
+
551
+ for (let i = 0; i < matches.length; i++) {
552
+ if (matches[i] === 'as' && i < matches.length - 1) {
553
+ asName = matches[i + 1];
554
+ break;
555
+ }
556
+ args.push(matches[i]);
557
+ }
558
+
559
+ return new CycleNode(args, asName);
560
+ }
561
+
562
+ function parseFirstof(tagContent, _parser) {
563
+ const content = tagContent.slice(8).trim();
564
+ const argRegex = /(".*?"|'.*?'|[^\s]+)/g;
565
+ const args = (content.match(argRegex) || []).map(a => a.trim()).filter(Boolean);
566
+ return new FirstofNode(args);
567
+ }
568
+
569
+ module.exports = {
570
+ IfNode,
571
+ ForNode,
572
+ WithNode,
573
+ CycleNode,
574
+ FirstofNode,
575
+ CommentNode,
576
+ AutoescapeNode,
577
+ PartialDefNode,
578
+ PartialNode,
579
+ evaluateCondition,
580
+ parsers: {
581
+ if: parseIf,
582
+ for: parseFor,
583
+ with: parseWith,
584
+ cycle: parseCycle,
585
+ autoescape: parseAutoescape,
586
+ comment: parseComment,
587
+ partialdef: parsePartialDef,
588
+ partial: parsePartial,
589
+ firstof: parseFirstof
590
+ }
591
+ };
@@ -0,0 +1,27 @@
1
+ // Custom Tag Helpers module
2
+
3
+ const { registerTag } = require('../tags/registry');
4
+
5
+ class HelperNode {
6
+ constructor(name, fn, body) {
7
+ this.name = name;
8
+ this.fn = fn;
9
+ this.body = body;
10
+ }
11
+
12
+ render(context) {
13
+ const inner = this.body.map(n => n.render(context)).join('');
14
+ const result = this.fn(inner, context);
15
+ return result instanceof Promise ? result : result;
16
+ }
17
+ }
18
+
19
+ function registerHelper(name, fn) {
20
+ const parserFn = (tagContent, parser) => {
21
+ const body = parser.parseUntilTag(`end${name}`);
22
+ return new HelperNode(name, fn, body);
23
+ };
24
+ registerTag(name, parserFn);
25
+ }
26
+
27
+ module.exports = { registerHelper };