@rhinostone/swig-core 2.0.1 → 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.
package/lib/backend.js CHANGED
@@ -273,6 +273,19 @@ exports.compile = function (template, parents, options, blockName) {
273
273
  ' return _output;\n' +
274
274
  '};\n' +
275
275
  '_ctx.' + node.name + '.safe = true;\n';
276
+ // Phase 3 (#T22): async-codegen mirrors the macro into _exports so
277
+ // {% import %} / Twig's {% from %} can bind macros across templates
278
+ // at runtime. The macro's closure still references _ctx (not
279
+ // _exports) for outer-ctx lookups — sync-semantics-preserving.
280
+ // Top-level filter is implicit: the existing macro body walker
281
+ // (above) only handles Text/Raw/LegacyJS children, so nested
282
+ // IRMacro never reaches this branch.
283
+ if (options && options.codegenMode === 'async') {
284
+ if (_security.dangerousProps.indexOf(node.name) !== -1) {
285
+ throw new Error('Macro name "' + node.name + '" is reserved.');
286
+ }
287
+ out += '_exports.' + node.name + ' = _ctx.' + node.name + ';\n';
288
+ }
276
289
  return;
277
290
  }
278
291
  if (node.type === 'Parent') {
@@ -295,6 +308,33 @@ exports.compile = function (template, parents, options, blockName) {
295
308
  // block, its body carries whichever generation's content is active.
296
309
  // Emit the body verbatim; the block name is carried as metadata for
297
310
  // downstream tooling (parent-chain walks happen in the parent tag).
311
+ //
312
+ // Phase 3 (#T22) async mode: block remapping happens at runtime
313
+ // instead of parse time. When _blocks is supplied (by an extending
314
+ // child via IRExtendsDeferred-emitted `_parent(_ctx, _mergedBlocks)`)
315
+ // and contains an override for this block name, call the override
316
+ // async fn and append its return string. Otherwise emit the parent's
317
+ // own body inline as the default (matches sync behavior). The
318
+ // override fn closes over the calling template's own _swig / _filters
319
+ // / _utils / _ext, since it was constructed inside that template's
320
+ // body wrapper — it does NOT see this template's local _output, which
321
+ // is correct (the override returns its own string and the outer
322
+ // _output += await ... appends it here).
323
+ if (options && options.codegenMode === 'async') {
324
+ var blockNameJS = JSON.stringify(node.name);
325
+ out += 'if (_blocks && _blocks[' + blockNameJS + ']) {\n';
326
+ out += ' _output += await _blocks[' + blockNameJS + '](_ctx);\n';
327
+ out += '} else {\n';
328
+ utils.each(node.body, function (b) {
329
+ if (b.type === 'LegacyJS') { out += b.js; return; }
330
+ if (b.type === 'Text' || b.type === 'Raw') {
331
+ out += '_output += "' + escapeTextValue(b.value) + '";\n';
332
+ return;
333
+ }
334
+ });
335
+ out += '}\n';
336
+ return;
337
+ }
298
338
  utils.each(node.body, function (b) {
299
339
  if (b.type === 'LegacyJS') { out += b.js; return; }
300
340
  if (b.type === 'Text' || b.type === 'Raw') {
@@ -343,6 +383,157 @@ exports.compile = function (template, parents, options, blockName) {
343
383
  (node.ignoreMissing ? '} catch (e) {}\n' : '');
344
384
  return;
345
385
  }
386
+ if (node.type === 'IncludeDeferred') {
387
+ // Phase 3 (#T22): async-codegen counterpart to Include. Resolves the
388
+ // path via `_swig.getTemplate(...)` at render time (Promise<TemplateFn>),
389
+ // then awaits the resolved template fn's call. The double-await flows
390
+ // correctly whether the loaded tpl is sync (returns string) or async-
391
+ // compiled (returns Promise<string>) — `await <non-Promise>` resolves
392
+ // to the value unchanged. Mirrors the Include branch's path/context/
393
+ // isolated/ignoreMissing dispatch; only the runtime call shape differs.
394
+ var incdPathJS, incdCtxJS;
395
+ if (node.path && typeof node.path === 'object' && typeof node.path.type === 'string') {
396
+ incdPathJS = exports.emitExpr(node.path);
397
+ } else {
398
+ incdPathJS = node.path;
399
+ }
400
+ if (node.context !== undefined) {
401
+ if (typeof node.context === 'object' && typeof node.context.type === 'string') {
402
+ incdCtxJS = exports.emitExpr(node.context);
403
+ } else {
404
+ incdCtxJS = node.context;
405
+ }
406
+ }
407
+ var incdSelector;
408
+ if (node.isolated && incdCtxJS) {
409
+ incdSelector = incdCtxJS;
410
+ } else if (!incdCtxJS) {
411
+ incdSelector = '_ctx';
412
+ } else {
413
+ incdSelector = '_utils.extend({}, _ctx, ' + incdCtxJS + ')';
414
+ }
415
+ var incdOpts = '{resolveFrom: "' + (node.resolveFrom || '') + '"}';
416
+ out += (node.ignoreMissing ? ' try {\n' : '') +
417
+ '_output += (await (await _swig.getTemplate(' + incdPathJS + ', ' + incdOpts + '))(' + incdSelector + ')).output;\n' +
418
+ (node.ignoreMissing ? '} catch (e) {}\n' : '');
419
+ return;
420
+ }
421
+ if (node.type === 'ImportDeferred') {
422
+ // Phase 3 (#T22): async-codegen counterpart to the parse-time
423
+ // import flow. The imported template is loaded via _swig.getTemplate
424
+ // and called with the parent's _ctx so its body's compiled JS
425
+ // closes over outer-ctx vars (sync-semantics-preserving). Only
426
+ // the resolved value's `.exports` is bound; the imported body's
427
+ // `.output` is discarded — import doesn't render.
428
+ if (_security.dangerousProps.indexOf(node.alias) !== -1) {
429
+ throw new Error('Import alias "' + node.alias + '" is reserved.');
430
+ }
431
+ var impdPathJS;
432
+ if (node.path && typeof node.path === 'object' && typeof node.path.type === 'string') {
433
+ impdPathJS = exports.emitExpr(node.path);
434
+ } else {
435
+ impdPathJS = node.path;
436
+ }
437
+ var impdOpts = '{resolveFrom: "' + (node.resolveFrom || '') + '"}';
438
+ out += '_ctx.' + node.alias + ' = (await (await _swig.getTemplate(' + impdPathJS + ', ' + impdOpts + '))(_ctx)).exports;\n';
439
+ return;
440
+ }
441
+ if (node.type === 'FromImportDeferred') {
442
+ // Phase 3 (#T22): async-codegen counterpart to Twig's `{% from
443
+ // "file" import a, b as c %}`. Single getTemplate call, then
444
+ // per-entry bind onto _ctx. Async IIFE keeps `_imp` local so
445
+ // multiple from-imports in the same template don't collide.
446
+ var fromdImports = node.imports || [];
447
+ utils.each(fromdImports, function (entry) {
448
+ if (_security.dangerousProps.indexOf(entry.name) !== -1) {
449
+ throw new Error('From-import name "' + entry.name + '" is reserved.');
450
+ }
451
+ var bindName = entry.alias || entry.name;
452
+ if (_security.dangerousProps.indexOf(bindName) !== -1) {
453
+ throw new Error('From-import binding "' + bindName + '" is reserved.');
454
+ }
455
+ });
456
+ var fromdPathJS;
457
+ if (node.path && typeof node.path === 'object' && typeof node.path.type === 'string') {
458
+ fromdPathJS = exports.emitExpr(node.path);
459
+ } else {
460
+ fromdPathJS = node.path;
461
+ }
462
+ var fromdOpts = '{resolveFrom: "' + (node.resolveFrom || '') + '"}';
463
+ var fromdBindings = '';
464
+ utils.each(fromdImports, function (entry) {
465
+ var bindName = entry.alias || entry.name;
466
+ fromdBindings += ' _ctx.' + bindName + ' = _imp["' + entry.name + '"];\n';
467
+ });
468
+ out += 'await (async function () {\n' +
469
+ ' var _imp = (await (await _swig.getTemplate(' + fromdPathJS + ', ' + fromdOpts + '))(_ctx)).exports;\n' +
470
+ fromdBindings +
471
+ '})();\n';
472
+ return;
473
+ }
474
+ if (node.type === 'ExtendsDeferred') {
475
+ // Phase 3 (#T22): async-codegen counterpart to engine.getParents +
476
+ // engine.precompile's parse-time block remap. Inverts at runtime
477
+ // via the _blocks parameter contract:
478
+ //
479
+ // 1. childIRs preludes (set / import / macros) emit via recursive
480
+ // backend.compile so they populate _ctx and _exports with the
481
+ // child's own bindings before the parent runs.
482
+ // 2. Build _localChildBlocks — each block in node.childBlocks
483
+ // becomes an `async function (_ctx) -> Promise<string>` that
484
+ // shadows _output to a local accumulator and returns it. The
485
+ // block body uses the same shallow Text/Raw/LegacyJS walker as
486
+ // the IRBlock branch above.
487
+ // 3. Merge: _mergedBlocks = _utils.extend({}, _localChildBlocks,
488
+ // _blocks || {}). Inherited overrides from a deeper child win
489
+ // per "child blocks override parent's wholesale per name"
490
+ // (matches sync's remapBlocks). Multi-level chain (child →
491
+ // middle → grandparent) propagates correctly: middle merges
492
+ // its own block bodies with the inherited child overrides,
493
+ // and grandparent sees the merged map.
494
+ // 4. Resolve parent via _swig.getTemplate (Promise<TemplateFn>),
495
+ // then await _parent(_ctx, _mergedBlocks). Parent's body's
496
+ // IRBlock branch consults _mergedBlocks and uses overrides
497
+ // where present, defaults otherwise.
498
+ // 5. _output = _parentResult.output. Parent's _exports is
499
+ // DISCARDED — _exports stays as the child's own preludes-
500
+ // populated map. Matches sync where {% import "child" %}
501
+ // exposes child's own top-level macros only and never
502
+ // traverses extends. One semantic across sync and async.
503
+ if (node.childIRs && node.childIRs.length) {
504
+ out += exports.compile(node.childIRs, parents, options);
505
+ }
506
+ out += 'var _localChildBlocks = {};\n';
507
+ if (node.childBlocks) {
508
+ utils.each(node.childBlocks, function (block, name) {
509
+ var blockBodyJS = '';
510
+ utils.each(block.body, function (b) {
511
+ if (b.type === 'LegacyJS') { blockBodyJS += b.js; return; }
512
+ if (b.type === 'Text' || b.type === 'Raw') {
513
+ blockBodyJS += '_output += "' + escapeTextValue(b.value) + '";\n';
514
+ return;
515
+ }
516
+ });
517
+ out += '_localChildBlocks[' + JSON.stringify(name) + '] = async function (_ctx) {\n' +
518
+ ' var _output = "";\n' +
519
+ blockBodyJS +
520
+ ' return _output;\n' +
521
+ '};\n';
522
+ });
523
+ }
524
+ out += 'var _mergedBlocks = _utils.extend({}, _localChildBlocks, _blocks || {});\n';
525
+ var extPathJS;
526
+ if (node.path && typeof node.path === 'object' && typeof node.path.type === 'string') {
527
+ extPathJS = exports.emitExpr(node.path);
528
+ } else {
529
+ extPathJS = node.path;
530
+ }
531
+ var extOpts = '{resolveFrom: "' + (node.resolveFrom || '') + '"}';
532
+ out += 'var _parentTpl = await _swig.getTemplate(' + extPathJS + ', ' + extOpts + ');\n';
533
+ out += 'var _parentResult = await _parentTpl(_ctx, _mergedBlocks);\n';
534
+ out += '_output = _parentResult.output;\n';
535
+ return;
536
+ }
346
537
  if (node.type === 'With') {
347
538
  // Phase 3 Session 12: scoped-context region (Twig's `{% with %}`).
348
539
  // Emits an IIFE that shadows `_ctx` for the body's lexical scope;
@@ -606,20 +797,19 @@ function checkDotExpr(path, ctxPrefix) {
606
797
  }
607
798
 
608
799
  /*!
609
- * Replica of `TokenParser.prototype.checkMatch`. Kept as a local private
610
- * helper rather than imported from tokenparser.js because (a) it is a
611
- * pure function of its argument and (b) the backend must not acquire a
612
- * runtime dependency on the TokenParser module (which is a specific
800
+ * Build the dot-path emit. `(checkDot_ctx ? _ctx.<path> : (checkDot_closure
801
+ * ? <path> : ""))` checks the `_ctx.<path>` walk first, falls back to a
802
+ * bare-closure walk (covers macro params, host-globals like `Math`), and
803
+ * coerces a missing path to `""` for safe interpolation. Kept as a local
804
+ * private helper rather than imported from tokenparser.js because (a) it
805
+ * is a pure function of its argument and (b) the backend must not acquire
806
+ * a runtime dependency on the TokenParser module (which is a specific
613
807
  * frontend concern, not a shared-backend one). @private
614
808
  */
615
809
  function checkMatchExpr(match) {
616
- var result;
617
-
618
- function buildDot(ctx) {
619
- return '(' + checkDotExpr(match, ctx) + ' ? ' + ctx + match.join('.') + ' : "")';
620
- }
621
- result = '(' + checkDotExpr(match, '_ctx.') + ' ? ' + buildDot('_ctx.') + ' : ' + buildDot('') + ')';
622
- return '(' + result + ' !== null ? ' + result + ' : ' + '"" )';
810
+ var leaf = match.join('.');
811
+ return '(' + checkDotExpr(match, '_ctx.') + ' ? _ctx.' + leaf
812
+ + ' : (' + checkDotExpr(match, '') + ' ? ' + leaf + ' : ""))';
623
813
  }
624
814
 
625
815
  /*!
package/lib/engine.js CHANGED
@@ -1,7 +1,19 @@
1
1
  var utils = require('./utils'),
2
+ ir = require('./ir'),
2
3
  backend = require('./backend'),
3
4
  cache = require('./cache');
4
5
 
6
+ /**
7
+ * Constructor for AsyncFunction. Not a global; resolved once at module
8
+ * load via the prototype-chain walk of an async function expression.
9
+ * Used by {@link buildTemplateFunction} to wrap async-codegen template
10
+ * bodies so they return a Promise. Available in any engine that supports
11
+ * the `async function` syntax — Node ≥ 7.6 / all modern browsers; the
12
+ * package's engine floor (>=12) guarantees it.
13
+ * @private
14
+ */
15
+ var AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
16
+
5
17
  /**
6
18
  * Empty function used as a fallback in compiled template code.
7
19
  * @return {string} Empty string.
@@ -68,6 +80,82 @@ exports.importNonBlocks = function (blocks, tokens) {
68
80
  });
69
81
  };
70
82
 
83
+ /**
84
+ * Build an `IRExtendsDeferred` node from a parsed child template.
85
+ *
86
+ * Walks `tokens.blocks` (the parser's map of every block-level tag that
87
+ * appeared at top level — both `{% block %}` overrides and non-block
88
+ * preludes like `{% set %}`, `{% import %}`, `{% macro %}`) and:
89
+ *
90
+ * - For each `{% block %}` token: invokes its `.compile(...)` with an
91
+ * empty parents list to obtain an `IRBlock` (body is one `IRLegacyJS`
92
+ * wrapping the recursively-compiled JS source — same shape sync mode
93
+ * produces). The IRBlock is keyed under the block name in `childBlocks`.
94
+ * - For each non-`block` block-level token: invokes its `.compile(...)`
95
+ * and wraps any JS-string return in `IRLegacyJS`. The resulting IR
96
+ * node is pushed onto `childIRs` (the prelude list).
97
+ *
98
+ * The backend's `IRExtendsDeferred` emit branch handles runtime parent
99
+ * resolution via `_swig.getTemplate` and the `_blocks` parameter contract
100
+ * (see `packages/swig-core/lib/backend.js`).
101
+ *
102
+ * `tokens.parent` is lifted into an `IRLiteral('string', …)` so the
103
+ * backend's `emitExpr` path produces a quoted JS string literal. The
104
+ * native parser stashes `tokens.parent` as the literal text of the
105
+ * extends argument after stripping enclosing quotes (lib/parser.js:273),
106
+ * which works for `{% extends "layout.html" %}` but for a bare
107
+ * identifier (`{% extends parent_var %}`) yields a pre-lowered JS
108
+ * source fragment such as `((typeof _ctx.parent_var !== "undefined")
109
+ * ? _ctx.parent_var : …)`. Embedded here as a string literal it
110
+ * becomes a garbage template lookup at runtime
111
+ * (`Template not found: /((typeof _ctx.parent_var …`). Closing the
112
+ * dynamic-extends gap requires the parser to stash an IRExpr on
113
+ * `tokens.parent` and this helper to lower it through
114
+ * `ir.extendsDeferred`'s `parentExpr` slot — already designed as
115
+ * `<IRExpr>` per the deferred IR contract.
116
+ *
117
+ * @param {object} tokens Parsed child template (must have `.parent`).
118
+ * @param {object} options Per-call Swig options; `options.filename` is
119
+ * used as the deferred resolveFrom.
120
+ * @return {object} IRExtendsDeferred node.
121
+ * @private
122
+ */
123
+ function buildExtendsDeferred(tokens, options) {
124
+ var childBlocks = {};
125
+ var childIRs = [];
126
+ utils.each(tokens.blocks, function (blockToken) {
127
+ var args = blockToken.args ? blockToken.args.slice(0) : [];
128
+ var content = blockToken.content ? blockToken.content.slice(0) : [];
129
+ if (blockToken.name === 'block') {
130
+ var blockName = args.join('');
131
+ var blockIR = blockToken.compile(backend.compile, args, content, [], options, blockName, blockToken);
132
+ if (blockIR && typeof blockIR === 'object' && typeof blockIR.type === 'string') {
133
+ childBlocks[blockName] = blockIR;
134
+ }
135
+ return;
136
+ }
137
+ var result = blockToken.compile(backend.compile, args, content, [], options, undefined, blockToken);
138
+ if (result === undefined || result === null || result === '') { return; }
139
+ if (typeof result === 'string') {
140
+ childIRs.push(ir.legacyJS(result));
141
+ return;
142
+ }
143
+ if (utils.isArray(result)) {
144
+ utils.each(result, function (n) { childIRs.push(n); });
145
+ return;
146
+ }
147
+ if (typeof result === 'object' && typeof result.type === 'string') {
148
+ childIRs.push(result);
149
+ }
150
+ });
151
+ return ir.extendsDeferred(
152
+ ir.literal('string', tokens.parent),
153
+ childBlocks,
154
+ childIRs,
155
+ options.filename || ''
156
+ );
157
+ }
158
+
71
159
  /**
72
160
  * Walk a template's `extends` chain and build the parent token tree.
73
161
  * Detects circular inheritance.
@@ -132,24 +220,46 @@ exports.getParents = function (tokens, options, deps) {
132
220
  * contract every tag's compile() output depends on — `_output`, `_ext`,
133
221
  * `_ctx`, etc. are referenced by name in emitted code.
134
222
  *
223
+ * When `options.codegenMode === 'async'` the body is wrapped with
224
+ * AsyncFunction instead so the function returns a
225
+ * `Promise<{output: string, exports: object}>`. The exports map collects
226
+ * top-level macros so async importers (`IRImportDeferred` /
227
+ * `IRFromImportDeferred`) can bind them under their alias at runtime
228
+ * without parse-time pre-resolution. Async mode is also the consumer
229
+ * of the deferred-resolution IR shapes (IRIncludeDeferred,
230
+ * IRImportDeferred, IRFromImportDeferred, IRExtendsDeferred) which emit
231
+ * `await` calls into the body. The async wrapper takes a 6th positional
232
+ * `_blocks` parameter — an optional `{name: async fn(_ctx) -> string}`
233
+ * map of block overrides supplied by an extending child template. The
234
+ * IRBlock async-emit consults `_blocks[name]` first and falls back to
235
+ * the parent's inline body when no override is registered. Sync mode
236
+ * argument list is unchanged.
237
+ *
135
238
  * Filename attribution on compile-time failures lives on the frontend
136
239
  * per the seam rule (the caller knows which template the body came from
137
240
  * and can attach that via options.filename in its own try/catch). This
138
- * helper only throws whatever `new Function(...)` throws — the caller
139
- * can catch and rewrap.
241
+ * helper only throws whatever the wrapper constructor throws — the
242
+ * caller can catch and rewrap.
140
243
  *
141
244
  * @param {object|array} tokens Parsed token tree.
142
245
  * @param {array} [parents] Parent tokens from getParents().
143
246
  * @param {object} [options] Swig options object.
144
- * @return {Function} Template function.
247
+ * @return {Function} Template function (sync) or async template function (Promise-returning).
145
248
  */
146
249
  exports.buildTemplateFunction = function (tokens, parents, options) {
147
- return new Function('_swig', '_ctx', '_filters', '_utils', '_fn',
148
- ' var _ext = _swig.extensions,\n' +
250
+ if (options && options.codegenMode === 'async') {
251
+ var asyncBody = ' var _ext = _swig.extensions,\n' +
252
+ ' _output = "",\n' +
253
+ ' _exports = {};\n' +
254
+ backend.compile(tokens, parents, options) + '\n' +
255
+ ' return { output: _output, exports: _exports };\n';
256
+ return new AsyncFunction('_swig', '_ctx', '_filters', '_utils', '_fn', '_blocks', asyncBody);
257
+ }
258
+ var body = ' var _ext = _swig.extensions,\n' +
149
259
  ' _output = "";\n' +
150
260
  backend.compile(tokens, parents, options) + '\n' +
151
- ' return _output;\n'
152
- );
261
+ ' return _output;\n';
262
+ return new Function('_swig', '_ctx', '_filters', '_utils', '_fn', body);
153
263
  };
154
264
 
155
265
  /**
@@ -274,12 +384,24 @@ exports.install = function (self, frontend) {
274
384
 
275
385
  self.precompile = function (source, options) {
276
386
  var tokens = self.parse(source, options),
277
- parents = getParentsInternal(tokens, options),
387
+ parents,
278
388
  tpl;
279
389
 
280
- if (parents.length) {
281
- tokens.tokens = exports.remapBlocks(tokens.blocks, parents[0].tokens);
282
- exports.importNonBlocks(tokens.blocks, tokens.tokens);
390
+ if (options && options.codegenMode === 'async' && tokens.parent) {
391
+ // Async extends — defer parent walking and block remapping to
392
+ // runtime via the IRExtendsDeferred emit branch in backend.js.
393
+ // The deferred node carries the child's blocks + non-block
394
+ // preludes; the backend resolves the parent via _swig.getTemplate
395
+ // and threads block overrides through the _blocks parameter
396
+ // contract.
397
+ parents = [];
398
+ tokens.tokens = [buildExtendsDeferred(tokens, options)];
399
+ } else {
400
+ parents = getParentsInternal(tokens, options);
401
+ if (parents.length) {
402
+ tokens.tokens = exports.remapBlocks(tokens.blocks, parents[0].tokens);
403
+ exports.importNonBlocks(tokens.blocks, tokens.tokens);
404
+ }
283
405
  }
284
406
 
285
407
  try {
@@ -306,7 +428,7 @@ exports.install = function (self, frontend) {
306
428
  contextLength = utils.keys(context).length;
307
429
  pre = self.precompile(source, options);
308
430
 
309
- function compiled(locals) {
431
+ function compiled(locals, blocks) {
310
432
  var lcls;
311
433
  if (locals && contextLength) {
312
434
  lcls = utils.extend({}, context, locals);
@@ -317,7 +439,13 @@ exports.install = function (self, frontend) {
317
439
  } else {
318
440
  lcls = {};
319
441
  }
320
- return pre.tpl(self, lcls, filters, utils, efn);
442
+ // `blocks` is the optional async-mode block-override map supplied
443
+ // by an extending child template's IRExtendsDeferred-emitted
444
+ // `_parent(_ctx, _mergedBlocks)` call. Sync templates ignore the
445
+ // extra positional arg (regular Function discards unread args);
446
+ // async wrapper picks it up as `_blocks` and consults it from
447
+ // IRBlock emits.
448
+ return pre.tpl(self, lcls, filters, utils, efn, blocks);
321
449
  }
322
450
 
323
451
  utils.extend(compiled, pre.tokens);
@@ -374,12 +502,102 @@ exports.install = function (self, frontend) {
374
502
  return self.compile(src, options);
375
503
  };
376
504
 
505
+ /**
506
+ * Async-codegen runtime helper. Resolves a template path via the active
507
+ * loader (cb-shape preferred when supported, sync fallback otherwise),
508
+ * compiles the source in async-codegen mode, and returns a Promise that
509
+ * resolves to the compiled async template function. The compiled
510
+ * function, when called, returns a `Promise<{output: string, exports:
511
+ * object}>` — `output` is the rendered text, `exports` is the
512
+ * top-level-macro map used by `IRImportDeferred` /
513
+ * `IRFromImportDeferred` callers.
514
+ *
515
+ * Called from compiled bodies emitted for `IRIncludeDeferred`,
516
+ * `IRImportDeferred`, `IRFromImportDeferred` (and, in a subsequent
517
+ * slice, `IRExtendsDeferred`) when an async-mode template needs to
518
+ * load a child template at render time.
519
+ *
520
+ * Cache semantics — bypasses the cache via `options.cache = false` on
521
+ * the inner compile call. The sync compile cache and the async compile
522
+ * cache would otherwise share a key (the resolved filename) and serve
523
+ * a mode-mismatched compiled fn to the wrong caller. Cleaner
524
+ * namespacing (separate sync/async cache buckets, or a key suffix) is
525
+ * deferred to a follow-up slice.
526
+ *
527
+ * @param {string} pathname Template path; resolved via the active loader.
528
+ * @param {object} [options] Per-call options. Honored: `resolveFrom`,
529
+ * `filename`. Inherits `self.options` for the rest.
530
+ * @return {Promise<Function>} Resolves with the compiled async template fn.
531
+ */
532
+ self.getTemplate = function (pathname, options) {
533
+ options = options || {};
534
+
535
+ var resolved;
536
+ try {
537
+ resolved = self.options.loader.resolve(pathname, options.resolveFrom);
538
+ } catch (e) {
539
+ return Promise.reject(e);
540
+ }
541
+
542
+ var compileOpts = utils.extend({}, options, {
543
+ filename: options.filename || resolved,
544
+ codegenMode: 'async',
545
+ cache: false
546
+ });
547
+
548
+ return new Promise(function (resolve, reject) {
549
+ function onLoaded(err, src) {
550
+ if (err) {
551
+ reject(err);
552
+ return;
553
+ }
554
+ var compiled;
555
+ try {
556
+ compiled = self.compile(src, compileOpts);
557
+ } catch (err2) {
558
+ reject(err2);
559
+ return;
560
+ }
561
+ resolve(compiled);
562
+ }
563
+
564
+ if (self.options.loader.load.length >= 2) {
565
+ try {
566
+ self.options.loader.load(resolved, onLoaded);
567
+ } catch (e) {
568
+ onLoaded(e);
569
+ }
570
+ } else {
571
+ var src;
572
+ try {
573
+ src = self.options.loader.load(resolved);
574
+ } catch (e) {
575
+ onLoaded(e);
576
+ return;
577
+ }
578
+ onLoaded(null, src);
579
+ }
580
+ });
581
+ };
582
+
377
583
  self.render = function (source, options) {
378
584
  return self.compile(source, options)();
379
585
  };
380
586
 
381
587
  self.renderFile = function (pathName, locals, cb) {
382
588
  if (cb) {
589
+ // Async loader opt-in: route through getTemplate when
590
+ // loader.async === true. Explicit flag only — load.length is
591
+ // not a dispatch signal (the built-in fs + memory loaders are
592
+ // dual-mode with length 2 and must keep the sync-cb path).
593
+ if (self.options.loader && self.options.loader.async === true) {
594
+ self.getTemplate(pathName)
595
+ .then(function (fn) { return fn(locals); })
596
+ .then(function (result) { cb(null, result.output); })
597
+ .catch(cb);
598
+ return;
599
+ }
600
+
383
601
  self.compileFile(pathName, {}, function (err, fn) {
384
602
  var result;
385
603
 
package/lib/ir.js CHANGED
@@ -273,13 +273,93 @@
273
273
  * @property {IRLoc} [loc]
274
274
  */
275
275
 
276
+ /* ------------------------------------------------------------------ *
277
+ * Deferred-resolution shapes — async codegen path.
278
+ *
279
+ * In sync codegen mode the parser pre-resolves `extends` / `include` /
280
+ * `import` / Twig `from` paths via `swig.parseFile(...)` and inlines
281
+ * the resolved tokens at parse-finalization time. That model can't run
282
+ * against an async-only loader (S3 / Redis / fetch-backed), and it
283
+ * can't handle dynamic paths (`{% extends parent_var %}`) since the
284
+ * value isn't known until render.
285
+ *
286
+ * The deferred shapes carry the unresolved path expression to render
287
+ * time. The async backend (`compileAsync`) emits cb-shaped or
288
+ * AsyncFunction-shaped JS that hits a runtime `_swig.getTemplate(...)`
289
+ * call to resolve and apply the parent / included / imported template.
290
+ *
291
+ * Sync codegen uses the existing {@link IRInclude} / {@link IRImport}
292
+ * shapes and the parser's pre-resolution path. Async codegen uses
293
+ * these. The frontend tag handlers branch on the codegen mode.
294
+ * ------------------------------------------------------------------ */
295
+
296
+ /**
297
+ * Deferred extends marker — replaces the parse-finalization
298
+ * `getParents()` walk for async-mode templates. The runtime backend
299
+ * loads the parent via `_swig.getTemplate(<path>)`, applies the
300
+ * child's block overrides via `remapBlocks`, prepends the child's
301
+ * non-block IR via `importNonBlocks`, then renders.
302
+ *
303
+ * @typedef {Object} IRExtendsDeferred
304
+ * @property {'ExtendsDeferred'} type
305
+ * @property {IRExpr} path Path expression. Resolved at render time.
306
+ * @property {Object<string, IRBlock>} [childBlocks] Child template's block overrides.
307
+ * @property {IRStatement[]} [childIRs] Child template's non-block IR (prepended at runtime).
308
+ * @property {string} [resolveFrom] Including template's filename for loader-relative resolution.
309
+ * @property {IRLoc} [loc]
310
+ */
311
+
312
+ /**
313
+ * Deferred include — async codegen counterpart to {@link IRInclude}.
314
+ * Same field shape; the `'IncludeDeferred'` type tag is the dispatch
315
+ * signal that tells the backend to emit an async resolution call
316
+ * instead of an inlined sync include.
317
+ *
318
+ * @typedef {Object} IRIncludeDeferred
319
+ * @property {'IncludeDeferred'} type
320
+ * @property {IRExpr} path
321
+ * @property {IRExpr} [context]
322
+ * @property {boolean} [isolated]
323
+ * @property {boolean} [ignoreMissing]
324
+ * @property {string} [resolveFrom]
325
+ * @property {IRLoc} [loc]
326
+ */
327
+
328
+ /**
329
+ * Deferred import — async codegen counterpart to {@link IRImport}.
330
+ * `alias` MUST pass the dangerousProps guard at backend emit time.
331
+ *
332
+ * @typedef {Object} IRImportDeferred
333
+ * @property {'ImportDeferred'} type
334
+ * @property {IRExpr} path
335
+ * @property {string} alias
336
+ * @property {string} [resolveFrom]
337
+ * @property {IRLoc} [loc]
338
+ */
339
+
340
+ /**
341
+ * Deferred Twig `{% from "file" import macroA, macroB as alias %}` —
342
+ * async codegen path. Each `imports[]` entry binds the imported macro
343
+ * `name` as a callable in `_ctx` under `alias` (or under `name` when
344
+ * `alias` is null). Every `name` and every non-null `alias` MUST pass
345
+ * the dangerousProps guard at backend emit time.
346
+ *
347
+ * @typedef {Object} IRFromImportDeferred
348
+ * @property {'FromImportDeferred'} type
349
+ * @property {IRExpr} path
350
+ * @property {Array<{name: string, alias: (string|null)}>} imports
351
+ * @property {string} [resolveFrom]
352
+ * @property {IRLoc} [loc]
353
+ */
354
+
276
355
  /**
277
356
  * Any body-level IR node.
278
357
  *
279
358
  * @typedef {(
280
359
  * IRText | IROutput | IRIf | IRFor | IRBlock | IRInclude | IRImport |
281
360
  * IRMacro | IRCall | IRSet | IRRaw | IRParent | IRAutoescape | IRFilter |
282
- * IRWith | IRLegacyJS
361
+ * IRWith | IRLegacyJS |
362
+ * IRExtendsDeferred | IRIncludeDeferred | IRImportDeferred | IRFromImportDeferred
283
363
  * )} IRStatement
284
364
  */
285
365
 
@@ -754,6 +834,81 @@ exports.legacyJS = function (js, loc) {
754
834
  return withLoc({ type: 'LegacyJS', js: js }, loc);
755
835
  };
756
836
 
837
+ /* -- Deferred-resolution factories --------------------------------- */
838
+
839
+ /**
840
+ * Build an {@link IRExtendsDeferred} node. The factory stores `path`,
841
+ * `childBlocks`, and `childIRs` opaquely.
842
+ *
843
+ * @param {IRExpr} path
844
+ * @param {Object<string, IRBlock>} [childBlocks]
845
+ * @param {IRStatement[]} [childIRs]
846
+ * @param {string} [resolveFrom]
847
+ * @param {IRLoc} [loc]
848
+ * @return {IRExtendsDeferred}
849
+ */
850
+ exports.extendsDeferred = function (path, childBlocks, childIRs, resolveFrom, loc) {
851
+ var node = { type: 'ExtendsDeferred', path: path };
852
+ if (childBlocks !== undefined) { node.childBlocks = childBlocks; }
853
+ if (childIRs !== undefined) { node.childIRs = childIRs; }
854
+ if (resolveFrom !== undefined) { node.resolveFrom = resolveFrom; }
855
+ return withLoc(node, loc);
856
+ };
857
+
858
+ /**
859
+ * Build an {@link IRIncludeDeferred} node. Async-codegen counterpart
860
+ * to {@link exports.include}.
861
+ *
862
+ * @param {IRExpr} path
863
+ * @param {IRExpr} [context]
864
+ * @param {boolean} [isolated]
865
+ * @param {boolean} [ignoreMissing]
866
+ * @param {string} [resolveFrom]
867
+ * @param {IRLoc} [loc]
868
+ * @return {IRIncludeDeferred}
869
+ */
870
+ exports.includeDeferred = function (path, context, isolated, ignoreMissing, resolveFrom, loc) {
871
+ var node = { type: 'IncludeDeferred', path: path };
872
+ if (context !== undefined) { node.context = context; }
873
+ if (isolated !== undefined) { node.isolated = isolated; }
874
+ if (ignoreMissing !== undefined) { node.ignoreMissing = ignoreMissing; }
875
+ if (resolveFrom !== undefined) { node.resolveFrom = resolveFrom; }
876
+ return withLoc(node, loc);
877
+ };
878
+
879
+ /**
880
+ * Build an {@link IRImportDeferred} node. `alias` MUST pass the
881
+ * dangerousProps guard at backend emit time.
882
+ *
883
+ * @param {IRExpr} path
884
+ * @param {string} alias
885
+ * @param {string} [resolveFrom]
886
+ * @param {IRLoc} [loc]
887
+ * @return {IRImportDeferred}
888
+ */
889
+ exports.importDeferred = function (path, alias, resolveFrom, loc) {
890
+ var node = { type: 'ImportDeferred', path: path, alias: alias };
891
+ if (resolveFrom !== undefined) { node.resolveFrom = resolveFrom; }
892
+ return withLoc(node, loc);
893
+ };
894
+
895
+ /**
896
+ * Build an {@link IRFromImportDeferred} node. Each entry's `name` and
897
+ * non-null `alias` MUST pass the dangerousProps guard at backend emit
898
+ * time.
899
+ *
900
+ * @param {IRExpr} path
901
+ * @param {Array<{name: string, alias: (string|null)}>} imports
902
+ * @param {string} [resolveFrom]
903
+ * @param {IRLoc} [loc]
904
+ * @return {IRFromImportDeferred}
905
+ */
906
+ exports.fromImportDeferred = function (path, imports, resolveFrom, loc) {
907
+ var node = { type: 'FromImportDeferred', path: path, imports: imports };
908
+ if (resolveFrom !== undefined) { node.resolveFrom = resolveFrom; }
909
+ return withLoc(node, loc);
910
+ };
911
+
757
912
  /* -- Expression factories ------------------------------------------ */
758
913
 
759
914
  /**
@@ -884,7 +884,8 @@ TokenParser.prototype = {
884
884
  * @private
885
885
  */
886
886
  checkMatch: function (match) {
887
- var temp = match[0], result;
887
+ var temp = match[0],
888
+ leaf = match.join('.');
888
889
 
889
890
  function checkDot(ctx) {
890
891
  var c = ctx + temp,
@@ -904,11 +905,8 @@ TokenParser.prototype = {
904
905
  return build;
905
906
  }
906
907
 
907
- function buildDot(ctx) {
908
- return '(' + checkDot(ctx) + ' ? ' + ctx + match.join('.') + ' : "")';
909
- }
910
- result = '(' + checkDot('_ctx.') + ' ? ' + buildDot('_ctx.') + ' : ' + buildDot('') + ')';
911
- return '(' + result + ' !== null ? ' + result + ' : ' + '"" )';
908
+ return '(' + checkDot('_ctx.') + ' ? _ctx.' + leaf
909
+ + ' : (' + checkDot('') + ' ? ' + leaf + ' : ""))';
912
910
  }
913
911
  };
914
912
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhinostone/swig-core",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Shared IR, backend, and runtime for the @rhinostone/swig family of template engines.",
5
5
  "keywords": [
6
6
  "template",