diff --git a/CHANGELOG.md b/CHANGELOG.md index 287494c..3c00ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- fix: STRF-9873 use modified implementations of `get`, `getObject`, `moment`, `option` 3p helpers ([#171](https://github.com/bigcommerce/paper-handlebars/pull/171)) ## 5.0.0 - feat: STRF-9791 drop node 12 support ([#169](https://github.com/bigcommerce/paper-handlebars/pull/169)) diff --git a/helpers.js b/helpers.js index f8c725a..3028cbc 100644 --- a/helpers.js +++ b/helpers.js @@ -11,6 +11,7 @@ const helpersList = [ 'dynamicComponent', 'encodeHtmlEntities', 'for', + 'get', 'getContentImage', 'getContentImageSrcset', 'getFontLoaderConfig', @@ -20,6 +21,7 @@ const helpersList = [ 'getImageManagerImageSrcset', 'getImageSrcset', 'getImageSrcset1x2x', + 'getObject', 'getVar', 'helperMissing', 'if', @@ -31,9 +33,11 @@ const helpersList = [ 'lang', 'langJson', 'limit', + 'moment', 'money', 'nl2br', 'occurrences', + 'option', 'or', 'partial', 'pluck', diff --git a/helpers/get.js b/helpers/get.js new file mode 100644 index 0000000..51c6edf --- /dev/null +++ b/helpers/get.js @@ -0,0 +1,31 @@ +'use strict'; + +const { getValue } = require('./lib/common'); + +/** + * Based on https://github.com/helpers/handlebars-helpers/blob/0.9.0/lib/object.js#L128-L134 + * + * Get a value from the given context object. Property paths (`a.b.c`) may be used + * to get nested properties. + */ +const factory = () => { + return function (path, context) { + let options = arguments[arguments.length - 1]; + + // use an empty context if none was given + if (arguments.length < 2) { + context = {}; + } + + let value = getValue(context, path); + if (options && options.fn) { + return value ? options.fn(value) : options.inverse(context); + } + return value; + }; +}; + +module.exports = [{ + name: 'get', + factory: factory, +}]; \ No newline at end of file diff --git a/helpers/getObject.js b/helpers/getObject.js new file mode 100644 index 0000000..2b1b19c --- /dev/null +++ b/helpers/getObject.js @@ -0,0 +1,51 @@ +'use strict'; + +const { getValue } = require('./lib/common'); + +/* + * Based on https://github.com/helpers/handlebars-helpers/blob/0.9.0/lib/object.js#L149-L151 + * and https://github.com/jonschlinkert/get-object/blob/0.2.0/index.js#L12-L24 + * + * Get an object or array containing a value from the given context object. + * Property paths (`a.b.c`) may be used to get nested properties. + */ +const factory = () => { + return function (path, context) { + // use an empty context if none was given + // (expect 3 args: `path`, `context`, and the `options` object + // Handlebars always passes as the last argument to a helper) + if (arguments.length < 3) { + context = {}; + } + + if (!path) { + // return entire context object if no property/path specified + return context; + } + + path = String(path); + + let value = getValue(context, path); + + // for backwards compatibility: `get-object` returns on empty object instead of + // getting props with 'false' values (not just undefined) + if (!value) { + return {}; + } + + // return an array if the final path part is numeric to mimic behavior of `get-object` + const parts = path.split(/[[.\]]/).filter(Boolean); + const last = parts[parts.length - 1]; + if (Number.isFinite(Number(last))) { + return [ value ]; + } + let result = {}; + result[last] = value + return result; + }; +}; + +module.exports = [{ + name: 'getObject', + factory: factory, +}]; \ No newline at end of file diff --git a/helpers/lib/common.js b/helpers/lib/common.js index e120b28..16fec04 100644 --- a/helpers/lib/common.js +++ b/helpers/lib/common.js @@ -9,6 +9,41 @@ function isValidURL(val) { } } +/* + * Based on https://github.com/jonschlinkert/get-value/blob/3.0.1/index.js, but + * with configurability that was not used in handlebars-helpers removed. + */ +function getValue(object, path) { + let parts; + // accept array or string for backwards compatibility + if (!Array.isArray(path)) { + if (typeof path !== 'string') { + return object; + } + parts = path.split(/[[.\]]/).filter(Boolean); + } else { + parts = path.map(v => String(v)); + } + + let result = object; + let prefix = ''; + for (let key of parts) { + // preserve handling of trailing backslashes for backwards compatibility + if (key.slice(-1) === '\\') { + prefix = prefix + key.slice(0, -1) + '.'; + continue; + } + key = prefix + key; + if (Object.prototype.hasOwnProperty.call(result, key)) { + result = result[key]; + prefix = ''; + } else { + return; + } + } + return result; +} + function unwrapIfSafeString(handlebars, val) { if (val instanceof handlebars.SafeString) { val = val.toString(); @@ -20,6 +55,7 @@ const maximumPixelSize = 5120; module.exports = { isValidURL, + getValue, unwrapIfSafeString, maximumPixelSize }; diff --git a/helpers/moment.js b/helpers/moment.js new file mode 100644 index 0000000..7254df1 --- /dev/null +++ b/helpers/moment.js @@ -0,0 +1,80 @@ +'use strict'; + +const utils = require('handlebars-utils'); +const date = require('date.js'); + +// suppress error messages that are not actionable +const moment = require('moment'); +moment.suppressDeprecationWarnings = true; + +/* + * Modified from https://github.com/helpers/helper-date/blob/1.0.1/index.js + */ +const factory = () => { + return function (str, pattern) { + // always use the Handlebars-generated options object + let options = arguments[arguments.length - 1]; + if (arguments.length < 3) { + pattern = null; + } + if (arguments.length < 2) { + str = null; + } + + // if no args are passed, return a formatted date + if (str === null && pattern === null) { + moment.locale('en'); + return moment().format('MMMM DD, YYYY'); + } + + var defaults = { lang: 'en', date: new Date(str) }; + var opts = utils.context(this, defaults, options); + + // set the language to use + moment.locale(opts.lang || opts.language); + + if (opts.datejs === false) { + return moment(new Date(str)).format(pattern); + } + + // if both args are strings, this could apply to either lib. + // so instead of doing magic we'll just ask the user to tell + // us if the args should be passed to date.js or moment. + if (typeof str === 'string' && typeof pattern === 'string') { + return moment(date(str)).format(pattern); + } + + // If handlebars, expose moment methods as hash properties + if (options && options.hash) { + if (options.context) { + options.hash = Object.assign({}, options.hash, options.context); + } + + var res = moment(str); + for (var key in options.hash) { + // prevent access to prototype methods + if (res.hasOwnProperty(key) && typeof res[key] === 'function') { + return res[key](options.hash[key]); + } else { + console.error('moment.js does not support "' + key + '"'); + } + } + } + + if (utils.isObject(str)) { + return moment(str).format(pattern); + } + + // if only a string is passed, assume it's a date pattern ('YYYY') + if (typeof str === 'string' && !pattern) { + return moment().format(str); + } + + return moment(str).format(pattern); + }; +}; + +module.exports = [{ + name: "moment", + factory: factory, +}] \ No newline at end of file diff --git a/helpers/option.js b/helpers/option.js new file mode 100644 index 0000000..54f8ac5 --- /dev/null +++ b/helpers/option.js @@ -0,0 +1,33 @@ +'use strict'; + +const util = require('handlebars-utils'); +const { getValue } = require('./lib/common'); + +/** + * Based on https://github.com/helpers/handlebars-helpers/blob/0.9.0/lib/misc.js#L26-L28 + * + * Get a value from the options object. Property paths (`a.b.c`) may be used + * to get nested properties. + */ +const factory = () => { + return function (path, locals) { + // preserve `option` behavior with missing args while ensuring the correct + // options object is used + let options = arguments[arguments.length - 1]; + if (arguments.length < 3) { + locals = null; + } + if (arguments.length < 2) { + path = ''; + } + + let opts = util.options(this, locals, options); + + return getValue(opts, path); + }; +}; + +module.exports = [{ + name: "option", + factory: factory, +}] \ No newline at end of file diff --git a/helpers/thirdParty.js b/helpers/thirdParty.js index 84b2e0c..93bbb29 100644 --- a/helpers/thirdParty.js +++ b/helpers/thirdParty.js @@ -55,16 +55,6 @@ const whitelist = [ 'unlessLteq', ], }, - { - name: 'date', - include: ['moment'], - init: () => { - // date-helper uses moment under the hood, so we can hook in to supress - // error messages that are not actionable - const moment = require('moment'); - moment.suppressDeprecationWarnings = true; - }, - }, { name: 'html', include: ['ellipsis', 'sanitize', 'ul', 'ol', 'thumbnailImage'] @@ -83,7 +73,7 @@ const whitelist = [ }, { name: 'misc', - include: ['default', 'option', 'noop', 'withHash'], + include: ['default', 'noop', 'withHash'], }, { name: 'number', @@ -106,8 +96,6 @@ const whitelist = [ 'forIn', 'forOwn', 'toPath', - 'get', - 'getObject', 'hasOwn', 'isObject', 'merge', diff --git a/spec/helpers.js b/spec/helpers.js index b3e24c6..6152f8f 100644 --- a/spec/helpers.js +++ b/spec/helpers.js @@ -30,6 +30,7 @@ describe('helper registration', () => { 'dynamicComponent', 'encodeHtmlEntities', 'for', + 'get', 'getContentImage', 'getContentImageSrcset', 'getFontLoaderConfig', @@ -39,6 +40,7 @@ describe('helper registration', () => { 'getImageManagerImageSrcset', 'getImageSrcset', 'getImageSrcset1x2x', + 'getObject', 'getVar', 'helperMissing', 'if', @@ -50,9 +52,11 @@ describe('helper registration', () => { 'lang', 'langJson', 'limit', + 'moment', 'money', 'nl2br', 'occurrences', + 'option', 'or', 'partial', 'pluck', @@ -106,7 +110,6 @@ describe('helper registration', () => { 'unlessLt', 'unlessGteq', 'unlessLteq', - 'moment', 'ellipsis', 'sanitize', 'ul', @@ -125,7 +128,6 @@ describe('helper registration', () => { 'sum', 'avg', 'default', - 'option', 'noop', 'withHash', 'addCommas', @@ -141,8 +143,6 @@ describe('helper registration', () => { 'forIn', 'forOwn', 'toPath', - 'get', - 'getObject', 'hasOwn', 'isObject', 'merge', diff --git a/spec/helpers/get.js b/spec/helpers/get.js new file mode 100644 index 0000000..ca914e7 --- /dev/null +++ b/spec/helpers/get.js @@ -0,0 +1,33 @@ +const Lab = require('lab'), + lab = exports.lab = Lab.script(), + describe = lab.experiment, + it = lab.it, + testRunner = require('../spec-helpers').testRunner; + +describe('get helper', function () { + const context = { + array: [1, 2, 3, 4, 5], + options: { a: { b: { c: 'd' } } } + }; + + const runTestCases = testRunner({ context }); + + it('gets a nested prop', (done) => { + runTestCases([ + { + input: `{{get "a.b.c" this.options}}`, + output: `d`, + } + ], done); + }); + + it('does not access prototype properties', (done) => { + context.__proto__ = {x: 'yz'}; + runTestCases([ + { + input: `{{get "x" this}}`, + output: ``, + } + ], done); + }); +}); \ No newline at end of file diff --git a/spec/helpers/getObject.js b/spec/helpers/getObject.js new file mode 100644 index 0000000..958c1b9 --- /dev/null +++ b/spec/helpers/getObject.js @@ -0,0 +1,43 @@ +const Lab = require('lab'), + lab = exports.lab = Lab.script(), + describe = lab.experiment, + it = lab.it, + testRunner = require('../spec-helpers').testRunner; + +describe('getObject helper', function () { + const context = { + obj: { + a: { + b: { + c: 'd' + }, + array: [1, 2, 3, 4, 5] + } + } + }; + + const runTestCases = testRunner({context}); + + it('returns requested prop in correct object type', function (done) { + runTestCases([ + { + input: `{{#with (getObject "a.b.c" obj)}}{{c}}{{/with}}`, + output: 'd', + }, + { + input: `{{getObject "a.array[2]" obj}}`, + output: `3`, + } + ], done); + }); + + it('does not access prototype props', function (done) { + context.obj.__proto__ = {x: 'yz'}; + runTestCases([ + { + input: `{{#with (getObject "x" obj)}}{{x}}{{/with}}`, + output: ``, + }, + ], done); + }); +}); \ No newline at end of file diff --git a/spec/helpers/lib/common.js b/spec/helpers/lib/common.js new file mode 100644 index 0000000..8fa5399 --- /dev/null +++ b/spec/helpers/lib/common.js @@ -0,0 +1,84 @@ +const getValue = require('../../../helpers/lib/common').getValue; +const Code = require('code'), + expect = Code.expect; +const Lab = require('lab'), + lab = exports.lab = Lab.script(), + describe = lab.experiment, + it = lab.it; + +describe('common utils', function () { + describe('getValue', function () { + const obj = { + a: { + a: [{x: 'a'}, {y: 'b'}], + b: { + b: { + a: 1, + }, + }, + c: [1, 1, 2, 3, 5, 8, 13, 21, 34], + }, + b: [2, 3, 5, 7, 11, 13, 17, 19], + c: 3, + d: false, + }; + obj.__proto__ = {x: 'yz'}; + + it('should get a value from an object', (done) => { + expect(getValue(obj, 'c')).to.equal(3); + expect(getValue(obj, 'd')).to.equal(false); + done(); + }); + + it('should get nested values', (done) => { + expect(getValue(obj, 'a.b.b.a')).to.equal(1); + expect(getValue(obj, ['a', 'b', 'b', 'a'])).to.equal(1); + done(); + }); + + it('should get nested values from arrays', (done) => { + expect(getValue(obj, 'b.0')).to.equal(2); + expect(getValue(obj, 'a.c.5')).to.equal(8); + done(); + }); + + it('should get nested values from objects in arrays', (done) => { + expect(getValue(obj, 'a.a.1.y')).to.equal('b'); + done(); + }); + + it('should return the whole object if path is not a string or array', (done) => { + expect(getValue(obj, {})).to.equal(obj); + expect(getValue(obj)).to.equal(obj); + done(); + }); + + it('should return the whole object if path is empty', (done) => { + expect(getValue(obj, '')).to.equal(obj); + expect(getValue(obj, [])).to.equal(obj); + done(); + }); + + it('should return undefined if prop does not exist', (done) => { + expect(getValue(obj, 'a.a.a.a')).to.equal(undefined); + expect(getValue(obj, 'a.c.23')).to.equal(undefined); + expect(getValue(obj, 'ab')).to.equal(undefined); + expect(getValue(obj, 'nonexistent')).to.equal(undefined); + done(); + }); + + it('should treat backslash-escaped . characters as part of a prop name', (done) => { + const data = {'a.b': {'c.d.e': 42, z: 'xyz'}}; + + expect(getValue(data, 'a\\.b.z')).to.equal('xyz'); + expect(getValue(data, 'a\\.b.c\\.d\\.e')).to.equal(42); + done() + }); + + it('should not access inherited props', (done) => { + expect(getValue(obj, 'x')).to.equal(undefined); + expect(getValue(obj, 'a.constructor')).to.equal(undefined); + done(); + }); + }); +}); \ No newline at end of file diff --git a/spec/helpers/moment.js b/spec/helpers/moment.js new file mode 100644 index 0000000..5c22c2d --- /dev/null +++ b/spec/helpers/moment.js @@ -0,0 +1,19 @@ +const Lab = require('lab'), + lab = exports.lab = Lab.script(), + describe = lab.experiment, + it = lab.it, + testRunner = require('../spec-helpers').testRunner; + +describe('moment helper', function () { + const runTestCases = testRunner({}); + + it('renders the date in the format specified', function (done) { + const now = new Date(); + runTestCases([ + { + input: `{{#moment "1 year ago" "YYYY"}}{{/moment}}`, + output: `${now.getFullYear() - 1}`, + }, + ], done); + }); +}); \ No newline at end of file diff --git a/spec/helpers/option.js b/spec/helpers/option.js new file mode 100644 index 0000000..ab11b58 --- /dev/null +++ b/spec/helpers/option.js @@ -0,0 +1,33 @@ +const Lab = require('lab'), + lab = exports.lab = Lab.script(), + describe = lab.experiment, + it = lab.it, + testRunner = require('../spec-helpers').testRunner; + +describe('option helper', function () { + const context = { + array: [1, 2, 3, 4, 5], + options: { a: { b: { c: 'd' } } } + }; + + const runTestCases = testRunner({context}); + + it('returns the nested prop of this.options', function (done) { + runTestCases([ + { + input: `{{option "a.b.c"}}`, + output: 'd', + }, + ], done); + }); + + it('does not access prototype props', function (done) { + context.options.__proto__ = {x: 'yz'}; + runTestCases([ + { + input: `{{option "x"}}`, + output: ``, + }, + ], done); + }); +}); \ No newline at end of file diff --git a/spec/helpers/thirdParty.js b/spec/helpers/thirdParty.js index d5e1b5a..5e28384 100644 --- a/spec/helpers/thirdParty.js +++ b/spec/helpers/thirdParty.js @@ -77,22 +77,6 @@ describe('third party handlebars-helpers', function() { }); - describe('date helpers', function() { - - describe('contains moment', function() { - it('renders the date in the format specified', function(done) { - const now = new Date(); - runTestCases([ - { - input: `{{#moment "1 year ago" "YYYY"}}{{/moment}}`, - output: `${now.getFullYear() - 1}`, - }, - ], done); - }); - }); - - }); - describe('html helpers', function() { describe('contains ellipsis', function() { @@ -177,21 +161,6 @@ describe('third party handlebars-helpers', function() { }); - describe('misc helpers', function() { - - describe('contains option', function() { - it('returns the nested prop of this.options', function(done) { - runTestCases([ - { - input: `{{option "a.b.c"}}`, - output: 'd', - }, - ], done); - }); - }); - - }); - describe('object helpers', function() { describe('contains isObject', function() {