@rhinostone/swig-django 2.9.8 → 2.9.9

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/README.md CHANGED
@@ -43,7 +43,7 @@ Explicitly unsupported (parse-time throw or absent)
43
43
  * `{% url %}`, `{% static %}`, `{% csrf_token %}`, `{% trans %}`, `{% blocktrans %}` — framework infrastructure, out of scope.
44
44
  * No whitespace-control (`{%- … -%}`) — not part of the Django Template Language.
45
45
  * No `is` tests — Django's `is` is identity comparison, not Jinja2-style tests.
46
- * `{{ block.super }}`, `{% for … reversed %}`, and one-variable `{% for x in dict.items %}` are deferred (clear parse-time errors).
46
+ * `{% for … reversed %}` is deferred (a clear parse-time error). `{{ block.super }}` is deferred too, and renders an empty string with no error.
47
47
  * Deferred filters — `dictsort` / `dictsortreversed`, `stringformat`, `random`, `urlize` / `urlizetrunc`, `timesince` / `timeuntil`, `unordered_list`, `json_script`, `truncatechars_html` / `truncatewords_html`, `safeseq`, `pprint`.
48
48
 
49
49
  Repository
package/lib/index.js CHANGED
@@ -32,7 +32,7 @@ exports.name = 'django';
32
32
  *
33
33
  * @type {String}
34
34
  */
35
- exports.version = "2.9.8";
35
+ exports.version = "2.9.9";
36
36
 
37
37
  /**
38
38
  * Expression-level parser — Pratt-style recursive descent that consumes
package/lib/lexer.js CHANGED
@@ -35,12 +35,15 @@ var TYPES = require('./tokentypes');
35
35
  *
36
36
  * - FILTEREMPTY / FUNCTION above VAR — `|name` and `name(` must be
37
37
  * consumed as filter / function tokens before VAR's `^[a-zA-Z_$]\w*`
38
- * pattern gobbles the bare identifier.
38
+ * pattern gobbles the bare identifier. FUNCTION and FUNCTIONEMPTY skip
39
+ * `and`, `or`, `not` and `in`, so `not(x)` reads as `not (x)`.
39
40
  * - LOGIC (`and` / `or`), NOT (`not`), BOOL (`True` / `False`), NONE
40
41
  * (`None`), and the `is` / `is not` keywords above VAR — same reason:
41
42
  * bake the keyword sequence into its own token rather than emit an
42
43
  * identifier. The `\b` word boundary keeps identifiers like `Truthy`,
43
- * `Nonexistent`, or `island` from matching.
44
+ * `Nonexistent`, or `island` from matching; the `and`, `or`, `not` and
45
+ * `in` rules take whitespace or `(` after the keyword, so an object key
46
+ * such as `{and: 1}` stays a name.
44
47
  *
45
48
  * Rules are tried in order; first match wins. Patterns are anchored at
46
49
  * start-of-string because the consumer slices `str` before each dispatch.
@@ -71,14 +74,14 @@ var rules = [
71
74
  {
72
75
  type: TYPES.FUNCTIONEMPTY,
73
76
  regex: [
74
- /^\s*(\w+)\(\)/
77
+ /^\s*(?!(?:and|or|not|in)\()(\w+)\(\)/
75
78
  ],
76
79
  idx: 1
77
80
  },
78
81
  {
79
82
  type: TYPES.FUNCTION,
80
83
  regex: [
81
- /^\s*(\w+)\(/
84
+ /^\s*(?!(?:and|or|not|in)\()(\w+)\(/
82
85
  ],
83
86
  idx: 1
84
87
  },
@@ -104,7 +107,7 @@ var rules = [
104
107
  type: TYPES.LOGIC,
105
108
  regex: [
106
109
  /^(&&|\|\|)\s*/,
107
- /^(and|or)\s+/
110
+ /^(and|or)(?=[\s(])\s*/
108
111
  ],
109
112
  idx: 1,
