diff --git a/README.md b/README.md index 0dd89af4c..8059bfcaa 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ engine.render(tpl, {name: 'alice'}) ```javascript var engine = Liquid({ - root: path.resolve(__dirname, 'views/'), // for layouts and partials + root: path.resolve(__dirname, 'views/'), // for layouts and includes extname: '.liquid', cache: false }); @@ -60,7 +60,13 @@ engine.renderFile("hello", {name: 'alice'}) }); ``` -`cache` default to `false`, `extname` default to `.liquid`, `root` default to `""`. +* `root` is a directory or an array of directories to resolve layouts and includes, as well as the filename passed in when calling `.renderFile()`. +If an array, the files are looked up in the order they occur in the array. +Defaults to `["."]` + +* `extname` is used to lookup the template file when filepath doesn't include an extension name. Defaults to `.liquid` + +* `cache` indicates whether or not to cache resolved templates. Defaults to `false`. ## Strict Rendering @@ -95,7 +101,9 @@ app.set('views', './views'); // specify the views directory app.set('view engine', 'liquid'); // set to default ``` -There's an Express demo [here](demo/express/). +> There's an Express demo [here](demo/express/). + +Note: includes and layouts lookup path should always be specified by `Liquid({root: []})`. ## Use in Browser diff --git a/demo/express/app.js b/demo/express/app.js new file mode 100644 index 000000000..46c15f96f --- /dev/null +++ b/demo/express/app.js @@ -0,0 +1,22 @@ +var express = require('express'); +var app = express(); +var Liquid = require('../..'); + +var engine = Liquid({ + root: __dirname, // for layouts and partials + extname: '.liquid' +}); + +app.engine('liquid', engine.express()); // register liquid engine +app.set('views', __dirname); // specify the views directory +app.set('view engine', 'liquid'); // set to default + +app.get('/', function (req, res) { + var todos = ['fork and clone', 'make it better', 'make a pull request']; + res.render('todolist', { + todos: todos, + title: 'Welcome to shopify-liquid!' + }); +}); + +module.exports = app; diff --git a/demo/express/bar.liquid b/demo/express/bar.liquid deleted file mode 100644 index ee82ed63d..000000000 --- a/demo/express/bar.liquid +++ /dev/null @@ -1 +0,0 @@ - diff --git a/demo/express/foo.liquid b/demo/express/foo.liquid deleted file mode 100644 index 9c18c6a02..000000000 --- a/demo/express/foo.liquid +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - -

Welcome to shopify-liquid!

