diff --git a/.eslintrc.json b/.eslintrc.json index 2fbd4286b..6d3467339 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,6 +18,7 @@ "prefer-const": 2, "no-unused-vars": "off", "indent": "off", + "no-dupe-class-members": "off", "@typescript-eslint/indent": ["error", 2], "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-explicit-any": "off", diff --git a/demo/nodejs/index.js b/demo/nodejs/index.js index cf2318906..8c2a4ff4c 100644 --- a/demo/nodejs/index.js +++ b/demo/nodejs/index.js @@ -10,9 +10,9 @@ engine.registerTag('header', { const [key, val] = token.args.split(':') this[key] = val }, - render: function (scope, hash) { - const title = this.liquid.evalValue(this.content, scope) - return `

${title}

` + render: async function (scope, hash, emitter) { + const title = await this.liquid.evalValue(this.content, scope) + emitter.write(`

${title}

`) } }) diff --git a/demo/typescript/index.ts b/demo/typescript/index.ts index a49c3df80..ffb3bde3c 100644 --- a/demo/typescript/index.ts +++ b/demo/typescript/index.ts @@ -1,4 +1,4 @@ -import { Liquid, TagToken, Hash, Context } from 'liquidjs' +import { Liquid, TagToken, Hash, Context, Emitter } from 'liquidjs' const engine = new Liquid({ root: __dirname, @@ -10,9 +10,9 @@ engine.registerTag('header', { const [key, val] = token.args.split(':') this[key] = val }, - render: function (context: Context, hash: Hash) { - const title = this.liquid.evalValue(this['content'], context) - return `

${title}

` + render: async function (context: Context, hash: Hash, emitter: Emitter) { + const title = await this.liquid.evalValue(this['content'], context) + emitter.write(`

${title}

`) } }) diff --git a/src/builtin/tags/assign.ts b/src/builtin/tags/assign.ts index 6c40b4f40..e54244727 100644 --- a/src/builtin/tags/assign.ts +++ b/src/builtin/tags/assign.ts @@ -12,6 +12,8 @@ export default { this.value = match[2] }, render: async function (ctx: Context) { - ctx.front()[this.key] = await this.liquid.evalValue(this.value, ctx) + ctx.front()[this.key] = ctx.sync + ? this.liquid.evalValueSync(this.value, ctx) + : await this.liquid.evalValue(this.value, ctx) } } as ITagImplOptions diff --git a/src/builtin/tags/block.ts b/src/builtin/tags/block.ts index 9c78eac4a..b3da8991c 100644 --- a/src/builtin/tags/block.ts +++ b/src/builtin/tags/block.ts @@ -17,9 +17,13 @@ export default { render: async function (ctx: Context, hash: Hash, emitter: Emitter) { const blocks = ctx.getRegister('blocks') const childDefined = blocks[this.block] + const r = this.liquid.renderer const html = childDefined !== undefined ? childDefined - : await this.liquid.renderer.renderTemplates(this.tpls, ctx) + : (ctx.sync + ? r.renderTemplatesSync(this.tpls, ctx) + : await r.renderTemplates(this.tpls, ctx) + ) if (ctx.getRegister('blockMode', BlockMode.OUTPUT) === BlockMode.STORE) { blocks[this.block] = html diff --git a/src/builtin/tags/break.ts b/src/builtin/tags/break.ts index 5f3f49d34..f765bc888 100644 --- a/src/builtin/tags/break.ts +++ b/src/builtin/tags/break.ts @@ -1,7 +1,7 @@ -import { RenderBreakError } from '../../util/error' +import { Emitter, Context, Hash } from '../../types' export default { - render: async function () { - throw new RenderBreakError('break') + render: async function (ctx: Context, hash: Hash, emitter: Emitter) { + emitter.break = true } } diff --git a/src/builtin/tags/capture.ts b/src/builtin/tags/capture.ts index 60c7b3455..2354e11cc 100644 --- a/src/builtin/tags/capture.ts +++ b/src/builtin/tags/capture.ts @@ -21,7 +21,10 @@ export default { stream.start() }, render: async function (ctx: Context) { - const html = await this.liquid.renderer.renderTemplates(this.templates, ctx) + const r = this.liquid.renderer + const html = ctx.sync + ? r.renderTemplatesSync(this.templates, ctx) + : await r.renderTemplates(this.templates, ctx) ctx.front()[this.variable] = html } } as ITagImplOptions diff --git a/src/builtin/tags/case.ts b/src/builtin/tags/case.ts index 22cf9ecd2..b38f49825 100644 --- a/src/builtin/tags/case.ts +++ b/src/builtin/tags/case.ts @@ -25,15 +25,30 @@ export default { }, render: async function (ctx: Context, hash: Hash, emitter: Emitter) { + const r = this.liquid.renderer for (let i = 0; i < this.cases.length; i++) { const branch = this.cases[i] - const val = new Expression(branch.val).value(ctx) - const cond = new Expression(this.cond).value(ctx) + const val = await new Expression(branch.val).value(ctx) + const cond = await new Expression(this.cond).value(ctx) if (val === cond) { - this.liquid.renderer.renderTemplates(branch.templates, ctx, emitter) + await r.renderTemplates(branch.templates, ctx, emitter) return } } - this.liquid.renderer.renderTemplates(this.elseTemplates, ctx, emitter) + await r.renderTemplates(this.elseTemplates, ctx, emitter) + }, + + renderSync: function (ctx: Context, hash: Hash, emitter: Emitter) { + const r = this.liquid.renderer + for (let i = 0; i < this.cases.length; i++) { + const branch = this.cases[i] + const val = new Expression(branch.val).valueSync(ctx) + const cond = new Expression(this.cond).valueSync(ctx) + if (val === cond) { + r.renderTemplatesSync(branch.templates, ctx, emitter) + return + } + } + r.renderTemplatesSync(this.elseTemplates, ctx, emitter) } } as ITagImplOptions diff --git a/src/builtin/tags/continue.ts b/src/builtin/tags/continue.ts index 66d5f81cb..6e14bbcd4 100644 --- a/src/builtin/tags/continue.ts +++ b/src/builtin/tags/continue.ts @@ -1,7 +1,7 @@ -import { RenderBreakError } from '../../util/error' +import { Emitter, Context, Hash } from '../../types' export default { - render: async function () { - throw new RenderBreakError('continue') + render: async function (ctx: Context, hash: Hash, emitter: Emitter) { + emitter.continue = true } } diff --git a/src/builtin/tags/cycle.ts b/src/builtin/tags/cycle.ts index 0af3518b5..2a7a93b3a 100644 --- a/src/builtin/tags/cycle.ts +++ b/src/builtin/tags/cycle.ts @@ -22,7 +22,9 @@ export default { }, render: async function (ctx: Context, hash: Hash, emitter: Emitter) { - const group = this.group.value(ctx) + const group = ctx.sync + ? this.group.valueSync(ctx) + : await this.group.value(ctx) const fingerprint = `cycle:${group}:` + this.candidates.join(',') const groups = ctx.getRegister('cycle') let idx = groups[fingerprint] @@ -34,6 +36,9 @@ export default { const candidate = this.candidates[idx] idx = (idx + 1) % this.candidates.length groups[fingerprint] = idx - emitter.write(new Expression(candidate).value(ctx)) + const html = ctx.sync + ? new Expression(candidate).valueSync(ctx) + : await new Expression(candidate).value(ctx) + emitter.write(html) } } as ITagImplOptions diff --git a/src/builtin/tags/for.ts b/src/builtin/tags/for.ts index 116a75dfe..99363196e 100644 --- a/src/builtin/tags/for.ts +++ b/src/builtin/tags/for.ts @@ -37,7 +37,10 @@ export default { stream.start() }, render: async function (ctx: Context, hash: Hash, emitter: Emitter) { - let collection = new Expression(this.collection).value(ctx) + const r = this.liquid.renderer + let collection = ctx.sync + ? new Expression(this.collection).valueSync(ctx) + : await new Expression(this.collection).value(ctx) if (!isArray(collection)) { if (isString(collection) && collection.length > 0) { @@ -47,7 +50,9 @@ export default { } } if (!isArray(collection) || !collection.length) { - this.liquid.renderer.renderTemplates(this.elseTemplates, ctx, emitter) + ctx.sync + ? r.renderTemplatesSync(this.elseTemplates, ctx, emitter) + : await r.renderTemplates(this.elseTemplates, ctx, emitter) return } @@ -57,17 +62,19 @@ export default { collection = collection.slice(offset, offset + limit) if (this.reversed) collection.reverse() - const context = { forloop: new ForloopDrop(collection.length) } - ctx.push(context) + const scope = { forloop: new ForloopDrop(collection.length) } + ctx.push(scope) for (const item of collection) { - context[this.variable] = item - try { - await this.liquid.renderer.renderTemplates(this.templates, ctx, emitter) - } catch (e) { - if (e.name !== 'RenderBreakError') throw e - if (e.message === 'break') break + scope[this.variable] = item + ctx.sync + ? r.renderTemplatesSync(this.templates, ctx, emitter) + : await r.renderTemplates(this.templates, ctx, emitter) + if (emitter.break) { + emitter.break = false + break } - context.forloop.next() + emitter.continue = false + scope.forloop.next() } ctx.pop() } diff --git a/src/builtin/tags/if.ts b/src/builtin/tags/if.ts index ab643b67a..1f086f8d5 100644 --- a/src/builtin/tags/if.ts +++ b/src/builtin/tags/if.ts @@ -28,13 +28,28 @@ export default { }, render: async function (ctx: Context, hash: Hash, emitter: Emitter) { + const r = this.liquid.renderer + for (const branch of this.branches) { - const cond = new Expression(branch.cond).value(ctx) + const cond = await new Expression(branch.cond).value(ctx) if (isTruthy(cond)) { - await this.liquid.renderer.renderTemplates(branch.templates, ctx, emitter) + await r.renderTemplates(branch.templates, ctx, emitter) return } } - await this.liquid.renderer.renderTemplates(this.elseTemplates, ctx, emitter) + await r.renderTemplates(this.elseTemplates, ctx, emitter) + }, + + renderSync: function (ctx: Context, hash: Hash, emitter: Emitter) { + const r = this.liquid.renderer + + for (const branch of this.branches) { + const cond = new Expression(branch.cond).valueSync(ctx) + if (isTruthy(cond)) { + r.renderTemplatesSync(branch.templates, ctx, emitter) + return + } + } + r.renderTemplatesSync(this.elseTemplates, ctx, emitter) } } as ITagImplOptions diff --git a/src/builtin/tags/include.ts b/src/builtin/tags/include.ts index 7c5e61455..077cb81be 100644 --- a/src/builtin/tags/include.ts +++ b/src/builtin/tags/include.ts @@ -17,14 +17,14 @@ export default { match = withRE.exec(token.args) if (match) this.with = match[1] }, - render: async function (ctx: Context, hash: Hash, emitter: Emitter) { + renderSync: 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 = await this.liquid.parseAndRender(template, ctx.getAll(), ctx.opts) + filepath = this.liquid.parseAndRenderSync(template, ctx.getAll(), ctx.opts) } else { - filepath = new Expression(this.value).value(ctx) + filepath = new Expression(this.value).valueSync(ctx) } } else { filepath = this.staticValue @@ -37,7 +37,37 @@ export default { ctx.setRegister('blocks', {}) ctx.setRegister('blockMode', BlockMode.OUTPUT) if (this.with) { - hash[filepath] = new Expression(this.with).evaluate(ctx) + hash[filepath] = new Expression(this.with).evaluateSync(ctx) + } + const templates = this.liquid.getTemplateSync(filepath, ctx.opts) + ctx.push(hash) + this.liquid.renderer.renderTemplatesSync(templates, ctx, emitter) + ctx.pop() + ctx.setRegister('blocks', originBlocks) + ctx.setRegister('blockMode', originBlockMode) + }, + + render: async 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 = await this.liquid.parseAndRender(template, ctx.getAll(), ctx.opts) + } else { + filepath = await new Expression(this.value).value(ctx) + } + } else { + filepath = this.staticValue + } + assert(filepath, `cannot include with empty filename`) + + const originBlocks = ctx.getRegister('blocks') + const originBlockMode = ctx.getRegister('blockMode') + + ctx.setRegister('blocks', {}) + ctx.setRegister('blockMode', BlockMode.OUTPUT) + if (this.with) { + hash[filepath] = await new Expression(this.with).evaluate(ctx) } const templates = await this.liquid.getTemplate(filepath, ctx.opts) ctx.push(hash) diff --git a/src/builtin/tags/layout.ts b/src/builtin/tags/layout.ts index 017b8f0ea..68dacc34e 100644 --- a/src/builtin/tags/layout.ts +++ b/src/builtin/tags/layout.ts @@ -21,21 +21,31 @@ export default { }, render: async function (ctx: Context, hash: Hash, emitter: Emitter) { const layout = ctx.opts.dynamicPartials - ? await (new Expression(this.layout).value(ctx)) + ? (ctx.sync + ? new Expression(this.layout).valueSync(ctx) + : await new Expression(this.layout).value(ctx) + ) : this.staticLayout assert(layout, `cannot apply layout with empty filename`) // render the remaining tokens immediately ctx.setRegister('blockMode', BlockMode.STORE) const blocks = ctx.getRegister('blocks') - const html = await this.liquid.renderer.renderTemplates(this.tpls, ctx) + const r = this.liquid.renderer + const html = ctx.sync + ? r.renderTemplatesSync(this.tpls, ctx) + : await r.renderTemplates(this.tpls, ctx) if (blocks[''] === undefined) { blocks[''] = html } - const templates = await this.liquid.getTemplate(layout, ctx.opts) + const templates = ctx.sync + ? this.liquid.getTemplateSync(layout, ctx.opts) + : await this.liquid.getTemplate(layout, ctx.opts) ctx.push(hash) ctx.setRegister('blockMode', BlockMode.OUTPUT) - const partial = await this.liquid.renderer.renderTemplates(templates, ctx) + const partial = ctx.sync + ? r.renderTemplatesSync(templates, ctx) + : await r.renderTemplates(templates, ctx) ctx.pop() emitter.write(partial) } diff --git a/src/builtin/tags/raw.ts b/src/builtin/tags/raw.ts index 5390f7236..dd43fd0fe 100644 --- a/src/builtin/tags/raw.ts +++ b/src/builtin/tags/raw.ts @@ -15,7 +15,7 @@ export default { }) stream.start() }, - render: function (ctx: Context, hash: Hash, emitter: Emitter) { - emitter.write(this.tokens.map((token: Token) => token.raw).join('')) + render: function (ctx: Context) { + return this.tokens.map((token: Token) => token.raw).join('') } } as ITagImplOptions diff --git a/src/builtin/tags/tablerow.ts b/src/builtin/tags/tablerow.ts index 4648ba290..e95492c22 100644 --- a/src/builtin/tags/tablerow.ts +++ b/src/builtin/tags/tablerow.ts @@ -29,13 +29,16 @@ export default { }, render: async function (ctx: Context, hash: Hash, emitter: Emitter) { - let collection = new Expression(this.collection).value(ctx) || [] + let collection = ctx.sync + ? new Expression(this.collection).valueSync(ctx) || [] + : await new Expression(this.collection).value(ctx) || [] const offset = hash.offset || 0 const limit = (hash.limit === undefined) ? collection.length : hash.limit collection = collection.slice(offset, offset + limit) const cols = hash.cols || collection.length + const r = this.liquid.renderer const tablerowloop = new TablerowloopDrop(collection.length, cols) const scope = { tablerowloop } ctx.push(scope) @@ -47,7 +50,9 @@ export default { emitter.write(``) } emitter.write(``) - await this.liquid.renderer.renderTemplates(this.templates, ctx, emitter) + ctx.sync + ? r.renderTemplatesSync(this.templates, ctx, emitter) + : await r.renderTemplates(this.templates, ctx, emitter) emitter.write('') } if (collection.length) emitter.write('') diff --git a/src/builtin/tags/unless.ts b/src/builtin/tags/unless.ts index d2418225f..670ec4cdd 100644 --- a/src/builtin/tags/unless.ts +++ b/src/builtin/tags/unless.ts @@ -20,10 +20,19 @@ export default { stream.start() }, - render: async function (ctx: Context, hash: Hash, emitter: Emitter) { - const cond = new Expression(this.cond).value(ctx) + renderSync: async function (ctx: Context, hash: Hash, emitter: Emitter) { + const r = this.liquid.renderer + const cond = new Expression(this.cond).valueSync(ctx) isFalsy(cond) - ? await this.liquid.renderer.renderTemplates(this.templates, ctx, emitter) - : await this.liquid.renderer.renderTemplates(this.elseTemplates, ctx, emitter) + ? r.renderTemplatesSync(this.templates, ctx, emitter) + : r.renderTemplatesSync(this.elseTemplates, ctx, emitter) + }, + + render: async function (ctx: Context, hash: Hash, emitter: Emitter) { + const r = this.liquid.renderer + const cond = await new Expression(this.cond).value(ctx) + await isFalsy(cond) + ? r.renderTemplates(this.templates, ctx, emitter) + : r.renderTemplates(this.elseTemplates, ctx, emitter) } } as ITagImplOptions diff --git a/src/context/context.ts b/src/context/context.ts index 902eaa7eb..5f770e6d5 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -9,8 +9,10 @@ export class Context { private scopes: Scope[] = [{}] private registers = {} public environments: Scope + public sync: boolean public opts: NormalizedFullOptions - public constructor (ctx: object = {}, opts?: NormalizedFullOptions) { + public constructor (ctx: object = {}, opts?: NormalizedFullOptions, sync: boolean = false) { + this.sync = sync this.opts = applyDefault(opts) this.environments = ctx } diff --git a/src/fs/browser.ts b/src/fs/browser.ts index 643f858d5..c94a4c5ae 100644 --- a/src/fs/browser.ts +++ b/src/fs/browser.ts @@ -44,8 +44,22 @@ async function readFile (url: string): Promise { }) } +function readFileSync (url: string): string { + const xhr = new XMLHttpRequest() + xhr.open('GET', url, false) + xhr.send() + if (xhr.status < 200 || xhr.status >= 300) { + throw new Error(xhr.statusText) + } + return xhr.responseText as string +} + async function exists () { return true } -export default { readFile, resolve, exists } as IFS +function existsSync () { + return true +} + +export default { readFile, resolve, exists, existsSync, readFileSync } as IFS diff --git a/src/fs/ifs.ts b/src/fs/ifs.ts index 6734e937e..6baefdf58 100644 --- a/src/fs/ifs.ts +++ b/src/fs/ifs.ts @@ -1,5 +1,7 @@ export default interface IFS { exists: (filepath: string) => Promise; readFile: (filepath: string) => Promise; + existsSync: (filepath: string) => boolean; + readFileSync: (filepath: string) => string; resolve: (root: string, file: string, ext: string) => string; } diff --git a/src/fs/node.ts b/src/fs/node.ts index a1b83e2ee..3deda0f2c 100644 --- a/src/fs/node.ts +++ b/src/fs/node.ts @@ -1,6 +1,6 @@ import * as _ from '../util/underscore' import { resolve, extname } from 'path' -import { stat, readFile } from 'fs' +import { stat, statSync, readFile, readFileSync } from 'fs' import IFS from './ifs' const statAsync = _.promisify(stat) @@ -13,6 +13,17 @@ const fs: IFS = { 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) diff --git a/src/liquid-options.ts b/src/liquid-options.ts index 22d4ea7bc..0bc1a029f 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -88,7 +88,7 @@ export function applyDefault (options?: NormalizedOptions): NormalizedFullOption return { ...defaultOptions, ...options } } -function normalizeStringArray (value: any): string[] { +export function normalizeStringArray (value: any): string[] { if (_.isArray(value)) return value as string[] if (_.isString(value)) return [value as string] return [] diff --git a/src/liquid.ts b/src/liquid.ts index 3f2269060..4b881d7ba 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -11,9 +11,12 @@ import { ITagImplOptions } from './template/tag/itag-impl-options' import { Value } from './template/value' import builtinTags from './builtin/tags' import builtinFilters from './builtin/filters' -import { LiquidOptions, NormalizedFullOptions, applyDefault, normalize } from './liquid-options' +import { LiquidOptions, normalizeStringArray, NormalizedFullOptions, applyDefault, normalize } from './liquid-options' import { FilterImplOptions } from './template/filter/filter-impl-options' import IFS from './fs/ifs' + +type GetTemplateResult = ITemplate[] | undefined + export * from './types' export class Liquid { @@ -34,47 +37,72 @@ export class Liquid { _.forOwn(builtinTags, (conf, name) => this.registerTag(name, conf)) _.forOwn(builtinFilters, (handler, name) => this.registerFilter(name, handler)) } - public parse (html: string, filepath?: string) { + public parse (html: string, filepath?: string): ITemplate[] { const tokens = this.tokenizer.tokenize(html, filepath) return this.parser.parse(tokens) } - public render (tpl: ITemplate[], scope?: object, opts?: LiquidOptions) { + public render (tpl: ITemplate[], scope?: object, opts?: LiquidOptions): Promise { const options = { ...this.options, ...normalize(opts) } const ctx = new Context(scope, options) return this.renderer.renderTemplates(tpl, ctx) } - public async parseAndRender (html: string, ctx?: object, opts?: LiquidOptions) { - const tpl = await this.parse(html) - return this.render(tpl, ctx, opts) + public renderSync (tpl: ITemplate[], scope?: object, opts?: LiquidOptions): string { + const options = { ...this.options, ...normalize(opts) } + const ctx = new Context(scope, options, true) + return this.renderer.renderTemplatesSync(tpl, ctx) } - public async getTemplate (file: string, opts?: LiquidOptions) { - const options = normalize(opts) - const roots = options.root ? [...options.root, ...this.options.root] : this.options.root - const paths = roots.map(root => this.fs.resolve(root, file, this.options.extname)) + public async parseAndRender (html: string, scope?: object, opts?: LiquidOptions): Promise { + const tpl = this.parse(html) + return this.render(tpl, scope, opts) + } + public parseAndRenderSync (html: string, scope?: object, opts?: LiquidOptions): string { + const tpl = this.parse(html) + return this.renderSync(tpl, scope, opts) + } + public getTemplateSync (file: string, opts?: LiquidOptions): ITemplate[] { + const options = { ...this.options, ...normalize(opts) } + const paths = options.root.map(root => this.fs.resolve(root, file, options.extname)) for (const filepath of paths) { - if (this.options.cache && this.cache[filepath]) return this.cache[filepath] - - if (!(await this.fs.exists(filepath))) continue - - const value = this.parse(await this.fs.readFile(filepath), filepath) - if (this.options.cache) this.cache[filepath] = value - return value + const tpl = this.respectCache(filepath, () => { + if (!(this.fs.existsSync(filepath))) return + return this.parse(this.fs.readFileSync(filepath), filepath) + }) + if (tpl) return tpl } - const err = new Error('ENOENT') as any - err.message = `ENOENT: Failed to lookup "${file}" in "${roots}"` - err.code = 'ENOENT' - throw err + throw this.lookupError(file, options.root) + } + public async getTemplate (file: string, opts?: LiquidOptions): Promise { + const options = { ...this.options, ...normalize(opts) } + const paths = options.root.map(root => this.fs.resolve(root, file, options.extname)) + + for (const filepath of paths) { + const tpl = await this.respectCache(filepath, async () => { + if (!(await this.fs.exists(filepath))) return + return this.parse(await this.fs.readFile(filepath), filepath) + }) + if (tpl !== undefined) return tpl + } + throw this.lookupError(file, options.root) } public async renderFile (file: string, ctx?: object, opts?: LiquidOptions) { - const options = normalize(opts) - const templates = await this.getTemplate(file, options) + const templates = await this.getTemplate(file, opts) return this.render(templates, ctx, opts) } - public evalValue (str: string, ctx: Context) { + public renderFileSync (file: string, ctx?: object, opts?: LiquidOptions) { + const options = normalize(opts) + const templates = this.getTemplateSync(file, options) + return this.renderSync(templates, ctx, opts) + } + public async evalValue (str: string, ctx: Context): Promise { return new Value(str, this.options.strictFilters).value(ctx) } + + public evalValueSync (str: string, ctx: Context): any { + return new Value(str, this.options.strictFilters).valueSync(ctx) + } + public registerFilter (name: string, filter: FilterImplOptions) { return Filter.register(name, filter) } @@ -85,10 +113,35 @@ export class Liquid { return plugin.call(this, Liquid) } public express () { - const self = this + const self = this // eslint-disable-line return function (this: any, filePath: string, ctx: object, cb: (err: Error | null, html?: string) => void) { - const opts = { root: this.root } + const opts = { root: [...normalizeStringArray(this.root), ...self.options.root] } self.renderFile(filePath, ctx, opts).then(html => cb(null, html), cb) } } -} + + private lookupError (file: string, roots: string[]) { + const err = new Error('ENOENT') as any + err.message = `ENOENT: Failed to lookup "${file}" in "${roots}"` + err.code = 'ENOENT' + return err + } + + private setCache (filepath: string, tpl: T): T { + if (tpl === undefined) return tpl + this.cache[filepath] = tpl + return tpl + } + + private respectCache (filepath: string, resolver: () => GetTemplateResult): GetTemplateResult + private respectCache (filepath: string, resolver: () => Promise): Promise + private respectCache (filepath: string, resolver: () => GetTemplateResult | Promise): GetTemplateResult | Promise { + if (!this.options.cache) return resolver() + if (this.cache[filepath]) return this.cache[filepath] + const tpl = resolver() + if (tpl instanceof Promise) { + return tpl.then(c => this.setCache(filepath, c)) + } + return this.setCache(filepath, tpl) + } +} \ No newline at end of file diff --git a/src/render/emitter.ts b/src/render/emitter.ts index 8e199dd28..6231a6382 100644 --- a/src/render/emitter.ts +++ b/src/render/emitter.ts @@ -1,5 +1,7 @@ export class Emitter { - public html: string = ''; + public html = ''; + public break = false; + public continue = false; public write (html: string) { this.html += html diff --git a/src/render/expression.ts b/src/render/expression.ts index 9e3cd35c5..d3f63ccfd 100644 --- a/src/render/expression.ts +++ b/src/render/expression.ts @@ -1,38 +1,55 @@ import { assert } from '../util/assert' -import { isRange, rangeValue } from './range' +import { isRange, rangeValue, rangeValueSync } from './range' import { Value } from './value' import { Context } from '../context/context' import { toValue } from '../util/underscore' import { isOperator, precedence, operatorImpls } from './operator' export class Expression { - private str: string + private operands: any[] = [] + private postfix: string[] - public constructor (str: string = '') { - this.str = str + public constructor (str = '') { + this.postfix = [...toPostfix(str)] } - public evaluate (ctx: Context): any { + public async evaluate (ctx: Context): Promise { assert(ctx, 'unable to evaluate: context not defined') - const operands = [] - for (const token of toPostfix(this.str)) { + for (const token of this.postfix) { if (isOperator(token)) { - const r = operands.pop() - const l = operands.pop() - const result = operatorImpls[token](l, r) - operands.push(result) - continue - } - if (isRange(token)) { - operands.push(rangeValue(token, ctx)) - continue - } - operands.push(new Value(token).evaluate(ctx)) + this.evaluateOnce(token) + } else if (isRange(token)) { + this.operands.push(await rangeValue(token, ctx)) + } else this.operands.push(await new Value(token).evaluate(ctx)) } - return operands[0] + return this.operands[0] } - public value (ctx: Context): any { - return toValue(this.evaluate(ctx)) + public evaluateSync (ctx: Context): any { + assert(ctx, 'unable to evaluate: context not defined') + + for (const token of this.postfix) { + if (isOperator(token)) { + this.evaluateOnce(token) + } else if (isRange(token)) { + this.operands.push(rangeValueSync(token, ctx)) + } else { + const val = new Value(token).evaluateSync(ctx) + this.operands.push(val) + } + } + return this.operands[0] + } + public async value (ctx: Context): Promise { + return toValue(await this.evaluate(ctx)) + } + public valueSync (ctx: Context): any { + return toValue(this.evaluateSync(ctx)) + } + private evaluateOnce (token: string) { + const r = this.operands.pop() + const l = this.operands.pop() + const result = operatorImpls[token](l, r) + this.operands.push(result) } } diff --git a/src/render/range.ts b/src/render/range.ts index 7d962ab8f..b3eed3de7 100644 --- a/src/render/range.ts +++ b/src/render/range.ts @@ -3,15 +3,24 @@ import { Context } from '../context/context' import { range } from '../util/underscore' import { Value } from './value' -export function isRange (token: string = '') { +export function isRange (token: string) { return token[0] === '(' && token[token.length - 1] === ')' } -export function rangeValue (token: string = '', ctx: Context) { +export async function rangeValue (token: string, ctx: Context) { let match if ((match = token.match(rangeLine))) { - const low = new Value(match[1]).value(ctx) - const high = new Value(match[2]).value(ctx) + const low = await new Value(match[1]).value(ctx) + const high = await new Value(match[2]).value(ctx) + return range(+low, +high + 1) + } +} + +export function rangeValueSync (token: string, ctx: Context) { + let match + if ((match = token.match(rangeLine))) { + const low = new Value(match[1]).valueSync(ctx) + const high = new Value(match[2]).valueSync(ctx) return range(+low, +high + 1) } } diff --git a/src/render/render.ts b/src/render/render.ts index 3afb05429..3f7c9cbcb 100644 --- a/src/render/render.ts +++ b/src/render/render.ts @@ -4,13 +4,26 @@ import { ITemplate } from '../template/itemplate' import { Emitter } from './emitter' export class Render { - public async renderTemplates (templates: ITemplate[], ctx: Context, emitter = new Emitter()) { + public async renderTemplates (templates: ITemplate[], ctx: Context, emitter = new Emitter()): Promise { for (const tpl of templates) { try { - await tpl.render(ctx, emitter) + const html = await tpl.render(ctx, emitter) + html && emitter.write(html) + if (emitter.break || emitter.continue) break } catch (e) { - if (e.name === 'RenderBreakError') throw e - throw e.name === 'RenderError' ? e : new RenderError(e, tpl) + throw RenderError.is(e) ? e : new RenderError(e, tpl) + } + } + return emitter.html + } + public renderTemplatesSync (templates: ITemplate[], ctx: Context, emitter = new Emitter()): string { + for (const tpl of templates) { + try { + const html = tpl.renderSync(ctx, emitter) + html && !(html instanceof Promise) && emitter.write(html) + if (emitter.break || emitter.continue) break + } catch (e) { + throw RenderError.is(e) ? e : new RenderError(e, tpl) } } return emitter.html diff --git a/src/render/value.ts b/src/render/value.ts index cbc3ad656..d5d952008 100644 --- a/src/render/value.ts +++ b/src/render/value.ts @@ -5,11 +5,15 @@ import { parseLiteral } from '../parser/literal' export class Value { private str: string - public constructor (str: string = '') { + public constructor (str: string) { this.str = str } - public evaluate (ctx: Context) { + public async evaluate (ctx: Context) { + return this.evaluateSync(ctx) + } + + public evaluateSync (ctx: Context) { const literalValue = parseLiteral(this.str) if (literalValue !== undefined) { return literalValue @@ -17,7 +21,11 @@ export class Value { return ctx.get(this.str) } - public value (ctx: Context) { - return toValue(this.evaluate(ctx)) + public async value (ctx: Context) { + return toValue(await this.evaluate(ctx)) + } + + public valueSync (ctx: Context) { + return toValue(this.evaluateSync(ctx)) } } diff --git a/src/template/filter/filter.ts b/src/template/filter/filter.ts index 818a995e8..e48fdef52 100644 --- a/src/template/filter/filter.ts +++ b/src/template/filter/filter.ts @@ -21,11 +21,19 @@ export class Filter { this.impl = impl || (x => x) this.args = args } - public render (value: any, context: Context) { + public async render (value: any, context: Context) { const argv: any[] = [] for (const arg of this.args) { - if (isKeyValuePair(arg)) argv.push([arg[0], new Expression(arg[1]).evaluate(context)]) - else argv.push(new Expression(arg).evaluate(context)) + if (isKeyValuePair(arg)) argv.push([arg[0], await new Expression(arg[1]).evaluate(context)]) + else argv.push(await new Expression(arg).evaluate(context)) + } + return this.impl.apply({ context }, [value, ...argv]) + } + public renderSync (value: any, context: Context) { + const argv: any[] = [] + for (const arg of this.args) { + if (isKeyValuePair(arg)) argv.push([arg[0], new Expression(arg[1]).evaluateSync(context)]) + else argv.push(new Expression(arg).evaluateSync(context)) } return this.impl.apply({ context }, [value, ...argv]) } diff --git a/src/template/html.ts b/src/template/html.ts index f167fa4fa..326621ee9 100644 --- a/src/template/html.ts +++ b/src/template/html.ts @@ -10,7 +10,10 @@ export class HTML extends Template implements ITemplate { super(token) this.str = token.value } - public render (ctx: Context, emitter: Emitter) { + public renderSync (ctx: Context, emitter: Emitter) { emitter.write(this.str) } + public async render (ctx: Context, emitter: Emitter) { + this.renderSync(ctx, emitter) + } } diff --git a/src/template/itemplate.ts b/src/template/itemplate.ts index 956014cf9..b14bc7491 100644 --- a/src/template/itemplate.ts +++ b/src/template/itemplate.ts @@ -4,5 +4,6 @@ import { Emitter } from '../render/emitter' export interface ITemplate { token: Token; - render(ctx: Context, emitter: Emitter): Promise | void; + render(ctx: Context, emitter: Emitter): Promise; + renderSync(ctx: Context, emitter: Emitter): any; } diff --git a/src/template/output.ts b/src/template/output.ts index 48d8f6bc2..d4a866611 100644 --- a/src/template/output.ts +++ b/src/template/output.ts @@ -12,6 +12,10 @@ export class Output extends Template implements ITemplate { super(token) this.value = new Value(token.value, strictFilters) } + public renderSync (ctx: Context, emitter: Emitter) { + const val = this.value.valueSync(ctx) + emitter.write(stringify(toValue(val))) + } public async render (ctx: Context, emitter: Emitter) { const val = await this.value.value(ctx) emitter.write(stringify(toValue(val))) diff --git a/src/template/tag/hash.ts b/src/template/tag/hash.ts index 07cd5f342..2dedfc3ef 100644 --- a/src/template/tag/hash.ts +++ b/src/template/tag/hash.ts @@ -10,14 +10,28 @@ import { Context } from '../../context/context' */ export class Hash { [key: string]: any - public static async create (markup: string, ctx: Context) { + 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] = new Expression(v).evaluate(ctx) + instance[k] = v + } + return instance + } + public static createSync (markup: string, ctx: Context) { + const instance = Hash.parse(markup) + for (const key of Object.keys(instance)) { + instance[key] = new Expression(instance[key]).evaluateSync(ctx) + } + return instance + } + public static async create (markup: string, ctx: Context) { + const instance = Hash.parse(markup) + for (const key of Object.keys(instance)) { + instance[key] = await new Expression(instance[key]).evaluate(ctx) } return instance } diff --git a/src/template/tag/itag-impl-options.ts b/src/template/tag/itag-impl-options.ts index 5cf66d183..a0699dbad 100644 --- a/src/template/tag/itag-impl-options.ts +++ b/src/template/tag/itag-impl-options.ts @@ -8,4 +8,5 @@ import { Emitter } from '../../render/emitter' export interface ITagImplOptions { parse?: (this: ITagImpl, token: TagToken, remainingTokens: Token[]) => void; render: (this: ITagImpl, ctx: Context, hash: Hash, emitter: Emitter) => void; + renderSync?: (this: ITagImpl, ctx: Context, hash: Hash, emitter: Emitter) => void; } diff --git a/src/template/tag/tag.ts b/src/template/tag/tag.ts index 320b59f34..dc0f674d5 100644 --- a/src/template/tag/tag.ts +++ b/src/template/tag/tag.ts @@ -23,10 +23,16 @@ export class Tag extends Template implements ITemplate { this.impl.parse(token, tokens) } } + public renderSync (ctx: Context, emitter: Emitter) { + const hash = Hash.createSync(this.token.args, ctx) + const impl = this.impl + if (isFunction(impl.renderSync)) return impl.renderSync(ctx, hash, emitter) + if (isFunction(impl.render)) return impl.render(ctx, hash, emitter) + } public async render (ctx: Context, emitter: Emitter) { const hash = await Hash.create(this.token.args, ctx) const impl = this.impl - if (isFunction(impl.render)) await impl.render(ctx, hash, emitter) + if (isFunction(impl.render)) return impl.render(ctx, hash, emitter) } public static register (name: string, tag: ITagImplOptions) { Tag.impls[name] = tag diff --git a/src/template/value.ts b/src/template/value.ts index ca54f4f32..a353bfe19 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -47,10 +47,17 @@ export class Value { } this.filters.push(new Filter(name, args, this.strictFilters)) } - public value (ctx: Context) { - let val = new Expression(this.initial).evaluate(ctx) + public async value (ctx: Context) { + let val = await new Expression(this.initial).evaluate(ctx) for (const filter of this.filters) { - val = filter.render(val, ctx) + val = await filter.render(val, ctx) + } + return val + } + public valueSync (ctx: Context) { + let val = new Expression(this.initial).evaluateSync(ctx) + for (const filter of this.filters) { + val = filter.renderSync(val, ctx) } return val } diff --git a/src/types.ts b/src/types.ts index 8a6ba69a7..cfa3605e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export { ParseError, TokenizationError, RenderBreakError, AssertionError } from './util/error' +export { ParseError, TokenizationError, AssertionError } from './util/error' export { Drop } from './drop/drop' export { Emitter } from './render/emitter' export { Expression } from './render/expression' diff --git a/src/util/error.ts b/src/util/error.ts index 3a7ac8f9b..f0fec86a1 100644 --- a/src/util/error.ts +++ b/src/util/error.ts @@ -43,14 +43,8 @@ export class RenderError extends LiquidError { this.message = err.message super.update() } -} - -export class RenderBreakError extends Error { - public resolvedHTML: string = '' - public constructor (message: string) { - super(message) - this.name = 'RenderBreakError' - this.message = message + '' + public static is (obj: any): obj is RenderError { + return obj instanceof RenderError } } diff --git a/test/e2e/eval-value-sync.ts b/test/e2e/eval-value-sync.ts new file mode 100644 index 000000000..030117d9f --- /dev/null +++ b/test/e2e/eval-value-sync.ts @@ -0,0 +1,11 @@ +import { expect } from 'chai' +import { Liquid } from '../..' + +describe('#evalValueSync()', function () { + var engine: Liquid + beforeEach(() => { engine = new Liquid() }) + + it('should throw when scope undefined', async function () { + return expect(() => engine.evalValueSync('{{"foo"}}', null as any)).to.throw(/context not defined/) + }) +}) diff --git a/test/e2e/eval-value.ts b/test/e2e/eval-value.ts index 1b000a47b..c46e9d4a4 100644 --- a/test/e2e/eval-value.ts +++ b/test/e2e/eval-value.ts @@ -6,6 +6,6 @@ describe('.evalValue()', function () { beforeEach(() => { engine = new Liquid() }) it('should throw when scope undefined', async function () { - return expect(() => engine.evalValue('{{"foo"}}', null as any)).to.throw(/context not defined/) + return expect(engine.evalValue('{{"foo"}}', null as any)).to.be.rejectedWith(/context not defined/) }) }) diff --git a/test/integration/builtin/tags/assign.ts b/test/integration/builtin/tags/assign.ts index 9d8f1fcb6..60115971b 100644 --- a/test/integration/builtin/tags/assign.ts +++ b/test/integration/builtin/tags/assign.ts @@ -81,4 +81,9 @@ describe('tags/assign', function () { return expect(html).to.equal('12 2') }) }) + it('should support sync', function () { + const src = '{% assign foo="bar" %}{{foo}}' + const html = liquid.parseAndRenderSync(src) + return expect(html).to.equal('bar') + }) }) diff --git a/test/integration/builtin/tags/capture.ts b/test/integration/builtin/tags/capture.ts index 1c51b8eb4..4aed916ed 100644 --- a/test/integration/builtin/tags/capture.ts +++ b/test/integration/builtin/tags/capture.ts @@ -32,4 +32,9 @@ describe('tags/capture', function () { return expect(liquid.parseAndRender(src)) .to.be.rejectedWith(/tag .* not closed/) }) + it('should support sync', function () { + const src = '{% capture f %}{{"a" | capitalize}}{%endcapture%}{{f}}' + const html = liquid.parseAndRenderSync(src) + return expect(html).to.equal('A') + }) }) diff --git a/test/integration/builtin/tags/case.ts b/test/integration/builtin/tags/case.ts index 11abdf8b4..6044eebc2 100644 --- a/test/integration/builtin/tags/case.ts +++ b/test/integration/builtin/tags/case.ts @@ -48,4 +48,20 @@ describe('tags/case', function () { const html = await liquid.parseAndRender(src) return expect(html).to.equal('d') }) + describe('sync support', function () { + it('should hit the specified case', function () { + const src = '{% case "foo"%}' + + '{% when "foo" %}foo{% when "bar"%}bar' + + '{%endcase%}' + const html = liquid.parseAndRenderSync(src) + return expect(html).to.equal('foo') + }) + it('should support else branch', function () { + const src = '{% case "a" %}' + + '{% when "b" %}b{% when "c"%}c{%else %}d' + + '{%endcase%}' + const html = liquid.parseAndRenderSync(src) + return expect(html).to.equal('d') + }) + }) }) diff --git a/test/integration/builtin/tags/comment.ts b/test/integration/builtin/tags/comment.ts index 5895f4db8..db652ecc7 100644 --- a/test/integration/builtin/tags/comment.ts +++ b/test/integration/builtin/tags/comment.ts @@ -31,4 +31,11 @@ describe('tags/comment', function () { const html = await liquid.parseAndRender(src) return expect(html).to.equal('') }) + describe('sync support', function () { + it('should ignore plain string', function () { + const src = 'My name is {% comment %}super{% endcomment %} Shopify.' + const html = liquid.parseAndRenderSync(src) + return expect(html).to.equal('My name is Shopify.') + }) + }) }) diff --git a/test/integration/builtin/tags/cycle.ts b/test/integration/builtin/tags/cycle.ts index 6335006a1..082ddc381 100644 --- a/test/integration/builtin/tags/cycle.ts +++ b/test/integration/builtin/tags/cycle.ts @@ -35,4 +35,9 @@ describe('tags/cycle', function () { const html = await liquid.parseAndRender(src, ctx) return expect(html).to.equal('121') }) + it('should support sync', function () { + const src = "{% cycle '1', '2', '3' %}" + const html = liquid.parseAndRenderSync(src + src + src + src) + return expect(html).to.equal('1231') + }) }) diff --git a/test/integration/builtin/tags/for.ts b/test/integration/builtin/tags/for.ts index af5956aee..7e22bdd10 100644 --- a/test/integration/builtin/tags/for.ts +++ b/test/integration/builtin/tags/for.ts @@ -6,13 +6,13 @@ import { Scope } from '../../../../src/context/scope' use(chaiAsPromised) describe('tags/for', function () { - let liquid: Liquid, ctx: Scope + let liquid: Liquid, scope: Scope before(function () { liquid = new Liquid() liquid.registerTag('throwingTag', { render: function () { throw new Error('intended render error') } }) - ctx = { + scope = { one: 1, // eslint-disable-next-line strObj: new String(''), @@ -25,30 +25,30 @@ describe('tags/for', function () { }) it('should support array', async function () { const src = '{%for c in alpha%}{{c}}{%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('abc') }) it('should support object', async function () { const src = '{%for item in obj%}{{item[0]}},{{item[1]}}-{%else%}b{%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('foo,bar-coo,haa-') }) it('should output forloop', async function () { const src = '{%for i in (1..1)%}{{forloop}}{%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('{"i":0,"length":1}') }) describe('illegal', function () { it('should reject when for not closed', function () { const src = '{%for c in alpha%}{{c}}' - return expect(liquid.parseAndRender(src, ctx)) + return expect(liquid.parseAndRender(src, scope)) .to.be.rejectedWith(/tag .* not closed/) }) it('should reject when inner templates rejected', function () { const src = '{%for c in alpha%}{%throwingTag%}{%endfor%}' - return expect(liquid.parseAndRender(src, ctx)) + return expect(liquid.parseAndRender(src, scope)) .to.be.rejectedWith(/intended render error/) }) }) @@ -56,38 +56,38 @@ describe('tags/for', function () { describe('else', function () { it('should goto else for empty array', async function () { const src = '{%for c in emptyArray%}a{%else%}b{%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('b') }) it('should treat non-empty string as one single element', async function () { const src = '{%for c in "abc"%}x{{c}}{%else%}y{%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('xabc') }) it('should goto else for empty string', async function () { const src = '{%for c in ""%}a{%else%}b{%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('b') }) it('should goto else for empty string object', async function () { // it should be false although `new String` is none-conform const src = '{%for c in strObj%}a{%else%}b{%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('b') }) it('should goto else for empty object', async function () { const src = '{%for c in emptyObj%}a{%else%}b{%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('b') }) it('should goto else for null-prototyped object', async function () { const src = '{%for c in nullProtoObj%}a{%else%}b{%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('b') }) }) @@ -102,7 +102,7 @@ describe('tags/for', function () { const dst = 'true.1.0.false.3.3.2a\n' + 'false.2.1.false.3.2.1b\n' + 'false.3.2.true.3.1.0c\n' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal(dst) }) @@ -111,7 +111,7 @@ describe('tags/for', function () { const src = '{% for i in (1..5) %}' + '{% if i == 4 %}continue{% continue %}{% endif %}{{i}}' + '{% endfor %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('123continue5') }) it('should output contents before continue', async function () { @@ -119,7 +119,7 @@ describe('tags/for', function () { '{% if i == 4 %}continue{% continue %}{% endif %}' + '{{ i }}' + '{% endfor %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('123continue5') }) }) @@ -129,7 +129,7 @@ describe('tags/for', function () { '{% if i == 4 %}{% break %}{% endif %}' + '{{ i }}' + '{% endfor %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('123') }) it('should output contents before break', async function () { @@ -137,7 +137,7 @@ describe('tags/for', function () { '{% if i == 4 %}breaking{% break %}{% endif %}' + '{{ i }}' + '{% endfor %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('123breaking') }) }) @@ -145,22 +145,22 @@ describe('tags/for', function () { describe('limit', function () { it('should support for with limit', async function () { const src = '{% for i in (1..5) limit:2 %}{{ i }}{% endfor %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('12') }) it('should set forloop.last properly', async function () { const src = '{%for i in (1..10) limit:2 offset:3%}{{forloop.last}} {%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('false true ') }) it('should set forloop.first properly', async function () { const src = '{%for i in (1..10) limit:2 offset:3%}{{forloop.first}} {%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('true false ') }) it('should set forloop.length properly', function () { const src = '{%for i in (1..10) limit:2 offset:3%}{{forloop.length}} {%endfor%}' - return expect(liquid.parseAndRender(src, ctx)) + return expect(liquid.parseAndRender(src, scope)) .to.eventually.equal('2 2 ') }) }) @@ -168,27 +168,27 @@ describe('tags/for', function () { describe('offset', function () { it('should support offset with limit', async function () { const src = '{% for i in (1..10) limit:2 offset:5%}{{ i }}{% endfor %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('67') }) it('should set index properly', async function () { const src = '{%for i in (1..10) limit:2 offset:3%}{{forloop.index}} {%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('1 2 ') }) it('should set index0 properly', async function () { const src = '{%for i in (1..10) limit:2 offset:3%}{{forloop.index0}} {%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('0 1 ') }) it('should set rindex properly', async function () { const src = '{%for i in (1..10) limit:2 offset:3%}{{forloop.rindex}} {%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('2 1 ') }) it('should set rindex0 properly', async function () { const src = '{%for i in (1..10) limit:2 offset:3%}{{forloop.rindex0}} {%endfor%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('1 0 ') }) }) @@ -196,20 +196,47 @@ describe('tags/for', function () { describe('reverse', function () { it('should support for reversed in the last position', async function () { const src = '{% for i in (1..5) limit:2 reversed %}{{ i }}{% endfor %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('21') }) it('should support for reversed in the first position', async function () { const src = '{% for i in (1..5) reversed limit:2 %}{{ i }}{% endfor %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('21') }) it('should support for reversed in the middle position', async function () { const src = '{% for i in (1..5) offset:2 reversed limit:4 %}{{ i }}{% endfor %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src) return expect(html).to.equal('543') }) }) + describe('sync', function () { + it('should support sync', function () { + const src = '{% for i in (1..5) %}{{i}}{%endfor%}' + const html = liquid.parseAndRenderSync(src) + return expect(html).to.equal('12345') + }) + it('should output contents before break', function () { + const src = '{% for i in (1..5) %}' + + '{% if i == 4 %}breaking{% break %}{% endif %}' + + '{{ i }}' + + '{% endfor %}' + const html = liquid.parseAndRenderSync(src, scope) + return expect(html).to.equal('123breaking') + }) + it('should support for with continue', function () { + const src = '{% for i in (1..5) %}' + + '{% if i == 4 %}continue{% continue %}{% endif %}{{i}}' + + '{% endfor %}' + const html = liquid.parseAndRenderSync(src, scope) + return expect(html).to.equal('123continue5') + }) + it('should goto else for empty array', async function () { + const src = '{%for c in emptyArray%}a{%else%}b{%endfor%}' + const html = await liquid.parseAndRenderSync(src, scope) + return expect(html).to.equal('b') + }) + }) }) diff --git a/test/integration/builtin/tags/if.ts b/test/integration/builtin/tags/if.ts index 8fb747d57..65cf09625 100644 --- a/test/integration/builtin/tags/if.ts +++ b/test/integration/builtin/tags/if.ts @@ -3,7 +3,7 @@ import { expect } from 'chai' describe('tags/if', function () { const liquid = new Liquid() - const ctx = { + const scope = { one: 1, two: 2, emptyString: '', @@ -12,52 +12,52 @@ describe('tags/if', function () { it('should throw if not closed', function () { const src = '{% if false%}yes' - return expect(liquid.parseAndRender(src, ctx)) + return expect(liquid.parseAndRender(src, scope)) .to.be.rejectedWith(/tag {% if false%} not closed/) }) it('should support nested', async function () { const src = '{%if false%}{%if true%}{%else%}a{%endif%}{%endif%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('') }) describe('single value as condition', function () { it('should support boolean', async function () { const src = '{% if false %}1{%elsif true%}2{%else%}3{%endif%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('2') }) it('should treat Array truthy', async function () { const src = '{%if emptyArray%}a{%endif%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('a') }) it('should return true if empty string', async function () { const src = '{%if emptyString%}a{%endif%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('a') }) }) describe('expression as condition', function () { it('should support ==', async function () { const src = '{% if 2 == 3 %}yes{%else%}no{%endif%}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('no') }) it('should support >=', async function () { const src = '{% if 1 >= 2 and one 10', async function () { const src = '{% if null > 10 %}yes{% else %}no{% endif %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('no') }) it('should evaluate false for null <= 10', async function () { const src = '{% if null <= 10 %}yes{% else %}no{% endif %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('no') }) it('should evaluate false for null >= 10', async function () { const src = '{% if null >= 10 %}yes{% else %}no{% endif %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('no') }) it('should evaluate false for 10 < null', async function () { const src = '{% if 10 < null %}yes{% else %}no{% endif %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('no') }) it('should evaluate false for 10 > null', async function () { const src = '{% if 10 > null %}yes{% else %}no{% endif %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('no') }) it('should evaluate false for 10 <= null', async function () { const src = '{% if 10 <= null %}yes{% else %}no{% endif %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('no') }) it('should evaluate false for 10 >= null', async function () { const src = '{% if 10 >= null %}yes{% else %}no{% endif %}' - const html = await liquid.parseAndRender(src, ctx) + const html = await liquid.parseAndRender(src, scope) return expect(html).to.equal('no') }) }) + it('should support sync', function () { + const src = '{%if true%}true{%else%}false{%endif%}' + const html = liquid.parseAndRenderSync(src, scope) + return expect(html).to.equal('true') + }) }) diff --git a/test/integration/builtin/tags/include.ts b/test/integration/builtin/tags/include.ts index 1e1184feb..94709fafb 100644 --- a/test/integration/builtin/tags/include.ts +++ b/test/integration/builtin/tags/include.ts @@ -171,4 +171,39 @@ describe('tags/include', function () { return expect(html).to.equal('Xchild with redY') }) }) + describe('sync support', function () { + it('should support quoted string', function () { + mock({ + '/current.html': 'bar{% include "bar/foo.html" %}bar', + '/bar/foo.html': 'foo' + }) + const html = liquid.renderFileSync('/current.html') + return expect(html).to.equal('barfoobar') + }) + it('should support template string', function () { + mock({ + '/current.html': 'bar{% include name" %}bar', + '/bar/foo.html': 'foo' + }) + const html = liquid.renderFileSync('/current.html', { name: '/bar/foo.html' }) + return expect(html).to.equal('barfoobar') + }) + it('should support include: with', function () { + mock({ + '/with.html': '{% include "color" with "red", shape: "rect" %}', + '/color.html': 'color:{{color}}, shape:{{shape}}' + }) + const html = liquid.renderFileSync('with.html') + return expect(html).to.equal('color:red, shape:rect') + }) + it('should support filename with extention', function () { + mock({ + '/parent.html': 'X{% include child.html color:"red" %}Y', + '/child.html': 'child with {{color}}' + }) + const staticLiquid = new Liquid({ dynamicPartials: false, root: '/' }) + const html = staticLiquid.renderFileSync('parent.html') + return expect(html).to.equal('Xchild with redY') + }) + }) }) diff --git a/test/integration/builtin/tags/layout.ts b/test/integration/builtin/tags/layout.ts index 6f0052cf5..de6eae0eb 100644 --- a/test/integration/builtin/tags/layout.ts +++ b/test/integration/builtin/tags/layout.ts @@ -133,4 +133,13 @@ describe('tags/layout', function () { return expect(html).to.equal('blackA') }) }) + it('should support sync', function () { + mock({ + '/grand.html': 'X{%block a%}G{%endblock%}Y', + '/parent.html': '{%layout "grand" %}{%block a%}P{%endblock%}', + '/main.html': '{%layout "parent"%}{%block a%}A{%endblock%}' + }) + const html = liquid.renderFileSync('/main.html') + return expect(html).to.equal('XAY') + }) }) diff --git a/test/integration/builtin/tags/raw.ts b/test/integration/builtin/tags/raw.ts index 832840498..3d62b837c 100644 --- a/test/integration/builtin/tags/raw.ts +++ b/test/integration/builtin/tags/raw.ts @@ -3,20 +3,24 @@ import { expect } from 'chai' describe('tags/raw', function () { const liquid = new Liquid() - it('should support raw 1', async function () { + it('should throw when not closed', async function () { const p = liquid.parseAndRender('{% raw%}') return expect(p).be.rejectedWith(/{% raw%} not closed/) }) - it('should support raw 2', async function () { + it('should output filters as it is', async function () { const src = '{% raw %}{{ 5 | plus: 6 }}{% endraw %} is equal to 11.' const dst = '{{ 5 | plus: 6 }} is equal to 11.' const html = await liquid.parseAndRender(src) return expect(html).to.equal(dst) }) - it('should support raw 3', async function () { + it('should preserve blank characters', async function () { const src = '{% raw %}\n{{ foo}} \n{% endraw %}' const dst = '\n{{ foo}} \n' const html = await liquid.parseAndRender(src) return expect(html).to.equal(dst) }) + it('should support sync', function () { + const html = liquid.parseAndRenderSync('{% raw %}{{foo}}{% endraw %}') + return expect(html).to.equal('{{foo}}') + }) }) diff --git a/test/integration/builtin/tags/tablerow.ts b/test/integration/builtin/tags/tablerow.ts index abbe61d59..eff3538a3 100644 --- a/test/integration/builtin/tags/tablerow.ts +++ b/test/integration/builtin/tags/tablerow.ts @@ -107,4 +107,18 @@ describe('tags/tablerow', function () { return expect(html).to.equal(dst) }) }) + describe('sync support', function () { + it('should support tablerow', function () { + const src = '{% tablerow i in (1..3)%}{{ i }}{% endtablerow %}' + const dst = '123' + const html = liquid.parseAndRenderSync(src) + expect(html).to.equal(dst) + }) + it('should support empty tablerow', function () { + const src = '{% tablerow i in "" cols:2 %}{{ i }}{% endtablerow %}' + const dst = '' + const html = liquid.parseAndRenderSync(src) + return expect(html).to.equal(dst) + }) + }) }) diff --git a/test/integration/builtin/tags/unless.ts b/test/integration/builtin/tags/unless.ts index 4f7b68cfe..ca0b1a5d1 100644 --- a/test/integration/builtin/tags/unless.ts +++ b/test/integration/builtin/tags/unless.ts @@ -34,4 +34,16 @@ describe('tags/unless', function () { const html = await liquid.parseAndRender(src) return expect(html).to.equal('') }) + describe('sync support', function () { + it('should render else when predicate yields true', function () { + const src = '{% unless 0 %}yes{%else%}no{%endunless%}' + const html = liquid.parseAndRenderSync(src) + expect(html).to.equal('no') + }) + it('should render unless when predicate yields false', function () { + const src = '{% unless false %}yes{%else%}no{%endunless%}' + const html = liquid.parseAndRenderSync(src) + expect(html).to.equal('yes') + }) + }) }) diff --git a/test/integration/drop/drop.ts b/test/integration/drop/drop.ts index 0b5d7988a..bd81ba21b 100644 --- a/test/integration/drop/drop.ts +++ b/test/integration/drop/drop.ts @@ -49,6 +49,10 @@ describe('drop/drop', function () { const html = await liquid.parseAndRender(`{{obj.name}}`, { obj: new PromiseDrop() }) expect(html).to.equal('NAME') }) + it('should resolve before calling filters', async function () { + const html = await liquid.parseAndRender(`{{obj.name | downcase}}`, { obj: new PromiseDrop() }) + expect(html).to.equal('name') + }) it('should support promise returned by liquidMethodMissing', async function () { const html = await liquid.parseAndRender(`{{obj.foo}}`, { obj: new PromiseDrop() }) expect(html).to.equal('FOO') diff --git a/test/integration/liquid/cache.ts b/test/integration/liquid/cache.ts index 6e6356ff5..ff66238ad 100644 --- a/test/integration/liquid/cache.ts +++ b/test/integration/liquid/cache.ts @@ -3,36 +3,87 @@ import { Liquid } from '../../../src/liquid' import { mock, restore } from '../../stub/mockfs' describe('LiquidOptions#cache', function () { - let engine: Liquid - beforeEach(function () { - engine = new Liquid({ - root: '/root/', - extname: '.html' - }) - mock({ '/root/files/foo.html': 'foo' }) - }) afterEach(restore) - it('should be disabled by default', function () { - return engine.renderFile('files/foo') - .then(x => expect(x).to.equal('foo')) - .then(() => mock({ - '/root/files/foo.html': 'bar' - })) - .then(() => engine.renderFile('files/foo')) - .then(x => expect(x).to.equal('bar')) - }) - it('should respect cache=true option', function () { - engine = new Liquid({ - root: '/root/', - extname: '.html', - cache: true + + describe('#renderFile', function () { + it('should be disabled by default', async function () { + const engine = new Liquid({ + root: '/root/', + extname: '.html' + }) + 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/', + extname: '.html', + cache: true + }) + 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('foo') + }) + it('should not cache not exist file', async function () { + const engine = new Liquid({ + root: '/root/', + extname: '.html', + cache: true + }) + try { await engine.renderFile('foo') } catch (err) {} + + mock({ '/root/foo.html': 'foo' }) + const y = await engine.renderFile('foo') + expect(y).to.equal('foo') + }) + }) + + describe('#renderFileSync', function () { + it('should be disabled by default', function () { + const engine = new Liquid({ + root: '/root/', + extname: '.html' + }) + mock({ '/root/foo.html': 'foo' }) + const x = engine.renderFileSync('foo') + expect(x).to.equal('foo') + + mock({ '/root/foo.html': 'bar' }) + const y = engine.renderFileSync('foo') + expect(y).to.equal('bar') + }) + it('should respect cache=true option', function () { + const engine = new Liquid({ + root: '/root/', + extname: '.html', + cache: true + }) + mock({ '/root/foo.html': 'foo' }) + const x = engine.renderFileSync('foo') + expect(x).to.equal('foo') + + mock({ '/root/foo.html': 'bar' }) + const y = engine.renderFileSync('foo') + expect(y).to.equal('foo') + }) + it('should not cache not exist file', async function () { + const engine = new Liquid({ + root: '/root/', + extname: '.html', + cache: true + }) + try { engine.renderFileSync('foo') } catch (err) {} + + mock({ '/root/foo.html': 'foo' }) + const y = await engine.renderFile('foo') + expect(y).to.equal('foo') }) - return engine.renderFile('files/foo') - .then(x => expect(x).to.equal('foo')) - .then(() => mock({ - '/root/files/foo.html': 'bar' - })) - .then(() => engine.renderFile('files/foo')) - .then(x => expect(x).to.equal('foo')) }) }) diff --git a/test/integration/liquid/liquid.ts b/test/integration/liquid/liquid.ts index 6bd48fbd4..e79e29b4e 100644 --- a/test/integration/liquid/liquid.ts +++ b/test/integration/liquid/liquid.ts @@ -53,14 +53,14 @@ describe('Liquid', function () { }) after(restore) it('should render single template', function (done) { - render.call({ root: '.' }, 'foo', null as any, (err: Error | null, result: string | undefined) => { + render.call({ root: '/root' }, 'foo', null as any, (err: Error | null, result: string | undefined) => { if (err) return done(err) expect(result).to.equal('foo') done() }) }) it('should render single template with Array-typed root', function (done) { - render.call({ root: ['.'] }, 'foo', null as any, (err: Error | null, result: string | undefined) => { + render.call({ root: ['/root'] }, 'foo', null as any, (err: Error | null, result: string | undefined) => { if (err) return done(err) expect(result).to.equal('foo') done() diff --git a/test/integration/liquid/register-filters.ts b/test/integration/liquid/register-filters.ts index a7b6be01a..447cff36b 100644 --- a/test/integration/liquid/register-filters.ts +++ b/test/integration/liquid/register-filters.ts @@ -1,17 +1,24 @@ -import { test, liquid } from '../../stub/render' +import { expect } from 'chai' +import { Liquid } from '../../../src/liquid' describe('liquid#registerFilter()', function () { + const liquid = new Liquid() + describe('object arguments', function () { - liquid.registerFilter('obj_test', function () { - return JSON.stringify(arguments) + liquid.registerFilter('obj_test', function (...args) { + return JSON.stringify(args) + }) + it('should support object', async () => { + const src = `{{ "a" | obj_test: k1: "v1", k2: foo }}`, + const dst = '["a",["k1","v1"],["k2","bar"]]' + const html = await liquid.parseAndRender(src, { foo: 'bar' }) + return expect(html).to.equal(dst) + }) + it('should support mixed object', async () => { + const src = `{{ "a" | obj_test: "something", k1: "v1", k2: foo }}`, + const dst = '["a","something",["k1","v1"],["k2","bar"]]' + const html = await liquid.parseAndRender(src, { foo: 'bar' }) + return expect(html).to.equal(dst) }) - it('should support object', () => test( - `{{ "a" | obj_test: k1: "v1", k2: foo }}`, - '{"0":"a","1":["k1","v1"],"2":["k2","bar"]}' - )) - it('should support mixed object', () => test( - `{{ "a" | obj_test: "something", k1: "v1", k2: foo }}`, - '{"0":"a","1":"something","2":["k1","v1"],"3":["k2","bar"]}' - )) }) }) diff --git a/test/integration/util/error.ts b/test/integration/util/error.ts index 0a554df3b..039cfce32 100644 --- a/test/integration/util/error.ts +++ b/test/integration/util/error.ts @@ -1,4 +1,5 @@ import { expect } from 'chai' +import { RenderError } from '../../../src/util/error' import { Liquid } from '../../../src/liquid' import * as path from 'path' import { mock, restore } from '../../stub/mockfs' @@ -264,4 +265,46 @@ describe('error', function () { expect(err.stack).to.match(/at .*:\d+:\d+\)/) }) }) + describe('sync support', function () { + let engine + beforeEach(function () { + engine = new Liquid({ + root: '/' + }) + engine.registerTag('throwingTag', { + render: function () { + throw new Error('intended render error') + } + }) + }) + it('should throw RenderError when tag throws', function () { + const src = '{%throwingTag%}' + expect(() => engine.parseAndRenderSync(src)) + .to.throw(RenderError, /intended render error/) + }) + it('should contain original error info for {% include %}', function () { + const origin = ['1st', '2nd', '3rd', 'X{%throwingTag%} Y', '5th', '6th', '7th'] + mock({ + '/throwing-tag.html': origin.join('\n') + }) + const html = '{%include "throwing-tag.html"%}' + const message = [ + ' 2| 2nd', + ' 3| 3rd', + '>> 4| X{%throwingTag%} Y', + ' 5| 5th', + ' 6| 6th', + ' 7| 7th', + 'RenderError' + ] + try { + engine.parseAndRenderSync(html) + throw new Error('expected throw') + } catch (err) { + expect(err.message).to.equal(`intended render error, file:${path.resolve('/throwing-tag.html')}, line:4, col:2`) + expect(err.stack).to.contain(message.join('\n')) + expect(err.name).to.equal('RenderError') + } + }) + }) }) diff --git a/test/stub/mockfs.ts b/test/stub/mockfs.ts index e29d7d50c..2a75bd8ef 100644 --- a/test/stub/mockfs.ts +++ b/test/stub/mockfs.ts @@ -8,8 +8,7 @@ interface FileDescriptor { } let files: { [path: string]: FileDescriptor } = {} -const readFile = fs.readFile -const exists = fs.exists +const { readFile, exists, readFileSync, existsSync } = fs export function mock (options: { [path: string]: (string | FileDescriptor) }) { forOwn(options, (val, key) => { @@ -18,19 +17,26 @@ export function mock (options: { [path: string]: (string | FileDescriptor) }) { : val as FileDescriptor }) fs.readFile = async function (path) { + return fs.readFileSync(path) + } + fs.readFileSync = function (path) { 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) { + return fs.existsSync(path) + } + fs.existsSync = function (path: string) { return !!files[path] } } export function restore () { files = {} + fs.readFileSync = readFileSync + fs.existsSync = existsSync fs.readFile = readFile fs.exists = exists } diff --git a/test/stub/render.ts b/test/stub/render.ts index b9f81a672..10d7d72d2 100644 --- a/test/stub/render.ts +++ b/test/stub/render.ts @@ -8,7 +8,7 @@ export const ctx = { foo: 'bar', arr: [-2, 'a'], obj: { foo: 'bar' }, - func: function () {}, + func: function () {}, // eslint-disable-line posts: [{ category: 'foo' }, { category: 'bar' }], products: [ { title: 'Vacuum', type: 'living room' }, diff --git a/test/unit/fs/browser.ts b/test/unit/fs/browser.ts index 4e383e2a5..6b65856c0 100644 --- a/test/unit/fs/browser.ts +++ b/test/unit/fs/browser.ts @@ -59,6 +59,12 @@ describe('fs/browser', function () { }) }) + describe('#existsSync()', () => { + it('should always return true', function () { + expect(fs.existsSync('/foo/bar')).to.equal(true) + }) + }) + describe('#readFile()', () => { let server: sinon.SinonFakeServer beforeEach(() => { @@ -87,4 +93,29 @@ describe('fs/browser', function () { return result }) }) + + describe('#readFileSync()', () => { + let server: sinon.SinonFakeServer + beforeEach(() => { + server = sinon.fakeServer.create() + server.autoRespond = true + server.respondWith( + 'GET', 'https://example.com/views/hello.html', + [200, { 'Content-Type': 'text/plain' }, 'hello {{name}}'] + ); + (global as any).XMLHttpRequest = sinon.useFakeXMLHttpRequest() + }) + afterEach(() => { + server.restore() + delete (global as any).XMLHttpRequest + }) + it('should get corresponding text', function () { + const html = fs.readFileSync('https://example.com/views/hello.html') + return expect(html).to.equal('hello {{name}}') + }) + it('should throw 404', () => { + return expect(() => fs.readFileSync('https://example.com/not/exist.html')) + .to.throw('Not Found') + }) + }) }) diff --git a/test/unit/fs/node.ts b/test/unit/fs/node.ts index d0feaa8dd..574686402 100644 --- a/test/unit/fs/node.ts +++ b/test/unit/fs/node.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from 'chai-as-promised' use(chaiAsPromised) describe('fs', function () { - describe('#resolve()', function () { + describe('.resolve()', function () { it('should resolve based on root', async function () { const filepath = fs.resolve('/foo', 'bar.html', '.liquid') const expected = path.resolve('/foo/bar.html') @@ -18,23 +18,40 @@ describe('fs', function () { return expect(filepath).to.equal(expected) }) }) - describe('#exists', () => { + describe('.existsSync', () => { + it('should resolve as false if not exists', () => { + expect(fs.existsSync('/foo/bar')).to.be.false + }) + it('should resolve as true if exists', () => { + expect(fs.existsSync(__filename)).to.be.true + }) + }) + describe('.exists', () => { it('should resolve as false if not exists', async () => { const result = await fs.exists('/foo/bar') - return expect(result).to.be.false + expect(result).to.be.false }) it('should resolve as true if exists', async () => { const result = await fs.exists(__filename) - return expect(result).to.be.true + expect(result).to.be.true }) }) - describe('#readFile', function () { + describe('.readFileSync', function () { + it('should throw when not exist', function () { + return expect(() => fs.readFileSync('/foo/bar')).to.throw('ENOENT') + }) + it('should read content if exists', function () { + const content = fs.readFileSync(__filename) + expect(content).to.contain('should read content if exists') + }) + }) + describe('.readFile', function () { it('should throw when not exist', function () { return expect(fs.readFile('/foo/bar')).to.rejectedWith('ENOENT') }) it('should read content if exists', async function () { const content = await fs.readFile(__filename) - return expect(content).to.contain('should read content if exists') + expect(content).to.contain('should read content if exists') }) }) }) diff --git a/test/unit/render/expression.ts b/test/unit/render/expression.ts index adbbf6dc0..2293b3f43 100644 --- a/test/unit/render/expression.ts +++ b/test/unit/render/expression.ts @@ -17,51 +17,56 @@ describe('Expression', function () { }) it('should throw when context not defined', async function () { - return expect(() => new Expression().value()).to.throw(/context not defined/) + return expect(new Expression().value()).to.be.rejectedWith(/context not defined/) }) it('should eval simple expression', async function () { - expect(new Expression('1 < 2').value(ctx)).to.equal(true) - expect(new Expression('2 <= 2').value(ctx)).to.equal(true) - expect(new Expression('one <= two').value(ctx)).to.equal(true) - expect(new Expression('x contains "x"').value(ctx)).to.equal(false) - expect(new Expression('x contains "X"').value(ctx)).to.equal(true) - expect(new Expression('1 contains "x"').value(ctx)).to.equal(false) - expect(new Expression('y contains "x"').value(ctx)).to.equal(false) - expect(new Expression('z contains "x"').value(ctx)).to.equal(false) - expect(new Expression('(1..5) contains 3').value(ctx)).to.equal(true) - expect(new Expression('(1..5) contains 6').value(ctx)).to.equal(false) - expect(new Expression('"<=" == "<="').value(ctx)).to.equal(true) + expect(await new Expression('1 < 2').value(ctx)).to.equal(true) + expect(await new Expression('1 < 2').value(ctx)).to.equal(true) + expect(await new Expression('2 <= 2').value(ctx)).to.equal(true) + expect(await new Expression('one <= two').value(ctx)).to.equal(true) + expect(await new Expression('x contains "x"').value(ctx)).to.equal(false) + expect(await new Expression('x contains "X"').value(ctx)).to.equal(true) + expect(await new Expression('1 contains "x"').value(ctx)).to.equal(false) + expect(await new Expression('y contains "x"').value(ctx)).to.equal(false) + expect(await new Expression('z contains "x"').value(ctx)).to.equal(false) + expect(await new Expression('(1..5) contains 3').value(ctx)).to.equal(true) + expect(await new Expression('(1..5) contains 6').value(ctx)).to.equal(false) + expect(await new Expression('"<=" == "<="').value(ctx)).to.equal(true) }) describe('complex expression', function () { it('should support value or value', async function () { - expect(new Expression('false or true').value(ctx)).to.equal(true) + expect(await new Expression('false or true').value(ctx)).to.equal(true) }) it('should support < and contains', async function () { - expect(new Expression('1 < 2 and x contains "x"').value(ctx)).to.equal(false) + expect(await new Expression('1 < 2 and x contains "x"').value(ctx)).to.equal(false) }) it('should support < or contains', async function () { - expect(new Expression('1 < 2 or x contains "x"').value(ctx)).to.equal(true) + expect(await new Expression('1 < 2 or x contains "x"').value(ctx)).to.equal(true) }) it('should support value and !=', async function () { - expect(new Expression('empty and empty != ""').value(ctx)).to.equal(false) + expect(await new Expression('empty and empty != ""').value(ctx)).to.equal(false) }) it('should recognize quoted value', async function () { - expect(new Expression('">"').value(ctx)).to.equal('>') + expect(await new Expression('">"').value(ctx)).to.equal('>') }) - it('should evaluate from right to left', function () { - expect(new Expression('true or false and false').value(ctx)).to.equal(true) - expect(new Expression('true and false and false or true').value(ctx)).to.equal(false) + it('should evaluate from right to left', async function () { + expect(await new Expression('true or false and false').value(ctx)).to.equal(true) + expect(await new Expression('true and false and false or true').value(ctx)).to.equal(false) }) - it('should recognize property access', function () { + it('should recognize property access', async function () { const ctx = new Context({ obj: { foo: true } }) - expect(new Expression('obj["foo"] and true').value(ctx)).to.equal(true) + expect(await new Expression('obj["foo"] and true').value(ctx)).to.equal(true) }) }) it('should eval range expression', async function () { - expect(new Expression('(2..4)').value(ctx)).to.deep.equal([2, 3, 4]) - expect(new Expression('(two..4)').value(ctx)).to.deep.equal([2, 3, 4]) + expect(await new Expression('(2..4)').value(ctx)).to.deep.equal([2, 3, 4]) + expect(await new Expression('(two..4)').value(ctx)).to.deep.equal([2, 3, 4]) + }) + + it('should support sync', function () { + expect(new Expression('empty and empty != ""').valueSync(ctx)).to.equal(false) }) }) diff --git a/test/unit/render/value.ts b/test/unit/render/value.ts index 96c9034b9..5fa7c4e92 100644 --- a/test/unit/render/value.ts +++ b/test/unit/render/value.ts @@ -5,20 +5,20 @@ import { expect } from 'chai' describe('Value', function () { it('should eval number variable', async function () { const ctx = new Context({ one: 1 }) - expect(new Value('one').value(ctx)).to.equal(1) + expect(new Value('one').valueSync(ctx)).to.equal(1) }) it('question mark should be valid variable name', async function () { const ctx = new Context({ 'has_value?': true }) - expect(new Value('has_value?').value(ctx)).to.equal(true) + expect(new Value('has_value?').valueSync(ctx)).to.equal(true) }) it('should eval string variable', async function () { const ctx = new Context({ x: 'XXX' }) - expect(new Value('x').value(ctx)).to.equal('XXX') + expect(new Value('x').valueSync(ctx)).to.equal('XXX') }) it('should eval null literal', async function () { - expect(new Value('null').value({})).to.be.null + expect(new Value('null').valueSync({})).to.be.null }) it('should eval nil literal', async function () { - expect(new Value('nil').value({})).to.be.null + expect(new Value('nil').valueSync({})).to.be.null }) }) diff --git a/test/unit/template/filter/filter.ts b/test/unit/template/filter/filter.ts index 0c1f6fb50..a57b8fb79 100644 --- a/test/unit/template/filter/filter.ts +++ b/test/unit/template/filter/filter.ts @@ -60,4 +60,14 @@ describe('filter', function () { new Filter('/', [], false) }).to.not.throw() }) + + it('should support sync', function () { + Filter.register('add', (a, b) => a + b) + expect(new Filter('add', ['2'], false).renderSync(3, ctx)).to.equal(5) + }) + + it('should support key value pairs', function () { + Filter.register('add', (a, b) => b[0] + ':' + (a + b[1])) + expect(new Filter('add', [['num', '2']], false).renderSync(3, ctx)).to.equal('num:5') + }) }) diff --git a/test/unit/template/hash.ts b/test/unit/template/hash.ts new file mode 100644 index 000000000..94b15e6f4 --- /dev/null +++ b/test/unit/template/hash.ts @@ -0,0 +1,20 @@ +import * as chai from 'chai' +import { Hash } from '../../../src/template/tag/hash' +import { Context } from '../../../src/context/context' + +const expect = chai.expect + +describe('Hash', function () { + it('should parse variable', async function () { + const hash = await Hash.create('num:foo', new Context({ foo: 3 })) + expect(hash.num).to.equal(3) + }) + it('should parse literals', async function () { + const hash = await Hash.create('num:3', new Context()) + expect(hash.num).to.equal(3) + }) + it('should support sync', function () { + const hash = Hash.createSync('num:3', new Context()) + expect(hash.num).to.equal(3) + }) +}) diff --git a/test/unit/template/tag.ts b/test/unit/template/tag.ts index 2925c4365..211150ee2 100644 --- a/test/unit/template/tag.ts +++ b/test/unit/template/tag.ts @@ -10,7 +10,7 @@ chai.use(sinonChai) const expect = chai.expect const liquid = new Liquid() -describe('tag', function () { +describe('Tag', function () { let ctx: Context const emitter = { write: (html: string) => (emitter.html += html), html: '' } before(function () {