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
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Additional utility template tags: now, set, ifchanged.
3
+ */
4
+
5
+ // Helper to resolve expression values (literals, numbers, booleans, or context lookups)
6
+ function resolveValue(token, context) {
7
+ if (token === undefined || token === null) return '';
8
+ if (typeof token !== 'string') return token;
9
+ if (token === '') return '';
10
+ if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith('\'') && token.endsWith('\''))) {
11
+ return token.slice(1, -1);
12
+ }
13
+ if (token === 'true' || token === 'True') return true;
14
+ if (token === 'false' || token === 'False') return false;
15
+ if (token === 'none' || token === 'None' || token === 'null') return null;
16
+ if (/^-?\d+(\.\d+)?$/.test(token)) return Number(token);
17
+ return context.get(token);
18
+ }
19
+
20
+ // --- {% now %} tag ---
21
+
22
+ class NowNode {
23
+ constructor(formatExpr) {
24
+ this.formatExpr = formatExpr;
25
+ }
26
+
27
+ render(context) {
28
+ const fmt = resolveValue(this.formatExpr, context);
29
+ const now = new Date();
30
+ if (!fmt) return now.toISOString();
31
+ const dateFilter = require('../filters');
32
+ return dateFilter.getFilter('date')(now, fmt);
33
+ }
34
+ }
35
+
36
+ function parseNow(tagContent, _parser) {
37
+ const fmt = tagContent.slice(3).trim();
38
+ return new NowNode(fmt || '"Y-m-d H:i:s"');
39
+ }
40
+
41
+ // --- {% set %} tag ---
42
+
43
+ class SetNode {
44
+ constructor(nameExpr, valueExpr, body) {
45
+ this.nameExpr = nameExpr;
46
+ this.valueExpr = valueExpr;
47
+ this.body = body || [];
48
+ }
49
+
50
+ render(context) {
51
+ const name = String(this.nameExpr);
52
+ const value = this.valueExpr !== undefined ? resolveValue(this.valueExpr, context) : '';
53
+ if (name) {
54
+ context.scopes[0][name] = value;
55
+ }
56
+ return this.body.map(n => n.render(context)).join('');
57
+ }
58
+ }
59
+
60
+ function parseSet(tagContent, parser) {
61
+ // {% set var = expr %}
62
+ // {% set var %}...{% endset %}
63
+ const trimmed = tagContent.slice(3).trim();
64
+
65
+ // Block form: {% set var %}...{% endset %}
66
+ if (!trimmed.includes('=')) {
67
+ const varName = trimmed.trim();
68
+ const body = parser.parse(['endset']);
69
+ const next = parser.peek();
70
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endset') {
71
+ parser.advance();
72
+ }
73
+ return new SetNode(varName, undefined, body);
74
+ }
75
+
76
+ // Inline form: {% set var = expr %}
77
+ const eqIdx = trimmed.indexOf('=');
78
+ const nameExpr = trimmed.slice(0, eqIdx).trim();
79
+ const valueExpr = trimmed.slice(eqIdx + 1).trim();
80
+ return new SetNode(nameExpr, valueExpr, []);
81
+ }
82
+
83
+ // --- {% ifchanged %} tag ---
84
+
85
+ class IfChangedNode {
86
+ constructor(conditionStr, body, elseBody) {
87
+ this.conditionStr = conditionStr;
88
+ this.body = body;
89
+ this.elseBody = elseBody;
90
+ }
91
+
92
+ render(context) {
93
+ const currentVal = this.conditionStr ? resolveValue(this.conditionStr, context) : undefined;
94
+
95
+ if (!context.ifChangedState) {
96
+ context.ifChangedState = new Map();
97
+ }
98
+
99
+ const lastVal = context.ifChangedState.get(this.conditionStr);
100
+
101
+ let changed = false;
102
+ if (lastVal === undefined) {
103
+ changed = true;
104
+ } else if (currentVal !== lastVal) {
105
+ changed = true;
106
+ }
107
+
108
+ if (changed) {
109
+ context.ifChangedState.set(this.conditionStr, currentVal);
110
+ return this.body.map(n => n.render(context)).join('');
111
+ }
112
+
113
+ if (this.elseBody) {
114
+ return this.elseBody.map(n => n.render(context)).join('');
115
+ }
116
+
117
+ return '';
118
+ }
119
+ }
120
+
121
+ function parseIfChanged(tagContent, parser) {
122
+ // {% ifchanged [value] %}
123
+ const conditionStr = tagContent.slice(9).trim();
124
+ const body = parser.parse(['else', 'endifchanged']);
125
+ let elseBody = null;
126
+
127
+ while (true) {
128
+ const next = parser.peek();
129
+ if (!next || next.type !== 'block') break;
130
+ const tagName = next.content.split(/\s+/)[0];
131
+ if (tagName === 'else') {
132
+ parser.advance();
133
+ elseBody = parser.parse(['endifchanged']);
134
+ } else if (tagName === 'endifchanged') {
135
+ parser.advance();
136
+ break;
137
+ } else {
138
+ break;
139
+ }
140
+ }
141
+
142
+ return new IfChangedNode(conditionStr, body, elseBody);
143
+ }
144
+
145
+ module.exports = {
146
+ NowNode,
147
+ SetNode,
148
+ IfChangedNode,
149
+ parsers: {
150
+ now: parseNow,
151
+ set: parseSet,
152
+ ifchanged: parseIfChanged
153
+ }
154
+ };
package/src/tags/i18n.js CHANGED
@@ -141,19 +141,57 @@ function parseLanguage(tagContent, parser) {
141
141
  }
