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.
- package/.github/release-notes/v1.3.1.md +55 -55
- package/.github/release-notes/v1.3.3.md +77 -0
- package/.github/workflows/ci.yml +38 -54
- package/.github/workflows/release.yml +106 -0
- package/AGENT.md +71 -71
- package/API_REFERENCE.md +314 -314
- package/CHANGELOG.md +173 -169
- package/CODE_OF_CONDUCT.md +14 -14
- package/CONTRIBUTING.md +27 -27
- package/README.md +342 -321
- package/ROADMAP.md +40 -40
- package/benchmarks/report.json +16 -16
- package/benchmarks/run.js +49 -49
- package/benchmarks/stress.mjs +647 -647
- package/benchmarks/templates/large.dtpl +7 -7
- package/benchmarks/templates/medium.dtpl +3 -3
- package/benchmarks/templates/small.dtpl +7 -7
- package/context/component.md +109 -109
- package/context/prd.md +131 -131
- package/context/project-structure.md +33 -33
- package/dir/base.html +22 -22
- package/dir/cmpnt.html +10 -10
- package/dir/footer.html +2 -2
- package/dir/home.html +80 -80
- package/dir/index.html +80 -0
- package/dir/navbar.html +8 -8
- package/docs/README.md +18 -18
- package/docs/advanced_usage.md +71 -71
- package/docs/api.md +119 -119
- package/docs/filters.md +708 -708
- package/docs/installation.md +106 -106
- package/docs/overview.md +57 -57
- package/docs/partialdef.md +70 -70
- package/docs/security.md +27 -27
- package/docs/tags.md +673 -673
- package/docs/usage.md +646 -646
- package/eslint.config.mjs +42 -42
- package/ex.mjs +32 -32
- package/miki-template-extension/.github/workflows/ci.yml +116 -116
- package/miki-template-extension/.vscodeignore +7 -7
- package/miki-template-extension/CHANGELOG.md +99 -99
- package/miki-template-extension/LICENSE +21 -21
- package/miki-template-extension/README.md +273 -273
- package/miki-template-extension/extension.js +1013 -1013
- package/miki-template-extension/icon.svg +10 -10
- package/miki-template-extension/package.json +280 -280
- package/miki-template-extension/snippets/miki-template.json +717 -717
- package/miki-template-extension/syntaxes/language-configuration.json +114 -114
- package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +355 -355
- package/miki-template-extension/tests/grammar-tests.json +162 -162
- package/miki-template-extension/tests/run-grammar-tests.js +82 -82
- package/package.json +37 -34
- package/sample-app/package-lock.json +901 -0
- package/sample-app/package.json +9 -0
- package/sample-app/server.js +14 -0
- package/sample-app/views/index.html +1 -0
- package/scripts/build-vsix.js +129 -129
- package/scripts/build-vsix.ps1 +15 -15
- package/snippets/miki-template.json +177 -177
- package/src/asyncRender.js +20 -20
- package/src/cache.js +80 -80
- package/src/context.js +126 -126
- package/src/context_processors.js +48 -48
- package/src/esm.mjs +84 -84
- package/src/filters.js +975 -975
- package/src/i18n.js +171 -171
- package/src/index.js +974 -940
- package/src/lexer.js +114 -114
- package/src/libraries.js +371 -371
- package/src/parser.js +270 -270
- package/src/security.js +53 -53
- package/src/tags/control.js +719 -719
- package/src/tags/extra.js +154 -154
- package/src/tags/helpers.js +26 -26
- package/src/tags/i18n.js +256 -256
- package/src/tags/inheritance.js +335 -335
- package/src/tags/registry.js +18 -18
- package/src/tags/util.js +400 -400
- package/src/types.d.ts +107 -107
- package/syntaxes/language-configuration.json +26 -26
- package/syntaxes/miki-template.tmLanguage.json +146 -146
- package/tests/asyncRender.test.js +17 -17
- package/tests/base.html +6 -6
- package/tests/child.html +3 -3
- package/tests/context_processors.test.js +13 -13
- package/tests/esm.test.mjs +61 -61
- package/tests/filters.test.js +254 -254
- package/tests/include_security.test.js +9 -9
- package/tests/integration/README.md +32 -32
- package/tests/integration/features.test.cjs +1681 -1681
- package/tests/integration/features.test.mjs +1697 -1697
- package/tests/integration/templates/base.miki +6 -6
- package/tests/integration/templates/child.miki +6 -6
- package/tests/integration/templates/index.html +17 -17
- package/tests/lexer.test.js +45 -45
- package/tests/parser.test.js +57 -57
- package/tests/partial.html +1 -1
- package/tests/partialdef.test.js +79 -79
- package/tests/production_checks.js +57 -57
- package/tests/security.test.js +28 -28
- package/tests/tags.test.js +233 -233
package/src/tags/control.js
CHANGED
|
@@ -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
|
};
|