From 6ea6881f08769e482cb84e2f21174a5d95990cdd Mon Sep 17 00:00:00 2001 From: harttle Date: Wed, 4 Mar 2020 02:29:25 +0800 Subject: [PATCH] feat: with & for in `render` tag, closes #195 --- bin/char-types.js | 43 +++ bin/liquid.js | 6 +- src/builtin/tags/assign.ts | 2 +- src/builtin/tags/block.ts | 4 +- src/builtin/tags/break.ts | 4 +- src/builtin/tags/capture.ts | 2 +- src/builtin/tags/case.ts | 4 +- src/builtin/tags/continue.ts | 4 +- src/builtin/tags/cycle.ts | 4 +- src/builtin/tags/decrement.ts | 4 +- src/builtin/tags/for.ts | 28 +- src/builtin/tags/if.ts | 4 +- src/builtin/tags/include.ts | 60 ++-- src/builtin/tags/increment.ts | 4 +- src/builtin/tags/layout.ts | 45 ++- src/builtin/tags/render.ts | 85 +++--- src/builtin/tags/tablerow.ts | 12 +- src/builtin/tags/unless.ts | 4 +- src/cache/cache.ts | 5 + src/cache/lru.ts | 67 +++++ src/context/context.ts | 8 +- src/fs/browser.ts | 13 +- src/fs/{ifs.ts => fs.ts} | 2 +- src/fs/node.ts | 59 ++-- src/liquid-options.ts | 21 +- src/liquid.ts | 18 +- src/parser/delimited-token.ts | 10 +- src/parser/expression-tokenizer.ts | 50 ---- src/parser/filter-arg.ts | 9 + src/parser/filter-token.ts | 17 ++ src/parser/html-token.ts | 2 +- src/parser/lexical.ts | 14 - src/parser/tag-token.ts | 2 +- src/parser/token.ts | 23 +- src/parser/tokenizer.ts | 340 ++++++++++++++++++----- src/parser/whitespace-ctrl.ts | 4 +- src/render/expression.ts | 5 +- src/render/operator.ts | 4 +- src/render/range.ts | 4 +- src/template/filter/filter-map.ts | 5 +- src/template/filter/filter.ts | 15 +- src/template/html.ts | 2 +- src/template/output.ts | 2 +- src/template/tag/hash.ts | 29 +- src/template/tag/tag-impl-options.ts | 2 +- src/template/tag/tag.ts | 4 +- src/template/value.ts | 68 +---- src/types.ts | 1 + src/util/collection.ts | 8 + src/util/underscore.ts | 4 + test/integration/builtin/tags/include.ts | 11 +- test/integration/builtin/tags/layout.ts | 25 +- test/integration/builtin/tags/render.ts | 88 ++++-- test/integration/liquid/cache.ts | 53 +++- test/integration/util/error.ts | 5 +- test/stub/mockfs.ts | 28 +- test/unit/cache/lru.ts | 36 +++ test/unit/fs/browser.ts | 2 +- test/unit/fs/node.ts | 2 +- test/unit/parser/expression-tokenizer.ts | 43 --- test/unit/parser/lexical.ts | 12 - test/unit/parser/tokenizer.ts | 271 +++++++++++++----- test/unit/render/render.ts | 2 +- test/unit/template/hash.ts | 35 ++- test/unit/template/output.ts | 8 +- test/unit/template/tag.ts | 48 +--- test/unit/template/value.ts | 27 -- 67 files changed, 1108 insertions(+), 724 deletions(-) create mode 100644 bin/char-types.js create mode 100644 src/cache/cache.ts create mode 100644 src/cache/lru.ts rename src/fs/{ifs.ts => fs.ts} (90%) delete mode 100644 src/parser/expression-tokenizer.ts create mode 100644 src/parser/filter-arg.ts create mode 100644 src/parser/filter-token.ts create mode 100644 src/util/collection.ts create mode 100644 test/unit/cache/lru.ts delete mode 100644 test/unit/parser/expression-tokenizer.ts delete mode 100644 test/unit/parser/lexical.ts diff --git a/bin/char-types.js b/bin/char-types.js new file mode 100644 index 000000000..4ad77c785 --- /dev/null +++ b/bin/char-types.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +function isQuote (c) { + return c === '"' || c === "'" +} + +function isOperator (c) { + return '!=<>'.includes(c) +} + +function isNumber (c) { + return c >= '0' && c <= '9' +} + +function isCharacter (c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +function isVariable (c) { + return '_-?'.includes(c) || isCharacter(c) || isNumber(c) +} + +function isBlank (c) { + return c === '\n' || c === '\t' || c === ' ' || c === '\r' +} + +const types = [] +for (let i = 0; i < 128; i++) { + const c = String.fromCharCode(i) + let n = 0 + if (isVariable(c)) n |= 1 + if (isOperator(c)) n |= 2 + if (isBlank(c)) n |= 4 + if (isQuote(c)) n |= 8 + types.push(n) +} +console.log(` +const TYPES = '${types.join('')}' +const VARIABLE = 1 +const OPERATOR = 2 +const BLANK = 4 +const QUOTE = 8 +`.trim()) diff --git a/bin/liquid.js b/bin/liquid.js index e0011360e..a331b4fd6 100755 --- a/bin/liquid.js +++ b/bin/liquid.js @@ -1,8 +1,8 @@ #!/usr/bin/env node const Liquid = require('..').Liquid -var contextArg = process.argv.slice(2)[0] -var context = {} +const contextArg = process.argv.slice(2)[0] +let context = {} if (contextArg) { if (contextArg.endsWith('.json')) { @@ -20,5 +20,5 @@ process.stdin.on('end', () => render(tpl)) async function render (tpl) { const liquid = new Liquid() const html = await liquid.parseAndRender(tpl, context) - console.log(html) + process.stdout.write(html) } diff --git a/src/builtin/tags/assign.ts b/src/builtin/tags/assign.ts index 5198021d7..5c60f8bc3 100644 --- a/src/builtin/tags/assign.ts +++ b/src/builtin/tags/assign.ts @@ -12,6 +12,6 @@ export default { this.value = match[2] }, render: function * (ctx: Context) { - ctx.front()[this.key] = yield this.liquid._evalValue(this.value, ctx) + ctx.bottom()[this.key] = yield this.liquid._evalValue(this.value, ctx) } } as TagImplOptions diff --git a/src/builtin/tags/block.ts b/src/builtin/tags/block.ts index e591aa2cf..dad49b498 100644 --- a/src/builtin/tags/block.ts +++ b/src/builtin/tags/block.ts @@ -1,5 +1,5 @@ import BlockMode from '../../context/block-mode' -import { ParseStream, TagToken, Token, Template, Context, TagImplOptions, Emitter, Hash } from '../../types' +import { ParseStream, TagToken, Token, Template, Context, TagImplOptions, Emitter } from '../../types' export default { parse: function (token: TagToken, remainTokens: Token[]) { @@ -14,7 +14,7 @@ export default { }) stream.start() }, - render: function * (ctx: Context, hash: Hash, emitter: Emitter) { + render: function * (ctx: Context, emitter: Emitter) { const blocks = ctx.getRegister('blocks') const childDefined = blocks[this.block] const r = this.liquid.renderer diff --git a/src/builtin/tags/break.ts b/src/builtin/tags/break.ts index 1b0c1512d..c224f3899 100644 --- a/src/builtin/tags/break.ts +++ b/src/builtin/tags/break.ts @@ -1,7 +1,7 @@ -import { Emitter, Context, Hash } from '../../types' +import { Emitter, Context } from '../../types' export default { - render: function (ctx: Context, hash: Hash, emitter: Emitter) { + render: function (ctx: Context, emitter: Emitter) { emitter.break = true } } diff --git a/src/builtin/tags/capture.ts b/src/builtin/tags/capture.ts index 10c47d918..22d69ca1c 100644 --- a/src/builtin/tags/capture.ts +++ b/src/builtin/tags/capture.ts @@ -23,6 +23,6 @@ export default { render: function * (ctx: Context) { const r = this.liquid.renderer const html = yield r.renderTemplates(this.templates, ctx) - ctx.front()[this.variable] = html + ctx.bottom()[this.variable] = html } } as TagImplOptions diff --git a/src/builtin/tags/case.ts b/src/builtin/tags/case.ts index f5935f83b..28ffbeaef 100644 --- a/src/builtin/tags/case.ts +++ b/src/builtin/tags/case.ts @@ -1,4 +1,4 @@ -import { Expression, Hash, Emitter, TagToken, Token, Context, Template, TagImplOptions, ParseStream } from '../../types' +import { Expression, Emitter, TagToken, Token, Context, Template, TagImplOptions, ParseStream } from '../../types' export default { parse: function (tagToken: TagToken, remainTokens: Token[]) { @@ -24,7 +24,7 @@ export default { stream.start() }, - render: function * (ctx: Context, hash: Hash, emitter: Emitter) { + render: function * (ctx: Context, emitter: Emitter) { const r = this.liquid.renderer const cond = yield new Expression(this.cond).value(ctx) for (let i = 0; i < this.cases.length; i++) { diff --git a/src/builtin/tags/continue.ts b/src/builtin/tags/continue.ts index bc606184c..89450340e 100644 --- a/src/builtin/tags/continue.ts +++ b/src/builtin/tags/continue.ts @@ -1,7 +1,7 @@ -import { Emitter, Context, Hash } from '../../types' +import { Emitter, Context } from '../../types' export default { - render: function (ctx: Context, hash: Hash, emitter: Emitter) { + render: function (ctx: Context, emitter: Emitter) { emitter.continue = true } } diff --git a/src/builtin/tags/cycle.ts b/src/builtin/tags/cycle.ts index 811b45ed1..970117656 100644 --- a/src/builtin/tags/cycle.ts +++ b/src/builtin/tags/cycle.ts @@ -1,6 +1,6 @@ import { assert } from '../../util/assert' import { value as rValue } from '../../parser/lexical' -import { Emitter, Expression, TagToken, Context, TagImplOptions, Hash } from '../../types' +import { Emitter, Expression, TagToken, Context, TagImplOptions } from '../../types' const groupRE = new RegExp(`^(?:(${rValue.source})\\s*:\\s*)?(.*)$`) const candidatesRE = new RegExp(rValue.source, 'g') @@ -21,7 +21,7 @@ export default { assert(this.candidates.length, `empty candidates: ${tagToken.raw}`) }, - render: function * (ctx: Context, hash: Hash, emitter: Emitter) { + render: function * (ctx: Context, emitter: Emitter) { const group = yield this.group.value(ctx) const fingerprint = `cycle:${group}:` + this.candidates.join(',') const groups = ctx.getRegister('cycle') diff --git a/src/builtin/tags/decrement.ts b/src/builtin/tags/decrement.ts index 228d39d03..b234ee7d7 100644 --- a/src/builtin/tags/decrement.ts +++ b/src/builtin/tags/decrement.ts @@ -1,6 +1,6 @@ import { assert } from '../../util/assert' import { identifier } from '../../parser/lexical' -import { Emitter, TagToken, Context, TagImplOptions, Hash } from '../../types' +import { Emitter, TagToken, Context, TagImplOptions } from '../../types' import { isNumber, stringify } from '../../util/underscore' export default { @@ -9,7 +9,7 @@ export default { assert(match, `illegal identifier ${token.args}`) this.variable = match[0] }, - render: function (context: Context, hash: Hash, emitter: Emitter) { + render: function (context: Context, emitter: Emitter) { const scope = context.environments if (!isNumber(scope[this.variable])) { scope[this.variable] = 0 diff --git a/src/builtin/tags/for.ts b/src/builtin/tags/for.ts index bd6ee1abf..343e0f85e 100644 --- a/src/builtin/tags/for.ts +++ b/src/builtin/tags/for.ts @@ -1,16 +1,12 @@ import { Emitter, TagToken, Token, Context, Template, TagImplOptions, ParseStream } from '../../types' -import { isString, isObject, isArray } from '../../util/underscore' +import { toCollection } from '../../util/collection' import { Expression } from '../../render/expression' import { assert } from '../../util/assert' -import { identifier, value, hash } from '../../parser/lexical' +import { identifier, value } from '../../parser/lexical' import { ForloopDrop } from '../../drop/forloop-drop' import { Hash } from '../../template/tag/hash' -const re = new RegExp(`^(${identifier.source})\\s+in\\s+` + - `(${value.source})` + - `(?:\\s+${hash.source})*` + - `(?:\\s+(reversed))?` + - `(?:\\s+${hash.source})*$`) +const re = new RegExp(`^(${identifier.source})\\s+in\\s+(${value.source})`) export default { type: 'block', @@ -19,8 +15,7 @@ export default { assert(match, `illegal tag: ${tagToken.raw}`) this.variable = match[1] this.collection = match[2] - this.reversed = !!match[3] - + this.hash = new Hash(tagToken.args.slice(match[0].length)) this.templates = [] this.elseTemplates = [] @@ -36,27 +31,22 @@ export default { stream.start() }, - render: function * (ctx: Context, hash: Hash, emitter: Emitter) { + render: function * (ctx: Context, emitter: Emitter) { const r = this.liquid.renderer let collection = yield new Expression(this.collection).value(ctx) + collection = toCollection(collection) - if (!isArray(collection)) { - if (isString(collection) && collection.length > 0) { - collection = [collection] as string[] - } else if (isObject(collection)) { - collection = Object.keys(collection).map((key) => [key, collection[key]]) - } - } - if (!isArray(collection) || !collection.length) { + if (!collection.length) { yield r.renderTemplates(this.elseTemplates, ctx, emitter) return } + const hash = yield this.hash.render(ctx) const offset = hash.offset || 0 const limit = (hash.limit === undefined) ? collection.length : hash.limit collection = collection.slice(offset, offset + limit) - if (this.reversed) collection.reverse() + if ('reversed' in hash) collection.reverse() const scope = { forloop: new ForloopDrop(collection.length) } ctx.push(scope) diff --git a/src/builtin/tags/if.ts b/src/builtin/tags/if.ts index 6b14a0df3..9801f10f8 100644 --- a/src/builtin/tags/if.ts +++ b/src/builtin/tags/if.ts @@ -1,4 +1,4 @@ -import { Hash, Emitter, isTruthy, Expression, TagToken, Token, Context, Template, TagImplOptions, ParseStream } from '../../types' +import { Emitter, isTruthy, Expression, TagToken, Token, Context, Template, TagImplOptions, ParseStream } from '../../types' export default { parse: function (tagToken: TagToken, remainTokens: Token[]) { @@ -27,7 +27,7 @@ export default { stream.start() }, - render: function * (ctx: Context, hash: Hash, emitter: Emitter) { + render: function * (ctx: Context, emitter: Emitter) { const r = this.liquid.renderer for (const branch of this.branches) { diff --git a/src/builtin/tags/include.ts b/src/builtin/tags/include.ts index e9ad0dfce..e71fd218f 100644 --- a/src/builtin/tags/include.ts +++ b/src/builtin/tags/include.ts @@ -1,49 +1,39 @@ import { assert } from '../../util/assert' import { Expression, Hash, Emitter, TagToken, Context, TagImplOptions } from '../../types' -import { value, quotedLine } from '../../parser/lexical' +import { quoted, value, quotedLine } from '../../parser/lexical' import BlockMode from '../../context/block-mode' -const staticFileRE = /[^\s,]+/ -const withRE = new RegExp(`with\\s+(${value.source})`) +const rFile = new RegExp(`^(${quoted.source}|[^\\s,]+)(?:\\s+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 + const match = rFile.exec(token.args) + if (!match) { + throw new Error(`illegal argument "${token.args}"`) } - assert(filepath, `cannot include with empty filename`) - - const originBlocks = ctx.getRegister('blocks') - const originBlockMode = ctx.getRegister('blockMode') + this.file = match[1] + this.hash = new Hash(token.args.slice(match[0].length)) + this.withVar = match[2] + }, + render: function * (ctx: Context, emitter: Emitter) { + const { liquid, hash, withVar, file } = this + const { renderer } = liquid + const filepath = ctx.opts.dynamicPartials + ? (quotedLine.exec(file) + ? yield renderer.renderTemplates(liquid.parse(file.slice(1, -1)), ctx) + : yield new Expression(file).value(ctx)) + : file + assert(filepath, `illegal filename "${file}":"${filepath}"`) + const saved = ctx.saveRegister('blocks', 'blockMode') ctx.setRegister('blocks', {}) ctx.setRegister('blockMode', BlockMode.OUTPUT) - if (this.with) { - hash[filepath] = yield new Expression(this.with).evaluate(ctx) - } - const templates = yield this.liquid._parseFile(filepath, ctx.opts, ctx.sync) - ctx.push(hash) - yield this.liquid.renderer.renderTemplates(templates, ctx, emitter) + const scope = yield hash.render(ctx) + if (withVar) scope[filepath] = yield new Expression(withVar).evaluate(ctx) + const templates = yield liquid._parseFile(filepath, ctx.opts, ctx.sync) + ctx.push(scope) + yield renderer.renderTemplates(templates, ctx, emitter) ctx.pop() - ctx.setRegister('blocks', originBlocks) - ctx.setRegister('blockMode', originBlockMode) + ctx.restoreRegister(saved) } } as TagImplOptions diff --git a/src/builtin/tags/increment.ts b/src/builtin/tags/increment.ts index 8b0d3ec6b..02ec03f32 100644 --- a/src/builtin/tags/increment.ts +++ b/src/builtin/tags/increment.ts @@ -1,7 +1,7 @@ import { assert } from '../../util/assert' import { identifier } from '../../parser/lexical' import { isNumber, stringify } from '../../util/underscore' -import { Emitter, TagToken, Context, TagImplOptions, Hash } from '../../types' +import { Emitter, TagToken, Context, TagImplOptions } from '../../types' export default { parse: function (token: TagToken) { @@ -9,7 +9,7 @@ export default { assert(match, `illegal identifier ${token.args}`) this.variable = match![0] }, - render: function (context: Context, hash: Hash, emitter: Emitter) { + render: function (context: Context, emitter: Emitter) { const scope = context.environments if (!isNumber(scope[this.variable])) { scope[this.variable] = 0 diff --git a/src/builtin/tags/layout.ts b/src/builtin/tags/layout.ts index 1970cb46e..21029c3b7 100644 --- a/src/builtin/tags/layout.ts +++ b/src/builtin/tags/layout.ts @@ -1,42 +1,39 @@ import { assert } from '../../util/assert' -import { value as rValue } from '../../parser/lexical' +import { quotedLine, quoted } from '../../parser/lexical' import { Emitter, Hash, Expression, TagToken, Token, Context, TagImplOptions } from '../../types' import BlockMode from '../../context/block-mode' -const staticFileRE = /\S+/ +const rFile = new RegExp(`^(${quoted.source}|[^\\s,]+)`) export default { parse: function (token: TagToken, remainTokens: Token[]) { - let match = staticFileRE.exec(token.args) - if (match) { - this.staticLayout = match[0] + const match = rFile.exec(token.args) + if (!match) { + throw new Error(`illegal argument "${token.args}"`) } - - match = rValue.exec(token.args) - if (match) { - this.layout = match[0] - } - + this.file = match[1] + this.hash = new Hash(token.args.slice(match[0].length)) this.tpls = this.liquid.parser.parse(remainTokens) }, - render: function * (ctx: Context, hash: Hash, emitter: Emitter) { - const layout = ctx.opts.dynamicPartials - ? yield new Expression(this.layout).value(ctx) - : this.staticLayout - assert(layout, `cannot apply layout with empty filename`) + render: function * (ctx: Context, emitter: Emitter) { + const { liquid, hash, file } = this + const { renderer } = liquid + const filepath = ctx.opts.dynamicPartials + ? (quotedLine.exec(file) + ? yield renderer.renderTemplates(liquid.parse(file.slice(1, -1)), ctx) + : yield new Expression(file).value(ctx)) + : this.file + assert(filepath, `illegal filename "${file}":"${filepath}"`) // render the remaining tokens immediately ctx.setRegister('blockMode', BlockMode.STORE) const blocks = ctx.getRegister('blocks') - const r = this.liquid.renderer - const html = yield r.renderTemplates(this.tpls, ctx) - if (blocks[''] === undefined) { - blocks[''] = html - } - const templates = yield this.liquid._parseFile(layout, ctx.opts, ctx.sync) - ctx.push(hash) + const html = yield renderer.renderTemplates(this.tpls, ctx) + if (blocks[''] === undefined) blocks[''] = html + const templates = yield liquid._parseFile(filepath, ctx.opts, ctx.sync) + ctx.push(yield hash.render(ctx)) ctx.setRegister('blockMode', BlockMode.OUTPUT) - const partial = yield r.renderTemplates(templates, ctx) + const partial = yield renderer.renderTemplates(templates, ctx) ctx.pop() emitter.write(partial) } diff --git a/src/builtin/tags/render.ts b/src/builtin/tags/render.ts index 108304a41..6b470c5db 100644 --- a/src/builtin/tags/render.ts +++ b/src/builtin/tags/render.ts @@ -1,50 +1,63 @@ import { assert } from '../../util/assert' +import { ForloopDrop } from '../../drop/forloop-drop' +import { toCollection } from '../../util/collection' import { Expression, Hash, Emitter, TagToken, Context, TagImplOptions } from '../../types' -import { value, quotedLine } from '../../parser/lexical' -import BlockMode from '../../context/block-mode' +import { identifier, value, quoted, quotedLine } from '../../parser/lexical' -const staticFileRE = /[^\s,]+/ -const withRE = new RegExp(`with\\s+(${value.source})`) +const rFile = new RegExp(`^(${quoted.source}|[^\\s,]+)`) +const rWith = new RegExp(`^\\s+with\\s+(${value.source})(?:\\s+as\\s+(${identifier.source}))?`) +const rFor = new RegExp(`^\\s+for\\s+(${value.source})\\s+as\\s+(${identifier.source})`) export default { parse: function (token: TagToken) { - let match = staticFileRE.exec(token.args) - if (match) this.staticValue = match[0] + let args = token.args + let match = rFile.exec(args) - match = value.exec(token.args) - if (match) this.value = match[0] + assert(match, `illegal argument "${token.args}"`) + this.file = match![1] + args = args.substr(match![0].length) - 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 + while (true) { + if ((match = rWith.exec(args))) { + this.withVar = match[1] + this.withAs = match[2] + args = args.substr(match[0].length) + } else if ((match = rFor.exec(args))) { + this.forVar = match[1] + this.forAs = match[2] + args = args.substr(match[0].length) + } else break } - assert(filepath, `cannot render with empty filename`) - - const originBlocks = ctx.getRegister('blocks') - const originBlockMode = ctx.getRegister('blockMode') + this.hash = new Hash(args) + }, + render: function * (ctx: Context, emitter: Emitter) { + const { liquid, withVar, withAs, forVar, forAs, file, hash } = this + const { renderer } = liquid + const filepath = ctx.opts.dynamicPartials + ? (quotedLine.exec(file) + ? yield renderer.renderTemplates(liquid.parse(file.slice(1, -1)), ctx) + : yield new Expression(file).value(ctx)) + : this.file + assert(filepath, `illegal filename "${file}":"${filepath}"`) 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) + const scope = yield hash.render(ctx) + if (withVar) scope[withAs || filepath] = yield new Expression(withVar).evaluate(ctx) + childCtx.push(scope) - childCtx.setRegister('blocks', originBlocks) - childCtx.setRegister('blockMode', originBlockMode) + if (forVar) { + let collection = yield new Expression(forVar).value(ctx) + collection = toCollection(collection) + scope['forloop'] = new ForloopDrop(collection.length) + for (const item of collection) { + scope[forAs] = item + const templates = yield liquid._parseFile(filepath, childCtx.opts, childCtx.sync) + yield renderer.renderTemplates(templates, childCtx, emitter) + scope.forloop.next() + } + } else { + const templates = yield liquid._parseFile(filepath, childCtx.opts, childCtx.sync) + yield renderer.renderTemplates(templates, childCtx, emitter) + } } } as TagImplOptions diff --git a/src/builtin/tags/tablerow.ts b/src/builtin/tags/tablerow.ts index 198820ba5..f6a346318 100644 --- a/src/builtin/tags/tablerow.ts +++ b/src/builtin/tags/tablerow.ts @@ -1,11 +1,11 @@ import { assert } from '../../util/assert' +import { toCollection } from '../../util/collection' import { Expression, Emitter, Hash, TagToken, Token, Context, Template, TagImplOptions, ParseStream } from '../../types' -import { identifier, value, hash } from '../../parser/lexical' +import { identifier, value } from '../../parser/lexical' import { TablerowloopDrop } from '../../drop/tablerowloop-drop' const re = new RegExp(`^(${identifier.source})\\s+in\\s+` + - `(${value.source})` + - `(?:\\s+${hash.source})*$`) + `(${value.source})`) export default { parse: function (tagToken: TagToken, remainTokens: Token[]) { @@ -15,6 +15,7 @@ export default { this.variable = match[1] this.collection = match[2] this.templates = [] + this.hash = new Hash(tagToken.args.slice(match[0].length)) let p const stream: ParseStream = this.liquid.parser.parseStream(remainTokens) @@ -28,8 +29,9 @@ export default { stream.start() }, - render: function * (ctx: Context, hash: Hash, emitter: Emitter) { - let collection = (yield new Expression(this.collection).value(ctx)) || [] + render: function * (ctx: Context, emitter: Emitter) { + let collection = toCollection(yield new Expression(this.collection).value(ctx)) + const hash = yield this.hash.render(ctx) const offset = hash.offset || 0 const limit = (hash.limit === undefined) ? collection.length : hash.limit diff --git a/src/builtin/tags/unless.ts b/src/builtin/tags/unless.ts index 28ade8cd4..206d53ae3 100644 --- a/src/builtin/tags/unless.ts +++ b/src/builtin/tags/unless.ts @@ -1,4 +1,4 @@ -import { Emitter, Expression, isFalsy, ParseStream, Context, TagImplOptions, Token, Hash, TagToken } from '../../types' +import { Emitter, Expression, isFalsy, ParseStream, Context, TagImplOptions, Token, TagToken } from '../../types' export default { parse: function (tagToken: TagToken, remainTokens: Token[]) { @@ -20,7 +20,7 @@ export default { stream.start() }, - render: function * (ctx: Context, hash: Hash, emitter: Emitter) { + render: function * (ctx: Context, emitter: Emitter) { const r = this.liquid.renderer const cond = yield new Expression(this.cond).value(ctx) yield (isFalsy(cond) diff --git a/src/cache/cache.ts b/src/cache/cache.ts new file mode 100644 index 000000000..c2d544e89 --- /dev/null +++ b/src/cache/cache.ts @@ -0,0 +1,5 @@ +export interface Cache { + write (key: string, value: T): void; + read (key: string): T | undefined; + has (key: string): boolean; +} diff --git a/src/cache/lru.ts b/src/cache/lru.ts new file mode 100644 index 000000000..6fbe271ce --- /dev/null +++ b/src/cache/lru.ts @@ -0,0 +1,67 @@ +import { Cache } from './cache' + +class Node { + constructor ( + public key: string, + public value: T, + public next: Node, + public prev: Node + ) {} +} + +export class LRU implements Cache { + private cache: { [key: string]: Node } = {} + private head: Node + private tail: Node + + constructor ( + public limit: number, + public size = 0 + ) { + this.head = new Node('HEAD', null as any, null as any, null as any) + this.tail = new Node('TAIL', null as any, null as any, null as any) + this.head.next = this.tail + this.tail.prev = this.head + } + + write (key: string, value: T) { + const node = new Node(key, value, this.head.next, this.head) + this.head.next.prev = node + this.head.next = node + + this.cache[key] = node + this.size++ + this.ensureLimit() + } + + read (key: string): T | undefined { + if (!this.cache[key]) return + const { value } = this.cache[key] + this.remove(key) + this.write(key, value) + return value + } + + has (key: string): boolean { + return !!this.cache[key] + } + + remove (key: string) { + const node = this.cache[key] + node.prev.next = node.next + node.next.prev = node.prev + delete this.cache[key] + this.size-- + } + + clear () { + this.head.next = this.tail + this.tail.prev = this.head + this.size = 0 + this.cache = {} + } + + private ensureLimit () { + if (this.size > this.limit) this.remove(this.tail.prev.key) + } +} diff --git a/src/context/context.ts b/src/context/context.ts index e596d5228..d38e40491 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -24,6 +24,12 @@ export class Context { public setRegister (key: string, value: any) { return (this.registers[key] = value) } + public saveRegister (...keys: string[]): [string, any][] { + return keys.map(key => [key, this.getRegister(key)]) + } + public restoreRegister (keyValues: [string, any][]) { + return keyValues.forEach(([key, value]) => this.setRegister(key, value)) + } public getAll () { return [this.globals, this.environments, ...this.scopes] .reduce((ctx, val) => __assign(ctx, val), {}) @@ -49,7 +55,7 @@ export class Context { public pop () { return this.scopes.pop() } - public front () { + public bottom () { return this.scopes[0] } private findScope (key: string) { diff --git a/src/fs/browser.ts b/src/fs/browser.ts index c94a4c5ae..053d9dea8 100644 --- a/src/fs/browser.ts +++ b/src/fs/browser.ts @@ -1,5 +1,4 @@ import { last } from '../util/underscore' -import IFS from './ifs' function domResolve (root: string, path: string) { const base = document.createElement('base') @@ -16,7 +15,7 @@ function domResolve (root: string, path: string) { return resolved } -function resolve (root: string, filepath: string, ext: string) { +export function resolve (root: string, filepath: string, ext: string) { if (root.length && last(root) !== '/') root += '/' const url = domResolve(root, filepath) return url.replace(/^(\w+:\/\/[^/]+)(\/[^?]+)/, (str, origin, path) => { @@ -26,7 +25,7 @@ function resolve (root: string, filepath: string, ext: string) { }) } -async function readFile (url: string): Promise { +export async function readFile (url: string): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.onload = () => { @@ -44,7 +43,7 @@ async function readFile (url: string): Promise { }) } -function readFileSync (url: string): string { +export function readFileSync (url: string): string { const xhr = new XMLHttpRequest() xhr.open('GET', url, false) xhr.send() @@ -54,12 +53,10 @@ function readFileSync (url: string): string { return xhr.responseText as string } -async function exists () { +export async function exists (filepath: string) { return true } -function existsSync () { +export function existsSync (filepath: string) { return true } - -export default { readFile, resolve, exists, existsSync, readFileSync } as IFS diff --git a/src/fs/ifs.ts b/src/fs/fs.ts similarity index 90% rename from src/fs/ifs.ts rename to src/fs/fs.ts index 808c33edf..badbf9a95 100644 --- a/src/fs/ifs.ts +++ b/src/fs/fs.ts @@ -1,4 +1,4 @@ -export default interface IFS { +export interface FS { exists: (filepath: string) => Promise; readFile: (filepath: string) => Promise; existsSync: (filepath: string) => boolean; diff --git a/src/fs/node.ts b/src/fs/node.ts index 553a391ba..03712eb52 100644 --- a/src/fs/node.ts +++ b/src/fs/node.ts @@ -1,38 +1,33 @@ import * as _ from '../util/underscore' -import { resolve, extname } from 'path' -import { stat, statSync, readFile, readFileSync } from 'fs' -import IFS from './ifs' +import { resolve as nodeResolve, extname } from 'path' +import { stat, statSync, readFile as nodeReadFile, readFileSync as nodeReadFileSync } from 'fs' const statAsync = _.promisify(stat) -const readFileAsync = _.promisify(readFile) +const readFileAsync = _.promisify(nodeReadFile) -const fs: IFS = { - exists: (filepath: string) => { - return statAsync(filepath).then(() => true).catch(() => false) - }, - readFile: filepath => { - return readFileAsync(filepath, 'utf8') - }, - existsSync: (filepath: string) => { - try { - statSync(filepath) - return true - } catch (err) { - return false - } - }, - readFileSync: filepath => { - return readFileSync(filepath, 'utf8') - }, - resolve: (root: string, file: string, ext: string) => { - if (!extname(file)) file += ext - return resolve(root, file) - }, - fallback: (file: string) => { - try { - return require.resolve(file) - } catch (e) {} +export function exists (filepath: string) { + return statAsync(filepath).then(() => true).catch(() => false) +} +export function readFile (filepath: string) { + return readFileAsync(filepath, 'utf8') +} +export function existsSync (filepath: string) { + try { + statSync(filepath) + return true + } catch (err) { + return false } } - -export default fs +export function readFileSync (filepath: string) { + return nodeReadFileSync(filepath, 'utf8') +} +export function resolve (root: string, file: string, ext: string) { + if (!extname(file)) file += ext + return nodeResolve(root, file) +} +export function fallback (file: string) { + try { + return require.resolve(file) + } catch (e) {} +} diff --git a/src/liquid-options.ts b/src/liquid-options.ts index 15941d395..46232e790 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -1,5 +1,8 @@ import * as _ from './util/underscore' -import IFS from './fs/ifs' +import { Template } from './template/template' +import { Cache } from './cache/cache' +import { LRU } from './cache/lru' +import { FS } from './fs/fs' export interface LiquidOptions { /** A directory or an array of directories from where to resolve layout and include templates, and the filename passed to `.renderFile()`. If it's an array, the files are looked up in the order they occur in the array. Defaults to `["."]` */ @@ -7,7 +10,7 @@ export interface LiquidOptions { /** Add a extname (if filepath doesn't include one) before template file lookup. Eg: setting to `".html"` will allow including file by basename. Defaults to `""`. */ extname?: string; /** Whether or not to cache resolved templates. Defaults to `false`. */ - cache?: boolean; + cache?: boolean | number | Cache; /** If set, treat the `filepath` parameter in `{%include filepath %}` and `{%layout filepath%}` as a variable, otherwise as a literal value. Defaults to `true`. */ dynamicPartials?: boolean; /** Enable strict filter existence. If set to `false`, undefined filters will be rendered as empty string. Otherwise, undefined filters will cause an exception. Defaults to `false`. */ @@ -33,19 +36,20 @@ export interface LiquidOptions { /** Whether `trim*Left`/`trim*Right` is greedy. When set to `true`, all consecutive blank characters including `\n` will be trimed regardless of line breaks. Defaults to `true`. */ greedy?: boolean; /** `fs` is used to override the default file-system module with a custom implementation. */ - fs?: IFS; + fs?: FS; /** the global environment passed down to all partial templates, i.e. templates included by `include`, `layout` and `render` tags. */ globals?: object; } interface NormalizedOptions extends LiquidOptions { root?: string[]; + cache?: Cache; } export interface NormalizedFullOptions extends NormalizedOptions { root: string[]; extname: string; - cache: boolean; + cache: undefined | Cache; dynamicPartials: boolean; strictFilters: boolean; strictVariables: boolean; @@ -63,7 +67,7 @@ export interface NormalizedFullOptions extends NormalizedOptions { export const defaultOptions: NormalizedFullOptions = { root: ['.'], - cache: false, + cache: undefined, extname: '', dynamicPartials: true, trimTagRight: false, @@ -85,10 +89,15 @@ export function normalize (options?: LiquidOptions): NormalizedOptions { if (options.hasOwnProperty('root')) { options.root = normalizeStringArray(options.root) } + let cache: Cache | undefined + if (typeof options.cache === 'number') cache = options.cache > 0 ? new LRU(options.cache) : undefined + else if (typeof options.cache === 'object') cache = options.cache + else cache = options.cache ? new LRU(1024) : undefined + options.cache = cache return options as NormalizedOptions } -export function applyDefault (options?: NormalizedOptions): NormalizedFullOptions { +export function applyDefault (options: NormalizedOptions): NormalizedFullOptions { return { ...defaultOptions, ...options } } diff --git a/src/liquid.ts b/src/liquid.ts index 6be321ee5..2a9a53e3c 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -1,5 +1,5 @@ import { Context } from './context/context' -import fs from './fs/node' +import * as fs from './fs/node' import * as _ from './util/underscore' import { Template } from './template/template' import { Tokenizer } from './parser/tokenizer' @@ -13,7 +13,7 @@ import { TagMap } from './template/tag/tag-map' import { FilterMap } from './template/filter/filter-map' import { LiquidOptions, normalizeStringArray, NormalizedFullOptions, applyDefault, normalize } from './liquid-options' import { FilterImplOptions } from './template/filter/filter-impl-options' -import IFS from './fs/ifs' +import { FS } from './fs/fs' import { toThenable, toValue } from './util/async' export * from './types' @@ -24,15 +24,12 @@ export class Liquid { public parser: Parser public filters: FilterMap public tags: TagMap - private cache: object = {} - private tokenizer: Tokenizer - private fs: IFS + private fs: FS public constructor (opts: LiquidOptions = {}) { this.options = applyDefault(normalize(opts)) this.parser = new Parser(this) this.renderer = new Render() - this.tokenizer = new Tokenizer(this.options) this.fs = opts.fs || fs this.filters = new FilterMap(this.options.strictFilters) this.tags = new TagMap() @@ -41,7 +38,8 @@ export class Liquid { _.forOwn(builtinFilters, (handler, name) => this.registerFilter(name, handler)) } public parse (html: string, filepath?: string): Template[] { - const tokens = this.tokenizer.tokenize(html, filepath) + const tokenizer = new Tokenizer(html, filepath, this.options) + const tokens = tokenizer.readTokens() return this.parser.parse(tokens) } @@ -77,10 +75,12 @@ export class Liquid { } for (const filepath of paths) { - if (this.options.cache && this.cache[filepath]) return this.cache[filepath] + const { cache } = this.options + if (cache && cache.has(filepath)) return cache.read(filepath) if (!(sync ? this.fs.existsSync(filepath) : yield this.fs.exists(filepath))) continue const tpl = this.parse(sync ? this.fs.readFileSync(filepath) : yield this.fs.readFile(filepath), filepath) - return (this.cache[filepath] = tpl) + cache && cache.write(filepath, tpl) + return tpl } throw this.lookupError(file, options.root) } diff --git a/src/parser/delimited-token.ts b/src/parser/delimited-token.ts index 90b02f499..764f6c970 100644 --- a/src/parser/delimited-token.ts +++ b/src/parser/delimited-token.ts @@ -4,7 +4,7 @@ import { last } from '../util/underscore' export class DelimitedToken extends Token { public constructor ( raw: string, - value: string, + content: string, input: string, line: number, pos: number, @@ -13,12 +13,12 @@ export class DelimitedToken extends Token { file?: string ) { super(raw, input, line, pos, file) - const tl = value[0] === '-' - const tr = last(value) === '-' - this.value = value + const tl = content[0] === '-' + const tr = last(content) === '-' + this.content = content .slice( tl ? 1 : 0, - tr ? -1 : value.length + tr ? -1 : content.length ) .trim() this.trimLeft = tl || trimLeft diff --git a/src/parser/expression-tokenizer.ts b/src/parser/expression-tokenizer.ts deleted file mode 100644 index 3c28ed7e3..000000000 --- a/src/parser/expression-tokenizer.ts +++ /dev/null @@ -1,50 +0,0 @@ -const rBlank = /\s/ -const rPunctuation = /[<>=!]/ - -enum ParseState { - INIT = 1, - SINGLE_QUOTE = 2, - DOUBLE_QUOTE = 4, - QUOTE = 6, - BRACKET = 8 -} - -export function * tokenize (expr: string): IterableIterator { - const N = expr.length - const stack = [ParseState.INIT] - let str = '' - let lastIsPunc = false - - for (let i = 0; i < N; i++) { - const c = expr[i] - const top = stack[stack.length - 1] - const isPunc = rPunctuation.test(c) - if (c === '\\') { - str += expr.substr(i++, 2) - } else if (top === ParseState.SINGLE_QUOTE && c === "'") { - str += c - stack.pop() - } else if (top === ParseState.DOUBLE_QUOTE && c === '"') { - str += c - stack.pop() - } else if (ParseState.QUOTE & top) { - str += c - } else if (top === ParseState.BRACKET && c === ']') { - str += c - stack.pop() - } else if (top === ParseState.INIT && rBlank.exec(c)) { - if (str) yield str - str = '' - } else if (top === ParseState.INIT && isPunc !== lastIsPunc) { - if (str) yield str - str = c - } else { - if (c === '"') stack.push(ParseState.DOUBLE_QUOTE) - else if (c === "'") stack.push(ParseState.SINGLE_QUOTE) - else if (c === '[') stack.push(ParseState.BRACKET) - str += c - } - lastIsPunc = isPunc - } - if (str) yield str -} diff --git a/src/parser/filter-arg.ts b/src/parser/filter-arg.ts new file mode 100644 index 000000000..43190ed62 --- /dev/null +++ b/src/parser/filter-arg.ts @@ -0,0 +1,9 @@ +import { isArray } from '../util/underscore' + +type KeyValuePair = [string?, string?] + +export type FilterArg = string|KeyValuePair + +export function isKeyValuePair (arr: FilterArg): arr is KeyValuePair { + return isArray(arr) +} diff --git a/src/parser/filter-token.ts b/src/parser/filter-token.ts new file mode 100644 index 000000000..bf8805e0b --- /dev/null +++ b/src/parser/filter-token.ts @@ -0,0 +1,17 @@ +import { Token } from './token' +import { FilterArg } from './filter-arg' + +export class FilterToken extends Token { + public constructor ( + public name: string, + public args: FilterArg[], + raw: string, + input: string, + line: number, + col: number, + file?: string + ) { + super(raw, input, line, col, file) + this.type = 'filter' + } +} diff --git a/src/parser/html-token.ts b/src/parser/html-token.ts index 1e631b231..c87b0deda 100644 --- a/src/parser/html-token.ts +++ b/src/parser/html-token.ts @@ -4,7 +4,7 @@ export class HTMLToken extends Token { public constructor (str: string, input: string, line: number, col: number, file?: string) { super(str, input, line, col, file) this.type = 'html' - this.value = str + this.content = str } public static is (token: Token): token is HTMLToken { return token.type === 'html' diff --git a/src/parser/lexical.ts b/src/parser/lexical.ts index 9048c3722..ca8a26a85 100644 --- a/src/parser/lexical.ts +++ b/src/parser/lexical.ts @@ -21,23 +21,9 @@ export const rangeCapture = new RegExp(`\\((${rangeLimit.source})\\.\\.(${rangeL export const value = new RegExp(`(?:${variable.source}|${literal.source}|${range.source})`) -// hash related -export const hash = new RegExp(`(?:${identifier.source})\\s*:\\s*(?:${value.source})`) -export const hashCapture = new RegExp(`(${identifier.source})\\s*:\\s*(${value.source})`, 'g') - // full match export const tagLine = new RegExp(`^\\s*(${identifier.source})\\s*([\\s\\S]*?)\\s*$`) export const numberLine = new RegExp(`^${number.source}$`) export const boolLine = new RegExp(`^${bool.source}$`, 'i') export const quotedLine = new RegExp(`^${quoted.source}$`) export const rangeLine = new RegExp(`^${rangeCapture.source}$`) - -export const operators = [ - /\s+or\s+/, - /\s+and\s+/, - /==|!=|<=|>=|<|>|\s+contains\s+/ -] - -export function isRange (str: string) { - return rangeLine.test(str) -} diff --git a/src/parser/tag-token.ts b/src/parser/tag-token.ts index d6697b05c..4f2189d96 100644 --- a/src/parser/tag-token.ts +++ b/src/parser/tag-token.ts @@ -18,7 +18,7 @@ export class TagToken extends DelimitedToken { ) { super(raw, value, input, line, pos, options.trimTagLeft, options.trimTagRight, file) this.type = 'tag' - const match = this.value.match(lexical.tagLine) + const match = this.content.match(lexical.tagLine) if (!match) { throw new TokenizationError(`illegal tag syntax`, this) } diff --git a/src/parser/token.ts b/src/parser/token.ts index 162f83426..6657a3201 100644 --- a/src/parser/token.ts +++ b/src/parser/token.ts @@ -1,19 +1,18 @@ +import { flatten } from './flatten/node' + export class Token { public trimLeft = false public trimRight = false public type = 'notset' - public line: number - public col: number public raw: string - public input: string - public file?: string - public value: string - public constructor (raw: string, input: string, line: number, col: number, file?: string) { - this.col = col - this.line = line - this.raw = raw - this.value = raw - this.input = input - this.file = file + public content: string + public constructor (raw: string, + public input: string, + public line: number, + public col: number, + public file?: string + ) { + this.raw = flatten(raw) + this.content = raw } } diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index c601a1dc0..ed7558220 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -1,93 +1,279 @@ import { whiteSpaceCtrl } from './whitespace-ctrl' +import { FilterArg } from './filter-arg' +import { FilterToken } from './filter-token' +import { ellipsis } from '../util/underscore' import { HTMLToken } from './html-token' import { TagToken } from './tag-token' import { Token } from './token' import { OutputToken } from './output-token' import { TokenizationError } from '../util/error' -import { NormalizedFullOptions, applyDefault } from '../liquid-options' -import { flatten } from './flatten/node' +import { NormalizedFullOptions, defaultOptions } from '../liquid-options' -enum ParseState { HTML, OUTPUT, TAG } +// bitmask character types to boost performance +// generated by bin/char-types.js +const TYPES = '00000000044004000000000000000000428000080000010011111111110022210111111111111111111111111110000101111111111111111111111111100000' +const VARIABLE = 1 +const OPERATOR = 2 +const BLANK = 4 +const QUOTE = 8 export class Tokenizer { - private options: NormalizedFullOptions - public constructor (options?: NormalizedFullOptions) { - this.options = applyDefault(options) + private p = 0 + private N: number + private line = 1 + private col = 1 + constructor ( + private input: string, + private file: string = '', + private options: NormalizedFullOptions = defaultOptions + ) { + this.N = input.length } - public tokenize (input: string, file?: string) { + + * readExpression (): IterableIterator { + while (this.p < this.N) { + let val = this.readValue() + if (val) { + yield val + continue + } + this.readBlank() + while (OPERATOR & this.peekType()) val += this.read() + if (val) { + yield val + continue + } + this.read() + } + } + + readFilterTokens (): FilterToken[] { + const filters = [] + while (true) { + const filter = this.readFilterToken() + if (!filter) return filters + filters.push(filter) + } + } + + // | foo + // | foo: a + // | foo: a, b + // | foo: a, b: 1 + readFilterToken (): FilterToken | null { + this.readTo('|') + const begin = this.p + const name = this.readVariable() + if (!name) return null + const args = [] + this.readBlank() + if (this.peek() === ':') { + do { + this.read() + const arg = this.readFilterArg() + arg && args.push(arg) + while (this.p < this.N && this.peek() !== ',' && this.peek() !== '|') this.read() + } while (this.peek() === ',') + } + const raw = this.input.slice(begin, this.p) + return new FilterToken(name, args, raw, this.input, this.line, this.col, this.file) + } + + readFilterArg (): FilterArg | null { + const key = this.readValue() + if (!key) return null + this.readBlank() + if (this.peek() === ':') { + this.read() + return [key, this.readValue()] + } + return key + } + + readTokens (): Token[] { const tokens: Token[] = [] - const { - tagDelimiterLeft, - tagDelimiterRight, - outputDelimiterLeft, - outputDelimiterRight - } = this.options - let p = 0 - let curLine = 1 - let state = ParseState.HTML - let buffer = '' - let lineBegin = 0 - let line = 1 - let col = 1 - - while (p < input.length) { - if (input[p] === '\n') { - curLine++ - lineBegin = p + 1 - } - if (state === ParseState.HTML) { - if (input.substr(p, outputDelimiterLeft.length) === outputDelimiterLeft) { - if (buffer) tokens.push(new HTMLToken(flatten(buffer), input, line, col, file)) - buffer = outputDelimiterLeft - line = curLine - col = p - lineBegin + 1 - p += outputDelimiterLeft.length - state = ParseState.OUTPUT - continue - } else if (input.substr(p, tagDelimiterLeft.length) === tagDelimiterLeft) { - if (buffer) tokens.push(new HTMLToken(flatten(buffer), input, line, col, file)) - buffer = tagDelimiterLeft - line = curLine - col = p - lineBegin + 1 - p += tagDelimiterLeft.length - state = ParseState.TAG - continue - } - } else if ( - state === ParseState.OUTPUT && - input.substr(p, outputDelimiterRight.length) === outputDelimiterRight - ) { - buffer += outputDelimiterRight - tokens.push(new OutputToken(flatten(buffer), buffer.slice(outputDelimiterLeft.length, -outputDelimiterRight.length), input, line, col, this.options, file)) - p += outputDelimiterRight.length - buffer = '' - line = curLine - col = p - lineBegin + 1 - state = ParseState.HTML - continue - } else if (input.substr(p, tagDelimiterRight.length) === tagDelimiterRight) { - buffer += tagDelimiterRight - tokens.push(new TagToken(flatten(buffer), buffer.slice(tagDelimiterLeft.length, -tagDelimiterRight.length), input, line, col, this.options, file)) - p += tagDelimiterRight.length - buffer = '' - line = curLine - col = p - lineBegin + 1 - state = ParseState.HTML - continue - } - buffer += input[p++] + while (this.p < this.N) { + const token = this.readToken() + tokens.push(token) } - if (state !== ParseState.HTML) { - const t = state === ParseState.OUTPUT ? 'output' : 'tag' - const str = buffer.length > 16 ? buffer.slice(0, 13) + '...' : buffer - throw new TokenizationError( - `${t} "${str}" not closed`, - new Token(flatten(buffer), input, line, col, file) - ) - } - if (buffer) tokens.push(new HTMLToken(flatten(buffer), input, line, col, file)) - whiteSpaceCtrl(tokens, this.options) return tokens } + + readToken (): Token { + const { tagDelimiterLeft, outputDelimiterLeft } = this.options + if (this.peekWord(tagDelimiterLeft.length) === tagDelimiterLeft) return this.readTagToken() + if (this.peekWord(outputDelimiterLeft.length) === outputDelimiterLeft) return this.readOutputToken() + return this.readHTMLToken() + } + + readHTMLToken (): HTMLToken { + let html = '' + while (this.p < this.N) { + const { tagDelimiterLeft, outputDelimiterLeft } = this.options + if (this.peekWord(tagDelimiterLeft.length) === tagDelimiterLeft) break + if (this.peekWord(outputDelimiterLeft.length) === outputDelimiterLeft) break + html += this.read() + } + return new HTMLToken(html, this.input, this.line, this.col, this.file) + } + + readTagToken (): TagToken { + const { line, col, file, input, options } = this + const { tagDelimiterLeft, tagDelimiterRight } = options + const buffer = this.readTo(tagDelimiterRight) + if (buffer.slice(-tagDelimiterRight.length) !== tagDelimiterRight) { + throw new TokenizationError( + `tag "${ellipsis(buffer, 16)}" not closed`, + new Token(buffer, input, line, col, file) + ) + } + const value = buffer.slice(tagDelimiterLeft.length, -tagDelimiterRight.length) + return new TagToken(buffer, value, input, line, col, options, file) + } + + readOutputToken (): OutputToken { + const { line, col, file, input, options } = this + const { outputDelimiterLeft, outputDelimiterRight } = options + const buffer = this.readTo(outputDelimiterRight) + if (buffer.slice(-outputDelimiterRight.length) !== outputDelimiterRight) { + throw new TokenizationError( + `output "${ellipsis(buffer, 16)}" not closed`, + new Token(buffer, input, line, col, file) + ) + } + const value = buffer.slice(outputDelimiterLeft.length, -outputDelimiterRight.length) + return new OutputToken(buffer, value, input, line, col, options, file) + } + + readVariable () { + this.readBlank() + let ans = '' + while (this.peekType() & VARIABLE) ans += this.read() + return ans + } + + readHashes () { + const hashes = [] + while (true) { + const hash = this.readHash() + if (!hash) return hashes + hashes.push(hash) + } + } + + readHash () { + this.readBlank() + if (this.peek() === ',') this.read() + const name = this.readVariable() + if (!name) return null + + this.readBlank() + let value = '' + if (this.peek() === ':') { + this.read() + value = this.readValue() + } + return [name, value] + } + + readPropertyAccess () { + this.readBlank() + let ans = '' + let nested = 0 + while (this.p < this.N) { + const c = this.peek() + const code = this.peekType() + if (c === '[') { + ans += this.read() + this.readValue() + nested++ + } else if (c === ']') { + if (!nested) break + ans += this.read() + nested-- + } else if (c === '.') { + if (this.peekType(1) & VARIABLE) { + ans += this.read() + ans += this.readVariable() + } else break + } else if (code & VARIABLE) { + ans += this.read() + } else { + if (nested) this.read() + else break + } + } + return ans + } + readTo (end: string) { + let ans = '' + while (this.p < this.N) { + ans += this.read() + if (ans.slice(-end.length) === end) break + } + return ans + } + readValue () { + let val = this.readQuoted() + if (val) return val + val = this.readBoolean() + if (val) return val + val = this.readPropertyAccess() + if (val) return val + return this.readRange() + } + readRange () { + this.readBlank() + if (this.peek() !== '(') return '' + let ans = this.read() + ans += this.readValue() + ans += this.read(2) + ans += this.readValue() + ans += this.read() + return ans + } + readBoolean () { + this.readBlank() + if (this.peekWord(4) === 'true' && !(this.peekType(4) & VARIABLE)) return this.read(4) + if (this.peekWord(5) === 'false' && !(this.peekType(5) & VARIABLE)) return this.read(5) + return '' + } + readQuoted () { + this.readBlank() + if (!(this.peekType() & QUOTE)) return '' + let ans = this.read() + let escaped = false + while (this.p < this.N) { + const c = this.read() + ans += c + if (c === ans[0] && !escaped) break + if (escaped) escaped = false + else if (c === '\\') escaped = true + } + return ans + } + read (n = 1): string { + const c = this.input[this.p++] + if (c === '\n') { + this.line++ + this.col = 1 + } else { + this.col++ + } + return n === 1 ? c : c + this.read(n - 1) + } + peekWord (n: number) { + return this.input.substr(this.p, n) + } + peekType (n = 0) { + return +TYPES[this.input.charCodeAt(this.p + n)] + } + peek (n = 0) { + return this.input[this.p + n] + } + readBlank () { + let ans = '' + while (this.peekType() & BLANK) ans += this.read() + return ans + } } diff --git a/src/parser/whitespace-ctrl.ts b/src/parser/whitespace-ctrl.ts index 46c9638f4..17e03e5e8 100644 --- a/src/parser/whitespace-ctrl.ts +++ b/src/parser/whitespace-ctrl.ts @@ -28,12 +28,12 @@ function trimLeft (token: Token, greedy: boolean) { if (!token || !HTMLToken.is(token)) return const rLeft = greedy ? /\s+$/g : /[\t\r ]*$/g - token.value = token.value.replace(rLeft, '') + token.content = token.content.replace(rLeft, '') } function trimRight (token: Token, greedy: boolean) { if (!token || !HTMLToken.is(token)) return const rRight = greedy ? /^\s+/g : /^[\t\r ]*\n?/g - token.value = token.value.replace(rRight, '') + token.content = token.content.replace(rRight, '') } diff --git a/src/render/expression.ts b/src/render/expression.ts index 144c735ae..8c0e7b1d8 100644 --- a/src/render/expression.ts +++ b/src/render/expression.ts @@ -4,14 +4,15 @@ import { Value } from './value' import { Context } from '../context/context' import { toValue } from '../util/underscore' import { isOperator, precedence, operatorImpls } from './operator' -import { tokenize } from '../parser/expression-tokenizer' +import { Tokenizer } from '../parser/tokenizer' export class Expression { private operands: any[] = [] private postfix: string[] public constructor (str = '') { - this.postfix = [...toPostfix(tokenize(str))] + const tokenizer = new Tokenizer(str) + this.postfix = [...toPostfix(tokenizer.readExpression())] } public * evaluate (ctx: Context) { assert(ctx, 'unable to evaluate: context not defined') diff --git a/src/render/operator.ts b/src/render/operator.ts index fdc28372d..b1d5fc10b 100644 --- a/src/render/operator.ts +++ b/src/render/operator.ts @@ -52,8 +52,6 @@ export const operatorImpls: {[key: string]: (lhs: any, rhs: any) => boolean} = { 'or': (l: any, r: any) => isTruthy(l) || isTruthy(r) } -const list = Object.keys(precedence) - export function isOperator (token: string) { - return list.includes(token) + return precedence.hasOwnProperty(token) } diff --git a/src/render/range.ts b/src/render/range.ts index 275a31b7d..a78956886 100644 --- a/src/render/range.ts +++ b/src/render/range.ts @@ -3,8 +3,8 @@ import { Context } from '../context/context' import { range } from '../util/underscore' import { Value } from './value' -export function isRange (token: string) { - return token[0] === '(' && token[token.length - 1] === ')' +export function isRange (str: string) { + return rangeLine.test(str) } export function * rangeValue (token: string, ctx: Context) { diff --git a/src/template/filter/filter-map.ts b/src/template/filter/filter-map.ts index 83df9ff37..af6b4264e 100644 --- a/src/template/filter/filter-map.ts +++ b/src/template/filter/filter-map.ts @@ -1,5 +1,6 @@ import { FilterImplOptions } from './filter-impl-options' -import { Filter, FilterArgs } from './filter' +import { Filter } from './filter' +import { FilterArg } from '../../parser/filter-arg' import { assert } from '../../util/assert' export class FilterMap { @@ -17,7 +18,7 @@ export class FilterMap { this.impls[name] = impl } - create (name: string, args: FilterArgs) { + create (name: string, args: FilterArg[]) { return new Filter(name, this.get(name), args) } } diff --git a/src/template/filter/filter.ts b/src/template/filter/filter.ts index 614fee54d..6cec08682 100644 --- a/src/template/filter/filter.ts +++ b/src/template/filter/filter.ts @@ -1,18 +1,15 @@ import { Expression } from '../../render/expression' import { Context } from '../../context/context' -import { isArray, identify } from '../../util/underscore' +import { identify } from '../../util/underscore' import { FilterImplOptions } from './filter-impl-options' - -type KeyValuePair = [string?, string?] -type FilterArg = string|KeyValuePair -export type FilterArgs = FilterArg[] +import { FilterArg, isKeyValuePair } from '../../parser/filter-arg' export class Filter { public name: string - public args: FilterArgs + public args: FilterArg[] private impl: FilterImplOptions - public constructor (name: string, impl: FilterImplOptions, args: FilterArgs) { + public constructor (name: string, impl: FilterImplOptions, args: FilterArg[]) { this.name = name this.impl = impl || identify this.args = args @@ -26,7 +23,3 @@ export class Filter { return this.impl.apply({ context }, [value, ...argv]) } } - -function isKeyValuePair (arr: FilterArg): arr is KeyValuePair { - return isArray(arr) -} diff --git a/src/template/html.ts b/src/template/html.ts index 5f9a70b1c..0027fe280 100644 --- a/src/template/html.ts +++ b/src/template/html.ts @@ -8,7 +8,7 @@ export class HTML extends TemplateImpl implements Template { private str: string public constructor (token: HTMLToken) { super(token) - this.str = token.value + this.str = token.content } public * render (ctx: Context, emitter: Emitter): IterableIterator { emitter.write(this.str) diff --git a/src/template/output.ts b/src/template/output.ts index a74615858..36281c931 100644 --- a/src/template/output.ts +++ b/src/template/output.ts @@ -11,7 +11,7 @@ export class Output extends TemplateImpl implements Template { private value: Value public constructor (token: OutputToken, filters: FilterMap) { super(token) - this.value = new Value(token.value, filters) + this.value = new Value(token.content, filters) } public * render (ctx: Context, emitter: Emitter) { const val = yield this.value.value(ctx) diff --git a/src/template/tag/hash.ts b/src/template/tag/hash.ts index 0154485a0..2d7825d3c 100644 --- a/src/template/tag/hash.ts +++ b/src/template/tag/hash.ts @@ -1,31 +1,28 @@ -import { hashCapture } from '../../parser/lexical' import { Expression } from '../../render/expression' import { Context } from '../../context/context' +import { Tokenizer } from '../../parser/tokenizer' /** * Key-Value Pairs Representing Tag Arguments * Example: - * For the markup `{% include 'head.html' foo='bar' %}`, + * For the markup `, foo:'bar', coo:2 reversed %}`, * hash['foo'] === 'bar' + * hash['coo'] === 2 + * hash['reversed'] === undefined */ export class Hash { [key: string]: any - private static parse (markup: string) { - const instance = new Hash() - let match - hashCapture.lastIndex = 0 - while ((match = hashCapture.exec(markup))) { - const k = match[1] - const v = match[2] - instance[k] = v + constructor (markup: string) { + const tokenizer = new Tokenizer(markup) + for (const [name, value] of tokenizer.readHashes()) { + this[name] = value } - return instance } - public static * create (markup: string, ctx: Context) { - const instance = Hash.parse(markup) - for (const key of Object.keys(instance)) { - instance[key] = yield new Expression(instance[key]).evaluate(ctx) + * render (ctx: Context) { + const hash = {} + for (const key of Object.keys(this)) { + hash[key] = yield new Expression(this[key]).evaluate(ctx) } - return instance + return hash } } diff --git a/src/template/tag/tag-impl-options.ts b/src/template/tag/tag-impl-options.ts index 954890f84..a57e1ffde 100644 --- a/src/template/tag/tag-impl-options.ts +++ b/src/template/tag/tag-impl-options.ts @@ -7,5 +7,5 @@ import { Emitter } from '../../render/emitter' export interface TagImplOptions { parse?: (this: TagImpl, token: TagToken, remainingTokens: Token[]) => void; - render: (this: TagImpl, ctx: Context, hash: Hash, emitter: Emitter) => any; + render: (this: TagImpl, ctx: Context, emitter: Emitter, hash: Hash) => any; } diff --git a/src/template/tag/tag.ts b/src/template/tag/tag.ts index f6931d687..13be5e753 100644 --- a/src/template/tag/tag.ts +++ b/src/template/tag/tag.ts @@ -22,8 +22,8 @@ export class Tag extends TemplateImpl implements Template { } } public * render (ctx: Context, emitter: Emitter) { - const hash = yield Hash.create(this.token.args, ctx) + const hash = yield new Hash(this.token.args).render(ctx) const impl = this.impl - if (isFunction(impl.render)) return yield impl.render(ctx, hash, emitter) + if (isFunction(impl.render)) return yield impl.render(ctx, emitter, hash) } } diff --git a/src/template/value.ts b/src/template/value.ts index 0ed413775..47396aad7 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -1,6 +1,7 @@ import { Expression } from '../render/expression' +import { Tokenizer } from '../parser/tokenizer' import { FilterMap } from '../template/filter/filter-map' -import { FilterArgs, Filter } from './filter/filter' +import { Filter } from './filter/filter' import { Context } from '../context/context' export class Value { @@ -8,43 +9,12 @@ export class Value { public readonly initial: string /** - * @param str value string, like: "i have a dream | truncate: 3 + * @param str the value to be valuated, eg.: "foobar" | truncate: 3 */ public constructor (str: string, private readonly filterMap: FilterMap) { - const tokens = Value.tokenize(str) - 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 as string) - } - argValue = argName = undefined - } else if (tokens[i] === ':') { - argName = argValue - argValue = undefined - } else if (argValue === undefined) { - argValue = tokens[i] - } - } - this.filters.push(new Filter(name, this.filterMap.get(name), args)) + const tokenizer = new Tokenizer(str) + this.initial = tokenizer.readValue() + this.filters = tokenizer.readFilterTokens().map(({ name, args }) => new Filter(name, this.filterMap.get(name), args)) } public * value (ctx: Context) { let val = yield new Expression(this.initial).evaluate(ctx) @@ -53,30 +23,4 @@ export class Value { } return val } - public static tokenize (str: string): ('|' | ',' | ':' | 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++ - let ch - for (; i < str.length && !/[|,:\s]/.test(ch = str[i]); ++i) { - if (ch === '"' || ch === "'") { - for (i += 2; i < str.length && str[i - 1] !== ch; ++i); - } - } - tokens.push(str.slice(j, i)) - } - } - return tokens - } } diff --git a/src/types.ts b/src/types.ts index 2c55551ea..c29eea062 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,4 +9,5 @@ export { Template } from './template/template' export { TagImplOptions } from './template/tag/tag-impl-options' export { ParseStream } from './parser/parse-stream' export { Token } from './parser/token' +export { Tokenizer } from './parser/tokenizer' export { Hash } from './template/tag/hash' diff --git a/src/util/collection.ts b/src/util/collection.ts new file mode 100644 index 000000000..9f890e479 --- /dev/null +++ b/src/util/collection.ts @@ -0,0 +1,8 @@ +import { isString, isObject, isArray } from './underscore' + +export function toCollection (val: any) { + if (isArray(val)) return val + if (isString(val) && val.length > 0) return [val] + if (isObject(val)) return Object.keys(val).map((key) => [key, val[key]]) + return [] +} diff --git a/src/util/underscore.ts b/src/util/underscore.ts index 9bbc0ec10..51f3aa278 100644 --- a/src/util/underscore.ts +++ b/src/util/underscore.ts @@ -123,3 +123,7 @@ export function changeCase (str: string): string { const hasLowerCase = [...str].some(ch => ch >= 'a' && ch <= 'z') return hasLowerCase ? str.toUpperCase() : str.toLowerCase() } + +export function ellipsis (str: string, N: number): string { + return str.length > N ? str.substr(0, N - 3) + '...' : str +} diff --git a/test/integration/builtin/tags/include.ts b/test/integration/builtin/tags/include.ts index 5daae8e0a..6daf2729e 100644 --- a/test/integration/builtin/tags/include.ts +++ b/test/integration/builtin/tags/include.ts @@ -30,12 +30,11 @@ describe('tags/include', function () { it('should throw when not specified', function () { mock({ - '/parent.html': '{%include%}' + '/parent.html': '{%include , %}' }) return liquid.renderFile('/parent.html').catch(function (e) { - console.log(e) - expect(e.name).to.equal('RenderError') - expect(e.message).to.match(/cannot include with empty filename/) + expect(e.name).to.equal('ParseError') + expect(e.message).to.match(/illegal argument ","/) }) }) @@ -45,7 +44,7 @@ describe('tags/include', function () { }) return liquid.renderFile('/parent.html').catch(function (e) { expect(e.name).to.equal('RenderError') - expect(e.message).to.match(/cannot include with empty filename/) + expect(e.message).to.match(/illegal filename/) }) }) @@ -183,7 +182,7 @@ describe('tags/include', function () { }) it('should support template string', function () { mock({ - '/current.html': 'bar{% include name" %}bar', + '/current.html': 'bar{% include name %}bar', '/bar/foo.html': 'foo' }) const html = liquid.renderFileSync('/current.html', { name: '/bar/foo.html' }) diff --git a/test/integration/builtin/tags/layout.ts b/test/integration/builtin/tags/layout.ts index de6eae0eb..4e9926f4d 100644 --- a/test/integration/builtin/tags/layout.ts +++ b/test/integration/builtin/tags/layout.ts @@ -25,8 +25,8 @@ describe('tags/layout', function () { '/parent.html': '{%layout%}' }) return liquid.renderFile('/parent.html').catch(function (e) { - expect(e.name).to.equal('RenderError') - expect(e.message).to.match(/cannot apply layout with empty filename/) + expect(e.name).to.equal('ParseError') + expect(e.message).to.match(/illegal argument ""/) }) }) describe('anonymous block', function () { @@ -57,6 +57,14 @@ describe('tags/layout', function () { const html = await liquid.parseAndRender(src) return expect(html).to.equal('XAYBZ') }) + it('should support variable as layout name', async function () { + mock({ + '/parent.html': 'X{% block "a"%}{% endblock %}Y' + }) + const src = '{% layout parent %}{%block a%}A{%endblock%}' + const html = await liquid.parseAndRender(src, { parent: 'parent.html' }) + return expect(html).to.equal('XAY') + }) it('should support default block content', async function () { mock({ '/parent.html': 'X{% block "a"%}A{% endblock %}Y{% block b%}B{%endblock%}Z' @@ -74,7 +82,7 @@ describe('tags/layout', function () { const html = await liquid.renderFile('/main.html') return expect(html).to.equal('XAY') }) - it('should not bleed scope into included layout', async function () { + it('should not bleed scope into `include` layout', async function () { mock({ '/parent.html': 'X{%block a%}{%endblock%}Y{%block b%}{%endblock%}Z', '/main.html': '{%layout "parent"%}' + @@ -85,6 +93,17 @@ describe('tags/layout', function () { const html = await liquid.renderFile('main') return expect(html).to.equal('XAYIXaYZJZ') }) + it('should not bleed scope into `render` layout', async function () { + mock({ + '/parent.html': 'X{%block a%}{%endblock%}Y{%block b%}{%endblock%}Z', + '/main.html': '{%layout "parent"%}' + + '{%block a%}A{%endblock%}' + + '{%block b%}I{%render "included"%}J{%endblock%}', + '/included.html': '{%layout "parent"%}{%block a%}a{%endblock%}' + }) + const html = await liquid.renderFile('main') + return expect(html).to.equal('XAYIXaYZJZ') + }) it('should support hash list', async function () { mock({ '/parent.html': '{{color}}{%block%}{%endblock%}', diff --git a/test/integration/builtin/tags/render.ts b/test/integration/builtin/tags/render.ts index cb53c4ffc..797a0e547 100644 --- a/test/integration/builtin/tags/render.ts +++ b/test/integration/builtin/tags/render.ts @@ -17,7 +17,7 @@ describe('tags/render', function () { '/bar/foo.html': 'foo' }) const html = await liquid.renderFile('/current.html') - return expect(html).to.equal('barfoobar') + expect(html).to.equal('barfoobar') }) it('should support template string', async function () { mock({ @@ -25,7 +25,7 @@ describe('tags/render', function () { '/bar/foo.html': 'foo' }) const html = await liquid.renderFile('/current.html', { name: 'foo.html' }) - return expect(html).to.equal('barfoobar') + expect(html).to.equal('barfoobar') }) it('should throw when not specified', function () { @@ -33,9 +33,8 @@ describe('tags/render', function () { '/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/) + expect(e.name).to.equal('ParseError') + expect(e.message).to.match(/illegal argument ""/) }) }) @@ -45,7 +44,7 @@ describe('tags/render', function () { }) return liquid.renderFile('/parent.html').catch(function (e) { expect(e.name).to.equal('RenderError') - expect(e.message).to.match(/cannot render with empty filename/) + expect(e.message).to.match(/illegal filename "not-exist":"undefined"/) }) }) @@ -55,7 +54,7 @@ describe('tags/render', function () { '/foo/relative.html': 'bar{% render "../bar/foo.html" %}bar' }) const html = await liquid.renderFile('foo/relative.html') - return expect(html).to.equal('barfoobar') + expect(html).to.equal('barfoobar') }) it('should support render: hash list', async function () { @@ -64,7 +63,7 @@ describe('tags/render', function () { '/user.html': '{{role}} : {{alias}}' }) const html = await liquid.renderFile('hash.html') - return expect(html).to.equal('admin : harttle') + expect(html).to.equal('admin : harttle') }) it('should not bleed into child template', async function () { @@ -73,7 +72,7 @@ describe('tags/render', function () { '/user.html': 'InChild: {{name}}' }) const html = await liquid.renderFile('hash.html') - return expect(html).to.equal('InParent: harttle InChild: ') + expect(html).to.equal('InParent: harttle InChild: ') }) it('should be able to access globals', async function () { @@ -86,16 +85,49 @@ describe('tags/render', function () { }, { globals: { name: 'Harttle' } }) - return expect(html).to.equal('InParent: harttle InChild: Harttle') + expect(html).to.equal('InParent: harttle InChild: Harttle') }) - it('should support render: with', async function () { + it('should support 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') + expect(html).to.equal('color:red, shape:rect') + }) + it('should support with...as', async function () { + mock({ + '/with.html': '{% render "color" with color as c %}', + '/color.html': 'color:{{c}}' + }) + const html = await liquid.renderFile('with.html', { color: 'red' }) + expect(html).to.equal('color:red') + }) + it('should support with...as and other parameters', async function () { + mock({ + '/index.html': '{% render "item" with color as c, s: shape %}', + '/item.html': 'color:{{c}}, shape:{{s}}' + }) + const scope = { color: 'red', shape: 'rect' } + const html = await liquid.renderFile('index.html', scope) + expect(html).to.equal('color:red, shape:rect') + }) + it('should support for...as', async function () { + mock({ + '/index.html': '{% render "item" for colors as color %}', + '/item.html': '{{forloop.index}}: {{color}}\n' + }) + const html = await liquid.renderFile('index.html', { colors: ['red', 'green'] }) + expect(html).to.equal('1: red\n2: green\n') + }) + it('should support for...as with other parameters', async function () { + mock({ + '/index.html': '{% render "item" for colors as color with ".\n" as tail, sep: ". "%}', + '/item.html': '{{forloop.index}}{{sep}}{{color}}{{tail}}' + }) + const html = await liquid.renderFile('index.html', { colors: ['red', 'green'] }) + expect(html).to.equal('1. red.\n2. green.\n') }) it('should support render: with as Drop', async function () { class ColorDrop extends Drop { @@ -141,7 +173,7 @@ describe('tags/render', function () { } } const html = await liquid.renderFile('personInfo.html', ctx) - return expect(html).to.equal('This is a person