142
142
 
143
143
  function parseBlockTrans(tagContent, parser) {
144
- // Collect body until {% endblocktrans %}
145
- const body = parser.parse(['blocktrans_internal_marker']); // placeholder
146
- // Actually we need a different approach: scan raw body and parse it ourselves
147
- // because blocktrans parses its own text content as a template.
148
- // We'll rewind and use the lexer on the body tokens.
149
- // For simplicity here, we re-parse the entire slice as a mini-template.
150
- // The body tokens between {% blocktrans %} and {% endblocktrans %} are still
151
- // available because parseUntil was called.
152
- // For now, treat the body as a list of nodes that we'll re-render:
144
+ // Collect body until {% endblocktrans %}. The parser will stop at the
145
+ // first matching end tag it encounters in its untilTags list.
146
+ const body = parser.parse(['endblocktrans']);
147
+ // Consume the endblocktrans token if it's still pending.
148
+ const next = parser.peek();
149
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endblocktrans') {
150
+ parser.advance();
151
+ }
152
+
153
+ // Parse `with name=value` and `count` from the blocktrans tag content.
154
+ // Format: {% blocktrans with name1=var1 name2=var2 count n %}...{% endblocktrans %}
155
+ const withMappings = [];
156
+ const pluralMappings = [];
157
+ const afterTrans = tagContent.replace(/^blocktrans\s*/, '').trim();
158
+ // Split on "with" / "count" boundaries, keeping the keywords
159
+ const tokens = afterTrans.match(/(?:"[^"]*"|'[^']*'|\S+)/g) || [];
160
+ let i = 0;
161
+ // Skip any leading context (e.g. "context")
162
+ if (tokens[i] === 'context') i += 2;
163
+ // Check for `with` or `count` after the main text
164
+ while (i < tokens.length) {
165
+ const tok = tokens[i];
166
+ if (tok === 'with') {
167
+ i++;
168
+ // Read name=var pairs until we hit end or another keyword
169
+ while (i < tokens.length && tokens[i].indexOf('=') > 0) {
170
+ const pair = tokens[i];
171
+ const eq = pair.indexOf('=');
172
+ const name = pair.slice(0, eq);
173
+ let val = pair.slice(eq + 1);
174
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith('\'') && val.endsWith('\''))) {
175
+ val = val.slice(1, -1);
176
+ }
177
+ withMappings.push({ name, valPath: val });
178
+ i++;
179
+ }
180
+ } else if (tok === 'count') {
181
+ i++;
182
+ // Next token is the count variable
183
+ if (i < tokens.length) {
184
+ pluralMappings.push({ name: 'count', valPath: tokens[i++] });
185
+ }
186
+ } else {
187
+ i++;
188
+ }
189
+ }
190
+
153
191
  return new BlockTransNode(
154
192
  extractTextAndVars(body),
155
- extractWithMappings(body),
156
- extractPluralMappings(body),
193
+ withMappings.length ? withMappings : extractWithMappings(body),
194
+ pluralMappings,
157
195
  body
158
196
  );
