diff --git a/README.md b/README.md index 04bd475de..0c7d0ea06 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,9 @@ engine.render(tpl, {name: 'alice'}) var engine = Liquid({ root: path.resolve(__dirname, 'views/'), // for layouts and includes extname: '.liquid', - cache: false + cache: false, + strict_filters: false, // default: false + strict_variables: false // default: false }); engine.renderFile("hello.liquid", {name: 'alice'}) .then(function(html){ @@ -69,26 +71,11 @@ Defaults to `process.cwd()` * `cache` indicates whether or not to cache resolved templates. Defaults to `false`. -## Strict Rendering +* `strict_filters` is used to enable strict filter existence. If set to `false`, undefined filters will be rendered as empty string. Otherwise, undefined filters will cause an exception. Defaults to `false`. -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: the below opts also work: -// engine.render(tpl, ctx, opts) -// engine.renderFile(path, ctx, opts) -``` +* `strict_variables` is used to enable strict variable derivation. +If set to `false`, undefined variables will be rendered as empty string. +Otherwise, undefined variables will cause an exception. Defaults to `false`. ## Use with Express.js @@ -104,7 +91,8 @@ app.set('view engine', 'liquid'); // set to default > There's an Express demo [here](demo/express/). -Note: includes and layouts lookup path should always be specified by `Liquid({root: []})`. +When using with Express.js, partials(includes and layouts) will be looked up in +both Liquid `root` and Express `views` directories. ## Use in Browser diff --git a/index.js b/index.js index f6dd36bd1..b979807a9 100644 --- a/index.js +++ b/index.js @@ -38,6 +38,7 @@ var _engine = { return this.parser.parse(tokens); }, render: function(tpl, ctx, opts) { + opts = _.assign({}, this.options, opts); var scope = Scope.factory(ctx, opts); return this.renderer.renderTemplates(tpl, scope); }, @@ -73,6 +74,7 @@ var _engine = { }, lookup: function(filepath, root) { root = this.options.root.concat(root || []); + root = _.uniq(root); var paths = root.map(root => path.resolve(root, filepath)); return anySeries(paths, path => statFileAsync(path).then(() => path)) .catch((e) => { diff --git a/src/filter.js b/src/filter.js index d75848cb0..0c03e5660 100644 --- a/src/filter.js +++ b/src/filter.js @@ -21,7 +21,7 @@ module.exports = function() { if (typeof filter !== 'function'){ return { name: name, - error: new Error(`undefined filter: ${name}`) + error: new TypeError(`undefined filter: ${name}`) }; } diff --git a/src/scope.js b/src/scope.js index 72e808ce0..960f1029f 100644 --- a/src/scope.js +++ b/src/scope.js @@ -4,9 +4,9 @@ const assert = require('./util/assert.js'); const referenceError = /undefined variable|Cannot read property .* of undefined/; var Scope = { - getAll: function(str) { + getAll: function() { var ctx = {}; - for (i = this.scopes.length - 1; i >= 0; i--) { + for (var i = this.scopes.length - 1; i >= 0; i--) { _.assign(ctx, this.scopes[i]); } return ctx; @@ -26,13 +26,6 @@ var Scope = { throw new TypeError('undefined variable: ' + str); } }, - safeGet: function(str) { - try { - var val = this.safeGet(str); - } catch (e) {; - } - return val; - }, set: function(k, v) { this.setPropertyByPath(this.scopes[this.scopes.length - 1], k, v); return this; @@ -112,7 +105,7 @@ var Scope = { } // foo["bar"] else { - var j = str.indexOf(delemiter, i + 2); + j = str.indexOf(delemiter, i + 2); assert(j !== -1, `unbalanced ${delemiter}: ${str}`); name = str.slice(i + 2, j); seq.push(name); diff --git a/src/util/underscore.js b/src/util/underscore.js index 1e8b8cb25..67744f421 100644 --- a/src/util/underscore.js +++ b/src/util/underscore.js @@ -25,27 +25,75 @@ function forOwn(object, iteratee) { return object; } -function assign(dst, src) { - dst = dst || {}; +/* + * Assigns own enumerable string keyed properties of source objects to the destination object. + * Source objects are applied from left to right. + * Subsequent sources overwrite property assignments of previous sources. + * + * Note: This method mutates object and is loosely based on Object.assign. + * + * @param {Object} object The destination object. + * @param {...Object} sources The source objects. + * @return {Object} Returns object. + */ +function assign(object) { + object = isObject(object) ? object : {}; + var srcs = Array.prototype.slice.call(arguments, 1); + srcs.forEach(function(src) { + _assignBinary(object, src); + }); + return object; +} + +function _assignBinary(dst, src) { + if (!dst) return dst; forOwn(src, function(v, k) { dst[k] = v; }); return dst; } + function isArray(value) { return value instanceof Array; } -function echo(prefix){ +function echo(prefix) { return v => { console.log('[' + prefix + ']', v); return v; }; } +function uniq(arr) { + var u = {}, + a = []; + for (var i = 0, l = arr.length; i < l; ++i) { + if (u.hasOwnProperty(arr[i])) { + continue; + } + a.push(arr[i]); + u[arr[i]] = 1; + } + return a; +} + +/* + * Checks if value is the language type of Object. + * (e.g. arrays, functions, objects, regexes, new Number(0), and new String('')) + * @param {any} value The value to check. + * @return {Boolean} Returns true if value is an object, else false. + */ +function isObject(value) { + return value !== null && typeof value === 'object'; +} + exports.isString = isString; exports.isArray = isArray; +exports.isObject = isObject; + exports.forOwn = forOwn; exports.assign = assign; +exports.uniq = uniq; + exports.echo = echo; diff --git a/test/liquid.js b/test/liquid.js index 593f61186..eec3f9bbf 100644 --- a/test/liquid.js +++ b/test/liquid.js @@ -1,5 +1,4 @@ const chai = require("chai"); -const should = chai.should(); const expect = chai.expect; const Liquid = require('..'); const mock = require('mock-fs'); @@ -152,10 +151,10 @@ describe('liquid', function() { it('should be disabled by default', function() { return engine.renderFile('files/foo') .then(x => expect(x).to.equal('foo')) - .then(x => mock({ + .then(() => mock({ '/root/files/foo.html': 'bar' })) - .then(x => engine.renderFile('files/foo')) + .then(() => engine.renderFile('files/foo')) .then(x => expect(x).to.equal('bar')); }); it('should respect cache=true option', function() { @@ -166,10 +165,10 @@ describe('liquid', function() { }); return engine.renderFile('files/foo') .then(x => expect(x).to.equal('foo')) - .then(x => mock({ + .then(() => mock({ '/root/files/foo.html': 'bar' })) - .then(x => engine.renderFile('files/foo')) + .then(() => engine.renderFile('files/foo')) .then(x => expect(x).to.equal('foo')); }); }); diff --git a/test/util/error.js b/test/util/error.js index a1f3aa1e2..293b330f2 100644 --- a/test/util/error.js +++ b/test/util/error.js @@ -1,65 +1,103 @@ const chai = require("chai"); const expect = chai.expect; const mock = require('mock-fs'); +chai.use(require("chai-as-promised")); -var engine = require('../..')(), ctx; - -function test(promise, cb){ - return promise - .then((result) => { - return cb({}); - }) - .catch((error) => { - return cb(error); - }); -} +var engine = require('../..')(); +var strictEngine = require('../..')({ + strict_variables: true, + strict_filters: true +}); describe('error', function() { - it('should throw TokenizationError when tag illegal', function() { - return test(engine.parseAndRender('{% . a %}', {}), function(err){ - expect(err.name).to.equal('TokenizationError'); - expect(err.message).to.equal('illegal tag: {% . a %}'); - expect(err.input).to.equal('{% . a %}'); - expect(err.line).to.equal(1); + describe('TokenizationError', function() { + it('should throw TokenizationError when tag illegal', function() { + return engine.parseAndRender('{% . a %}', {}).catch(function(err) { + expect(err.name).to.equal('TokenizationError'); + expect(err.message).to.equal('illegal tag: {% . a %}'); + expect(err.input).to.equal('{% . a %}'); + expect(err.line).to.equal(1); + }); + }); + it('should throw TokenizationError when tag syntax illegal', function() { + return engine.parseAndRender('{% . a }', {}).catch(function(err) { + expect(err.name).to.equal('TokenizationError'); + expect(err.message).to.equal('illegal tag: {% . a }'); + expect(err.input).to.equal('{% . a }'); + expect(err.line).to.equal(1); + }); + }); + it('should throw TokenizationError when filter syntax illegal', function() { + return engine.parseAndRender('{{ a|| }}', {}).catch(function(err) { + expect(err.name).to.equal('TokenizationError'); + expect(err.message).to.equal('{{ a|| }'); + expect(err.input).to.equal('{{ a|| }'); + expect(err.line).to.equal(1); + }); }); }); - it('should throw correct error info', function() { - return test(engine.parseAndRender('{%if true%}\naaa{%endif%}\n{% -a %}\n3', {}), function(err){ - expect(err.input).to.equal('{% -a %}'); - expect(err.line).to.equal(3); + describe('TypeError', function() { + it('should not throw when variable undefined by default', function() { + return expect(engine.parseAndRender('X{{a}}Y')).to.eventually.equal('XY'); + }); + it('should throw TypeError when variable not defined', function() { + return expect(strictEngine.parseAndRender('{{a}}')).to.eventually + .be.rejected + .then(function(e){ + expect(e).to.have.property('name', 'TypeError'); + expect(e).to.have.property('message', 'undefined variable: a'); + }); + }); + it('should throw TypeError when filter not defined', function() { + return expect(strictEngine.parseAndRender('{{1 | a}}')).to.eventually + .be.rejected + .then(function(e){ + expect(e).to.have.property('name', 'TypeError'); + expect(e).to.have.property('message', 'undefined filter: a'); + }); }); }); - it('should throw correct error info for files', function() { - mock({ - "/foo.html": '\n\n\n{% raw %}\n\n' + describe('ParseError', function() { + it('should throw correct error info', function() { + var src = '{%if true%}\naaa{%endif%}\n{% -a %}\n3'; + return engine.parseAndRender(src).catch(function(err) { + expect(err.name).to.equal('ParseError'); + expect(err.input).to.equal('{% -a %}'); + expect(err.line).to.equal(3); + }); }); - return test(engine.renderFile('/foo.html', {}), function(err){ - mock.restore(); - expect(err.input).to.equal('{% raw %}'); - expect(err.line).to.equal(4); - expect(err.file).to.equal('/foo.html'); + it('should throw correct error info for files', function() { + mock({ + "/foo.html": '\n\n\n{% raw %}\n\n' + }); + return engine.renderFile('/foo.html').catch(function(err) { + mock.restore(); + expect(err.name).to.equal('ParseError'); + expect(err.input).to.equal('{% raw %}'); + expect(err.line).to.equal(4); + expect(err.file).to.equal('/foo.html'); + }); + }); + + it('should throw ParseError when tag not exist', function() { + return engine.parseAndRender('{% a %}').catch(function(err) { + expect(err.name).to.equal('ParseError'); + expect(err.message).to.equal('tag a not found'); + expect(err.input).to.equal('{% a %}'); + expect(err.line).to.equal(1); + }); + }); + + it('should throw ParseError when tag not closed', function() { + return engine.parseAndRender('{% if %}').catch(function(err) { + expect(err.name).to.equal('ParseError'); + expect(err.message).to.equal('tag {% if %} not closed'); + expect(err.input).to.equal('{% if %}'); + 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'); - expect(err.message).to.equal('tag a not found'); - expect(err.input).to.equal('{% a %}'); - expect(err.line).to.equal(1); - }); - }); - - it('should throw ParseError when tag not closed', function() { - return test(engine.parseAndRender('{% if %}', {}), function(err){ - expect(err.name).to.equal('ParseError'); - expect(err.message).to.equal('tag {% if %} not closed'); - expect(err.input).to.equal('{% if %}'); - expect(err.line).to.equal(1); - }); - }); - }); diff --git a/test/util/underscore.js b/test/util/underscore.js index 0cb39ee5f..fad3dd0a1 100644 --- a/test/util/underscore.js +++ b/test/util/underscore.js @@ -11,7 +11,7 @@ describe('util/underscore', function() { expect(_.isString('foo')).to.be.true; }); it('should return true String instance', function() { - expect(_.isString(new String('foo'))).to.be.true; + expect(_.isString(String('foo'))).to.be.true; }); it('should return false for 123 ', function() { expect(_.isString(123)).to.be.false; @@ -53,7 +53,7 @@ describe('util/underscore', function() { expect(_.isArray("foo")).to.be.false; }); }); - describe('.echo()', function(){ + describe('.echo()', function() { it('should be transparent', function() { expect(_.echo('foo')('bar')).to.equal('bar'); }); @@ -63,4 +63,49 @@ describe('util/underscore', function() { expect(log).to.have.been.calledWith('[foo]', 'bar'); }); }); + describe('.assign()', function() { + it('should handle null dst', function() { + expect(_.assign(null, { + foo: 'bar' + })).to.deep.equal({ + foo: 'bar' + }); + }); + it('should assign 2 objects', function() { + var src = { + foo: 'foo', + bar: 'bar' + }; + var dst = { + foo: 'bar', + kaa: 'kaa' + }; + expect(_.assign(dst, src)).to.deep.equal({ + foo: 'foo', + bar: 'bar', + kaa: 'kaa' + }); + }); + it('should assign 3 objects', function() { + expect(_.assign({ + foo: 'foo' + }, { + bar: 'bar' + }, { + car: 'car' + })).to.deep.equal({ + foo: 'foo', + bar: 'bar', + car: 'car' + }); + }); + }); + describe('.uniq()', function() { + it('should handle empty array', function() { + expect(_.uniq([])).to.deep.equal([]); + }); + it('should do uniq', function() { + expect(_.uniq([1, 'a', 'a', 1])).to.deep.equal([1, 'a']); + }); + }); });