From 92b80112e14238ad3edd27224de74cda8d199bbe Mon Sep 17 00:00:00 2001 From: Jordan Ramsay Date: Sat, 3 Oct 2026 18:25:59 +1000 Subject: [PATCH] fix(helpers): forward @root across block helper data frames `{{@root}}` rendered empty inside several custom block helpers because they replaced the Handlebars private-variable (`@`) frame with an object that had no `root` key. Handlebars itself handles `@root` correctly; the built-in helpers derive child frames from `options.data`. Root causes ----------- - helpers/block.js called `partial(this, { data: options.hash })`, so the frame was *only* the hash args. Every `{{#partial}}` body rendered via `{{#block}}`/`{{{block}}}`, and every block fallback, lost `@root` (and every other `@` variable). Stencil themes wrap most page content this way. - helpers/3p/array.js (`forEach`) and helpers/3p/object.js (`forIn`, `forOwn`) called `utils.createFrame(options, options.hash)`, copying the options object (`fn`, `inverse`, `hash`, `data`, ...) instead of `options.data`. `iterate` delegates to `forEach`/`forOwn`, so it was broken too. Fix: forward only @root, link _parent for scope shifts ----------------------------------------------------- Each affected helper now builds its frame as: data = createFrame({ root: options.data && options.data.root }, hash) data._parent = options.data - `@root` is forwarded; it is the page context everywhere. - No other enclosing `@` variable (`@index`, `@key`, `@first`, `@last`, custom hash vars) is visible directly inside the block. - `_parent` points at the real enclosing frame, so explicit scope shifts (`@../index`, `@../../index`, `@../key`) resolve to the parent. For a `{{#partial}}` body, that parent is the `{{#block}}` call site. - Hash args are still exposed as `@` variables (`{{#block "x" foo=1}}` gives `@foo`). Each call site carries a comment documenting the side effects: - a hash arg named `root` (e.g. `root="x"`) shadows `@root`; - `@data.root`, `@fn`, `@hash`, which leaked through by accident from `createFrame(options, ...)`, are now empty; use `@root` directly; - the one-line alternative to forward *all* enclosing `@` variables, and why it was not chosen (outer loop variables would leak in). Issue documented in dynamicComponent: The rendering is performed without any context forwarding from the primary render call. This has left requests to @root from within the context of a dynamicComponent render to have unexpected results. Tests ----- - spec/helpers/blockHelpersRoot.js (new): renders `[{{@root.title}}]` inside all 59 registered block helpers (inside `{{#with nested}}`, so page `T` cannot be confused with context `N`) on v3 and v4, with exact outputs. A guard test fails if a new block helper is registered without a case (`typeof` is excluded as a false positive). Against the original code, block, partial, forEach, forIn, forOwn and iterate fail. - spec/helpers/3p/array.js, spec/helpers/3p/object.js, spec/helpers/block.js: `@ variable boundary` tables, run on v3 and v4, labelled per case: pierces: must reach the block body (@root at top level and when nested, own @index/@key, hash vars, @../index, @../key, @../first/@../last, @../../index, @../abc from an outer block's hash, nested helpers); blocked: must NOT reach it directly (outer @index/@key/@first/@last, custom outer @vars, @data/@fn/@hash, inner frame changes leaking back out, #block hash args leaking out); gotcha: documented surprises (hash `root` shadows @root). Single-case specs for @root, hash vars and scope shifts were also added to the existing describe blocks. - Looping cases prefix each render with an `l{n}:` marker (n = the iteration's @index/@key/item; nested loops list each level outer-first, e.g. `l0x1:`) and end each render with a single space, so the expected output reads as one chunk per iteration, e.g. `l0:T l1:T `. ol/ul are already split per item by `\n
  • ` and get no space. Co-Authored-By: Claude Opus 5.5 --- helpers/3p/array.js | 24 ++++++- helpers/3p/object.js | 30 +++++++- helpers/block.js | 28 +++++++- helpers/dynamicComponent.js | 16 +++++ spec/helpers/3p/array.js | 87 ++++++++++++++++++++++- spec/helpers/3p/object.js | 78 +++++++++++++++++++- spec/helpers/block.js | 99 ++++++++++++++++++++++++++ spec/helpers/blockHelpersRoot.js | 118 +++++++++++++++++++++++++++++++ 8 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 spec/helpers/blockHelpersRoot.js diff --git a/helpers/3p/array.js b/helpers/3p/array.js index 0bc9baca..9668a301 100644 --- a/helpers/3p/array.js +++ b/helpers/3p/array.js @@ -215,7 +215,29 @@ helpers.first = function(array, n) { helpers.forEach = function(array) { const options = arguments[arguments.length - 1]; - var data = utils.createFrame(options, options.hash); + // Child `@` frame for the block body. Only `@root` is forwarded from the + // enclosing frame (options.data) into the block's own frame; no other private + // variables are copied across the helper boundary. createFrame(base, hash): + // 1. shallow-copies `base` ({ root }) into a new object, + // 2. sets `_parent` to `base`; overwritten below with the real enclosing + // frame, so explicit scope shifts (`@../index`) walk up to the parent, + // 3. copies hash args on top, so `{{#forEach arr foo=1}}` exposes `@foo`. + // + // Side effects / things NOT forwarded: + // - enclosing `@index`, `@key`, `@first`, `@last`, custom `@vars` are not + // visible directly (`@key` is empty), only via a scope shift (`@../key`). + // - a hash arg named `root` (e.g. `root="x"`) shadows `@root`. + // - this used to be `createFrame(options, ...)`, which copied the options + // object itself (`fn`, `inverse`, `hash`, `data`, ...) and no `@root`. + // Templates reaching `@root` via `@data.root` (or using `@fn`, `@hash`) + // get empty now; use `@root` directly. + // To make ALL enclosing `@` variables visible directly, use + // utils.createFrame(options.data || {}, options.hash) + // instead (it sets `_parent` itself), accepting that outer loop variables then + // leak into the block. + // The frame object is reused across iterations and mutated (`data.index`). + var data = utils.createFrame({ root: options.data && options.data.root }, options.hash); + data._parent = options.data; if (!array) { return ''; } diff --git a/helpers/3p/object.js b/helpers/3p/object.js index a15001d6..9c365edd 100644 --- a/helpers/3p/object.js +++ b/helpers/3p/object.js @@ -63,7 +63,29 @@ helpers.forIn = function(obj) { return obj.inverse(this); } - var data = utils.createFrame(options, options.hash); + // Child `@` frame for the block body. Only `@root` is forwarded from the + // enclosing frame (options.data) into the block's own frame; no other private + // variables are copied across the helper boundary. createFrame(base, hash): + // 1. shallow-copies `base` ({ root }) into a new object, + // 2. sets `_parent` to `base`; overwritten below with the real enclosing + // frame, so explicit scope shifts (`@../index`) walk up to the parent, + // 3. copies hash args on top, so `{{#forIn obj foo=1}}` exposes `@foo`. + // + // Side effects / things NOT forwarded: + // - enclosing `@index`, `@key`, `@first`, `@last`, custom `@vars` are not + // visible directly (`@index` is empty), only via a scope shift (`@../index`). + // - a hash arg named `root` (e.g. `root="x"`) shadows `@root`. + // - this used to be `createFrame(options, ...)`, which copied the options + // object itself (`fn`, `inverse`, `hash`, `data`, ...) and no `@root`. + // Templates reaching `@root` via `@data.root` (or using `@fn`, `@hash`) + // get empty now; use `@root` directly. + // To make ALL enclosing `@` variables visible directly, use + // utils.createFrame(options.data || {}, options.hash) + // instead (it sets `_parent` itself), accepting that outer loop variables then + // leak into the block. + // The frame object is reused across iterations and mutated (`data.key`). + var data = utils.createFrame({ root: options.data && options.data.root }, options.hash); + data._parent = options.data; var result = ''; for (var key in obj) { @@ -90,7 +112,11 @@ helpers.forOwn = function(obj) { return obj.inverse(this); } - var data = utils.createFrame(options, options.hash); + // Child `@` frame forwarding only `@root`, with `_parent` linked to the + // enclosing frame for `@../` scope shifts. Same behaviour and side effects as + // `forIn` above. + var data = utils.createFrame({ root: options.data && options.data.root }, options.hash); + data._parent = options.data; var result = ''; for (var key in obj) { diff --git a/helpers/block.js b/helpers/block.js index 39890b85..48a7ac16 100644 --- a/helpers/block.js +++ b/helpers/block.js @@ -20,7 +20,33 @@ const factory = globals => { /* Look for partial by name. */ const partial = globals.handlebars.partials[name] || options.fn; - return partial(this, { data: options.hash }); + /* + * Child `@` frame for the partial / fallback body. Only `@root` is forwarded + * from the enclosing frame (options.data) into the block's own frame; no other + * private variables are copied across the helper boundary. + * handlebars.createFrame(base): + * 1. shallow-copies `base` ({ root }) into a new object, + * 2. sets `_parent` to `base`; overwritten below with the real enclosing + * frame, so explicit scope shifts (`@../index`) walk up to the parent. + * For a `{{#partial}}` body that parent is the frame at the + * `{{#block}}` call site (e.g. the layout), not where it was defined. + * Hash args are then copied on top, so `{{#block "x" foo=1}}` exposes `@foo`. + * + * Side effects / things NOT forwarded: + * - enclosing `@index`, `@key`, `@first`, `@last`, custom `@vars` are not + * visible directly, e.g. a block inside `{{#each}}` sees an empty + * `@index`, but `@../index` returns the loop's index. + * - a hash arg named `root` (e.g. `root="x"`) shadows `@root`. + * - this used to be `{ data: options.hash }`, i.e. the frame was ONLY the + * hash args, with no `@root`. The hash still becomes `@` variables. + * To make ALL enclosing `@` variables visible directly, use + * globals.handlebars.createFrame(options.data || {}) + * instead (it sets `_parent` itself), accepting that outer loop variables + * then leak into the block. + */ + const data = globals.handlebars.createFrame({ root: options.data && options.data.root }); + data._parent = options.data; + return partial(this, { data: Object.assign(data, options.hash) }); }; }; diff --git a/helpers/dynamicComponent.js b/helpers/dynamicComponent.js index b5aa02aa..567b3fa9 100644 --- a/helpers/dynamicComponent.js +++ b/helpers/dynamicComponent.js @@ -20,6 +20,22 @@ const factory = globals => { path = Path.join(path, this['partial']); if (globals.handlebars.partials[path] && Object.hasOwnProperty.call(globals.handlebars.partials, path)) { + /* + * KNOWN VARIATION: `@root` inside a dynamic component is NOT the page. + * + * The partial is called directly with no `data`, so Handlebars treats the + * call as a fresh top-level render and sets `@root` to `this` (the current + * item, e.g. the loop entry). Every other path gives `@root` = the page + * context, as asserted for every block helper in + * spec/helpers/blockHelpersRoot.js, and as the native `{{> partial}}` + * syntax does. Inside `{{#each items}}`, a component rendering + * `[{{@root.title}}|{{@index}}|{{title}}]` gives (v3 and v4 alike): + * {{> components/card}} -> [PAGE|0|ITEM0] + * {{{dynamicComponent "components"}}} -> [ITEM0||ITEM0] + * Also as a result: no enclosing `@` variables (`@index`, `@key`, ...) + * and no `@../` scope shifts are available inside the component. + * + */ return globals.handlebars.partials[path](this); } }; diff --git a/spec/helpers/3p/array.js b/spec/helpers/3p/array.js index 05eaaa61..b412fa3b 100644 --- a/spec/helpers/3p/array.js +++ b/spec/helpers/3p/array.js @@ -231,6 +231,30 @@ describe('array', function() { done(); }); + it('should expose `@root`', function(done) { + var arr = [{name: 'a'}, {name: 'b'}]; + + var fn = hbs.compile('{{#forEach arr}}{{@root.title}}{{/forEach}}'); + expect(fn({arr: arr, title: 'x'})).to.equal('xx'); + done(); + }); + + it('should expose `@index` and hash private variables alongside `@root`', function(done) { + var arr = [{name: 'a'}, {name: 'b'}]; + + var fn = hbs.compile('{{#forEach arr abc="y"}}{{@index}}{{@abc}}{{@root.title}}{{/forEach}}'); + expect(fn({arr: arr, title: 'x'})).to.equal('0yx1yx'); + done(); + }); + + it('should not forward enclosing private variables other than `@root`, but allow `@../` scope shifts', function(done) { + var arr = [{name: 'a'}]; + + var fn = hbs.compile('{{#each outer}}{{#forEach ../arr}}<{{@key}}{{@../key}}>{{/forEach}}{{/each}}'); + expect(fn({arr: arr, outer: {k1: 1, k2: 2}})).to.equal(''); + done(); + }); + it('should return empty block', function(done) { let arr = []; var fn = hbs.compile('{{#forEach arr}}{{isLast}}{{/forEach}}'); @@ -563,4 +587,65 @@ describe('array', function() { done(); }); }); -}); \ No newline at end of file +}); + +describe('forEach @ variable boundary', function() { + // pierces: reaches the block body across the helper boundary. + // blocked: must NOT reach the block body directly. + // gotcha: documented behaviour that may surprise. + // l{n}: loop marker, one per iteration, each iteration ends with a single space; + // nested loops list each level outer-first (l0x1:). + const { renderString } = require('../../spec-helpers'); + const boundaryContext = { + title: 'T', + nested: { title: 'N' }, + arr: [{n: 'a'}, {n: 'b'}], + outer: [1, 2], + outerObj: {k1: 1, k2: 2}, + obj: {a: 1, b: 2}, + }; + const boundaryCases = [ + { desc: 'pierces: @root from the top level', + input: '{{#forEach arr}}l{{@index}}:{{@root.title}} {{/forEach}}', output: 'l0:T l1:T ' }, + { desc: 'pierces: @root is the page, not the current context, when nested', + input: '{{#with nested}}{{#forEach ../arr}}l{{@index}}:{{@root.title}} {{/forEach}}{{/with}}', output: 'l0:T l1:T ' }, + { desc: 'pierces: own @index and hash private variables', + input: '{{#forEach arr foo="y"}}l{{@index}}:{{@index}}{{@foo}} {{/forEach}}', output: 'l0:0y l1:1y ' }, + { desc: 'pierces: @../index scope shift to an enclosing #each', + input: '{{#each outer}}{{#forEach ../arr}}l{{@../index}}x{{@index}}:{{@../index}} {{/forEach}}{{/each}}', output: 'l0x0:0 l0x1:0 l1x0:1 l1x1:1 ' }, + { desc: 'pierces: @../key scope shift to an enclosing #each over an object', + input: '{{#each outerObj}}{{#forEach ../arr}}l{{@../key}}x{{@index}}:{{@../key}} {{/forEach}}{{/each}}', output: 'lk1x0:k1 lk1x1:k1 lk2x0:k2 lk2x1:k2 ' }, + { desc: 'pierces: @../first / @../last scope shift', + input: '{{#each outer}}{{#forEach ../arr}}l{{@../index}}x{{@index}}:[{{@../first}}{{@../last}}] {{/forEach}}{{/each}}', + output: 'l0x0:[truefalse] l0x1:[truefalse] l1x0:[falsetrue] l1x1:[falsetrue] ' }, + { desc: 'pierces: @../../index two-level scope shift', + input: '{{#each outer}}{{#each @root.outerObj}}{{#forEach @root.arr}}l{{@../../index}}x{{@../key}}x{{@index}}:{{@../../index}}{{@../key}} {{/forEach}}{{/each}}{{/each}}', + output: 'l0xk1x0:0k1 l0xk1x1:0k1 l0xk2x0:0k2 l0xk2x1:0k2 l1xk1x0:1k1 l1xk1x1:1k1 l1xk2x0:1k2 l1xk2x1:1k2 ' }, + { desc: 'pierces: nested forEach sees its own @index and the outer one via @../index', + input: '{{#forEach arr}}{{#forEach ../arr}}l{{@../index}}x{{@index}}:{{@index}}{{@../index}} {{/forEach}}{{/forEach}}', output: 'l0x0:00 l0x1:10 l1x0:01 l1x1:11 ' }, + { desc: 'blocked: enclosing @key', + input: '{{#each outerObj}}{{#forEach ../arr}}l{{@../key}}x{{@index}}:[{{@key}}] {{/forEach}}{{/each}}', output: 'lk1x0:[] lk1x1:[] lk2x0:[] lk2x1:[] ' }, + { desc: 'blocked: enclosing @first / @last', + input: '{{#each outer}}{{#forEach ../arr}}l{{@../index}}x{{@index}}:[{{@first}}{{@last}}] {{/forEach}}{{/each}}', output: 'l0x0:[] l0x1:[] l1x0:[] l1x1:[] ' }, + { desc: 'blocked: enclosing custom @var (reachable via @../ only)', + input: '{{#forIn obj abc="x"}}{{#forEach @root.arr}}l{{@../key}}x{{@index}}:[{{@abc}}|{{@../abc}}] {{/forEach}}{{/forIn}}', + output: 'lax0:[|x] lax1:[|x] lbx0:[|x] lbx1:[|x] ' }, + { desc: 'blocked: options internals that used to leak (@data, @fn, @hash)', + input: '{{#forEach arr foo="y"}}l{{@index}}:[{{@data.root.title}}{{@fn}}{{@hash.foo}}] {{/forEach}}', output: 'l0:[] l1:[] ' }, + { desc: 'blocked: inner frame changes do not leak back out to the enclosing #each', + input: '{{#each outer}}l{{@index}}:{{#forEach ../arr}}{{/forEach}}{{@index}} {{/each}}', output: 'l0:0 l1:1 ' }, + { desc: 'gotcha: a hash arg named root shadows @root', + input: '{{#forEach arr root="X"}}l{{@index}}:{{@root}} {{/forEach}}', output: 'l0:X l1:X ' }, + ]; + + ['v3', 'v4'].forEach(hbVersion => { + boundaryCases.forEach(({ desc, input, output }) => { + it(`${hbVersion} ${desc}`, function(done) { + renderString(input, boundaryContext, {}, {}, undefined, hbVersion).then(result => { + expect(result).to.equal(output); + done(); + }).catch(done); + }); + }); + }); +}); diff --git a/spec/helpers/3p/object.js b/spec/helpers/3p/object.js index 55feb85c..53b813de 100644 --- a/spec/helpers/3p/object.js +++ b/spec/helpers/3p/object.js @@ -57,6 +57,18 @@ describe('object', function() { expect(fn(context)).to.equal(' b '); done(); }); + + it('should expose `@root`:', function(done) { + var fn = hbs.compile('{{#forIn object}}{{@root.title}}{{/forIn}}'); + expect(fn({object: {a: 1, b: 2}, title: 'x'})).to.equal('xx'); + done(); + }); + + it('should not forward enclosing private variables other than `@root`, but allow `@../` scope shifts:', function(done) { + var fn = hbs.compile('{{#each outer}}{{#forIn ../object}}<{{@index}}{{@../index}}>{{/forIn}}{{/each}}'); + expect(fn({object: {a: 1}, outer: [1, 2]})).to.equal('<0><1>'); + done(); + }); }); describe('forOwn', function() { @@ -88,6 +100,18 @@ describe('object', function() { expect(fn(context)).to.equal(' d '); done(); }); + + it('should expose `@root`:', function(done) { + var fn = hbs.compile('{{#forOwn object}}{{@root.title}}{{/forOwn}}'); + expect(fn({object: {a: 1, b: 2}, title: 'x'})).to.equal('xx'); + done(); + }); + + it('should not forward enclosing private variables other than `@root`, but allow `@../` scope shifts:', function(done) { + var fn = hbs.compile('{{#each outer}}{{#forOwn ../object}}<{{@index}}{{@../index}}>{{/forOwn}}{{/each}}'); + expect(fn({object: {a: 1}, outer: [1, 2]})).to.equal('<0><1>'); + done(); + }); }); describe('toPath', function() { @@ -174,4 +198,56 @@ describe('object', function() { done(); }); }); -}); \ No newline at end of file +}); + +['forIn', 'forOwn'].forEach(helper => { + describe(`${helper} @ variable boundary`, function() { + // pierces: reaches the block body across the helper boundary. + // blocked: must NOT reach the block body directly. + // gotcha: documented behaviour that may surprise. + // l{n}: loop marker, one per iteration, each iteration ends with a single space; + // nested loops list each level outer-first (l0x1:). + const { renderString } = require('../../spec-helpers'); + const boundaryContext = { + title: 'T', + nested: { title: 'N' }, + outer: [1, 2], + outerObj: {k1: 1, k2: 2}, + obj: {a: 1, b: 2}, + }; + const t = body => body.replace(/HELPER/g, helper); + const boundaryCases = [ + { desc: 'pierces: @root from the top level', + input: t('{{#HELPER obj}}l{{@key}}:{{@root.title}} {{/HELPER}}'), output: 'la:T lb:T ' }, + { desc: 'pierces: @root is the page, not the current context, when nested', + input: t('{{#with nested}}{{#HELPER ../obj}}l{{@key}}:{{@root.title}} {{/HELPER}}{{/with}}'), output: 'la:T lb:T ' }, + { desc: 'pierces: own @key and hash private variables', + input: t('{{#HELPER obj foo="y"}}l{{@key}}:{{@key}}{{@foo}} {{/HELPER}}'), output: 'la:ay lb:by ' }, + { desc: 'pierces: @../index scope shift to an enclosing #each', + input: t('{{#each outer}}{{#HELPER ../obj}}l{{@../index}}x{{@key}}:{{@../index}} {{/HELPER}}{{/each}}'), output: 'l0xa:0 l0xb:0 l1xa:1 l1xb:1 ' }, + { desc: 'pierces: @../key scope shift to an enclosing #each over an object', + input: t('{{#each outerObj}}{{#HELPER @root.obj}}l{{@../key}}x{{@key}}:{{@../key}} {{/HELPER}}{{/each}}'), output: 'lk1xa:k1 lk1xb:k1 lk2xa:k2 lk2xb:k2 ' }, + { desc: 'blocked: enclosing @index', + input: t('{{#each outer}}{{#HELPER ../obj}}l{{@../index}}x{{@key}}:[{{@index}}] {{/HELPER}}{{/each}}'), output: 'l0xa:[] l0xb:[] l1xa:[] l1xb:[] ' }, + { desc: 'blocked: enclosing @first / @last', + input: t('{{#each outer}}{{#HELPER ../obj}}l{{@../index}}x{{@key}}:[{{@first}}{{@last}}] {{/HELPER}}{{/each}}'), output: 'l0xa:[] l0xb:[] l1xa:[] l1xb:[] ' }, + { desc: 'blocked: options internals that used to leak (@data, @fn, @hash)', + input: t('{{#HELPER obj foo="y"}}l{{@key}}:[{{@data.root.title}}{{@fn}}{{@hash.foo}}] {{/HELPER}}'), output: 'la:[] lb:[] ' }, + { desc: 'blocked: inner frame changes do not leak back out to the enclosing #each', + input: t('{{#each outerObj}}l{{@key}}:{{#HELPER @root.obj}}{{/HELPER}}{{@key}} {{/each}}'), output: 'lk1:k1 lk2:k2 ' }, + { desc: 'gotcha: a hash arg named root shadows @root', + input: t('{{#HELPER obj root="X"}}l{{@key}}:{{@root}} {{/HELPER}}'), output: 'la:X lb:X ' }, + ]; + + ['v3', 'v4'].forEach(hbVersion => { + boundaryCases.forEach(({ desc, input, output }) => { + it(`${hbVersion} ${desc}`, function(done) { + renderString(input, boundaryContext, {}, {}, undefined, hbVersion).then(result => { + expect(result).to.equal(output); + done(); + }).catch(done); + }); + }); + }); + }); +}); diff --git a/spec/helpers/block.js b/spec/helpers/block.js index fcc99abd..2023f3d1 100644 --- a/spec/helpers/block.js +++ b/spec/helpers/block.js @@ -75,6 +75,53 @@ describe('partial and block helpers', function () { }); }); + it('should expose `@root` inside a partial rendered into a block', function (done) { + const templates = { + template: '{{#partial "page"}}

    {{@root.title}}

    {{/partial}}{{> layout}}', + layout: '{{#with nested}}{{#block "page"}}{{/block}}{{/with}}', + }; + + render('template', { title: 'Hello', nested: {} }, {}, {}, templates).then(result => { + expect(result).to.equal('

    Hello

    '); + done(); + }); + }); + + it('should expose `@root` inside the fallback content of a block', function (done) { + const templates = { + template: '{{#block "missing"}}{{@root.title}}{{/block}}', + }; + + render('template', { title: 'Hello' }, {}, {}, templates).then(result => { + expect(result).to.equal('Hello'); + done(); + }); + }); + + it('should expose block hash arguments as private variables alongside `@root`', function (done) { + const templates = { + template: '{{#partial "page"}}{{@greeting}} {{@root.title}}{{/partial}}{{> layout}}', + layout: '{{#block "page" greeting="Hi"}}{{/block}}', + }; + + render('template', { title: 'Hello' }, {}, {}, templates).then(result => { + expect(result).to.equal('Hi Hello'); + done(); + }); + }); + + it('should not forward enclosing private variables other than `@root`, but allow `@../` scope shifts', function (done) { + const templates = { + template: '{{#partial "page"}}<{{@index}}{{@../index}}{{@root.title}}>{{/partial}}{{> layout}}', + layout: '{{#each items}}{{#block "page"}}{{/block}}{{/each}}', + }; + + render('template', { title: 'Hello', items: [1, 2] }, {}, {}, templates).then(result => { + expect(result).to.equal('<0Hello><1Hello>'); + done(); + }); + }); + it('should return empty string if using a reserved object property name', function (done) { const templates = { template: '{{#partial "__proto__"}}Page partial content.{{/partial}}{{> layout}}', @@ -89,3 +136,55 @@ describe('partial and block helpers', function () { }); }); }); + +describe('block / partial @ variable boundary', function () { + // pierces: reaches the block body across the helper boundary. + // blocked: must NOT reach the block body directly. + // gotcha: documented behaviour that may surprise. + // l{n}: loop marker, one per iteration, each iteration ends with a single space; + // nested loops list each level outer-first (l0x1:). + const { renderString } = require('../spec-helpers'); + const boundaryContext = { + title: 'T', + nested: { title: 'N' }, + outer: [1, 2], + outerObj: { k1: 1, k2: 2 }, + arr: [{ n: 'a' }, { n: 'b' }], + }; + const boundaryCases = [ + { desc: 'pierces: @root into a #partial body rendered by #block', + input: '{{#partial "p"}}{{@root.title}}{{/partial}}{{#with nested}}{{#block "p"}}{{/block}}{{/with}}', output: 'T' }, + { desc: 'pierces: @root into #block fallback content', + input: '{{#with nested}}{{#block "none"}}{{@root.title}}{{/block}}{{/with}}', output: 'T' }, + { desc: 'pierces: @root through a block helper nested inside #block', + input: '{{#block "o"}}{{#forEach @root.arr}}l{{@index}}:{{@root.title}} {{/forEach}}{{/block}}', output: 'l0:T l1:T ' }, + { desc: 'pierces: #block hash args as private variables', + input: '{{#partial "p"}}{{@greeting}}{{/partial}}{{#block "p" greeting="Hi"}}{{/block}}', output: 'Hi' }, + { desc: 'pierces: @../index scope shift to the #each around the #block call site', + input: '{{#partial "p"}}l{{@../index}}:{{@../index}} {{/partial}}{{#each outer}}{{#block "p"}}{{/block}}{{/each}}', output: 'l0:0 l1:1 ' }, + { desc: 'pierces: @../abc scope shift to an enclosing #block hash var', + input: '{{#block "o" abc="x"}}{{#block "i"}}[{{@abc}}|{{@../abc}}]{{/block}}{{/block}}', output: '[|x]' }, + { desc: 'blocked: enclosing @index / @key / @first', + input: '{{#partial "p"}}l{{@../key}}:[{{@index}}{{@key}}{{@first}}] {{/partial}}{{#each outerObj}}{{#block "p"}}{{/block}}{{/each}}', + output: 'lk1:[] lk2:[] ' }, + { desc: 'blocked: options internals (@data, @fn, @hash)', + input: '{{#block "none" foo="y"}}[{{@data.root.title}}{{@fn}}{{@hash.foo}}]{{/block}}', output: '[]' }, + { desc: 'blocked: #block hash args do not leak out after the block', + input: '{{#block "none" foo="y"}}{{/block}}[{{@foo}}]', output: '[]' }, + { desc: 'blocked: a #block hash arg does not overwrite the enclosing #each @index', + input: '{{#each outer}}l{{@index}}:{{#block "none" index="Z"}}{{/block}}{{@index}} {{/each}}', output: 'l0:0 l1:1 ' }, + { desc: 'gotcha: a hash arg named root shadows @root', + input: '{{#block "none" root="X"}}{{@root}}{{/block}}', output: 'X' }, + ]; + + ['v3', 'v4'].forEach(hbVersion => { + boundaryCases.forEach(({ desc, input, output }) => { + it(`${hbVersion} ${desc}`, function(done) { + renderString(input, boundaryContext, {}, {}, undefined, hbVersion).then(result => { + expect(result).to.equal(output); + done(); + }).catch(done); + }); + }); + }); +}); diff --git a/spec/helpers/blockHelpersRoot.js b/spec/helpers/blockHelpersRoot.js new file mode 100644 index 00000000..aea7b6b1 --- /dev/null +++ b/spec/helpers/blockHelpersRoot.js @@ -0,0 +1,118 @@ +const Code = require('code'); +const Lab = require('lab'); +const lab = exports.lab = Lab.script(); +const describe = lab.experiment; +const expect = Code.expect; +const it = lab.it; +const { buildRenderer, renderString } = require('../spec-helpers'); + +// Every registered block helper must let `@root` pierce into its block body. +// Each template is rendered inside `{{#with nested}}`, so `@root.title` ('T') +// can't be confused with the current context's `title` ('N'). +// Looping helpers prefix each render with `l{n}:` (n = that iteration's index/key/item) +// and end each render with a single space, so repeated output reads as one chunk per +// iteration (e.g. `l0:[T] l1:[T] `). ol/ul get no space: their items are already split +// by `
  • `. +// Known exception (not a block helper, so not covered here): `dynamicComponent`, +// where `@root` is the current item. See the comment in helpers/dynamicComponent.js. +describe('@root inside every block helper', function () { + const B = '[{{@root.title}}]'; + const context = { + title: 'T', + nested: { title: 'N' }, + arr: [{ n: 'a' }, { n: 'b' }], + arr2: ['a', 'b'], + obj: { a: 1 }, + json: '{"a":1}', + empty: [], + isString: v => typeof v === 'string', + }; + + const cases = { + JSONparse: { input: `{{#JSONparse @root.json}}${B}{{/JSONparse}}`, output: '[T]' }, + JSONparseSafe: { input: `{{#JSONparseSafe @root.json}}${B}{{/JSONparseSafe}}`, output: '[T]' }, + all: { input: `{{#all true true}}${B}{{/all}}`, output: '[T]' }, + and: { input: `{{#and true true}}${B}{{/and}}`, output: '[T]' }, + any: { input: `{{#any true}}${B}{{/any}}`, output: '[T]' }, + block: { input: `{{#block "none"}}${B}{{/block}}`, output: '[T]' }, + blockHelperMissing: { input: `{{#title}}${B}{{/title}}`, output: '[T]' }, + compare: { input: `{{#compare 1 1 operator="=="}}${B}{{/compare}}`, output: '[T]' }, + contains: { input: `{{#contains @root.arr2 "a"}}${B}{{/contains}}`, output: '[T]' }, + each: { input: `{{#each @root.arr}}l{{@index}}:${B} {{/each}}`, output: 'l0:[T] l1:[T] ' }, + eachIndex: { input: `{{#eachIndex @root.arr}}l{{index}}:${B} {{/eachIndex}}`, output: 'l0:[T] l1:[T] ' }, + enumerate: { input: `{{#enumerate 1 2}}l{{this}}:${B} {{/enumerate}}`, output: 'l1:[T] l2:[T] ' }, + eq: { input: `{{#eq 1 1}}${B}{{/eq}}`, output: '[T]' }, + equals: { input: `{{#equals 1 1}}${B}{{/equals}}`, output: '[T]' }, + filter: { input: `{{#filter @root.arr2 "a"}}${B}{{/filter}}`, output: '[T]' }, + for: { input: `{{#for 1 2}}l{{$index}}:${B} {{/for}}`, output: 'l1:[T] l2:[T] ' }, + forEach: { input: `{{#forEach @root.arr}}l{{@index}}:${B} {{/forEach}}`, output: 'l0:[T] l1:[T] ' }, + forIn: { input: `{{#forIn @root.obj}}${B}{{/forIn}}`, output: '[T]' }, + forOwn: { input: `{{#forOwn @root.obj}}${B}{{/forOwn}}`, output: '[T]' }, + get: { input: `{{#get "nested" @root}}${B}{{/get}}`, output: '[T]' }, + gt: { input: `{{#gt 2 1}}${B}{{/gt}}`, output: '[T]' }, + gte: { input: `{{#gte 2 1}}${B}{{/gte}}`, output: '[T]' }, + has: { input: `{{#has "abc" "b"}}${B}{{/has}}`, output: '[T]' }, + if: { input: `{{#if true}}${B}{{/if}}`, output: '[T]' }, + ifEven: { input: `{{#ifEven 2}}${B}{{/ifEven}}`, output: '[T]' }, + ifNth: { input: `{{#ifNth 2 4}}${B}{{/ifNth}}`, output: '[T]' }, + ifOdd: { input: `{{#ifOdd 1}}${B}{{/ifOdd}}`, output: '[T]' }, + inArray: { input: `{{#inArray @root.arr2 "a"}}${B}{{/inArray}}`, output: '[T]' }, + is: { input: `{{#is 1 1}}${B}{{/is}}`, output: '[T]' }, + isEmpty: { input: `{{#isEmpty @root.empty}}${B}{{/isEmpty}}`, output: '[T]' }, + isnt: { input: `{{#isnt 1 2}}${B}{{/isnt}}`, output: '[T]' }, + iterate: { input: `{{#iterate @root.arr}}l{{@index}}:${B} {{/iterate}}`, output: 'l0:[T] l1:[T] ' }, + lengthEqual: { input: `{{#lengthEqual @root.arr2 2}}${B}{{/lengthEqual}}`, output: '[T]' }, + lt: { input: `{{#lt 1 2}}${B}{{/lt}}`, output: '[T]' }, + lte: { input: `{{#lte 1 2}}${B}{{/lte}}`, output: '[T]' }, + markdown: { input: `{{#markdown}}${B}{{/markdown}}`, output: '

    [T]

    \n' }, + neither: { input: `{{#neither false false}}${B}{{/neither}}`, output: '[T]' }, + noop: { input: `{{#noop}}${B}{{/noop}}`, output: '[T]' }, + ol: { input: `{{#ol @root.arr}}l{{n}}:${B}{{/ol}}`, output: '
    1. la:[T]
    2. \n
    3. lb:[T]
    ' }, + or: { input: `{{#or true}}${B}{{/or}}`, output: '[T]' }, + partial: { input: `{{#partial "p"}}${B}{{/partial}}{{{block "p"}}}`, output: '[T]' }, + replace: { input: `{{#replace "a" "abc"}}${B}{{/replace}}`, output: '[T]bc' }, + some: { input: `{{#some @root.arr2 @root.isString}}${B}{{/some}}`, output: '[T]' }, + startsWith: { input: `{{#startsWith "a" "abc"}}${B}{{/startsWith}}`, output: '[T]' }, + ul: { input: `{{#ul @root.arr}}l{{n}}:${B}{{/ul}}`, output: '' }, + unless: { input: `{{#unless false}}${B}{{/unless}}`, output: '[T]' }, + unlessEq: { input: `{{#unlessEq 1 2}}${B}{{/unlessEq}}`, output: '[T]' }, + unlessGt: { input: `{{#unlessGt 1 2}}${B}{{/unlessGt}}`, output: '[T]' }, + unlessGteq: { input: `{{#unlessGteq 1 2}}${B}{{/unlessGteq}}`, output: '[T]' }, + unlessLt: { input: `{{#unlessLt 2 1}}${B}{{/unlessLt}}`, output: '[T]' }, + unlessLteq: { input: `{{#unlessLteq 2 1}}${B}{{/unlessLteq}}`, output: '[T]' }, + uppercase: { input: `{{#uppercase}}${B}{{/uppercase}}`, output: '[T]' }, + with: { input: `{{#with @root.nested}}${B}{{/with}}`, output: '[T]' }, + withAfter: { input: `{{#withAfter @root.arr2 0}}l{{this}}:${B} {{/withAfter}}`, output: 'la:[T] lb:[T] ' }, + withBefore: { input: `{{#withBefore @root.arr2 1}}${B}{{/withBefore}}`, output: '[T]' }, + withFirst: { input: `{{#withFirst @root.arr2}}${B}{{/withFirst}}`, output: '[T]' }, + withHash: { input: `{{#withHash a=1}}${B}{{/withHash}}`, output: '[T]' }, + withLast: { input: `{{#withLast @root.arr2}}${B}{{/withLast}}`, output: '[T]' }, + withSort: { input: `{{#withSort @root.arr2}}l{{this}}:${B} {{/withSort}}`, output: 'la:[T] lb:[T] ' }, + }; + + // Helpers whose source looks block-ish to the guard below but are not block helpers. + const notBlockHelpers = ['typeof']; + + it('has a case for every registered block helper', function (done) { + const helpers = buildRenderer().handlebars.helpers; + const blockish = /\.fn\(|\.inverse\(|utils?\.value\(|isBlock|options\.fn|\.fn\b/; + const missing = Object.keys(helpers) + .filter(name => blockish.test(helpers[name].toString())) + .filter(name => !notBlockHelpers.includes(name) && !cases[name]); + + expect(missing).to.equal([]); + done(); + }); + + ['v3', 'v4'].forEach(hbVersion => { + Object.keys(cases).forEach(name => { + it(`${hbVersion} ${name}`, function (done) { + const { input, output } = cases[name]; + renderString(`{{#with nested}}${input}{{/with}}`, context, {}, {}, undefined, hbVersion).then(result => { + expect(result).to.equal(output); + done(); + }).catch(done); + }); + }); + }); +});