feature: strict rendering, close #9

This commit is contained in:
harttle
2016-09-26 23:25:08 +08:00
parent be6c67a1ba
commit 937d213a2d
8 changed files with 101 additions and 68 deletions
+20
View File
@@ -58,6 +58,26 @@ engine.renderFile("hello", {name: 'alice'})
`cache` default to `false`, `extname` default to `.liquid`, `root` default to `""`. `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 ## Use with Express.js
```javascript ```javascript
+3 -3
View File
@@ -43,7 +43,7 @@ var _engine = {
var scope = Scope.factory(ctx, { var scope = Scope.factory(ctx, {
strict: opts.strict_variables, strict: opts.strict_variables,
}); });
return this.renderer.renderTemplates(tpl, scope); return this.renderer.renderTemplates(tpl, scope, opts);
}, },
parseAndRender: function(html, ctx, opts) { parseAndRender: function(html, ctx, opts) {
try { try {
@@ -55,10 +55,10 @@ var _engine = {
return Promise.reject(error); return Promise.reject(error);
} }
}, },
renderFile: function(filepath, ctx) { renderFile: function(filepath, ctx, opts) {
return this.handleCache(filepath) return this.handleCache(filepath)
.then((templates) => { .then((templates) => {
return this.render(templates, ctx); return this.render(templates, ctx, opts);
}) })
.catch((e) => { .catch((e) => {
e.file = filepath; e.file = filepath;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "shopify-liquid", "name": "shopify-liquid",
"version": "1.1.14", "version": "1.1.15",
"description": "Liquid template engine in Node.js (Shopify compliant)", "description": "Liquid template engine in Node.js (Shopify compliant)",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+5 -3
View File
@@ -19,7 +19,10 @@ module.exports = function() {
var name = match[1], argList = match[2] || '', filter = filters[name]; var name = match[1], argList = match[2] || '', filter = filters[name];
if (typeof filter !== 'function'){ if (typeof filter !== 'function'){
throw new Error(`filter "${name}" not found`); return {
name: name,
error: new Error(`undefined filter: ${name}`)
};
} }
var args = []; var args = [];
@@ -37,8 +40,7 @@ module.exports = function() {
function construct(str) { function construct(str) {
var instance = Object.create(_filterInstance); var instance = Object.create(_filterInstance);
instance.parse(str); return instance.parse(str);
return instance;
} }
function register(name, filter) { function register(name, filter) {
+58 -49
View File
@@ -9,7 +9,6 @@ var render = {
renderTemplates: function(templates, scope, opts) { renderTemplates: function(templates, scope, opts) {
assert(scope, 'unable to evalTemplates: scope undefined'); assert(scope, 'unable to evalTemplates: scope undefined');
opts = _.defaults(opts, { opts = _.defaults(opts, {
strict_variables: false,
strict_filters: false strict_filters: false
}); });
@@ -20,52 +19,52 @@ var render = {
// emptyPromise.then(renderTag(template0).then(renderTag(template1).then(renderTag(template2)... // emptyPromise.then(renderTag(template0).then(renderTag(template1).then(renderTag(template2)...
var lastPromise = templates.reduce((promise, template) => { var lastPromise = templates.reduce((promise, template) => {
return promise.then((partial) => { return promise.then((partial) => {
if (scope.safeGet('forloop.skip')) { if (scope.safeGet('forloop.skip')) {
return Promise.resolve(''); return Promise.resolve('');
} }
if (scope.safeGet('forloop.stop')) { if (scope.safeGet('forloop.stop')) {
throw new Error('forloop.stop'); // this will stop/break the sequential promise chain and go to the catch throw new Error('forloop.stop'); // this will stop/break the sequential promise chain and go to the catch
} }
var promiseLink = Promise.resolve(''); var promiseLink = Promise.resolve('');
switch (template.type) { switch (template.type) {
case 'tag': case 'tag':
// Add Promises to the chain // Add Promises to the chain
promiseLink = this.renderTag(template, scope, this.register) promiseLink = this.renderTag(template, scope, this.register)
.then((partial) => { .then((partial) => {
if (partial === undefined) { if (partial === undefined) {
return true; // basically a noop (do nothing) return true; // basically a noop (do nothing)
} }
return html += partial; return html += partial;
}); });
break; break;
case 'html': case 'html':
promiseLink = Promise.resolve(template.value) promiseLink = Promise.resolve(template.value)
.then((partial) => { .then((partial) => {
return html += partial; return html += partial;
}); });
break; break;
case 'output': case 'output':
var val = this.evalOutput(template, scope); var val = this.evalOutput(template, scope, opts);
promiseLink = Promise.resolve(val === undefined ? '' : stringify(val)) promiseLink = Promise.resolve(val === undefined ? '' : stringify(val))
.then((partial) => { .then((partial) => {
return html += partial; return html += partial;
}); });
break; break;
} }
return promiseLink; return promiseLink;
}) })
.catch((error) => { .catch((error) => {
if (error.message === 'forloop.skip') { if (error.message === 'forloop.skip') {
// the error is a controlled, purposeful stop. so just return the html that we have up to this point // the error is a controlled, purposeful stop. so just return the html that we have up to this point
return html; return html;
} else { } else {
// rethrow actual error // rethrow actual error
throw error; throw error;
} }
}); });
}, Promise.resolve('')); // start the reduce chain with a resolved Promise. After first run, the "promise" argument }, 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 // 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. // 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); return template.render(scope, register);
}, },
evalOutput: function(template, scope) { evalOutput: function(template, scope, opts) {
assert(scope, 'unable to evalOutput: scope undefined'); assert(scope, 'unable to evalOutput: scope undefined');
var val = Exp.evalExp(template.initial, scope); var val = Exp.evalExp(template.initial, scope);
return template.filters template.filters.some(filter => {
.reduce((v, filter) => filter.render(v, scope), val); 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 = {}; return this.register = {};
} }
}; };
-8
View File
@@ -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() { it('should throw ParseError when tag not exist', function() {
return test(engine.parseAndRender('{% a %}', {}), function(err){ return test(engine.parseAndRender('{% a %}', {}), function(err){
expect(err.name).to.equal('ParseError'); expect(err.name).to.equal('ParseError');
+4 -4
View File
@@ -14,10 +14,10 @@ describe('filter', function() {
filter.clear(); filter.clear();
scope = Scope.factory(); scope = Scope.factory();
}); });
it('should throw when not registered', function() { it('should return undefined when not registered', function() {
expect(function() { var result = filter.construct('foo');
filter.construct('foo'); expect(result.name).to.equal('foo');
}).to.throw(/filter "foo" not found/); expect(result.error).to.be.an('Error');
}); });
it('should parse argument syntax', function(){ it('should parse argument syntax', function(){
+10
View File
@@ -37,6 +37,16 @@ describe('liquid', function() {
it('should output undefined to empty', function() { it('should output undefined to empty', function() {
return engine.parseAndRender('foo{{zzz}}bar', ctx).should.eventually.equal('foobar'); 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() { it('should parse html', function() {
(function() { (function() {
engine.parse('{{obj}}'); engine.parse('{{obj}}');