@rhinostone/swig 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/HISTORY.md CHANGED
@@ -1,3 +1,64 @@
1
+ [2.8.2](https://github.com/gina-io/swig/tree/v2.8.2) / 2026-10-20
2
+ -----------------------------------------------------------------
3
+
4
+ * **Fixed** SECURITY.md's supported-versions table now names 2.8.x as the supported release line; it still listed 2.7.x.
5
+
6
+ * **Fixed** The issue and pull-request templates no longer open with the original project's "Swig is no longer maintained" notice. The issue template asks for the package and version, the environment, and a template with locals that reproduces the problem, and sends security reports to SECURITY.md; the pull-request template points to the develop branch and the test, lint and bundle checks.
7
+
8
+ * **Fixed** The README no longer says a callback to compileFile is enough to load templates through an async loader: compileFile also needs codegenMode set to async in its options, as its API documentation says, while renderFile switches on loader.async alone. It now also says that, in that mode, the compiled function returns a promise of { output, exports }. The README's documentation section now names packages/swig-core/lib/loaders/, where the loader JSDoc lives, instead of the one-line lib/loaders/ re-exports, and the swig-jinja2 and swig-django READMEs lead with renderFile against an async loader and mark renderFileAsync and compileFileAsync as deprecated.
9
+
10
+ * **Fixed** A template compiled with codegenMode set to async no longer shares its cache entry with a sync compile of the same file. A sync render or include of a file first compiled in async mode now renders its output instead of [object Promise], and an async-mode compile after a sync one now returns an async function instead of the cached sync one. The fix is in the shared engine, so all four frontends get it; the async entry is cached under its own key.
11
+
12
+ * **Fixed** An error raised while a template is compiled is now reported when render, compile or precompile is called without an options object; it used to surface as TypeError: Cannot read properties of undefined (reading 'filename'), hiding the template error. An error whose message already names the template file, such as Multiple "else" branches in "if" tag in file page.html, now names it once instead of twice, and an error with no file name to add is thrown unchanged instead of being wrapped again with an "Error: " prefix and a doubled period. All four frontends.
13
+
14
+ * **Fixed** The varControls, tagControls and cmtControls options are rejected when a control is not a string, with the message Option "<name>" must be an array containing 2 different control strings. A number or a nested array used to pass validation and fail at the first render with str.replace is not a function, and null failed inside the validator with a TypeError. All four frontends.
15
+
16
+ * **Fixed** The error for a control option that is too short now names the right control: a short open control is reported as the open control and a short close control as the close control; the two words were swapped. All four frontends.
17
+
18
+ * **Fixed** Jinja2 first, last, reverse, unique, min, max and slice now use the keys of a mapping, as Jinja2 iterates a mapping. first and last give the first and last key, reverse gives the keys in reverse order, unique the unique keys, min and max compare the keys, and slice distributes them; they used the values before. sum and random keep their results on a mapping, where Jinja2 raises.
19
+
20
+ * **Fixed** Jinja2 batch now follows Jinja2. A new row starts when the current one holds exactly size items, so a size of 0 gives an empty first row and a size that no row length can equal (negative, fractional or not a number) gives a single row; batch returned an empty list for a size of 0, a negative size or one that is not a number, rounded a fractional size up, and read a numeric string such as "2" as a number. A string is now batched by its characters and a mapping by its keys; batch returned an empty list for a string and used the values of a mapping. The fill pads the last row only up to a finite numeric size.
21
+
22
+ * **Fixed** Jinja2 sort of a string now returns its characters as a sorted list, as Jinja2 does; it joined them back into a string, so "cba" gave "abc" where Jinja2 gives ["a", "b", "c"].
23
+
24
+ * **Fixed** Jinja2 replace now follows Python. A number input is converted to a string first, so 5|replace("5", "X") gives "X" where it returned the number 5; an empty search string inserts the replacement before each character and at the end, honouring the count, so "abc"|replace("", "X") gives "XaXbXcX" where it returned "abc"; and a negative count replaces every occurrence, where it replaced none.
25
+
26
+ * **Fixed** Jinja2 format now prints %e and %E with at least two exponent digits, as Python does, so 12345.678 gives "1.234568e+04" where it gave "1.234568e+4". %g and %G now follow Python too: the precision (6 by default) counts significant digits, the number is printed in fixed notation when its decimal exponent is at least -4 and below the precision and in exponential notation otherwise, and trailing zeros are removed; they printed the number as JavaScript does, so 1234567 gave "1234567" where Python gives "1.23457e+06".
27
+
28
+ * **Fixed** swig-jinja2 wordwrap keeps the separating space when a line is exactly full before a long word, as Python textwrap does, so the output is "abcd |xxxxx|xxxxx" where it was "abcd|xxxxx|xxxxx".
29
+
30
+ * **Fixed** Django join now joins the characters of a string and the keys of a mapping, as Django does. It returned a string unchanged, so "abc"|join:"," gave "abc" where Django gives "a,b,c", and it returned a mapping itself, which rendered as [object Object].
31
+
32
+ * **Fixed** Django pluralize now reads its input as Django does. A boolean is compared with 1, so true gives the singular and false the plural, where both gave the empty string; a string must parse as a Python float, so "abc" and the empty string give the empty string where they gave the plural, and "1_000" gives the plural where it gave the singular; a mapping counts its keys, so an empty mapping gives the plural where it gave the empty string.
33
+
34
+ * **Fixed** Django truncatechars and truncatewords now return the empty string for a count of 0 or less, as Django does, where truncatechars gave the ellipsis alone and truncatewords kept a word or added an ellipsis ("a"|truncatewords:0 gave " …"). They also read the count as Python int() does, so a count string int() rejects, such as "3abc" or "2.5", returns the value unchanged where swig truncated on its leading digits.
35
+
36
+ * **Fixed** The Jinja2 lower and upper tests now need a cased letter, as Python's islower and isupper do: an empty string, digits, punctuation, whitespace or an uncased script such as Chinese is neither lower nor upper, and so is a null or missing variable; they used to be both.
37
+
38
+ * **Fixed** The Jinja2 odd test now follows Python's n % 2 == 1, so a non-integer such as 1.5, or an infinity, is not odd, as in Jinja2; it used to call them odd. A negative odd integer is still odd.
39
+
40
+ * **Fixed** The Jinja2 number test now accepts NaN, as Python's numbers.Number does; it used to reject it.
41
+
42
+ * **Fixed** Parenthesized sub-expressions keep their grouping when a template compiles, in every flavor. Since 2.0.0-alpha.2 the emitted JavaScript could lose the parentheses of a group, so 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 (("a" + "b")[1] rendered aundefined), and {{ -(-x) }} failed to compile. A group that JavaScript reads the same way without the parentheses, not (...), -(...) and a group followed by a filter rendered correctly.
43
+
44
+ * **Fixed** swig-jinja2 applies a unary minus or plus before the filters and the ** that follow it, as Jinja2 does: {{ -x|abs }} is the absolute value of -x, so it gives 5 for x 5 where it gave -5, and -2 ** 2 is (-2) ** 2, so it gives 4 where it gave -4.
45
+
46
+ * **Fixed** swig-jinja2 reads a chain of ** from the left, as Jinja2 does: 2 ** 3 ** 2 is (2 ** 3) ** 2, so it gives 64 where it gave 512.
47
+
48
+ * **Fixed** swig-jinja2 binds a test to the operand next to it, as Jinja2 does: {{ 1 + 2 is number }} tests 2, so it gives 2 where it gave true, and {% if not x is defined %} negates the test, so it is false for a defined x where it was true. A test also takes one argument without parentheses, as in Jinja2 (x is divisibleby 3, x is sameas false), where that threw an Unexpected token error.
49
+
50
+ * **Fixed** swig-jinja2 gives not its Jinja2 level, below the comparisons and arithmetic: {% if not x == y %} is not (x == y), so it is true for x 1 and y 2 where it was false, and {% if not x + y %} negates the sum, so it is false for x 0 and y 1 where it was true. A not in another operand position (x == not y) still binds its operand.
51
+
52
+ * **Fixed** The error messages of the prototype-chain guards, in every package, end with (prototype-chain guard) instead of (CVE-2023-25345). That CVE is the directory traversal through include and extends that 2.7.1 fixed in the filesystem loader, whose message keeps the label; the guards block access to __proto__, constructor and prototype. Code that matched the old text has to match the new one.
53
+
54
+ * **Fixed** swig-core, swig-twig, swig-jinja2 and swig-django export their version as a string, as swig always has; the property was undefined.
55
+
56
+ * **Changed** Upgraded the globals dev dependency, which the ESLint config reads, from 16.x to 17.x. Its node global set drops DisposableStack, AsyncDisposableStack and SuppressedError, which no linted file uses, and its mocha set is unchanged, so make lint reports the same clean result.
57
+
58
+ * **Security** swig-jinja2 wordwrap no longer hangs on a width below 1. A width of 0 or less, given 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, which hung the same way, now breaks one character at a time, as Python does. Every swig-jinja2 release since 2.5.0 has the hang; reaching it takes an application that passes a width from the request into the filter.
59
+
60
+ * **Security** swig-twig ranges no longer hang on an infinite bound. With a bound of Infinity, which plain JSON produces for a number such as 1e999, a..b 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 (PHP 8.3 rejects such a bound). Every release since 2.0.0-alpha.5 has the hang; reaching it takes an application that passes a number from the request into a range bound.
61
+
1
62
  [2.8.1](https://github.com/gina-io/swig/tree/v2.8.1) / 2026-10-20
2
63
  -----------------------------------------------------------------
3
64
 
package/ISSUE_TEMPLATE.md CHANGED
@@ -1,5 +1,13 @@
1
- # Swig is no longer maintained
1
+ <!--
2
+ Security problems: do not open a public issue; see SECURITY.md for private reporting.
3
+ -->
2
4
 
3
- Feel free to file an issue. Someone in the community may be able to help.
5
+ **Package and version** (e.g. @rhinostone/swig 2.8.1, or swig-twig, swig-jinja2, swig-django, swig-core):
4
6
 
5
- However, no new releases are being made to this repository.
7
+ **Node version, or browser and bundle:**
8
+
9
+ **Template and locals that reproduce it:**
10
+
11
+ **Expected output:**
12
+
13
+ **Actual output or error:**
@@ -1,3 +1,9 @@
1
- # Swig is no longer maintained
1
+ <!--
2
+ Please target the develop branch. See CONTRIBUTING.md.
3
+ -->
2
4
 
3
- If you're in need of a fix or a new feature, you should maintain your own fork of this repository.
5
+ **What this changes and why:**
6
+
7
+ - [ ] Tests added or updated (`make test`)
8
+ - [ ] `make lint` passes
9
+ - [ ] `make test-bundles` passes, if `lib/`, `packages/` or the build changed
package/README.md CHANGED
@@ -34,7 +34,7 @@ Features
34
34
  * Available for node.js **and** major web browsers.
35
35
  * **Strict-CSP browser runtime** — AOT-compile templates ahead of time (`swig compile --recursive --register`) and render them with the runtime-only bundle (`dist/swig.runtime.min.js`, ~22 KB) — no parser, no `new Function`, no `unsafe-eval` required.
36
36
  * [Express](https://expressjs.com/) compatible.
37
- * **Async template loading** — back the loader with S3 / Redis / HTTP / any callback API: set `loader.async = true` and pass a callback to `renderFile` / `compileFile`; `extends` / `include` / `import` resolve at render time, dynamic `{% extends %}` paths included.
37
+ * **Async template loading** — back the loader with S3 / Redis / HTTP / any callback API: set `loader.async = true` and pass a callback to `renderFile`, or to `compileFile` with `codegenMode: 'async'` in its options, whose compiled function returns a promise of `{ output, exports }`; `extends` / `include` / `import` resolve at render time, dynamic `{% extends %}` paths included.
38
38
  * Object-Oriented template inheritance.
39
39
  * Apply filters and transformations to output in your templates.
40
40
  * **Security-hardened** — prototype-pollution vectors (`__proto__` / `constructor` / `prototype`) blocked at parser, tag-side, and IR-emission layers; template-driven file reads outside the loader root rejected (CVE-2023-25345 directory traversal, patched in 2.7.1). Dedicated CVE regression suite in [`tests/regressions.test.js`](./tests/regressions.test.js).
@@ -43,14 +43,24 @@ 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.1
46
+ What's new in v2.8.2
47
47
  --------------------
48
48
 
49
- * **Fixed — README corrections now on npm.** The 2.8.0 README listed CVE-2023-25345 under the prototype-chain hardening; it is the directory-traversal fix, and the security bullet now says so without pinning an exact CVE regression-test count. The benchmark summary reflects the latest measured run (swig leads on all five workloads; the previous table predated the 2.2.0 escape-filter rewrite and showed nunjucks ahead on simple var output). The Jinja2/Django migration notes no longer list the `default` filter as absent — it has been built into native swig since upstream 2014.
50
- * **Fixed — README completeness for the 2.x line.** The feature list now covers the async-loader render path (callback dispatch with `loader.async`, dynamic `extends` included) and the single-runtime-dependency posture, and "How it works" explains the runtime-only strict-CSP path.
51
- * **Added — a "What's new" section.** The README now opens with a section like this one, regenerated from the release notes at every cut.
52
- * **Fixed — attribution and changelog links.** The bundled DateZ attribution banner points at `https://github.com/tn0ms/DateZ` after the upstream account's rename, and the v0.9.0 changelog's PHP date-format citation links to the canonical HTTPS `php.net` page. Both old links still resolved; documentation only, no runtime or API change.
53
- * **Changed — ESLint 10 for development.** ESLint 9 is no longer supported by its maintainers, so the lint toolchain moves to ESLint 10 with the flat config unchanged. Running `make lint` now needs Node ^20.19.0, ^22.13.0 or >=24; the published package and its runtime Node floor are unaffected.
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.
54
64
 
55
65
  See the full [HISTORY.md](./HISTORY.md) and [ROADMAP.md](./ROADMAP.md).
56
66
 
@@ -63,7 +73,7 @@ Benchmarks
63
73
  cd benchmarks && npm install && node render.js
64
74
  ```
65
75
 
66
- In production-typical settings (autoescape on), `@rhinostone/swig` leads Nunjucks on all five workload shapes — from ~1.8× on simple variable output to 3.5–5.5× on iteration-heavy templates (median of 3 runs, 2026-08). See [`benchmarks/README.md`](./benchmarks/README.md) for the methodology, the full result table, and how to reproduce on your own hardware.
76
+ In production-typical settings (autoescape on), `@rhinostone/swig` leads Nunjucks on all five workload shapes — from ~1.3× on simple variable output to 5.3–5.9× on iteration-heavy templates (median of 5 runs, 2026-10). See [`benchmarks/README.md`](./benchmarks/README.md) for the methodology, the full result table, and how to reproduce on your own hardware.
67
77
 
68
78
  Need Help? Have Questions? Comments?
69
79
  ------------------------------------
@@ -91,7 +101,7 @@ For Django syntax:
91
101
  Documentation
92
102
  -------------
93
103
 
94
- User-facing documentation lives in the Gina Docusaurus site under the [Swig Template Engine](https://gina.io/docs/templating/swig) section, maintained in [gina-io/docs](https://github.com/gina-io/docs) at `docs/templating/swig/`. The JSDoc blocks in `lib/swig.js`, `lib/filters.js`, `lib/tags/`, and `lib/loaders/` remain the canonical source-of-truth for the public API and are mirrored into the Docusaurus pages.
104
+ User-facing documentation lives in the Gina Docusaurus site under the [Swig Template Engine](https://gina.io/docs/templating/swig) section, maintained in [gina-io/docs](https://github.com/gina-io/docs) at `docs/templating/swig/`. The JSDoc blocks in `lib/swig.js`, `lib/filters.js`, `lib/tags/`, and `packages/swig-core/lib/loaders/` remain the canonical source-of-truth for the public API and are mirrored into the Docusaurus pages.
95
105
 
96
106
  Basic Example
97
107
  -------------
@@ -143,7 +153,7 @@ Migrating from Jinja2 or Django
143
153
 
144
154
  Swig is *inspired by* Jinja2 and Django, not a drop-in replacement. Common pitfalls when porting existing templates:
145
155
 
146
- * **No `is` / `is not` / `not in` operators** — rewrite `{% if x is defined %}` as `{% if x !== undefined %}`, `{% if x not in xs %}` as `{% if not (x in xs) %}`.
156
+ * **No `is` / `is not` / `not in` operators** — rewrite `{% if x is defined %}` as `{% if x !== undefined %}`, and `{% if x not in xs %}` for a list as `{% if xs.indexOf(x) === -1 %}` (`in` tests an object's keys or an array's indices, not membership).
147
157
  * **Django `forloop.counter` → Swig `loop.index`** (Swig follows Jinja2 loop-variable naming).
148
158
  * **`{{ super() }}` / `{{ block.super }}` → `{% parent %}`** — Swig uses a dedicated tag inside the overriding block.
149
159
  * **Django filter args use a colon (`|date:"Y-m-d"`) — Swig uses parens (`|date("Y-m-d")`)**.
package/ROADMAP.md CHANGED
@@ -8,20 +8,35 @@ For bug reports and feature requests, file an issue at [gina-io/swig](https://gi
8
8
 
9
9
  ## Next
10
10
 
11
- _No near-term scheduled items. See [Future (post-2.0)](#future-post-20) for upcoming work._
11
+ | Status | Item |
12
+ | --- | --- |
13
+ | Planned | `in` tests membership in all four flavors: `x in list` tests the elements and `x in "text"` the substring. Today `in` tests an object's keys or an array's indices, so `3 in [1, 2, 3]` is false. |
14
+ | Planned | swig-jinja2 parity: loop filters (`{% for x in xs if cond %}`), `not(x)` written without a space, the `none` / `True` / `False` constants, for-loops over a mapping and over a list of pairs as Jinja2 iterates them, and the builtin filters `attr`, `filesizeformat`, `forceescape`, `items`, `string` and `urlize`. |
15
+ | Planned | swig-django: two-variable for-loops over a list of pairs unpack each pair, as Django does. |
16
+ | Planned | Unknown filter and test names rejected when a Twig, Jinja2 or Django template compiles; today the error comes only at render, or never when the branch does not run. Native swig already rejects an unknown filter. |
12
17
 
13
18
  ## Future (post-2.0)
14
19
 
15
20
  | Status | Item |
16
21
  | --- | --- |
17
22
  | Planned | Async parse path for the remaining dynamic targets — runtime-resolved `{% import %}` / `{% from %}` paths on the async-codegen branch. Static-target async dispatch shipped in 2.2.0; dynamic `{% extends %}` shipped in 2.5.2 (native + Twig + Jinja2) and dynamic `{% include %}` paths already resolve (the include path has always been an expression). Dynamic `import` / `from` are on hold pending consumer demand. |
18
- | Planned | Modern browser-test harness. The legacy phantomjs runner was removed; a replacement (e.g. Playwright or JSDOM) has not yet landed, so browser parity is verified in the interim via the production build plus a symbol grep. (The `mocha` → `node:test` runner migration, the `expect.js` → in-repo assertion-shim swap, and the `blanket` → `node:test` built-in coverage migration have all shipped.) |
23
+ | Planned | Modern browser-test harness. The legacy phantomjs runner was removed; a replacement (e.g. Playwright or JSDOM) has not yet landed. In the interim, CI builds the four browser bundles and renders a template set through each in a Node vm context, comparing the output with the Node library. (The `mocha` → `node:test` runner migration, the `expect.js` → in-repo assertion-shim swap, and the `blanket` → `node:test` built-in coverage migration have all shipped.) |
19
24
  | On demand | Ahead-of-time browser support for the Twig, Jinja2, and Django frontends, in two parts that ship together. Runtime-only bundles: the shared runtime factory already supports them, and each flavor needs its own bundle entry and build target, since filter catalogs are per-flavor. A flavor-aware compile step: `swig compile --register` compiles with the native parser only, so flavor templates are precompiled programmatically until it lands. The native `dist/swig.runtime.min.js` shipped in 2.8.0; the flavor pieces land when a consumer needs them. |
20
25
 
21
26
  ---
22
27
 
23
28
  ## Completed
24
29
 
30
+ ### v2.8.2 (October 2026)
31
+
32
+ - 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.
33
+ - Fixed parenthesized sub-expressions in every flavor. Since 2.0.0-alpha.2 a group whose operator binds more loosely than the operator outside it, a comparison grouped inside a comparison and a subscript on a `+` group were evaluated without their parentheses, and `-(-x)` failed to compile.
34
+ - Fixed swig-jinja2 expression precedence to follow Jinja2 (a unary minus before the filters and `**`, `**` read from the left, tests bound at the filter level and taking a bare argument, `not` below the comparisons) and its `lower`, `upper`, `odd` and `number` tests.
35
+ - Fixed swig-jinja2 and swig-django filters that diverged from the reference engines: Jinja2 `first`, `last`, `reverse`, `unique`, `min`, `max`, `slice`, `batch`, `sort`, `replace`, `format` and `wordwrap`, and Django `join`, `pluralize`, `truncatechars` and `truncatewords`.
36
+ - Fixed the shared engine in all four frontends: an async-mode compile has its own cache entry, a compile error is reported when no options object is passed, and the control options must be strings.
37
+ - The prototype-chain guards' error messages end with `(prototype-chain guard)` instead of `(CVE-2023-25345)`, which is the loader's directory-traversal CVE, and every package exports its `version`.
38
+ - All five packages released in lockstep at `2.8.2`.
39
+
25
40
  ### v2.8.0 (August 2026)
26
41
 
27
42
  - Added ahead-of-time template registration: `swig.register(path, fn)` and `swig.registerBundle(map)` place pre-compiled template functions into the template cache under loader-normalized keys, so `include`, `compileFile`, and `renderFile` resolve them without a compiler. Available on all four frontends (native, Twig, Jinja2, Django). `swig compile --recursive --register` emits a bundle that registers itself on load and also exports the template map, so a page needs no per-template wiring.
package/SECURITY.md CHANGED
@@ -14,8 +14,8 @@ security fix ships across the family at the same version.
14
14
 
15
15
  | Version | Supported |
16
16
  | ------------------ | ------------------ |
17
- | `2.7.x` (latest) | :white_check_mark: |
18
- | `< 2.7.0` | :x: — upgrade |
17
+ | `2.8.x` (latest) | :white_check_mark: |
18
+ | `< 2.8.0` | :x: — upgrade |
19
19
 
20
20
  ## Reporting a vulnerability
21
21
 
package/dist/swig.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! Swig v2.8.1 | https://github.com/gina-io/swig | @license https://github.com/gina-io/swig/blob/master/LICENSE */
1
+ /*! Swig v2.8.2 | 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;
@@ -124,6 +124,9 @@
124
124
  exports.range = function(from, to) {
125
125
  var out = [], i, fc, tc;
126
126
  if (typeof from === "number" && typeof to === "number") {
127
+ if (!isFinite(from) || !isFinite(to)) {
128
+ return out;
129
+ }
127
130
  if (from <= to) {
128
131
  for (i = from; i <= to; i += 1) {
129
132
  out.push(i);
@@ -1328,7 +1331,7 @@
1328
1331
  throw new Error('Loop variable "' + token.match + '" must be a bare identifier in "for" tag on line ' + line + ".");
1329
1332
  }
1330
1333
  if (_dangerousProps.indexOf(token.match) !== -1) {
1331
- throw new Error('Unsafe loop variable "' + token.match + '" is not allowed (CVE-2023-25345) on line ' + line + ".");
1334
+ throw new Error('Unsafe loop variable "' + token.match + '" is not allowed (prototype-chain guard) on line ' + line + ".");
1332
1335
  }
1333
1336
  if (!this.out.length) {
1334
1337
  firstVar = true;
@@ -1849,7 +1852,7 @@
1849
1852
  if (d.dangerousProps.indexOf(segment) !== -1) {
1850
1853
  var line = node && node.loc && node.loc.line || void 0;
1851
1854
  var filename = node && node.loc && node.loc.filename || void 0;
1852
- d.throwError('Unsafe access to "' + segment + '" is not allowed in templates (CVE-2023-25345)', line, filename);
1855
+ d.throwError('Unsafe access to "' + segment + '" is not allowed in templates (prototype-chain guard)', line, filename);
1853
1856
  }
1854
1857
  }
1855
1858
  function emitLiteral(node, d) {
@@ -1909,13 +1912,19 @@
1909
1912
  if (node.key && node.key.type === "Literal" && node.key.kind === "string") {
1910
1913
  checkDangerousSegment(node.key.value, d, node);
1911
1914
  }
1912
- return emitExpr(node.object, d) + "[" + emitExpr(node.key, d) + "]";
1915
+ return groupOperand(node.object, emitExpr(node.object, d), true) + "[" + emitExpr(node.key, d) + "]";
1913
1916
  }
1914
1917
  function isArithmeticOp(op) {
1915
1918
  return op === "+" || op === "-" || op === "*" || op === "/" || op === "%";
1916
1919
  }
1920
+ function groupOperand(node, js, unary) {
1921
+ if (node && (node.type === "BinaryOp" || unary && node.type === "UnaryOp")) {
1922
+ return "(" + js + ")";
1923
+ }
1924
+ return js;
1925
+ }
1917
1926
  function emitBinaryOp(node, d) {
1918
- var left = emitExpr(node.left, d), right = emitExpr(node.right, d);
1927
+ var left = groupOperand(node.left, emitExpr(node.left, d)), right = groupOperand(node.right, emitExpr(node.right, d));
1919
1928
  if (isArithmeticOp(node.op)) {
1920
1929
  return left + " " + node.op + " " + right;
1921
1930
  }
@@ -1932,11 +1941,7 @@
1932
1941
  return left + node.op + right;
1933
1942
  }
1934
1943
  function emitUnaryOp(node, d) {
1935
- var operandJS = emitExpr(node.operand, d);
1936
- if (node.operand && node.operand.type === "BinaryOp") {
1937
- operandJS = "(" + operandJS + ")";
1938
- }
1939
- return node.op + operandJS;
1944
+ return node.op + groupOperand(node.operand, emitExpr(node.operand, d), true);
1940
1945
  }
1941
1946
  function emitConditional(node, d) {
1942
1947
  return "(" + emitExpr(node.test, d) + " ? " + emitExpr(node.then, d) + " : " + emitExpr(node["else"], d) + ")";
@@ -2311,7 +2316,7 @@
2311
2316
  if (lastState === _t.BRACKETOPEN) {
2312
2317
  var strippedMatch = match.replace(/^['"]|['"]$/g, "");
2313
2318
  if (_dangerousProps.indexOf(strippedMatch) !== -1) {
2314
- utils.throwError('Unsafe access to "' + strippedMatch + '" via bracket notation is not allowed in templates (CVE-2023-25345)', self.line, self.filename);
2319
+ utils.throwError('Unsafe access to "' + strippedMatch + '" via bracket notation is not allowed in templates (prototype-chain guard)', self.line, self.filename);
2315
2320
  }
2316
2321
  }
2317
2322
  self.filterApplyIdx.push(self.out.length);
@@ -2459,7 +2464,7 @@
2459
2464
  utils.throwError('Unexpected key "' + match + '"', self.line, self.filename);
2460
2465
  }
2461
2466
  if (_dangerousProps.indexOf(match) !== -1) {
2462
- utils.throwError('Unsafe access to "' + match + '" is not allowed in templates (CVE-2023-25345)', self.line, self.filename);
2467
+ utils.throwError('Unsafe access to "' + match + '" is not allowed in templates (prototype-chain guard)', self.line, self.filename);
2463
2468
  }
2464
2469
  self.out.push("." + match);
2465
2470
  break;
@@ -2485,7 +2490,7 @@
2485
2490
  }
2486
2491
  utils.each(match, function(segment) {
2487
2492
  if (_dangerousProps.indexOf(segment) !== -1) {
2488
- utils.throwError('Unsafe access to "' + segment + '" is not allowed in templates (CVE-2023-25345)', self.line, self.filename);
2493
+ utils.throwError('Unsafe access to "' + segment + '" is not allowed in templates (prototype-chain guard)', self.line, self.filename);
2489
2494
  }
2490
2495
  });
2491
2496
  self.filterApplyIdx.push(self.out.length);
@@ -2553,12 +2558,12 @@
2553
2558
  }
2554
2559
  function guardSegment(segment) {
2555
2560
  if (_dangerousProps.indexOf(segment) !== -1) {
2556
- bail('Unsafe access to "' + segment + '" is not allowed in templates (CVE-2023-25345)');
2561
+ bail('Unsafe access to "' + segment + '" is not allowed in templates (prototype-chain guard)');
2557
2562
  }
2558
2563
  }
2559
2564
  function guardBracketString(value) {
2560
2565
  if (_dangerousProps.indexOf(value) !== -1) {
2561
- bail('Unsafe access to "' + value + '" via bracket notation is not allowed in templates (CVE-2023-25345)');
2566
+ bail('Unsafe access to "' + value + '" via bracket notation is not allowed in templates (prototype-chain guard)');
2562
2567
  }
2563
2568
  }
2564
2569
  function getBinaryOpInfo(tok) {
@@ -3301,7 +3306,7 @@
3301
3306
  return;
3302
3307
  }
3303
3308
  if (_dangerousProps.indexOf(token.match) !== -1) {
3304
- throw new Error('Unsafe import alias "' + token.match + '" is not allowed (CVE-2023-25345) on line ' + line + ".");
3309
+ throw new Error('Unsafe import alias "' + token.match + '" is not allowed (prototype-chain guard) on line ' + line + ".");
3305
3310
  }
3306
3311
  ctx = token.match;
3307
3312
  self.out.push(ctx);
@@ -3452,21 +3457,21 @@
3452
3457
  }
3453
3458
  if (!name) {
3454
3459
  if (_dangerousProps.indexOf(token.match) !== -1) {
3455
- throw new Error('Unsafe macro name "' + token.match + '" is not allowed (CVE-2023-25345) on line ' + line + ".");
3460
+ throw new Error('Unsafe macro name "' + token.match + '" is not allowed (prototype-chain guard) on line ' + line + ".");
3456
3461
  }
3457
3462
  name = token.match;
3458
3463
  this.out.push(name);
3459
3464
  return;
3460
3465
  }
3461
3466
  if (_dangerousProps.indexOf(token.match) !== -1) {
3462
- throw new Error('Unsafe macro argument "' + token.match + '" is not allowed (CVE-2023-25345) on line ' + line + ".");
3467
+ throw new Error('Unsafe macro argument "' + token.match + '" is not allowed (prototype-chain guard) on line ' + line + ".");
3463
3468
  }
3464
3469
  this.out.push({ name: token.match });
3465
3470
  });
3466
3471
  parser.on(types.FUNCTION, function(token) {
3467
3472
  if (!name) {
3468
3473
  if (_dangerousProps.indexOf(token.match) !== -1) {
3469
- throw new Error('Unsafe macro name "' + token.match + '" is not allowed (CVE-2023-25345) on line ' + line + ".");
3474
+ throw new Error('Unsafe macro name "' + token.match + '" is not allowed (prototype-chain guard) on line ' + line + ".");
3470
3475
  }
3471
3476
  name = token.match;
3472
3477
  this.out.push(name);
@@ -3476,7 +3481,7 @@
3476
3481
  parser.on(types.FUNCTIONEMPTY, function(token) {
3477
3482
  if (!name) {
3478
3483
  if (_dangerousProps.indexOf(token.match) !== -1) {
3479
- throw new Error('Unsafe macro name "' + token.match + '" is not allowed (CVE-2023-25345) on line ' + line + ".");
3484
+ throw new Error('Unsafe macro name "' + token.match + '" is not allowed (prototype-chain guard) on line ' + line + ".");
3480
3485
  }
3481
3486
  name = token.match;
3482
3487
  this.out.push(name);
@@ -3613,7 +3618,7 @@
3613
3618
  var segments = token.match.split(".");
3614
3619
  for (var i = 0; i < segments.length; i++) {
3615
3620
  if (_dangerousProps.indexOf(segments[i]) !== -1) {
3616
- throw new Error('Unsafe assignment to "' + segments[i] + '" is not allowed (CVE-2023-25345) on line ' + line + ".");
3621
+ throw new Error('Unsafe assignment to "' + segments[i] + '" is not allowed (prototype-chain guard) on line ' + line + ".");
3617
3622
  }
3618
3623
  }
3619
3624
  nameSet += token.match;
@@ -3632,7 +3637,7 @@
3632
3637
  if (propertyName && !this.out.length) {
3633
3638
  var stripped = token.match.replace(/^['"]|['"]$/g, "");
3634
3639
  if (_dangerousProps.indexOf(stripped) !== -1) {
3635
- throw new Error('Unsafe assignment to "' + stripped + '" via bracket notation is not allowed (CVE-2023-25345) on line ' + line + ".");
3640
+ throw new Error('Unsafe assignment to "' + stripped + '" via bracket notation is not allowed (prototype-chain guard) on line ' + line + ".");
3636
3641
  }
3637
3642
  propertyName += token.match;
3638
3643
  return;
@@ -3652,7 +3657,7 @@
3652
3657
  return true;
3653
3658
  }
3654
3659
  if (_dangerousProps.indexOf(token.match) !== -1) {
3655
- throw new Error('Unsafe assignment to "' + token.match + '" is not allowed (CVE-2023-25345) on line ' + line + ".");
3660
+ throw new Error('Unsafe assignment to "' + token.match + '" is not allowed (prototype-chain guard) on line ' + line + ".");
3656
3661
  }
3657
3662
  nameSet += "." + token.match;
3658
3663
  return;
@@ -4412,6 +4417,9 @@
4412
4417
  function efn() {
4413
4418
  return "";
4414
4419
  }
4420
+ function compileCacheKey(filename, options) {
4421
+ return options && options.codegenMode === "async" ? filename + "#async" : filename;
4422
+ }
4415
4423
  exports.remapBlocks = function remapBlocks(blocks, tokens) {
4416
4424
  return utils.map(tokens, function(token) {
4417
4425
  var args = token.args ? token.args.join("") : "";
@@ -4652,7 +4660,7 @@
4652
4660
  return { tpl, tokens };
4653
4661
  };
4654
4662
  self.compile = function(source, options) {
4655
- var key = options ? options.filename : null, cached = key ? cacheGet(key, options) : null, context, contextLength, pre;
4663
+ var key = options && options.filename ? compileCacheKey(options.filename, options) : null, cached = key ? cacheGet(key, options) : null, context, contextLength, pre;
4656
4664
  if (cached) {
4657
4665
  return cached;
4658
4666
  }
@@ -4687,7 +4695,7 @@
4687
4695
  if (!options.filename) {
4688
4696
  options = utils.extend({ filename: pathname }, options);
4689
4697
  }
4690
- cached = cacheGet(pathname, options);
4698
+ cached = cacheGet(compileCacheKey(pathname, options), options);
4691
4699
  if (cached) {
4692
4700
  if (cb) {
4693
4701
  cb(null, cached);
@@ -4814,7 +4822,7 @@
4814
4822
  var loaders = require_loaders2();
4815
4823
  var preWalker = require_pre_walker();
4816
4824
  var engine = require_engine();
4817
- exports.version = "2.8.1";
4825
+ exports.version = "2.8.2";
4818
4826
  var defaultOptions = {
4819
4827
  autoescape: true,
4820
4828
  varControls: ["{{", "}}"],
@@ -4869,7 +4877,7 @@
4869
4877
  if (!options.hasOwnProperty(key)) {
4870
4878
  return;
4871
4879
  }
4872
- if (!utils.isArray(options[key]) || options[key].length !== 2) {
4880
+ if (!utils.isArray(options[key]) || options[key].length !== 2 || typeof options[key][0] !== "string" || typeof options[key][1] !== "string") {
4873
4881
  throw new Error('Option "' + key + '" must be an array containing 2 different control strings.');
4874
4882
  }
4875
4883
  if (options[key][0] === options[key][1]) {
@@ -4877,7 +4885,7 @@
4877
4885
  }
4878
4886
  utils.each(options[key], function(a, i) {
4879
4887
  if (a.length < 2) {
4880
- throw new Error('Option "' + key + '" ' + (i ? "open " : "close ") + 'control must be at least 2 characters. Saw "' + a + '" instead.');
4888
+ throw new Error('Option "' + key + '" ' + (i ? "close " : "open ") + 'control must be at least 2 characters. Saw "' + a + '" instead.');
4881
4889
  }
4882
4890
  });
4883
4891
  });
@@ -4914,7 +4922,11 @@
4914
4922
  filters: _filters,
4915
4923
  validateOptions,
4916
4924
  onCompileError: function(err, options) {
4917
- utils.throwError(err, null, options.filename);
4925
+ var filename = options && options.filename;
4926
+ if (err instanceof Error && (!filename || err.message.indexOf(" in file " + filename) !== -1)) {
4927
+ throw err;
4928
+ }
4929
+ utils.throwError(err, null, filename);
4918
4930
  }
4919
4931
  });
4920
4932
  var self = this;
@@ -5143,6 +5155,14 @@
5143
5155
  * COMPARATOR output shape. `in` needs trailing space so the keyword
5144
5156
  * detokenises — `(a)in(b)` parses but `ain(b)` does not. @private
5145
5157
  */
5158
+ /*!
5159
+ * Parenthesize an operand whose emitted JS could regroup. The IR tree
5160
+ * carries the grouping (`(a + b) * c` parses to a `*` node whose left
5161
+ * operand is the `+` node), so a nested binary node is wrapped or JS
5162
+ * precedence and associativity regroup it (`a + b * c`). `unary` also
5163
+ * wraps a unary operand, for the positions where its prefix would bind
5164
+ * differently (`-(-x)`, `(-x)[0]`). @private
5165
+ */
5146
5166
  /*!
5147
5167
  * Emit a function / method invocation. Three callee shapes:
5148
5168
  * 1. Single-segment VarRef (`foo(...)`) — FUNCTION-token pattern with