From d29558035c96230258ced2dc7d440e43f3b45719 Mon Sep 17 00:00:00 2001 From: harttle Date: Wed, 15 Jun 2016 00:58:14 +0800 Subject: [PATCH] render, filter, tag --- README.md | 14 ++++++ context.js | 33 ++++++++----- filter.js | 46 +++++++++++++++++ identifier.js | 41 ---------------- index.js | 10 ++++ lexical.js | 54 ++++++++++++++++++++ package.json | 5 +- render.js | 46 +++++++++++++++++ tag.js | 57 +++++++++++++++++++++ test/context.js | 14 ++++-- test/filter.js | 34 +++++++++++++ test/{identifier.js => lexical.js} | 2 +- test/render.js | 79 ++++++++++++++++++++++++++++++ test/tag.js | 65 ++++++++++++++++++++++++ test/tokenizer.js | 1 - 15 files changed, 439 insertions(+), 62 deletions(-) create mode 100644 README.md create mode 100644 filter.js delete mode 100644 identifier.js create mode 100644 index.js create mode 100644 lexical.js create mode 100644 render.js create mode 100644 tag.js create mode 100644 test/filter.js rename test/{identifier.js => lexical.js} (98%) create mode 100644 test/render.js create mode 100644 test/tag.js diff --git a/README.md b/README.md new file mode 100644 index 000000000..6835c8a7b --- /dev/null +++ b/README.md @@ -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]: +* [nunjucks][nunjucks]: + +[nunjucks]: http://mozilla.github.io/nunjucks/ +[liquid-node]: https://github.com/sirlantis/liquid-node diff --git a/context.js b/context.js index fa57b44f3..7e9ff30fd 100644 --- a/context.js +++ b/context.js @@ -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; }; diff --git a/filter.js b/filter.js new file mode 100644 index 000000000..595208bd0 --- /dev/null +++ b/filter.js @@ -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; diff --git a/identifier.js b/identifier.js deleted file mode 100644 index e9f61de37..000000000 --- a/identifier.js +++ /dev/null @@ -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]); - } -}; diff --git a/index.js b/index.js new file mode 100644 index 000000000..91a9ee3fd --- /dev/null +++ b/index.js @@ -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; diff --git a/lexical.js b/lexical.js new file mode 100644 index 000000000..f309d1e4b --- /dev/null +++ b/lexical.js @@ -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]); + } +}; diff --git a/package.json b/package.json index d9a30252d..43fb96321 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/render.js b/render.js new file mode 100644 index 000000000..cc0c77c30 --- /dev/null +++ b/render.js @@ -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); + } +}; + diff --git a/tag.js b/tag.js new file mode 100644 index 000000000..8787d455f --- /dev/null +++ b/tag.js @@ -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; diff --git a/test/context.js b/test/context.js index 17ba67411..325cddb86 100644 --- a/test/context.js +++ b/test/context.js @@ -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); }); }); diff --git a/test/filter.js b/test/filter.js new file mode 100644 index 000000000..5f4fe9e18 --- /dev/null +++ b/test/filter.js @@ -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); + }); +}); diff --git a/test/identifier.js b/test/lexical.js similarity index 98% rename from test/identifier.js rename to test/lexical.js index 072022fa7..01cc44c9f 100644 --- a/test/identifier.js +++ b/test/lexical.js @@ -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() { diff --git a/test/render.js b/test/render.js new file mode 100644 index 000000000..5e63ac044 --- /dev/null +++ b/test/render.js @@ -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: '

' + }; + 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('

'); + }); + + 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); + }); +}); diff --git a/test/tag.js b/test/tag.js new file mode 100644 index 000000000..0c2003996 --- /dev/null +++ b/test/tag.js @@ -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 + }); + }); +}); diff --git a/test/tokenizer.js b/test/tokenizer.js index 2b2374f2d..a4d7db627 100644 --- a/test/tokenizer.js +++ b/test/tokenizer.js @@ -1,6 +1,5 @@ var chai = require("chai"); var should = chai.should(); -chai.use(require("chai-as-promised")); var tokenizer = require('../tokenizer.js');