159
197
  }
@@ -186,18 +224,6 @@ function extractWithMappings(body) {
186
224
  return mappings;
187
225
  }
188
226
 
189
- function extractPluralMappings(body) {
190
- // Look for a custom PluralNode (we mark it via a special tag)
191
- // For now, treat WithNodes that have a count attribute as plural mappings.
192
- const mappings = [];
193
- for (const node of body) {
194
- if (node.constructor.name === 'PluralMappingNode') {
195
- mappings.push({ name: node.name, valPath: node.valPath });
196
- }
197
- }
198
- return mappings;
199
- }
200
-
201
227
  class PluralMappingNode {
202
228
  constructor(name, valPath) {
203
229
  this.name = name;
@@ -3,12 +3,20 @@
3
3
  */
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const { Context } = require('../context');
6
7
 
7
- // Helper to resolve expression values
8
+ // Helper to resolve expression values (literals, numbers, booleans, or context lookups)
8
9
  function resolveValue(token, context) {
10
+ if (token === undefined || token === null) return '';
11
+ if (typeof token !== 'string') return token;
12
+ if (token === '') return '';
9
13
  if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith('\'') && token.endsWith('\''))) {
10
14
  return token.slice(1, -1);
11
15
  }
16
+ if (token === 'true' || token === 'True') return true;
17
+ if (token === 'false' || token === 'False') return false;
18
+ if (token === 'none' || token === 'None' || token === 'null') return null;
19
+ if (/^-?\d+(\.\d+)?$/.test(token)) return Number(token);
12
20
  return context.get(token);
13
21
  }
14
22
 
@@ -83,14 +91,25 @@ class BlockNode {
83
91
  }
84
92
 
