diff --git a/README.md b/README.md index 774e4bb88..a13ca7785 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ Documentation: - [x] for [Document: limit,offset,range,reversed)](https://shopify.github.io/liquid/tags/iteration/) [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/for.js) [Test][tt] - [x] cycle [Document](https://shopify.github.io/liquid/tags/iteration/) [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/cycle.js) [Test][tt] - [x] cycle [Document: group)](https://shopify.github.io/liquid/tags/iteration/) [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/cycle.js) [Test][tt] -- [ ] tablerow [Document](https://shopify.github.io/liquid/tags/iteration/) [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/.js) [Test][tt] -- [ ] tablerow [Document: cols,limit,offset,range](https://shopify.github.io/liquid/tags/iteration/) [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/.js) [Test][tt] +- [x] tablerow [Document](https://shopify.github.io/liquid/tags/iteration/) [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/tablerow.js) [Test][tt] +- [x] tablerow [Document: cols,limit,offset,range](https://shopify.github.io/liquid/tags/iteration/) [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/tablerow.js) [Test][tt] - [x] assign [Document](https://shopify.github.io/liquid/tags/variable/) [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/assign.js) [Test][tt] - [x] capture [Document](https://shopify.github.io/liquid/tags/variable/) [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/capture.js) [Test][tt] - [x] increment [Document](https://shopify.github.io/liquid/tags/variable/) [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/increment.js) [Test][tt] diff --git a/error.js b/error.js index f4ba888ce..c67fbf98a 100644 --- a/error.js +++ b/error.js @@ -1,11 +1,21 @@ - -function factory(msg, token){ - var err = new Error(msg || 'unkown error'); - if(token){ - err.token = token.raw; - err.line = token.line; - } - throw err; +function TokenizationError(message, input, line, stack) { + this.name = "TokenizationError"; + this.message = (message || ""); + this.input = input; + this.line = line; + if(stack) this.stack = stack; } +TokenizationError.prototype = Error.prototype; -module.exports = factory; +function ParseError(message, input, line, stack) { + this.name = "ParseError"; + this.message = (message || ""); + this.input = input; + this.line = line; + if(stack) this.stack = stack; +} +ParseError.prototype = Error.prototype; + +module.exports = { + TokenizationError, ParseError +}; diff --git a/render.js b/render.js index 149da932b..9f09214a9 100644 --- a/render.js +++ b/render.js @@ -9,6 +9,7 @@ function stringify(val) { function factory(Filter, Tag) { function renderTemplates(templates, scope) { + if (!scope) throw new Error('unable to evalTemplates: scope undefined'); var html = ''; templates.some(template => { if (scope.get('forloop.skip')) return true; @@ -45,7 +46,7 @@ function factory(Filter, Tag) { return { renderTemplates, evalFilter }; -}; +} factory.stringify = stringify; diff --git a/tag.js b/tag.js index 685b4c85f..2b1a45af3 100644 --- a/tag.js +++ b/tag.js @@ -1,10 +1,11 @@ const lexical = require('./lexical.js'); const Exp = require('./expression.js'); +const TokenizationError = require('./error.js').TokenizationError; var _tagInstance = { render: function(scope, register) { var reg = register[this.name]; - if(!reg) reg = register[this.name] = {} + if(!reg) reg = register[this.name] = {}; var obj = hash(this.token.args, scope); return this.tagImpl.render(scope, obj, reg) || ''; }, diff --git a/tags/for.js b/tags/for.js index 961187a0f..475bde4be 100644 --- a/tags/for.js +++ b/tags/for.js @@ -42,7 +42,7 @@ module.exports = function(liquid) { var offset = hash.offset || 0; var limit = (hash.limit === undefined) ? collection.length : hash.limit; - var collection = collection.slice(offset, offset + limit); + collection = collection.slice(offset, offset + limit); if(this.reversed) collection.reverse(); collection.some((item, i) => { ctx[this.variable] = item; diff --git a/tags/tablerow.js b/tags/tablerow.js new file mode 100644 index 000000000..1204559bf --- /dev/null +++ b/tags/tablerow.js @@ -0,0 +1,67 @@ +var Liquid = require('..'); +var lexical = Liquid.lexical; +var re = new RegExp(`^(${lexical.identifier.source})\\s+in\\s+` + + `(${lexical.variableOrRange.source})` + + `(?:\\s+${lexical.hash.source})*$`); + +module.exports = function(liquid) { + liquid.registerTag('tablerow', { + + 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('endtablerow', 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.evalExp(this.collection, scope); + if (Liquid.isFalsy(collection)) { + return liquid.renderTemplates(this.elseTemplates, scope); + } + + var html = '', + ctx = {}, + length = collection.length; + var offset = hash.offset || 0; + var limit = (hash.limit === undefined) ? collection.length : hash.limit; + + var cols = hash.cols; + if (!cols) throw new Error(`illegal cols: ${cols}`); + + collection.slice(offset, offset + limit).some((item, i) => { + var row = Math.floor(i / cols) + 1, + col = (i % cols) + 1; + if(col === 1){ + if(row !== 1){ + html += ''; + } + html += ``; + } + + ctx[this.variable] = item; + scope.push(ctx); + html += `'; + scope.pop(ctx); + }); + html += '
`; + html += liquid.renderTemplates(this.templates, scope); + html += '
'; + return html; + } + }); +}; diff --git a/template.js b/template.js index 40f350c59..3d0611fc9 100644 --- a/template.js +++ b/template.js @@ -1,5 +1,6 @@ const lexical = require('./lexical.js'); const error = require('./error.js'); +const ParseError = require('./error.js').ParseError; module.exports = function(Tag) { @@ -26,10 +27,9 @@ module.exports = function(Tag) { var template; if (token.type == 'tag') { if (this.trigger(`tag:${token.name}`, token)) continue; - if (token.name === 'continue' || token.name === 'break'){ + if (token.name === 'continue' || token.name === 'break') { template = token; - } - else{ + } else { template = parseTag(token, this.tokens); } } else { @@ -71,10 +71,14 @@ module.exports = function(Tag) { } function parseTag(token, tokens) { - return Tag.construct(token).parse(tokens); + try { + return Tag.construct(token).parse(tokens); + } catch (e) { + throw new ParseError(e.message, + token.input, token.line, e.stack); + } } - function parseStream(tokens) { var s = Object.create(stream); return s.init(tokens); diff --git a/test/error.js b/test/error.js new file mode 100644 index 000000000..1baa667fb --- /dev/null +++ b/test/error.js @@ -0,0 +1,63 @@ +var chai = require("chai"); +var sinonChai = require("sinon-chai"); +var sinon = require("sinon"); +var expect = chai.expect; +chai.use(sinonChai); + +var liquid = require('..')(), ctx; + +function test(func, cb){ + try{ + func(); + cb({}); + } + catch(e){ + cb(e); + } +} + +describe('error', function() { + + it('should throw TokenizationError when tag illegal', function() { + test(function(){ + liquid.render('{% -a %}', {}); + }, function(err){ + expect(err.name).to.equal('TokenizationError'); + expect(err.message).to.equal('illegal tag: {% -a %}'); + expect(err.input).to.equal('{% -a %}'); + expect(err.line).to.equal(1); + }); + }); + + it('should throw correct error info', function() { + test(function(){ + liquid.render('{%if true%}\naaa{%endif%}\n{% -a %}\n3', {}); + }, function(err){ + expect(err.input).to.equal('{% -a %}'); + expect(err.line).to.equal(3); + }); + }); + + it('should throw ParseError when tag not exist', function() { + test(function(){ + liquid.render('{% a %}', {}); + }, function(err){ + expect(err.name).to.equal('ParseError'); + expect(err.message).to.equal('tag a not found'); + expect(err.input).to.equal('{% a %}'); + expect(err.line).to.equal(1); + }); + }); + + it('should throw ParseError when tag not closed', function() { + test(function(){ + liquid.render('{% if %}', {}); + }, function(err){ + expect(err.name).to.equal('ParseError'); + expect(err.message).to.equal('tag {% if %} not closed'); + expect(err.input).to.equal('{% if %}'); + expect(err.line).to.equal(1); + }); + }); + +}); diff --git a/test/tags.js b/test/tags.js index 21259a9be..2ecfc7cbd 100644 --- a/test/tags.js +++ b/test/tags.js @@ -136,4 +136,47 @@ describe('tags', function() { test('{% decrement foo %}{%decrement foo%}{{foo}}', '-2'); test('{% decrement one %}{{one}}', '0'); }); + + it('should support tablerow', function() { + src = '{% tablerow i in alpha cols:2 %}{{ i }}{% endtablerow %}'; + dst = '' + + '' + + '' + + '
ab
c
'; + test(src, dst); + }); + + it('should support tablerow with range', function() { + src = '{% tablerow i in (1..5) cols:2 %}{{ i }}{% endtablerow %}'; + dst = '' + + '' + + '' + + '' + + '
12
34
5
'; + test(src, dst); + }); + + it('tablerow should throw on illegal cols', function() { + testThrow('{% tablerow i in (1..5) cols:0 %}{{ i }}{% endtablerow %}', + /illegal cols: 0/); + testThrow('{% tablerow i in (1..5) %}{{ i }}{% endtablerow %}', + /illegal cols: undefined/); + }); + + it('should support tablerow with limit', function() { + src = '{% tablerow i in (1..5) cols:2 limit:3 %}{{ i }}{% endtablerow %}'; + dst = '' + + '' + + '' + + '
12
3
'; + test(src, dst); + }); + + it('should support tablerow with offset', function() { + src = '{% tablerow i in (1..5) cols:2 offset:3 %}{{ i }}{% endtablerow %}'; + dst = '' + + '' + + '
45
'; + test(src, dst); + }); }); diff --git a/tokenizer.js b/tokenizer.js index d4d30de1d..d60f8bdf5 100644 --- a/tokenizer.js +++ b/tokenizer.js @@ -1,16 +1,18 @@ const lexical = require('./lexical.js'); -const error = require('./error.js'); +const TokenizationError = require('./error.js').TokenizationError; -function parse(html){ +function parse(html) { var tokens = []; - if(!html) return tokens; + if (!html) return tokens; var syntax = /({%(.*?)%})|({{(.*?)}})/g; var result, htmlFragment, token; var idx = 0; + var _idx = -1; + var _count = 0; while ((result = syntax.exec(html)) !== null) { - if(result.index > idx){ + if (result.index > idx) { htmlFragment = html.slice(idx, result.index); tokens.push({ type: 'html', @@ -18,23 +20,25 @@ function parse(html){ }); } - if(result[1]){ + if (result[1]) { token = factory('tag', 1, result); var match = token.value.match(lexical.tagLine); - if (!match) error('illegal tag', token); + if (!match) { + throw new TokenizationError(`illegal tag: ${token.raw}`, + token.input, token.line); + } token.name = match[1]; token.args = match[2]; tokens.push(token); - } - else{ + } else { token = factory('output', 3, result); tokens.push(token); } idx = syntax.lastIndex; } - if(html.length > idx){ + if (html.length > idx) { htmlFragment = html.slice(idx, html.length); tokens.push({ type: 'html', @@ -43,23 +47,26 @@ function parse(html){ } return tokens; - function factory(type, offset, match){ + function factory(type, offset, match) { + + var lines = match.input.slice(_idx + 1, match.index).split('\n'); + var idx1 = match.input.lastIndexOf('\n', match.index); + var idx2 = match.input.indexOf('\n', match.index); + if(idx2 === -1) idx2 = match.input.length; + var input = match.input.slice(idx1 + 1, idx2); + _count += lines.length - 1; + _idx = match.index; + var token = { - type, + type: type, raw: match[offset], value: match[offset + 1].trim(), - line: crCount(match.index) + 1 + line: _count + 1, + input: input }; return token; } - var lastIdx = -1; - var lastCount = 0; - function crCount(idx){ - lastCount += html.slice(lastIdx+1, idx); - lastIdx = idx; - return lastCount; - } } exports.parse = parse;