diff --git a/README.md b/README.md index 4f1c4ed7b..db4f561e8 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,14 @@ shall be implemented. > [Shopify liquid][shopify-liquid] is used by [Jekyll][jekyll] and [Github Pages][gh]. -## Usage - -Install: +Installation: ```bash npm install --save shopify-liquid ``` +## Render from String + Parse and Render: ```javascript @@ -35,6 +35,43 @@ var tpl = engine.parse('{{name | capitalize}}'); engine.render(tpl, {name: 'alice'}); // Alice ``` +## Render from File + +```javascript +var engine = Liquid({ + root: path.resolve(__dirname, 'views/'), + extname: '.liquid', + cache: false +}); +// equivalent to: engine.renderFile("hello", {name: 'alice'}); +var html = engine.renderFile("hello.html", {name: 'alice'}); +``` + +`cache` default to `false`, `extname` default to `.liquid`, `root` default to `""`. + +## Include + +``` +// file: color.liquid +color: '{{ color }}' shape: '{{ shape }}' + +// file: theme.liquid +{% assign shape = 'circle' %} +{% include 'color' %} +{% include 'color' with 'red' %} +{% include 'color', color: 'yellow', shape: 'square' %} +``` + +The output will be: + +``` +color: '' shape: 'circle' +color: 'red' shape: 'circle' +color: 'yellow' shape: 'square' +``` + +## Extension + Register Filters: ```javascript @@ -93,6 +130,7 @@ Tag | Document | Source | Test `decrement` | [Document](https://shopify.github.io/liquid/tags/variable/) | [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/decrement.js) | [Test][tt] `raw` | [Document](https://help.shopify.com/themes/liquid/tags/theme-tags#raw) | [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/raw.js) | [Test][tt] `comment` | [Document](https://help.shopify.com/themes/liquid/tags/theme-tags#comment) | [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/comment.js) | [Test][tt] +`include` | [Document](https://help.shopify.com/themes/liquid/tags/theme-tags#include) | [Source](https://github.com/harttle/shopify-liquid/blob/master/tags/include.js) | [Test][tt] ## Filters @@ -143,10 +181,6 @@ Filter | Document | Source | Test `upcase` | [Document](https://shopify.github.io/liquid/filters/upcase) | [Source](https://github.com/harttle/shopify-liquid/blob/master/filters.js) | [Test][ft] `url_encode` | [Document](https://shopify.github.io/liquid/filters/url_encode) | [Source](https://github.com/harttle/shopify-liquid/blob/master/filters.js) | [Test][ft] -## Differences from Shopify Liquid - -* `url_encode` is based on `encodeURIComponent`, ` ` will be converted to `%20`. - ## Async Support harttle/shopify-liquid do NOT support async rendering, this is by design. @@ -156,8 +190,8 @@ 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]: +* liquid-node: +* nunjucks: [nunjucks]: http://mozilla.github.io/nunjucks/ [liquid-node]: https://github.com/sirlantis/liquid-node diff --git a/index.js b/index.js index 88c58f104..cc4153acd 100644 --- a/index.js +++ b/index.js @@ -1,17 +1,24 @@ -const scope = require('./scope'); -const tokenizer = require('./tokenizer.js'); -const Render = require('./render.js'); -const lexical = require('./lexical.js'); +const scope = require('./src/scope'); +const assert = require('assert'); +const _ = require('lodash'); +const tokenizer = require('./src/tokenizer.js'); +const Render = require('./src/render.js'); +const lexical = require('./src/lexical.js'); const path = require("path"); const fs = require('fs'); -const Tag = require('./tag.js'); -const Filter = require('./filter.js'); -const Template = require('./parser'); -const Expression = require('./expression.js'); -const tagsPath = path.join(__dirname, "tags"); +const Tag = require('./src/tag.js'); +const Filter = require('./src/filter.js'); +const Template = require('./src/parser'); +const Expression = require('./src/expression.js'); +const tagsPath = path.join(__dirname, "tags/"); +const filtersPath = path.join(__dirname, "filters.js"); var _engine = { - init: function(tag, filter) { + init: function(tag, filter, options) { + if (options.cache) { + this.cache = {}; + } + this.options = options; this.tag = tag; this.filter = filter; this.parser = Template(tag, filter); @@ -30,6 +37,10 @@ var _engine = { var tpl = this.parse(html); return this.render(tpl, ctx); }, + renderFile: function(filepath, ctx) { + var tpl = this.handleCache(filepath); + return this.render(tpl, ctx); + }, evalOutput: function(str, scope) { var tpl = this.parser.parseOutput(str.trim()); return this.renderer.evalOutput(tpl, scope); @@ -40,11 +51,26 @@ var _engine = { registerTag: function(name, tag) { return this.tag.register(name, tag); }, + handleCache: function(filepath) { + assert(filepath, 'filepath cannot be null'); + filepath = path.resolve(this.options.root, filepath); + if(path.extname(filepath) === ''){ + filepath += this.options.extname; + } + var tpl = this.options.cache && this.cache[filepath] || + this.parse(fs.readFileSync(filepath)); + return this.options.cache ? (this.cache[filepath] = tpl) : tpl; + } }; -function factory() { +function factory(options) { + options = _.defaults(options || { + root: '', + extname: '.liquid' + }); var engine = Object.create(_engine); - engine.init(Tag(), Filter()); + + engine.init(Tag(), Filter(), options); registerTagsAndFilters(engine); return engine; } @@ -55,7 +81,7 @@ function registerTagsAndFilters(engine) { if (!match) return; require("./tags/" + f)(engine); }); - require("./filters.js")(engine); + require(filtersPath)(engine); } factory.lexical = lexical; diff --git a/package.json b/package.json index 2d47189c5..c085f8d1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shopify-liquid", - "version": "1.1.3", + "version": "1.1.4", "description": "A Shopify Liquid Implementation in Node.js", "main": "index.js", "scripts": { @@ -27,9 +27,12 @@ "devDependencies": { "chai": "^3.5.0", "coveralls": "^2.11.9", + "express": "^4.14.0", "istanbul": "^0.4.3", "mocha": "^2.5.3", + "mock-fs": "^3.9.0", "sinon": "^1.17.4", - "sinon-chai": "^2.8.0" + "sinon-chai": "^2.8.0", + "supertest": "^1.2.0" } } diff --git a/error.js b/src/error.js similarity index 100% rename from error.js rename to src/error.js diff --git a/expression.js b/src/expression.js similarity index 100% rename from expression.js rename to src/expression.js diff --git a/filter.js b/src/filter.js similarity index 100% rename from filter.js rename to src/filter.js diff --git a/lexical.js b/src/lexical.js similarity index 100% rename from lexical.js rename to src/lexical.js diff --git a/parser.js b/src/parser.js similarity index 100% rename from parser.js rename to src/parser.js diff --git a/render.js b/src/render.js similarity index 100% rename from render.js rename to src/render.js diff --git a/scope.js b/src/scope.js similarity index 100% rename from scope.js rename to src/scope.js diff --git a/syntax.js b/src/syntax.js similarity index 100% rename from syntax.js rename to src/syntax.js diff --git a/tag.js b/src/tag.js similarity index 100% rename from tag.js rename to src/tag.js diff --git a/tokenizer.js b/src/tokenizer.js similarity index 100% rename from tokenizer.js rename to src/tokenizer.js diff --git a/tags/include.js b/tags/include.js new file mode 100644 index 000000000..6644175ae --- /dev/null +++ b/tags/include.js @@ -0,0 +1,31 @@ +var Liquid = require('..'); +var lexical = Liquid.lexical; +var withRE = new RegExp(`with\\s+(${lexical.value.source})`); + +module.exports = function(liquid) { + + liquid.registerTag('include', { + parse: function(token){ + var match = lexical.value.exec(token.args); + if(!match) error(`illegal token ${token.raw}`, token); + this.value = match[0]; + + match = withRE.exec(token.args); + if(match){ + this.with = match[1]; + } + }, + render: function(scope, hash) { + var filepath = Liquid.evalValue(this.value, scope); + if(this.with){ + hash[filepath] = Liquid.evalValue(this.with, scope); + } + var tpl = liquid.handleCache(filepath); + scope.push(hash); + var html = liquid.renderer.renderTemplates(tpl, scope); + scope.pop(); + return html; + } + }); + +}; diff --git a/test/expression.js b/test/expression.js index 9f6c3f902..e37c8435f 100644 --- a/test/expression.js +++ b/test/expression.js @@ -1,7 +1,7 @@ const chai = require("chai"); const expect = chai.expect; -var expression = require('../expression.js'); -var Scope = require('../scope.js'); +var expression = require('../src/expression.js'); +var Scope = require('../src/scope.js'); var evalExp = expression.evalExp; var evalValue = expression.evalValue; diff --git a/test/filter.js b/test/filter.js index 2891fedfc..9a90f0ad6 100644 --- a/test/filter.js +++ b/test/filter.js @@ -5,8 +5,8 @@ const expect = chai.expect; chai.use(sinonChai); -var filter = require('../filter.js')(); -var Scope = require('../scope.js'); +var filter = require('../src/filter.js')(); +var Scope = require('../src/scope.js'); describe('filter', function() { var scope; diff --git a/test/lexical.js b/test/lexical.js index 5ef7e2d37..fd561385d 100644 --- a/test/lexical.js +++ b/test/lexical.js @@ -2,7 +2,7 @@ var chai = require("chai"); var should = chai.should(); var expect = chai.expect; -var lexical = require('../lexical.js'); +var lexical = require('../src/lexical.js'); describe('lexical', function() { it('should test filter syntax', function(){ diff --git a/test/liquid.js b/test/liquid.js index ff8458dce..2d9f0c801 100644 --- a/test/liquid.js +++ b/test/liquid.js @@ -2,24 +2,29 @@ const chai = require("chai"); const expect = chai.expect; const should = chai.should; const Liquid = require('..'); +const mock = require('mock-fs'); describe('liquid', function() { var engine, ctx; beforeEach(function() { - engine = Liquid(); ctx = { - date: new Date(), - foo: 'bar', + name: 'harttle', arr: [-2, 'a'], obj: { foo: 'bar' - }, - posts: [{ - category: 'foo' - }, { - category: 'bar' - }] + } }; + engine = Liquid({ + root: '/root/', + extname: '.html' + }); + mock({ + '/root/files/foo.html': 'foo', + '/root/files/name.html': 'My name is {{name}}.' + }); + }); + afterEach(function(){ + mock.restore(); }); it('should output object', function() { engine.parseAndRender('{{obj}}', ctx).should.equal('{"foo":"bar"}'); @@ -44,4 +49,38 @@ describe('liquid', function() { var template = engine.parse('

