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');