miki-template 1.2.0 → 1.3.3
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 -0
- package/CHANGELOG.md +72 -0
- package/README.md +43 -26
- package/assets/banner.png +0 -0
- package/benchmarks/stress.mjs +647 -0
- package/dir/base.html +23 -0
- package/dir/cmpnt.html +11 -0
- package/dir/footer.html +3 -0
- package/dir/home.html +80 -0
- package/dir/navbar.html +9 -0
- package/docs/api.md +20 -3
- package/docs/filters.md +301 -133
- package/docs/partialdef.md +30 -1
- package/docs/tags.md +63 -0
- package/docs/usage.md +50 -3
- package/eslint.config.mjs +9 -1
- package/ex.mjs +33 -0
- package/miki-template-extension/.github/workflows/ci.yml +116 -0
- package/miki-template-extension/.vscodeignore +7 -0
- package/miki-template-extension/CHANGELOG.md +99 -0
- package/miki-template-extension/README.md +244 -53
- package/miki-template-extension/extension.js +1013 -0
- package/miki-template-extension/icon.png +0 -0
- package/miki-template-extension/miki-template-1.7.1.vsix +0 -0
- package/miki-template-extension/package.json +244 -10
- package/miki-template-extension/snippets/miki-template.json +612 -72
- package/miki-template-extension/syntaxes/language-configuration.json +101 -13
- package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +270 -61
- package/miki-template-extension/tests/grammar-tests.json +162 -0
- package/miki-template-extension/tests/run-grammar-tests.js +82 -0
- package/package.json +7 -4
- package/scripts/build-vsix.js +129 -0
- package/scripts/build-vsix.ps1 +15 -0
- package/src/cache.js +41 -2
- package/src/context.js +9 -5
- package/src/context_processors.js +9 -2
- package/src/esm.mjs +12 -0
- package/src/filters.js +472 -24
- package/src/index.js +571 -85
- package/src/lexer.js +76 -54
- package/src/libraries.js +134 -3
- package/src/parser.js +22 -2
- package/src/security.js +4 -2
- package/src/tags/control.js +150 -21
- package/src/tags/extra.js +154 -0
- package/src/tags/i18n.js +49 -23
- package/src/tags/inheritance.js +142 -23
- package/src/tags/util.js +102 -24
- package/tests/esm.test.mjs +37 -2
- package/tests/filters.test.js +155 -0
- package/tests/integration/README.md +32 -0
- package/tests/integration/features.test.cjs +1681 -0
- package/tests/integration/features.test.mjs +1697 -0
- package/tests/integration/templates/base.miki +6 -0
- package/tests/integration/templates/child.miki +6 -0
- package/tests/integration/templates/index.html +17 -0
- package/tests/parser.test.js +5 -3
- package/tests/partialdef.test.js +40 -1
- package/tests/tags.test.js +30 -0
- package/miki-template-1.2.0.vsix +0 -0
package/src/lexer.js
CHANGED
|
@@ -14,74 +14,96 @@ function tokenize(template) {
|
|
|
14
14
|
throw new TypeError('Template must be a string');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
// Regex to split by delimiters: {{ ... }}, {% ... %}, and {# ... #}
|
|
18
|
-
// Using 's' flag to match dot as newline (dotAll)
|
|
19
|
-
const tagRegexp = /(\{\{.*?\}\}|\{\%.*?\%\}|\{\#.*?\#\})/gs;
|
|
20
|
-
const parts = template.split(tagRegexp);
|
|
21
17
|
const tokens = [];
|
|
22
|
-
|
|
18
|
+
let i = 0;
|
|
19
|
+
const len = template.length;
|
|
23
20
|
let inVerbatim = false;
|
|
24
21
|
let verbatimBuffer = [];
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
23
|
+
while (i < len) {
|
|
24
|
+
// Detect the start of a variable, block, or comment.
|
|
25
|
+
if (template[i] === '{' && i + 1 < len) {
|
|
26
|
+
const next = template[i + 1];
|
|
27
|
+
if (next === '{' || next === '%' || next === '#') {
|
|
28
|
+
// Find the matching closer, accounting for nested {{ }} and {% %}
|
|
29
|
+
// so expressions like {{ x|default:"{{ y }}" }} parse correctly.
|
|
30
|
+
const isVar = next === '{';
|
|
31
|
+
const isBlock = next === '%';
|
|
32
|
+
const isComment = next === '#';
|
|
33
|
+
const closer = isVar ? '}}' : (isBlock ? '%}' : '#}');
|
|
34
|
+
let j = i + 2;
|
|
35
|
+
let depth = 1;
|
|
36
|
+
while (j < len && depth > 0) {
|
|
37
|
+
if (isVar && template[j] === '{' && template[j + 1] === '{') {
|
|
38
|
+
depth++;
|
|
39
|
+
j += 2;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (isBlock && template[j] === '{' && template[j + 1] === '%') {
|
|
43
|
+
depth++;
|
|
44
|
+
j += 2;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (template.substr(j, closer.length) === closer) {
|
|
48
|
+
depth--;
|
|
49
|
+
if (depth === 0) break;
|
|
50
|
+
j += closer.length;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
j++;
|
|
54
|
+
}
|
|
55
|
+
if (depth !== 0) {
|
|
56
|
+
throw new Error(`Unclosed template tag at position ${i}`);
|
|
57
|
+
}
|
|
58
|
+
const raw = template.slice(i, j + closer.length);
|
|
59
|
+
const inner = template.slice(i + 2, j).trim();
|
|
29
60
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
if (inVerbatim) {
|
|
62
|
+
if (isBlock && inner === 'endverbatim') {
|
|
63
|
+
inVerbatim = false;
|
|
64
|
+
if (verbatimBuffer.length > 0) {
|
|
65
|
+
tokens.push({ type: 'text', content: verbatimBuffer.join('') });
|
|
66
|
+
verbatimBuffer = [];
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
verbatimBuffer.push(raw);
|
|
70
|
+
}
|
|
71
|
+
} else if (isComment) {
|
|
72
|
+
// Comments are ignored
|
|
73
|
+
} else if (isVar) {
|
|
74
|
+
tokens.push({ type: 'var', content: inner, raw });
|
|
75
|
+
} else {
|
|
76
|
+
// Block tag
|
|
77
|
+
if (inner === 'verbatim') {
|
|
78
|
+
inVerbatim = true;
|
|
79
|
+
} else {
|
|
80
|
+
tokens.push({ type: 'block', content: inner, raw });
|
|
81
|
+
}
|
|
40
82
|
}
|
|
41
|
-
|
|
42
|
-
|
|
83
|
+
i = j + closer.length;
|
|
84
|
+
continue;
|
|
43
85
|
}
|
|
44
|
-
continue;
|
|
45
86
|
}
|
|
46
87
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
type: 'var',
|
|
54
|
-
content,
|
|
55
|
-
raw: part
|
|
56
|
-
});
|
|
57
|
-
} else if (part.startsWith('{%') && part.endsWith('%}')) {
|
|
58
|
-
const content = part.slice(2, -2).trim();
|
|
59
|
-
if (content === 'verbatim') {
|
|
60
|
-
inVerbatim = true;
|
|
61
|
-
} else {
|
|
62
|
-
tokens.push({
|
|
63
|
-
type: 'block',
|
|
64
|
-
content,
|
|
65
|
-
raw: part
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
} else {
|
|
69
|
-
// Do not push empty text tokens to keep AST clean
|
|
70
|
-
if (part !== '') {
|
|
71
|
-
tokens.push({
|
|
72
|
-
type: 'text',
|
|
73
|
-
content: part
|
|
74
|
-
});
|
|
88
|
+
// Plain text — collect until the next special delimiter.
|
|
89
|
+
let textStart = i;
|
|
90
|
+
while (i < len) {
|
|
91
|
+
if (template[i] === '{' && i + 1 < len) {
|
|
92
|
+
const nx = template[i + 1];
|
|
93
|
+
if (nx === '{' || nx === '%' || nx === '#') break;
|
|
75
94
|
}
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
const text = template.slice(textStart, i);
|
|
98
|
+
if (inVerbatim) {
|
|
99
|
+
verbatimBuffer.push(text);
|
|
100
|
+
} else if (text !== '') {
|
|
101
|
+
tokens.push({ type: 'text', content: text });
|
|
76
102
|
}
|
|
77
103
|
}
|
|
78
104
|
|
|
79
|
-
// If verbatim wasn't closed, treat the remaining buffer as text
|
|
80
105
|
if (inVerbatim && verbatimBuffer.length > 0) {
|
|
81
|
-
tokens.push({
|
|
82
|
-
type: 'text',
|
|
83
|
-
content: verbatimBuffer.join('')
|
|
84
|
-
});
|
|
106
|
+
tokens.push({ type: 'text', content: verbatimBuffer.join('') });
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
return tokens;
|
package/src/libraries.js
CHANGED
|
@@ -18,6 +18,13 @@ const path = require('path');
|
|
|
18
18
|
|
|
19
19
|
const libraries = new Map(); // Map<name, libraryDef>
|
|
20
20
|
|
|
21
|
+
// Registration functions are injected by index.js to break the
|
|
22
|
+
// circular dependency between libraries.js and index.js.
|
|
23
|
+
let registrationFunctions = null;
|
|
24
|
+
function setRegistrationFunctions(fns) {
|
|
25
|
+
registrationFunctions = fns;
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
/**
|
|
22
29
|
* Register a library.
|
|
23
30
|
*
|
|
@@ -92,7 +99,9 @@ function activateLibrary(name) {
|
|
|
92
99
|
if (!lib) {
|
|
93
100
|
throw new Error(`Library not found: '${name}'`);
|
|
94
101
|
}
|
|
95
|
-
|
|
102
|
+
// Require lazily; index.js will pass the real registration
|
|
103
|
+
// functions via setRegistrationFunctions() before calling us.
|
|
104
|
+
const { registerTag, registerFilter, registerHelper } = registrationFunctions || require('./index');
|
|
96
105
|
for (const [tagName, parserFn] of Object.entries(lib.tags || {})) {
|
|
97
106
|
registerTag(tagName, parserFn);
|
|
98
107
|
}
|
|
@@ -199,15 +208,15 @@ registerLibrary('cache', {
|
|
|
199
208
|
}
|
|
200
209
|
});
|
|
201
210
|
|
|
202
|
-
/** Lorem: generate placeholder text. */
|
|
211
|
+
/** Lorem: generate placeholder text (Django-compatible). */
|
|
203
212
|
registerLibrary('lorem', {
|
|
204
213
|
filters: {
|
|
205
214
|
lorem: (val, arg) => {
|
|
206
|
-
const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
|
|
207
215
|
let n = parseInt(arg, 10);
|
|
208
216
|
if (isNaN(n)) n = 5;
|
|
209
217
|
if (n < 1) n = 1;
|
|
210
218
|
if (n > 100) n = 100;
|
|
219
|
+
const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
|
|
211
220
|
const words = text.split(' ');
|
|
212
221
|
let result = [];
|
|
213
222
|
for (let i = 0; i < n; i++) {
|
|
@@ -215,6 +224,127 @@ registerLibrary('lorem', {
|
|
|
215
224
|
}
|
|
216
225
|
return result.join(' ');
|
|
217
226
|
}
|
|
227
|
+
},
|
|
228
|
+
tags: {
|
|
229
|
+
// Django-compatible lorem tag.
|
|
230
|
+
//
|
|
231
|
+
// {% lorem [count] [method] [random] %}
|
|
232
|
+
//
|
|
233
|
+
// count - number of words/paragraphs to generate (default 1)
|
|
234
|
+
// method - w (words), p (HTML paragraphs), b (plain-text blocks, default)
|
|
235
|
+
// random - skip the common "Lorem ipsum dolor sit amet..." opening
|
|
236
|
+
//
|
|
237
|
+
// {% lorem %} -> one block of common Lorem ipsum
|
|
238
|
+
// {% lorem 3 p %} -> common paragraph + 2 random <p> blocks
|
|
239
|
+
// {% lorem 2 w random %} -> 2 random words
|
|
240
|
+
//
|
|
241
|
+
// The tag is built into the engine and requires no {% load lorem %}.
|
|
242
|
+
lorem: (tagContent, _parser) => {
|
|
243
|
+
return {
|
|
244
|
+
render: () => {
|
|
245
|
+
// The tag registry calls the parser with the full content
|
|
246
|
+
// including the tag name. For "{% lorem 3 p random %}" the
|
|
247
|
+
// tagContent is "lorem 3 p random".
|
|
248
|
+
const parts = tagContent.trim().split(/\s+/);
|
|
249
|
+
|
|
250
|
+
// count = first numeric token (or 1 if none)
|
|
251
|
+
let n = 1;
|
|
252
|
+
for (let i = 0; i < parts.length; i++) {
|
|
253
|
+
const num = parseInt(parts[i], 10);
|
|
254
|
+
if (!isNaN(num) && String(num) === parts[i]) { n = Math.max(1, num); break; }
|
|
255
|
+
}
|
|
256
|
+
// method = w | b | p (default b, like Django)
|
|
257
|
+
let unit = 'b';
|
|
258
|
+
for (let i = 0; i < parts.length; i++) {
|
|
259
|
+
if (/^(w|b|p|words?|blocks?|paragraphs?)$/i.test(parts[i])) {
|
|
260
|
+
unit = parts[i][0].toLowerCase();
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// random = presence of the literal word "random"
|
|
265
|
+
const random = parts.some(t => t.toLowerCase() === 'random');
|
|
266
|
+
|
|
267
|
+
// The famous "common paragraph" Django uses as the seed.
|
|
268
|
+
const COMMON = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
|
|
269
|
+
// Word bank used to assemble random text when the "random"
|
|
270
|
+
// keyword is given. Picked from the same Latin vocabulary.
|
|
271
|
+
const WORDS = ('lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute irure in voluptate velit esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt culpa qui officia deserunt mollit anim id est laborum').split(' ');
|
|
272
|
+
|
|
273
|
+
// The non-random blocks cycle through a fixed set of
|
|
274
|
+
// sentences taken from the COMMON paragraph above (the
|
|
275
|
+
// first one is the "Lorem ipsum dolor sit amet" line).
|
|
276
|
+
const SENTENCES = [
|
|
277
|
+
'Lorem ipsum dolor sit amet, consectetur adipisicing elit.',
|
|
278
|
+
'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
|
279
|
+
'Ut enim ad minim veniam, quis nostrud exercitation.',
|
|
280
|
+
'Duis aute irure dolor in reprehenderit in voluptate velit.',
|
|
281
|
+
'Excepteur sint occaecat cupidatat non proident.'
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
if (unit === 'w') {
|
|
285
|
+
// Words
|
|
286
|
+
if (random) {
|
|
287
|
+
const out = [];
|
|
288
|
+
for (let i = 0; i < n; i++) {
|
|
289
|
+
out.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
|
290
|
+
}
|
|
291
|
+
return out.join(' ');
|
|
292
|
+
}
|
|
293
|
+
// Non-random: first word is always "lorem", the rest
|
|
294
|
+
// continue the common paragraph.
|
|
295
|
+
const commonWords = COMMON.replace(/[.,]/g, '').split(/\s+/);
|
|
296
|
+
const out = [];
|
|
297
|
+
for (let i = 0; i < n; i++) {
|
|
298
|
+
out.push(commonWords[i % commonWords.length]);
|
|
299
|
+
}
|
|
300
|
+
return out.join(' ');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (unit === 'p') {
|
|
304
|
+
// HTML paragraphs (each wrapped in <p>...</p>)
|
|
305
|
+
const out = [];
|
|
306
|
+
for (let i = 0; i < n; i++) {
|
|
307
|
+
let paragraph;
|
|
308
|
+
if (i === 0 && !random) {
|
|
309
|
+
paragraph = COMMON;
|
|
310
|
+
} else if (random) {
|
|
311
|
+
// Generate a sentence worth of random words
|
|
312
|
+
const len = 12 + Math.floor(Math.random() * 16);
|
|
313
|
+
const words = [];
|
|
314
|
+
for (let j = 0; j < len; j++) {
|
|
315
|
+
words.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
|
316
|
+
}
|
|
317
|
+
paragraph = words.join(' ') + '.';
|
|
318
|
+
} else {
|
|
319
|
+
paragraph = SENTENCES[(i - 1) % (SENTENCES.length - 1) + 1];
|
|
320
|
+
}
|
|
321
|
+
out.push('<p>' + paragraph + '</p>');
|
|
322
|
+
}
|
|
323
|
+
return out.join('\n');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// unit === 'b' (default): plain-text blocks separated by blank lines
|
|
327
|
+
const out = [];
|
|
328
|
+
for (let i = 0; i < n; i++) {
|
|
329
|
+
let paragraph;
|
|
330
|
+
if (i === 0 && !random) {
|
|
331
|
+
paragraph = COMMON;
|
|
332
|
+
} else if (random) {
|
|
333
|
+
const len = 30 + Math.floor(Math.random() * 20);
|
|
334
|
+
const words = [];
|
|
335
|
+
for (let j = 0; j < len; j++) {
|
|
336
|
+
words.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
|
337
|
+
}
|
|
338
|
+
paragraph = words.join(' ') + '.';
|
|
339
|
+
} else {
|
|
340
|
+
paragraph = SENTENCES[(i - 1) % (SENTENCES.length - 1) + 1];
|
|
341
|
+
}
|
|
342
|
+
out.push(paragraph);
|
|
343
|
+
}
|
|
344
|
+
return out.join('\n\n');
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
218
348
|
}
|
|
219
349
|
});
|
|
220
350
|
|
|
@@ -236,5 +366,6 @@ module.exports = {
|
|
|
236
366
|
hasLibrary,
|
|
237
367
|
registerLibraryFromPath,
|
|
238
368
|
activateLibrary,
|
|
369
|
+
setRegistrationFunctions,
|
|
239
370
|
libraries
|
|
240
371
|
};
|
package/src/parser.js
CHANGED
|
@@ -40,7 +40,7 @@ class VariableNode {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
val = filterFn(val, argVal);
|
|
43
|
+
val = filterFn(val, argVal, context);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// Auto-escape logic – skip escaping if value is marked safe
|
|
@@ -123,6 +123,26 @@ function parseVariableExpression(expr) {
|
|
|
123
123
|
idx++; // Skip opening quote
|
|
124
124
|
let argVal = '';
|
|
125
125
|
while (idx < len && expr[idx] !== quote) {
|
|
126
|
+
// Track nested {{ ... }} and {% ... %} so the closing
|
|
127
|
+
// quote isn't confused with one inside a nested template
|
|
128
|
+
// expression. This lets us write
|
|
129
|
+
// {{ x|default:"{{ y|upper }}" }}
|
|
130
|
+
// where the inner `}}` must not close the outer string.
|
|
131
|
+
if (expr[idx] === '{' && idx + 1 < len && (expr[idx + 1] === '{' || expr[idx + 1] === '%')) {
|
|
132
|
+
const open = expr.substr(idx, 2);
|
|
133
|
+
const close = open === '{{' ? '}}' : '%}';
|
|
134
|
+
argVal += open;
|
|
135
|
+
idx += 2;
|
|
136
|
+
while (idx < len && expr.substr(idx, close.length) !== close) {
|
|
137
|
+
argVal += expr[idx];
|
|
138
|
+
idx++;
|
|
139
|
+
}
|
|
140
|
+
if (idx < len) {
|
|
141
|
+
argVal += close;
|
|
142
|
+
idx += close.length;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
126
146
|
if (expr[idx] === '\\') {
|
|
127
147
|
idx++;
|
|
128
148
|
}
|
|
@@ -131,7 +151,7 @@ function parseVariableExpression(expr) {
|
|
|
131
151
|
idx++;
|
|
132
152
|
}
|
|
133
153
|
}
|
|
134
|
-
idx++; // Skip closing quote
|
|
154
|
+
if (idx < len) idx++; // Skip closing quote
|
|
135
155
|
arg = { type: 'literal', value: argVal };
|
|
136
156
|
} else {
|
|
137
157
|
// Read unquoted token
|
package/src/security.js
CHANGED
|
@@ -32,9 +32,11 @@ function isSafe(value) {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Escape a string's HTML special characters unless it is marked safe.
|
|
35
|
+
* If `force` is true (used by the |escape filter), even a SafeString is
|
|
36
|
+
* re-escaped. This matches Django's `{{ value|escape }}` semantics.
|
|
35
37
|
*/
|
|
36
|
-
function escapeHtml(value) {
|
|
37
|
-
if (value instanceof SafeString) {
|
|
38
|
+
function escapeHtml(value, force = false) {
|
|
39
|
+
if (value instanceof SafeString && !force) {
|
|
38
40
|
return value.toString();
|
|
39
41
|
}
|
|
40
42
|
|
package/src/tags/control.js
CHANGED
|
@@ -282,15 +282,40 @@ class ForNode {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
class WithNode {
|
|
285
|
-
constructor(mappings, body) {
|
|
285
|
+
constructor(mappings, body, aliasName = null) {
|
|
286
286
|
this.mappings = mappings;
|
|
287
287
|
this.body = body;
|
|
288
|
+
this.aliasName = aliasName;
|
|
288
289
|
}
|
|
289
290
|
|
|
290
291
|
render(context) {
|
|
291
292
|
const scope = {};
|
|
292
293
|
for (const mapping of this.mappings) {
|
|
293
|
-
|
|
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];
|
|
294
319
|
}
|
|
295
320
|
context.push(scope);
|
|
296
321
|
const result = this.body.map(n => n.render(context)).join('');
|
|
@@ -314,6 +339,9 @@ class CycleNode {
|
|
|
314
339
|
|
|
315
340
|
if (this.asName) {
|
|
316
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 '';
|
|
317
345
|
}
|
|
318
346
|
return String(val);
|
|
319
347
|
}
|
|
@@ -379,8 +407,9 @@ class PartialDefNode {
|
|
|
379
407
|
}
|
|
380
408
|
|
|
381
409
|
class PartialNode {
|
|
382
|
-
constructor(name) {
|
|
410
|
+
constructor(name, extraMappings = []) {
|
|
383
411
|
this.name = name;
|
|
412
|
+
this.extraMappings = extraMappings;
|
|
384
413
|
}
|
|
385
414
|
|
|
386
415
|
render(context) {
|
|
@@ -388,6 +417,34 @@ class PartialNode {
|
|
|
388
417
|
if (!partial) {
|
|
389
418
|
throw new Error(`Partial '${this.name}' not found`);
|
|
390
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
|
+
}
|
|
391
448
|
return partial.body.map(n => n.render(context)).join('');
|
|
392
449
|
}
|
|
393
450
|
}
|
|
@@ -402,7 +459,8 @@ function parsePartialDef(tagContent, parser) {
|
|
|
402
459
|
if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith('\'') && name.endsWith('\''))) {
|
|
403
460
|
name = name.slice(1, -1);
|
|
404
461
|
}
|
|
405
|
-
|
|
462
|
+
// Recognize `inline` or `inline=true` as a keyword (case-insensitive)
|
|
463
|
+
const inline = /\binline\b/.test(content);
|
|
406
464
|
const body = parser.parse(['endpartialdef']);
|
|
407
465
|
const next = parser.peek();
|
|
408
466
|
if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endpartialdef') {
|
|
@@ -412,6 +470,8 @@ function parsePartialDef(tagContent, parser) {
|
|
|
412
470
|
}
|
|
413
471
|
|
|
414
472
|
function parsePartial(tagContent, _parser) {
|
|
473
|
+
// {% partial "name" %}
|
|
474
|
+
// {% partial "name" with k1=v1 k2=v2 %}
|
|
415
475
|
const content = tagContent.slice(7).trim();
|
|
416
476
|
const nameMatch = content.match(/^(".*?"|'.*?'|\S+)/);
|
|
417
477
|
if (!nameMatch) {
|
|
@@ -421,7 +481,16 @@ function parsePartial(tagContent, _parser) {
|
|
|
421
481
|
if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith('\'') && name.endsWith('\''))) {
|
|
422
482
|
name = name.slice(1, -1);
|
|
423
483
|
}
|
|
424
|
-
|
|
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);
|
|
425
494
|
}
|
|
426
495
|
|
|
427
496
|
/* --- Tag Registry Parsers --- */
|
|
@@ -511,25 +580,30 @@ function parseFor(tagContent, parser) {
|
|
|
511
580
|
function parseWith(tagContent, parser) {
|
|
512
581
|
const mappings = [];
|
|
513
582
|
const content = tagContent.slice(4).trim();
|
|
583
|
+
let aliasName = null;
|
|
514
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)
|
|
515
590
|
if (content.includes(' as ')) {
|
|
516
591
|
const asIdx = content.indexOf(' as ');
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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 });
|
|
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
|
|
532
602
|
}
|
|
603
|
+
} else {
|
|
604
|
+
// Form 2: key=value pairs only
|
|
605
|
+
const pairMappings = parseKeyValuePairs(content);
|
|
606
|
+
mappings.push(...pairMappings);
|
|
533
607
|
}
|
|
534
608
|
|
|
535
609
|
const body = parser.parse(['endwith']);
|
|
@@ -538,7 +612,62 @@ function parseWith(tagContent, parser) {
|
|
|
538
612
|
parser.advance();
|
|
539
613
|
}
|
|
540
614
|
|
|
541
|
-
return new WithNode(mappings, body);
|
|
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;
|
|
542
671
|
}
|
|
543
672
|
|
|
544
673
|
function parseCycle(tagContent, _parser) {
|