@rhinostone/swig-core 2.0.0 → 2.1.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
@@ -2,6 +2,17 @@ var utils = require('./utils'),
2
2
  backend = require('./backend'),
3
3
  cache = require('./cache');
4
4
 
5
+ /**
6
+ * Constructor for AsyncFunction. Not a global; resolved once at module
7
+ * load via the prototype-chain walk of an async function expression.
8
+ * Used by {@link buildTemplateFunction} to wrap async-codegen template
9
+ * bodies so they return a Promise. Available in any engine that supports
10
+ * the `async function` syntax — Node ≥ 7.6 / all modern browsers; the
11
+ * package's engine floor (>=12) guarantees it.
12
+ * @private
13
+ */
14
+ var AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
15
+
5
16
  /**
6
17
  * Empty function used as a fallback in compiled template code.
7
18
  * @return {string} Empty string.
@@ -132,24 +143,46 @@ exports.getParents = function (tokens, options, deps) {
132
143
  * contract every tag's compile() output depends on — `_output`, `_ext`,
133
144
  * `_ctx`, etc. are referenced by name in emitted code.
134
145
  *
146
+ * When `options.codegenMode === 'async'` the body is wrapped with
147
+ * AsyncFunction instead so the function returns a
148
+ * `Promise<{output: string, exports: object}>`. The exports map collects
149
+ * top-level macros so async importers (`IRImportDeferred` /
150
+ * `IRFromImportDeferred`) can bind them under their alias at runtime
151
+ * without parse-time pre-resolution. Async mode is also the consumer
152
+ * of the deferred-resolution IR shapes (IRIncludeDeferred,
153
+ * IRImportDeferred, IRFromImportDeferred, IRExtendsDeferred) which emit
154
+ * `await` calls into the body. The async wrapper takes a 6th positional
155
+ * `_blocks` parameter — an optional `{name: async fn(_ctx) -> string}`
156
+ * map of block overrides supplied by an extending child template. The
157
+ * IRBlock async-emit consults `_blocks[name]` first and falls back to
158
+ * the parent's inline body when no override is registered. Sync mode
159
+ * argument list is unchanged.
160
+ *
135
161
  * Filename attribution on compile-time failures lives on the frontend
136
162
  * per the seam rule (the caller knows which template the body came from
137
163
  * 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.
164
+ * helper only throws whatever the wrapper constructor throws — the
165
+ * caller can catch and rewrap.
140
166
  *
141
167
  * @param {object|array} tokens Parsed token tree.
142
168
  * @param {array} [parents] Parent tokens from getParents().
143
169
  * @param {object} [options] Swig options object.
144
- * @return {Function} Template function.
170
+ * @return {Function} Template function (sync) or async template function (Promise-returning).
145
171
  */
146
172
  exports.buildTemplateFunction = function (tokens, parents, options) {
147
- return new Function('_swig', '_ctx', '_filters', '_utils', '_fn',
148
- ' var _ext = _swig.extensions,\n' +
173
+ if (options && options.codegenMode === 'async') {
174
+ var asyncBody = ' var _ext = _swig.extensions,\n' +
175
+ ' _output = "",\n' +
176
+ ' _exports = {};\n' +
177
+ backend.compile(tokens, parents, options) + '\n' +
178
+ ' return { output: _output, exports: _exports };\n';
179
+ return new AsyncFunction('_swig', '_ctx', '_filters', '_utils', '_fn', '_blocks', asyncBody);
180
+ }
181
+ var body = ' var _ext = _swig.extensions,\n' +
149
182
  ' _output = "";\n' +
150
183
  backend.compile(tokens, parents, options) + '\n' +
151
- ' return _output;\n'
152
- );
184
+ ' return _output;\n';
185
+ return new Function('_swig', '_ctx', '_filters', '_utils', '_fn', body);
153
186
  };
154
187
 
