@rhinostone/swig 2.8.0 → 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,79 @@
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
+
62
+ [2.8.1](https://github.com/gina-io/swig/tree/v2.8.1) / 2026-10-20
63
+ -----------------------------------------------------------------
64
+
65
+ * **Added** A "What's new in v<version>" section at the top of the README, regenerated from the release notes at every cut
66
+
67
+ * **Fixed** Refreshed stale README claims — the benchmark summary now 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), and the security bullet no longer pins an exact CVE regression-test count nor conflates the prototype-chain hardening with CVE-2023-25345 (a directory-traversal fix)
68
+
69
+ * **Fixed** README completeness pass against the 2.x release 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, the "How it works" section explains the runtime-only strict-CSP path, and the Jinja2/Django migration notes no longer list the default filter as absent (it has been built into native swig since upstream 2014)
70
+
71
+ * **Fixed** The v0.9.0 changelog entry's PHP date-format citation now links to the canonical `https://www.php.net/manual/en/function.date.php` instead of the plain-HTTP `http://php.net/date` it had carried since upstream. The old form still resolves — it 301s through to exactly that URL — so this is the same destination over HTTPS, with the link text unchanged; it removes the last plain-HTTP link a link-health scan can flag in this repo (reported twice, gina-io/gina#51 and #58). Documentation only; no runtime or API change.
72
+
73
+ * **Fixed** The bundled DateZ attribution banner now points at `https://github.com/tn0ms/DateZ`, following the fourth rename of that upstream account. The previous `tomoio` form still resolves — GitHub 301s a renamed account through to its current owner — so this is the same destination, with the misspelled upstream `LISENCE` filename preserved because that is genuinely how the file is named there. The banner ships in every `dist/` artifact, so the corrected URL reaches consumers at the next publish. Documentation only; no runtime or API change.
74
+
75
+ * **Changed** Upgraded the ESLint dev dependency from 9.x, which its maintainers no longer support, to 10.x. The flat-config eslint.config.js carries over unchanged, and make lint reports the same results as under 9.x. ESLint 10 raises the lint toolchain Node floor to ^20.19.0, ^22.13.0 or >=24, which affects development and CI only; the published package is unaffected and its only runtime dependency remains @rhinostone/swig-core.
76
+
1
77
  [2.8.0](https://github.com/gina-io/swig/tree/v2.8.0) / 2026-08-02
2
78
  -----------------------------------------------------------------
3
79
 
@@ -565,7 +641,7 @@ Migrating from v0.x.x? The upstream wiki has since been deleted; see the individ
565
641
  * **Added** New `raw` tag.
566
642
  * **Changed** Swig is no longer node 0.4 compatible.
567
643
  * **Fixed** Filter `date('f')` for 10am times.
568
- * **Fixed** Filter `date('r')` returns in UTC date format. This is more correct tospec RFC2822, per [php.net/date](http://php.net/date).
644
+ * **Fixed** Filter `date('r')` returns in UTC date format. This is more correct tospec RFC2822, per [php.net/date](https://www.php.net/manual/en/function.date.php).
569
645
  * **Fixed** Filter `add` when adding numbers/numbers+strings together.
570
646
  * **Fixed** Tests for error messages that changed in node >0.6.0.
571
647
 
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
@@ -32,15 +32,38 @@ Features
32
32
  --------
33
33
 
34
34
  * Available for node.js **and** major web browsers.
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.
35
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`, 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.
36
38
  * Object-Oriented template inheritance.
37
39
  * Apply filters and transformations to output in your templates.
38
- * **Hardened against prototype-pollution** — `__proto__` / `constructor` / `prototype` blocked at parser, tag-side, and IR-emission layers. CVE-2023-25345 fully patched. 11 CVE regression cases under [`tests/regressions.test.js`](./tests/regressions.test.js).
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).
39
41
  * Automatically escapes all variable output (HTML by default; configurable per-call).
40
42
  * Lots of iteration and conditionals supported.
41
- * Robust without the bloat.
43
+ * Robust without the bloat — a single runtime dependency ([`@rhinostone/swig-core`](https://www.npmjs.com/package/@rhinostone/swig-core)).
42
44
  * Extendable and customizable — register custom filters, tags, and loaders per-instance.
43
45
 
46
+ What's new in v2.8.2
47
+ --------------------
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.
64
+
65
+ See the full [HISTORY.md](./HISTORY.md) and [ROADMAP.md](./ROADMAP.md).
66
+
44
67
  Benchmarks
45
68
  ----------
46
69
 
@@ -50,7 +73,7 @@ Benchmarks
50
73
  cd benchmarks && npm install && node render.js
51
74
  ```
52
75
 
53
- In production-typical settings (autoescape on), `@rhinostone/swig` outperforms Nunjucks on iteration-heavy templates by 2–3.5× and ties on simple control flow. 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.
54
77
 
55
78
  Need Help? Have Questions? Comments?
56
79
  ------------------------------------
@@ -78,7 +101,7 @@ For Django syntax:
78
101
  Documentation
79
102
  -------------
80
103
 
81
- 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.
82
105
 
83
106
  Basic Example
84
107
  -------------
@@ -130,14 +153,14 @@ Migrating from Jinja2 or Django
130
153
 
131
154
  Swig is *inspired by* Jinja2 and Django, not a drop-in replacement. Common pitfalls when porting existing templates:
132
155
 
133
- * **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).
134
157
  * **Django `forloop.counter` → Swig `loop.index`** (Swig follows Jinja2 loop-variable naming).
135
158
  * **`{{ super() }}` / `{{ block.super }}` → `{% parent %}`** — Swig uses a dedicated tag inside the overriding block.
136
159
  * **Django filter args use a colon (`|date:"Y-m-d"`) — Swig uses parens (`|date("Y-m-d")`)**.
137
160
  * **`{% with x=1 %}` → `{% set x = 1 %}`**, and no block-form `{% set %}…{% endset %}`.
138
161
  * **No `{% from "f" import x %}` — use `{% import "f" as ns %}` + `ns.x` instead**.
139
162
  * **Method calls require parens** — Django auto-invokes `x.get_absolute_url`; Swig needs `x.get_absolute_url()`.
140
- * **~25 Jinja2 filters are absent** — `default`, `truncate`, `tojson`, `round`, `int`, `float`, `map`, `select`, `batch`, `trim`, etc. Register them via `swig.setFilter(name, fn)`.
163
+ * **Many Jinja2 filters are absent** — `truncate`, `tojson`, `round`, `int`, `float`, `map`, `select`, `batch`, `trim`, etc. (`default` **is** built in). Register missing ones via `swig.setFilter(name, fn)` — or use [@rhinostone/swig-jinja2](https://www.npmjs.com/package/@rhinostone/swig-jinja2), which ships 39 of them natively.
141
164
 
142
165
  Full parity tables and workaround patterns: **[Migration Guide](https://gina.io/docs/templating/swig/migration)**.
143
166
 
@@ -148,6 +171,8 @@ Swig reads template files and translates them into cached JavaScript functions.
148
171
 
149
172
  In `2.x`, frontend parsers (native Swig syntax in [@rhinostone/swig](https://www.npmjs.com/package/@rhinostone/swig), Twig syntax in [@rhinostone/swig-twig](https://www.npmjs.com/package/@rhinostone/swig-twig), Python Jinja2 syntax in [@rhinostone/swig-jinja2](https://www.npmjs.com/package/@rhinostone/swig-jinja2), Django Template Language syntax in [@rhinostone/swig-django](https://www.npmjs.com/package/@rhinostone/swig-django)) emit a shared intermediate representation. The backend in [@rhinostone/swig-core](https://www.npmjs.com/package/@rhinostone/swig-core) lowers IR to JS. New flavors plug in at the frontend without touching the runtime.
150
173
 
174
+ For strict-CSP targets the compile pipeline can be skipped entirely: AOT-compile templates with the CLI (`swig compile --recursive --register`) and execute the resulting bundle with the runtime-only build (`dist/swig.runtime.min.js`), which contains no parser and no `new Function`.
175
+
151
176
  License
152
177
  -------
153
178
 
package/ROADMAP.md CHANGED
@@ -8,24 +8,39 @@ 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.) |
19
- | On demand | Runtime-only browser bundles for the Twig, Jinja2, and Django frontends. The shared runtime factory already supports them — each needs its own bundle entry and build target, since filter catalogs are per-flavor. The native `dist/swig.runtime.min.js` shipped in 2.8.0; the flavor bundles land when a consumer needs one. |
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.) |
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.
28
- - Added a runtime-only browser build — `dist/swig.runtime.js` and `dist/swig.runtime.min.js` (21 KB minified, roughly a quarter of the full bundle). It executes pre-compiled bundles with the full filter catalog but ships no lexer, parser, or code generator, and therefore contains no `new Function`: pages served under a strict Content-Security-Policy without `unsafe-eval` can render AOT-compiled templates. Every compiling entry point throws a clear error naming the CLI workflow instead of failing obscurely. Also available to Node as `require('@rhinostone/swig/runtime')`.
43
+ - Added a runtime-only browser build — `dist/swig.runtime.js` and `dist/swig.runtime.min.js` (22 KB minified, roughly a quarter of the full bundle). It executes pre-compiled bundles with the full filter catalog but ships no lexer, parser, or code generator, and therefore contains no `new Function`: pages served under a strict Content-Security-Policy without `unsafe-eval` can render AOT-compiled templates. Every compiling entry point throws a clear error naming the CLI workflow instead of failing obscurely. Also available to Node as `require('@rhinostone/swig/runtime')`.
29
44
  - Fixed several template-cache paths that failed silently. A registered template no longer shadows a loadable `{% extends %}` parent — the parent's source is still read through the loader, where previously registering a layout could make a child that rendered correctly start rendering an empty string with no error. The memory loader rejects a climbing path when a `basepath` is set rather than resolving it to a different template than the one requested. Passing a filepath to `run` now caches an entry a later `include` can actually use. The memory cache no longer treats inherited object members as cache hits. `register` reports a disabled cache for every falsy `cache` value, and `registerBundle` validates a whole bundle before committing any of it, so a malformed map cannot leave the registry half-populated.
30
45
  - Fixed browser integration for the new bundles: a self-registering bundle finds the engine whether it is a bare global or a property of the global object (under AMD there is no bare binding, so every template previously went unregistered in silence) and warns when no engine is present; loading a runtime bundle twice, or after the full build, no longer discards existing registrations or replaces a compiler-capable instance; and the runtime entry no longer assumes `window`, so it loads in a worker. Both minified bundles now reference the source map filename that is actually written.
31
46
  - All five packages (`@rhinostone/swig`, `@rhinostone/swig-core`, `@rhinostone/swig-twig`, `@rhinostone/swig-jinja2`, `@rhinostone/swig-django`) released in lockstep at `2.8.0`. Lockstep is now required rather than conventional: the frontends reference modules inside `@rhinostone/swig-core` that exist only from this version forward.
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/context7.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "url": "https://context7.com/gina-io/swig",
3
+ "public_key": "pk_MGD4ulFUKUHnxmzFGuWc8",
4
+ "projectTitle": "Rhinostone Swig",
5
+ "description": "Rhinostone Swig is a simple, powerful, and extendable template engine for Node.js and browsers, supporting native Swig, Twig, Python Jinja2, and Django Template Language syntaxes.",
6
+ "branch": "develop",
7
+ "excludeFolders": [
8
+ ".changes"
9
+ ],
10
+ "excludeFiles": [
11
+ "HISTORY.md",
12
+ "ROADMAP.md",
13
+ "AUTHORS",
14
+ "ISSUE_TEMPLATE.md",
15
+ "PULL_REQUEST_TEMPLATE.md"
16
+ ]
17
+ }
package/dist/swig.js CHANGED
@@ -1,9 +1,13 @@
1
- /*! Swig v2.8.0 | https://github.com/gina-io/swig | @license https://github.com/gina-io/swig/blob/master/LICENSE */
2
- /*! DateZ (c) 2011 Tomo Universalis | @license https://github.com/tomoio/DateZ/blob/master/LISENCE */
1
+ /*! Swig v2.8.2 | https://github.com/gina-io/swig | @license https://github.com/gina-io/swig/blob/master/LICENSE */
2
+ /*! DateZ (c) 2011 Tomo Universalis | @license https://github.com/tn0ms/DateZ/blob/master/LISENCE */
3
3
  (() => {
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __commonJS = (cb, mod) => function __require() {
6
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
6
+ try {
7
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
8
+ } catch (e) {
9
+ throw mod = 0, e;
10
+ }
7
11
  };
8
12
 
9
13
  // packages/swig-core/lib/security.js
@@ -120,6 +124,9 @@
120
124
  exports.range = function(from, to) {
121
125
  var out = [], i, fc, tc;
122
126
  if (typeof from === "number" && typeof to === "number") {
127
+ if (!isFinite(from) || !isFinite(to)) {
128
+ return out;
129
+ }
123
130
  if (from <= to) {
124
131
  for (i = from; i <= to; i += 1) {
125
132
  out.push(i);
@@ -1324,7 +1331,7 @@
1324
1331
  throw new Error('Loop variable "' + token.match + '" must be a bare identifier in "for" tag on line ' + line + ".");
1325
1332
  }
1326
1333
  if (_dangerousProps.indexOf(token.match) !== -1) {
1327
- 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 + ".");
1328
1335
  }
1329
1336
  if (!this.out.length) {
1330
1337
  firstVar = true;
@@ -1845,7 +1852,7 @@
1845
1852
  if (d.dangerousProps.indexOf(segment) !== -1) {
1846
1853
  var line = node && node.loc && node.loc.line || void 0;
1847
1854
  var filename = node && node.loc && node.loc.filename || void 0;
1848
- 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);
1849
1856
  }
1850
1857
  }
1851
1858
  function emitLiteral(node, d) {
@@ -1905,13 +1912,19 @@
1905
1912
  if (node.key && node.key.type === "Literal" && node.key.kind === "string") {
1906
1913
  checkDangerousSegment(node.key.value, d, node);
1907
1914
  }
1908
- return emitExpr(node.object, d) + "[" + emitExpr(node.key, d) + "]";
1915
+ return groupOperand(node.object, emitExpr(node.object, d), true) + "[" + emitExpr(node.key, d) + "]";
1909
1916
  }
1910
1917
  function isArithmeticOp(op) {
1911
1918
  return op === "+" || op === "-" || op === "*" || op === "/" || op === "%";
1912
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
+ }
1913
1926
  function emitBinaryOp(node, d) {
1914
- 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));
1915
1928
  if (isArithmeticOp(node.op)) {
1916
1929
  return left + " " + node.op + " " + right;
1917
1930
  }
@@ -1928,11 +1941,7 @@
1928
1941
  return left + node.op + right;
1929
1942
  }
1930
1943
  function emitUnaryOp(node, d) {
1931
- var operandJS = emitExpr(node.operand, d);
1932
- if (node.operand && node.operand.type === "BinaryOp") {
1933
- operandJS = "(" + operandJS + ")";
1934
- }
1935
- return node.op + operandJS;
1944
+ return node.op + groupOperand(node.operand, emitExpr(node.operand, d), true);
1936
1945
  }
1937
1946
  function emitConditional(node, d) {
1938
1947
  return "(" + emitExpr(node.test, d) + " ? " + emitExpr(node.then, d) + " : " + emitExpr(node["else"], d) + ")";
@@ -2307,7 +2316,7 @@
2307
2316
  if (lastState === _t.BRACKETOPEN) {
2308
2317
  var strippedMatch = match.replace(/^['"]|['"]$/g, "");
2309
2318
  if (_dangerousProps.indexOf(strippedMatch) !== -1) {
2310
- 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);
2311
2320
  }
2312
2321
  }
2313
2322
  self.filterApplyIdx.push(self.out.length);
@@ -2455,7 +2464,7 @@
2455
2464
  utils.throwError('Unexpected key "' + match + '"', self.line, self.filename);
2456
2465
  }
2457
2466
  if (_dangerousProps.indexOf(match) !== -1) {
2458
- 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);
2459
2468
  }
2460
2469
  self.out.push("." + match);
2461
2470
  break;
@@ -2481,7 +2490,7 @@
2481
2490
  }
2482
2491
  utils.each(match, function(segment) {
2483
2492
  if (_dangerousProps.indexOf(segment) !== -1) {
2484
- 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);
2485
2494
  }
2486
2495
  });
2487
2496
  self.filterApplyIdx.push(self.out.length);
@@ -2549,12 +2558,12 @@
2549
2558
  }
2550
2559
  function guardSegment(segment) {
2551
2560
  if (_dangerousProps.indexOf(segment) !== -1) {
2552
- 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)');
2553
2562
  }
2554
2563
  }