- {% include 'bar' %} - - diff --git a/demo/express/index.js b/demo/express/index.js index e8376fbbc..29778f6e3 100644 --- a/demo/express/index.js +++ b/demo/express/index.js @@ -1,20 +1,6 @@ -var express = require('express'); -var app = express(); -var Liquid = require('..'); - -var engine = Liquid({ - root: __dirname, // for layouts and partials - extname: '.liquid' -}); - -app.engine('liquid', engine.express()); // register liquid engine -app.set('views', __dirname); // specify the views directory -app.set('view engine', 'liquid'); // set to default - -app.get('/', function (req, res) { - res.render('foo'); -}); +const app = require('./app.js'); app.listen(3000, function () { console.log('Example app listening on port 3000!'); }); + diff --git a/demo/express/partials/layout.liquid b/demo/express/partials/layout.liquid new file mode 100644 index 000000000..a92d4ff2c --- /dev/null +++ b/demo/express/partials/layout.liquid @@ -0,0 +1,14 @@ + + + + + {{title}} + + +

{{title}}

+ + {% block %} + + + + diff --git a/demo/express/partials/todo.liquid b/demo/express/partials/todo.liquid new file mode 100644 index 000000000..649c41fb3 --- /dev/null +++ b/demo/express/partials/todo.liquid @@ -0,0 +1 @@ +{{id}} - {{todo}} diff --git a/demo/express/views/todolist.liquid b/demo/express/views/todolist.liquid new file mode 100644 index 000000000..0ad8bc98e --- /dev/null +++ b/demo/express/views/todolist.liquid @@ -0,0 +1,11 @@ +{% layout 'layout' %} + + + +{% block 'footer' %} + Copyright @ 2016, Harttle +{% endblock %} diff --git a/index.js b/index.js index 59602ab1f..844936119 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,9 @@ const Scope = require('./src/scope'); +const _ = require('./src/util/underscore.js'); const tokenizer = require('./src/tokenizer.js'); -const fs = require('fs'); +const statFileAsync = require('./src/util/fs.js').statFileAsync; +const readFileAsync = require('./src/util/fs.js').readFileAsync; +const pathResolve = require('./src/util/fs.js').pathResolve; const Render = require('./src/render.js'); const lexical = require('./src/lexical.js'); const Tag = require('./src/tag.js'); @@ -10,6 +13,7 @@ const Syntax = require('./src/syntax.js'); const tags = require('./tags'); const filters = require('./filters'); const Promise = require('any-promise'); +const someSeries = require('./src/util/promise.js').someSeries; var _engine = { init: function(tag, filter, options) { @@ -53,7 +57,7 @@ var _engine = { } }, renderFile: function(filepath, ctx, opts) { - return this.handleCache(filepath) + return this.getTemplate(filepath) .then((templates) => { return this.render(templates, ctx, opts); }) @@ -72,30 +76,34 @@ var _engine = { registerTag: function(name, tag) { return this.tag.register(name, tag); }, - handleCache: function(filepath) { - if (!filepath) throw new Error('filepath cannot be null'); - - return this.getTemplate(filepath) - .then((html) => { - var tpl = this.options.cache && this.cache[filepath] || this.parse(html); - return this.options.cache ? (this.cache[filepath] = tpl) : tpl; - }); + lookup: function(filepath) { + var paths = this.options.root.map(root => pathResolve(root, filepath)); + return someSeries(paths, path => statFileAsync(path).then(() => path)); }, getTemplate: function(filepath) { - filepath = resolvePath(this.options.root, filepath); - if (!filepath.match(/\.\w+$/)) { filepath += this.options.extname; } - return new Promise(function(resolve, reject) { - fs.readFile(filepath, 'utf8', function(err, html) { - err ? reject(err) : resolve(html); + return this + .lookup(filepath) + .then(filepath => { + if (this.options.cache) { + var tpl = this.cache[filepath]; + if (tpl) { + return Promise.resolve(tpl); + } + return readFileAsync(filepath) + .then(str => this.parse(str)) + .then(tpl => this.cache[filepath] = tpl); + } else { + return readFileAsync(filepath).then(str => this.parse(str)); + } }); - }); }, - express: function(renderingOptions) { + express: function(renderOption) { + renderOption = renderOption || {}; return (filePath, options, callback) => { - this.renderFile(filePath, options, renderingOptions) + this.renderFile(filePath, options, renderOption) .then(html => callback(null, html)) .catch(e => callback(e)); }; @@ -104,7 +112,9 @@ var _engine = { function factory(options) { options = options || {}; - options.root = options.root || ''; + options.root = normalizeStringArray(options.root); + if (!options.root.length) options.root = ['.']; + options.extname = options.extname || '.liquid'; var engine = Object.create(_engine); @@ -113,17 +123,10 @@ function factory(options) { return engine; } -function resolvePath(root, path) { - if (path[0] == '/') return path; - - var arr = root.split('/').concat(path.split('/')); - var result = []; - arr.forEach(function(slug) { - if (slug == '..') result.pop(); - else if (!slug || slug == '.'); - else result.push(slug); - }); - return '/' + result.join('/'); +function normalizeStringArray(value) { + if (_.isArray(value)) return value; + if (_.isString(value)) return [value]; + return []; } factory.lexical = lexical; diff --git a/src/util/fs.js b/src/util/fs.js new file mode 100644 index 000000000..c080211cb --- /dev/null +++ b/src/util/fs.js @@ -0,0 +1,34 @@ +const fs = require('fs'); + +function readFileAsync(filepath) { + return new Promise(function(resolve, reject) { + fs.readFile(filepath, 'utf8', function(err, content) { + err ? reject(err) : resolve(content); + }); + }); +}; + +function statFileAsync(path) { + return new Promise(function(resolve, reject) { + fs.stat(path, (err, stat) => err ? reject(err) : resolve(stat)) + }); +}; + +function pathResolve(root, path) { + if (path[0] == '/') return path; + + var arr = root.split('/').concat(path.split('/')); + var result = []; + arr.forEach(function(slug) { + if (slug == '..') result.pop(); + else if (!slug || slug == '.'); + else result.push(slug); + }); + return '/' + result.join('/'); +} + +module.exports = { + readFileAsync, + pathResolve, + statFileAsync +}; diff --git a/src/util/promise.js b/src/util/promise.js new file mode 100644 index 000000000..03c8b69b7 --- /dev/null +++ b/src/util/promise.js @@ -0,0 +1,19 @@ +const Promise = require('any-promise'); + +/* + * Call functions in serial until someone resolved. + * @param {Array} iterable the array to iterate with. + * @param {Array} iteratee returns a new promise. + * The iteratee is invoked with three arguments: (value, index, iterable). + */ +function someSeries(iterable, iteratee) { + var ret = Promise.reject(new Error('init')); + iterable.forEach(function(item, idx) { + ret = ret + .then(x => x) + .catch(e => iteratee(item, idx, iterable)); + }); + return ret; +} + +exports.someSeries = someSeries; diff --git a/src/util/underscore.js b/src/util/underscore.js index 8bfa1ca80..ab2da2981 100644 --- a/src/util/underscore.js +++ b/src/util/underscore.js @@ -13,7 +13,7 @@ function isString(value) { * Iteratee functions may exit iteration early by explicitly returning false. * @param {Object} object The object to iterate over. * @param {Function} iteratee The function invoked per iteration. - * @return {Object} Returs object. + * @return {Object} Returns object. */ function forOwn(object, iteratee) { object = object || {}; @@ -25,5 +25,10 @@ function forOwn(object, iteratee) { return object; } +function isArray(value) { + return value instanceof Array; +} + exports.isString = isString; +exports.isArray = isArray; exports.forOwn = forOwn; diff --git a/tags/include.js b/tags/include.js index 7d995040e..b3b348bec 100644 --- a/tags/include.js +++ b/tags/include.js @@ -20,7 +20,7 @@ module.exports = function(liquid) { if(this.with){ hash[filepath] = Liquid.evalValue(this.with, scope); } - return liquid.handleCache(filepath) + return liquid.getTemplate(filepath) .then((templates) => { scope.push(hash); return liquid.renderer.renderTemplates(templates, scope); @@ -29,7 +29,6 @@ module.exports = function(liquid) { scope.pop(); return html; }); - } }); diff --git a/tags/layout.js b/tags/layout.js index 3b09bf222..3feeefef9 100644 --- a/tags/layout.js +++ b/tags/layout.js @@ -12,7 +12,7 @@ module.exports = function(liquid) { this.layout = match[0]; this.tpls = liquid.parser.parse(remainTokens); }, - render: function(scope, hash) { + render: function(scope) { var layout = Liquid.evalValue(this.layout, scope); var html = ''; @@ -21,7 +21,7 @@ module.exports = function(liquid) { return liquid.renderer.renderTemplates(this.tpls, scope) .then((partial) => { html += partial; - return liquid.handleCache(layout); + return liquid.getTemplate(layout); }) .then((templates) => { return liquid.renderer.renderTemplates(templates, scope); @@ -43,15 +43,15 @@ module.exports = function(liquid) { this.block = match ? match[0] : 'anonymous'; this.tpls = []; - var p, stream = liquid.parser.parseStream(remainTokens) - .on('tag:endblock', token => stream.stop()) + var stream = liquid.parser.parseStream(remainTokens) + .on('tag:endblock', () => stream.stop()) .on('template', tpl => this.tpls.push(tpl)) - .on('end', x => { + .on('end', () => { throw new Error(`tag ${token.raw} not closed`); }); stream.start(); }, - render: function(scope, hash){ + render: function(scope){ var html = scope.get(`_liquid.blocks.${this.block}`); var promise = Promise.resolve(''); if (html === undefined) { diff --git a/test/express.js b/test/express.js new file mode 100644 index 000000000..2a99ef3a6 --- /dev/null +++ b/test/express.js @@ -0,0 +1,61 @@ +const chai = require("chai"); +const expect = chai.expect; +const mock = require('mock-fs'); +const request = require('supertest'); +const express = require('express'); +const Liquid = require('..'); + +describe('engine#express()', function() { + var app, engine; + + before(function() { + mock({ + '/root/foo.html': 'foo', + '/views/name.html': 'My name is {{name}}.', + '/views/include.html': '{% include file %}', + '/partials/bar.html': 'bar' + }); + app = express(); + engine = Liquid({ + root: '/root', + extname: '.html' + }); + + app.set('views', ['/views', '/partials']); + app.set('view engine', 'html'); + app.engine('html', engine.express()); + + app.get('/name', function(req, res) { + res.render('name', { + name: 'harttle' + }); + }); + app.get('/include/:file', function(req, res) { + res.render('include', { + file: req.params.file + }); + }); + }); + it('should render templates', function(done) { + request(app).get('/name') + .expect('My name is harttle.') + .expect(200, done); + }); + it('should pass error when file not found', function(done) { + var view = { + root: [] + }; + var file = '/not-exist.html'; + var ctx = {}; + engine.express().call(view, file, ctx, function(err) { + expect(err.code).to.equal('ENOENT'); + console.log(err.message); + done(); + }); + }); + it('should respect root option when lookup', function(done) { + request(app).get('/include/foo') + .expect('foo') + .expect(200, done); + }); +}); diff --git a/test/liquid.js b/test/liquid.js index bd7c2c3bf..a2fecd5e5 100644 --- a/test/liquid.js +++ b/test/liquid.js @@ -1,10 +1,9 @@ const chai = require("chai"); -const chaiAsPromised = require("chai-as-promised"); const should = chai.should(); const expect = chai.expect; const Liquid = require('..'); const mock = require('mock-fs'); -chai.use(chaiAsPromised); +chai.use(require("chai-as-promised")); describe('liquid', function() { var engine, ctx; @@ -28,24 +27,26 @@ describe('liquid', function() { afterEach(function() { mock.restore(); }); - it('should output object', function() { - return engine.parseAndRender('{{obj}}', ctx).should.eventually.equal('{"foo":"bar"}'); - }); - it('should output array', function() { - return engine.parseAndRender('{{arr}}', ctx).should.eventually.equal('[-2,"a"]'); - }); - 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/); + describe('{{output}}', function() { + it('should output object', function() { + return engine.parseAndRender('{{obj}}', ctx).should.eventually.equal('{"foo":"bar"}'); + }); + it('should output array', function() { + return engine.parseAndRender('{{arr}}', ctx).should.eventually.equal('[-2,"a"]'); + }); + 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() { @@ -77,10 +78,20 @@ describe('liquid', function() { }); describe('#renderFile()', function() { it('should render file', function() { - return engine.renderFile('/root/files/foo.html', ctx).should.eventually.equal('foo'); + return expect(engine.renderFile('/root/files/foo.html', ctx)) + .to.eventually.equal('foo'); }); it('should accept relative path', function() { - return expect(engine.renderFile('files/foo.html')).to.eventually.equal('foo'); + return expect(engine.renderFile('files/foo.html')) + .to.eventually.equal('foo'); + }); + it('should resolve array as root', function(){ + engine = Liquid({ + root: ['/boo', '/root/'], + extname: '.html' + }); + return expect(engine.renderFile('files/foo.html')) + .to.eventually.equal('foo'); }); it('should render file with context', function() { return engine.renderFile('/root/files/name.html', ctx).should.eventually.equal('My name is harttle.'); @@ -102,19 +113,6 @@ describe('liquid', function() { return expect(engine.renderFile('files/foo/../foo.html')).to.eventually.equal('foo'); }); }); - describe('#express()', function() { - it('should render templates', function() { - engine.express()('/root/files/name.html', ctx, function(err, html) { - expect(err).to.equal(null); - expect(html).to.equal('My name is harttle.'); - }); - }); - it('should pass error when file not found', function() { - engine.express()('/root/files/name1.html', ctx, function(err, html) { - expect(err.code).to.equal('ENOENT'); - }); - }); - }); describe('strict', function() { it('should not throw when strict_variables false (default)', function() { return expect(engine.parseAndRender('before{{notdefined}}after', ctx)).to @@ -163,7 +161,6 @@ describe('liquid', function() { .then((result) => { return expect(result).to.equal('foo'); }); - }); }); }); diff --git a/test/util/promise.js b/test/util/promise.js new file mode 100644 index 000000000..47d8c8a2c --- /dev/null +++ b/test/util/promise.js @@ -0,0 +1,51 @@ +const chai = require("chai"); +const sinon = require('sinon'); +const expect = chai.expect; +chai.use(require("chai-as-promised")); +chai.use(require("sinon-chai")); + +var P = require('../../src/util/promise.js'); + +describe('util/promise', function() { + describe('.someSeries()', function() { + it('should resolve in series', function() { + var spy1 = sinon.spy(), + spy2 = sinon.spy(); + return P + .someSeries( + ['first', 'second'], + (item, idx) => new Promise(function(resolve, reject) { + if (idx === 0) { + setTimeout(function() { + spy1(); + reject(new Error('first cb')); + }, 10); + } else { + spy2(); + resolve('foo'); + } + })) + .then(() => expect(spy2).to.have.been.calledAfter(spy1)); + }); + it('should reject when all rejected', function() { + var p = P.someSeries(['first', 'second', 'third'], + item => Promise.reject(new Error(item))); + return expect(p).to.be.rejectedWith("third"); + }); + it('should resolve the value that first callback resolved', () => { + var p = P.someSeries(['first', 'second'], + item => Promise.resolve(item)); + return expect(p).to.eventually.equal('first'); + }); + it('should not call rest of callbacks once resolved', () => { + var spy = sinon.spy(); + return P.someSeries(['first', 'second'], (item, idx) => { + if (idx > 0) { + spy(); + } + return Promise.resolve(item); + }) + .then(() => expect(spy).to.not.have.been.called); + }); + }); +}); diff --git a/test/util/underscore.js b/test/util/underscore.js index f4e25bf7b..da673c62b 100644 --- a/test/util/underscore.js +++ b/test/util/underscore.js @@ -45,4 +45,12 @@ describe('util/underscore', function() { expect(spy).to.have.been.calledOnce; }); }); + describe('.isArray()', function() { + it('should return true for []', function() { + expect(_.isArray([])).to.be.true; + }); + it('should return false for "foo"', function() { + expect(_.isArray("foo")).to.be.false; + }); + }); });