From fde9924ee622efae4c013d2aa01c6d705c8d5f46 Mon Sep 17 00:00:00 2001 From: James Prior Date: Sun, 19 Dec 2021 09:17:29 +0000 Subject: [PATCH] feat: implement `liquid` and `echo` tags, see #428 --- src/builtin/tags/echo.ts | 13 +++++ src/builtin/tags/index.ts | 4 +- src/builtin/tags/liquid.ts | 14 +++++ src/parser/tokenizer.ts | 19 +++++++ src/tokens/liquid-tag-token.ts | 33 +++++++++++ test/e2e/issues.ts | 17 ++++++ test/integration/builtin/tags/echo.ts | 43 ++++++++++++++ test/integration/builtin/tags/liquid.ts | 76 +++++++++++++++++++++++++ test/unit/parser/tokenizer.ts | 25 ++++++++ 9 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 src/builtin/tags/echo.ts create mode 100644 src/builtin/tags/liquid.ts create mode 100644 src/tokens/liquid-tag-token.ts create mode 100644 test/integration/builtin/tags/echo.ts create mode 100644 test/integration/builtin/tags/liquid.ts diff --git a/src/builtin/tags/echo.ts b/src/builtin/tags/echo.ts new file mode 100644 index 0000000000..fee2b81ab4 --- /dev/null +++ b/src/builtin/tags/echo.ts @@ -0,0 +1,13 @@ +import { Value } from '../../template/value' +import { Emitter } from '../../emitters/emitter' +import { TagImplOptions, TagToken, Context } from '../../types' + +export default { + parse: function (token: TagToken) { + this.value = new Value(token.args, this.liquid) + }, + render: function * (ctx: Context, emitter: Emitter): Generator { + const val = yield this.value.value(ctx, false) + emitter.write(val) + } +} as TagImplOptions diff --git a/src/builtin/tags/index.ts b/src/builtin/tags/index.ts index cb6a322beb..7951ea8c91 100644 --- a/src/builtin/tags/index.ts +++ b/src/builtin/tags/index.ts @@ -16,10 +16,12 @@ import tablerow from './tablerow' import unless from './unless' import Break from './break' import Continue from './continue' +import echo from './echo' +import liquid from './liquid' import { TagImplOptions } from '../../template/tag/tag-impl-options' const tags: { [key: string]: TagImplOptions } = { - assign, 'for': For, capture, 'case': Case, comment, include, render, 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, echo, liquid } export default tags diff --git a/src/builtin/tags/liquid.ts b/src/builtin/tags/liquid.ts new file mode 100644 index 0000000000..55294254c3 --- /dev/null +++ b/src/builtin/tags/liquid.ts @@ -0,0 +1,14 @@ +import { Emitter } from '../../emitters/emitter' +import { TagImplOptions, TagToken, Context } from '../../types' +import { Tokenizer } from '../../parser/tokenizer' + +export default { + parse: function (token: TagToken) { + const tokenizer = new Tokenizer(token.args, this.liquid.options.operatorsTrie) + const tokens = tokenizer.readLiquidTagTokens(this.liquid.options) + this.tpls = this.liquid.parser.parseTokens(tokens) + }, + render: function * (ctx: Context, emitter: Emitter): Generator { + yield this.liquid.renderer.renderTemplates(this.tpls, ctx, emitter) + } +} as TagImplOptions diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index 5641088311..7117f73579 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -24,6 +24,7 @@ import { TYPES, QUOTE, BLANK, IDENTIFIER } from '../util/character' import { matchOperator } from './match-operator' import { Trie } from '../util/operator-trie' import { Expression } from '../render/expression' +import { LiquidTagToken } from '../tokens/liquid-tag-token' export class Tokenizer { p = 0 @@ -191,6 +192,24 @@ export class Tokenizer { throw this.mkError(`raw ${this.snapshot(this.rawBeginAt)} not closed`, begin) } + readLiquidTagTokens (options: NormalizedFullOptions = defaultOptions): LiquidTagToken[] { + const tokens: LiquidTagToken[] = [] + while (this.p < this.N) { + const token = this.readLiquidTagToken(options) + if (token.name) tokens.push(token) + } + return tokens + } + + readLiquidTagToken (options: NormalizedFullOptions): LiquidTagToken { + const { file, input } = this + const begin = this.p + let end = this.N + if (this.readToDelimiter('\n') !== -1) end = this.p + const token = new LiquidTagToken(input, begin, end, options, file) + return token + } + mkError (msg: string, begin: number) { return new TokenizationError(msg, new IdentifierToken(this.input, begin, this.N, this.file)) } diff --git a/src/tokens/liquid-tag-token.ts b/src/tokens/liquid-tag-token.ts new file mode 100644 index 0000000000..d3681e1bc6 --- /dev/null +++ b/src/tokens/liquid-tag-token.ts @@ -0,0 +1,33 @@ +import { DelimitedToken } from './delimited-token' +import { TokenizationError } from '../util/error' +import { NormalizedFullOptions } from '../liquid-options' +import { TokenKind } from '../parser/token-kind' +import { Tokenizer } from '../parser/tokenizer' + +export class LiquidTagToken extends DelimitedToken { + public name: string + public args: string + public constructor ( + input: string, + begin: number, + end: number, + options: NormalizedFullOptions, + file?: string + ) { + const value = input.slice(begin, end) + super(TokenKind.Tag, value, input, begin, end, false, false, file) + + if (!/\S/.test(value)) { + // A line that contains only whitespace. + this.name = '' + this.args = '' + } else { + const tokenizer = new Tokenizer(this.content, options.operatorsTrie) + this.name = tokenizer.readIdentifier().getText() + if (!this.name) throw new TokenizationError(`illegal liquid tag syntax`, this) + + tokenizer.skipBlank() + this.args = tokenizer.remaining() + } + } +} diff --git a/test/e2e/issues.ts b/test/e2e/issues.ts index e8a7698a86..a6dcc4f422 100644 --- a/test/e2e/issues.ts +++ b/test/e2e/issues.ts @@ -177,4 +177,21 @@ describe('Issues', function () { const html = await engine.render(tpl, { my_variable: 'foo' }) expect(html).to.equal('CONTENT for /tmp/prefix/foo-bar/suffix') }) + it('#428 Implement liquid/echo tags', () => { + const template = `{%- liquid + for value in array + assign double_value = value | times: 2 + echo double_value | times: 2 + unless forloop.last + echo '#' + endunless + endfor + + echo '#' + echo double_value + -%}` + const engine = new Liquid() + const html = engine.parseAndRenderSync(template, { array: [1, 2, 3] }) + expect(html).to.equal('4#8#12#6') + }) }) diff --git a/test/integration/builtin/tags/echo.ts b/test/integration/builtin/tags/echo.ts new file mode 100644 index 0000000000..a10b31638a --- /dev/null +++ b/test/integration/builtin/tags/echo.ts @@ -0,0 +1,43 @@ +import { Liquid } from '../../../../src/liquid' +import { expect, use } from 'chai' +import * as chaiAsPromised from 'chai-as-promised' + +use(chaiAsPromised) + +describe('tags/echo', function () { + const liquid = new Liquid() + + it('should output literals', async function () { + const src = '{% echo 1 %} {% echo "1" %} {% echo 1.1 %}' + const html = await liquid.parseAndRender(src) + return expect(html).to.equal('1 1 1.1') + }) + + it('should output variables', async function () { + const src = '{% echo people.users[0].name %}' + const html = await liquid.parseAndRender(src, { people: { users: [ { name: 'Sally' } ] } }) + return expect(html).to.equal('Sally') + }) + + it('should apply filters before output', async function () { + const src = '{% echo user.name | upcase | prepend: "Hello, " | append: "!" %}' + const html = await liquid.parseAndRender(src, { user: { name: 'Sally' } }) + return expect(html).to.equal('Hello, SALLY!') + }) + + it('should handle empty tag', async function () { + const src = '{% echo %}' + const html = await liquid.parseAndRender(src) + return expect(html).to.equal('') + }) + + it('should handle extra whitespace', async function () { + const src = `{% echo + user.name + | upcase | prepend: + "Hello, " | append: "!" + %}` + const html = await liquid.parseAndRender(src, { user: { name: 'Sally' } }) + return expect(html).to.equal('Hello, SALLY!') + }) +}) diff --git a/test/integration/builtin/tags/liquid.ts b/test/integration/builtin/tags/liquid.ts new file mode 100644 index 0000000000..fbc859cf48 --- /dev/null +++ b/test/integration/builtin/tags/liquid.ts @@ -0,0 +1,76 @@ +import { Liquid } from '../../../../src/liquid' +import { expect, use } from 'chai' +import * as chaiAsPromised from 'chai-as-promised' + +use(chaiAsPromised) + +describe('tags/liquid', function () { + const liquid = new Liquid() + + it('should support shorthand syntax', async function () { + const src = ` + {%- liquid + for value in array + echo value + unless forloop.last + echo '#' + endunless + endfor + -%} + ` + const html = await liquid.parseAndRender(src, { array: [1, 2, 3] }) + return expect(html).to.equal('1#2#3') + }) + + it('should support shorthand syntax with assignments and filters', async function () { + const src = ` + {%- liquid + for value in array + assign double_value = value | times: 2 + echo double_value | times: 2 + unless forloop.last + echo '#' + endunless + endfor + + echo '#' + echo double_value + -%} + ` + const html = await liquid.parseAndRender(src, { array: [1, 2, 3] }) + return expect(html).to.equal('4#8#12#6') + }) + + it('should handle empty tag', async function () { + const src = '{% liquid %}' + const html = await liquid.parseAndRender(src) + return expect(html).to.equal('') + }) + + it('should handle lines containing only whitespace', async function () { + const src = `{% liquid + echo 'hello ' + + + \t + echo 'goodbye' + %}` + const html = await liquid.parseAndRender(src) + return expect(html).to.equal('hello goodbye') + }) + + it('should fail with carriage return terminated tags', async function () { + const src = [ + '{%- liquid', + ' for value in array', + ' echo value', + ' unless forloop.last', + ' echo "#"', + ' endunless', + 'endfor', + '-%}' + ].join('\r') + return expect(liquid.parseAndRender(src)) + .to.be.rejectedWith(/not closed/) + }) +}) diff --git a/test/unit/parser/tokenizer.ts b/test/unit/parser/tokenizer.ts index 5d413cdc8b..2acf7efbcf 100644 --- a/test/unit/parser/tokenizer.ts +++ b/test/unit/parser/tokenizer.ts @@ -11,6 +11,7 @@ import { OutputToken } from '../../../src/tokens/output-token' import { HTMLToken } from '../../../src/tokens/html-token' import { createTrie } from '../../../src/util/operator-trie' import { defaultOperators } from '../../../src/types' +import { LiquidTagToken } from '../../../src/tokens/liquid-tag-token' describe('Tokenizer', function () { const trie = createTrie(defaultOperators) @@ -500,4 +501,28 @@ describe('Tokenizer', function () { expect(rhs.getText()).to.deep.equal('"\\""') }) }) + describe('#readLiquidTagTokens', () => { + it('should read newline terminated tokens', () => { + const tokenizer = new Tokenizer('echo \'hello\'', trie) + const tokens = tokenizer.readLiquidTagTokens() + expect(tokens.length).to.equal(1) + const tag = tokens[0] + expect(tag).instanceOf(LiquidTagToken) + expect(tag.name).to.equal('echo') + expect(tag.args).to.equal('\'hello\'') + }) + it('should gracefully handle empty lines', () => { + const tokenizer = new Tokenizer(` + echo 'hello' + + decrement foo + `, trie) + const tokens = tokenizer.readLiquidTagTokens() + expect(tokens.length).to.equal(2) + }) + it('should throw if line does not start with an identifier', () => { + const tokenizer = new Tokenizer('!', trie) + expect(() => tokenizer.readLiquidTagTokens()).to.throw(/illegal liquid tag syntax/) + }) + }) })