2555
2564
  function guardBracketString(value) {
2556
2565
  if (_dangerousProps.indexOf(value) !== -1) {
2557
- 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)');
2558
2567
  }
2559
2568
  }
2560
2569
  function getBinaryOpInfo(tok) {
@@ -3297,7 +3306,7 @@
3297
3306
  return;
3298
3307
  }
3299
3308
  if (_dangerousProps.indexOf(token.match) !== -1) {
3300
- 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 + ".");
3301
3310
  }
3302
3311
  ctx = token.match;
3303
3312
  self.out.push(ctx);
@@ -3448,21 +3457,21 @@
3448
3457
  }
3449
3458
  if (!name) {
3450
3459
  if (_dangerousProps.indexOf(token.match) !== -1) {
3451
- 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 + ".");
3452
3461
  }
3453
3462
  name = token.match;
3454
3463
  this.out.push(name);
3455
3464
  return;
3456
3465
  }
3457
3466
  if (_dangerousProps.indexOf(token.match) !== -1) {
3458
- 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 + ".");
3459
3468
  }
3460
3469
  this.out.push({ name: token.match });
3461
3470
  });
3462
3471
  parser.on(types.FUNCTION, function(token) {
3463
3472
  if (!name) {
3464
3473
  if (_dangerousProps.indexOf(token.match) !== -1) {
3465
- 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 + ".");
3466
3475
  }
3467
3476
  name = token.match;
3468
3477
  this.out.push(name);
@@ -3472,7 +3481,7 @@
3472
3481
  parser.on(types.FUNCTIONEMPTY, function(token) {
3473
3482
  if (!name) {
3474
3483
  if (_dangerousProps.indexOf(token.match) !== -1) {
3475
- 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 + ".");
3476
3485
  }
3477
3486
  name = token.match;
3478
3487
  this.out.push(name);
@@ -3609,7 +3618,7 @@
3609
3618
  var segments = token.match.split(".");
3610
3619
  for (var i = 0; i < segments.length; i++) {
3611
3620
  if (_dangerousProps.indexOf(segments[i]) !== -1) {
3612
- 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 + ".");
3613
3622
  }
3614
3623
  }
3615
3624
  nameSet += token.match;
@@ -3628,7 +3637,7 @@
3628
3637
  if (propertyName && !this.out.length) {
3629
3638
  var stripped = token.match.replace(/^['"]|['"]$/g, "");
3630
3639
  if (_dangerousProps.indexOf(stripped) !== -1) {
3631
- 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 + ".");
3632
3641
  }
3633
3642
  propertyName += token.match;
3634
3643
  return;
@@ -3648,7 +3657,7 @@
3648
3657
  return true;
3649
3658
  }
3650
3659
  if (_dangerousProps.indexOf(token.match) !== -1) {
3651
- 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 + ".");
3652
3661
  }
3653
3662
  nameSet += "." + token.match;
3654
3663
  return;
@@ -4408,6 +4417,9 @@
4408
4417
  function efn() {
4409
4418
  return "";
4410
4419
  }
4420
+ function compileCacheKey(filename, options) {
4421
+ return options && options.codegenMode === "async" ? filename + "#async" : filename;
4422
+ }
4411
4423
  exports.remapBlocks = function remapBlocks(blocks, tokens) {
4412
4424
  return utils.map(tokens, function(token) {
4413
4425
  var args = token.args ? token.args.join("") : "";
@@ -4648,7 +4660,7 @@
4648
4660
  return { tpl, tokens };
4649
4661
  };
4650
4662
  self.compile = function(source, options) {
4651
- 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;
4652
4664
  if (cached) {
4653
4665
  return cached;
4654
4666
  }
@@ -4683,7 +4695,7 @@
4683
4695
  if (!options.filename) {
4684
4696
  options = utils.extend({ filename: pathname }, options);
4685
4697
  }
4686
- cached = cacheGet(pathname, options);
4698
+ cached = cacheGet(compileCacheKey(pathname, options), options);
4687
4699
  if (cached) {
4688
4700
  if (cb) {
4689
4701
  cb(null, cached);
@@ -4810,7 +4822,7 @@
4810
4822
  var loaders = require_loaders2();
4811
4823
  var preWalker = require_pre_walker();
4812
4824
  var engine = require_engine();
4813
- exports.version = "2.8.0";
4825
+ exports.version = "2.8.2";
4814
4826
  var defaultOptions = {
4815
4827
  autoescape: true,
4816
4828
  varControls: ["{{", "}}"],
@@ -4865,7 +4877,7 @@
4865
4877
  if (!options.hasOwnProperty(key)) {
4866
4878
  return;
4867
4879
  }
4868
- 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") {
4869
4881
  throw new Error('Option "' + key + '" must be an array containing 2 different control strings.');
4870
4882
  }
4871
4883
  if (options[key][0] === options[key][1]) {
@@ -4873,7 +4885,7 @@
4873
4885
  }
4874
4886
  utils.each(options[key], function(a, i) {
4875
4887
  if (a.length < 2) {
4876
- 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.');
4877
4889
  }
4878
4890
  });
4879
4891
  });
@@ -4910,7 +4922,11 @@
4910
4922
  filters: _filters,
4911
4923
  validateOptions,
4912
4924
  onCompileError: function(err, options) {
4913
- 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);
4914
4930
  }
4915
4931
  });
4916
4932
  var self = this;
@@ -5139,6 +5155,14 @@
5139
5155
  * COMPARATOR output shape. `in` needs trailing space so the keyword
5140
5156
  * detokenises — `(a)in(b)` parses but `ain(b)` does not. @private
5141
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
+ */
5142
5166
  /*!
5143
5167
  * Emit a function / method invocation. Three callee shapes:
5144
5168
  * 1. Single-segment VarRef (`foo(...)`) — FUNCTION-token pattern with