{{arr | join: "_"}}

'); engine.render(template, ctx).should.equal('

-2_a

'); }); + it('should render file', function(){ + engine.renderFile('/root/files/foo.html', ctx).should.equal('foo'); + }); + it('should render file relative to root', function(){ + engine.renderFile('files/foo.html', ctx).should.equal('foo'); + }); + it('should render file with context', function(){ + engine.renderFile('/root/files/name.html', ctx).should.equal('My name is harttle.'); + }); + it('should render file with default extname', function() { + engine.renderFile('files/name', ctx).should.equal('My name is harttle.'); + }); + it('should disable cache by default', function(){ + mock({ + '/root/files/foo.html': 'bar' + }); + engine.renderFile('files/foo', ctx).should.equal('bar'); + }); + it('should respect cache option', function(){ + mock.restore(); + engine = Liquid({ + root: '/root/', + extname: '.html', + cache: true + }); + mock({ + '/root/files/foo.html': 'foo' + }); + engine.renderFile('files/foo', ctx).should.equal('foo'); + mock({ + '/root/files/foo.html': 'bar' + }); + engine.renderFile('files/foo', ctx).should.equal('foo'); + }); }); diff --git a/test/parser.js b/test/parser.js index e4c1d227f..366a3b937 100644 --- a/test/parser.js +++ b/test/parser.js @@ -6,9 +6,9 @@ const expect = chai.expect; chai.use(sinonChai); -var filter = require('../filter.js')(); -var tag = require('../tag.js')(); -var Template = require('../parser.js'); +var filter = require('../src/filter.js')(); +var tag = require('../src/tag.js')(); +var Template = require('../src/parser.js'); describe('template', function() { var scope, template, add = (l, r) => l + r; diff --git a/test/render.js b/test/render.js index 5ce78a4f3..8bdfda535 100644 --- a/test/render.js +++ b/test/render.js @@ -5,11 +5,11 @@ const expect = chai.expect; chai.use(sinonChai); -var tag = require('../tag.js')(); -var Scope = require('../scope.js'); -var filter = require('../filter')(); -var Render = require('../render.js'); -var Template = require('../parser.js')(tag, filter); +var tag = require('../src/tag.js')(); +var Scope = require('../src/scope.js'); +var filter = require('../src/filter')(); +var Render = require('../src/render.js'); +var Template = require('../src/parser.js')(tag, filter); describe('render', function() { var scope, render; diff --git a/test/scope.js b/test/scope.js index 693b80ab2..5f09b4f4d 100644 --- a/test/scope.js +++ b/test/scope.js @@ -2,7 +2,7 @@ var chai = require("chai"); var should = chai.should(); var expect = chai.expect; -var Scope = require('../scope.js'); +var Scope = require('../src/scope.js'); describe('scope', function() { var scope; diff --git a/test/tag.js b/test/tag.js index 0746f4153..062b5635f 100644 --- a/test/tag.js +++ b/test/tag.js @@ -4,8 +4,8 @@ var sinon = require("sinon"); var expect = chai.expect; chai.use(sinonChai); -var tag = require('../tag.js')(); -var Scope = require('../scope.js'); +var tag = require('../src/tag.js')(); +var Scope = require('../src/scope.js'); describe('tag', function() { var scope; diff --git a/test/tags.js b/test/tags.js index b9e0e3253..11deb4775 100644 --- a/test/tags.js +++ b/test/tags.js @@ -1,7 +1,11 @@ const chai = require("chai"); const expect = chai.expect; - -var liquid = require('..')(), +const Liquid = require('..'); +const mock = require('mock-fs'); +var liquid = Liquid({ + root: '/', + extname: '.html' + }), ctx, src, dst; function test(src, dst) { @@ -26,6 +30,20 @@ describe('tags', function() { alpha: ['a', 'b', 'c'], emptyArray: [] }; + mock({ + '/files/foo.html': 'foo', + '/current.html': 'bar{% include "foo.html" %}bar', + '/relative.html': 'bar{% include "../files/foo.html" %}bar', + '/foo.html': 'FOO', + '/user.html': '{{name}} : {{role}} : {{alias}}', + '/color.html': 'color:{{color}}, shape:{{shape}}', + '/with.html': '{% include "color" with "red", shape: "rect" %}', + '/scope.html': '{% assign shape="triangle" %}{% assign color="yellow" %}{% include "color.html" %}', + '/hash.html': '{% assign name="harttle" %}{% include "user.html", role: "admin", alias: name %}' + }); + }); + afterEach(function() { + mock.restore(); }); it('should support assign', function() { @@ -217,4 +235,27 @@ describe('tags', function() { ''; test(src, dst); }); + + it('should support include', function() { + expect(liquid.renderFile('/current.html', ctx)).to.equal('barFOObar'); + }); + + it('should support include with relative path', function() { + expect(liquid.renderFile('relative.html', ctx)).to.equal('barfoobar'); + }); + + it('should support include: hash list', function() { + expect(liquid.renderFile('hash.html', ctx)).to.equal('harttle : admin : harttle'); + }); + + it('should support include: parent scope', function() { + expect(liquid.renderFile('scope.html', ctx)).to.equal('color:yellow, shape:triangle'); + }); + + it('should support include: with', function() { + var filepath = 'with.html'; + var dst = 'color:red, shape:rect'; + expect(liquid.renderFile(filepath, ctx)).to.equal(dst); + }); + }); diff --git a/test/tokenizer.js b/test/tokenizer.js index 13525249e..fa8d7fb64 100644 --- a/test/tokenizer.js +++ b/test/tokenizer.js @@ -2,7 +2,7 @@ var chai = require("chai"); var should = chai.should(); var expect = chai.expect; -var tokenizer = require('../tokenizer.js'); +var tokenizer = require('../src/tokenizer.js'); describe('tokenizer', function() { it('should handle plain HTML', function() {