diff --git a/src/builtin/tags/index.ts b/src/builtin/tags/index.ts index 9c9c0df52d..924f62f832 100644 --- a/src/builtin/tags/index.ts +++ b/src/builtin/tags/index.ts @@ -4,6 +4,7 @@ import capture from './capture' import Case from './case' import comment from './comment' import include from './include' +import render from './render' import decrement from './decrement' import cycle from './cycle' import If from './if' @@ -18,7 +19,7 @@ import Continue from './continue' import { ITagImplOptions } from '../../template/tag/itag-impl-options' const tags: { [key: string]: ITagImplOptions } = { - assign, 'for': For, capture, 'case': Case, comment, include, decrement, increment, cycle, 'if': If, layout, block, raw, tablerow, unless, 'break': Break, 'continue': Continue + assign, 'for': For, capture, 'case': Case, comment, include, render, decrement, increment, cycle, 'if': If, layout, block, raw, tablerow, unless, 'break': Break, 'continue': Continue } export default tags diff --git a/src/builtin/tags/raw.ts b/src/builtin/tags/raw.ts index 5e2aa76b7f..791dfbe2e5 100644 --- a/src/builtin/tags/raw.ts +++ b/src/builtin/tags/raw.ts @@ -1,4 +1,4 @@ -import { Hash, Emitter, TagToken, Token, ITagImplOptions, Context } from '../../types' +import { TagToken, Token, ITagImplOptions } from '../../types' export default { parse: function (tagToken: TagToken, remainTokens: Token[]) { @@ -15,7 +15,7 @@ export default { }) stream.start() }, - render: function (ctx: Context, hash: Hash, emitter: Emitter) { - emitter.write(this.tokens.map((token: Token) => token.raw).join('')) + render: function () { + return this.tokens.map((token: Token) => token.raw).join('') } } as ITagImplOptions diff --git a/src/builtin/tags/render.ts b/src/builtin/tags/render.ts new file mode 100644 index 0000000000..b41fc75fa3 --- /dev/null +++ b/src/builtin/tags/render.ts @@ -0,0 +1,50 @@ +import { assert } from '../../util/assert' +import { Expression, Hash, Emitter, TagToken, Context, ITagImplOptions } from '../../types' +import { value, quotedLine } from '../../parser/lexical' +import BlockMode from '../../context/block-mode' + +const staticFileRE = /[^\s,]+/ +const withRE = new RegExp(`with\\s+(${value.source})`) + +export default { + parse: function (token: TagToken) { + let match = staticFileRE.exec(token.args) + if (match) this.staticValue = match[0] + + match = value.exec(token.args) + if (match) this.value = match[0] + + match = withRE.exec(token.args) + if (match) this.with = match[1] + }, + render: function * (ctx: Context, hash: Hash, emitter: Emitter) { + let filepath + if (ctx.opts.dynamicPartials) { + if (quotedLine.exec(this.value)) { + const template = this.value.slice(1, -1) + filepath = yield this.liquid._parseAndRender(template, ctx.getAll(), ctx.opts, ctx.sync) + } else { + filepath = yield new Expression(this.value).value(ctx) + } + } else { + filepath = this.staticValue + } + assert(filepath, `cannot render with empty filename`) + + const originBlocks = ctx.getRegister('blocks') + const originBlockMode = ctx.getRegister('blockMode') + + const childCtx = new Context({}, ctx.opts, ctx.sync) + childCtx.setRegister('blocks', {}) + childCtx.setRegister('blockMode', BlockMode.OUTPUT) + if (this.with) { + hash[filepath] = yield new Expression(this.with).evaluate(ctx) + } + childCtx.push(hash) + const templates = yield this.liquid._parseFile(filepath, childCtx.opts, childCtx.sync) + yield this.liquid.renderer.renderTemplates(templates, childCtx, emitter) + + childCtx.setRegister('blocks', originBlocks) + childCtx.setRegister('blockMode', originBlockMode) + } +} as ITagImplOptions diff --git a/src/context/context.ts b/src/context/context.ts index dddbb996ea..af43ec3a57 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -11,10 +11,10 @@ export class Context { public environments: Scope public sync: boolean public opts: NormalizedFullOptions - public constructor (ctx: object = {}, opts?: NormalizedFullOptions, sync = false) { + public constructor (env: object = {}, opts?: NormalizedFullOptions, sync = false) { this.sync = sync this.opts = applyDefault(opts) - this.environments = ctx + this.environments = env } public getRegister (key: string, defaultValue = {}) { return (this.registers[key] = this.registers[key] || defaultValue) diff --git a/src/liquid.ts b/src/liquid.ts index f3c19bb883..2b61cafa7d 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -16,8 +16,6 @@ import { FilterImplOptions } from './template/filter/filter-impl-options' import IFS from './fs/ifs' import { toThenable, toValue } from './util/async' -type nullableTemplates = ITemplate[] | null - export * from './types' export class Liquid { diff --git a/src/util/async.ts b/src/util/async.ts index 8f8b647416..01a442316b 100644 --- a/src/util/async.ts +++ b/src/util/async.ts @@ -34,7 +34,7 @@ function isCustomIterable (val: any): val is IterableIterator { return val && isFunction(val.next) && isFunction(val.throw) && isFunction(val.return) } -export function toThenable (val: IterableIterator | Thenable): Thenable { +export function toThenable (val: IterableIterator | Thenable | any): Thenable { if (isThenable(val)) return val if (isCustomIterable(val)) return reduce() return mkResolve(val) @@ -61,7 +61,7 @@ export function toThenable (val: IterableIterator | Thenable): Thenable { } } -export function toValue (val: IterableIterator | Thenable) { +export function toValue (val: IterableIterator | Thenable | any) { let ret: any toThenable(val) .then((x: any) => { diff --git a/test/integration/builtin/tags/render.ts b/test/integration/builtin/tags/render.ts new file mode 100644 index 0000000000..73f5340669 --- /dev/null +++ b/test/integration/builtin/tags/render.ts @@ -0,0 +1,210 @@ +import { Liquid, Drop } from '../../../../src/liquid' +import { expect } from 'chai' +import { mock, restore } from '../../../stub/mockfs' + +describe('tags/render', function () { + let liquid: Liquid + before(function () { + liquid = new Liquid({ + root: '/', + extname: '.html' + }) + }) + afterEach(restore) + it('should support render', async function () { + mock({ + '/current.html': 'bar{% render "bar/foo.html" %}bar', + '/bar/foo.html': 'foo' + }) + const html = await liquid.renderFile('/current.html') + return expect(html).to.equal('barfoobar') + }) + it('should support template string', async function () { + mock({ + '/current.html': 'bar{% render "bar/{{name}}" %}bar', + '/bar/foo.html': 'foo' + }) + const html = await liquid.renderFile('/current.html', { name: 'foo.html' }) + return expect(html).to.equal('barfoobar') + }) + + it('should throw when not specified', function () { + mock({ + '/parent.html': '{%render%}' + }) + return liquid.renderFile('/parent.html').catch(function (e) { + console.log(e) + expect(e.name).to.equal('RenderError') + expect(e.message).to.match(/cannot render with empty filename/) + }) + }) + + it('should throw when not exist', function () { + mock({ + '/parent.html': '{%render not-exist%}' + }) + return liquid.renderFile('/parent.html').catch(function (e) { + expect(e.name).to.equal('RenderError') + expect(e.message).to.match(/cannot render with empty filename/) + }) + }) + + it('should support render with relative path', async function () { + mock({ + '/bar/foo.html': 'foo', + '/foo/relative.html': 'bar{% render "../bar/foo.html" %}bar' + }) + const html = await liquid.renderFile('foo/relative.html') + return expect(html).to.equal('barfoobar') + }) + + it('should support render: hash list', async function () { + mock({ + '/hash.html': '{% assign name="harttle" %}{% render "user.html", role: "admin", alias: name %}', + '/user.html': '{{role}} : {{alias}}' + }) + const html = await liquid.renderFile('hash.html') + return expect(html).to.equal('admin : harttle') + }) + + it('should not bleed into child template', async function () { + mock({ + '/hash.html': '{% assign name="harttle" %}InParent: {{name}} {% render "user.html" %}', + '/user.html': 'InChild: {{name}}' + }) + const html = await liquid.renderFile('hash.html') + return expect(html).to.equal('InParent: harttle InChild: ') + }) + + it('should support render: with', async function () { + mock({ + '/with.html': '{% render "color" with "red", shape: "rect" %}', + '/color.html': 'color:{{color}}, shape:{{shape}}' + }) + const html = await liquid.renderFile('with.html') + return expect(html).to.equal('color:red, shape:rect') + }) + it('should support render: with as Drop', async function () { + class ColorDrop extends Drop { + public valueOf (): string { + return 'red!' + } + } + mock({ + '/with.html': '{% render "color" with color %}', + '/color.html': 'color:{{color}}' + }) + const html = await liquid.renderFile('with.html', { color: new ColorDrop() }) + expect(html).to.equal('color:red!') + }) + it('should support render: with passed as Drop', async function () { + class ColorDrop extends Drop { + public valueOf (): string { + return 'red!' + } + } + liquid.registerFilter('name', x => x.constructor.name) + mock({ + '/with.html': '{% render "color" with color %}', + '/color.html': '{{color | name}}' + }) + const html = await liquid.renderFile('with.html', { color: new ColorDrop() }) + expect(html).to.equal('ColorDrop') + }) + + it('should support nested renders', async function () { + mock({ + '/personInfo.html': 'This is a person {% render "card.html", person: person%}', + '/card.html': '

{{person.firstName}} {{person.lastName}}
{% render "address", address: person.address %}

', + '/address.html': 'City: {{address.city}}' + }) + const ctx = { + person: { + firstName: 'Joe', + lastName: 'Shmoe', + address: { + city: 'Dallas' + } + } + } + const html = await liquid.renderFile('personInfo.html', ctx) + return expect(html).to.equal('This is a person

Joe Shmoe
City: Dallas

') + }) + + describe('static partial', function () { + it('should support filename with extention', async function () { + mock({ + '/parent.html': 'X{% render child.html color:"red" %}Y', + '/child.html': 'child with {{color}}' + }) + const staticLiquid = new Liquid({ dynamicPartials: false, root: '/' }) + const html = await staticLiquid.renderFile('parent.html') + return expect(html).to.equal('Xchild with redY') + }) + + it('should support parent paths', async function () { + mock({ + '/parent.html': 'X{% render bar/./../foo/child.html %}Y', + '/foo/child.html': 'child' + }) + const staticLiquid = new Liquid({ dynamicPartials: false, root: '/' }) + const html = await staticLiquid.renderFile('parent.html') + return expect(html).to.equal('XchildY') + }) + + it('should support subpaths', async function () { + mock({ + '/parent.html': 'X{% render foo/child.html %}Y', + '/foo/child.html': 'child' + }) + const staticLiquid = new Liquid({ dynamicPartials: false, root: '/' }) + const html = await staticLiquid.renderFile('parent.html') + return expect(html).to.equal('XchildY') + }) + + it('should support comma separated arguments', async function () { + mock({ + '/parent.html': 'X{% render child.html, color:"red" %}Y', + '/child.html': 'child with {{color}}' + }) + const staticLiquid = new Liquid({ dynamicPartials: false, root: '/' }) + const html = await staticLiquid.renderFile('parent.html') + return expect(html).to.equal('Xchild with redY') + }) + }) + describe('sync support', function () { + it('should support quoted string', function () { + mock({ + '/current.html': 'bar{% render "bar/foo.html" %}bar', + '/bar/foo.html': 'foo' + }) + const html = liquid.renderFileSync('/current.html') + return expect(html).to.equal('barfoobar') + }) + it('should support template string', function () { + mock({ + '/current.html': 'bar{% render name" %}bar', + '/bar/foo.html': 'foo' + }) + const html = liquid.renderFileSync('/current.html', { name: '/bar/foo.html' }) + return expect(html).to.equal('barfoobar') + }) + it('should support render: with', function () { + mock({ + '/with.html': '{% render "color" with "red", shape: "rect" %}', + '/color.html': 'color:{{color}}, shape:{{shape}}' + }) + const html = liquid.renderFileSync('with.html') + return expect(html).to.equal('color:red, shape:rect') + }) + it('should support filename with extention', function () { + mock({ + '/parent.html': 'X{% render child.html color:"red" %}Y', + '/child.html': 'child with {{color}}' + }) + const staticLiquid = new Liquid({ dynamicPartials: false, root: '/' }) + const html = staticLiquid.renderFileSync('parent.html') + return expect(html).to.equal('Xchild with redY') + }) + }) +})