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
|
@@ -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
|
-
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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;
|
package/src/tags/inheritance.js
CHANGED
|
@@ -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
|
|
93
|
-
if (!
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 (
|
|
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
|
|
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,
|
|
49
|
+
constructor(routeNameExpr, positionalArgs, kwargs) {
|
|
40
50
|
this.routeNameExpr = routeNameExpr;
|
|
41
|
-
this.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
132
|
-
const { markSafe } = require('../security');
|
|
133
|
-
return markSafe(
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
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,
|
|
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
|
|