diff --git a/package-lock.json b/package-lock.json index a3d151f90..6363b7379 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9145,21 +9145,6 @@ "acorn": "^6.0.5" } }, - "rollup-plugin-alias": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-alias/-/rollup-plugin-alias-1.5.1.tgz", - "integrity": "sha512-pQTYBRNfLedoVOO7AYHNegIavEIp4jKTga5jUi1r//KYgHKGWgG4qJXYhbcWKt2k1FwGlR5wCYoY+IFkme0t4A==", - "dev": true, - "requires": { - "slash": "^2.0.0" - } - }, - "rollup-plugin-import-alias": { - "version": "1.0.6", - "resolved": "http://registry.npm.taobao.org/rollup-plugin-import-alias/download/rollup-plugin-import-alias-1.0.6.tgz", - "integrity": "sha1-iywc3ujjnuRmOXcrAI3gppRzyT8=", - "dev": true - }, "rollup-plugin-replace": { "version": "2.1.0", "resolved": "http://registry.npm.taobao.org/rollup-plugin-replace/download/rollup-plugin-replace-2.1.0.tgz", diff --git a/src/liquid.ts b/src/liquid.ts index 4b9989f9b..2acf4d38f 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -6,7 +6,7 @@ import ITemplate from './template/itemplate' import Tokenizer from './parser/tokenizer' import Render from './render/render' import Tag from './template/tag/tag' -import Filter from './template/filter/filter' +import { Filter } from './template/filter/filter' import Parser from './parser/parser' import ITagImplOptions from './template/tag/itag-impl-options' import Value from './template/value' diff --git a/src/render/syntax.ts b/src/render/syntax.ts index 53872a129..b4041b457 100644 --- a/src/render/syntax.ts +++ b/src/render/syntax.ts @@ -77,7 +77,7 @@ export function evalExp (str: string, scope: Scope): any { return value instanceof Drop ? value.valueOf() : value } -function parseValue (str: string, scope: Scope): any { +function parseValue (str: string | undefined, scope: Scope): any { if (!str) return null str = str.trim() @@ -91,7 +91,7 @@ function parseValue (str: string, scope: Scope): any { return scope.get(str) } -export function evalValue (str: string, scope: Scope): any { +export function evalValue (str: string | undefined, scope: Scope): any { const value = parseValue(str, scope) return value instanceof Drop ? value.valueOf() : value } diff --git a/src/template/filter/filter.ts b/src/template/filter/filter.ts index 212d7800e..a06c4f658 100644 --- a/src/template/filter/filter.ts +++ b/src/template/filter/filter.ts @@ -1,14 +1,17 @@ import { evalValue } from '../../render/syntax' import Scope from '../../scope/scope' +import { isArray } from '../../util/underscore' import { FilterImpl } from './filter-impl' -export default class Filter { +export type FilterArgs = Array + +export class Filter { name: string impl: FilterImpl - args: string[] + args: FilterArgs private static impls: {[key: string]: FilterImpl} = {} - constructor (name: string, args: string[], strictFilters: boolean) { + constructor (name: string, args: FilterArgs, strictFilters: boolean) { const impl = Filter.impls[name] if (!impl && strictFilters) throw new TypeError(`undefined filter: ${name}`) @@ -17,7 +20,7 @@ export default class Filter { this.args = args } render (value: any, scope: Scope): any { - const args = this.args.map(arg => evalValue(arg, scope)) + const args = this.args.map(arg => isArray(arg) ? [arg[0], evalValue(arg[1], scope)] : evalValue(arg, scope)) return this.impl.apply(null, [value, ...args]) } static register (name: string, filter: FilterImpl) { diff --git a/src/template/value.ts b/src/template/value.ts index 5a2cb1af6..a590e4631 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -1,80 +1,76 @@ import { evalExp } from '../render/syntax' -import Filter from './filter/filter' +import { FilterArgs, Filter } from './filter/filter' import Scope from '../scope/scope' -enum ParseState { - INIT = 0, - FILTER_NAME = 1, - FILTER_ARG = 2 -} - -export default class { - initial: any +export default class Value { + private strictFilters: boolean + initial: string filters: Array = [] /** * @param str value string, like: "i have a dream | truncate: 3 */ constructor (str: string, strictFilters: boolean) { - let buffer = '' - let quoted = '' - let state = ParseState.INIT - let sealed = false - - let filterName = '' - let filterArgs: string[] = [] - - for (let i = 0; i < str.length; i++) { - if (quoted) { - if (str[i] === quoted) { - quoted = '' - sealed = true + const tokens = Value.tokenize(str) + this.strictFilters = strictFilters + this.initial = tokens[0] + this.parseFilters(tokens, 1) + } + private parseFilters (tokens: string[], begin: number) { + let i = begin + while (i < tokens.length) { + if (tokens[i] !== '|') { + i++ + continue + } + const j = ++i + while (i < tokens.length && tokens[i] !== '|') i++ + this.parseFilter(tokens, j, i) + } + } + private parseFilter (tokens: string[], begin: number, end: number) { + const name = tokens[begin] + const args: FilterArgs = [] + let argName, argValue + for (let i = begin + 1; i < end + 1; i++) { + if (i === end || tokens[i] === ',') { + if (argName || argValue) { + args.push(argName ? [argName, argValue] : argValue) } - buffer += str[i] - } else if (/\s/.test(str[i])) { - if (!buffer) continue - else sealed = true - } else if (str[i] === '|') { - if (state === ParseState.INIT) { - this.initial = buffer - } else { - if (state === ParseState.FILTER_NAME) filterName = buffer - else filterArgs.push(buffer) - this.filters.push(new Filter(filterName, filterArgs, strictFilters)) - filterName = '' - filterArgs = [] - } - state = ParseState.FILTER_NAME - buffer = '' - sealed = false - } else if (state === ParseState.FILTER_NAME && str[i] === ':') { - filterName = buffer - state = ParseState.FILTER_ARG - buffer = '' - sealed = false - } else if (state === ParseState.FILTER_ARG && str[i] === ',') { - filterArgs.push(buffer) - buffer = '' - sealed = false - } else if (sealed) continue - else { - if ((str[i] === '"' || str[i] === "'") && !quoted) quoted = str[i] - buffer += str[i] - } - } - - if (buffer) { - if (state === ParseState.INIT) this.initial = buffer - else if (state === ParseState.FILTER_NAME) this.filters.push(new Filter(buffer, [], strictFilters)) - else { - filterArgs.push(buffer) - this.filters.push(new Filter(filterName, filterArgs, strictFilters)) + argValue = argName = undefined + } else if (tokens[i] === ':') { + argName = argValue + argValue = undefined + } else if (argValue === undefined) { + argValue = tokens[i] } } + this.filters.push(new Filter(name, args, this.strictFilters)) } value (scope: Scope) { return this.filters.reduce( (prev, filter) => filter.render(prev, scope), evalExp(this.initial, scope)) } + static tokenize (str: string): Array<'|' | ',' | ':' | string> { + const tokens = [] + let i = 0 + while (i < str.length) { + const ch = str[i] + if (ch === '"' || ch === "'") { + const j = i + for (i += 2; i < str.length && str[i - 1] !== ch; ++i); + tokens.push(str.slice(j, i)) + } else if (/\s/.test(ch)) { + i++ + } else if (/[|,:]/.test(ch)) { + tokens.push(str[i++]) + } else { + const j = i++ + for (; i < str.length && !/[|,:\s]/.test(str[i]); ++i); + tokens.push(str.slice(j, i)) + } + } + return tokens + } } diff --git a/test/integration/builtin/filters/custom.ts b/test/integration/builtin/filters/custom.ts new file mode 100644 index 000000000..6371c1d37 --- /dev/null +++ b/test/integration/builtin/filters/custom.ts @@ -0,0 +1,15 @@ +import { test, liquid } from '../../../stub/render' + +describe('filters/custom', function () { + liquid.registerFilter('obj_test', function () { + return JSON.stringify(arguments) + }) + it('should support object', () => test( + `{{ "a" | obj_test: k1: "v1", k2: foo }}`, + '{"0":"a","1":["k1","v1"],"2":["k2","bar"]}' + )) + it('should support mixed object', () => test( + `{{ "a" | obj_test: "something", k1: "v1", k2: foo }}`, + '{"0":"a","1":"something","2":["k1","v1"],"3":["k2","bar"]}' + )) +}) diff --git a/test/unit/render/render.ts b/test/unit/render/render.ts index 214ce0221..56f8cd6b2 100644 --- a/test/unit/render/render.ts +++ b/test/unit/render/render.ts @@ -2,7 +2,7 @@ import { expect } from 'chai' import Scope from '../../../src/scope/scope' import Token from '../../../src/parser/token' import Tag from '../../../src/template/tag/tag' -import Filter from '../../../src/template/filter/filter' +import { Filter } from '../../../src/template/filter/filter' import Render from '../../../src/render/render' import HTML from '../../../src/template/html' diff --git a/test/unit/template/filter/filter.ts b/test/unit/template/filter/filter.ts index 48d9da764..86dab239a 100644 --- a/test/unit/template/filter/filter.ts +++ b/test/unit/template/filter/filter.ts @@ -1,7 +1,7 @@ import * as chai from 'chai' import * as sinon from 'sinon' import * as sinonChai from 'sinon-chai' -import Filter from '../../../../src/template/filter/filter' +import { Filter } from '../../../../src/template/filter/filter' import Scope from '../../../../src/scope/scope' chai.use(sinonChai) diff --git a/test/unit/template/output.ts b/test/unit/template/output.ts index c35e3e5e5..5f2900841 100644 --- a/test/unit/template/output.ts +++ b/test/unit/template/output.ts @@ -2,7 +2,7 @@ import * as chai from 'chai' import Scope from '../../../src/scope/scope' import Output from '../../../src/template/output' import OutputToken from '../../../src/parser/output-token' -import Filter from '../../../src/template/filter/filter' +import { Filter } from '../../../src/template/filter/filter' const expect = chai.expect diff --git a/test/unit/template/value.ts b/test/unit/template/value.ts index aa6c288ed..6f93be3ba 100644 --- a/test/unit/template/value.ts +++ b/test/unit/template/value.ts @@ -2,7 +2,7 @@ import * as chai from 'chai' import * as sinonChai from 'sinon-chai' import * as sinon from 'sinon' import Scope from '../../../src/scope/scope' -import Filter from '../../../src/template/filter/filter' +import { Filter } from '../../../src/template/filter/filter' import Value from '../../../src/template/value' chai.use(sinonChai) @@ -12,56 +12,99 @@ const expect = chai.expect describe('Value', function () { beforeEach(() => Filter.clear()) - it('should parse "foo', function () { - const tpl: any = new Value('foo', false) - expect(tpl.initial).to.equal('foo') - expect(tpl.filters).to.deep.equal([]) - }) - - it('should parse "foo | add"', function () { - const tpl: any = new Value('foo | add', false) - expect(tpl.initial).to.equal('foo') - expect(tpl.filters.length).to.equal(1) - expect(tpl.filters[0].args).to.eql([]) - }) - it('should parse "foo | add: "foo" bar, 3"', function () { - const tpl: any = new Value('foo | add: "foo" bar, 3', false) - expect(tpl.initial).to.equal('foo') - expect(tpl.filters.length).to.equal(1) - expect(tpl.filters[0].name).to.eql('add') - expect(tpl.filters[0].args).to.eql(['"foo"', '3']) - }) - it('should parse "foo | add: 3, false"', function () { - const tpl: any = new Value('foo | add: 3, "foo"', false) - expect(tpl.initial).to.equal('foo') - expect(tpl.filters.length).to.equal(1) - expect(tpl.filters[0].args).to.eql(['3', '"foo"']) - }) - it('should parse "foo | add: "|", 3', function () { - const tpl: any = new Value('foo | add: "|", 3', false) - expect(tpl.initial).to.equal('foo') - expect(tpl.filters.length).to.equal(1) - expect(tpl.filters[0].args).to.eql(['"|"', '3']) - }) - - it('should parse "foo | add: "|", 3', function () { - const tpl: any = new Value('foo | add: "|", 3', false) - expect(tpl.initial).to.equal('foo') - expect(tpl.filters.length).to.equal(1) - expect(tpl.filters[0].args).to.eql(['"|"', '3']) - }) - - it('should call chained filters correctly', function () { - const date = sinon.stub().returns('y') - const time = sinon.spy() - Filter.register('date', date) - Filter.register('time', time) - const tpl = new Value('foo.bar | date: "b" | time:2', false) - const scope = new Scope({ - foo: { bar: 'bar' } + describe('#constructor()', function () { + it('should parse "foo', function () { + const tpl = new Value('foo', false) + expect(tpl.initial).to.equal('foo') + expect(tpl.filters).to.deep.equal([]) + }) + + it('should parse "foo | add"', function () { + const tpl = new Value('foo | add', false) + expect(tpl.initial).to.equal('foo') + expect(tpl.filters.length).to.equal(1) + expect(tpl.filters[0].args).to.eql([]) + }) + it('should parse "foo | add: 3, false"', function () { + const tpl = new Value('foo | add: 3, "foo"', false) + expect(tpl.initial).to.equal('foo') + expect(tpl.filters.length).to.equal(1) + expect(tpl.filters[0].args).to.eql(['3', '"foo"']) + }) + it('should parse "foo | add: "foo" bar, 3"', function () { + const tpl = new Value('foo | add: "foo" bar, 3', false) + expect(tpl.initial).to.equal('foo') + expect(tpl.filters.length).to.equal(1) + expect(tpl.filters[0].name).to.eql('add') + expect(tpl.filters[0].args).to.eql(['"foo"', '3']) + }) + it('should parse "foo | add: "|", 3', function () { + const tpl = new Value('foo | add: "|", 3', false) + expect(tpl.initial).to.equal('foo') + expect(tpl.filters.length).to.equal(1) + expect(tpl.filters[0].args).to.eql(['"|"', '3']) + }) + it('should parse "foo | add: "|", 3', function () { + const tpl = new Value('foo | add: "|", 3', false) + expect(tpl.initial).to.equal('foo') + expect(tpl.filters.length).to.equal(1) + expect(tpl.filters[0].args).to.eql(['"|"', '3']) + }) + it('should support arguments as named key/values', function () { + const f = new Value('o | foo: key1: "literal1", key2: value2', false) + expect(f.filters[0].name).to.equal('foo') + expect(f.filters[0].args).to.eql([['key1', '"literal1"'], ['key2', 'value2']]) + }) + it('should support arguments as named key/values with inline literals', function () { + const f = new Value('o | foo: "test0", key1: "literal1", key2: value2', false) + expect(f.filters[0].name).to.equal('foo') + expect(f.filters[0].args).to.deep.equal(['"test0"', ['key1', '"literal1"'], ['key2', 'value2']]) + }) + it('should support arguments as named key/values with inline values', function () { + const f = new Value('o | foo: test0, key1: "literal1", key2: value2', false) + expect(f.filters[0].name).to.equal('foo') + expect(f.filters[0].args).to.deep.equal(['test0', ['key1', '"literal1"'], ['key2', 'value2']]) + }) + it('should support argument values named same as keys', function () { + const f = new Value('o | foo: a: a', false) + expect(f.filters[0].name).to.equal('foo') + expect(f.filters[0].args).to.deep.equal([['a', 'a']]) + }) + it('should support argument literals named same as keys', function () { + const f = new Value('o | foo: a: "a"', false) + expect(f.filters[0].name).to.equal('foo') + expect(f.filters[0].args).to.deep.equal([['a', '"a"']]) + }) + }) + + describe('#tokenize()', function () { + it('should tokenize a simple value', function () { + expect(Value.tokenize('foo')).to.eql(['foo']) + }) + it('should tokenize a value with spaces', function () { + expect(Value.tokenize(' foo \t')).to.eql(['foo']) + }) + it('should tokenize a simple filter', function () { + expect(Value.tokenize('foo | add')).to.eql(['foo', '|', 'add']) + }) + it('should tokenize a filter with a single argument', function () { + expect(Value.tokenize('foo | add: 1')).to.eql(['foo', '|', 'add', ':', '1']) + }) + }) + + describe('#value()', function () { + it('should call chained filters correctly', function () { + const date = sinon.stub().returns('y') + const time = sinon.spy() + Filter.register('date', date) + Filter.register('time', time) + const tpl = new Value('foo.bar | date: "b" | time:2', false) + const scope = new Scope({ + foo: { bar: 'bar' } + }) + tpl.value(scope) + expect(date).to.have.been.calledWith('bar', 'b') + expect(time).to.have.been.calledWith('y', 2) }) - tpl.value(scope) - expect(date).to.have.been.calledWith('bar', 'b') - expect(time).to.have.been.calledWith('y', 2) }) })