@rhinostone/swig 2.1.0 → 2.2.0

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.
@@ -0,0 +1,8 @@
1
+ [2.2.0](https://github.com/gina-io/swig/tree/v2.2.0) / 2026-05-11
2
+ -----------------------------------------------------------------
3
+
4
+ * **Added** `renderFile(path, locals, cb)` and `compileFile(path, options, cb)` automatically route to the async-codegen path when the configured loader signals async support via `loader.async === true`. The async path defers template resolution from parse time to render time via a new `_swig.getTemplate(path, options)` runtime helper that returns `Promise<TemplateFn>` — `extends`, `include`, `import`, and `from` emit deferred IR shapes from the frontend (`IRExtendsDeferred`, `IRIncludeDeferred`, `IRImportDeferred`, `IRFromImportDeferred`) and the shared backend wraps the compiled body in an `AsyncFunction`. Block overrides thread through the inheritance chain via a sixth `_blocks` positional argument on the wrapped template function; macro imports pick up exports via the new `Promise<{output, exports}>` template-fn return shape. Both `@rhinostone/swig` and `@rhinostone/swig-twig` flavors — parity across the two surfaces. Static template targets (string literals in `extends` / `include` / `import` / `from`) work end-to-end against async loaders; dynamic targets (`{% extends parent_var %}`, `{% include user_template %}`) are not yet supported on the async path and surface a clear runtime error with the unresolved expression visible in the message — full dynamic-target support is tracked as a follow-up. The sync render path is unchanged — loaders without `loader.async === true` continue to use the established sync `_swig.compileFile(...)` resolution, including the built-in `loaders.fs` and `loaders.memory` which remain dual-mode. End-to-end coverage at `tests/async/render-file-cb-dispatch.test.js` (native, 11 cases) and `tests/swig-twig/async/render-file-cb-dispatch.test.js` (Twig, 11 cases) covers static extends chains, includes, macro imports, mixed graphs, dynamic include paths surfacing the runtime error, the `ignoreMissing` flag, `with-context` isolation, bare-name and aliased `from` import, and error propagation.
5
+
6
+ * **Changed** `renderFileAsync(path, locals, cb)` and `compileFileAsync(path, options, cb)` on both `@rhinostone/swig` and `@rhinostone/swig-twig` are soft-deprecated via JSDoc only — no runtime warning, no `console.warn`. Use `renderFile(path, locals, cb)` / `compileFile(path, options, cb)` with an async loader (`loader.async === true`) instead; the dispatch is automatic. The legacy pre-walker entry points remain fully functional in 2.x and will be removed in 3.0.
7
+
8
+ * **Changed** Improved the performance of the `escape` / `e` filter in both `@rhinostone/swig` and `@rhinostone/swig-twig`. The HTML default branch now runs a two-pass form — an entity-aware first pass that preserves already-escaped sequences (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&#39;`) followed by a single character-class regex `[<>"']` with a lookup function for the rest — instead of five sequential single-character regex passes. A scalar fast-path also skips `iterateFilter.apply` when input is null, undefined, or a non-object. Output is byte-identical to the previous behavior on every input, including the entity-preservation semantics swig has shipped since the upstream fork. Measured against `benchmarks/render.js` (medians of 5 runs, autoescape on, Node 25), simple-var-output goes from ~2.46M to 3.86M ops/s (+57%, flipping the verdict from `nunjucks 1.32x faster` to `swig 1.18x faster`); filter chain +37%; for-loop (5 items) +54%; if/else branch +71%; nested for+if+filter +56%. The `case 'js'` branch and array-iteration fallback through `iterateFilter` are unchanged. All 1443 tests pass including the 9 CVE regressions.
package/HISTORY.md CHANGED
@@ -1,3 +1,12 @@
1
+ [2.2.0](https://github.com/gina-io/swig/tree/v2.2.0) / 2026-05-11
2
+ -----------------------------------------------------------------
3
+
4
+ * **Added** `renderFile(path, locals, cb)` and `compileFile(path, options, cb)` automatically route to the async-codegen path when the configured loader signals async support via `loader.async === true`. The async path defers template resolution from parse time to render time via a new `_swig.getTemplate(path, options)` runtime helper that returns `Promise<TemplateFn>` — `extends`, `include`, `import`, and `from` emit deferred IR shapes from the frontend (`IRExtendsDeferred`, `IRIncludeDeferred`, `IRImportDeferred`, `IRFromImportDeferred`) and the shared backend wraps the compiled body in an `AsyncFunction`. Block overrides thread through the inheritance chain via a sixth `_blocks` positional argument on the wrapped template function; macro imports pick up exports via the new `Promise<{output, exports}>` template-fn return shape. Both `@rhinostone/swig` and `@rhinostone/swig-twig` flavors — parity across the two surfaces. Static template targets (string literals in `extends` / `include` / `import` / `from`) work end-to-end against async loaders; dynamic targets (`{% extends parent_var %}`, `{% include user_template %}`) are not yet supported on the async path and surface a clear runtime error with the unresolved expression visible in the message — full dynamic-target support is tracked as a follow-up. The sync render path is unchanged — loaders without `loader.async === true` continue to use the established sync `_swig.compileFile(...)` resolution, including the built-in `loaders.fs` and `loaders.memory` which remain dual-mode. End-to-end coverage at `tests/async/render-file-cb-dispatch.test.js` (native, 11 cases) and `tests/swig-twig/async/render-file-cb-dispatch.test.js` (Twig, 11 cases) covers static extends chains, includes, macro imports, mixed graphs, dynamic include paths surfacing the runtime error, the `ignoreMissing` flag, `with-context` isolation, bare-name and aliased `from` import, and error propagation.
5
+
6
+ * **Changed** `renderFileAsync(path, locals, cb)` and `compileFileAsync(path, options, cb)` on both `@rhinostone/swig` and `@rhinostone/swig-twig` are soft-deprecated via JSDoc only — no runtime warning, no `console.warn`. Use `renderFile(path, locals, cb)` / `compileFile(path, options, cb)` with an async loader (`loader.async === true`) instead; the dispatch is automatic. The legacy pre-walker entry points remain fully functional in 2.x and will be removed in 3.0.
7
+
8
+ * **Changed** Improved the performance of the `escape` / `e` filter in both `@rhinostone/swig` and `@rhinostone/swig-twig`. The HTML default branch now runs a two-pass form — an entity-aware first pass that preserves already-escaped sequences (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&#39;`) followed by a single character-class regex `[<>"']` with a lookup function for the rest — instead of five sequential single-character regex passes. A scalar fast-path also skips `iterateFilter.apply` when input is null, undefined, or a non-object. Output is byte-identical to the previous behavior on every input, including the entity-preservation semantics swig has shipped since the upstream fork. Measured against `benchmarks/render.js` (medians of 5 runs, autoescape on, Node 25), simple-var-output goes from ~2.46M to 3.86M ops/s (+57%, flipping the verdict from `nunjucks 1.32x faster` to `swig 1.18x faster`); filter chain +37%; for-loop (5 items) +54%; if/else branch +71%; nested for+if+filter +56%. The `case 'js'` branch and array-iteration fallback through `iterateFilter` are unchanged. All 1443 tests pass including the 9 CVE regressions.
9
+
1
10
  [2.1.0](https://github.com/gina-io/swig/tree/v2.1.0) / 2026-05-10
2
11
  -----------------------------------------------------------------
3
12
 
package/ROADMAP.md CHANGED
@@ -14,6 +14,7 @@ _No near-term scheduled items. See [Future (post-2.0)](#future-post-20) for upco
14
14
 
15
15
  | Status | Item |
16
16
  | --- | --- |
17
+ | Planned | Async parse path for dynamic targets — full support for `{% extends parent_var %}`, `{% include user_template %}`, and runtime-resolved `import` / `from` paths on the async-codegen branch. Static-target async dispatch shipped in 2.2.0; dynamic-target support is on hold pending consumer demand. |
17
18
  | Planned | Ship Jinja2 and Django frontends as additional `@rhinostone/swig-*` packages. On demand — when there's concrete user demand. |
18
19
  | Planned | Test framework migration. Replace mocha 1.x + expect.js with `node:test` + `node:assert/strict`, swap mocha-phantomjs for a modern browser-test harness, swap blanket for `c8`. (The Node engines bump is upstream-driven by gina and is being treated as done.) |
19
20
 
@@ -21,6 +22,12 @@ _No near-term scheduled items. See [Future (post-2.0)](#future-post-20) for upco
21
22
 
22
23
  ## Completed
23
24
 
25
+ ### v2.2.0 (May 2026)
26
+
27
+ - `renderFile(path, locals, cb)` and `compileFile(path, options, cb)` now automatically route to the async-codegen path when the configured loader signals async support via `loader.async === true`. The async path defers template resolution from parse time to render time via a new `_swig.getTemplate(path, options)` runtime helper that returns `Promise<TemplateFn>`; `extends`, `include`, `import`, and `from` emit deferred IR shapes and the shared backend wraps the compiled body in an `AsyncFunction`. Block overrides thread through the inheritance chain via a sixth `_blocks` positional argument; macro imports pick up exports via a `Promise<{output, exports}>` template-fn return shape. Both `@rhinostone/swig` and `@rhinostone/swig-twig` flavors — parity across the two surfaces. Static template targets (string literals in `extends` / `include` / `import` / `from`) work end-to-end against async loaders; dynamic targets surface a clear runtime error and are tracked as a follow-up. The sync render path is unchanged — loaders without `loader.async === true` continue to use the established sync `_swig.compileFile(...)` resolution, including the built-in `loaders.fs` and `loaders.memory` which remain dual-mode.
28
+ - `renderFileAsync(path, locals, cb)` and `compileFileAsync(path, options, cb)` on both `@rhinostone/swig` and `@rhinostone/swig-twig` are soft-deprecated via JSDoc only — no runtime warning. Use `renderFile` / `compileFile` with an async loader (`loader.async === true`) instead; the dispatch is automatic. The legacy pre-walker entry points remain fully functional in 2.x and will be removed in 3.0.
29
+ - Performance improvement to the `escape` / `e` filter in both flavors. The HTML default branch switched from a five-replace chain to an entity-preserving two-pass form (entity-aware first pass that preserves already-escaped sequences, followed by a single character-class regex with a lookup function). A scalar fast-path skips the array/object iteration when input is null, undefined, or a non-object. Output is byte-identical to the previous behavior on every input. Measured against `benchmarks/render.js` (medians of 5 runs, autoescape on, Node 25): simple-var-output `+57%` (flipping the bench verdict from `nunjucks 1.32x faster` to `swig 1.18x faster`); filter chain `+37%`; for-loop (5 items) `+54%`; if/else branch `+71%`; nested for+if+filter `+56%`.
30
+
24
31
  ### v2.1.0 (May 2026)
25
32
 
26
33
  - Async loader support via `renderFileAsync(path, locals, cb)` and `compileFileAsync(path, options, cb)` on `@rhinostone/swig` and `@rhinostone/swig-twig`. The implementation pre-walks the template dependency graph through the user loader's cb-shape arm, builds an in-memory map keyed by resolved path, then runs the existing sync render against an in-memory wrapper for the duration of the call. Supports `extends`, `include`, `import`, and Twig `from import` with string-literal paths; dynamic paths surface a `Pre-walked map missing path` error at render time. Existing sync `renderFile` / `compileFile` consumers are unaffected.
package/dist/swig.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! Swig v2.1.0 | https://github.com/gina-io/swig | @license https://github.com/gina-io/swig/blob/master/LICENSE */
1
+ /*! Swig v2.2.0 | 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/ocrybit/DateZ/blob/master/LISENCE */
3
3
  (() => {
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -844,32 +844,40 @@
844
844
  exports["default"] = function(input, def) {
845
845
  return typeof input !== "undefined" && (input || typeof input === "number") ? input : def;
846
846
  };
847
+ function escapeHtmlRest(ch) {
848
+ return ch === "<" ? "&lt;" : ch === ">" ? "&gt;" : ch === '"' ? "&quot;" : "&#39;";
849
+ }
847
850
  exports.escape = function(input, type) {
848
- var out = iterateFilter.apply(exports.escape, arguments), inp = input, i = 0, code;
849
- if (out !== void 0) {
850
- return out;
851
+ var t, inp, out, i, code;
852
+ if (input === null || input === void 0) {
853
+ return input;
851
854
  }
852
- if (typeof input !== "string") {
855
+ t = typeof input;
856
+ if (t !== "string") {
857
+ if (t === "object") {
858
+ out = iterateFilter.apply(exports.escape, arguments);
859
+ if (out !== void 0) {
860
+ return out;
861
+ }
862
+ }
853
863
  return input;
854
864
  }
855
- out = "";
856
- switch (type) {
857
- case "js":
858
- inp = inp.replace(/\\/g, "\\u005C");
859
- for (i; i < inp.length; i += 1) {
860
- code = inp.charCodeAt(i);
861
- if (code < 32) {
862
- code = code.toString(16).toUpperCase();
863
- code = code.length < 2 ? "0" + code : code;
864
- out += "\\u00" + code;
865
- } else {
866
- out += inp[i];
867
- }
865
+ if (type === "js") {
866
+ inp = input.replace(/\\/g, "\\u005C");
867
+ out = "";
868
+ for (i = 0; i < inp.length; i += 1) {
869
+ code = inp.charCodeAt(i);
870
+ if (code < 32) {
871
+ code = code.toString(16).toUpperCase();
872
+ code = code.length < 2 ? "0" + code : code;
873
+ out += "\\u00" + code;
874
+ } else {
875
+ out += inp[i];
868
876
  }
869
- return out.replace(/&/g, "\\u0026").replace(/</g, "\\u003C").replace(/>/g, "\\u003E").replace(/\'/g, "\\u0027").replace(/"/g, "\\u0022").replace(/\=/g, "\\u003D").replace(/-/g, "\\u002D").replace(/;/g, "\\u003B");
870
- default:
871
- return inp.replace(/&(?!amp;|lt;|gt;|quot;|#39;)/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
877
+ }
878
+ return out.replace(/&/g, "\\u0026").replace(/</g, "\\u003C").replace(/>/g, "\\u003E").replace(/\'/g, "\\u0027").replace(/"/g, "\\u0022").replace(/\=/g, "\\u003D").replace(/-/g, "\\u002D").replace(/;/g, "\\u003B");
872
879
  }
880
+ return input.replace(/&(?!amp;|lt;|gt;|quot;|#39;)/g, "&amp;").replace(/[<>"']/g, escapeHtmlRest);
873
881
  };
874
882
  exports.e = exports.escape;
875
883
  exports.first = function(input) {
@@ -2993,9 +3001,19 @@
2993
3001
  var require_import = __commonJS({
2994
3002
  "lib/tags/import.js"(exports) {
2995
3003
  var utils = require_utils2();
3004
+ var ir = require_ir();
2996
3005
  var backend = require_backend();
2997
3006
  var _dangerousProps = require_security().dangerousProps;
2998
- exports.compile = function(compiler, args) {
3007
+ exports.compile = function(compiler, args, content, parents, options) {
3008
+ if (options && options.codegenMode === "async") {
3009
+ var asyncAlias = args[args.length - 1];
3010
+ var asyncPath = args[0].path;
3011
+ return ir.importDeferred(
3012
+ ir.literal("string", asyncPath),
3013
+ asyncAlias,
3014
+ options.filename || ""
3015
+ );
3016
+ }
2999
3017
  var ctx = args.pop(), allMacros = utils.map(args, function(arg) {
3000
3018
  return arg.name;
3001
3019
  }).join("|"), out = "_ctx." + ctx + ' = {};\n var _output = "";\n', replacements = utils.map(args, function(arg) {
@@ -3014,27 +3032,31 @@
3014
3032
  return out;
3015
3033
  };
3016
3034
  exports.parse = function(str, line, parser, types, stack, opts, swig2) {
3017
- var compiler = require_parser().compile, parseOpts = { resolveFrom: opts.filename }, compileOpts = utils.extend({}, opts, parseOpts), tokens, ctx;
3035
+ var compiler = require_parser().compile, parseOpts = { resolveFrom: opts.filename }, compileOpts = utils.extend({}, opts, parseOpts), isAsync = !!(opts && opts.codegenMode === "async"), importPath, ctx;
3018
3036
  parser.on(types.STRING, function(token) {
3019
3037
  var self = this;
3020
- if (!tokens) {
3021
- tokens = swig2.parseFile(token.match.replace(/^("|')|("|')$/g, ""), parseOpts).tokens;
3022
- utils.each(tokens, function(token2) {
3023
- var out = "", macroName;
3024
- if (!token2 || token2.name !== "macro" || !token2.compile) {
3025
- return;
3026
- }
3027
- macroName = token2.args[0];
3028
- out += backend.compile([token2.compile(compiler, token2.args, token2.content, [], compileOpts)], [], compileOpts) + "\n";
3029
- self.out.push({ compiled: out, name: macroName });
3030
- });
3038
+ if (importPath !== void 0) {
3039
+ throw new Error("Unexpected string " + token.match + " on line " + line + ".");
3040
+ }
3041
+ importPath = token.match.replace(/^("|')|("|')$/g, "");
3042
+ if (isAsync) {
3043
+ self.out.push({ path: importPath });
3031
3044
  return;
3032
3045
  }
3033
- throw new Error("Unexpected string " + token.match + " on line " + line + ".");
3046
+ var tokens = swig2.parseFile(importPath, parseOpts).tokens;
3047
+ utils.each(tokens, function(token2) {
3048
+ var out = "", macroName;
3049
+ if (!token2 || token2.name !== "macro" || !token2.compile) {
3050
+ return;
3051
+ }
3052
+ macroName = token2.args[0];
3053
+ out += backend.compile([token2.compile(compiler, token2.args, token2.content, [], compileOpts)], [], compileOpts) + "\n";
3054
+ self.out.push({ compiled: out, name: macroName });
3055
+ });
3034
3056
  });
3035
3057
  parser.on(types.VAR, function(token) {
3036
3058
  var self = this;
3037
- if (!tokens || ctx) {
3059
+ if (importPath === void 0 || ctx) {
3038
3060
  throw new Error('Unexpected variable "' + token.match + '" on line ' + line + ".");
3039
3061
  }
3040
3062
  if (token.match === "as") {
@@ -3071,6 +3093,9 @@
3071
3093
  w = void 0;
3072
3094
  }
3073
3095
  }
3096
+ if (options && options.codegenMode === "async") {
3097
+ return ir.includeDeferred(file, w || void 0, !!onlyCtx, !!ignoreMissing, parentFile);
3098
+ }
3074
3099
  return ir.include(file, w || void 0, !!onlyCtx, !!ignoreMissing, parentFile);
3075
3100
  };
3076
3101
  exports.lowerExpr = function(parser, tokens) {
@@ -4130,6 +4155,7 @@
4130
4155
  var require_engine = __commonJS({
4131
4156
  "packages/swig-core/lib/engine.js"(exports) {
4132
4157
  var utils = require_utils();
4158
+ var ir = require_ir();
4133
4159
  var backend = require_backend();
4134
4160
  var cache = require_cache();
4135
4161
  var AsyncFunction = Object.getPrototypeOf(async function() {
@@ -4160,6 +4186,45 @@
4160
4186
  }
4161
4187
  });
4162
4188
  };
4189
+ function buildExtendsDeferred(tokens, options) {
4190
+ var childBlocks = {};
4191
+ var childIRs = [];
4192
+ utils.each(tokens.blocks, function(blockToken) {
4193
+ var args = blockToken.args ? blockToken.args.slice(0) : [];
4194
+ var content = blockToken.content ? blockToken.content.slice(0) : [];
4195
+ if (blockToken.name === "block") {
4196
+ var blockName = args.join("");
4197
+ var blockIR = blockToken.compile(backend.compile, args, content, [], options, blockName, blockToken);
4198
+ if (blockIR && typeof blockIR === "object" && typeof blockIR.type === "string") {
4199
+ childBlocks[blockName] = blockIR;
4200
+ }
4201
+ return;
4202
+ }
4203
+ var result = blockToken.compile(backend.compile, args, content, [], options, void 0, blockToken);
4204
+ if (result === void 0 || result === null || result === "") {
4205
+ return;
4206
+ }
4207
+ if (typeof result === "string") {
4208
+ childIRs.push(ir.legacyJS(result));
4209
+ return;
4210
+ }
4211
+ if (utils.isArray(result)) {
4212
+ utils.each(result, function(n) {
4213
+ childIRs.push(n);
4214
+ });
4215
+ return;
4216
+ }
4217
+ if (typeof result === "object" && typeof result.type === "string") {
4218
+ childIRs.push(result);
4219
+ }
4220
+ });
4221
+ return ir.extendsDeferred(
4222
+ ir.literal("string", tokens.parent),
4223
+ childBlocks,
4224
+ childIRs,
4225
+ options.filename || ""
4226
+ );
4227
+ }
4163
4228
  exports.getParents = function(tokens, options, deps) {
4164
4229
  var parentName = tokens.parent, parentFiles = [], parents = [], parentFile, parent, l;
4165
4230
  while (parentName) {
@@ -4267,10 +4332,16 @@
4267
4332
  return self.parse(src, options);
4268
4333
  };
4269
4334
  self.precompile = function(source, options) {
4270
- var tokens = self.parse(source, options), parents = getParentsInternal(tokens, options), tpl;
4271
- if (parents.length) {
4272
- tokens.tokens = exports.remapBlocks(tokens.blocks, parents[0].tokens);
4273
- exports.importNonBlocks(tokens.blocks, tokens.tokens);
4335
+ var tokens = self.parse(source, options), parents, tpl;
4336
+ if (options && options.codegenMode === "async" && tokens.parent) {
4337
+ parents = [];
4338
+ tokens.tokens = [buildExtendsDeferred(tokens, options)];
4339
+ } else {
4340
+ parents = getParentsInternal(tokens, options);
4341
+ if (parents.length) {
4342
+ tokens.tokens = exports.remapBlocks(tokens.blocks, parents[0].tokens);
4343
+ exports.importNonBlocks(tokens.blocks, tokens.tokens);
4344
+ }
4274
4345
  }
4275
4346
  try {
4276
4347
  tpl = exports.buildTemplateFunction(tokens, parents, options);
@@ -4394,6 +4465,14 @@
4394
4465
  };
4395
4466
  self.renderFile = function(pathName, locals, cb) {
4396
4467
  if (cb) {
4468
+ if (self.options.loader && self.options.loader.async === true) {
4469
+ self.getTemplate(pathName).then(function(fn) {
4470
+ return fn(locals);
4471
+ }).then(function(result) {
4472
+ cb(null, result.output);
4473
+ }).catch(cb);
4474
+ return;
4475
+ }
4397
4476
  self.compileFile(pathName, {}, function(err, fn) {
4398
4477
  var result;
4399
4478
  if (err) {
@@ -4434,7 +4513,7 @@
4434
4513
  var loaders = require_loaders2();
4435
4514
  var preWalker = require_pre_walker();
4436
4515
  var engine = require_engine();
4437
- exports.version = "2.1.0";
4516
+ exports.version = "2.2.0";
4438
4517
  var defaultOptions = {
4439
4518
  autoescape: true,
4440
4519
  varControls: ["{{", "}}"],