diff --git a/README.md b/README.md index 8059bfcaa..9815a1bb3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ See: * [supported filter list](https://github.com/harttle/shopify-liquid/wiki/Builtin-Filters), and * [supported tag list](https://github.com/harttle/shopify-liquid/wiki/Builtin-Tags) +* any-promise Installation: @@ -108,7 +109,7 @@ Note: includes and layouts lookup path should always be specified by `Liquid({ro ## Use in Browser [Download][releases] the dist files and import into your HTML. -And `window.Liquid` is what you want. +And `window.Liquid` is what you want. There's also a [demo](demo/browser/). ```html @@ -131,7 +132,7 @@ And `window.Liquid` is what you want. ``` -There's also a [demo](demo/browser/). +Note: any-promise browser requires a polyfill or explicit registration. e.g: `require('any-promise/register/bluebird')` ## Includes diff --git a/index.js b/index.js index f17d4b551..49f5faf9a 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,7 @@ const tags = require('./tags'); const filters = require('./filters'); const Promise = require('any-promise'); const anySeries = require('./src/util/promise.js').anySeries; +const Errors = require('./src/util/error.js'); var _engine = { init: function(tag, filter, options) { @@ -47,14 +48,15 @@ var _engine = { return this.renderer.renderTemplates(tpl, scope, opts); }, parseAndRender: function(html, ctx, opts) { - try { - var tpl = this.parse(html); - return this.render(tpl, ctx, opts); - } catch (error) { - // A throw inside of a then or catch of a Promise automatically rejects, but since we mix a sync call - // with an async call, we need to do this in case the sync call throws. - return Promise.reject(error); - } + return Promise.resolve() + .then(() => this.parse(html)) + .then(tpl => this.render(tpl, ctx, opts)) + .catch(e => { + if (e instanceof Errors.RenderBreak) { + return e.html; + } + throw e; + }); }, renderFile: function(filepath, ctx, opts) { return this.getTemplate(filepath) @@ -137,5 +139,10 @@ factory.isTruthy = Syntax.isTruthy; factory.isFalsy = Syntax.isFalsy; factory.evalExp = Syntax.evalExp; factory.evalValue = Syntax.evalValue; +factory.Types = { + ParseError: Errors.ParseError, + TokenizationEroor: Errors.TokenizationError, + RenderBreak: Errors.RenderBreak +}; module.exports = factory; diff --git a/src/parser.js b/src/parser.js index 0b991477f..7ffeabf70 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,5 +1,5 @@ const lexical = require('./lexical.js'); -const ParseError = require('./error.js').ParseError; +const ParseError = require('./util/error.js').ParseError; module.exports = function(Tag, Filter) { diff --git a/src/render.js b/src/render.js index fb9e037b7..e65449164 100644 --- a/src/render.js +++ b/src/render.js @@ -1,5 +1,7 @@ const Syntax = require('./syntax.js'); const Promise = require('any-promise'); +const mapSeries = require('./util/promise.js').mapSeries; +const RenderBreak = require('./util/error.js').RenderBreak; var render = { @@ -9,54 +11,36 @@ var render = { opts.strict_filters = opts.strict_filters || false; var html = ''; + return mapSeries(templates, (tpl) => { + return renderTemplate.call(this, tpl) + .then(partial => html += partial) + .catch(e => { + if(e instanceof RenderBreak){ + e.resolvedHTML = html; + } + throw e; + }); + }).then(() => html); - // This executes an array of promises sequentially for every template in the templates array - http://webcache.googleusercontent.com/search?q=cache:rNbMUn9TPtkJ:joost.vunderink.net/blog/2014/12/15/processing-an-array-of-promises-sequentially-in-node-js/+&cd=5&hl=en&ct=clnk&gl=us - // It's fundamentally equivalent to the following... - // emptyPromise.then(renderTag(template0).then(renderTag(template1).then(renderTag(template2)... - var lastPromise = templates.reduce((promise, template) => { - return promise.then(() => { - var promiseLink = Promise.resolve(''); - switch (template.type) { - case 'tag': - // Add Promises to the chain - promiseLink = this.renderTag(template, scope, this.register) - .then((partial) => { - if (partial === undefined) { - return true; // basically a noop (do nothing) - } - return html += partial; - }); - break; - case 'html': - promiseLink = Promise.resolve(template.value) - .then((partial) => { - return html += partial; - }); - break; - case 'output': - var val = this.evalOutput(template, scope, opts); - promiseLink = Promise.resolve(val === undefined ? '' : stringify(val)) - .then((partial) => { - return html += partial; - }); - break; - } - - return promiseLink; - }); - }, Promise.resolve('')); // start the reduce chain with a resolved Promise. After first run, the "promise" argument - // in our reduce callback will be the returned promise from our "then" above. In this - // case, that's the promise returned from this.renderTag or a resolved promise with raw html. - - return lastPromise; + function renderTemplate(template){ + if (template.type === 'tag') { + return this.renderTag(template, scope, this.register) + .then(partial => partial === undefined ? '' : partial); + } else if (template.type === 'output') { + return Promise.resolve(this.evalOutput(template, scope, opts)) + .then(partial => partial === undefined ? '' : stringify(partial)); + } else { // template.type === 'html' + return Promise.resolve(template.value); + } + } }, renderTag: function(template, scope, register) { if (template.name === 'continue') { - return Promise.resolve(''); + return Promise.reject(new RenderBreak('continue')); } if (template.name === 'break') { - return Promise.reject(new Error('forloop.stop')); // this will stop the sequential promise chain + return Promise.reject(new RenderBreak('break')); } return template.render(scope, register); }, diff --git a/src/tokenizer.js b/src/tokenizer.js index 874671a45..48400f5d8 100644 --- a/src/tokenizer.js +++ b/src/tokenizer.js @@ -1,5 +1,5 @@ const lexical = require('./lexical.js'); -const TokenizationError = require('./error.js').TokenizationError; +const TokenizationError = require('./util/error.js').TokenizationError; const _ = require('./util/underscore.js'); function parse(html) { diff --git a/src/error.js b/src/util/error.js similarity index 52% rename from src/error.js rename to src/util/error.js index 1011dbde2..0819bf4a3 100644 --- a/src/error.js +++ b/src/util/error.js @@ -1,5 +1,7 @@ function TokenizationError(message, input, line) { - Error.captureStackTrace(this, this.constructor); + if(Error.captureStackTrace){ + Error.captureStackTrace(this, this.constructor); + } this.name = this.constructor.name; this.message = message; @@ -10,7 +12,9 @@ TokenizationError.prototype = Object.create(Error.prototype); TokenizationError.prototype.constructor = TokenizationError; function ParseError(message, input, line, e) { - Error.captureStackTrace(this, this.constructor); + if(Error.captureStackTrace){ + Error.captureStackTrace(this, this.constructor); + } this.name = this.constructor.name; this.originalError = e; @@ -21,6 +25,16 @@ function ParseError(message, input, line, e) { ParseError.prototype = Object.create(Error.prototype); ParseError.prototype.constructor = ParseError; +function RenderBreak(message){ + if(Error.captureStackTrace){ + Error.captureStackTrace(this, this.constructor); + } + this.name = this.constructor.name; + this.message = message; +} +RenderBreak.prototype = Object.create(Error.prototype); +RenderBreak.prototype.constructor = RenderBreak; + module.exports = { - TokenizationError, ParseError + TokenizationError, ParseError, RenderBreak }; diff --git a/tags/for.js b/tags/for.js index 124fa731b..e31660d5c 100644 --- a/tags/for.js +++ b/tags/for.js @@ -1,7 +1,9 @@ -var Liquid = require('..'); -var Promise = require('any-promise'); -var lexical = Liquid.lexical; -var re = new RegExp(`^(${lexical.identifier.source})\\s+in\\s+` + +const Liquid = require('..'); +const Promise = require('any-promise'); +const lexical = Liquid.lexical; +const mapSeries = require('../src/util/promise.js').mapSeries; +const RenderBreak = Liquid.Types.RenderBreak; +const re = new RegExp(`^(${lexical.identifier.source})\\s+in\\s+` + `(${lexical.value.source})` + `(?:\\s+${lexical.hash.source})*` + `(?:\\s+(reversed))?$`); @@ -37,19 +39,14 @@ module.exports = function(liquid) { return liquid.renderer.renderTemplates(this.elseTemplates, scope); } - var html = ''; var length = collection.length; var offset = hash.offset || 0; var limit = (hash.limit === undefined) ? collection.length : hash.limit; collection = collection.slice(offset, offset + limit); - if(this.reversed) collection.reverse(); + if (this.reversed) collection.reverse(); - // for needs to execute the promises sequentially, not just resolve them sequentially, due to break and continue. - // We can't just loop through executing everything then resolve them all sequentially like we do for render.renderTemplates - // First, we build the array of parameters we are going to use for each call to renderTemplates - var contexts = []; - collection.some((item, i) => { + var contexts = collection.map((item, i) => { var ctx = {}; ctx[this.variable] = item; ctx.forloop = { @@ -63,51 +60,29 @@ module.exports = function(liquid) { stop: false, skip: false }; - // We are just putting together an array of the arguments we will be passing to our sequential promises - contexts.push(ctx); + return ctx; }); - // This is some pretty tricksy javascript, at least to me. - // This executes an array of promises sequentially for every argument in the contexts array - http://webcache.googleusercontent.com/search?q=cache:rNbMUn9TPtkJ:joost.vunderink.net/blog/2014/12/15/processing-an-array-of-promises-sequentially-in-node-js/+&cd=5&hl=en&ct=clnk&gl=us - // It's fundamentally equivalent to the following... - // emptyPromise.then(renderTemplates(args0).then(renderTemplates(args1).then(renderTemplates(args2)... - var lastPromise = contexts.reduce((promise, context) => { - return promise.then((partial) => { - if (scope.get('forloop.stop')) { - throw new Error('forloop.stop'); // this will stop the sequential promise chain - } - - return html += partial; - }) - .then((partial) => { - // todo: Make sure our scope management is sound here. Create some tests that revolve around loops - // with sections that take differing amounts of time to complete. Make sure the order is maintained - // and scope doesn't bleed over into other renderTemplate calls. - scope.push(context); - return liquid.renderer.renderTemplates(this.templates, scope); - }) - .then((partial) => { - scope.pop(context); - return partial; - }); - }, Promise.resolve('')); // start the reduce chain with a resolved Promise. After first run, the "promise" argument - // in our reduce callback will be the returned promise from our "then" above. In this - // case, the promise returned from liquid.renderer.renderTemplates. - - return lastPromise - .then((partial) => { - return html += partial; - }) - .catch((error) => { - if (error.message === 'forloop.stop') { - // the error is a controlled, purposeful stop. so just return the html that we have up to this point - return html; - } else { - // rethrow actual error - throw error; - } - }); - + var html = ''; + return mapSeries(contexts, (context) => { + scope.push(context); + return liquid.renderer + .renderTemplates(this.templates, scope) + .then(partial => html += partial) + .catch(e => { + if (e instanceof RenderBreak) { + html += e.resolvedHTML; + if (e.message === 'continue') return; + } + throw e; + }) + .then(() => scope.pop()); + }).catch((e) => { + if (e instanceof RenderBreak && e.message === 'break') { + return; + } + throw e; + }).then(() => html); } }); }; diff --git a/test/tags/capture.js b/test/tags/capture.js index 2951813fd..66952fe3b 100644 --- a/test/tags/capture.js +++ b/test/tags/capture.js @@ -6,18 +6,19 @@ chai.use(require("chai-as-promised")); describe('tags/capture', function() { var liquid = Liquid(); - it('should support capture 1', function() { + it('should support capture', function() { var src = '{% capture f %}{{"a" | capitalize}}{%endcapture%}{{f}}'; return expect(liquid.parseAndRender(src)) .to.eventually.equal('A'); }); - it('should support capture 2', function() { + + it('should throw on invalid identifier', function() { var src = '{% capture = %}{%endcapture%}'; return expect(liquid.parseAndRender(src)) .to.be.rejectedWith(/= not valid identifier/); }); - it('should throw when for capture closed', function() { + it('should throw when capture not closed', function() { var src = '{%capture c%}{{c}}'; return expect(liquid.parseAndRender(src)) .to.be.rejectedWith(/tag .* not closed/); diff --git a/test/tags/for.js b/test/tags/for.js index af80ad888..b9afd7a5b 100644 --- a/test/tags/for.js +++ b/test/tags/for.js @@ -46,12 +46,10 @@ describe('tags/for', function() { it('should support for with continue', function() { var src = '{% for i in (1..5) %}' + - '{% if i == 4 %}{% continue %}' + - '{% else %}{{ i }}' + - '{% endif %}' + + '{{i}}{% continue %}after' + '{% endfor %}'; return expect(liquid.parseAndRender(src, ctx)) - .to.eventually.equal('1235'); + .to.eventually.equal('12345'); }); it('should support for with break', function() { src = '{% for i in (one..5) %}' + diff --git a/test/tags/include.js b/test/tags/include.js index f8dfa3a12..d76f9a009 100644 --- a/test/tags/include.js +++ b/test/tags/include.js @@ -2,7 +2,7 @@ const Liquid = require('../..'); const mock = require('mock-fs'); const chai = require("chai"); const expect = chai.expect; -const error = require('../../src/error.js'); +const ParseError = Liquid.Types.ParseError; chai.use(require("chai-as-promised")); describe('tags/include', function() { @@ -52,7 +52,7 @@ describe('tags/include', function() { it('should throw when illegal', function() { return expect(liquid.renderFile('/illegal.html')).to. - be.rejectedWith(error.ParseError, /illegal token {%include%}/); + be.rejectedWith(ParseError, /illegal token {%include%}/); }); it('should support include with relative path', function() { diff --git a/test/tags/layout.js b/test/tags/layout.js index 31b066ff4..a5f228322 100644 --- a/test/tags/layout.js +++ b/test/tags/layout.js @@ -2,7 +2,6 @@ const Liquid = require('../..'); const mock = require('mock-fs'); const chai = require("chai"); const expect = chai.expect; -const error = require('../../src/error.js'); chai.use(require("chai-as-promised")); describe('tags/layout', function() { diff --git a/test/tags/tablerow.js b/test/tags/tablerow.js index acac2033d..f65ab5b15 100644 --- a/test/tags/tablerow.js +++ b/test/tags/tablerow.js @@ -1,7 +1,6 @@ const Liquid = require('../..'); const chai = require("chai"); const expect = chai.expect; -const error = require('../../src/error.js'); chai.use(require("chai-as-promised")); describe('tags/tablerow', function() { diff --git a/test/error.js b/test/util/error.js similarity index 95% rename from test/error.js rename to test/util/error.js index 363203c65..a1a9f966b 100644 --- a/test/error.js +++ b/test/util/error.js @@ -1,8 +1,9 @@ -var chai = require("chai"); -var expect = chai.expect; -var engine = require('..')(), ctx; +const chai = require("chai"); +const expect = chai.expect; const mock = require('mock-fs'); +var engine = require('../..')(), ctx; + function test(promise, cb){ return promise .then((result) => {