From 768fb79e32d84cdacee79a080f0d07a8a7439558 Mon Sep 17 00:00:00 2001 From: sschuldenzucker Date: Sat, 28 Nov 2020 19:11:32 +0100 Subject: [PATCH] Implement lenientIf option (resolve #265; default off) See description of the option in liquid-options.ts. --- src/builtin/tags/if.ts | 2 +- src/liquid-options.ts | 4 ++++ src/render/expression.ts | 31 ++++++++++++++++++++++++++----- src/template/value.ts | 6 +++++- test/integration/liquid/strict.ts | 26 ++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/builtin/tags/if.ts b/src/builtin/tags/if.ts index b0be01eb5..36a25126b 100644 --- a/src/builtin/tags/if.ts +++ b/src/builtin/tags/if.ts @@ -31,7 +31,7 @@ export default { const r = this.liquid.renderer for (const branch of this.branches) { - const cond = yield new Expression(branch.cond).value(ctx) + const cond = yield new Expression(branch.cond, ctx.opts.lenientIf).value(ctx) if (isTruthy(cond, ctx)) { yield r.renderTemplates(branch.templates, ctx, emitter) return diff --git a/src/liquid-options.ts b/src/liquid-options.ts index 40e59d135..9e868fb4f 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -19,6 +19,8 @@ export interface LiquidOptions { strictFilters?: boolean; /** Whether or not to assert variable existence. If set to `false`, undefined variables will be rendered as empty string. Otherwise, undefined variables will cause an exception. Defaults to `false`. */ strictVariables?: boolean; + /** Modifies the behavior of `strictVariables`. If set, a single undefined variable will *not* cause an exception in the context of the `if` tag and the `default` filter. Instead, it will evaluate to `false` and `null`, respectively. Irrelevant if `strictVariables` is not set. Defaults to `false`. **/ + lenientIf?: boolean; /** Strip blank characters (including ` `, `\t`, and `\r`) from the right of tags (`{% %}`) until `\n` (inclusive). Defaults to `false`. */ trimTagRight?: boolean; /** Similar to `trimTagRight`, whereas the `\n` is exclusive. Defaults to `false`. See Whitespace Control for details. */ @@ -56,6 +58,7 @@ export interface NormalizedFullOptions extends NormalizedOptions { dynamicPartials: boolean; strictFilters: boolean; strictVariables: boolean; + lenientIf: boolean; trimTagRight: boolean; trimTagLeft: boolean; trimOutputRight: boolean; @@ -85,6 +88,7 @@ export const defaultOptions: NormalizedFullOptions = { outputDelimiterRight: '}}', strictFilters: false, strictVariables: false, + lenientIf: false, globals: {} } diff --git a/src/render/expression.ts b/src/render/expression.ts index 2f897709b..8717dbf59 100644 --- a/src/render/expression.ts +++ b/src/render/expression.ts @@ -16,21 +16,32 @@ import { operatorImpls } from '../render/operator' export class Expression { private operands: any[] = [] private postfix: IterableIterator + private lenient: boolean - public constructor (str: string) { + public constructor (str: string, lenient: boolean = false) { const tokenizer = new Tokenizer(str) this.postfix = toPostfix(tokenizer.readExpression()) + this.lenient = lenient } public evaluate (ctx: Context): any { - for (const token of this.postfix) { + // we manually loop over the iterator to tell if it's a single variable, for lenient. + let iterResult = this.postfix.next() + let isFirstToken = true + while (!iterResult.done) { + const token = iterResult.value + iterResult = this.postfix.next() + const isLastToken = iterResult.done + if (TypeGuards.isOperatorToken(token)) { const r = this.operands.pop() const l = this.operands.pop() const result = evalOperatorToken(token, l, r, ctx) this.operands.push(result) } else { - this.operands.push(evalToken(token, ctx)) + this.operands.push(evalToken(token, ctx, this.lenient && isFirstToken && isLastToken)) } + + isFirstToken = false } return this.operands[0] } @@ -39,12 +50,22 @@ export class Expression { } } -export function evalToken (token: Token | undefined, ctx: Context): any { +export function evalToken (token: Token | undefined, ctx: Context, lenient: boolean = false): any { assert(ctx, () => 'unable to evaluate: context not defined') if (TypeGuards.isPropertyAccessToken(token)) { const variable = token.getVariableAsText() const props: string[] = token.props.map(prop => evalToken(prop, ctx)) - return ctx.get([variable, ...props]) + try { + return ctx.get([variable, ...props]) + } catch (e) { + // we catch the error thrown by Context.getFromScope() for undefined vars. + // Alt, we could make this more robust by setting a flag or using a separate error class. + if (lenient && e instanceof TypeError && e.message.startsWith("undefined variable:")) { + return null + } else { + throw(e) + } + } } if (TypeGuards.isRangeToken(token)) return evalRangeToken(token, ctx) if (TypeGuards.isLiteralToken(token)) return evalLiteralToken(token) diff --git a/src/template/value.ts b/src/template/value.ts index e8ef76563..ade698ed2 100644 --- a/src/template/value.ts +++ b/src/template/value.ts @@ -4,6 +4,7 @@ import { FilterMap } from '../template/filter/filter-map' import { Filter } from './filter/filter' import { Context } from '../context/context' import { ValueToken } from '../tokens/value-token' +import { assert } from '../util/assert' export class Value { public readonly filters: Filter[] = [] @@ -18,7 +19,10 @@ export class Value { this.filters = tokenizer.readFilters().map(({ name, args }) => new Filter(name, this.filterMap.get(name), args)) } public * value (ctx: Context) { - let val = yield evalToken(this.initial, ctx) + assert(ctx, () => 'unable to evaluate: context not defined') + const lenient = ctx.opts.lenientIf && this.filters.length > 0 && this.filters[0].name == "default" + + let val = yield evalToken(this.initial, ctx, lenient) for (const filter of this.filters) { val = yield filter.render(val, ctx) } diff --git a/test/integration/liquid/strict.ts b/test/integration/liquid/strict.ts index 2858ac422..0f4040672 100644 --- a/test/integration/liquid/strict.ts +++ b/test/integration/liquid/strict.ts @@ -30,4 +30,30 @@ describe('LiquidOptions#strict*', function () { return expect(engine.parseAndRender(html, ctx, opts)).to .be.rejectedWith(/undefined variable: notdefined/) }) + describe('with strictVariables and lenientIf', function() { + const strictLenientOpts = { + strictVariables: true, + lenientIf: true + } + it('should not throw in `if` with a single variable', async function () { + const tpl = engine.parse('before{% if notdefined %}{{notdefined}}{% endif %}after') + const html = await engine.render(tpl, ctx, strictLenientOpts) + return expect(html).to.equal('beforeafter') + }) + it('should support elsif with undefined variables', async function () { + const tpl = engine.parse('{% if notdefined1 %}a{% elsif notdefined2 %}b{% elsif defined3 %}{{defined3}}{% else %}d{% endif %}') + const html = await engine.render(tpl, {'defined3': 'bla'}, strictLenientOpts) + return expect(html).to.equal('bla') + }) + it('should still throw with an undefined variable in an expression', function () { + const tpl = engine.parse('{% if notdefined == 15 %}a{% endif %}') + const fhtml = engine.render(tpl, ctx, strictLenientOpts) + return expect(fhtml).to.be.rejectedWith(/undefined variable: notdefined/) + }) + it('should allow an undefined variable when before the `default` filter', async function () { + const tpl = engine.parse('{{notdefined | default: "a" | tolower}}') + const html = await engine.render(tpl, ctx, strictLenientOpts) + return expect(html).to.equal('a') + }) + }) })