diff --git a/README.md b/README.md index 448e280d6..4e2774a0b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,26 @@ engine.renderFile("hello", {name: 'alice'}) `cache` default to `false`, `extname` default to `.liquid`, `root` default to `""`. +## Strict Rendering + +Undefined filters and variables will be rendered as empty string by default. +Enable strict rendering to throw errors upon undefined variables/filters: + +```javascript +var opts = { + strict_variables: true, + strict_filters: true +}; +engine.parseAndRender("{{ foo }}", {}, opts).catch(function(err){ + // err.message === undefined variable: foo +}); +engine.parseAndRender("{{ 'foo' | filter1 }}", {}, opts).catch(function(err){ + // err.message === undefined filter: filter1 +}); +// Note: +// `engine.render(tpl, ctx, opts)` and `engine.renderFile(path, ctx, opts)` also works. +``` + ## Use with Express.js ```javascript diff --git a/index.js b/index.js index 9cdf7e9b5..850e69db4 100644 --- a/index.js +++ b/index.js @@ -43,7 +43,7 @@ var _engine = { var scope = Scope.factory(ctx, { strict: opts.strict_variables, }); - return this.renderer.renderTemplates(tpl, scope); + return this.renderer.renderTemplates(tpl, scope, opts); }, parseAndRender: function(html, ctx, opts) { try { @@ -55,10 +55,10 @@ var _engine = { return Promise.reject(error); } }, - renderFile: function(filepath, ctx) { + renderFile: function(filepath, ctx, opts) { return this.handleCache(filepath) .then((templates) => { - return this.render(templates, ctx); + return this.render(templates, ctx, opts); }) .catch((e) => { e.file = filepath; diff --git a/package.json b/package.json index 4363c0da3..e5d1d519e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shopify-liquid", - "version": "1.1.14", + "version": "1.1.15", "description": "Liquid template engine in Node.js (Shopify compliant)", "main": "index.js", "scripts": { diff --git a/src/filter.js b/src/filter.js index 4d9aa721f..d336d9d91 100644 --- a/src/filter.js +++ b/src/filter.js @@ -19,7 +19,10 @@ module.exports = function() { var name = match[1], argList = match[2] || '', filter = filters[name]; if (typeof filter !== 'function'){ - throw new Error(`filter "${name}" not found`); + return { + name: name, + error: new Error(`undefined filter: ${name}`) + }; } var args = []; @@ -37,8 +40,7 @@ module.exports = function() { function construct(str) { var instance = Object.create(_filterInstance); - instance.parse(str); - return instance; + return instance.parse(str); } function register(name, filter) { diff --git a/src/render.js b/src/render.js index 1b13f8678..74dafdb9b 100644 --- a/src/render.js +++ b/src/render.js @@ -9,7 +9,6 @@ var render = { renderTemplates: function(templates, scope, opts) { assert(scope, 'unable to evalTemplates: scope undefined'); opts = _.defaults(opts, { - strict_variables: false, strict_filters: false }); @@ -20,52 +19,52 @@ var render = { // emptyPromise.then(renderTag(template0).then(renderTag(template1).then(renderTag(template2)... var lastPromise = templates.reduce((promise, template) => { return promise.then((partial) => { - if (scope.safeGet('forloop.skip')) { - return Promise.resolve(''); - } - if (scope.safeGet('forloop.stop')) { - throw new Error('forloop.stop'); // this will stop/break the sequential promise chain and go to the catch - } + if (scope.safeGet('forloop.skip')) { + return Promise.resolve(''); + } + if (scope.safeGet('forloop.stop')) { + throw new Error('forloop.stop'); // this will stop/break the sequential promise chain and go to the catch + } - var promiseLink = Promise.resolve(''); - switch (template.type) { - case 'tag': - // Add Promises to the chain - promiseLink = this.renderTag(template, scope, this.register) - .then((partial) => { - if (partial === undefined) { - return true; // basically a noop (do nothing) - } - return html += partial; - }); - break; - case 'html': - promiseLink = Promise.resolve(template.value) - .then((partial) => { - return html += partial; - }); - break; - case 'output': - var val = this.evalOutput(template, scope); - promiseLink = Promise.resolve(val === undefined ? '' : stringify(val)) - .then((partial) => { - return html += partial; - }); - break; - } + var promiseLink = Promise.resolve(''); + switch (template.type) { + case 'tag': + // Add Promises to the chain + promiseLink = this.renderTag(template, scope, this.register) + .then((partial) => { + if (partial === undefined) { + return true; // basically a noop (do nothing) + } + return html += partial; + }); + break; + case 'html': + promiseLink = Promise.resolve(template.value) + .then((partial) => { + return html += partial; + }); + break; + case 'output': + var val = this.evalOutput(template, scope, opts); + promiseLink = Promise.resolve(val === undefined ? '' : stringify(val)) + .then((partial) => { + return html += partial; + }); + break; + } - return promiseLink; - }) - .catch((error) => { - if (error.message === 'forloop.skip') { - // the error is a controlled, purposeful stop. so just return the html that we have up to this point - return html; - } else { - // rethrow actual error - throw error; - } - }); - }, Promise.resolve('')); // start the reduce chain with a resolved Promise. After first run, the "promise" argument + return promiseLink; + }) + .catch((error) => { + if (error.message === 'forloop.skip') { + // the error is a controlled, purposeful stop. so just return the html that we have up to this point + return html; + } else { + // rethrow actual error + throw error; + } + }); + }, Promise.resolve('')); // start the reduce chain with a resolved Promise. After first run, the "promise" argument // in our reduce callback will be the returned promise from our "then" above. In this // case, that's the promise returned from this.renderTag or a resolved promise with raw html. @@ -92,14 +91,24 @@ var render = { return template.render(scope, register); }, - evalOutput: function(template, scope) { + evalOutput: function(template, scope, opts) { assert(scope, 'unable to evalOutput: scope undefined'); var val = Exp.evalExp(template.initial, scope); - return template.filters - .reduce((v, filter) => filter.render(v, scope), val); + template.filters.some(filter => { + if (filter.error) { + if (opts.strict_filters) { + throw filter.error; + } else { // render as null + val = ''; + return true; + } + } + val = filter.render(val, scope); + }); + return val; }, - resetRegisters: function(){ + resetRegisters: function() { return this.register = {}; } }; diff --git a/test/error.js b/test/error.js index 51c0ee666..e4530f893 100644 --- a/test/error.js +++ b/test/error.js @@ -42,14 +42,6 @@ describe('error', function() { }); }); - it('should throw ParseError when filter not exist', function() { - return test(engine.parseAndRender('{{ a | xz }}', {}), function(err){ - expect(err.name).to.equal('ParseError'); - expect(err.message).to.equal('filter "xz" not found'); - expect(err.input).to.equal('{{ a | xz }}'); - expect(err.line).to.equal(1); - }); - }); it('should throw ParseError when tag not exist', function() { return test(engine.parseAndRender('{% a %}', {}), function(err){ expect(err.name).to.equal('ParseError'); diff --git a/test/filter.js b/test/filter.js index 9a90f0ad6..52bc6bc0e 100644 --- a/test/filter.js +++ b/test/filter.js @@ -14,10 +14,10 @@ describe('filter', function() { filter.clear(); scope = Scope.factory(); }); - it('should throw when not registered', function() { - expect(function() { - filter.construct('foo'); - }).to.throw(/filter "foo" not found/); + it('should return undefined when not registered', function() { + var result = filter.construct('foo'); + expect(result.name).to.equal('foo'); + expect(result.error).to.be.an('Error'); }); it('should parse argument syntax', function(){ diff --git a/test/liquid.js b/test/liquid.js index 89d836171..d0c717fea 100644 --- a/test/liquid.js +++ b/test/liquid.js @@ -37,6 +37,16 @@ describe('liquid', function() { it('should output undefined to empty', function() { return engine.parseAndRender('foo{{zzz}}bar', ctx).should.eventually.equal('foobar'); }); + it('should render as null when filter undefined', function() { + return engine.parseAndRender('{{arr | filter1}}', ctx).should.eventually.equal(''); + }); + it('should throw upon undefined filter when strict_filters set', function() { + var opts = { + strict_filters: true + }; + return expect(engine.parseAndRender('{{arr | filter1}}', ctx, opts)).to + .be.rejectedWith(/undefined filter: filter1/); + }); it('should parse html', function() { (function() { engine.parse('{{obj}}');