155
188
  /**
@@ -306,7 +339,7 @@ exports.install = function (self, frontend) {
306
339
  contextLength = utils.keys(context).length;
307
340
  pre = self.precompile(source, options);
308
341
 
309
- function compiled(locals) {
342
+ function compiled(locals, blocks) {
310
343
  var lcls;
311
344
  if (locals && contextLength) {
312
345
  lcls = utils.extend({}, context, locals);
@@ -317,7 +350,13 @@ exports.install = function (self, frontend) {
317
350
  } else {
318
351
  lcls = {};
319
352
  }
320
- return pre.tpl(self, lcls, filters, utils, efn);
353
+ // `blocks` is the optional async-mode block-override map supplied
354
+ // by an extending child template's IRExtendsDeferred-emitted
355
+ // `_parent(_ctx, _mergedBlocks)` call. Sync templates ignore the
356
+ // extra positional arg (regular Function discards unread args);
357
+ // async wrapper picks it up as `_blocks` and consults it from
358
+ // IRBlock emits.
359
+ return pre.tpl(self, lcls, filters, utils, efn, blocks);
321
360
  }
322
361
 
323
362
  utils.extend(compiled, pre.tokens);
@@ -374,6 +413,84 @@ exports.install = function (self, frontend) {
374
413
  return self.compile(src, options);
375
414
  };
376
415
 
416
+ /**
417
+ * Async-codegen runtime helper. Resolves a template path via the active
418
+ * loader (cb-shape preferred when supported, sync fallback otherwise),
419
+ * compiles the source in async-codegen mode, and returns a Promise that
420
+ * resolves to the compiled async template function. The compiled
421
+ * function, when called, returns a `Promise<{output: string, exports:
422
+ * object}>` — `output` is the rendered text, `exports` is the
423
+ * top-level-macro map used by `IRImportDeferred` /
424
+ * `IRFromImportDeferred` callers.
425
+ *
426
+ * Called from compiled bodies emitted for `IRIncludeDeferred`,
427
+ * `IRImportDeferred`, `IRFromImportDeferred` (and, in a subsequent
428
+ * slice, `IRExtendsDeferred`) when an async-mode template needs to
429
+ * load a child template at render time.
430
+ *
431
+ * Cache semantics — bypasses the cache via `options.cache = false` on
432
+ * the inner compile call. The sync compile cache and the async compile
433
+ * cache would otherwise share a key (the resolved filename) and serve
434
+ * a mode-mismatched compiled fn to the wrong caller. Cleaner
435
+ * namespacing (separate sync/async cache buckets, or a key suffix) is
436
+ * deferred to a follow-up slice.
437
+ *
438
+ * @param {string} pathname Template path; resolved via the active loader.
439
+ * @param {object} [options] Per-call options. Honored: `resolveFrom`,
440
+ * `filename`. Inherits `self.options` for the rest.
441
+ * @return {Promise<Function>} Resolves with the compiled async template fn.
442
+ */
443
+ self.getTemplate = function (pathname, options) {
444
+ options = options || {};
445
+
446
+ var resolved;
447
+ try {
448
+ resolved = self.options.loader.resolve(pathname, options.resolveFrom);
449
+ } catch (e) {
450
+ return Promise.reject(e);
451
+ }
452
+
453
+ var compileOpts = utils.extend({}, options, {
454
+ filename: options.filename || resolved,
455
+ codegenMode: 'async',
456
+ cache: false
457
+ });
458
+
459
+ return new Promise(function (resolve, reject) {
460
+ function onLoaded(err, src) {
461
+ if (err) {
462
+ reject(err);
463
+ return;
464
+ }
465
+ var compiled;
466
+ try {
467
+ compiled = self.compile(src, compileOpts);
468
+ } catch (err2) {
469
+ reject(err2);
470
+ return;
471
+ }
472
+ resolve(compiled);
473
+ }
474
+
475
+ if (self.options.loader.load.length >= 2) {
476
+ try {
477
+ self.options.loader.load(resolved, onLoaded);
478
+ } catch (e) {
479
+ onLoaded(e);
480
+ }
481
+ } else {
482
+ var src;
483
+ try {
484
+ src = self.options.loader.load(resolved);
485
+ } catch (e) {
486
+ onLoaded(e);
487
+ return;
488
+ }
489
+ onLoaded(null, src);
490
+ }
491
+ });
492
+ };
493
+
377
494
  self.render = function (source, options) {
378
495
  return self.compile(source, options)();
379
496
  };
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.0",
3
+ "version": "2.1.0",
4
4
  "description": "Shared IR, backend, and runtime for the @rhinostone/swig family of template engines.",
5
5
  "keywords": [
6
6
  "template",