diff --git a/src/builtin/tags/case.ts b/src/builtin/tags/case.ts index 4033ffc68..e8f4a20ad 100644 --- a/src/builtin/tags/case.ts +++ b/src/builtin/tags/case.ts @@ -16,13 +16,10 @@ export default { while (!tokenizer.end()) { const value = tokenizer.readValue() - if (value) { - this.cases.push({ - val: value, - templates: p - }) - } - + this.cases.push({ + val: value, + templates: p + }) tokenizer.readTo(',') } }) diff --git a/src/builtin/tags/if.ts b/src/builtin/tags/if.ts index 1cf06d3b0..5a600af59 100644 --- a/src/builtin/tags/if.ts +++ b/src/builtin/tags/if.ts @@ -1,4 +1,4 @@ -import { Value, Emitter, isTruthy, TagToken, TopLevelToken, Context, Template, TagImplOptions, ParseStream } from '../../types' +import { Value, Emitter, isTruthy, TagToken, TopLevelToken, Context, Template, TagImplOptions } from '../../types' export default { parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) { @@ -6,34 +6,29 @@ export default { this.elseTemplates = [] let p - const stream: ParseStream = this.liquid.parser.parseStream(remainTokens) + this.liquid.parser.parseStream(remainTokens) .on('start', () => this.branches.push({ - cond: new Value(tagToken.args, this.liquid), + predicate: new Value(tagToken.args, this.liquid), + templates: (p = []) + })) + .on('tag:elsif', (token: TagToken) => this.branches.push({ + predicate: new Value(token.args, this.liquid), templates: (p = []) })) - .on('tag:elsif', (token: TagToken) => { - this.branches.push({ - cond: new Value(token.args, this.liquid), - templates: p = [] - }) - }) .on('tag:else', () => (p = this.elseTemplates)) - .on('tag:endif', () => stream.stop()) + .on('tag:endif', function () { this.stop() }) .on('template', (tpl: Template) => p.push(tpl)) - .on('end', () => { - throw new Error(`tag ${tagToken.getText()} not closed`) - }) - - stream.start() + .on('end', () => { throw new Error(`tag ${tagToken.getText()} not closed`) }) + .start() }, render: function * (ctx: Context, emitter: Emitter) { const r = this.liquid.renderer - for (const branch of this.branches) { - const cond = yield branch.cond.value(ctx, ctx.opts.lenientIf) - if (isTruthy(cond, ctx)) { - yield r.renderTemplates(branch.templates, ctx, emitter) + for (const { predicate, templates } of this.branches) { + const value = yield predicate.value(ctx, ctx.opts.lenientIf) + if (isTruthy(value, ctx)) { + yield r.renderTemplates(templates, ctx, emitter) return } } diff --git a/src/builtin/tags/render.ts b/src/builtin/tags/render.ts index e281d66f7..3b90a0459 100644 --- a/src/builtin/tags/render.ts +++ b/src/builtin/tags/render.ts @@ -18,8 +18,10 @@ export default { const keyword = tokenizer.readIdentifier() if (keyword.content === 'with' || keyword.content === 'for') { tokenizer.skipBlank() + // can be normal key/value pair, like "with: true" if (tokenizer.peek() !== ':') { const value = tokenizer.readValue() + // can be normal key, like "with," if (value) { const beforeAs = tokenizer.p const asStr = tokenizer.readIdentifier() @@ -30,10 +32,14 @@ export default { this[keyword.content] = { value, alias: alias && alias.content } tokenizer.skipBlank() if (tokenizer.peek() === ',') tokenizer.advance() + // matched! continue } } } + /** + * restore cursor if with/for not matched + */ tokenizer.p = begin break } @@ -83,14 +89,11 @@ export function parseFilePath (tokenizer: Tokenizer, liquid: Liquid): ParsedFile const file = tokenizer.readValue() if (file === undefined) throw new TypeError(`illegal argument "${tokenizer.input}"`) if (file.getText() === 'none') return null - // for filenames like "files/{{file}}", eval as liquid template if (TypeGuards.isQuotedToken(file)) { + // for filenames like "files/{{file}}", eval as liquid template const tpls = liquid.parse(evalQuotedToken(file)) // for filenames like "files/file.liquid", extract the string directly - if (tpls.length === 1) { - const first = tpls[0] - if (TypeGuards.isHTMLToken(first)) return first.getText() - } + if (tpls.length === 1 && TypeGuards.isHTMLToken(tpls[0].token)) return tpls[0].token.getContent() return tpls } return file diff --git a/src/builtin/tags/unless.ts b/src/builtin/tags/unless.ts index 442a75827..54ed7d63e 100644 --- a/src/builtin/tags/unless.ts +++ b/src/builtin/tags/unless.ts @@ -1,45 +1,35 @@ -import { Value, TopLevelToken, Template, Emitter, isTruthy, isFalsy, ParseStream, Context, TagImplOptions, TagToken } from '../../types' +import { Value, TopLevelToken, Template, Emitter, isTruthy, isFalsy, Context, TagImplOptions, TagToken } from '../../types' export default { parse: function (tagToken: TagToken, remainTokens: TopLevelToken[]) { - this.templates = [] this.branches = [] this.elseTemplates = [] let p - const stream: ParseStream = this.liquid.parser.parseStream(remainTokens) - .on('start', () => { - p = this.templates - this.cond = new Value(tagToken.args, this.liquid) - }) - .on('tag:elsif', (token: TagToken) => { - this.branches.push({ - cond: new Value(token.args, this.liquid), - templates: p = [] - }) - }) + this.liquid.parser.parseStream(remainTokens) + .on('start', () => this.branches.push({ + predicate: new Value(tagToken.args, this.liquid), + test: isFalsy, + templates: (p = []) + })) + .on('tag:elsif', (token: TagToken) => this.branches.push({ + predicate: new Value(token.args, this.liquid), + test: isTruthy, + templates: (p = []) + })) .on('tag:else', () => (p = this.elseTemplates)) - .on('tag:endunless', () => stream.stop()) + .on('tag:endunless', function () { this.stop() }) .on('template', (tpl: Template) => p.push(tpl)) - .on('end', () => { - throw new Error(`tag ${tagToken.getText()} not closed`) - }) - - stream.start() + .on('end', () => { throw new Error(`tag ${tagToken.getText()} not closed`) }) + .start() }, render: function * (ctx: Context, emitter: Emitter) { const r = this.liquid.renderer - const cond = yield this.cond.value(ctx, ctx.opts.lenientIf) - if (isFalsy(cond, ctx)) { - yield r.renderTemplates(this.templates, ctx, emitter) - return - } - - for (const branch of this.branches) { - const cond = yield branch.cond.value(ctx, ctx.opts.lenientIf) - if (isTruthy(cond, ctx)) { - yield r.renderTemplates(branch.templates, ctx, emitter) + for (const { predicate, test, templates } of this.branches) { + const value = yield predicate.value(ctx, ctx.opts.lenientIf) + if (test(value, ctx)) { + yield r.renderTemplates(templates, ctx, emitter) return } } diff --git a/src/parser/parse-stream.ts b/src/parser/parse-stream.ts index 5c7833907..7543f2125 100644 --- a/src/parser/parse-stream.ts +++ b/src/parser/parse-stream.ts @@ -15,13 +15,13 @@ export class ParseStream { this.tokens = tokens this.parseToken = parseToken } - public on (name: string, cb: (arg: T2) => void): ParseStream { + public on (name: string, cb: (this: ParseStream, arg: T2) => void): ParseStream { this.handlers[name] = cb return this } private trigger (event: string, arg?: T) { const h = this.handlers[event] - return h ? (h(arg), true) : false + return h ? (h.call(this, arg), true) : false } public start () { this.trigger('start') diff --git a/test/integration/builtin/filters/date.ts b/test/integration/builtin/filters/date.ts index 73d7a269d..bc50815b5 100644 --- a/test/integration/builtin/filters/date.ts +++ b/test/integration/builtin/filters/date.ts @@ -30,6 +30,9 @@ describe('filters/date', function () { it('should apply numeric timezone offset (+2.30)', function () { return test('{{ "1990-12-31T23:00:00+02:30" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T23:00:00', undefined, opts) }) + it('should automatically work when timezone not specified', function () { + return test('{{ "1990-12-31T23:00:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T23:00:00', undefined, opts) + }) }) it('should render string as string if not valid', function () { return test('{{ "foo" | date: "%Y"}}', 'foo') diff --git a/test/integration/builtin/tags/layout.ts b/test/integration/builtin/tags/layout.ts index 4fbd21c4e..0be2d30a8 100644 --- a/test/integration/builtin/tags/layout.ts +++ b/test/integration/builtin/tags/layout.ts @@ -191,6 +191,15 @@ describe('tags/layout', function () { return expect(html).to.equal('blackA') }) + it('should support none', async function () { + mock({ + '/main.html': '{% layout none %}foo' + }) + const staticLiquid = new Liquid({ root: '/', dynamicPartials: false }) + const html = await staticLiquid.renderFile('/main.html') + return expect(html).to.equal('foo') + }) + it('should support subpaths', async function () { mock({ '/foo/parent.html': '{{color}}{%block%}{%endblock%}', diff --git a/test/integration/builtin/tags/render.ts b/test/integration/builtin/tags/render.ts index d7314786a..e05ec7c0c 100644 --- a/test/integration/builtin/tags/render.ts +++ b/test/integration/builtin/tags/render.ts @@ -1,6 +1,8 @@ import { Liquid, Drop } from '../../../../src/liquid' -import { expect } from 'chai' +import { expect, use } from 'chai' import { mock, restore } from '../../../stub/mockfs' +import * as chaiAsPromised from 'chai-as-promised' +use(chaiAsPromised) describe('tags/render', function () { let liquid: Liquid @@ -93,6 +95,22 @@ describe('tags/render', function () { const html = await liquid.renderFile('with.html') expect(html).to.equal('color:red, shape:rect') }) + it('should treat as normal key/value if followed by ":"', async () => { + mock({ + '/with.html': '{% render "color" with: "foo" %}', + '/color.html': 'color:{{color}}, with:{{with}}' + }) + const html = await liquid.renderFile('with.html') + expect(html).to.equal('color:, with:foo') + }) + it('should treat as normal key if with value not specified', async () => { + mock({ + '/with.html': '{% render "color" with, shape: "rect" %}', + '/color.html': 'color:{{color}}, with:{{with}}, shape:{{shape}}' + }) + const html = await liquid.renderFile('with.html') + expect(html).to.equal('color:, with:true, shape:rect') + }) it('should support with...as', async function () { mock({ '/with.html': '{% render "color" with color as c %}', @@ -271,7 +289,7 @@ describe('tags/render', function () { const html = liquid.renderFileSync('with.html') expect(html).to.equal('color:red, shape:rect') }) - it('should support filename with extention', function () { + it('should support filename with extension', function () { mock({ '/parent.html': 'X{% render child.html color:"red" %}Y', '/child.html': 'child with {{color}}' diff --git a/test/integration/liquid/fs-option.ts b/test/integration/liquid/fs-option.ts index be68f35ae..83b9ed9bf 100644 --- a/test/integration/liquid/fs-option.ts +++ b/test/integration/liquid/fs-option.ts @@ -1,5 +1,7 @@ -import { expect } from 'chai' +import { expect, use } from 'chai' import { Liquid } from '../../../src/liquid' +import * as chaiAsPromised from 'chai-as-promised' +use(chaiAsPromised) describe('LiquidOptions#fs', function () { let engine: Liquid @@ -31,4 +33,13 @@ describe('LiquidOptions#fs', function () { const html = engine.renderFileSync('notexist/foo') expect(html).to.equal('content for /root/files/fallback') }) + + it('should throw lookup failure if fallback not specified', function () { + const engine = new Liquid({ + root: '/root/', + fs: { ...fs, fallback: undefined } + } as any) + return expect(engine.renderFile('notexist/foo')) + .to.be.rejectedWith('Failed to lookup') + }) }) diff --git a/test/integration/liquid/liquid.ts b/test/integration/liquid/liquid.ts index c48f49ba2..55bf67ded 100644 --- a/test/integration/liquid/liquid.ts +++ b/test/integration/liquid/liquid.ts @@ -130,4 +130,13 @@ describe('Liquid', function () { .to.throw(/Failed to lookup "\/not\/exist.html" in "\/boo,\/root\/"/) }) }) + describe('#enderToNodeStream', function () { + const engine = new Liquid() + it('should render a simple value', function (done) { + const stream = engine.renderToNodeStream(engine.parse('{{"foo"}}')) + let html = '' + stream.on('data', data => { html += data }) + stream.on('end', () => { expect(html).to.equal('foo'); done() }) + }) + }) }) diff --git a/test/unit/liquid-options.ts b/test/unit/liquid-options.ts new file mode 100644 index 000000000..df4ace548 --- /dev/null +++ b/test/unit/liquid-options.ts @@ -0,0 +1,15 @@ +import { normalize } from '../../src/liquid-options' +import { expect } from 'chai' + +describe('liquid-options', () => { + describe('.normalize()', () => { + it('should return plain object for empty input', () => { + const options = normalize() + expect(JSON.stringify(options)).to.equal('{}') + }) + it('should set falsy cache to undefined', () => { + const options = normalize({ cache: false }) + expect(JSON.stringify(options)).to.equal('{}') + }) + }) +})