feat: await promises returned by Drop.valueOf() (#948) (#953) (#959)

* feat: WIP async Drop valueOf resolution (#948)

Incomplete/abandoned implementation of async Drop.valueOf() awaited across the render pipeline. Kept for reference only; known to be inconsistent and not production-ready.

Co-authored-by: Cursor <[email protected]>

* feat!: resolve async Drop.valueOf() for filters and tags

Co-authored-by: Cursor <[email protected]>

* fix: keep Drops on filter and tag inputs

Filters and tag arguments still receive the Drop. Await valueOf() at output, conditions, and operators, and restore the existing render/include Drop tests.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-10-02 00:58:49 +08:00
committed by GitHub
co-authored by Cursor
parent e68514f066
commit 7dfa76b7b2
19 changed files with 359 additions and 77 deletions
+3 -3
View File
@@ -2,11 +2,11 @@ import { isNil, isString, toValue } from '../util'
import { EmptyDrop } from '../drop'
export class BlankDrop extends EmptyDrop {
public equals (value: any) {
public * equals (value: any): Generator<unknown, boolean, any> {
if (value === false) return true
if (isNil(toValue(value))) return true
if (isNil(yield toValue(value))) return true
if (isString(value)) return /^\s*$/.test(value)
return super.equals(value)
return yield super.equals(value)
}
static is (value: unknown) {
return value instanceof BlankDrop
+12 -5
View File
@@ -1,11 +1,18 @@
import { isFunction } from '../util'
/**
* Comparison methods may return a boolean, a promise, or a generator.
* Operators yield the result, so sync implementations stay valid and async
* `valueOf()` can be awaited with `yield`.
*/
export type ComparableResult = boolean | Promise<boolean> | Generator<unknown, boolean, any>
export interface Comparable {
equals: (rhs: any) => boolean;
gt: (rhs: any) => boolean;
geq: (rhs: any) => boolean;
lt: (rhs: any) => boolean;
leq: (rhs: any) => boolean;
equals: (rhs: any) => ComparableResult;
gt: (rhs: any) => ComparableResult;
geq: (rhs: any) => ComparableResult;
lt: (rhs: any) => ComparableResult;
leq: (rhs: any) => ComparableResult;
}
export function isComparable (arg: any): arg is Comparable {
+2 -2
View File
@@ -3,9 +3,9 @@ import { Comparable } from './comparable'
import { isObject, isString, isArray, toValue } from '../util'
export class EmptyDrop extends Drop implements Comparable {
public equals (value: any) {
public * equals (value: any): Generator<unknown, boolean, any> {
if (value instanceof EmptyDrop) return false
value = toValue(value)
value = yield toValue(value)
if (isString(value) || isArray(value)) return value.length === 0
if (isObject(value)) return Object.keys(value).length === 0
return false
+2 -2
View File
@@ -3,8 +3,8 @@ import { Comparable } from './comparable'
import { isNil, toValue } from '../util'
export class NullDrop extends Drop implements Comparable {
public equals (value: any) {
return isNil(toValue(value))
public * equals (value: any): Generator<unknown, boolean, any> {
return isNil(yield toValue(value))
}
public gt () {
return false
+11 -12
View File
@@ -121,26 +121,26 @@ export function slice<T> (this: FilterImpl, v: T[] | string, begin: number, leng
: String.prototype.slice.call(v, begin, begin + length)
}
function expectedMatcher (this: FilterImpl, expected: any): (v: any) => boolean {
function * matches (this: FilterImpl, value: unknown, expected: unknown): Generator<unknown, boolean, any> {
if (this.context.opts.jekyllWhere) {
return (v: any) => EmptyDrop.is(expected) ? equals(v, expected) : (isArray(v) ? arrayIncludes(v, expected) : equals(v, expected))
} else if (expected === undefined) {
return (v: any) => isTruthy(v, this.context)
} else {
return (v: any) => equals(v, expected)
if (EmptyDrop.is(expected)) return yield equals(value, expected)
if (isArray(value)) return yield arrayIncludes(value, expected)
return yield equals(value, expected)
}
if (expected === undefined) return isTruthy(value, this.context)
return yield equals(value, expected)
}
function * filter<T extends object> (this: FilterImpl, include: boolean, arr: T[], property: string, expected: any): IterableIterator<unknown> {
const values: unknown[] = []
arr = toArray(arr)
this.context.memoryLimit.use(arr.length)
const token = new Tokenizer(stringify(property)).readScopeValue()
const result: T[] = []
for (const item of arr) {
values.push(yield evalToken(token, this.context.spawn(item)))
const value = yield evalToken(token, this.context.spawn(item))
if ((yield matches.call(this, value, expected)) === include) result.push(item)
}
const matcher = expectedMatcher.call(this, expected)
return Array.prototype.filter.call(arr, (_, i) => matcher(values[i]) === include)
return result
}
function * filter_exp<T extends object> (this: FilterImpl, include: boolean, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
@@ -204,10 +204,9 @@ export function * group_by_exp<T extends object> (this: FilterImpl, arr: T[], it
function * search<T extends object> (this: FilterImpl, arr: T[], property: string, expected: string): IterableIterator<unknown> {
const token = new Tokenizer(stringify(property)).readScopeValue()
const array = toArray(arr)
const matcher = expectedMatcher.call(this, expected)
for (let index = 0; index < array.length; index++) {
const value = yield evalToken(token, this.context.spawn(array[index]))
if (matcher(value)) return [index, array[index]]
if (yield matches.call(this, value, expected)) return [index, array[index]]
}
}
+2 -2
View File
@@ -4,9 +4,9 @@ export * as TypeGuards from './util/type-guards'
export { toValue, createTrie, Trie, toPromise, toValueSync, assert, LiquidError, ParseError, RenderError, UndefinedVariableError, TokenizationError, AssertionError } from './util'
export type { LiquidErrors } from './util/error'
export { Drop } from './drop'
export type { Comparable } from './drop'
export type { Comparable, ComparableResult } from './drop'
export { Emitter } from './emitters'
export { defaultOperators, Operators, evalToken, evalQuotedToken, Expression, isFalsy, isTruthy } from './render'
export { defaultOperators, Operators, evalToken, evalTokenValue, evalQuotedToken, Expression, isFalsy, isTruthy } from './render'
export { Context, Scope } from './context'
export { Value, Hash, Template, FilterImplOptions, Tag, Filter, Output, Variable, VariableLocation, VariableSegments, Variables, StaticAnalysis, StaticAnalysisOptions, analyze, analyzeSync, Arguments, PartialScope } from './template'
export type { TagRenderReturn } from './template'
+18 -5
View File
@@ -1,5 +1,5 @@
import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, OperatorType, operatorTypes, FilteredValueToken } from '../tokens'
import { isRangeToken, isPropertyAccessToken, isFilteredValueToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util'
import { isRangeToken, isPropertyAccessToken, isFilteredValueToken, UndefinedVariableError, range, isOperatorToken, assert, toValue } from '../util'
import type { Context } from '../context'
import type { UnaryOperatorHandler } from '../render'
import { Drop } from '../drop'
@@ -36,6 +36,11 @@ export class Expression {
}
}
/**
* Evaluate `token`, preserving Drops.
* Operators use this so `Comparable` drops (`empty`, `nil`, `blank`) and custom drops
* are compared as drops rather than as their `valueOf()` result.
*/
export function * evalToken (token: Token | undefined, ctx: Context, lenient = false): IterableIterator<unknown> {
if (!token) return
if ('content' in token) return token.content
@@ -59,10 +64,18 @@ function * evalFilteredValueToken (token: FilteredValueToken, ctx: Context, leni
return val
}
/**
* Evaluate `token` to the value filters and tags consume.
* Awaits a promise returned by `Drop.valueOf()`.
*/
export function * evalTokenValue (token: Token | undefined, ctx: Context, lenient = false): IterableIterator<unknown> {
return yield toValue(yield evalToken(token, ctx, lenient))
}
function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator<unknown> {
const props: (string | number | Drop)[] = []
for (const prop of token.props) {
props.push((yield evalToken(prop, ctx, false)) as unknown as string | number | Drop)
props.push((yield evalTokenValue(prop, ctx, false)) as unknown as string | number | Drop)
}
try {
if (token.variable) {
@@ -81,9 +94,9 @@ export function evalQuotedToken (token: QuotedToken) {
return token.content
}
function * evalRangeToken (token: RangeToken, ctx: Context) {
const low: number = yield evalToken(token.lhs, ctx)
const high: number = yield evalToken(token.rhs, ctx)
function * evalRangeToken (token: RangeToken, ctx: Context): IterableIterator<unknown> {
const low = (yield evalTokenValue(token.lhs, ctx)) as unknown as number
const high = (yield evalTokenValue(token.rhs, ctx)) as unknown as number
ctx.memoryLimit.use(high - low + 1)
return range(+low, +high + 1)
}
+50 -37
View File
@@ -4,61 +4,74 @@ import { toValue } from '../util'
import { isFalsy, isTruthy } from '../render/boolean'
import { isArray, isFunction } from '../util/underscore'
export type UnaryOperatorHandler = (operand: any, ctx: Context) => boolean;
export type BinaryOperatorHandler = (lhs: any, rhs: any, ctx: Context) => boolean;
/**
* A handler may return a `boolean` (legacy interface), a `Promise`, or a generator
* that `yield`s to await async values. The render driver resolves all three, so
* operators written against the legacy `=> boolean` signature keep working.
*/
export type OperatorGenerator = Generator<unknown, boolean, any>
export type OperatorResult = boolean | Promise<boolean> | OperatorGenerator
export type UnaryOperatorHandler = (operand: any, ctx: Context) => OperatorResult;
export type BinaryOperatorHandler = (lhs: any, rhs: any, ctx: Context) => OperatorResult;
export type OperatorHandler = UnaryOperatorHandler | BinaryOperatorHandler;
export type Operators = Record<string, OperatorHandler>
export const defaultOperators: Operators = {
'==': equals,
'!=': (l: any, r: any) => !equals(l, r),
'>': (l: any, r: any) => {
if (isComparable(l)) return l.gt(r)
if (isComparable(r)) return r.lt(l)
return toValue(l) > toValue(r)
'==': function * (l: any, r: any): OperatorGenerator { return yield equals(l, r) },
'!=': function * (l: any, r: any): OperatorGenerator { return !(yield equals(l, r)) },
'>': function * (l: any, r: any): OperatorGenerator {
if (isComparable(l)) return yield l.gt(r)
if (isComparable(r)) return yield r.lt(l)
return (yield toValue(l)) > (yield toValue(r))
},
'<': (l: any, r: any) => {
if (isComparable(l)) return l.lt(r)
if (isComparable(r)) return r.gt(l)
return toValue(l) < toValue(r)
'<': function * (l: any, r: any): OperatorGenerator {
if (isComparable(l)) return yield l.lt(r)
if (isComparable(r)) return yield r.gt(l)
return (yield toValue(l)) < (yield toValue(r))
},
'>=': (l: any, r: any) => {
if (isComparable(l)) return l.geq(r)
if (isComparable(r)) return r.leq(l)
return toValue(l) >= toValue(r)
'>=': function * (l: any, r: any): OperatorGenerator {
if (isComparable(l)) return yield l.geq(r)
if (isComparable(r)) return yield r.leq(l)
return (yield toValue(l)) >= (yield toValue(r))
},
'<=': (l: any, r: any) => {
if (isComparable(l)) return l.leq(r)
if (isComparable(r)) return r.geq(l)
return toValue(l) <= toValue(r)
'<=': function * (l: any, r: any): OperatorGenerator {
if (isComparable(l)) return yield l.leq(r)
if (isComparable(r)) return yield r.geq(l)
return (yield toValue(l)) <= (yield toValue(r))
},
'contains': (l: any, r: any) => {
l = toValue(l)
if (isArray(l)) return l.some((i) => equals(i, r))
if (isFunction(l?.indexOf)) return l.indexOf(toValue(r)) > -1
'contains': function * (l: any, r: any): OperatorGenerator {
l = yield toValue(l)
if (isArray(l)) return yield arrayIncludes(l, r)
if (isFunction(l?.indexOf)) return l.indexOf(yield toValue(r)) > -1
return false
},
'not': (v: any, ctx: Context) => isFalsy(toValue(v), ctx),
'and': (l: any, r: any, ctx: Context) => isTruthy(toValue(l), ctx) && isTruthy(toValue(r), ctx),
'or': (l: any, r: any, ctx: Context) => isTruthy(toValue(l), ctx) || isTruthy(toValue(r), ctx)
'not': function * (v: any, ctx: Context): OperatorGenerator { return isFalsy(yield toValue(v), ctx) },
'and': function * (l: any, r: any, ctx: Context): OperatorGenerator { return isTruthy(yield toValue(l), ctx) && isTruthy(yield toValue(r), ctx) },
'or': function * (l: any, r: any, ctx: Context): OperatorGenerator { return isTruthy(yield toValue(l), ctx) || isTruthy(yield toValue(r), ctx) }
}
export function equals (lhs: any, rhs: any): boolean {
if (isComparable(lhs)) return lhs.equals(rhs)
if (isComparable(rhs)) return rhs.equals(lhs)
lhs = toValue(lhs)
rhs = toValue(rhs)
export function * equals (lhs: any, rhs: any): Generator<unknown, boolean, any> {
if (isComparable(lhs)) return yield lhs.equals(rhs)
if (isComparable(rhs)) return yield rhs.equals(lhs)
lhs = yield toValue(lhs)
rhs = yield toValue(rhs)
if (isArray(lhs)) {
return isArray(rhs) && arrayEquals(lhs, rhs)
return isArray(rhs) && (yield arrayEquals(lhs, rhs))
}
return lhs === rhs
}
function arrayEquals (lhs: any[], rhs: any[]): boolean {
function * arrayEquals (lhs: any[], rhs: any[]): Generator<unknown, boolean, any> {
if (lhs.length !== rhs.length) return false
return !lhs.some((value, i) => !equals(value, rhs[i]))
for (let i = 0; i < lhs.length; i++) {
if (!(yield equals(lhs[i], rhs[i]))) return false
}
return true
}
export function arrayIncludes (arr: any[], item: any): boolean {
return arr.some(value => equals(value, item))
export function * arrayIncludes (arr: any[], item: any): Generator<unknown, boolean, any> {
for (const value of arr) {
if (yield equals(value, item)) return true
}
return false
}
+2 -2
View File
@@ -56,12 +56,12 @@ export default class extends Tag {
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
const r = this.liquid.renderer
const target = toValue(yield this.value.value(ctx, ctx.opts.lenientIf))
const target = yield toValue(yield this.value.value(ctx, ctx.opts.lenientIf))
let branchHit = false
for (const branch of this.branches) {
for (const valueToken of branch.values) {
const value = yield evalToken(valueToken, ctx, ctx.opts.lenientIf)
if (equals(target, value)) {
if (yield equals(target, value)) {
yield r.renderTemplates(branch.templates, ctx, emitter)
branchHit = true
break
+2 -1
View File
@@ -1,5 +1,6 @@
import { Liquid, TopLevelToken, Emitter, Value, TagToken, Context, Tag } from '..'
import { Arguments } from '../template'
import { toValue } from '../util'
export default class extends Tag {
private value?: Value
@@ -13,7 +14,7 @@ export default class extends Tag {
}
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
if (!this.value) return
const val = yield this.value.value(ctx, false)
const val = yield toValue(yield this.value.value(ctx, false))
emitter.write(val)
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Liquid, Tag, Value, Emitter, isTruthy, TagToken, TopLevelToken, Context, Template } from '..'
import { Parser } from '../parser'
import { Arguments } from '../template'
import { assert, assertEmpty } from '../util'
import { assert, assertEmpty, toValue } from '../util'
export default class extends Tag {
branches: { value: Value, templates: Template[] }[] = []
@@ -37,7 +37,7 @@ export default class extends Tag {
const r = this.liquid.renderer
for (const { value, templates } of this.branches) {
const v = yield value.value(ctx, ctx.opts.lenientIf)
const v = yield toValue(yield value.value(ctx, ctx.opts.lenientIf))
if (isTruthy(v, ctx)) {
yield r.renderTemplates(templates, ctx, emitter)
return
+2 -1
View File
@@ -1,6 +1,7 @@
import { Liquid, Tag, Value, TopLevelToken, Template, Emitter, isTruthy, isFalsy, Context, TagToken } from '..'
import { Parser } from '../parser'
import { Arguments } from '../template'
import { toValue } from '../util'
export default class extends Tag {
branches: { value: Value, test: (val: any, ctx: Context) => boolean, templates: Template[] }[] = []
@@ -44,7 +45,7 @@ export default class extends Tag {
const r = this.liquid.renderer
for (const { value, test, templates } of this.branches) {
const v = yield value.value(ctx, ctx.opts.lenientIf)
const v = yield toValue(yield value.value(ctx, ctx.opts.lenientIf))
if (test(v, ctx)) {
yield r.renderTemplates(templates, ctx, emitter)
return
+2 -1
View File
@@ -7,6 +7,7 @@ import { Tokenizer } from '../parser'
import { Liquid } from '../liquid'
import { Filter } from './filter'
import { FilterToken } from '../tokens'
import { toValue } from '../util'
export class Output extends TemplateImpl<OutputToken> implements Template {
value: Value
@@ -22,7 +23,7 @@ export class Output extends TemplateImpl<OutputToken> implements Template {
}
}
public * render (ctx: Context, emitter: Emitter): IterableIterator<unknown> {
const val = yield this.value.value(ctx, false)
const val = yield toValue(yield this.value.value(ctx, false))
emitter.write(val)
}