render, filter, tag

This commit is contained in:
harttle
2016-06-15 00:58:14 +08:00
parent d502860ca9
commit d29558035c
15 changed files with 439 additions and 62 deletions
+14
View File
@@ -0,0 +1,14 @@
## Async Support
harttle/shopify-liquid do NOT support async rendering, this is by design.
The primary principle of harttle/shopify-liquid is EASY TO EXTEND.
Async rendering introduces extra complexity in both implementation and extension.
For template-driven projects, checkout these Liquid-like engines:
* [liquid-node][liquid-node]: <https://github.com/sirlantis/liquid-node>
* [nunjucks][nunjucks]: <http://mozilla.github.io/nunjucks/>
[nunjucks]: http://mozilla.github.io/nunjucks/
[liquid-node]: https://github.com/sirlantis/liquid-node
+20 -13
View File
@@ -1,26 +1,33 @@
const _ = require('lodash');
const identifier = require('./identifier.js');
const lexical = require('./lexical.js');
var context = {
get: function(str){
if(identifier.isLiteral(str)){
return identifier.parseLiteral(str);
}
if(identifier.isVariable(str)){
return _.get(this.context, str);
get: function(str) {
str = str && str.trim();
if(!str) return '';
if (lexical.isLiteral(str)) {
var a = lexical.parseLiteral(str);
return lexical.parseLiteral(str);
}
if (lexical.isVariable(str)) {
for (var i = this.context.length - 1; i >= 0; i--) {
var v = _.get(this.context[i], str);
if (v !== undefined) return v;
}
}
return '';
},
init: function(ctx){
this.context = ctx;
push: function(ctx) {
return this.context.push(ctx);
},
merge: function(ctx){
_.merge(this.context, ctx);
pop: function() {
return this.context.pop();
}
};
exports.factory = function(_ctx){
exports.factory = function(_ctx) {
var ctx = Object.create(context);
ctx.init(_ctx);
ctx.context = [_ctx];
return ctx;
};
+46
View File
@@ -0,0 +1,46 @@
const lexical = require('./lexical.js');
const _ = require('lodash');
var filters = {};
var _filterInstance = {
render: function(output, ctx) {
var args = this.args.map(arg => ctx.get(arg));
args.unshift(output);
return this.filter.apply(null, args);
}
}
function parse(str) {
var match = lexical.patterns.filterLine.exec(str.trim());
if (!match) {
throw new Error('illegal filter: ' + str);
}
var k = match[1],
v = match[2];
return factory(k, [v]);
}
function factory(name, args) {
var filter = filters[name];
if (typeof filter !== 'function')
throw new Error(`filter ${name} not found`);
var instance = Object.create(_filterInstance);
instance.args = args;
instance.filter = filter;
return instance;
}
function register(name, filter) {
filters[name] = filter;
}
function clear() {
filters = {};
}
exports.parse = parse;
exports.register = register;
exports.clear = clear;
-41
View File
@@ -1,41 +0,0 @@
const _ = require('lodash');
var singleQuoted = /^'[^']*'$/;
var doubleQuoted = /^"[^"]*"$/;
var number = /^(?:\d+\.?\d*|\.?\d+)$/;
var bool = /^(?:true|false)$/i;
var range = /^\((\d+)\.\.(\d+)\)$/;
var quoted = new RegExp(`${singleQuoted.source}|${doubleQuoted.source}`);
var literal = new RegExp(`${quoted.source}|${range.source}|${bool.source}|${number.source}`, 'i');
var variable = /^[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*|\[\d+\])*$/;
var identifier = new RegExp(`${literal.source}|${variable.source}`, 'i');
exports.patterns = {
quoted, number, bool, range, literal
};
exports.isLiteral = function(str) {
return literal.test(str);
};
exports.isVariable = function(str) {
return variable.test(str);
};
exports.parseLiteral = function(str) {
var res;
if(res = str.match(number)){
return Number(str);
}
if(res = str.match(bool)){
return str.toLowerCase() === 'true';
}
if(res = str.match(quoted)){
return str.slice(1, -1);
}
if(res = str.match(range)){
return _.range(res[1], res[2]);
}
};
+10
View File
@@ -0,0 +1,10 @@
const context = require('./context');
const tokenizer = require('./tokenizer.js');
const render = require('./render.js');
const lexical = require('./lexical.js');
exports.render = function(html, ctx){
return render(tokenizer(html), context.factory(ctx));
};
exports.lexical = lexical;
+54
View File
@@ -0,0 +1,54 @@
const _ = require('lodash');
var singleQuoted = /'[^']*'/;
var doubleQuoted = /"[^"]*"/;
var number = /\d+\.?\d*|\.?\d+/;
var bool = /true|false/i;
var range = /\((\d+)\.\.(\d+)\)/;
var identifier = /[a-zA-Z_$][a-zA-Z_$0-9]*/;
var subscript = /\[\d+\]/;
var quoted = new RegExp(`${singleQuoted.source}|${doubleQuoted.source}`);
var literal = new RegExp(`${quoted.source}|${range.source}|${bool.source}|${number.source}`, 'i');
var variable = new RegExp(`${identifier.source}(?:\\.${identifier.source}|${subscript.source})*`);
var value = new RegExp(`${literal.source}|${variable.source}`, 'i');
var hash = new RegExp(`(${identifier.source})\\s*:\\s*(${value.source})`, 'g');
var filter = new RegExp(`(${identifier.source})(?:\\s*:\\s*(${value.source}))?`);
var literalLine = new RegExp(`^(?:${literal.source})$`, 'i');
var variableLine = new RegExp(`^(?:${variable.source})$`);
var numberLine = new RegExp(`^(?:${number.source})$`);
var boolLine = new RegExp(`^(?:${bool.source})$`, 'i');
var quotedLine = new RegExp(`^(?:${quoted.source})$`);
var rangeLine = new RegExp(`^(?:${range.source})$`);
var filterLine = new RegExp(`^(?:${filter.source})$`);
exports.patterns = {
quoted, number, bool, range, literal, hash, filter, identifier,
quotedLine, numberLine, boolLine, rangeLine, literalLine, filterLine
};
exports.isLiteral = function(str) {
return literalLine.test(str);
};
exports.isVariable = function(str) {
return variableLine.test(str);
};
exports.parseLiteral = function(str) {
var res;
if (res = str.match(numberLine)) {
return Number(str);
}
if (res = str.match(boolLine)) {
return str.toLowerCase() === 'true';
}
if (res = str.match(quotedLine)) {
return str.slice(1, -1);
}
if (res = str.match(rangeLine)) {
return _.range(res[1], res[2]);
}
};
+2 -3
View File
@@ -21,15 +21,14 @@
},
"homepage": "https://github.com/harttle/shopify-liquid#readme",
"dependencies": {
"bluebird": "^3.4.0",
"lodash": "^4.13.1"
},
"devDependencies": {
"chai": "^3.5.0",
"chai-as-promised": "^5.3.0",
"coveralls": "^2.11.9",
"istanbul": "^0.4.3",
"mocha": "^2.5.3",
"sinon": "^1.17.4"
"sinon": "^1.17.4",
"sinon-chai": "^2.8.0"
}
}
+46
View File
@@ -0,0 +1,46 @@
const Filter = require('./filter');
const Tag = require('./tag');
module.exports = function render(tokens, ctx) {
var html = '';
for (var i = 0; i < tokens.length; i++) {
var token = tokens.shift();
switch (token.type) {
case 'html':
html += token.value;
break;
case 'output':
html += renderOutput(token);
break;
case 'tag':
html += renderTag(token);
break;
default:
throw new Error(`unexpected type: ${token.type}`);
}
}
return html;
function renderOutput(token) {
var filters = token.value.split('|');
var val = ctx.get(filters.shift());
return filters
.map(str => Filter.parse(str))
.reduce((v, filter) => filter.render(v, ctx), val);
}
function renderTag(token) {
var tag = Tag.parse(token.value), subTokens = [];
if(tag.needClose){
var curToken, endToken = 'end' + tag.name;
while((curToken = tokens.shift()).value !== endToken){
subTokens.push(curToken);
}
if(curToken.value !== endToken){
throw new Error(`${token.value} not closed`);
}
}
return tag.render(subTokens, ctx);
}
};
+57
View File
@@ -0,0 +1,57 @@
const lexical = require('./lexical.js');
const context = require('./context.js');
var tags = {};
var _tagInstance = {
render: function(tokens, ctx){
var obj = hash(this.markup, ctx);
return this.tag.render(tokens, ctx, this.markup, obj);
}
};
function register(name, tag){
if(typeof tag.render !== 'function'){
throw new Error(`expect ${name}.render to be a function`);
}
tags[name] = tag;
}
function parse(str){
var match = lexical.patterns.identifier.exec(str.trim());
if(!match) throw new Error('illegal tag: '+ str);
var tagInstance = factory(match[0], str);
return tagInstance;
}
function hash(markup, ctx){
var obj = {};
lexical.patterns.hash.lastIndex = 0;
while(match = lexical.patterns.hash.exec(markup)){
var k = match[1], v = match[2];
if(!k) continue;
obj[k] = ctx.get(v);
}
return obj;
}
function factory(name, markup){
var tag = tags[name];
if(!tag) throw new Error(`tag ${name} not found`);
var instance = Object.create(_tagInstance);
instance.name = name;
instance.markup = markup;
instance.tag = tag;
return instance;
}
function clear(){
tags = {};
}
exports.parse = parse;
exports.register = register;
exports.hash = hash;
exports.clear = clear;
+11 -3
View File
@@ -1,6 +1,6 @@
var chai = require("chai");
var should = chai.should();
chai.use(require("chai-as-promised"));
var expect = chai.expect;
var context = require('../context.js');
@@ -29,9 +29,17 @@ describe('context', function() {
ctx.get('bar[1].b[1]').should.equal(2);
});
it('should merge context', function() {
ctx.merge({foo: 'foo', foo1: 'foo1'});
it('should push context', function() {
ctx.push({foo: 'foo', foo1: 'foo1'});
ctx.get('foo').should.equal('foo');
ctx.get('foo1').should.equal('foo1');
ctx.get('bar[1].b[1]').should.equal(2);
});
it('should pop context', function() {
ctx.pop();
expect(ctx.get('foo')).to.equal('bar');
expect(ctx.get('foo1')).to.equal('');
expect(ctx.get('bar[1].b[1]')).to.equal(2);
});
});
+34
View File
@@ -0,0 +1,34 @@
const chai = require("chai");
const sinon = require("sinon");
const sinonChai = require("sinon-chai");
const expect = chai.expect;
chai.use(sinonChai);
var filter = require('../filter.js');
var context = require('../context.js');
describe('filter', function() {
var ctx;
beforeEach(function(){
filter.clear();
ctx = context.factory();
});
it('should throw when not registered', function() {
expect(function() {
filter.parse('foo');
}).to.throw(/filter foo not found/);
});
it('should register a simple filter', function(){
filter.register('foo', x => x.toUpperCase());
expect(filter.parse('foo').render('foo', ctx)).to.equal('FOO');
});
it('should call filter with corrct arguments', function(){
var spy = sinon.spy();
filter.register('foo', spy);
filter.parse('foo: 33').render('foo', ctx);
expect(spy).to.have.been.calledWith('foo', 33);
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ var chai = require("chai");
var should = chai.should();
chai.use(require("chai-as-promised"));
var identifier = require('../identifier.js');
var identifier = require('../lexical.js');
describe('identifier', function() {
it('should test boolean literal', function() {
+79
View File
@@ -0,0 +1,79 @@
const chai = require("chai");
const sinonChai = require("sinon-chai");
const sinon = require("sinon");
const expect = chai.expect;
chai.use(sinonChai);
var tag = require('../tag.js');
var context = require('../context.js');
var filter = require('../filter');
var render = require('../render.js');
describe('render', function() {
var ctx, htmlToken, tagToken, filterToken;
before(function() {
ctx = context.factory({
x: 'XXX',
foo: {
bar: ['a', 2]
}
});
tagToken = {
type: 'tag',
value: 'foo 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();
tag.clear();
});
it('should render html', function() {
expect(render([htmlToken], ctx)).to.equal('<p>');
});
it('should render with tag function', function() {
tag.register('foo', {
render: x => 'X'
});
expect(render([tagToken], ctx)).to.equal('X');
});
it('should call tag with correct arguments', function() {
var spy = sinon.spy();
tag.register('foo', { render: spy });
render([tagToken], ctx);
expect(spy).to.have.been.calledWithMatch([], ctx, tagToken.value, {
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], ctx)).to.equal('ab6');
});
it('should call filter with correct arguments', function() {
var date = sinon.stub().returns('y');
var time = sinon.spy();
filter.register('date', date);
filter.register('time', time);
render([filterToken], ctx);
expect(date).to.have.been.calledWith('a', 'b');
expect(time).to.have.been.calledWith('y', 2);
});
});
+65
View File
@@ -0,0 +1,65 @@
var chai = require("chai");
var sinonChai = require("sinon-chai");
var sinon = require("sinon");
var expect = chai.expect;
chai.use(sinonChai);
var tag = require('../tag.js');
var context = require('../context.js');
describe('tag', function() {
var ctx;
before(function(){
ctx = context.factory({
foo: 'bar',
arr: [2, 1]
});
tag.clear();
});
it('should throw when not registered', function() {
expect(function() {
tag.parse('foo');
}).to.throw(/tag foo not found/);
});
it('should throw when render method not defined', function() {
expect(function() {
tag.register('foo', {});
}).to.throw(/expect foo.render to be a function/);
});
it('should register simple tag', function() {
expect(
function() {
tag.register('foo', {
render: x => 'bar'
});
}).not.throw();
});
it('should call tag.render', function() {
var spy = sinon.spy(),
tokens = [];
tag.register('foo', {
render: spy
});
tag.parse('foo').render(tokens, ctx);
expect(spy).to.have.been.called;
});
it('should call tag.render with resolved hash', function() {
var spy = sinon.spy(),
tokens = [];
tag.register('foo', {
render: spy
});
var t = tag.parse('foo aa:foo bb: arr[0] cc: 2.3');
t.render(tokens, ctx);
expect(spy).to.have.been.calledWithMatch(tokens, ctx, 'foo', {
aa: 'bar',
bb: 2,
cc: 2.3
});
});
});
-1
View File
@@ -1,6 +1,5 @@
var chai = require("chai");
var should = chai.should();
chai.use(require("chai-as-promised"));
var tokenizer = require('../tokenizer.js');