feature: root option allows array of strings

This commit is contained in:
harttle
2016-10-31 23:21:28 +08:00
parent 377c805230
commit 6dd836f157
18 changed files with 313 additions and 106 deletions
+11 -3
View File
@@ -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
+22
View File
@@ -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;
-1
View File
@@ -1 +0,0 @@
<footer>Copyright @ 2016, Harttle</footer>
-11
View File
@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<h1>Welcome to shopify-liquid!</h1>
{% include 'bar' %}
</body>
</html>
+2 -16
View File
@@ -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!');
});
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{title}}</title>
</head>
<body>
<h1>{{title}}</h1>
{% block %}
<footer> {% block footer %} </footer>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{{id}} - {{todo}}
+11
View File
@@ -0,0 +1,11 @@
{% layout 'layout' %}
<ul>
{% for todo in todolist %}
<li>{% include 'todo' id=forloop.index %}</li>
{% endfor %}
</ul>
{% block 'footer' %}
Copyright @ 2016, Harttle
{% endblock %}
+33 -30
View File
@@ -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;
+34
View File
@@ -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
};
+19
View File
@@ -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;
+6 -1
View File
@@ -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;
+1 -2
View File
@@ -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;
});
}
});
+6 -6
View File
@@ -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) {
+61
View File
@@ -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);
});
});
+33 -36
View File
@@ -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');
});
});
});
});
+51
View File
@@ -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);
});
});
});
+8
View File
@@ -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;
});
});
});