Joe Shmoe
City: Dallas

') + expect(html).to.equal('This is a person

Joe Shmoe
City: Dallas

') }) describe('static partial', function () { @@ -152,7 +184,7 @@ describe('tags/render', function () { }) const staticLiquid = new Liquid({ dynamicPartials: false, root: '/' }) const html = await staticLiquid.renderFile('parent.html') - return expect(html).to.equal('Xchild with redY') + expect(html).to.equal('Xchild with redY') }) it('should support parent paths', async function () { @@ -162,7 +194,7 @@ describe('tags/render', function () { }) const staticLiquid = new Liquid({ dynamicPartials: false, root: '/' }) const html = await staticLiquid.renderFile('parent.html') - return expect(html).to.equal('XchildY') + expect(html).to.equal('XchildY') }) it('should support subpaths', async function () { @@ -172,7 +204,7 @@ describe('tags/render', function () { }) const staticLiquid = new Liquid({ dynamicPartials: false, root: '/' }) const html = await staticLiquid.renderFile('parent.html') - return expect(html).to.equal('XchildY') + expect(html).to.equal('XchildY') }) it('should support comma separated arguments', async function () { @@ -182,7 +214,7 @@ describe('tags/render', function () { }) const staticLiquid = new Liquid({ dynamicPartials: false, root: '/' }) const html = await staticLiquid.renderFile('parent.html') - return expect(html).to.equal('Xchild with redY') + expect(html).to.equal('Xchild with redY') }) }) describe('sync support', function () { @@ -192,23 +224,31 @@ describe('tags/render', function () { '/bar/foo.html': 'foo' }) const html = liquid.renderFileSync('/current.html') - return expect(html).to.equal('barfoobar') + expect(html).to.equal('barfoobar') }) - it('should support template string', function () { + it('should support value string', function () { mock({ - '/current.html': 'bar{% render name" %}bar', + '/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') + expect(html).to.equal('barfoobar') }) - it('should support render: with', function () { + it('should support template string', function () { + mock({ + '/current.html': 'bar{% render "/bar/{{name}}" %}bar', + '/bar/foo.html': 'foo' + }) + const html = liquid.renderFileSync('/current.html', { name: '/foo.html' }) + expect(html).to.equal('barfoobar') + }) + it('should support 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') + expect(html).to.equal('color:red, shape:rect') }) it('should support filename with extention', function () { mock({ @@ -217,7 +257,7 @@ describe('tags/render', function () { }) const staticLiquid = new Liquid({ dynamicPartials: false, root: '/' }) const html = staticLiquid.renderFileSync('parent.html') - return expect(html).to.equal('Xchild with redY') + expect(html).to.equal('Xchild with redY') }) }) }) diff --git a/test/integration/liquid/cache.ts b/test/integration/liquid/cache.ts index ff66238ad..62b80b290 100644 --- a/test/integration/liquid/cache.ts +++ b/test/integration/liquid/cache.ts @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { Liquid } from '../../../src/liquid' +import { Liquid, Template } from '../../../src/liquid' import { mock, restore } from '../../stub/mockfs' describe('LiquidOptions#cache', function () { @@ -18,6 +18,19 @@ describe('LiquidOptions#cache', function () { const y = await engine.renderFile('files/foo') expect(y).to.equal('bar') }) + it('should be disabled when cache <= 0', async function () { + const engine = new Liquid({ + root: '/root/', + extname: '.html', + cache: -1 + }) + mock({ '/root/files/foo.html': 'foo' }) + const x = await engine.renderFile('files/foo') + expect(x).to.equal('foo') + mock({ '/root/files/foo.html': 'bar' }) + const y = await engine.renderFile('files/foo') + expect(y).to.equal('bar') + }) it('should respect cache=true option', async function () { const engine = new Liquid({ root: '/root/', @@ -31,6 +44,44 @@ describe('LiquidOptions#cache', function () { const y = await engine.renderFile('files/foo') expect(y).to.equal('foo') }) + it('should respect cache=2 option', async function () { + const engine = new Liquid({ + root: '/root/', + extname: '.html', + cache: 2 + }) + mock({ '/root/files/foo.html': 'foo' }) + mock({ '/root/files/bar.html': 'bar' }) + mock({ '/root/files/coo.html': 'coo' }) + await engine.renderFile('files/foo') + mock({ '/root/files/foo.html': 'FOO' }) + await engine.renderFile('files/bar') + const x = await engine.renderFile('files/foo') + expect(x).to.equal('foo') + + await engine.renderFile('files/bar') + await engine.renderFile('files/coo') + const y = await engine.renderFile('files/foo') + expect(y).to.equal('FOO') + }) + it('should respect cache={} option', async function () { + let last: Template[] | undefined + const engine = new Liquid({ + root: '/root/', + extname: '.html', + cache: { + read: (): Template[] | undefined => last, + has: (): boolean => !!last, + write: (key: string, value: Template[]) => { last = value } + } + }) + mock({ '/root/files/foo.html': 'foo' }) + mock({ '/root/files/bar.html': 'bar' }) + mock({ '/root/files/coo.html': 'coo' }) + expect(await engine.renderFile('files/foo')).to.equal('foo') + expect(await engine.renderFile('files/bar')).to.equal('foo') + expect(await engine.renderFile('files/coo')).to.equal('foo') + }) it('should not cache not exist file', async function () { const engine = new Liquid({ root: '/root/', diff --git a/test/integration/util/error.ts b/test/integration/util/error.ts index ddad51ba3..eb6a85324 100644 --- a/test/integration/util/error.ts +++ b/test/integration/util/error.ts @@ -1,8 +1,11 @@ -import { expect } from 'chai' +import { expect, use } from 'chai' import { RenderError } from '../../../src/util/error' import { Liquid } from '../../../src/liquid' import * as path from 'path' import { mock, restore } from '../../stub/mockfs' +import * as chaiAsPromised from 'chai-as-promised' + +use(chaiAsPromised) let engine = new Liquid() const strictEngine = new Liquid({ diff --git a/test/stub/mockfs.ts b/test/stub/mockfs.ts index 2a75bd8ef..a06d592e7 100644 --- a/test/stub/mockfs.ts +++ b/test/stub/mockfs.ts @@ -1,5 +1,5 @@ import { isString, forOwn } from '../../src/util/underscore' -import fs from '../../src/fs/node' +import * as fs from '../../src/fs/node' import { resolve } from 'path' interface FileDescriptor { @@ -15,28 +15,28 @@ export function mock (options: { [path: string]: (string | FileDescriptor) }) { files[resolve(key)] = isString(val) ? { mode: '33188', content: val } : val as FileDescriptor - }) - fs.readFile = async function (path) { + }); + (fs as any).readFile = async function (path: string) { return fs.readFileSync(path) - } - fs.readFileSync = function (path) { + }; + (fs as any).readFileSync = function (path: string) { const file = files[path] if (file === undefined) throw new Error('ENOENT') if (file.mode === '0000') throw new Error('EACCES') return file.content - } - fs.exists = async function (path: string) { + }; + (fs as any).exists = async function (path: string) { return fs.existsSync(path) - } - fs.existsSync = function (path: string) { + }; + (fs as any).existsSync = function (path: string) { return !!files[path] } } export function restore () { - files = {} - fs.readFileSync = readFileSync - fs.existsSync = existsSync - fs.readFile = readFile - fs.exists = exists + files = {}; + (fs as any).readFileSync = readFileSync; + (fs as any).existsSync = existsSync; + (fs as any).readFile = readFile; + (fs as any).exists = exists } diff --git a/test/unit/cache/lru.ts b/test/unit/cache/lru.ts new file mode 100644 index 000000000..dd57ac0c8 --- /dev/null +++ b/test/unit/cache/lru.ts @@ -0,0 +1,36 @@ +import { expect } from 'chai' +import { LRU } from '../../../src/cache/lru' + +describe('LRU', () => { + it('should perform read()/write()', () => { + const lru = new LRU(2) + expect(lru.limit).to.equal(2) + + lru.write('foo', 'FOO') + lru.write('bar', 'BAR') + expect(lru.read('foo')).to.equal('FOO') + expect(lru.read('bar')).to.equal('BAR') + }) + it('should perform clear()', () => { + const lru = new LRU(2) + lru.write('foo', 'FOO') + lru.write('bar', 'BAR') + expect(lru.size).to.equal(2) + lru.clear() + expect(lru.size).to.equal(0) + expect(lru.read('foo')).to.be.undefined + }) + it('should remove lrc item when full(2)', () => { + const lru = new LRU(2) + expect(lru.size).to.equal(0) + lru.write('foo', 'FOO') + expect(lru.size).to.equal(1) + lru.write('bar', 'BAR') + expect(lru.size).to.equal(2) + lru.write('coo', 'COO') + expect(lru.size).to.equal(2) + expect(lru.read('foo')).to.be.undefined + expect(lru.read('bar')).to.equal('BAR') + expect(lru.read('coo')).to.equal('COO') + }) +}) diff --git a/test/unit/fs/browser.ts b/test/unit/fs/browser.ts index 6b65856c0..9588c33d3 100644 --- a/test/unit/fs/browser.ts +++ b/test/unit/fs/browser.ts @@ -1,4 +1,4 @@ -import fs from '../../../src/fs/browser' +import * as fs from '../../../src/fs/browser' import * as sinon from 'sinon' import { expect, use } from 'chai' import * as chaiAsPromised from 'chai-as-promised' diff --git a/test/unit/fs/node.ts b/test/unit/fs/node.ts index 574686402..f690c3520 100644 --- a/test/unit/fs/node.ts +++ b/test/unit/fs/node.ts @@ -1,4 +1,4 @@ -import fs from '../../../src/fs/node' +import * as fs from '../../../src/fs/node' import * as path from 'path' import { expect, use } from 'chai' import * as chaiAsPromised from 'chai-as-promised' diff --git a/test/unit/parser/expression-tokenizer.ts b/test/unit/parser/expression-tokenizer.ts deleted file mode 100644 index 8e817bfc1..000000000 --- a/test/unit/parser/expression-tokenizer.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { tokenize } from '../../../src/parser/expression-tokenizer' -import { expect } from 'chai' - -describe('expression tokenizer', () => { - describe('spaces', () => { - it('should tokenize a + b', () => { - expect([...tokenize('a + b')]).to.deep.equal(['a', '+', 'b']) - }) - it('should tokenize a==1', () => { - expect([...tokenize('a==1')]).to.deep.equal(['a', '==', '1']) - }) - }) - - describe('range', () => { - it('should tokenize (1..3) contains 3', () => { - expect([...tokenize('(1..3)')]).to.deep.equal(['(1..3)']) - }) - }) - - describe('bracket', () => { - it('should tokenize a[b] = c', () => { - expect([...tokenize('a[b] = c')]).to.deep.equal(['a[b]', '=', 'c']) - }) - it('should tokenize c[a["b"]] < c', () => { - expect([...tokenize('c[a["b"]] < c')]).to.deep.equal(['c[a["b"]]', '<', 'c']) - }) - it('should tokenize "][" == var', () => { - expect([...tokenize('"][" == var')]).to.deep.equal(['"]["', '==', 'var']) - }) - }) - - describe('quotes', () => { - it('should tokenize " " == var', () => { - expect([...tokenize('" " == var')]).to.deep.equal(['" "', '==', 'var']) - }) - it('should tokenize "\\\'" == var', () => { - expect([...tokenize('"\\\'" == var')]).to.deep.equal(['"\\\'"', '==', 'var']) - }) - it('should tokenize "\\"" == var', () => { - expect([...tokenize('"\\"" == var')]).to.deep.equal(['"\\""', '==', 'var']) - }) - }) -}) diff --git a/test/unit/parser/lexical.ts b/test/unit/parser/lexical.ts deleted file mode 100644 index 93fdf3778..000000000 --- a/test/unit/parser/lexical.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as chai from 'chai' -import { isRange } from '../../../src/parser/lexical' - -const expect = chai.expect - -describe('lexical', function () { - it('should test range literal', function () { - expect(isRange('(12..32)')).to.equal(true) - expect(isRange('(12..foo)')).to.equal(true) - expect(isRange('(foo.bar..foo)')).to.equal(true) - }) -}) diff --git a/test/unit/parser/tokenizer.ts b/test/unit/parser/tokenizer.ts index 26ef8e4d2..d9740c139 100644 --- a/test/unit/parser/tokenizer.ts +++ b/test/unit/parser/tokenizer.ts @@ -4,84 +4,207 @@ import { TagToken } from '../../../src/parser/tag-token' import { OutputToken } from '../../../src/parser/output-token' import { HTMLToken } from '../../../src/parser/html-token' -describe('tokenizer', function () { - const tokenizer = new Tokenizer() - describe('#tokenize()', function () { - it('should handle plain HTML', function () { - const html = '

Lorem Ipsum

' - const tokens = tokenizer.tokenize(html) +describe('Tokenize', function () { + it('should read quoted', () => { + expect(new Tokenizer('"foo" ff').readQuoted()).to.equal('"foo"') + expect(new Tokenizer(' "foo"ff').readQuoted()).to.equal('"foo"') + }) + it('should read property access', () => { + expect(new Tokenizer('a[ b][ "c d" ]').readPropertyAccess()).to.equal('a[b]["c d"]') + expect(new Tokenizer('a.b[c[d.e]]').readPropertyAccess()).to.equal('a.b[c[d.e]]') + }) + it('should read value', () => { + expect(new Tokenizer('2.33.2').readValue()).to.equal('2.33.2') + expect(new Tokenizer('"foo"a').readValue()).to.equal('"foo"') + expect(new Tokenizer('a[b]["c d"]').readValue()).to.equal('a[b]["c d"]') + }) + it('should read hash', () => { + expect(new Tokenizer('foo: 3').readHash()).to.deep.equal(['foo', '3']) + expect(new Tokenizer(', foo: a[ "bar"]').readHash()).to.deep.equal(['foo', 'a["bar"]']) + }) + it('should read hashs', () => { + expect(new Tokenizer(', limit: 3 reverse offset:off').readHashes()) + .to.deep.equal([['limit', '3'], ['reverse', ''], ['offset', 'off']]) + expect(new Tokenizer('cols: 2, rows: data["rows"]').readHashes()) + .to.deep.equal([['cols', '2'], ['rows', 'data["rows"]']]) + }) + it('should read HTML token', function () { + const html = '

Lorem Ipsum

' + const tokenizer = new Tokenizer(html) + const tokens = tokenizer.readTokens() - expect(tokens.length).to.equal(1) - expect(tokens[0].value).to.equal(html) - expect(tokens[0]).instanceOf(HTMLToken) - }) - it('should handle tag syntax', function () { - const html = '

{% for p in a[1]%}

' - const tokens = tokenizer.tokenize(html) + expect(tokens.length).to.equal(1) + expect(tokens[0].content).to.equal(html) + expect(tokens[0]).instanceOf(HTMLToken) + }) + it('should read tag token', function () { + const html = '

{% for p in a[1]%}

' + const tokenizer = new Tokenizer(html) + const tokens = tokenizer.readTokens() - expect(tokens.length).to.equal(3) - expect(tokens[1]).instanceOf(TagToken) - expect(tokens[1].value).to.equal('for p in a[1]') - }) - it('should handle value syntax', function () { - const html = '

{{foo | date: "%Y-%m-%d"}}

' - const tokens = tokenizer.tokenize(html) + expect(tokens.length).to.equal(3) + expect(tokens[1]).instanceOf(TagToken) + expect(tokens[1].content).to.equal('for p in a[1]') + }) + it('should read value token', function () { + const html = '

{{foo | date: "%Y-%m-%d"}}

' + const tokenizer = new Tokenizer(html) + const tokens = tokenizer.readTokens() - expect(tokens.length).to.equal(3) - expect(tokens[1]).instanceOf(OutputToken) - expect(tokens[1].value).to.equal('foo | date: "%Y-%m-%d"') - }) - it('should handle consecutive value and tags', function () { - const html = '{{foo}}{{bar}}{%foo%}{%bar%}' - const tokens = tokenizer.tokenize(html) + expect(tokens.length).to.equal(3) + expect(tokens[1]).instanceOf(OutputToken) + expect(tokens[1].content).to.equal('foo | date: "%Y-%m-%d"') + }) + it('should handle consecutive value and tags', function () { + const html = '{{foo}}{{bar}}{%foo%}{%bar%}' + const tokenizer = new Tokenizer(html) + const tokens = tokenizer.readTokens() - expect(tokens.length).to.equal(4) - expect(tokens[0]).instanceOf(OutputToken) - expect(tokens[2]).instanceOf(TagToken) + expect(tokens.length).to.equal(4) + expect(tokens[0]).instanceOf(OutputToken) + expect(tokens[2]).instanceOf(TagToken) - expect(tokens[1].value).to.equal('bar') - expect(tokens[2].value).to.equal('foo') - }) - it('should keep white spaces and newlines', function () { - const html = '{%foo%}\n{%bar %} \n {%alice%}' - const tokens = tokenizer.tokenize(html) - expect(tokens.length).to.equal(5) - expect(tokens[1]).instanceOf(HTMLToken) - expect(tokens[1].raw).to.equal('\n') - expect(tokens[3]).instanceOf(HTMLToken) - expect(tokens[3].raw).to.equal(' \n ') - }) - it('should handle multiple lines tag', function () { - const html = '{%foo\na:a\nb:1.23\n%}' - const tokens = tokenizer.tokenize(html) - expect(tokens.length).to.equal(1) - expect(tokens[0]).instanceOf(TagToken) - expect((tokens[0] as TagToken).args).to.equal('a:a\nb:1.23') - expect(tokens[0].raw).to.equal('{%foo\na:a\nb:1.23\n%}') - }) - it('should handle multiple lines value', function () { - const html = '{{foo\n|date:\n"%Y-%m-%d"\n}}' - const tokens = tokenizer.tokenize(html) - expect(tokens.length).to.equal(1) - expect(tokens[0]).instanceOf(OutputToken) - expect(tokens[0].raw).to.equal('{{foo\n|date:\n"%Y-%m-%d"\n}}') - }) - it('should handle complex object property access', function () { - const html = '{{ obj["my:property with anything"] }}' - const tokens = tokenizer.tokenize(html) - expect(tokens.length).to.equal(1) - expect(tokens[0]).instanceOf(OutputToken) - expect(tokens[0].value).to.equal('obj["my:property with anything"]') - }) - it('should throw if tag not closed', function () { - expect(() => { - tokenizer.tokenize('{% assign foo = bar {{foo}}') - }).to.throw(/tag "{% assign foo..." not closed/) - }) - it('should throw if output not closed', function () { - expect(() => { - tokenizer.tokenize('{{name}') - }).to.throw(/output "{{name}" not closed/) - }) + expect(tokens[1].content).to.equal('bar') + expect(tokens[2].content).to.equal('foo') + }) + it('should keep white spaces and newlines', function () { + const html = '{%foo%}\n{%bar %} \n {%alice%}' + const tokenizer = new Tokenizer(html) + const tokens = tokenizer.readTokens() + expect(tokens.length).to.equal(5) + expect(tokens[1]).instanceOf(HTMLToken) + expect(tokens[1].raw).to.equal('\n') + expect(tokens[3]).instanceOf(HTMLToken) + expect(tokens[3].raw).to.equal(' \n ') + }) + it('should handle multiple lines tag', function () { + const html = '{%foo\na:a\nb:1.23\n%}' + const tokenizer = new Tokenizer(html) + const tokens = tokenizer.readTokens() + expect(tokens.length).to.equal(1) + expect(tokens[0]).instanceOf(TagToken) + expect((tokens[0] as TagToken).args).to.equal('a:a\nb:1.23') + expect(tokens[0].raw).to.equal('{%foo\na:a\nb:1.23\n%}') + }) + it('should handle multiple lines value', function () { + const html = '{{foo\n|date:\n"%Y-%m-%d"\n}}' + const tokenizer = new Tokenizer(html) + const tokens = tokenizer.readTokens() + expect(tokens.length).to.equal(1) + expect(tokens[0]).instanceOf(OutputToken) + expect(tokens[0].raw).to.equal('{{foo\n|date:\n"%Y-%m-%d"\n}}') + }) + it('should handle complex object property access', function () { + const html = '{{ obj["my:property with anything"] }}' + const tokenizer = new Tokenizer(html) + const tokens = tokenizer.readTokens() + expect(tokens.length).to.equal(1) + expect(tokens[0]).instanceOf(OutputToken) + expect(tokens[0].content).to.equal('obj["my:property with anything"]') + }) + it('should throw if tag not closed', function () { + const html = '{% assign foo = bar {{foo}}' + const tokenizer = new Tokenizer(html) + expect(() => tokenizer.readTokens()).to.throw(/tag "{% assign foo..." not closed/) + }) + it('should throw if output not closed', function () { + const tokenizer = new Tokenizer('{{name}') + expect(() => tokenizer.readTokens()).to.throw(/output "{{name}" not closed/) + }) + it('should read a simple filter', function () { + const tokenizer = new Tokenizer('| plus') + const token = tokenizer.readFilterToken() + expect(token).to.have.property('name', 'plus') + expect(token).to.have.property('args').to.deep.equal([]) + }) + it('should read a filter with argument', function () { + const tokenizer = new Tokenizer(' | plus: 1') + const token = tokenizer.readFilterToken() + expect(token).to.have.property('name', 'plus') + expect(token).to.have.property('args').to.deep.equal(['1']) + }) + it('should read a filter with colon but no argument', function () { + const tokenizer = new Tokenizer('| plus:') + const token = tokenizer.readFilterToken() + expect(token).to.have.property('name', 'plus') + expect(token).to.have.property('args').to.deep.equal([]) + }) + it('should read a filter with k/v argument', function () { + const tokenizer = new Tokenizer(' | plus: a:1') + const token = tokenizer.readFilterToken() + expect(token).to.have.property('name', 'plus') + expect(token).to.have.property('args').to.deep.equal([['a', '1']]) + }) + it('should read a filter with "arr[0]" argument', function () { + const tokenizer = new Tokenizer('| plus: arr[0]') + const token = tokenizer.readFilterToken() + expect(token).to.have.property('name', 'plus') + expect(token).to.have.property('args').to.deep.equal(['arr[0]']) + }) + it('should read a filter with obj.foo argument', function () { + const tokenizer = new Tokenizer('| plus: obj.foo') + const token = tokenizer.readFilterToken() + expect(token).to.have.property('name', 'plus') + expect(token).to.have.property('args').to.deep.equal(['obj.foo']) + }) + it('should read a filter with obj["foo"] argument', function () { + const tokenizer = new Tokenizer('| plus: obj["good luck"]') + const token = tokenizer.readFilterToken() + expect(token).to.have.property('name', 'plus') + expect(token).to.have.property('args').to.deep.equal(['obj["good luck"]']) + }) + it('should read simple filters', function () { + const tokenizer = new Tokenizer('| plus: 3 | capitalize') + const tokens = tokenizer.readFilterTokens() + + expect(tokens).to.have.lengthOf(2) + expect(tokens[0]).to.have.property('name', 'plus') + expect(tokens[0]).to.have.property('args').to.deep.equal(['3']) + expect(tokens[1]).to.have.property('name', 'capitalize') + expect(tokens[1]).to.have.property('args').to.deep.equal([]) + }) + it('should read filters', function () { + const tokenizer = new Tokenizer('| plus: 3 | capitalize | append: foo[a.b["c d"]]') + const tokens = tokenizer.readFilterTokens() + + expect(tokens).to.have.lengthOf(3) + expect(tokens[0]).to.have.property('name', 'plus') + expect(tokens[0]).to.have.property('args').to.deep.equal(['3']) + expect(tokens[1]).to.have.property('name', 'capitalize') + expect(tokens[1]).to.have.property('args').to.deep.equal([]) + expect(tokens[2]).to.have.property('name', 'append') + expect(tokens[2]).to.have.property('args').to.deep.equal(['foo[a.b["c d"]]']) + }) + it('should read expression `a==b`', () => { + const exp = new Tokenizer('a==b').readExpression() + expect([...exp]).to.deep.equal(['a', '==', 'b']) + }) + it('should read expression `^`', () => { + const exp = new Tokenizer('^').readExpression() + expect([...exp]).to.deep.equal([]) + }) + it('should read expression `a == b`', () => { + const exp = new Tokenizer('a == b').readExpression() + expect([...exp]).to.deep.equal(['a', '==', 'b']) + }) + it('should read expression `(1..3) contains 3`', () => { + const exp = new Tokenizer('(1..3) contains 3').readExpression() + expect([...exp]).to.deep.equal(['(1..3)', 'contains', '3']) + }) + it('should read expression `a[b] = c`', () => { + const exp = new Tokenizer('a[b] = c').readExpression() + expect([...exp]).to.deep.equal(['a[b]', '=', 'c']) + }) + it('should read expression `c[a["b"]] >= c`', () => { + const exp = new Tokenizer('c[a["b"]] >= c').readExpression() + expect([...exp]).to.deep.equal(['c[a["b"]]', '>=', 'c']) + }) + it('should read expression `"][" == var`', () => { + const exp = new Tokenizer('"][" == var').readExpression() + expect([...exp]).to.deep.equal(['"]["', '==', 'var']) + }) + it('should read expression `"\\\'" == "\\""`', () => { + const exp = new Tokenizer('"\\\'" == "\\""').readExpression() + expect([...exp]).to.deep.equal(['"\\\'"', '==', '"\\""']) }) }) diff --git a/test/unit/render/render.ts b/test/unit/render/render.ts index d88c36a25..78deef159 100644 --- a/test/unit/render/render.ts +++ b/test/unit/render/render.ts @@ -14,7 +14,7 @@ describe('render', function () { describe('.renderTemplates()', function () { it('should render html', async function () { const scope = new Context() - const token = { type: 'html', value: '

' } as Token + const token = { type: 'html', content: '

' } as Token const html = await toThenable(render.renderTemplates([new HTML(token)], scope)) return expect(html).to.equal('

') }) diff --git a/test/unit/template/hash.ts b/test/unit/template/hash.ts index b7eea4c67..7cdeefddf 100644 --- a/test/unit/template/hash.ts +++ b/test/unit/template/hash.ts @@ -6,12 +6,39 @@ import { Context } from '../../../src/context/context' const expect = chai.expect describe('Hash', function () { - it('should parse variable', async function () { - const hash = await toThenable(Hash.create('num:foo', new Context({ foo: 3 }))) + it('should parse "reverse"', async function () { + const hash = await toThenable(new Hash('reverse').render(new Context({ foo: 3 }))) + expect(hash).to.haveOwnProperty('reverse') + expect(hash.reverse).to.be.undefined + }) + it('should parse "num:foo"', async function () { + const hash = await toThenable(new Hash('num:foo').render(new Context({ foo: 3 }))) expect(hash.num).to.equal(3) }) - it('should parse literals', async function () { - const hash = await toThenable(Hash.create('num:3', new Context())) + it('should parse "num:3"', async function () { + const hash = await toThenable(new Hash('num:3').render(new Context())) expect(hash.num).to.equal(3) }) + it('should parse "num: arr[0]"', async function () { + const hash = await toThenable(new Hash('num:3').render(new Context({ arr: [3] }))) + expect(hash.num).to.equal(3) + }) + it('should parse "num: 2.3"', async function () { + const hash = await toThenable(new Hash('num:2.3').render(new Context())) + expect(hash.num).to.equal(2.3) + }) + it('should parse "num:bar.coo"', async function () { + const hash = await toThenable(new Hash('num:bar.coo').render(new Context({ bar: { coo: 3 } }))) + expect(hash.num).to.equal(3) + }) + it('should parse "num1:2.3 reverse,num2:bar.coo\n num3: arr[0]"', async function () { + const ctx = new Context({ bar: { coo: 3 }, arr: [4] }) + const hash = await toThenable(new Hash('num1:2.3 reverse,num2:bar.coo\n num3: arr[0]').render(ctx)) + expect(hash).to.deep.equal({ + num1: 2.3, + reverse: undefined, + num2: 3, + num3: 4 + }) + }) }) diff --git a/test/unit/template/output.ts b/test/unit/template/output.ts index 5f06b62ea..a47abca2d 100644 --- a/test/unit/template/output.ts +++ b/test/unit/template/output.ts @@ -19,25 +19,25 @@ describe('Output', function () { const scope = new Context({ foo: { obj: { arr: ['a', 2] } } }) - const output = new Output({ value: 'foo' } as OutputToken, filters) + const output = new Output({ content: 'foo' } as OutputToken, filters) await toThenable(output.render(scope, emitter)) return expect(emitter.html).to.equal('[object Object]') }) it('should skip function property', async function () { const scope = new Context({ obj: { foo: 'foo', bar: (x: any) => x } }) - const output = new Output({ value: 'obj' } as OutputToken, filters) + const output = new Output({ content: 'obj' } as OutputToken, filters) await toThenable(output.render(scope, emitter)) return expect(emitter.html).to.equal('[object Object]') }) it('should respect to .toString()', async () => { const scope = new Context({ obj: { toString: () => 'FOO' } }) - const output = new Output({ value: 'obj' } as OutputToken, filters) + const output = new Output({ content: 'obj' } as OutputToken, filters) await toThenable(output.render(scope, emitter)) return expect(emitter.html).to.equal('FOO') }) it('should respect to .toString()', async () => { const scope = new Context({ obj: { toString: () => 'FOO' } }) - const output = new Output({ value: 'obj' } as OutputToken, filters) + const output = new Output({ content: 'obj' } as OutputToken, filters) await toThenable(output.render(scope, emitter)) return expect(emitter.html).to.equal('FOO') }) diff --git a/test/unit/template/tag.ts b/test/unit/template/tag.ts index 3a500a7fb..2f31835dc 100644 --- a/test/unit/template/tag.ts +++ b/test/unit/template/tag.ts @@ -32,7 +32,8 @@ describe('Tag', function () { expect(function () { new Tag({ // eslint-disable-line type: 'tag', - value: 'foo', + content: 'foo', + args: '', name: 'not-exist' } as TagToken, [], liquid) }).to.throw(/tag "not-exist" not found/) @@ -49,52 +50,11 @@ describe('Tag', function () { liquid.registerTag('foo', { render: spy }) const token = { type: 'tag', - value: 'foo', + content: 'foo', + args: '', name: 'foo' } as TagToken await toThenable(new Tag(token, [], liquid).render(ctx, emitter)) expect(spy).to.have.been.called }) - - describe('hash', function () { - let spy: sinon.SinonSpy, token: TagToken - beforeEach(function () { - spy = sinon.spy() - liquid.registerTag('foo', { render: spy }) - token = { - type: 'tag', - value: 'foo aa:foo bb: arr[0] cc: 2.3\ndd:bar.coo', - name: 'foo', - args: 'aa:foo bb: arr[0] cc: 2.3\ndd:bar.coo' - } as TagToken - }) - it('should call tag.render with scope', async function () { - await toThenable(new Tag(token, [], liquid).render(ctx, emitter)) - expect(spy).to.have.been.calledWithMatch(ctx) - }) - it('should resolve identifier hash', async function () { - await toThenable(new Tag(token, [], liquid).render(ctx, emitter)) - expect(spy).to.have.been.calledWithMatch({}, { - aa: 'bar' - }) - }) - it('should accept space between key/value', async function () { - await toThenable(new Tag(token, [], liquid).render(ctx, emitter)) - expect(spy).to.have.been.calledWithMatch({}, { - bb: 2 - }) - }) - it('should resolve number value hash', async function () { - await toThenable(new Tag(token, [], liquid).render(ctx, emitter)) - expect(spy).to.have.been.calledWithMatch(ctx, { - cc: 2.3 - }) - }) - it('should resolve property access hash', async function () { - await toThenable(new Tag(token, [], liquid).render(ctx, emitter)) - expect(spy).to.have.been.calledWithMatch(ctx, { - dd: 'uoo' - }) - }) - }) }) diff --git a/test/unit/template/value.ts b/test/unit/template/value.ts index 442b2b8c9..62be991e3 100644 --- a/test/unit/template/value.ts +++ b/test/unit/template/value.ts @@ -83,33 +83,6 @@ describe('Value', function () { }) }) - 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']) - }) - it('should tokenize array indexing', function () { - expect(Value.tokenize('arr[0]')).to.eql(['arr[0]']) - }) - it('should tokenize simple object access', function () { - expect(Value.tokenize('obj["foo"]')).to.eql(['obj["foo"]']) - }) - it('should tokenize simple dot syntax object access', function () { - expect(Value.tokenize('obj.foo')).to.eql(['obj.foo']) - }) - it('should tokenize complex object property access', function () { - expect(Value.tokenize('obj["complex:string here"]')).to.eql(['obj["complex:string here"]']) - }) - }) - describe('#value()', function () { it('should call chained filters correctly', async function () { const date = sinon.stub().returns('y')