diff --git a/README.md b/README.md index 6835c8a7b..2259eeb7c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,85 @@ +# shopify-liquid + +[![NPM version](https://img.shields.io/npm/v/shopify-liquid.svg?style=flat)](https://www.npmjs.org/package/shopify-liquid) +[![Build Status](https://travis-ci.org/harttle/shopify-liquid.svg?branch=master)](https://travis-ci.org/harttle/shopify-liquid) +[![Coverage Status](https://coveralls.io/repos/github/harttle/shopify-liquid/badge.svg?branch=master)](https://coveralls.io/github/harttle/shopify-liquid?branch=master) +[![Dependency manager](https://david-dm.org/harttle/shopify-liquid.png)](https://david-dm.org/harttle/shopify-liquid) + +[Liquid][shopify-liquid] implementation for Node.js. +All tags and filters listed in [Shopify Documentation][shopify-liquid] +shall be implemented. + +> Shopify liquid is used by [Jekyll][jekyll] and [Github Pages][gh]. + +## Tags + +Documentation: + +- [x] [assign](https://shopify.github.io/liquid/tags/control-flow/) +- [ ] [case/when](https://shopify.github.io/liquid/tags/control-flow/) +- [ ] [if](https://shopify.github.io/liquid/tags/control-flow/) +- [ ] [unless](https://shopify.github.io/liquid/tags/control-flow/) +- [ ] [elsif/else](https://shopify.github.io/liquid/tags/control-flow/) +- [ ] [for](https://shopify.github.io/liquid/tags/iteration/) +- [ ] [break](https://shopify.github.io/liquid/tags/iteration/) +- [ ] [continue](https://shopify.github.io/liquid/tags/iteration/) +- [ ] [for: limit,offset,range,reversed)](https://shopify.github.io/liquid/tags/iteration/) +- [ ] [cycle](https://shopify.github.io/liquid/tags/iteration/) +- [ ] [cycle: group)](https://shopify.github.io/liquid/tags/iteration/) +- [ ] [tablerow](https://shopify.github.io/liquid/tags/iteration/) +- [ ] [tablerow: cols,limit,offset,range](https://shopify.github.io/liquid/tags/iteration/) +- [ ] [assign](https://shopify.github.io/liquid/tags/variable/) +- [ ] [capture](https://shopify.github.io/liquid/tags/variable/) +- [ ] [increment](https://shopify.github.io/liquid/tags/variable/) +- [ ] [decrement](https://shopify.github.io/liquid/tags/variable/) + + +## Filters + +Documentation: + +- [x] [abs](https://shopify.github.io/liquid/filters/abs/) +- [ ] [append](https://shopify.github.io/liquid/filters/append) +- [ ] [capitalize](https://shopify.github.io/liquid/filters/capitalize) +- [ ] [ceil](https://shopify.github.io/liquid/filters/ceil) +- [ ] [date](https://shopify.github.io/liquid/filters/date) +- [ ] [default](https://shopify.github.io/liquid/filters/default) +- [ ] [divided_by](https://shopify.github.io/liquid/filters/divided_by) +- [ ] [downcase](https://shopify.github.io/liquid/filters/downcase) +- [ ] [escape](https://shopify.github.io/liquid/filters/escape) +- [ ] [escape_once](https://shopify.github.io/liquid/filters/escape_once) +- [ ] [first](https://shopify.github.io/liquid/filters/first) +- [ ] [floor](https://shopify.github.io/liquid/filters/floor) +- [ ] [join](https://shopify.github.io/liquid/filters/join) +- [ ] [last](https://shopify.github.io/liquid/filters/last) +- [ ] [lstrip](https://shopify.github.io/liquid/filters/lstrip) +- [ ] [map](https://shopify.github.io/liquid/filters/map) +- [ ] [minus](https://shopify.github.io/liquid/filters/minus) +- [ ] [modulo](https://shopify.github.io/liquid/filters/modulo) +- [ ] [newline_to_br](https://shopify.github.io/liquid/filters/newline_to_br) +- [ ] [plus](https://shopify.github.io/liquid/filters/plus) +- [ ] [prepend](https://shopify.github.io/liquid/filters/prepend) +- [ ] [remove](https://shopify.github.io/liquid/filters/remove) +- [ ] [remove_first](https://shopify.github.io/liquid/filters/remove_first) +- [ ] [replace](https://shopify.github.io/liquid/filters/replace) +- [ ] [replace_first](https://shopify.github.io/liquid/filters/replace_first) +- [ ] [reverse](https://shopify.github.io/liquid/filters/reverse) +- [ ] [round](https://shopify.github.io/liquid/filters/round) +- [ ] [rstrip](https://shopify.github.io/liquid/filters/rstrip) +- [ ] [size](https://shopify.github.io/liquid/filters/size) +- [ ] [slice](https://shopify.github.io/liquid/filters/slice) +- [ ] [sort](https://shopify.github.io/liquid/filters/sort) +- [ ] [split](https://shopify.github.io/liquid/filters/split) +- [ ] [strip](https://shopify.github.io/liquid/filters/strip) +- [ ] [strip_html](https://shopify.github.io/liquid/filters/strip_html) +- [ ] [strip_newlines](https://shopify.github.io/liquid/filters/strip_newlines) +- [ ] [times](https://shopify.github.io/liquid/filters/times) +- [ ] [truncate](https://shopify.github.io/liquid/filters/truncate) +- [ ] [truncatewords](https://shopify.github.io/liquid/filters/truncatewords) +- [ ] [uniq](https://shopify.github.io/liquid/filters/uniq) +- [ ] [upcase](https://shopify.github.io/liquid/filters/upcase) +- [ ] [url_encode](https://shopify.github.io/liquid/filters/url_encode) + ## Async Support harttle/shopify-liquid do NOT support async rendering, this is by design. @@ -12,3 +94,6 @@ For template-driven projects, checkout these Liquid-like engines: [nunjucks]: http://mozilla.github.io/nunjucks/ [liquid-node]: https://github.com/sirlantis/liquid-node +[shopify-liquid]: https://shopify.github.io/liquid/ +[jekyll]: http://jekyllrb.com/ +[gh]: https://pages.github.com/ diff --git a/context.js b/context.js index 7e9ff30fd..f2d979447 100644 --- a/context.js +++ b/context.js @@ -18,6 +18,10 @@ var context = { } return ''; }, + set: function(k, v){ + this.context[this.context.length-1][k] = v; + return this; + }, push: function(ctx) { return this.context.push(ctx); }, diff --git a/filters/abs.js b/filters/abs.js index 2303ea5a3..a7a10cf57 100644 --- a/filters/abs.js +++ b/filters/abs.js @@ -1 +1,3 @@ -module.exports = v => Math.abs(v); +module.exports = function(liquid) { + liquid.registerFilter('abs', v => Math.abs(v)); +}; diff --git a/index.js b/index.js index 7d08816aa..a1fcd96b8 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ const context = require('./context'); const tokenizer = require('./tokenizer.js'); -const render = require('./render.js'); +const Render = require('./render.js'); const lexical = require('./lexical.js'); const path = require("path"); const fs = require('fs'); @@ -11,15 +11,11 @@ const tagsPath = path.join(__dirname, "tags"); const filtersPath = path.join(__dirname, "filters"); var _engine = { - render: function(html, ctx) { - return this.doRender(tokenizer.parse(html), context.factory(ctx)); - }, - lexical, registerFilter : function(name, filter){ return this.filter.register(name, filter); }, registerTag : function(name, tag){ - return this.filter.register(name, tag); + return this.tag.register(name, tag); } }; @@ -27,20 +23,27 @@ function factory(){ var engine = Object.create(_engine); engine.tag = Tag(); engine.filter = Filter(); - engine.doRender = render(engine.filter, engine.tag); + + var render = Render(engine.filter, engine.tag); + engine.render = function(html, ctx) { + return render.render(tokenizer.parse(html), context.factory(ctx)); + }, + engine.evaluate = render.evaluate; fs.readdirSync(tagsPath).map(function(f){ var match = /^(\w+)\.js$/.exec(f); if(!match) return; - engine.tag.register(match[1], require("./tags/" + f)); + require("./tags/" + f)(engine); }); fs.readdirSync(filtersPath).map(function(f){ var match = /^(\w+)\.js$/.exec(f); if(!match) return; - engine.filter.register(match[1], require("./filters/" + f)); + require("./filters/" + f)(engine); }); return engine; } +factory.lexical = lexical; + module.exports = factory; diff --git a/render.js b/render.js index 5a2b73406..29e1b6ff2 100644 --- a/render.js +++ b/render.js @@ -1,44 +1,49 @@ -module.exports = (Filter, Tag) => +module.exports = function(Filter, Tag) { function render(tokens, ctx) { var html = ''; - for (var i = 0; i < tokens.length; i++) { + while (tokens.length) { var token = tokens.shift(); switch (token.type) { case 'html': html += token.value; break; case 'output': - html += renderOutput(token); + html += evaluate(token.value, ctx); break; case 'tag': - html += renderTag(token); + html += renderTag(token, tokens, ctx); 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 evaluate(str, ctx) { + var filters = str.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`); - } + function renderTag(token, tokens, ctx) { + 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); } + return tag.render(subTokens, ctx); + } + + return { + render, evaluate, renderTag }; +}; diff --git a/tag.js b/tag.js index 81ebc7222..9d356c355 100644 --- a/tag.js +++ b/tag.js @@ -3,7 +3,8 @@ const lexical = require('./lexical.js'); var _tagInstance = { render: function(tokens, ctx) { var obj = hash(this.markup, ctx); - return this.tag.render(tokens, ctx, this.markup, obj); + var res = this.tag.render(tokens, ctx, this.markup, obj); + return res === undefined ? '' : res; } }; diff --git a/tags/assign.js b/tags/assign.js new file mode 100644 index 000000000..77d2b7b44 --- /dev/null +++ b/tags/assign.js @@ -0,0 +1,15 @@ +var Liquid = require('..'); +var patterns = Liquid.lexical.patterns; +var re = new RegExp(`(${patterns.identifier.source})\\s*=(.*)`); + +module.exports = function(liquid) { + + liquid.registerTag('assign', { + render: function(tokens, ctx, markup, hash) { + var match = markup.match(re); + var k = match[1], v = match[2]; + ctx.set(k, liquid.evaluate(v, ctx)); + } + }); + +}; diff --git a/test/context.js b/test/context.js index 325cddb86..bb2af1ce0 100644 --- a/test/context.js +++ b/test/context.js @@ -6,7 +6,7 @@ var context = require('../context.js'); describe('context', function() { var ctx; - before(function(){ + beforeEach(function(){ ctx = context.factory({ foo: 'bar', bar: ['a', {b: [1, 2]}] @@ -23,6 +23,11 @@ describe('context', function() { ctx.get('foo').should.equal('bar'); }); + it('should set property', function() { + ctx.set('foo', 'FOO'); + ctx.get('foo').should.equal('FOO'); + }); + it('should get desendent property', function() { ctx.get('bar[0]').should.equal('a'); ctx.get('bar[1].b').should.deep.equal([1, 2]); @@ -37,6 +42,7 @@ describe('context', function() { }); it('should pop context', function() { + ctx.push({foo: 'foo', foo1: 'foo1'}); ctx.pop(); expect(ctx.get('foo')).to.equal('bar'); expect(ctx.get('foo1')).to.equal(''); diff --git a/test/render.js b/test/render.js index ad4e06726..94b023480 100644 --- a/test/render.js +++ b/test/render.js @@ -8,7 +8,7 @@ chai.use(sinonChai); var tag = require('../tag.js')(); var context = require('../context.js'); var filter = require('../filter')(); -var render = require('../render.js')(filter, tag); +var render = require('../render.js')(filter, tag).render; describe('render', function() { var ctx, htmlToken, tagToken, filterToken; diff --git a/test/tags.js b/test/tags.js new file mode 100644 index 000000000..e8ec39aac --- /dev/null +++ b/test/tags.js @@ -0,0 +1,19 @@ +const chai = require("chai"); +const expect = chai.expect; + +var liquid = require('..')(); + +var ctx = { + foo: 'bar', + arr: [-2, 'a'] +}; + +describe('tags', function() { + it('should support assign', function() { + test('{% assign foo="bar"%}{{foo}}', 'bar'); + }); +}); + +function test(src, dst){ + expect(liquid.render(src, ctx)).to.equal(dst); +}