@rhinostone/swig 2.8.2 → 2.8.4

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/HISTORY.md CHANGED
@@ -1,3 +1,15 @@
1
+ [2.8.4](https://github.com/gina-io/swig/tree/v2.8.4) / 2026-10-21
2
+ -----------------------------------------------------------------
3
+
4
+ * **Fixed** A filename option that is not a string, such as a number, compiles again with import, from and extends on the async render path, as it did in 2.8.2, and include, which failed with a number in 2.8.2 as well, now compiles too. 2.8.3 escaped the filename as a string before writing it into the compiled template, so a number failed with value.replace is not a function; the filename is now converted to a string first. All four frontends.
5
+
6
+ * **Fixed** The autoescape option of @rhinostone/swig now takes the name of an escape strategy, as documented: autoescape set to 'js' gives JavaScript escaping and 'html' HTML escaping. The option was written into the compiled template as code, so these values failed with ReferenceError: js is not defined, and an autoescape option built from untrusted input could run as code in the template. A value already wrapped in quotes keeps working, and a value holding a double quote or a line break is now rejected with an error. The Twig, Jinja2 and Django frontends already passed the option to the escape filter as a string.
7
+
8
+ [2.8.3](https://github.com/gina-io/swig/tree/v2.8.3) / 2026-10-21
9
+ -----------------------------------------------------------------
10
+
11
+ * **Security** The template compiler now escapes the including template filename (resolveFrom) before writing it into the compiled function body, so a double quote in a filename option can no longer break out of the generated string literal (GHSA-q89c). Escaping is centralized in the code generator, which also fixes a latent broken-JS bug for backslash paths through import, from and extends.
12
+
1
13
  [2.8.2](https://github.com/gina-io/swig/tree/v2.8.2) / 2026-10-20
2
14
  -----------------------------------------------------------------
3
15
 
package/README.md CHANGED
@@ -43,24 +43,12 @@ Features
43
43
  * Robust without the bloat — a single runtime dependency ([`@rhinostone/swig-core`](https://www.npmjs.com/package/@rhinostone/swig-core)).
44
44
  * Extendable and customizable — register custom filters, tags, and loaders per-instance.
45
45
 
46
- What's new in v2.8.2
46
+ What's new in v2.8.4
47
47
  --------------------
48
48
 
49
- > **Upgrade if a template takes a `wordwrap` width or a range bound from request data.** swig-jinja2's `wordwrap` hung on a width below 1 and swig-twig's `a..b` on an infinite bound, which plain JSON produces for a number such as `1e999`; each took seconds and more than 2 GB of memory before failing.
50
-
51
- > **Error text changed.** The prototype-chain guards' messages end with `(prototype-chain guard)` instead of `(CVE-2023-25345)`; code that matches the old text has to match the new one.
52
-
53
- * **Security — swig-jinja2 `wordwrap` no longer hangs on a width below 1.** A width of 0 or less, as a number or as a string such as `"0"`, ran for four to six seconds and took more than 2 GB of memory before a RangeError; it now returns the input unchanged (Jinja2 raises). A width between 0 and 1 now breaks one character at a time, as Python does. Every swig-jinja2 release since 2.5.0 has the hang.
54
- * **Security — swig-twig ranges no longer hang on an infinite bound.** `a..b` with a bound of Infinity looped until the array overflowed, taking up to about three seconds and more than 2 GB of memory before a RangeError; the range is now empty, as it already was for NaN. Every release since 2.0.0-alpha.5 has the hang.
55
- * **Fixed — parenthesized sub-expressions keep their grouping, in every flavor.** Since 2.0.0-alpha.2 a group whose operator binds more loosely than the operator outside it was evaluated wrongly (`{{ (1 + 2) * 3 }}` rendered 7, and `{% if (x or y) and z %}` took its branch with x true and z false), as were a comparison grouped inside a comparison and a subscript on a `+` group, and `{{ -(-x) }}` failed to compile. Groups that JavaScript reads the same way, `not (…)`, `-(…)` and a group followed by a filter were not affected.
56
- * **Fixed — swig-jinja2 expressions follow Jinja2's precedence.** A unary minus applies before the filters and the `**` that follow it (`-2 ** 2` is 4); `**` reads from the left (`2 ** 3 ** 2` is 64); a test binds to the operand next to it (`{{ 1 + 2 is number }}` tests 2, and `not x is defined` negates the test) and takes one argument without parentheses (`x is divisibleby 3`); and `not` sits below the comparisons and arithmetic (`not x == y` is `not (x == y)`).
57
- * **Fixed — swig-jinja2 tests.** `lower` and `upper` need a cased letter, `odd` follows Python's `n % 2 == 1`, and `number` accepts NaN, as in Jinja2.
58
- * **Fixed — swig-jinja2 filters follow Jinja2.** `first`, `last`, `reverse`, `unique`, `min`, `max` and `slice` use a mapping's keys; `batch` starts a row at exactly `size` items and batches a string by its characters; `sort` of a string returns its sorted characters; `replace` converts a number first and handles an empty search string and a negative count as Python does; `format` prints `%e` with at least two exponent digits and `%g` as Python does; and `wordwrap` keeps the separating space when a line is exactly full.
59
- * **Fixed — swig-django filters follow Django.** `join` joins a string's characters and a mapping's keys; `pluralize` reads a boolean, a string and a mapping as Django does; `truncatechars` and `truncatewords` return the empty string for a count of 0 or less and the value unchanged for a count that Python's `int()` rejects.
60
- * **Fixed — the shared engine, in all four frontends.** An async-mode compile no longer shares its cache entry with a sync compile of the same file (a sync include of such a file rendered `[object Promise]`); an error raised while a template compiles is reported when no options object is passed, instead of a TypeError, and names its file once; and the `varControls`, `tagControls` and `cmtControls` options reject a control that is not a string, with the open and close controls named the right way round.
61
- * **Fixed — guard messages and version exports.** The prototype-chain guards' messages end with `(prototype-chain guard)`, and `swig-core`, `swig-twig`, `swig-jinja2` and `swig-django` export their `version`, as `swig` always has.
62
- * **Fixed — documentation.** SECURITY.md names 2.8.x as the supported line; the issue and pull-request templates no longer open with the original project's "no longer maintained" notice; and the README says that `compileFile` needs `codegenMode: 'async'` for an async loader, that its compiled function then returns a promise of `{ output, exports }`, and how to port `not in` to native swig; the swig-jinja2 and swig-django READMEs lead with `renderFile` against an async loader.
63
- * **Changed — development only.** The `globals` dev dependency moves to 17.x; `make lint` reports the same clean result.
49
+ * **Fixed — a string `autoescape` option names the escape strategy.** In `@rhinostone/swig`, `autoescape: 'js'` now gives JavaScript escaping and `autoescape: 'html'` HTML escaping, as documented. The option was written into the compiled template as code, so these values failed with `ReferenceError: js is not defined`, and an autoescape option built from untrusted input could run as code in the template. A value already wrapped in quotes keeps working, and a value holding a double quote or a line break is now rejected with an error. The Twig, Jinja2 and Django frontends already passed the option to the escape filter as a string.
50
+
51
+ * **Fixed — a numeric `filename` option compiles again.** A filename that is not a string, such as a number, compiles again with `import`, `from` and `extends` on the async render path, as it did in 2.8.2, and `include`, which failed with a number in 2.8.2 as well, now compiles too. 2.8.3 escaped the filename as a string and failed with `value.replace is not a function`; it is now converted to a string first. All four frontends.
64
52
 
65
53
  See the full [HISTORY.md](./HISTORY.md) and [ROADMAP.md](./ROADMAP.md).
66
54
 
package/ROADMAP.md CHANGED
@@ -27,6 +27,17 @@ For bug reports and feature requests, file an issue at [gina-io/swig](https://gi
27
27
 
28
28
  ## Completed
29
29
 
30
+ ### v2.8.4 (October 2026)
31
+
32
+ - Fixed the `autoescape` option of `@rhinostone/swig` set to an escape strategy name: `'js'` and `'html'` work as documented. The value was written into the compiled template as code, so these failed with a ReferenceError and an option built from untrusted input could run as code; a value holding a double quote or a line break is now rejected with an error.
33
+ - Fixed a `filename` option that is not a string, such as a number, which 2.8.3 no longer compiled with `import`, `from` and `extends` on the async render path; `include` now compiles with a number too. All four frontends.
34
+ - All five packages released in lockstep at `2.8.4`.
35
+
36
+ ### v2.8.3 (October 2026)
37
+
38
+ - Security: the template compiler escapes the including template's filename before writing it into the compiled function body, so a double quote in a `filename` option can no longer break out of the generated string literal (GHSA-q89c). Escaping is centralized in the code generator, which also fixes a latent broken-JS bug for backslash paths through `import`, `from` and `extends`.
39
+ - All five packages released in lockstep at `2.8.3`.
40
+
30
41
  ### v2.8.2 (October 2026)
31
42
 
32
43
  - Fixed two hangs that request data can reach. swig-jinja2's `wordwrap` with a width below 1, as a number or as a string such as `"0"`, ran for four to six seconds and took more than 2 GB of memory before a RangeError; it now returns the input unchanged, and a width between 0 and 1 breaks one character at a time. swig-twig's `a..b` with an infinite bound, which plain JSON produces for a number such as `1e999`, looped until the array overflowed; the range is now empty.
package/dist/swig.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! Swig v2.8.2 | https://github.com/gina-io/swig | @license https://github.com/gina-io/swig/blob/master/LICENSE */
1
+ /*! Swig v2.8.4 | https://github.com/gina-io/swig | @license https://github.com/gina-io/swig/blob/master/LICENSE */
2
2
  /*! DateZ (c) 2011 Tomo Universalis | @license https://github.com/tn0ms/DateZ/blob/master/LISENCE */
3
3
  (() => {
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -1618,7 +1618,7 @@
1618
1618
  } else {
1619
1619
  incSelector = "_utils.extend({}, _ctx, " + incCtxJS + ")";
1620
1620
  }
1621
- out += (node.ignoreMissing ? " try {\n" : "") + "_output += _swig.compileFile(" + incPathJS + ', {resolveFrom: "' + node.resolveFrom + '"})(' + incSelector + ");\n" + (node.ignoreMissing ? "} catch (e) {}\n" : "");
1621
+ out += (node.ignoreMissing ? " try {\n" : "") + "_output += _swig.compileFile(" + incPathJS + ', {resolveFrom: "' + escapeTextValue(String(node.resolveFrom || "")) + '"})(' + incSelector + ");\n" + (node.ignoreMissing ? "} catch (e) {}\n" : "");
1622
1622
  return;
1623
1623
  }
1624
1624
  if (node.type === "IncludeDeferred") {
@@ -1643,7 +1643,7 @@
1643
1643
  } else {
1644
1644
  incdSelector = "_utils.extend({}, _ctx, " + incdCtxJS + ")";
1645
1645
  }
1646
- var incdOpts = '{resolveFrom: "' + (node.resolveFrom || "") + '"}';
1646
+ var incdOpts = '{resolveFrom: "' + escapeTextValue(String(node.resolveFrom || "")) + '"}';
1647
1647
  out += (node.ignoreMissing ? " try {\n" : "") + "_output += (await (await _swig.getTemplate(" + incdPathJS + ", " + incdOpts + "))(" + incdSelector + ")).output;\n" + (node.ignoreMissing ? "} catch (e) {}\n" : "");
1648
1648
  return;
1649
1649
  }
@@ -1657,7 +1657,7 @@
1657
1657
  } else {
1658
1658
  impdPathJS = node.path;
1659
1659
  }
1660
- var impdOpts = '{resolveFrom: "' + (node.resolveFrom || "") + '"}';
1660
+ var impdOpts = '{resolveFrom: "' + escapeTextValue(String(node.resolveFrom || "")) + '"}';
1661
1661
  out += "_ctx." + node.alias + " = (await (await _swig.getTemplate(" + impdPathJS + ", " + impdOpts + "))(_ctx)).exports;\n";
1662
1662
  return;
1663
1663
  }
@@ -1678,7 +1678,7 @@
1678
1678
  } else {
1679
1679
  fromdPathJS = node.path;
1680
1680
  }
1681
- var fromdOpts = '{resolveFrom: "' + (node.resolveFrom || "") + '"}';
1681
+ var fromdOpts = '{resolveFrom: "' + escapeTextValue(String(node.resolveFrom || "")) + '"}';
1682
1682
  var fromdBindings = "";
1683
1683
  utils.each(fromdImports, function(entry) {
1684
1684
  var bindName = entry.alias || entry.name;
@@ -1715,7 +1715,7 @@
1715
1715
  } else {
1716
1716
  extPathJS = node.path;
1717
1717
  }
1718
- var extOpts = '{resolveFrom: "' + (node.resolveFrom || "") + '"}';
1718
+ var extOpts = '{resolveFrom: "' + escapeTextValue(String(node.resolveFrom || "")) + '"}';
1719
1719
  out += "var _parentTpl = await _swig.getTemplate(" + extPathJS + ", " + extOpts + ");\n";
1720
1720
  out += "var _parentResult = await _parentTpl(_ctx, _mergedBlocks);\n";
1721
1721
  out += "_output = _parentResult.output;\n";
@@ -3058,9 +3058,23 @@
3058
3058
  function escapeRegExp(str) {
3059
3059
  return str.replace(/[\-\/\\\^$*+?.()|\[\]{}]/g, "\\$&");
3060
3060
  }
3061
+ function quoteEscapeOption(value) {
3062
+ var quote;
3063
+ if (typeof value !== "string" || value === "") {
3064
+ return value;
3065
+ }
3066
+ quote = value.charAt(0);
3067
+ if (value.length > 1 && (quote === '"' || quote === "'") && value.charAt(value.length - 1) === quote) {
3068
+ value = value.slice(1, -1);
3069
+ }
3070
+ if (/["\n\r\u2028\u2029]/.test(value)) {
3071
+ throw new Error('Option "autoescape" must be true, false or an escape strategy name without double quotes or line breaks.');
3072
+ }
3073
+ return '"' + value + '"';
3074
+ }
3061
3075
  exports.parse = function(swig2, source, opts, tags, filters) {
3062
3076
  source = source.replace(/\r\n/g, "\n");
3063
- var escape = opts.autoescape, tagOpen = opts.tagControls[0], tagClose = opts.tagControls[1], varOpen = opts.varControls[0], varClose = opts.varControls[1], escapedTagOpen = escapeRegExp(tagOpen), escapedTagClose = escapeRegExp(tagClose), escapedVarOpen = escapeRegExp(varOpen), escapedVarClose = escapeRegExp(varClose), tagStrip = new RegExp("^" + escapedTagOpen + "-?\\s*|\\s*-?" + escapedTagClose + "$", "g"), tagStripBefore = new RegExp("^" + escapedTagOpen + "-"), tagStripAfter = new RegExp("-" + escapedTagClose + "$"), varStrip = new RegExp("^" + escapedVarOpen + "-?\\s*|\\s*-?" + escapedVarClose + "$", "g"), varStripBefore = new RegExp("^" + escapedVarOpen + "-"), varStripAfter = new RegExp("-" + escapedVarClose + "$"), cmtOpen = opts.cmtControls[0], cmtClose = opts.cmtControls[1], anyChar = "[\\s\\S]*?", splitter = new RegExp(
3077
+ var escape = quoteEscapeOption(opts.autoescape), tagOpen = opts.tagControls[0], tagClose = opts.tagControls[1], varOpen = opts.varControls[0], varClose = opts.varControls[1], escapedTagOpen = escapeRegExp(tagOpen), escapedTagClose = escapeRegExp(tagClose), escapedVarOpen = escapeRegExp(varOpen), escapedVarClose = escapeRegExp(varClose), tagStrip = new RegExp("^" + escapedTagOpen + "-?\\s*|\\s*-?" + escapedTagClose + "$", "g"), tagStripBefore = new RegExp("^" + escapedTagOpen + "-"), tagStripAfter = new RegExp("-" + escapedTagClose + "$"), varStrip = new RegExp("^" + escapedVarOpen + "-?\\s*|\\s*-?" + escapedVarClose + "$", "g"), varStripBefore = new RegExp("^" + escapedVarOpen + "-"), varStripAfter = new RegExp("-" + escapedVarClose + "$"), cmtOpen = opts.cmtControls[0], cmtClose = opts.cmtControls[1], anyChar = "[\\s\\S]*?", splitter = new RegExp(
3064
3078
  "(" + escapedTagOpen + anyChar + escapedTagClose + "|" + escapedVarOpen + anyChar + escapedVarClose + "|" + escapeRegExp(cmtOpen) + anyChar + escapeRegExp(cmtClose) + ")"
3065
3079
  ), line = 1, stack = [], parent = null, parentExpr = null, tokens = [], blocks = {}, inRaw = false, stripNext;
3066
3080
  function parseVariable(str, line2) {
@@ -3080,7 +3094,7 @@
3080
3094
  if (last && last.name === str.split(/\s+/)[0].replace(/^end/, "") && last.ends) {
3081
3095
  switch (last.name) {
3082
3096
  case "autoescape":
3083
- escape = opts.autoescape;
3097
+ escape = quoteEscapeOption(opts.autoescape);
3084
3098
  break;
3085
3099
  case "raw":
3086
3100
  inRaw = false;
@@ -3327,7 +3341,7 @@
3327
3341
  var missing = "missing";
3328
3342
  var only = "only";
3329
3343
  exports.compile = function(compiler, args, content, parents, options, blockName, token) {
3330
- var file = args.shift(), onlyIdx = args.indexOf(only), onlyCtx = onlyIdx !== -1 ? args.splice(onlyIdx, 1) : false, parentFile = (args.pop() || "").replace(/\\/g, "\\\\"), ignoreMissing = args[args.length - 1] === missing ? args.pop() : false, w = args.join("");
3344
+ var file = args.shift(), onlyIdx = args.indexOf(only), onlyCtx = onlyIdx !== -1 ? args.splice(onlyIdx, 1) : false, parentFile = args.pop() || "", ignoreMissing = args[args.length - 1] === missing ? args.pop() : false, w = args.join("");
3331
3345
  if (token && token.irExpr) {
3332
3346
  file = token.irExpr.file;
3333
3347
  if (token.irExpr.context !== void 0) {
@@ -4822,7 +4836,7 @@
4822
4836
  var loaders = require_loaders2();
4823
4837
  var preWalker = require_pre_walker();
4824
4838
  var engine = require_engine();
4825
- exports.version = "2.8.2";
4839
+ exports.version = "2.8.4";
4826
4840
  var defaultOptions = {
4827
4841
  autoescape: true,
4828
4842
  varControls: ["{{", "}}"],
@@ -5185,6 +5199,19 @@
5185
5199
  * @return {string}
5186
5200
  * @private
5187
5201
  */
5202
+ /*!
5203
+ * Turn a string autoescape option into the string literal that the
5204
+ * autoescape tail writes into the compiled template as the e filter's
5205
+ * strategy argument; the bare option used to be written there as code.
5206
+ * The tail doubles every backslash, so the value is wrapped in double
5207
+ * quotes without escaping it, and a value holding a double quote or a line
5208
+ * break is rejected. One pair of matching outer quotes is removed first,
5209
+ * so a value that was already quoted keeps working. A value that is not a
5210
+ * non-empty string is returned unchanged.
5211
+ * @param {*} value The autoescape option.
5212
+ * @return {*}
5213
+ * @private
5214
+ */
5188
5215
  /*!
5189
5216
  * Loop over the source, split via the tag/var/comment regular expression splitter.
5190
5217
  * Send each chunk to the appropriate parser.