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.
Files changed (60) hide show
  1. package/.github/release-notes/v1.3.1.md +55 -0
  2. package/CHANGELOG.md +72 -0
  3. package/README.md +43 -26
  4. package/assets/banner.png +0 -0
  5. package/benchmarks/stress.mjs +647 -0
  6. package/dir/base.html +23 -0
  7. package/dir/cmpnt.html +11 -0
  8. package/dir/footer.html +3 -0
  9. package/dir/home.html +80 -0
  10. package/dir/navbar.html +9 -0
  11. package/docs/api.md +20 -3
  12. package/docs/filters.md +301 -133
  13. package/docs/partialdef.md +30 -1
  14. package/docs/tags.md +63 -0
  15. package/docs/usage.md +50 -3
  16. package/eslint.config.mjs +9 -1
  17. package/ex.mjs +33 -0
  18. package/miki-template-extension/.github/workflows/ci.yml +116 -0
  19. package/miki-template-extension/.vscodeignore +7 -0
  20. package/miki-template-extension/CHANGELOG.md +99 -0
  21. package/miki-template-extension/README.md +244 -53
  22. package/miki-template-extension/extension.js +1013 -0
  23. package/miki-template-extension/icon.png +0 -0
  24. package/miki-template-extension/miki-template-1.7.1.vsix +0 -0
  25. package/miki-template-extension/package.json +244 -10
  26. package/miki-template-extension/snippets/miki-template.json +612 -72
  27. package/miki-template-extension/syntaxes/language-configuration.json +101 -13
  28. package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +270 -61
  29. package/miki-template-extension/tests/grammar-tests.json +162 -0
  30. package/miki-template-extension/tests/run-grammar-tests.js +82 -0
  31. package/package.json +7 -4
  32. package/scripts/build-vsix.js +129 -0
  33. package/scripts/build-vsix.ps1 +15 -0
  34. package/src/cache.js +41 -2
  35. package/src/context.js +9 -5
  36. package/src/context_processors.js +9 -2
  37. package/src/esm.mjs +12 -0
  38. package/src/filters.js +472 -24
  39. package/src/index.js +571 -85
  40. package/src/lexer.js +76 -54
  41. package/src/libraries.js +134 -3
  42. package/src/parser.js +22 -2
  43. package/src/security.js +4 -2
  44. package/src/tags/control.js +150 -21
  45. package/src/tags/extra.js +154 -0
  46. package/src/tags/i18n.js +49 -23
  47. package/src/tags/inheritance.js +142 -23
  48. package/src/tags/util.js +102 -24
  49. package/tests/esm.test.mjs +37 -2
  50. package/tests/filters.test.js +155 -0
  51. package/tests/integration/README.md +32 -0
  52. package/tests/integration/features.test.cjs +1681 -0
  53. package/tests/integration/features.test.mjs +1697 -0
  54. package/tests/integration/templates/base.miki +6 -0
  55. package/tests/integration/templates/child.miki +6 -0
  56. package/tests/integration/templates/index.html +17 -0
  57. package/tests/parser.test.js +5 -3
  58. package/tests/partialdef.test.js +40 -1
  59. package/tests/tags.test.js +30 -0
  60. 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
- for (let i = 0; i < parts.length; i++) {
27
- const part = parts[i];
28
- if (part === undefined || part === null) continue;
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
- // Handle verbatim mode
31
- if (inVerbatim) {
32
- if (part.startsWith('{%') && part.endsWith('%}') && part.slice(2, -2).trim() === 'endverbatim') {
33
- inVerbatim = false;
34
- if (verbatimBuffer.length > 0) {
35
- tokens.push({
36
- type: 'text',
37
- content: verbatimBuffer.join('')
38
- });
39
- verbatimBuffer = [];
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
- } else {
42
- verbatimBuffer.push(part);
83
+ i = j + closer.length;
84
+ continue;
43
85
  }
44
- continue;
45
86
  }
46
87
 
47
- if (part.startsWith('{#') && part.endsWith('#}')) {
48
- // Comments are ignored in the token output
49
- continue;
50
- } else if (part.startsWith('{{') && part.endsWith('}}')) {
51
- const content = part.slice(2, -2).trim();
52
- tokens.push({
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
- const { registerTag, registerFilter, registerHelper } = require('./index');
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
 
@@ -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
- scope[mapping.name] = context.get(mapping.valPath);
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
- const inline = content.includes('inline');
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
- return new PartialNode(name);
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 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 });
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) {