import parser to support nested tags

This commit is contained in:
harttle
2016-06-17 03:38:03 +08:00
parent a842284628
commit 8b9e9f759a
13 changed files with 340 additions and 161 deletions
+14 -4
View File
@@ -7,9 +7,9 @@ const fs = require('fs');
const Tag = require('./tag.js'); const Tag = require('./tag.js');
const Filter = require('./filter.js'); const Filter = require('./filter.js');
const error = require('./error.js'); const error = require('./error.js');
const Template = require('./template');
const tagsPath = path.join(__dirname, "tags"); const tagsPath = path.join(__dirname, "tags");
const filtersPath = path.join(__dirname, "filters");
var _engine = { var _engine = {
registerFilter : function(name, filter){ registerFilter : function(name, filter){
@@ -22,18 +22,26 @@ var _engine = {
function factory(){ function factory(){
var engine = Object.create(_engine); var engine = Object.create(_engine);
engine.tag = Tag(); engine.tag = Tag();
engine.filter = Filter(); engine.filter = Filter();
engine.tokenize = tokenizer.parse;
var template = Template(engine.tag);
engine.parse = template.parse;
engine.parseTag = template.parseTag;
engine.parseStream = template.parseStream;
var renderer = Render(engine.filter, engine.tag); var renderer = Render(engine.filter, engine.tag);
engine.evaluate = renderer.evalExp; engine.evaluate = renderer.evalExp;
engine.evalExp = renderer.evalExp; engine.evalExp = renderer.evalExp;
engine.evalFilter = renderer.evalFilter; engine.evalFilter = renderer.evalFilter;
engine.renderTemplates = renderer.renderTemplates;
engine.renderTokens = renderer.render;
engine.render = function(html, ctx) { engine.render = function(html, ctx) {
return engine.renderTokens(tokenizer.parse(html), scope.factory(ctx)); var tokens = engine.tokenize(html);
var templates = engine.parse(tokens);
return engine.renderTemplates(templates, scope.factory(ctx));
}, },
fs.readdirSync(tagsPath).map(function(f){ fs.readdirSync(tagsPath).map(function(f){
@@ -47,5 +55,7 @@ function factory(){
factory.lexical = lexical; factory.lexical = lexical;
factory.error = error; factory.error = error;
factory.isTruthy = Render.isTruthy;
factory.stringify = Render.stringify;
module.exports = factory; module.exports = factory;
View File
+27 -30
View File
@@ -2,23 +2,28 @@ const lexical = require('./lexical.js');
const syntax = require('./syntax.js'); const syntax = require('./syntax.js');
const error = require('./error.js'); const error = require('./error.js');
module.exports = function(Filter, Tag) { function stringify(val) {
function render(tokens, scope) { if (typeof val === 'string') return val;
return JSON.stringify(val);
}
function isTruthy(val) {
if (val instanceof Array) return !!val.length;
return !!val;
}
function factory(Filter, Tag) {
function renderTemplates(templates, scope) {
var html = ''; var html = '';
while (tokens.length) { var template;
var token = tokens.shift(); while (template = templates.shift()) {
switch (token.type) { if (template.type === 'tag') {
case 'html': html += template.render(scope);
html += token.value; } else if (template.type === 'html') {
break; html += template.value;
case 'output': } else if (template.type === 'output') {
html += evalFilter(token.value, scope); var val = evalFilter(template.value, scope);
break; html += stringify(val);
case 'tag':
html += renderTag(token, tokens, scope);
break;
default:
error(`unexpected type: ${token.type}`, token);
} }
} }
return html; return html;
@@ -50,20 +55,12 @@ module.exports = function(Filter, Tag) {
.reduce((v, filter) => filter.render(v, scope), val); .reduce((v, filter) => filter.render(v, scope), val);
} }
function renderTag(token, tokens, scope) {
var tag = Tag.construct(token),
subTokens = [];
if (tag.needClose) {
var curToken, endToken = 'end' + tag.token.name;
while ((curToken = tokens.shift()) && curToken.value !== endToken) {
subTokens.push(curToken);
}
if (!curToken) error(`${token.value} not closed`);
}
return tag.render(subTokens, scope);
}
return { return {
render, renderTag, evalFilter, evalExp renderTemplates, evalFilter, evalExp
}; };
}; };
factory.isTruthy = isTruthy;
factory.stringify = stringify;
module.exports = factory;
+14 -10
View File
@@ -1,10 +1,13 @@
const lexical = require('./lexical.js'); const lexical = require('./lexical.js');
var _tagInstance = { var _tagInstance = {
render: function(tokens, scope) { render: function(scope) {
var obj = hash(this.token.args, scope); var obj = hash(this.token.args, scope);
var res = this.tag.render(tokens, scope, this.token, obj); return this.tagImpl.render(scope, obj) || '';
return res === undefined ? '' : res; },
parse: function(tokens){
this.tagImpl.parse(this.token, tokens);
return this;
} }
}; };
@@ -21,28 +24,29 @@ function hash(markup, scope) {
} }
module.exports = function() { module.exports = function() {
var tags = {}; var tagImpls = {};
function register(name, tag) { function register(name, tag) {
if (typeof tag.render !== 'function') { if (typeof tag.render !== 'function') {
throw new Error(`expect ${name}.render to be a function`); throw new Error(`expect ${name}.render to be a function`);
} }
tags[name] = tag; tagImpls[name] = tag;
} }
function construct(token) { function construct(token) {
var tag = tags[token.name]; var tagImpl = tagImpls[token.name];
if (!tag) throw new Error(`tag ${token.name} not found`); if (!tagImpl) throw new Error(`tag ${token.name} not found`);
var instance = Object.create(_tagInstance); var instance = Object.create(_tagInstance);
instance.token = token; instance.token = token;
instance.tag = tag; instance.type = 'tag';
instance.needClose = tag.needClose; instance.name = token.name;
instance.tagImpl = Object.create(tagImpl);
return instance; return instance;
} }
function clear() { function clear() {
tags = {}; tagImpls = {};
} }
return { return {
+17 -6
View File
@@ -5,13 +5,24 @@ var re = new RegExp(`(${lexical.identifier.source})`);
module.exports = function(liquid) { module.exports = function(liquid) {
liquid.registerTag('capture', { liquid.registerTag('capture', {
needClose: true, parse: function(tagToken, remainTokens) {
render: function(tokens, scope, token, hash) { var match = tagToken.args.match(re);
var html = liquid.renderTokens(tokens, scope); if (!match) throw new Error(`${tagToken.args} not valid identifier`);
var match = token.args.match(re);
if (!match) throw new Error(`${token.args} not valid identifier`);
scope.set(match[1], html); this.variable = match[1];
this.templates = [];
var stream = liquid.parseStream(remainTokens);
stream.onTag('endcapture', token => stream.stop())
.onTemplate(tpl => this.templates.push(tpl))
.onEnd(x => {
throw new Error(`tag ${tagToken.raw} not closed`);
});
stream.start();
},
render: function(scope, hash) {
var html = liquid.renderTemplates(this.templates, scope);
scope.set(this.variable, html);
} }
}); });
+38
View File
@@ -0,0 +1,38 @@
var Liquid = require('..');
var lexical = Liquid.lexical;
var re = new RegExp(`^(${lexical.identifier.source})\\s+in\\s+(.+)$`);
module.exports = function(liquid) {
liquid.registerTag('for', {
parse: function(tagToken, remainTokens) {
var match = re.exec(tagToken.args);
if(!match) throw new Error(`illegal tag: ${tagToken.raw}`);
this.variable = match[1];
this.collection = match[2];
this.templates = [];
this.elseTemplates = [];
var p, stream = liquid.parseStream(remainTokens)
.onStart(x => p = this.templates)
.onTag('else', token => p = this.elseTemplates)
.onTag('endfor', token => stream.stop())
.onTemplate(tpl => p.push(tpl))
.onEnd(x => {
throw new Error(`tag ${tagToken.raw} not closed`);
});
stream.start();
},
render: function(scope, hash) {
var collection = liquid.evaluate(this.collection, scope);
if(!(collection instanceof Array) || !collection.length){
return liquid.renderTemplates(this.elseTemplates, scope);
}
liquid.renderTemplates(this.templates, scope);
}
});
};
+37 -15
View File
@@ -3,22 +3,44 @@ var lexical = Liquid.lexical;
module.exports = function(liquid) { module.exports = function(liquid) {
liquid.registerTag('if', { liquid.registerTag('if', {
needClose: true,
render: function(tokens, scope, token, hash) { parse: function(tagToken, remainTokens) {
var partialTokens = [], this.branches = [];
matching = liquid.evaluate(token.args, scope); this.elseTemplates = [];
for (var i = 0; i < tokens.length; i++) {
var tk = tokens[i]; var p, stream = liquid.parseStream(remainTokens)
if (tk.type === 'tag' && tk.name === 'elsif') { .onStart(x => this.branches.push({
if (matching) break; cond: tagToken.args,
matching = liquid.evaluate(tk.args, scope); templates: p = []
} else if (tk.type === 'tag' && tk.name === 'else') { }))
if (matching) break; .onTag('elsif', token => {
else matching = true; if (!this.branches[token.args]) {
} else if (matching) partialTokens.push(tk); this.branches.push({
cond: token.args,
templates: p = []
});
} }
return liquid.renderTokens(partialTokens, scope); })
.onTag('else', token => p = this.elseTemplates)
.onTag('endif', token => stream.stop())
.onTemplate(tpl => p.push(tpl))
.onEnd(x => {
throw new Error(`tag ${tagToken.raw} not closed`);
});
stream.start();
},
render: function(scope, hash) {
for (var i = 0; i < this.branches.length; i++) {
var branch = this.branches[i];
var cond = liquid.evaluate(branch.cond, scope);
if (Liquid.isTruthy(cond)) {
return liquid.renderTemplates(branch.templates, scope);
} }
}
return liquid.renderTemplates(this.elseTemplates, scope);
}
}); });
}; };
+39 -12
View File
@@ -3,19 +3,46 @@ var lexical = Liquid.lexical;
module.exports = function(liquid) { module.exports = function(liquid) {
liquid.registerTag('unless', { liquid.registerTag('unless', {
needClose: true,
render: function(tokens, scope, token, hash) { parse: function(tagToken, remainTokens) {
var partialTokens = [], this.branches = [];
matching = !liquid.evaluate(token.args, scope); this.elseTemplates = [];
for (var i = 0; i < tokens.length; i++) {
var tk = tokens[i]; var p, stream = liquid.parseStream(remainTokens)
if (tk.type === 'tag' && tk.name === 'else') { .onStart(x => this.branches.push({
if (matching) break; cond: tagToken.args,
else matching = true; templates: p = []
} else if (matching) partialTokens.push(tk); }))
.onTag('elsif', token => {
if (!this.branches[token.args]) {
this.branches.push({
cond: token.args,
templates: p = []
});
} }
return liquid.renderTokens(partialTokens, scope); })
.onTag('else', token => this.elseTemplates = p = [])
.onTag('endunless', token => stream.stop())
.onTemplate(tpl => p.push(tpl))
.onEnd(x => {
throw new Error(`tag ${tagToken.raw} not closed`);
});
stream.start();
},
render: function(scope, hash) {
for (var i = 0; i < this.branches.length; i++) {
var branch = this.branches[i];
var cond = liquid.evaluate(branch.cond, scope);
cond = Liquid.isTruthy(cond);
if (i === 0) cond = !cond;
if (cond) {
return liquid.renderTemplates(branch.templates, scope);
} }
}
return liquid.renderTemplates(this.elseTemplates, scope);
}
}); });
}; };
+81
View File
@@ -0,0 +1,81 @@
const lexical = require('./lexical.js');
const error = require('./error.js');
module.exports = function(Tag) {
var stream = {
init: function(tokens) {
this.tokens = tokens;
this.handlers = {};
return this;
},
on: function(name, cb) {
this.handlers[name] = cb;
return this;
},
trigger: function(event, arg) {
var h = this.handlers[event];
if (typeof h === 'function') {
h(arg);
return true;
}
},
start: function() {
this.trigger('start');
while (!this.stopRequested && (token = this.tokens.shift())) {
var template;
if (token.type == 'tag') {
if (this.trigger(`tag:${token.name}`, token)) continue;
template = parseTag(token, this.tokens);
} else {
template = token;
}
this.trigger('template', template);
}
if (!this.stopRequested) this.trigger('end');
return this;
},
stop: function() {
this.stopRequested = true;
return this;
},
onStart: function(cb) {
return this.on('start', cb);
},
onEnd: function(cb) {
return this.on('end', cb);
},
onTag: function(name, cb) {
return this.on(`tag:${name}`, cb);
},
onTemplate: function(cb) {
return this.on('template', cb);
}
};
function parse(tokens) {
var templates = [];
var token;
while (token = tokens.shift()) {
if (token.type === 'tag') {
var tagInstance = parseTag(token, tokens);
templates.push(tagInstance);
} else templates.push(token);
}
return templates;
}
function parseTag(token, tokens) {
return Tag.construct(token).parse(tokens);
}
function parseStream(tokens) {
var s = Object.create(stream);
return s.init(tokens);
}
return {
parse, parseTag, parseStream
};
};
+10 -1
View File
@@ -7,12 +7,21 @@ function test(src, dst) {
ctx = { ctx = {
date: new Date(), date: new Date(),
foo: 'bar', foo: 'bar',
arr: [-2, 'a'] arr: [-2, 'a'],
obj: {
foo: 'bar'
}
}; };
expect(liquid.render(src, ctx)).to.equal(dst); expect(liquid.render(src, ctx)).to.equal(dst);
} }
describe('filters', function() { describe('filters', function() {
it('should output object', function(){
test('{{obj}}', '{"foo":"bar"}');
});
it('should output array', function(){
test('{{arr}}', '[-2,"a"]');
});
it('should support abs', function() { it('should support abs', function() {
test('{{ -3 | abs }}', '3'); test('{{ -3 | abs }}', '3');
test('{{ arr[0] | abs }}', '2'); test('{{ arr[0] | abs }}', '2');
+15 -47
View File
@@ -9,14 +9,14 @@ var tag = require('../tag.js')();
var Scope = require('../scope.js'); var Scope = require('../scope.js');
var filter = require('../filter')(); var filter = require('../filter')();
var Render = require('../render.js')(filter, tag); var Render = require('../render.js')(filter, tag);
var render = Render.render; var render = Render.renderTemplates;
var evalExp = Render.evalExp; var evalExp = Render.evalExp;
var evalFilter = Render.evalFilter; var evalFilter = Render.evalFilter;
describe('render', function() { describe('render', function() {
var scope, htmlToken, tagToken, filterToken; var scope;
before(function() { beforeEach(function() {
scope = Scope.factory({ scope = Scope.factory({
one: 1, one: 1,
two: 2, two: 2,
@@ -25,65 +25,33 @@ describe('render', function() {
bar: ['a', 2] bar: ['a', 2]
} }
}); });
tagToken = {
type: 'tag',
value: 'foo bar:x foo:"FOO" num:2.3',
name: 'foo',
args: 'bar:x foo:"FOO" num:2.3'
};
htmlToken = {
type: 'html',
value: '<p>'
};
filterToken = {
type: 'output',
value: 'foo.bar[0] | date: "b" | time:2'
};
});
beforeEach(function(){
filter.clear(); filter.clear();
tag.clear(); tag.clear();
}); });
it('should render html', function() { it('should render html', function() {
expect(render([htmlToken], scope)).to.equal('<p>'); expect(render([{
type: 'html',
value: '<p>'
}], scope)).to.equal('<p>');
}); });
it('should render with tag function', function() { it('should eval filter with correct arguments', function() {
tag.register('foo', {
render: x => 'X'
});
expect(render([tagToken], scope)).to.equal('X');
});
it('should call tag with correct arguments', function() {
var spy = sinon.spy();
tag.register('foo', { render: spy });
render([tagToken], scope);
expect(spy).to.have.been.calledWithMatch([], scope, tagToken, {
bar: 'XXX',
foo: 'FOO',
num: 2.3
});
});
it('should render with filter function', function() {
filter.register('date', (l, r) => l + r);
filter.register('time', (l, r) => l + 3*r);
expect(render([filterToken], scope)).to.equal('ab6');
});
it('should call filter with correct arguments', function() {
var date = sinon.stub().returns('y'); var date = sinon.stub().returns('y');
var time = sinon.spy(); var time = sinon.spy();
filter.register('date', date); filter.register('date', date);
filter.register('time', time); filter.register('time', time);
render([filterToken], scope); evalFilter('foo.bar[0] | date: "b" | time:2', scope);
expect(date).to.have.been.calledWith('a', 'b'); expect(date).to.have.been.calledWith('a', 'b');
expect(time).to.have.been.calledWith('y', 2); expect(time).to.have.been.calledWith('y', 2);
}); });
it('should eval filter', function() {
filter.register('date', (l, r) => l + r);
filter.register('time', (l, r) => l + 3 * r);
expect(evalFilter('foo.bar[0] | date: "b" | time:2', scope)).to.equal('ab6');
});
it('should eval expression', function() { it('should eval expression', function() {
expect(evalExp('1<2', scope)).to.equal(true); expect(evalExp('1<2', scope)).to.equal(true);
expect(evalExp('2<=2', scope)).to.equal(true); expect(evalExp('2<=2', scope)).to.equal(true);
+3 -3
View File
@@ -52,7 +52,7 @@ describe('tag', function() {
type: 'tag', type: 'tag',
value: 'foo', value: 'foo',
name: 'foo' name: 'foo'
}).render(tokens, scope); }).render(scope);
expect(spy).to.have.been.called; expect(spy).to.have.been.called;
}); });
@@ -68,8 +68,8 @@ describe('tag', function() {
name: 'foo', name: 'foo',
args: 'aa:foo bb: arr[0] cc: 2.3' args: 'aa:foo bb: arr[0] cc: 2.3'
}; };
tag.construct(token).render(tokens, scope); tag.construct(token).render(scope);
expect(spy).to.have.been.calledWithMatch(tokens, scope, token, { expect(spy).to.have.been.calledWithMatch(scope, {
aa: 'bar', aa: 'bar',
bb: 2, bb: 2,
cc: 2.3 cc: 2.3
+39 -27
View File
@@ -22,36 +22,44 @@ describe('tags', function() {
leq: '<=', leq: '<=',
empty: '', empty: '',
foo: 'bar', foo: 'bar',
arr: [-2, 'a'] arr: [-2, 'a', {
foo: 'bar'
}],
emptyArray: []
}; };
}); });
it('should support assign', function() { //it('should support assign', function() {
test('{% assign foo="bar"%}{{foo}}', 'bar'); //test('{% assign foo="bar"%}{{foo}}', 'bar');
}); //});
it('should support case', function() { //it('should support case', function() {
testThrow('{% case "foo"%}', /case "foo" not closed/); //testThrow('{% case "foo"%}', /case "foo" not closed/);
test('{% case "foo"%}' + //test('{% case "foo"%}' +
'{% when "foo" %}foo{% when "bar"%}bar' + //'{% when "foo" %}foo{% when "bar"%}bar' +
'{%endcase%}', 'foo'); //'{%endcase%}', 'foo');
test('{% case empty %}' + //test('{% case empty %}' +
'{% when "foo" %}foo{% when ""%}bar' + //'{% when "foo" %}foo{% when ""%}bar' +
'{%endcase%}', 'bar'); //'{%endcase%}', 'bar');
test('{% case false %}' + //test('{% case false %}' +
'{% when "foo" %}foo{% when ""%}bar' + //'{% when "foo" %}foo{% when ""%}bar' +
'{%endcase%}', ''); //'{%endcase%}', '');
test('{% case "a" %}' + //test('{% case "a" %}' +
'{% when "b" %}b{% when "c"%}c{%else %}d' + //'{% when "b" %}b{% when "c"%}c{%else %}d' +
'{%endcase%}', 'd'); //'{%endcase%}', 'd');
}); //});
it('should support if', function() { it('should support if', function() {
testThrow('{% if false%}yes', /tag {% if false%} not closed/);
test('{%if emptyArray%}a{%endif%}', '');
test('{% if 2==3 %}yes{%else%}no{%endif%}', 'no'); test('{% if 2==3 %}yes{%else%}no{%endif%}', 'no');
test('{% if 1==2 and one<two %}a{%endif%}', ''); test('{% if 1==2 and one<two %}a{%endif%}', '');
test('{% if false %}yes{%else%}no{%endif%}', 'no'); test('{% if false %}yes{%else%}no{%endif%}', 'no');
test('{% if false %}1{%elsif true%}2{%else%}3{%endif%}', '2');
test('{%if false%}{%if true%}{%else%}a{%endif%}{%endif%}', '');
}); });
it('should support unless', function() { it('should support unless', function() {
test('{% unless 1 %}yes{%else%}no{%endunless%}', 'no'); test('{% unless 1 %}yes{%else%}no{%endunless%}', 'no');
testThrow('{% unless 1>2 %}yes', /tag {% unless 1>2 %} not closed/);
test('{% unless 1>2 %}yes{%endunless%}', 'yes'); test('{% unless 1>2 %}yes{%endunless%}', 'yes');
}); });
@@ -60,13 +68,17 @@ describe('tags', function() {
testThrow('{% capture = %}{%endcapture%}', /= not valid identifier/); testThrow('{% capture = %}{%endcapture%}', /= not valid identifier/);
}); });
it('should support increment', function() { //it('should support for', function() {
test('{% increment foo %}{%increment foo%}{{foo}}', '2'); //test('{%for i in arr%}{{"a" | capitalize}}{%endcapture%}{{f}}', 'A');
test('{% increment one %}{{one}}', '2'); //});
});
it('should support decrement', function() { //it('should support increment', function() {
test('{% decrement foo %}{%decrement foo%}{{foo}}', '-2'); //test('{% increment foo %}{%increment foo%}{{foo}}', '2');
test('{% decrement one %}{{one}}', '0'); //test('{% increment one %}{{one}}', '2');
}); //});
//it('should support decrement', function() {
//test('{% decrement foo %}{%decrement foo%}{{foo}}', '-2');
//test('{% decrement one %}{{one}}', '0');
//});
}); });