85
93
  class IncludeNode {
86
- constructor(templateNameExpr, extraMappings) {
94
+ constructor(templateNameExpr, extraMappings, partialName = null) {
87
95
  this.templateNameExpr = templateNameExpr;
88
96
  this.extraMappings = extraMappings; // Array of { name, valPath }
97
+ this.partialName = partialName; // When set, render only this partial
89
98
  }
90
99
 
91
100
  render(context) {
92
- const templateName = resolveValue(this.templateNameExpr, context);
93
- if (!templateName) return '';
101
+ const rawName = resolveValue(this.templateNameExpr, context);
102
+ if (!rawName) return '';
103
+
104
+ // Django-style partial selector: "home.html#card" means render
105
+ // only the `card` partial defined inside `home.html`.
106
+ let templateName = rawName;
107
+ let partialName = this.partialName;
108
+ const hashIdx = rawName.indexOf('#');
109
+ if (hashIdx >= 0) {
110
+ templateName = rawName.slice(0, hashIdx);
111
+ partialName = rawName.slice(hashIdx + 1);
112
+ }
94
113
 
95
114
  let viewsDirs = ['.'];
96
115
  if (context.options && context.options.settings && context.options.settings.views) {
@@ -115,11 +134,9 @@ class IncludeNode {
115
134
  loaded = true;
116
135
  break;
117
136
  } catch (e) {
118
- // Propagate traversal errors, otherwise try next directory
119
137
  if (e.message && e.message.startsWith('Include tag attempted path traversal')) {
120
138
  throw e;
121
139
  }
122
- // continue searching other directories
123
140
  }
124
141
  }
125
142
 
@@ -127,7 +144,9 @@ class IncludeNode {
127
144
  throw new Error(`Template not found: '${templateName}' in directories ${JSON.stringify(viewsDirs)}`);
128
145
  }
129
146
 
130
- // Parse and tokenize dynamically (uses lazy imports to break loops)
147
+ // Parse the included file. We use the same lexer/parser/registry
148
+ // as the outer compile, which means {% load %}, custom tags, and
149
+ // all partialdefs are registered when we parse the file.
131
150
  const { tokenize } = require('../lexer');
132
151
  const { Parser } = require('../parser');
133
152
  const { getTagRegistry } = require('./registry');
@@ -136,7 +155,46 @@ class IncludeNode {
136
155
  const parser = new Parser(tokens, getTagRegistry());
137
156
  const nodes = parser.parse();
138
157
 
139
- // Set up include scope
158
+ // If a partial selector was specified, compile the file so its
159
+ // partialdefs register themselves, then render only the named
160
+ // partial using the current context.
161
+ if (partialName) {
162
+ // Use a fresh context so partials from the included file do NOT
163
+ // leak into the caller's partial registry. Only the requested
164
+ // partial is rendered, and it can reference other partials from
165
+ // the same included file via the temp context's registry.
166
+ const tempContext = new Context({}, context.options || {});
167
+ nodes.map(n => n.render(tempContext));
168
+ const partial = tempContext.getPartial(partialName);
169
+ if (!partial) {
170
+ throw new Error(`Partial '${partialName}' not found in template '${templateName}'`);
171
+ }
172
+ // Build a render context that carries the caller's state but
173
+ // only the included file's partial definitions.
174
+ const renderCtx = new Context({}, context.options || {});
175
+ renderCtx.scopes = context.scopes.slice();
176
+ renderCtx.blocks = context.blocks;
177
+ renderCtx.cycleStates = context.cycleStates;
178
+ renderCtx.autoescape = context.autoescape;
179
+ renderCtx.parentTemplate = context.parentTemplate;
180
+ renderCtx.blockRenderIndices = context.blockRenderIndices;
181
+ renderCtx.partialDefs = tempContext.partialDefs;
182
+ // Apply include's `with` extra mappings on top of caller's ctx
183
+ if (this.extraMappings && this.extraMappings.length > 0) {
184
+ const extraScope = {};
185
+ for (const m of this.extraMappings) {
186
+ extraScope[m.name] = resolveValue(m.valPath, context);
187
+ }
188
+ renderCtx.push(extraScope);
189
+ const out = partial.body.map(n => n.render(renderCtx)).join('');
190
+ renderCtx.pop();
191
+ return out;
192
+ }
193
+ return partial.body.map(n => n.render(renderCtx)).join('');
194
+ }
195
+
196
+ // Plain include (no partial selector): render the included body
197
+ // inside the caller's context, applying any `with` extra mappings.
140
198
  if (this.extraMappings && this.extraMappings.length > 0) {
141
199
  const extraScope = {};
142
200
  for (const mapping of this.extraMappings) {
@@ -152,9 +210,16 @@ class IncludeNode {
152
210
  }
153
211
  }
154
212
 
155
- // --- Tag Registry Parsers ---
156
-
157
- // --- Tag Registry Parsers ---
213
+ module.exports = {
214
+ ExtendsNode,
215
+ BlockNode,
216
+ IncludeNode,
217
+ parsers: {
218
+ extends: parseExtends,
219
+ block: parseBlock,
220
+ include: parseInclude
221
+ }
222
+ };
158
223
 
159
224
  function parseExtends(tagContent, _parser) {
160
225
  // tagContent: "extends 'base.html'"
@@ -185,25 +250,79 @@ function parseBlock(tagContent, parser) {
185
250
  }
186
251
 
187
252
  function parseInclude(tagContent, _parser) {
188
- // tagContent: "include 'header.html'" or "include 'header.html' with val1=var1"
253
+ // tagContent: "include 'header.html'" or
254
+ // "include 'header.html' with val1=var1 val2='literal'"
255
+ // "include 'header.html#partial_name'"
189
256
  const content = tagContent.slice(8).trim();
190
-
191
- // Extract template expression
192
- const parts = content.split(/\s+with\s+/);
193
- const templateNameExpr = parts[0].trim();
194
-
195
- const extraMappings = [];
196
- if (parts[1]) {
197
- const pairRegex = /(\w+)=([^\s]+)/g;
198
- let match;
199
- while ((match = pairRegex.exec(parts[1])) !== null) {
200
- extraMappings.push({ name: match[1], valPath: match[2] });
257
+
258
+ // Extract template expression (may be quoted)
259
+ const tmplMatch = content.match(/^(".*?"|'.*?'|\S+)/);
260
+ if (!tmplMatch) {
261
+ throw new Error('include tag requires a template name');
262
+ }
263
+ const templateNameExpr = tmplMatch[1];
264
+ const rest = content.slice(tmplMatch[0].length).trim();
265
+
266
+ // Optional `with k1=v1 k2='v2' k3=v3`
267
+ let extraMappings = [];
268
+ if (rest) {
269
+ const withMatch = rest.match(/^with\s+(.+)$/s);
270
+ if (withMatch) {
271
+ extraMappings = parseKeyValuePairs(withMatch[1]);
201
272
  }
202
273
  }
203
274
 
204
275
  return new IncludeNode(templateNameExpr, extraMappings);
205
276
  }
206
277
 
278
+ /**
279
+ * Parse a whitespace-separated list of `key=value` pairs. Values may be
280
+ * quoted (single or double quotes) or bare identifiers. The
281
+ * control.js parseKeyValuePairs handles commas, so prefer that — but
282
+ * include's `with` historically used a whitespace separator. Support
283
+ * both for backwards compatibility.
284
+ */
285
+ function parseKeyValuePairs(str) {
286
+ const pairs = [];
287
+ let i = 0;
288
+ while (i < str.length) {
289
+ while (i < str.length && /\s/.test(str[i])) i++;
290
+ if (i >= str.length) break;
291
+
292
+ // Read key
293
+ let key = '';
294
+ while (i < str.length && /[a-zA-Z0-9_]/.test(str[i])) {
295
+ key += str[i++];
296
+ }
297
+ if (!key) { i++; continue; }
298
+ while (i < str.length && /\s/.test(str[i])) i++;
299
+ if (str[i] !== '=') { i++; continue; }
300
+ i++; // consume '='
301
+ while (i < str.length && /\s/.test(str[i])) i++;
302
+
303
+ let val = '';
304
+ if (str[i] === '"' || str[i] === '\'') {
305
+ const quote = str[i++];
306
+ while (i < str.length && str[i] !== quote) {
307
+ if (str[i] === '\\' && i + 1 < str.length) i++;
308
+ val += str[i++];
309
+ }
310
+ if (str[i] === quote) i++;
311
+ // Preserve quotes so resolveValue can recognize the literal
312
+ val = quote + val + quote;
313
+ } else {
314
+ // Unquoted value: read until comma or whitespace
315
+ while (i < str.length && str[i] !== ',' && !/\s/.test(str[i])) {
316
+ val += str[i++];
317
+ }
318
+ // Consume trailing comma if present
319
+ if (str[i] === ',') i++;
320
+ }
321
+ pairs.push({ name: key, valPath: val });
322
+ }
323
+ return pairs;
324
+ }
325
+
207
326
  module.exports = {
208
327
  ExtendsNode,
209
328
  BlockNode,
package/src/tags/util.js CHANGED
@@ -2,12 +2,20 @@
2
2
  * Utility template tags: static, url, regroup, spaceless.
3
3
  */
4
4
 
5
- // Helper to resolve expression values
5
+ // Helper to resolve expression values (literals, numbers, booleans, or context lookups)
6
6
  function resolveValue(token, context) {
7
- if (!token) return '';
7
+ if (token === undefined || token === null) return '';
8
+ if (typeof token !== 'string') return token;
9
+ if (token === '') return '';
8
10
  if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith('\'') && token.endsWith('\''))) {
9
11
  return token.slice(1, -1);
10
12
  }
13
+ if (token === 'true' || token === 'True') return true;
14
+ if (token === 'false' || token === 'False') return false;
15
+ if (token === 'none' || token === 'None' || token === 'null') return null;
16
+ // Numeric literal (integer or float)
17
+ if (/^-?\d+(\.\d+)?$/.test(token)) return Number(token);
18
+ // Otherwise treat as a context variable lookup
11
19
  return context.get(token);
12
20
  }
13
21
 
@@ -18,7 +26,7 @@ class StaticNode {
18
26
 
19
27
  render(context) {
20
28
  const resolvedPath = resolveValue(this.pathExpr, context);
21
-
29
+
22
30
  // Resolve static prefix
23
31
  let prefix = '/static/';
24
32
  if (context.options && context.options.staticUrl) {
@@ -29,29 +37,59 @@ class StaticNode {
29
37
 
30
38
  // Ensure double-slash doesn't occur and ends/starts correctly
31
39
  const base = prefix.endsWith('/') ? prefix : prefix + '/';
32
- const relative = resolvedPath.startsWith('/') ? resolvedPath.slice(1) : resolvedPath;
33
-
40
+ const relative = (typeof resolvedPath === 'string' && resolvedPath.startsWith('/'))
41
+ ? resolvedPath.slice(1)
42
+ : String(resolvedPath);
43
+
34
44
  return base + relative;
35
45
  }
36
46
  }
37
47
 
38
48
  class UrlNode {
39
- constructor(routeNameExpr, argsExprs) {
49
+ constructor(routeNameExpr, positionalArgs, kwargs) {
40
50
  this.routeNameExpr = routeNameExpr;
41
- this.argsExprs = argsExprs || [];
51
+ this.positionalArgs = positionalArgs || [];
52
+ // Each kwarg is { name, valueExpr } where valueExpr is the raw
53
+ // expression token (may be a number, a quoted string, or a var).
54
+ this.kwargs = kwargs || [];
42
55
  }
43
56
 
44
57
  render(context) {
45
58
  const routeName = resolveValue(this.routeNameExpr, context);
46
- const resolvedArgs = this.argsExprs.map(arg => resolveValue(arg, context));
59
+ const resolvedPositional = this.positionalArgs.map(arg => resolveValue(arg, context));
60
+ const resolvedKwargs = {};
61
+ for (const kw of this.kwargs) {
62
+ resolvedKwargs[kw.name] = resolveValue(kw.valueExpr, context);
63
+ }
64
+ const hasKwargs = Object.keys(resolvedKwargs).length > 0;
47
65
 
48
- // If an Express urlHelper is provided, use it
66
+ // If an Express urlHelper is provided, call it. We pass positional
67
+ // args spread, and (only when kwargs are present) a kwargs object
68
+ // as the last argument. This keeps the common `{{ url 'r' 42 }}`
69
+ // case with a `(name, ...args) => string` helper simple.
49
70
  if (context.options && typeof context.options.urlHelper === 'function') {
50
- return context.options.urlHelper(routeName, ...resolvedArgs);
71
+ if (hasKwargs) {
72
+ return context.options.urlHelper(routeName, ...resolvedPositional, resolvedKwargs);
73
+ }
74
+ return context.options.urlHelper(routeName, ...resolvedPositional);
51
75
  }
52
76
 
53
- // Fallback URL generator
54
- return '/' + [routeName, ...resolvedArgs].filter(Boolean).join('/');
77
+ // Fallback URL builder: convert dotted route names into deep paths
78
+ // (e.g. "user.profile.posts.show" -> "/user/profile/posts/show")
79
+ // and append positional args. Kwargs are appended as query string.
80
+ const deepPath = String(routeName).split('.').map(seg => seg).join('/');
81
+ const parts = ['/' + deepPath, ...resolvedPositional
82
+ .filter(v => v !== '' && v !== null && v !== undefined)
83
+ .map(v => String(v))];
84
+ let url = parts.join('/');
85
+ if (hasKwargs) {
86
+ const qs = Object.keys(resolvedKwargs)
87
+ .filter(k => resolvedKwargs[k] !== undefined)
88
+ .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(String(resolvedKwargs[k]))}`)
89
+ .join('&');
90
+ if (qs) url += '?' + qs;
91
+ }
92
+ return url;
55
93
  }
56
94
  }
57
95
 
@@ -118,9 +156,14 @@ class SpacelessNode {
118
156
  class CsrfTokenNode {
119
157
  render(context) {
120
158
  const token = context.get('csrf_token') || '';
121
- const html = `<input type="hidden" name="csrfmiddlewaretoken" value="${token}">`;
122
- const { markSafe } = require('../security');
123
- return markSafe(html);
159
+ // Escape the token for safe interpolation into an HTML attribute.
160
+ // CSRF tokens are typically alphanumeric, but defending against
161
+ // malicious or malformed token values is required to prevent
162
+ // attribute injection (" onerror=... etc).
163
+ const { escapeHtml, markSafe } = require('../security');
164
+ return markSafe(
165
+ `<input type="hidden" name="csrfmiddlewaretoken" value="${escapeHtml(String(token), true)}">`
166
+ );
124
167
  }
125
168
  }
126
169
 
@@ -128,9 +171,9 @@ class CspNonceAttrNode {
128
171
  render(context) {
129
172
  const nonce = context.get('csp_nonce') || '';
130
173
  if (!nonce) return '';
131
- const html = `nonce="${nonce}"`;
132
- const { markSafe } = require('../security');
133
- return markSafe(html);
174
+ // Escape the nonce for safe interpolation into an attribute value.
175
+ const { escapeHtml, markSafe } = require('../security');
176
+ return markSafe(`nonce="${escapeHtml(String(nonce), true)}"`);
134
177
  }
135
178
  }
136
179
 
@@ -143,17 +186,43 @@ function parseStatic(tagContent, _parser) {
143
186
  }
144
187
 
145
188
  function parseUrl(tagContent, _parser) {
146
- // tagContent: "url 'route_name' arg1 arg2"
189
+ // tagContent examples:
190
+ // "url 'route_name' arg1 arg2"
191
+ // "url 'route.name' 34"
192
+ // "url 'user.show' 34 email phone"
193
+ // "url 'user.show' userId=34 tab='posts'"
194
+ // "url 'user.show' 34 tab='posts'"
147
195
  const content = tagContent.slice(3).trim();
148
-
149
- // Extract route name and arguments
196
+
197
+ // Tokenize respecting quoted strings.
150
198
  const argRegex = /(".*?"|'.*?'|[^\s]+)/g;
151
199
  const matches = content.match(argRegex) || [];
152
-
200
+
201
+ if (matches.length === 0) {
202
+ throw new Error('url tag requires a route name');
203
+ }
153
204
  const routeNameExpr = matches[0];
154
- const argsExprs = matches.slice(1);
205
+ const positionalArgs = [];
206
+ const kwargs = [];
207
+
208
+ for (let i = 1; i < matches.length; i++) {
209
+ const tok = matches[i];
210
+ // Detect `key=value` pattern (not a quoted string)
211
+ const isQuoted = (tok.startsWith('"') && tok.endsWith('"')) ||
212
+ (tok.startsWith('\'') && tok.endsWith('\''));
213
+ if (!isQuoted) {
214
+ const eq = tok.indexOf('=');
215
+ if (eq > 0) {
216
+ const name = tok.slice(0, eq);
217
+ const valueExpr = tok.slice(eq + 1);
218
+ kwargs.push({ name, valueExpr });
219
+ continue;
220
+ }
221
+ }
222
+ positionalArgs.push(tok);
223
+ }
155
224
 
156
- return new UrlNode(routeNameExpr, argsExprs);
225
+ return new UrlNode(routeNameExpr, positionalArgs, kwargs);
157
226
  }
158
227
 
159
228
  function parseRegroup(tagContent, _parser) {
@@ -286,6 +355,15 @@ function parseLoad(tagContent, _parser) {
286
355
  if (parts.length === 0) {
287
356
  throw new Error('\'load\' tag requires at least one argument');
288
357
  }
358
+ // Pre-activate the library now so any tags it provides are available
359
+ // to the rest of the parser. The render-time activation in LoadNode
360
+ // is a no-op if the library is already active.
361
+ const { activateLibrary, hasLibrary } = require('../libraries');
362
+ for (const lib of parts) {
363
+ if (hasLibrary(lib)) {
364
+ activateLibrary(lib);
365
+ }
366
+ }
289
367
  return new LoadNode(parts);
290
368
  }
291
369