Implement lenientIf option (resolve #265; default off)

See description of the option in liquid-options.ts.
This commit is contained in:
sschuldenzucker
2020-12-06 22:58:08 +08:00
committed by Jun Yang
parent c920ebb282
commit 768fb79e32
5 changed files with 62 additions and 7 deletions
+1 -1
View File
@@ -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
+4
View File
@@ -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: {}
}
+26 -5
View File
@@ -16,21 +16,32 @@ import { operatorImpls } from '../render/operator'
export class Expression {
private operands: any[] = []
private postfix: IterableIterator<Token>
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)
+5 -1
View File
@@ -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)
}
+26
View File
@@ -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')
})
})
})