110
113
  replace: {
@@ -115,7 +118,7 @@ var rules = [
115
118
  {
116
119
  type: TYPES.COMPARATOR,
117
120
  regex: [
118
- /^(===|==|\!==|\!=|<=|<|>=|>|not\s+in\s|in\s)\s*/
121
+ /^(===|==|\!==|\!=|<=|<|>=|>|not\s+in(?=[\s(])|in(?=[\s(]))\s*/
119
122
  ],
120
123
  idx: 1
121
124
  },
@@ -129,7 +132,7 @@ var rules = [
129
132
  type: TYPES.NOT,
130
133
  regex: [
131
134
  /^\!\s*/,
132
- /^not\s+/
135
+ /^not(?=[\s(])\s*/
133
136
  ],
134
137
  replace: {
135
138
  'not': '!'
package/lib/parser.js CHANGED
@@ -42,6 +42,24 @@ function membership(left, right, negate) {
42
42
  return node;
43
43
  }
44
44
 
45
+ /**
46
+ * Build the IR of a call to one of the engine's own helpers (an `_ext` test,
47
+ * an `_utils` function, a `Math` function), flagged so swig-core reads the
48
+ * callee from the compiled template's scope and never from the template
49
+ * context, where a locals key such as `_ext`, `_utils` or `Math` would
50
+ * replace it.
51
+ *
52
+ * @param {string[]} path Callee path (`['_utils', 'range']`).
53
+ * @param {object[]} args Argument expressions.
54
+ * @return {object} IRFnCall with `closure === true`.
55
+ * @private
56
+ */
57
+ function helperCall(path, args) {
58
+ var node = ir.fnCall(ir.varRef(path), args);
59
+ node.closure = true;
60
+ return node;
61
+ }
62
+
45
63
  /**
46
64
  * Build a VarRef IR node flagged for Django-style runtime resolution. The
47
65
  * `resolve` flag makes swig-core's emitVarRef emit `_utils.resolve(_ctx,
@@ -62,6 +80,30 @@ function mkVar(path) {
62
80
  return n;
63
81
  }
64
82
 
83
+ /**
84
+ * Apply Django's string_if_invalid ("") to the variable an expression starts
85
+ * with: the input of its filter chain, or the expression itself. Django
86
+ * resolves that variable first and gives a failed lookup the value "" before
87
+ * any filter runs, in `{{ }}` outputs and in the values of `with`, `cycle`
88
+ * and include-with; `if`, `for` and `firstof` read a failed lookup as None
89
+ * and do not call this. Sets the VarRef's opt-in `missingAsEmpty` flag (the
90
+ * backend then passes "" as `_utils.resolve`'s third argument); any other
91
+ * start (a literal, a subscript) is left as it is.
92
+ *
93
+ * @param {object} expr IRExpr tree from parseExpr.
94
+ * @return {object} The same tree.
95
+ */
96
+ exports.missingAsEmpty = function (expr) {
97
+ var head = expr;
98
+ while (head && head.type === 'FilterCall') {
99
+ head = head.input;
100
+ }
101
+ if (head && head.type === 'VarRef') {
102
+ head.missingAsEmpty = true;
103
+ }
104
+ return expr;
105
+ };
106
+
65
107
  /**
66
108
  * Django expression parser — Pratt-style recursive descent.
67
109
  *
@@ -145,6 +187,10 @@ exports.parseExpr = function (tokens, filters, _posOut) {
145
187
  }
146
188
  }
147
189
 
190
+ // Levels follow Django's smartif: `or` 1, `and` 2, then `not` (parsed in
191
+ // parseUnary, with an operand at level 4), `in` / `not in` 4, and the
192
+ // comparisons with `is` / `is not` 5, so `not x == y` is `not (x == y)` and
193
+ // `a == b in c` is `(a == b) in c`. Arithmetic (6, 8) is a swig extension.
148
194
  function getBinaryOpInfo(tok) {
149
195
  var m;
150
196
  if (tok.type === _t.LOGIC) {
@@ -153,22 +199,22 @@ exports.parseExpr = function (tokens, filters, _posOut) {
153
199
  }
154
200
  if (tok.type === _t.COMPARATOR) {
155
201
  m = tok.match;
156
- if (m === '===' || m === '!==' || m === '==' || m === '!=') {
157
- return { op: m, prec: 3 };
158
- }
159
202
  if (/^not\s+in$/.test(m)) {
160
203
  return { op: 'not in', prec: 4 };
161
204
  }
162
- return { op: m, prec: 4 };
205
+ if (m === 'in') {
206
+ return { op: m, prec: 4 };
207
+ }
208
+ return { op: m, prec: 5 };
163
209
  }
164
210
  // Django `is` / `is not` are *identity* comparisons (not Jinja2 test
165
211
  // calls): lowered to `===` / `!==` in parseExpression. Same precedence
166
212
  // level as `==` so `x is None` / `x is not None` group naturally.
167
213
  if (tok.type === _t.IS) {
168
- return { op: 'is', prec: 3 };
214
+ return { op: 'is', prec: 5 };
169
215
  }
170
216
  if (tok.type === _t.ISNOT) {
171
- return { op: 'is not', prec: 3 };
217
+ return { op: 'is not', prec: 5 };
172
218
  }
173
219
  if (tok.type === _t.OPERATOR) {
174
220
  m = tok.match;
@@ -330,7 +376,7 @@ exports.parseExpr = function (tokens, filters, _posOut) {
330
376
  }
331
377
  }
332
378
  expectBracketClose();
333
- return ir.fnCall(ir.varRef(['_utils', 'slice']), [
379
+ return helperCall(['_utils', 'slice'], [
334
380
  obj,
335
381
  startExpr || undefinedLiteral(),
336
382
  stopExpr || undefinedLiteral(),
@@ -437,7 +483,9 @@ exports.parseExpr = function (tokens, filters, _posOut) {
437
483
  var tok = peek();
438
484
  if (tok && tok.type === _t.NOT) {
439
485
  consume();
440
- return ir.unaryOp('!', parseUnary());
486
+ // Django's `not` takes in, not in, the comparisons and is into its
487
+ // operand, and stops at `and` / `or`.
488
+ return ir.unaryOp('!', parseExpression(4));
441
489
  }
442
490
  if (tok && tok.type === _t.OPERATOR && (tok.match === '+' || tok.match === '-')) {
443
491
  consume();
@@ -603,7 +651,7 @@ exports.parse = function (swig, source, opts, tags, filters) {
603
651
  }
604
652
  }
605
653
  });
606
- var expr = exports.parseExpr(lexed, filters);
654
+ var expr = exports.missingAsEmpty(exports.parseExpr(lexed, filters));
607
655
  var tail;
608
656
  var esc = escapeStack[escapeStack.length - 1];
609
657
  if (esc && !sawSafe) {
package/lib/tags/cycle.js CHANGED
@@ -75,7 +75,7 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
75
75
  while (pos < tokens.length && tokens[pos].type === types.WHITESPACE) { pos += 1; }
76
76
  if (pos >= tokens.length) { break; }
77
77
  var posOut = {};
78
- var expr = parser.parseExpr(tokens.slice(pos), {}, posOut);
78
+ var expr = parser.missingAsEmpty(parser.parseExpr(tokens.slice(pos), {}, posOut));
79
79
  pos += posOut.pos;
80
80
  args.push(expr);
81
81
  }
@@ -96,7 +96,10 @@ exports.compile = function (compiler, args, content, parents, options, blockName
96
96
 
97
97
  var key = ('__cycle' + Math.random()).replace(/\./g, '');
98
98
  var value = '__cv[_ctx.' + key + ' % __cv.length]';
99
- var emit = token.escape ? '_filters["e"](' + value + ')' : '_utils.coerceOutput(' + value + ')';
99
+ // A string escape strategy (the `autoescape: 'js'` option) goes to the
100
+ // `e` filter as its argument, as firstof and `{{ }}` pass it.
101
+ var strategy = (typeof token.escape === 'string') ? ', ' + backend.emitExpr(ir.literal('string', token.escape)) : '';
102
+ var emit = token.escape ? '_filters["e"](' + value + strategy + ')' : '_utils.coerceOutput(' + value + ')';
100
103
 
101
104
  return ir.legacyJS(
102
105
  '(function () {\n' +
package/lib/tags/else.js CHANGED
@@ -22,6 +22,10 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
22
22
  if (!top || top.name !== 'if') {
23
23
  utils.throwError('"else" is only valid inside an "if" tag', line, opts.filename);
24
24
  }
25
+ // Django rejects anything after else (`{% else if b %}` is not elif).
26
+ if (utils.strip(str || '')) {
27
+ utils.throwError('"else" does not accept arguments (found "' + utils.strip(str) + '")', line, opts.filename);
28
+ }
25
29
  return true;
26
30
  };
27
31
 
package/lib/tags/empty.js CHANGED
@@ -20,6 +20,9 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
20
20
  if (!top || top.name !== 'for') {
21
21
  utils.throwError('"empty" is only valid inside a "for" tag', line, opts.filename);
22
22
  }
23
+ if (utils.strip(str || '')) {
24
+ utils.throwError('"empty" does not accept arguments (found "' + utils.strip(str) + '")', line, opts.filename);
25
+ }
23
26
  return true;
24
27
  };
25
28
 
package/lib/tags/for.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * mapping's keys). An item that does not hold two values binds what it holds,
21
21
  * a missing value rendering empty, where Django raises. Two names over a
22
22
  * mapping bind the key and the value, a swig extension (Django iterates the
23
- * keys and unpacks each one) that the `.items` rewrite relies on.
23
+ * keys and unpacks each one) that a two-name `.items` loop relies on.
24
24
  *
25
25
  * Loop variable names must be bare identifiers — dotted paths are rejected
26
26
  * at parse time, and the CVE-2023-25345 `_dangerousProps` guard runs on
@@ -31,9 +31,11 @@
31
31
  * compile splits the content at it into the loop body and the empty body.
32
32
  *
33
33
  * Django's dict-iteration pseudo-methods are supported on the iterable —
34
- * `{% for k in d.keys %}`, `{% for v in d.values %}`, and the canonical
35
- * `{% for key, value in d.items %}` (see rewriteDictIterable). The one-name
36
- * `{% for pair in d.items %}` tuple form is rejected with a clear message.
34
+ * `{% for k in d.keys %}`, `{% for v in d.values %}`, the canonical
35
+ * `{% for key, value in d.items %}`, and the one-name
36
+ * `{% for pair in d.items %}`, which binds each [key, value] pair. As in
37
+ * Django, a mapping's own key of that name is read first (see
38
+ * rewriteDictIterable).
37
39
  *
38
40
  * The `reversed` modifier (`{% for x in items reversed %}`) is not yet
39
41
  * supported; it is detected and rejected with a clear message rather than
@@ -83,30 +85,31 @@ function resolveVar(path) {
83
85
  * `.keys` / `.values` / `.items` would otherwise resolve to a (usually
84
86
  * missing) object property, yielding an empty loop.
85
87
  *
86
- * - `.keys` → `_utils.keys(obj)` (a plain key array; a one-name `for`
87
- * binds each key).
88
- * - `.values` → `_utils.values(obj)` (a plain value array; a one-name `for`
89
- * binds each value).
90
- * - `.items` → the object itself, walked with the two-name form, so swig
91
- * binds key + value per entry exactly as Django's two-name
92
- * `{% for k, v in d.items %}`. The one-name tuple form is
93
- * rejected (there is no `_utils.entries` to synthesize a
94
- * (key, value) tuple sequence flavor-locally).
88
+ * The iterable becomes the mapping (the path without its last segment), and
89
+ * `token.dictIter` records the name, which compile sets on the IRFor node.
90
+ * At render the backend passes the mapping to `_utils.dictIter`, which reads
91
+ * the mapping's own key of that name first, as Django's dictionary lookup
92
+ * does (so `{% for item in page.data.items %}` iterates the list stored under
93
+ * `items`), and otherwise applies the method:
94
+ *
95
+ * - `.keys` → the keys (a one-name `for` binds each key).
96
+ * - `.values` → the values (a one-name `for` binds each value).
97
+ * - `.items` → the mapping itself, so two names bind key + value per entry
98
+ * exactly as Django's `{% for k, v in d.items %}`; with one
99
+ * name (`pairs`), each [key, value] pair, Django's
100
+ * (key, value) tuple, and nothing for a value that is not a
101
+ * mapping.
95
102
  *
96
103
  * Anything that is not a resolved user-variable path of length >= 2 ending in
97
- * one of the three names is returned unchanged. Documented divergence: the
98
- * interception is unconditional, so if `obj` has a literal key named
99
- * `keys` / `values` / `items`, the pseudo-method wins (Django's dict-key
100
- * precedence is not honored for those three names).
104
+ * one of the three names is returned unchanged.
101
105
  *
102
106
  * @param {object} it Parsed iterable IRExpr.
103
107
  * @param {boolean} hasTwoNames Whether the loop declared `key, val`.
104
- * @param {number} line Source line, for error reporting.
105
- * @param {string} filename Template filename, for error reporting.
108
+ * @param {object} token In-progress TagToken; gets `dictIter`.
106
109
  * @return {object} The (possibly rewritten) iterable IRExpr.
107
110
  * @private
108
111
  */
109
- function rewriteDictIterable(it, hasTwoNames, line, filename) {
112
+ function rewriteDictIterable(it, hasTwoNames, token) {
110
113
  if (!it || it.type !== 'VarRef' || !it.resolve || it.path.length < 2) {
111
114
  return it;
112
115
  }
@@ -114,21 +117,11 @@ function rewriteDictIterable(it, hasTwoNames, line, filename) {
114
117
  if (!DICT_PSEUDO_METHODS.hasOwnProperty(lastSeg)) {
115
118
  return it;
116
119
  }
117
- var objPath = it.path.slice(0, -1);
118
- if (lastSeg === 'keys') {
119
- // The synthetic `_utils.keys` callee is intentionally NOT resolve-flagged
120
- // (emitFnCall reads a callee path directly, never through emitVarRef).
121
- return ir.fnCall(ir.varRef(['_utils', 'keys']), [resolveVar(objPath)]);
122
- }
123
- if (lastSeg === 'values') {
124
- return ir.fnCall(ir.varRef(['_utils', 'values']), [resolveVar(objPath)]);
125
- }
120
+ token.dictIter = { name: lastSeg };
126
121
  if (lastSeg === 'items' && !hasTwoNames) {
127
- utils.throwError('The "items" pseudo-method in a "for" tag requires two loop variables — use {% for key, value in ' + objPath.join('.') + '.items %}', line, filename);
122
+ token.dictIter.pairs = true;
128
123
  }
129
- // `.items` walks the object directly: two names over a mapping bind the key
130
- // and the value.
131
- return resolveVar(objPath);
124
+ return resolveVar(it.path.slice(0, -1));
132
125
  }
133
126
 
134
127
  exports.ends = true;
@@ -214,7 +207,7 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
214
207
  }
215
208
 
216
209
  token.args = key !== undefined ? [key, val] : [val];
217
- token.irExpr = rewriteDictIterable(parser.parseExpr(iterableTokens), key !== undefined, line, opts.filename);
210
+ token.irExpr = rewriteDictIterable(parser.parseExpr(iterableTokens), key !== undefined, token);
218
211
  return true;
219
212
  };
220
213
 
@@ -275,5 +268,9 @@ exports.compile = function (compiler, args, content, parents, options, blockName
275
268
  } else {
276
269
  node.mapKeys = true;
277
270
  }
271
+ // A dict pseudo-method on the iterable (see rewriteDictIterable).
272
+ if (token.dictIter) {
273
+ node.dictIter = token.dictIter;
274
+ }
278
275
  return node;
279
276
  };
@@ -112,7 +112,7 @@ function parsePairs(clause, parser, types, line, opts) {
112
112
  pos = j + 1;
113
113
  skipWS();
114
114
  var posOut = {};
115
- var valExpr = parser.parseExpr(clause.slice(pos), {}, posOut);
115
+ var valExpr = parser.missingAsEmpty(parser.parseExpr(clause.slice(pos), {}, posOut));
116
116
  pos += posOut.pos;
117
117
  props.push(ir.objectProperty(ir.literal('string', nameTok.match), valExpr));
118
118
  }
package/lib/tags/with.js CHANGED
@@ -89,7 +89,7 @@ function parsePairs(tokens, parser, types, line, opts) {
89
89
  pos = j + 1;
90
90
  skipWS();
91
91
  var posOut = {};
92
- var valExpr = parser.parseExpr(tokens.slice(pos), {}, posOut);
92
+ var valExpr = parser.missingAsEmpty(parser.parseExpr(tokens.slice(pos), {}, posOut));
93
93
  pos += posOut.pos;
94
94
  props.push(ir.objectProperty(ir.literal('string', nameTok.match), valExpr));
95
95
  }
@@ -134,7 +134,7 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
134
134
  utils.throwError('Expected a single variable name after "as" in "with" tag', line, opts.filename);
135
135
  }
136
136
  guardName(nameTokens[0].match, line, opts);
137
- props = [ir.objectProperty(ir.literal('string', nameTokens[0].match), parser.parseExpr(exprTokens))];
137
+ props = [ir.objectProperty(ir.literal('string', nameTokens[0].match), parser.missingAsEmpty(parser.parseExpr(exprTokens)))];
138
138
  } else {
139
139
  props = parsePairs(tokens, parser, types, line, opts);
140
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhinostone/swig-django",
3
- "version": "2.9.8",
3
+ "version": "2.9.9",
4
4
  "description": "Django-syntax frontend for the @rhinostone/swig-core template engine. Part of the @rhinostone/swig multi-flavor family.",
5
5
  "keywords": [
6
6
  "template",
@@ -22,7 +22,7 @@
22
22
  "node": ">=12"
23
23
  },
24
24
  "peerDependencies": {
25
- "@rhinostone/swig-core": "2.9.8"
25
+ "@rhinostone/swig-core": "2.9.9"
26
26
  },
27
27
  "publishConfig": {
28
28
  "access": "public"