@rhinostone/swig-jinja2 2.8.1 → 2.8.2

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
@@ -33,7 +33,7 @@ A near-subset of Python Jinja2 — everything below was cross-checked against Ji
33
33
  * **Operators** — `**` power, `//` floor-division, `~` string concat, inline-if (`a if c else b`), Python slicing (`seq[start:stop:step]`), `is <test>` / `is not <test>`, plus `{{- … -}}` / `{%- … -%}` whitespace control.
34
34
  * **Built-in `is` tests** — `defined`, `undefined`, `none`, `even`, `odd`, `divisibleby`, `iterable`, `mapping`, `sequence`, `string`, `number`, `boolean`, `callable`, `lower`, `upper`, `sameas`.
35
35
  * **Filters** — 39 built-ins (`upper`, `lower`, `capitalize`, `title`, `trim`, `truncate`, `replace`, `striptags`, `format`, `wordcount`, `wordwrap`, `indent`, `center`, `urlencode`, `escape` / `e`, `safe`, `first`, `last`, `join`, `reverse`, `sort`, `length` / `count`, `list`, `unique`, `batch`, `slice`, `dictsort`, `groupby`, `min`, `max`, `sum`, `random`, `abs`, `round`, `int`, `float`, `default` / `d`, `tojson`, `date`). See `lib/filters.js` for the full list.
36
- * **Async** — `renderFileAsync` / `compileFileAsync` for async loaders.
36
+ * **Async** — `renderFile(path, locals, cb)` against a loader with `async === true`, or `compileFile` with `codegenMode: 'async'`; `renderFileAsync` / `compileFileAsync` still work but are deprecated.
37
37
 
38
38
  Explicitly unsupported (parse-time throw or absent)
39
39
  ---------------------------------------------------
package/lib/filters.js CHANGED
@@ -177,7 +177,7 @@ exports.count = exports.length;
177
177
 
178
178
  /**
179
179
  * Return the first item of an array, the first character of a string, or
180
- * the first value of an object.
180
+ * the first key of a mapping (Jinja2 iterates a mapping's keys).
181
181
  *
182
182
  * @example
183
183
  * {{ ["a", "b", "c"]|first }}
@@ -188,8 +188,7 @@ exports.count = exports.length;
188
188
  */
