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 @@
-Copyright @ 2016, Harttle
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' %}
+
+
+ {% for todo in todolist %}
+ {% include 'todo' id=forloop.index %}
+ {% endfor %}
+
+
+{% 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;
+ });
+ });
});