189
189
  exports.first = function (input) {
190
190
  if (typeof input === 'object' && input !== null && !utils.isArray(input)) {
191
- var keys = utils.keys(input);
192
- return input[keys[0]];
191
+ return utils.keys(input)[0];
193
192
  }
194
193
  if (typeof input === 'string') {
195
194
  return input.substr(0, 1);
@@ -202,7 +201,7 @@ exports.first = function (input) {
202
201
 
203
202
  /**
204
203
  * Return the last item of an array, the last character of a string, or
205
- * the last value of an object.
204
+ * the last key of a mapping (Jinja2 iterates a mapping's keys).
206
205
  *
207
206
  * @example
208
207
  * {{ ["a", "b", "c"]|last }}
@@ -214,7 +213,7 @@ exports.first = function (input) {
214
213
  exports.last = function (input) {
215
214
  if (typeof input === 'object' && input !== null && !utils.isArray(input)) {
216
215
  var keys = utils.keys(input);
217
- return input[keys[keys.length - 1]];
216
+ return keys[keys.length - 1];
218
217
  }
219
218
  if (typeof input === 'string') {
220
219
  return input.charAt(input.length - 1);
@@ -254,13 +253,13 @@ exports.join = function (input, glue) {
254
253
 
255
254
  /**
256
255
  * Reverse an array or string. Does not sort — items come out in reverse
257
- * input order.
256
+ * input order. A mapping gives its keys in reverse order.
258
257
  *
259
258
  * @example
260
259
  * {{ [1, 2, 3]|reverse|join(",") }}
261
260
  * // => 3,2,1
262
261
  *
263
- * @param {array|string} input
262
+ * @param {array|string|object} input
264
263
  * @return {array|string}
265
264
  */
266
265
  exports.reverse = function (input) {
@@ -270,14 +269,17 @@ exports.reverse = function (input) {
270
269
  if (typeof input === 'string') {
271
270
  return input.split('').reverse().join('');
272
271
  }
272
+ if (input && typeof input === 'object') {
273
+ return utils.keys(input).reverse();
274
+ }
273
275
  return input;
274
276
  };
275
277
 
276
278
  /**
277
279
  * Sort an array ascending, returning a copy (does not mutate the input).
278
280
  * Numbers sort numerically; everything else compares case-insensitively.
279
- * A string sorts its characters; an object sorts its keys. Pass a truthy
280
- * first argument to sort descending.
281
+ * A string gives its characters as a sorted list, as in Jinja2; a mapping
282
+ * gives its sorted keys. Pass a truthy first argument to sort descending.
281
283
  *
282
284
  * @example
283
285
  * {{ [3, 1, 2]|sort|join(",") }}
@@ -292,12 +294,11 @@ exports.reverse = function (input) {
292
294
  * @return {*}
293
295
  */
294
296
  exports.sort = function (input, reverse) {
295
- var arr, isString = false;
297
+ var arr;
296
298
  if (utils.isArray(input)) {
297
299
  arr = utils.extend([], input);
298
300
  } else if (typeof input === 'string') {
299
301
  arr = input.split('');
300
- isString = true;
301
302
  } else if (input && typeof input === 'object') {
302
303
  arr = utils.keys(input);
303
304
  } else {
@@ -309,9 +310,17 @@ exports.sort = function (input, reverse) {
309
310
  return sa < sb ? -1 : sa > sb ? 1 : 0;
310
311
  });
311
312
  if (reverse) { arr.reverse(); }
312
- return isString ? arr.join('') : arr;
313
+ return arr;
313
314
  };
314
315
 
316
+ /*!
317
+ * Split a string into code points, keeping a surrogate pair together as
318
+ * Python's `str` iteration does. @private
319
+ */
320
+ function codePoints(s) {
321
+ return s.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]|[\s\S]/g) || [];
322
+ }
323
+
315
324
  /*!
316
325
  * Resolve a possibly-dotted attribute path against an object. Returns
317
326
  * undefined if any segment is missing. @private
@@ -645,7 +654,10 @@ exports.trim = function (input, chars) {
645
654
  * Replace occurrences of a literal substring with another. Unlike the
646
655
  * native swig `replace` (regex) and the Twig `replace` (mapping), Jinja2's
647
656
  * `replace` is a plain left-to-right literal substitution. An optional
648
- * count limits how many occurrences are replaced.
657
+ * count limits how many occurrences are replaced. As in Python, an empty
658
+ * search string inserts the replacement before each character and at the
659
+ * end, a negative count replaces every occurrence, and a number input is
660
+ * converted to a string first.
649
661
  *
650
662
  * @example
651
663
  * {{ "Hello World"|replace("Hello", "Goodbye") }}
@@ -662,20 +674,36 @@ exports.trim = function (input, chars) {
662
674
  * @return {*}
663
675
  */
664
676
  exports.replace = function (input, old, neu, count) {
665
- var limited, max, out, i, n;
677
+ var max, out, chars, i, n;
678
+ if (typeof input === 'number') {
679
+ input = String(input);
680
+ }
666
681
  if (typeof input !== 'string') {
667
682
  return input;
668
683
  }
669
684
  old = String(old);
670
685
  neu = (neu === undefined || neu === null) ? '' : String(neu);
671
- if (old === '') {
672
- return input;
686
+ max = (count !== undefined && count !== null) ? Number(count) : Infinity;
687
+ if (max < 0) {
688
+ max = Infinity;
673
689
  }
674
- limited = (count !== undefined && count !== null);
675
- max = limited ? Number(count) : Infinity;
676
690
  out = '';
677
- i = 0;
678
691
  n = 0;
692
+ if (old === '') {
693
+ chars = codePoints(input);
694
+ for (i = 0; i < chars.length; i += 1) {
695
+ if (n < max) {
696
+ out += neu;
697
+ n += 1;
698
+ }
699
+ out += chars[i];
700
+ }
701
+ if (n < max) {
702
+ out += neu;
703
+ }
704
+ return out;
705
+ }
706
+ i = 0;
679
707
  while (i < input.length) {
680
708
  if (n < max && input.substr(i, old.length) === old) {
681
709
  out += neu;
@@ -689,6 +717,35 @@ exports.replace = function (input, old, neu, count) {
689
717
  return out;
690
718
  };
691
719
 
720
+ /*!
721
+ * Pad the exponent of a JavaScript exponential string to at least two
722
+ * digits, as Python's `%e` prints it (`1e+4` becomes `1e+04`). @private
723
+ */
724
+ function padExponent(s) {
725
+ return s.replace(/e([+-])(\d)$/, function (m, sign, digit) {
726
+ return 'e' + sign + '0' + digit;
727
+ });
728
+ }
729
+
730
+ /*!
731
+ * Python's `%g` for a non-negative number: precision `prec` (0 counts as
732
+ * 1) significant digits, fixed notation when the decimal exponent X of
733
+ * the rounded value satisfies -4 <= X < precision and exponential
734
+ * notation otherwise, then trailing zeros and a bare point removed from
735
+ * the fractional part. @private
736
+ */
737
+ function formatGeneral(num, prec) {
738
+ var p = (prec === 0) ? 1 : prec, digits = Math.min(p - 1, 100), exp, parts;
739
+ // toExponential and toFixed take at most 100 digits; for most numbers the
740
+ // digits past those are zeros, which are stripped below anyway.
741
+ exp = parseInt(num.toExponential(digits).split('e')[1], 10);
742
+ parts = ((exp >= -4 && exp < p) ? num.toFixed(Math.min(p - 1 - exp, 100)) : num.toExponential(digits)).split('e');
743
+ if (parts[0].indexOf('.') !== -1) {
744
+ parts[0] = parts[0].replace(/0+$/, '').replace(/\.$/, '');
745
+ }
746
+ return (parts.length > 1) ? padExponent(parts[0] + 'e' + parts[1]) : parts[0];
747
+ }
748
+
692
749
  /*!
693
750
  * printf-style format expansion backing the `format` filter. Supports the
694
751
  * common conversions (s d i u f F e E g G x X o c %) with optional flags
@@ -738,14 +795,14 @@ function sprintf(fmt, args) {
738
795
  case 'E':
739
796
  num = parseFloat(arg);
740
797
  if (isNaN(num)) { num = 0; }
741
- s = withSign(num).toExponential(hasPrec ? prec : 6);
798
+ s = padExponent(withSign(num).toExponential(hasPrec ? prec : 6));
742
799
  if (conv === 'E') { s = s.toUpperCase(); }
743
800
  break;
744
801
  case 'g':
745
802
  case 'G':
746
803
  num = parseFloat(arg);
747
804
  if (isNaN(num)) { num = 0; }
748
- s = String(withSign(num));
805
+ s = formatGeneral(withSign(num), hasPrec ? prec : 6);
749
806
  if (conv === 'G') { s = s.toUpperCase(); }
750
807
  break;
751
808
  case 'x':
@@ -825,7 +882,9 @@ exports.wordcount = function (input) {
825
882
  * Word-wrap a string to a given line width (Jinja2's `wordwrap`). Words are
826
883
  * packed greedily; a word longer than the width is split when
827
884
  * `breakLongWords` is truthy (the default). Lines are joined with
828
- * `wrapstring` (default newline).
885
+ * `wrapstring` (default newline). A width of 0 or less returns the input
886
+ * unchanged (Jinja2 raises), and a width between 0 and 1 breaks one
887
+ * character at a time, as Python's textwrap does.
829
888
  *
830
889
  * @example
831
890
  * {{ "the quick brown fox"|wordwrap(10) }}
@@ -843,6 +902,12 @@ exports.wordwrap = function (input, width, breakLongWords, wrapstring) {
843
902
  return input;
844
903
  }
845
904
  if (width === undefined || width === null) { width = 79; }
905
+ if (width <= 0) {
906
+ return input;
907
+ }
908
+ if (width < 1) {
909
+ width = 1;
910
+ }
846
911
  if (breakLongWords === undefined) { breakLongWords = true; }
847
912
  if (wrapstring === undefined || wrapstring === null) { wrapstring = '\n'; }
848
913
  words = input.split(/\s+/);
@@ -854,7 +919,9 @@ exports.wordwrap = function (input, width, breakLongWords, wrapstring) {
854
919
  while (breakLongWords && word.length > width) {
855
920
  space = (cur === '') ? width : width - cur.length - 1;
856
921
  if (space <= 0) {
857
- lines.push(cur);
922
+ // Python's textwrap keeps the separating space when the line is
923
+ // exactly full.
924
+ lines.push(space === 0 ? cur + ' ' : cur);
858
925
  cur = '';
859
926
  space = width;
860
927
  }
@@ -974,7 +1041,8 @@ exports.list = function (input) {
974
1041
  /**
975
1042
  * Return the unique items of a sequence, preserving first-seen order
976
1043
  * (Jinja2's `unique`). String comparison is case-insensitive by default;
977
- * pass a truthy `caseSensitive` to compare exactly.
1044
+ * pass a truthy `caseSensitive` to compare exactly. A mapping gives its
1045
+ * unique keys.
978
1046
  *
979
1047
  * @example
980
1048
  * {{ [1, 2, 2, 3, 1]|unique|join(",") }}
@@ -988,6 +1056,8 @@ exports.unique = function (input, caseSensitive) {
988
1056
  var seen = [], out = [];
989
1057
  if (typeof input === 'string') {
990
1058
  input = input.split('');
1059
+ } else if (input && typeof input === 'object' && !utils.isArray(input)) {
1060
+ input = utils.keys(input);
991
1061
  } else if (!utils.isArray(input)) {
992
1062
  return input;
993
1063
  }
@@ -1002,9 +1072,13 @@ exports.unique = function (input, caseSensitive) {
1002
1072
  };
1003
1073
 
1004
1074
  /**
1005
- * Batch items into rows of `size` (Jinja2's `batch`). The last row holds
1075
+ * Batch items into rows of `size` (Jinja2's `batch`). A new row starts
1076
+ * when the current one holds exactly `size` items, so the last row holds
1006
1077
  * the remainder; an optional `fill` pads the last row up to `size`. A
1007
- * mapping batches its values.
1078
+ * string batches its characters and a mapping its keys; any other input
1079
+ * that is not a list gives an empty list. As in Jinja2, a size of 0 gives
1080
+ * an empty first row, and a size that no row length can equal (negative,
1081
+ * fractional or not a number) gives a single row.
1008
1082
  *
1009
1083
  * @example
1010
1084
  * {% for row in [1,2,3,4,5]|batch(2) %}[{{ row|join(",") }}]{% endfor %}
@@ -1016,31 +1090,32 @@ exports.unique = function (input, caseSensitive) {
1016
1090
  * @return {Array}
1017
1091
  */
1018
1092
  exports.batch = function (input, size, fill) {
1019
- var items, n, out, i, last;
1093
+ var items, out = [], tmp = [], i;
1020
1094
  if (utils.isArray(input)) {
1021
1095
  items = input;
1096
+ } else if (typeof input === 'string') {
1097
+ items = codePoints(input);
1022
1098
  } else if (input && typeof input === 'object') {
1023
- items = [];
1024
- utils.each(input, function (v) { items.push(v); });
1099
+ items = utils.keys(input);
1025
1100
  } else {
1026
1101
  return [];
1027
1102
  }
1028
- n = Number(size);
1029
- if (!isFinite(n) || n <= 0) {
1030
- return [];
1031
- }
1032
- n = Math.ceil(n);
1033
- out = [];
1034
- i = 0;
1035
- while (i < items.length) {
1036
- out.push(items.slice(i, i + n));
1037
- i += n;
1038
- }
1039
- if (fill !== undefined && out.length > 0) {
1040
- last = out[out.length - 1];
1041
- while (last.length < n) {
1042
- last.push(fill);
1103
+ for (i = 0; i < items.length; i += 1) {
1104
+ if (tmp.length === size) {
1105
+ out.push(tmp);
1106
+ tmp = [];
1043
1107
  }
1108
+ tmp.push(items[i]);
1109
+ }
1110
+ if (tmp.length) {
1111
+ // Pad only up to a finite numeric size, since a non-finite one would
1112
+ // never end the loop (Jinja2 raises unless the size is an integer).
1113
+ if (fill !== undefined && typeof size === 'number' && isFinite(size)) {
1114
+ while (tmp.length < size) {
1115
+ tmp.push(fill);
1116
+ }
1117
+ }
1118
+ out.push(tmp);
1044
1119
  }
1045
1120
  return out;
1046
1121
  };
@@ -1049,7 +1124,8 @@ exports.batch = function (input, size, fill) {
1049
1124
  * Distribute items across `slices` columns (Jinja2's `slice` filter — the
1050
1125
  * inverse of `batch`, and distinct from the `seq[start:stop:step]`
1051
1126
  * subscript). The first columns receive the extra items when the count
1052
- * does not divide evenly; an optional `fill` pads the shorter columns.
1127
+ * does not divide evenly; an optional `fill` pads the shorter columns. A
1128
+ * string distributes its characters and a mapping its keys.
1053
1129
  *
1054
1130
  * @example
1055
1131
  * {% for col in [1,2,3,4,5,6,7,8,9,10]|slice(3) %}[{{ col|join(",") }}]{% endfor %}
@@ -1067,8 +1143,7 @@ exports.slice = function (input, slices, fill) {
1067
1143
  } else if (typeof input === 'string') {
1068
1144
  seq = input.split('');
1069
1145
  } else if (input && typeof input === 'object') {
1070
- seq = [];
1071
- utils.each(input, function (v) { seq.push(v); });
1146
+ seq = utils.keys(input);
1072
1147
  } else {
1073
1148
  return [];
1074
1149
  }
@@ -1186,13 +1261,11 @@ exports.sum = function (input, attribute, start) {
1186
1261
  * numerically. @private
1187
1262
  */
1188
1263
  function minmax(input, caseSensitive, want) {
1189
- var best, found = false, vals;
1264
+ var best, found = false;
1190
1265
  if (typeof input === 'string') {
1191
1266
  input = input.split('');
1192
1267
  } else if (input && typeof input === 'object' && !utils.isArray(input)) {
1193
- vals = [];
1194
- utils.each(input, function (v) { vals.push(v); });
1195
- input = vals;
1268
+ input = utils.keys(input);
1196
1269
  } else if (!utils.isArray(input)) {
1197
1270
  return input;
1198
1271
  }
@@ -1219,7 +1292,8 @@ function minmax(input, caseSensitive, want) {
1219
1292
 
1220
1293
  /**
1221
1294
  * Return the smallest item of a sequence (Jinja2's `min`). String
1222
- * comparison is case-insensitive unless `caseSensitive` is truthy.
1295
+ * comparison is case-insensitive unless `caseSensitive` is truthy. A
1296
+ * mapping compares its keys.
1223
1297
  *
1224
1298
  * @example
1225
1299
  * {{ [3, 1, 2]|min }}
@@ -1235,7 +1309,8 @@ exports.min = function (input, caseSensitive) {
1235
1309
 
1236
1310
  /**
1237
1311
  * Return the largest item of a sequence (Jinja2's `max`). String
1238
- * comparison is case-insensitive unless `caseSensitive` is truthy.
1312
+ * comparison is case-insensitive unless `caseSensitive` is truthy. A
1313
+ * mapping compares its keys.
1239
1314
  *
1240
1315
  * @example
1241
1316
  * {{ [3, 1, 2]|max }}
package/lib/index.js CHANGED
@@ -19,6 +19,16 @@ var utils = require('@rhinostone/swig-core/lib/utils'),
19
19
 
20
20
  exports.name = 'jinja2';
21
21
 
22
+ /**
23
+ * Package version number as a string. The packages of the family are
24
+ * released together, so they share one version.
25
+ * @example
26
+ * if (jinja2.version === "2.8.2") { ... }
27
+ *
28
+ * @type {String}
29
+ */
30
+ exports.version = "2.8.2";
31
+
22
32
  /**
23
33
  * Expression-level parser — Pratt-style recursive descent that consumes
24
34
  * Jinja2 lexer tokens and returns swig-core IRExpr nodes, plus the
@@ -76,7 +86,7 @@ function validateOptions(options) {
76
86
  if (!options.hasOwnProperty(key)) {
77
87
  return;
78
88
  }
79
- if (!utils.isArray(options[key]) || options[key].length !== 2) {
89
+ if (!utils.isArray(options[key]) || options[key].length !== 2 || typeof options[key][0] !== 'string' || typeof options[key][1] !== 'string') {
80
90
  throw new Error('Option "' + key + '" must be an array containing 2 different control strings.');
81
91
  }
82
92
  if (options[key][0] === options[key][1]) {
@@ -84,7 +94,7 @@ function validateOptions(options) {
84
94
  }
85
95
  utils.each(options[key], function (a, i) {
86
96
  if (a.length < 2) {
87
- throw new Error('Option "' + key + '" ' + ((i) ? 'open ' : 'close ') + 'control must be at least 2 characters. Saw "' + a + '" instead.');
97
+ throw new Error('Option "' + key + '" ' + ((i) ? 'close ' : 'open ') + 'control must be at least 2 characters. Saw "' + a + '" instead.');
88
98
  }
89
99
  });
90
100
  });
@@ -152,7 +162,14 @@ exports.Jinja2 = function (opts) {
152
162
  filters: _filters,
153
163
  validateOptions: validateOptions,
154
164
  onCompileError: function (err, options) {
155
- utils.throwError(err, null, options.filename);
165
+ var filename = options && options.filename;
166
+ // An error with no file name to add, or one whose message already
167
+ // names the file (a tag's own throwError does), is rethrown
168
+ // unchanged instead of being wrapped a second time.
169
+ if (err instanceof Error && (!filename || err.message.indexOf(' in file ' + filename) !== -1)) {
170
+ throw err;
171
+ }
172
+ utils.throwError(err, null, filename);
156
173
  }
157
174
  });
158
175
 
package/lib/parser.js CHANGED
@@ -34,24 +34,31 @@ var _reserved = ['break', 'case', 'catch', 'continue', 'debugger', 'default', 'd
34
34
  * FUNCTION/FUNCTIONEMPTY callee names — same checkpoints as the native
35
35
  * frontend.
36
36
  *
37
- * Binding-power table (shared subset; higher binds tighter):
37
+ * Binding levels, loosest first, following Jinja2's parser (parse_condexpr,
38
+ * parse_or, parse_and, parse_not, parse_compare ... parse_pow,
39
+ * parse_unary, parse_postfix, parse_filter_expr):
38
40
  *
39
- * Level | Tokens | Assoc
40
- * ------+------------------------------------+------
41
- * 1 | || / or (LOGIC) | left
42
- * 2 | && / and (LOGIC) | left
43
- * 3 | == != === !== (COMPARATOR) | left
44
- * 4 | < > <= >= in (COMPARATOR) | left
45
- * 6 | + - (OPERATOR) | left
46
- * 8 | * / % (OPERATOR) | left
47
- * post | DOTKEY BRACKETOPEN PARENOPEN | —
48
- * | FILTER FILTEREMPTY |
49
- * pfx | NOT, unary +/- | —
41
+ * Level | Tokens | Assoc
42
+ * -------+------------------------------------------+--------
43
+ * 0 | inline `a if c else b` | right
44
+ * 1 | || / or (LOGIC) | left
45
+ * 2 | && / and (LOGIC) | left
46
+ * not | NOT starting an operand of the levels | prefix
47
+ * | above; its operand runs at level 3 |
48
+ * 3 | == != === !== (COMPARATOR) | left
49
+ * 4 | < > <= >= in (COMPARATOR) | left
50
+ * 6 | + - (OPERATOR) | left
51
+ * 7 | ~ (TILDE) | left
52
+ * 8 | * / % (OPERATOR), // (FLOORDIV) | left
53
+ * pow | ** (POWER), over unary operands | left
54
+ * filter | FILTER FILTEREMPTY, IS ISNOT tests | postfix
55
+ * unary | unary + / - | prefix
56
+ * post | DOTKEY BRACKETOPEN PARENOPEN | postfix
50
57
  *
51
- * The Jinja2-only operators (`~` concat, `**` power, `//` floor-division,
52
- * inline `if`/`else`, `is` / `is not` tests) are added in subsequent
53
- * commits; their precedence slots into the gaps left here (`~` at 7,
54
- * `**` above `*`, `//` alongside `*`, inline-if at the loosest level).
58
+ * So `not a == b` is not (a == b), `1 + 2 is number` tests 2, `-x|abs` is
59
+ * abs(-x) and `2 ** 3 ** 2` is (2 ** 3) ** 2. A NOT in any other operand
60
+ * position (`a == not b`) binds its unary operand; Jinja2 rejects that
61
+ * form.
55
62
  *
56
63
  * @param {object[]} tokens LexerToken[] from swig-jinja2's lexer.
57
64
  * @param {object} [filters] Filter catalog for name validation.
@@ -83,12 +90,12 @@ exports.parseExpr = function (tokens, filters, _posOut) {
83
90
 
84
91
  function guardSegment(segment) {
85
92
  if (_dangerousProps.indexOf(segment) !== -1) {
86
- bail('Unsafe access to "' + segment + '" is not allowed in templates (CVE-2023-25345)');
93
+ bail('Unsafe access to "' + segment + '" is not allowed in templates (prototype-chain guard)');
87
94
  }
88
95
  }
89
96
  function guardBracketString(value) {
90
97
  if (_dangerousProps.indexOf(value) !== -1) {
91
- bail('Unsafe access to "' + value + '" via bracket notation is not allowed in templates (CVE-2023-25345)');
98
+ bail('Unsafe access to "' + value + '" via bracket notation is not allowed in templates (prototype-chain guard)');
92
99
  }
93
100
  }
94
101
 
@@ -105,12 +112,6 @@ exports.parseExpr = function (tokens, filters, _posOut) {
105
112
  }
106
113
  return { op: m, prec: 4 };
107
114
  }
108
- if (tok.type === _t.IS) {
109
- return { op: 'is', prec: 3 };
110
- }
111
- if (tok.type === _t.ISNOT) {
112
- return { op: 'is not', prec: 3 };
113
- }
114
115
  if (tok.type === _t.OPERATOR) {
115
116
  m = tok.match;
116
117
  if (m === '+' || m === '-') { return { op: m, prec: 6 }; }
@@ -182,7 +183,7 @@ exports.parseExpr = function (tokens, filters, _posOut) {
182
183
  }
183
184
 
184
185
  function parseTest() {
185
- var nameTok = consume();
186
+ var nameTok = consume(), pk;
186
187
  if (!nameTok) { bail('Expected test name after "is" / "is not"'); }
187
188
  var testName;
188
189
  var testArgs = [];
@@ -191,6 +192,13 @@ exports.parseExpr = function (tokens, filters, _posOut) {
191
192
  bail('Dotted names are not valid test names');
192
193
  }
193
194
  testName = nameTok.match;
195
+ pk = peek();
196
+ if (pk && pk.type === _t.PARENOPEN) {
197
+ consume();
198
+ testArgs = parseArgList(_t.PARENCLOSE);
199
+ } else if (pk && isBareTestArgStart(pk)) {
200
+ testArgs = [parsePostfix(parsePrimary())];
201
+ }
194
202
  } else if (nameTok.type === _t.FUNCTIONEMPTY) {
195
203
  testName = nameTok.match;
196
204
  } else if (nameTok.type === _t.FUNCTION) {
@@ -206,6 +214,40 @@ exports.parseExpr = function (tokens, filters, _posOut) {
206
214
  return { name: testName, args: testArgs };
207
215
  }
208
216
 
217
+ // Jinja2's parse_test takes one argument without parentheses when the
218
+ // next token starts a primary (`x is divisibleby 3`, `x is sameas y`);
219
+ // the argument is a primary with its subscripts and calls, no filter or
220
+ // test. `else` ends an inline-if, and `if` is excluded too, so
221
+ // `x is defined if c else d` keeps parsing as an inline-if (Jinja2
222
+ // rejects it).
223
+ function isBareTestArgStart(tok) {
224
+ if (tok.type === _t.VAR) {
225
+ return tok.match !== 'if' && tok.match !== 'else';
226
+ }
227
+ return tok.type === _t.STRING || tok.type === _t.NUMBER || tok.type === _t.BOOL ||
228
+ tok.type === _t.FUNCTION || tok.type === _t.FUNCTIONEMPTY ||
229
+ tok.type === _t.BRACKETOPEN || tok.type === _t.CURLYOPEN;
230
+ }
231
+
232
+ // Lower `<subject> is [not] <test>` to `_ext._test_<name>(subject,
233
+ // ...args)`; `is not` wraps the call in a unary `!`. `defined` / `none` /
234
+ // `undefined` on a VarRef subject route through IRVarRefExists instead,
235
+ // because emitVarRef coerces a missing or null lookup to "" and so loses
236
+ // the defined/undefined signal those tests depend on. Other subjects
237
+ // evaluate to a concrete value (no coercion) and use the generic helper
238
+ // the engine registers.
239
+ function lowerTest(subject, negated) {
240
+ var test = parseTest(), testCall;
241
+ if (test.args.length === 0 && subject.type === 'VarRef' && test.name === 'defined') {
242
+ testCall = ir.varRefExists(subject.path, subject.loc);
243
+ } else if (test.args.length === 0 && subject.type === 'VarRef' && (test.name === 'none' || test.name === 'undefined')) {
244
+ testCall = ir.unaryOp('!', ir.varRefExists(subject.path, subject.loc));
245
+ } else {
246
+ testCall = ir.fnCall(ir.varRef(['_ext', '_test_' + test.name]), [subject].concat(test.args));
247
+ }
248
+ return negated ? ir.unaryOp('!', testCall) : testCall;
249
+ }
250
+
209
251
  function expectBracketClose() {
210
252
  var close = consume();
211
253
  if (!close || close.type !== _t.BRACKETCLOSE) {
@@ -268,7 +310,11 @@ exports.parseExpr = function (tokens, filters, _posOut) {
268
310
  ]);
269
311
  }
270
312
 
271
- function parsePostfix(expr) {
313
+ // Subscripts and calls, then (when `withFilter`) filters and tests, as in
314
+ // Jinja2's parse_postfix and parse_filter_expr. A unary minus/plus
315
+ // operand is read without them (Jinja2's parse_unary(False)); they then
316
+ // apply to the negated value.
317
+ function parsePostfix(expr, withFilter) {
272
318
  while (true) {
273
319
  var tok = peek();
274
320
  if (!tok) { break; }
@@ -286,7 +332,10 @@ exports.parseExpr = function (tokens, filters, _posOut) {
286
332
  } else if (tok.type === _t.PARENOPEN) {
287
333
  consume();
288
334
  expr = ir.fnCall(expr, parseArgList(_t.PARENCLOSE));
289
- } else if (tok.type === _t.FILTER || tok.type === _t.FILTEREMPTY) {
335
+ } else if (withFilter && (tok.type === _t.IS || tok.type === _t.ISNOT)) {
336
+ consume();
337
+ expr = lowerTest(expr, tok.type === _t.ISNOT);
338
+ } else if (withFilter && (tok.type === _t.FILTER || tok.type === _t.FILTEREMPTY)) {
290
339
  consume();
291
340
  var fname = tok.match;
292
341
  if (filters.hasOwnProperty(fname) && typeof filters[fname] !== 'function') {
@@ -317,22 +366,22 @@ exports.parseExpr = function (tokens, filters, _posOut) {
317
366
  var m;
318
367
  switch (tok.type) {
319
368
  case _t.STRING:
320
- return parsePostfix(ir.literal('string', unquoteString(tok.match)));
369
+ return ir.literal('string', unquoteString(tok.match));
321
370
  case _t.NUMBER:
322
- return parsePostfix(ir.literal('number', parseFloat(tok.match)));
371
+ return ir.literal('number', parseFloat(tok.match));
323
372
  case _t.BOOL:
324
- return parsePostfix(ir.literal('bool', tok.match === 'true'));
373
+ return ir.literal('bool', tok.match === 'true');
325
374
  case _t.PARENOPEN:
326
375
  var grouped = parseExpression(0);
327
376
  var close = consume();
328
377
  if (!close || close.type !== _t.PARENCLOSE) {
329
378
  bail('Mismatched nesting state');
330
379
  }
331
- return parsePostfix(grouped);
380
+ return grouped;
332
381
  case _t.BRACKETOPEN:
333
- return parsePostfix(ir.arrayLiteral(parseArgList(_t.BRACKETCLOSE)));
382
+ return ir.arrayLiteral(parseArgList(_t.BRACKETCLOSE));
334
383
  case _t.CURLYOPEN:
335
- return parsePostfix(parseObjectLiteral());
384
+ return parseObjectLiteral();
336
385
  case _t.VAR:
337
386
  var path = tok.match.split('.');
338
387
  if (_reserved.indexOf(path[0]) !== -1) {
@@ -341,7 +390,7 @@ exports.parseExpr = function (tokens, filters, _posOut) {
341
390
  utils.each(path, function (segment) {
342
391
  guardSegment(segment);
343
392
  });
344
- return parsePostfix(ir.varRef(path));
393
+ return ir.varRef(path);
345
394
  case _t.FUNCTION:
346
395
  case _t.FUNCTIONEMPTY:
347
396
  m = tok.match;
@@ -350,75 +399,69 @@ exports.parseExpr = function (tokens, filters, _posOut) {
350
399
  }
351
400
  guardSegment(m);
352
401
  if (tok.type === _t.FUNCTIONEMPTY) {
353
- return parsePostfix(ir.fnCall(ir.varRef([m]), []));
402
+ return ir.fnCall(ir.varRef([m]), []);
354
403
  }
355
- return parsePostfix(ir.fnCall(ir.varRef([m]), parseArgList(_t.PARENCLOSE)));
404
+ return ir.fnCall(ir.varRef([m]), parseArgList(_t.PARENCLOSE));
356
405
  }
357
406
  bail('Unexpected token "' + tok.match + '"');
358
407
  return null;
359
408
  }
360
409
 
361
- function parseUnary() {
362
- var tok = peek();
410
+ // Jinja2 parse_unary: a unary minus/plus takes its operand without the
411
+ // filters that follow, which then apply to the negated value (`-x|abs`
412
+ // is abs(-x)). A `not` reached here sits where Jinja2 has no not-level
413
+ // (`a == not b`, `-not x`, `2 ** not x`); it binds its unary operand.
414
+ function parseUnary(withFilter) {
415
+ var tok = peek(), node;
363
416
  if (tok && tok.type === _t.NOT) {
364
417
  consume();
365
- return ir.unaryOp('!', parseUnary());
418
+ return ir.unaryOp('!', parseUnary(withFilter));
366
419
  }
367
420
  if (tok && tok.type === _t.OPERATOR && (tok.match === '+' || tok.match === '-')) {
368
421
  consume();
369
- return ir.unaryOp(tok.match, parseUnary());
422
+ node = ir.unaryOp(tok.match, parseUnary(false));
423
+ } else {
424
+ node = parsePrimary();
370
425
  }
371
- return parsePower();
426
+ return parsePostfix(node, withFilter);
372
427
  }
373
428
 
374
429
  function parsePower() {
375
- var left = parsePrimary();
430
+ var left = parseUnary(true);
376
431
  var next = peek();
377
- if (next && next.type === _t.POWER) {
432
+ while (next && next.type === _t.POWER) {
378
433
  consume();
379
- // Right-associative (2 ** 3 ** 2 === 2 ** (3 ** 2)); the exponent is a
380
- // full unary so `2 ** -3` parses. Lowered to Math.pow because the emit
381
- // is parenthesis-safe — a bare `a ** b` emission would mis-group when an
382
- // operand is itself a binary op and would SyntaxError when the base is a
383
- // unary (`-2 ** 3`). The base is a parsePrimary (not parseUnary), so a
384
- // leading minus stays with the caller: `-2 ** 2` groups as `-(2 ** 2)`,
385
- // matching Jinja2/Python.
386
- var right = parseUnary();
387
- return ir.fnCall(ir.varRef(['Math', 'pow']), [left, right]);
434
+ // Left-associative, as Jinja2's parse_pow reads its unary operands in
435
+ // a loop: `2 ** 3 ** 2` is (2 ** 3) ** 2 = 64 (Python gives 512). The
436
+ // base is a unary operand, so a leading minus belongs to it: `-2 ** 2`
437
+ // is (-2) ** 2 = 4, as Jinja2 renders (Python gives -4). Lowered to
438
+ // Math.pow because the emit is parenthesis-safe — a bare `a ** b`
439
+ // emission would mis-group when an operand is itself a binary op and
440
+ // would SyntaxError when the base is a unary (`-2 ** 3`).
441
+ left = ir.fnCall(ir.varRef(['Math', 'pow']), [left, parseUnary(true)]);
442
+ next = peek();
388
443
  }
389
444
  return left;
390
445
  }
391
446
 
447
+ // Jinja2's parse_not: a `not` that starts an operand of `and`, `or` or
448
+ // the whole expression (minPrec up to the comparison level) negates a
449
+ // level-3 expression, so `not a == b` is not (a == b) and `not x + y` is
450
+ // not (x + y).
392
451
  function parseExpression(minPrec) {
393
- var left = parseUnary();
452
+ var left, ntok = peek();
453
+ if (ntok && ntok.type === _t.NOT && minPrec <= 3) {
454
+ consume();
455
+ left = ir.unaryOp('!', parseExpression(3));
456
+ } else {
457
+ left = parsePower();
458
+ }
394
459
  while (true) {
395
460
  var tok = peek();
396
461
  if (!tok) { break; }
397
462
  var info = getBinaryOpInfo(tok);
398
463
  if (!info || info.prec < minPrec) { break; }
399
464
  consume();
400
- // `is` / `is not` — the RHS is a constrained test name + optional arg
401
- // list, not a full expression. Lower to `_ext._test_<name>(subject,
402
- // ...args)`; `is not` wraps the call in a unary `!`. `defined` /
403
- // `none` / `undefined` on a VarRef subject route through
404
- // IRVarRefExists instead, because emitVarRef coerces a missing or
405
- // null lookup to "" and so loses the defined/undefined signal those
406
- // tests depend on. Non-VarRef subjects evaluate to a concrete value
407
- // (no coercion) and fall through to the generic `_ext._test_<name>`
408
- // helper registered by the engine.
409
- if (info.op === 'is' || info.op === 'is not') {
410
- var test = parseTest();
411
- var testCall;
412
- if (test.args.length === 0 && left.type === 'VarRef' && test.name === 'defined') {
413
- testCall = ir.varRefExists(left.path, left.loc);
414
- } else if (test.args.length === 0 && left.type === 'VarRef' && (test.name === 'none' || test.name === 'undefined')) {
415
- testCall = ir.unaryOp('!', ir.varRefExists(left.path, left.loc));
416
- } else {
417
- testCall = ir.fnCall(ir.varRef(['_ext', '_test_' + test.name]), [left].concat(test.args));
418
- }
419
- left = info.op === 'is not' ? ir.unaryOp('!', testCall) : testCall;
420
- continue;
421
- }
422
465
  var right = parseExpression(info.prec + 1);
423
466
  if (info.op === '//') {
424
467
  // Floor division — JS `a // b` is a line comment, so lower to
package/lib/tags/block.js CHANGED
@@ -56,7 +56,7 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
56
56
  utils.throwError('Block name "' + nameTok.match + '" must be a bare identifier', line, opts.filename);
57
57
  }
58
58
  if (_dangerousProps.indexOf(nameTok.match) !== -1) {
59
- utils.throwError('Unsafe block name "' + nameTok.match + '" is not allowed (CVE-2023-25345)', line, opts.filename);
59
+ utils.throwError('Unsafe block name "' + nameTok.match + '" is not allowed (prototype-chain guard)', line, opts.filename);
60
60
  }
61
61
 
62
62
  pos += 1;
@@ -108,7 +108,7 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
108
108
 
109
109
  function checkName(name) {
110
110
  if (_dangerousProps.indexOf(name) !== -1) {
111
- utils.throwError('Unsafe filter name "' + name + '" is not allowed (CVE-2023-25345)', line, opts.filename);
111
+ utils.throwError('Unsafe filter name "' + name + '" is not allowed (prototype-chain guard)', line, opts.filename);
112
112
  }
113
113
  }
114
114
 
package/lib/tags/for.js CHANGED
@@ -74,7 +74,7 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
74
74
  utils.throwError('Loop variable "' + tok.match + '" must be a bare identifier in "for" tag', line, opts.filename);
75
75
  }
76
76
  if (_dangerousProps.indexOf(tok.match) !== -1) {
77
- utils.throwError('Unsafe loop variable "' + tok.match + '" is not allowed (CVE-2023-25345)', line, opts.filename);
77
+ utils.throwError('Unsafe loop variable "' + tok.match + '" is not allowed (prototype-chain guard)', line, opts.filename);
78
78
  }
79
79
  return tok.match;
80
80
  }
package/lib/tags/from.js CHANGED
@@ -91,7 +91,7 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
91
91
  utils.throwError(role + ' "' + name + '" must be a bare identifier in "from" tag', line, opts.filename);
92
92
  }
93
93
  if (_dangerousProps.indexOf(name) !== -1) {
94
- utils.throwError('Unsafe ' + role.toLowerCase() + ' "' + name + '" is not allowed (CVE-2023-25345)', line, opts.filename);
94
+ utils.throwError('Unsafe ' + role.toLowerCase() + ' "' + name + '" is not allowed (prototype-chain guard)', line, opts.filename);
95
95
  }
96
96
  }
97
97
 
@@ -106,7 +106,7 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
106
106
  utils.throwError('Import alias "' + aliasTok.match + '" must be a bare identifier in "import" tag', line, opts.filename);
107
107
  }
108
108
  if (_dangerousProps.indexOf(aliasTok.match) !== -1) {
109
- utils.throwError('Unsafe import alias "' + aliasTok.match + '" is not allowed (CVE-2023-25345)', line, opts.filename);
109
+ utils.throwError('Unsafe import alias "' + aliasTok.match + '" is not allowed (prototype-chain guard)', line, opts.filename);
110
110
  }
111
111
 
112
112
  if (peek()) {
package/lib/tags/macro.js CHANGED
@@ -80,7 +80,7 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
80
80
  utils.throwError(role + ' "' + name + '" must be a bare identifier in "macro" tag', line, opts.filename);
81
81
  }
82
82
  if (_dangerousProps.indexOf(name) !== -1) {
83
- utils.throwError('Unsafe ' + role.toLowerCase() + ' "' + name + '" is not allowed (CVE-2023-25345)', line, opts.filename);
83
+ utils.throwError('Unsafe ' + role.toLowerCase() + ' "' + name + '" is not allowed (prototype-chain guard)', line, opts.filename);
84
84
  }
85
85
  }
86
86
 
package/lib/tags/set.js CHANGED
@@ -81,17 +81,18 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
81
81
  var path = lhsTok.match.split('.');
82
82
  utils.each(path, function (segment) {
83
83
  if (_dangerousProps.indexOf(segment) !== -1) {
84
- utils.throwError('Unsafe assignment to "' + segment + '" is not allowed (CVE-2023-25345)', line, opts.filename);
84
+ utils.throwError('Unsafe assignment to "' + segment + '" is not allowed (prototype-chain guard)', line, opts.filename);
85
85
  }
86
86
  });
87
87
 
88
- // DOTKEY tail — the Jinja2 lexer already folds dotted paths into the VAR
89
- // match, but a defensive DOTKEY consumer here keeps this tag robust if a
90
- // future lexer tightening splits `foo.bar` into VAR + DOTKEY.
88
+ // DOTKEY tail — the Jinja2 lexer folds `foo.bar` into one VAR match, but a
89
+ // dot after whitespace (`foo .bar`) lexes as its own DOTKEY token, so this
90
+ // loop runs today; it also keeps the tag safe if a future lexer splits
91
+ // `foo.bar` into VAR + DOTKEY.
91
92
  while (peek() && peek().type === types.DOTKEY) {
92
93
  var dk = consume();
93
94
  if (_dangerousProps.indexOf(dk.match) !== -1) {
94
- utils.throwError('Unsafe assignment to "' + dk.match + '" is not allowed (CVE-2023-25345)', line, opts.filename);
95
+ utils.throwError('Unsafe assignment to "' + dk.match + '" is not allowed (prototype-chain guard)', line, opts.filename);
95
96
  }
96
97
  path.push(dk.match);
97
98
  }
package/lib/tags/with.js CHANGED
@@ -77,7 +77,7 @@ exports.parse = function (str, line, parser, types, stack, opts, swig, token) {
77
77
  utils.throwError('"with" assignment target "' + nameTok.match + '" must be a bare identifier', line, opts.filename);
78
78
  }
79
79
  if (_dangerousProps.indexOf(nameTok.match) !== -1) {
80
- utils.throwError('Unsafe "with" assignment to "' + nameTok.match + '" is not allowed (CVE-2023-25345)', line, opts.filename);
80
+ utils.throwError('Unsafe "with" assignment to "' + nameTok.match + '" is not allowed (prototype-chain guard)', line, opts.filename);
81
81
  }
82
82
 
83
83
  var eqTok = consume();
@@ -33,7 +33,9 @@ function isArr(v) {
33
33
  }
34
34
 
35
35
  /*!
36
- * Finite-number guard shared by the numeric tests. @private
36
+ * Number guard shared by `even`, `odd` and `divisibleby`: any number but
37
+ * NaN. An infinity passes and then fails each test's remainder check.
38
+ * @private
37
39
  */
38
40
  function isNumber(v) {
39
41
  return typeof v === 'number' && !isNaN(v);
@@ -83,13 +85,14 @@ exports['boolean'] = function (v) {
83
85
  };
84
86
 
85
87
  /**
86
- * `foo is number` — true for a finite number.
88
+ * `foo is number` — true for any JavaScript number, NaN and the
89
+ * infinities included, as Python's `numbers.Number` test.
87
90
  *
88
91
  * @param {*} v
89
92
  * @return {boolean}
90
93
  */
91
94
  exports['number'] = function (v) {
92
- return isNumber(v);
95
+ return typeof v === 'number';
93
96
  };
94
97
 
95
98
  /**
@@ -158,26 +161,53 @@ exports['sameas'] = function (v, other) {
158
161
  return v === other;
159
162
  };
160
163
 
161
- /**
162
- * `foo is lower` — true when the subject is a string equal to its own
163
- * lowercase form.
164
+ /*!
165
+ * Unicode case properties for `lower` / `upper`, as Python's
166
+ * `str.islower` / `str.isupper` read them: a string needs at least one
167
+ * cased letter, and none of the other case (a titlecase letter counts
168
+ * against both). Built in a try/catch because an engine without Unicode
169
+ * property escapes (ES2018) throws on them; it keeps the case-mapping
170
+ * fallback below. @private
171
+ */
172
+ var caseProps = (function () {
173
+ try {
174
+ return {
175
+ lower: new RegExp('\\p{Lowercase}', 'u'),
176
+ upper: new RegExp('\\p{Uppercase}', 'u'),
177
+ notLower: new RegExp('[\\p{Uppercase}\\p{Lt}]', 'u'),
178
+ notUpper: new RegExp('[\\p{Lowercase}\\p{Lt}]', 'u')
179
+ };
180
+ } catch (e) {
181
+ return null;
182
+ }
183
+ }());
184
+
185
+ /**
186
+ * `foo is lower` — true when the subject is a string with at least one
187
+ * cased letter and no uppercase or titlecase letter, as Python's
188
+ * `str.islower`: `''`, `'123'` and `'-'` are neither lower nor upper.
164
189
  *
165
190
  * @param {*} v
166
191
  * @return {boolean}
167
192
  */
168
193
  exports['lower'] = function (v) {
169
- return typeof v === 'string' && v === v.toLowerCase();
194
+ if (typeof v !== 'string') { return false; }
195
+ if (caseProps) { return !caseProps.notLower.test(v) && caseProps.lower.test(v); }
196
+ return v === v.toLowerCase() && v !== v.toUpperCase();
170
197
  };
171
198
 
172
199
  /**
173
- * `foo is upper` — true when the subject is a string equal to its own
174
- * uppercase form.
200
+ * `foo is upper` — true when the subject is a string with at least one
201
+ * cased letter and no lowercase or titlecase letter, as Python's
202
+ * `str.isupper`.
175
203
  *
176
204
  * @param {*} v
177
205
  * @return {boolean}
178
206
  */
179
207
  exports['upper'] = function (v) {
180
- return typeof v === 'string' && v === v.toUpperCase();
208
+ if (typeof v !== 'string') { return false; }
209
+ if (caseProps) { return !caseProps.notUpper.test(v) && caseProps.upper.test(v); }
210
+ return v === v.toUpperCase() && v !== v.toLowerCase();
181
211
  };
182
212
 
183
213
  /**
@@ -191,13 +221,14 @@ exports['even'] = function (v) {
191
221
  };
192
222
 
193
223
  /**
194
- * `n is odd` — true for numbers whose remainder mod 2 is non-zero.
224
+ * `n is odd` — true for an odd integer, as Python's `n % 2 == 1`: a
225
+ * negative odd integer is odd; a non-integer or an infinity is not.
195
226
  *
196
227
  * @param {number} v
197
228
  * @return {boolean}
198
229
  */
199
230
  exports['odd'] = function (v) {
200
- return isNumber(v) && v % 2 !== 0;
231
+ return isNumber(v) && ((v % 2) + 2) % 2 === 1;
201
232
  };
202
233
 
203
234
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhinostone/swig-jinja2",
3
- "version": "2.8.1",
3
+ "version": "2.8.2",
4
4
  "description": "Jinja2-syntax frontend for the @rhinostone/swig-core template engine. Part of the @rhinostone/swig multi-flavor family.",
5
5
  "keywords": [
6
6
  "template",
@@ -23,7 +23,7 @@
23
23
  "node": ">=12"
24
24
  },
25
25
  "peerDependencies": {
26
- "@rhinostone/swig-core": "2.8.1"
26
+ "@rhinostone/swig-core": "2.8.2"
27
27
  },
28
28
  "publishConfig": {
29
29
  "access": "public"