fix(context): use null-prototype scope and register objects

Add createScope(); use for bottom scope, spawn default, getAll merge, ctx.push frames, filter loops, include/layout blocks registers, and cycle groups. registers uses Object.create(null) and getRegister uses ??.

For-loop continue register defaults to 0 (not {}): Array.slice coerces plain {} but not null-prototype objects.

Export createScope from the package entry.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Yang Jun
2026-05-12 22:50:51 +08:00
co-authored by Cursor
parent 02dcd4e970
commit a69c3ea2a6
11 changed files with 46 additions and 28 deletions
+8
View File
@@ -216,4 +216,12 @@ describe('Context', function () {
expect(ctx.getSync(['foo'])).toEqual('zoo')
})
})
describe('scope storage', function () {
it('should use null prototype for bottom scope', function () {
expect(Object.getPrototypeOf(new Context().bottom())).toBeNull()
})
it('should use null prototype for getAll() merge result', function () {
expect(Object.getPrototypeOf(new Context({ a: 1 }).getAll())).toBeNull()
})
})
})
+6 -6
View File
@@ -2,7 +2,7 @@ import { getPerformance } from '../util/performance'
import { Drop } from '../drop/drop'
import { __assign } from 'tslib'
import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options'
import { Scope } from './scope'
import { Scope, createScope } from './scope'
import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue } from '../util'
type PropertyKey = string | number;
@@ -12,8 +12,8 @@ export class Context {
* insert a Context-level empty scope,
* for tags like `{% capture %}` `{% assign %}` to operate
*/
private scopes: Scope[] = [{}]
private registers = {}
private scopes: Scope[] = [createScope()]
private registers: Record<string, any> = Object.create(null)
/**
* user passed in scope
* `{% increment %}`, `{% decrement %}` changes this scope,
@@ -49,7 +49,7 @@ export class Context {
this.renderLimit = renderLimit ?? new Limiter('template render', getPerformance().now() + (renderOptions.renderLimit ?? opts.renderLimit))
}
public getRegister<T> (key: string, defaultValue: T = undefined as T): T {
return (this.registers[key] = this.registers[key] || defaultValue)
return (this.registers[key] = this.registers[key] ?? defaultValue)
}
public setRegister (key: string, value: any) {
return (this.registers[key] = value)
@@ -62,7 +62,7 @@ export class Context {
}
public getAll () {
return [this.globals, this.environments, ...this.scopes]
.reduce((ctx, val) => __assign(ctx, val), {})
.reduce((ctx, val) => __assign(ctx, val), createScope())
}
/**
* @deprecated use `_get()` or `getSync()` instead
@@ -102,7 +102,7 @@ export class Context {
public bottom () {
return this.scopes[0]
}
public spawn (scope = {}) {
public spawn (scope: object = createScope()) {
return new Context(scope, this.opts, {
sync: this.sync,
globals: this.globals,
+10
View File
@@ -5,3 +5,13 @@ interface ScopeObject extends Record<string | number | symbol, any> {
}
export type Scope = ScopeObject | Drop
/**
* Plain scope bag with a null prototype so lookups like `__proto__` are not the
* Object.prototype accessor unless explicitly assigned as an own property.
*/
export function createScope (props?: Record<PropertyKey, any>): ScopeObject {
return props == null
? Object.create(null)
: Object.assign(Object.create(null), props)
}
+4 -4
View File
@@ -2,7 +2,7 @@ import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare,
import { arrayIncludes, equals, evalToken, isTruthy } from '../render'
import { Value, FilterImpl } from '../template'
import { Tokenizer } from '../parser'
import type { Scope } from '../context'
import { createScope, type Scope } from '../context'
import { EmptyDrop } from '../drop'
export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: string) {
@@ -139,7 +139,7 @@ function * filter_exp<T extends object> (this: FilterImpl, include: boolean, arr
const array = toArray(arr)
this.context.memoryLimit.use(array.length)
for (const item of array) {
this.context.push({ [itemName]: item })
this.context.push(createScope({ [itemName]: item }))
const value = yield keyTemplate.value(this.context)
this.context.pop()
if (value === include) filtered.push(item)
@@ -182,7 +182,7 @@ export function * group_by_exp<T extends object> (this: FilterImpl, arr: T[], it
arr = toEnumerable(arr)
this.context.memoryLimit.use(arr.length)
for (const item of arr) {
this.context.push({ [itemName]: item })
this.context.push(createScope({ [itemName]: item }))
const key = yield keyTemplate.value(this.context)
this.context.pop()
if (!map.has(key)) map.set(key, [])
@@ -205,7 +205,7 @@ function * search_exp<T extends object> (this: FilterImpl, arr: T[], itemName: s
const predicate = new Value(stringify(exp), this.liquid)
const array = toArray(arr)
for (let index = 0; index < array.length; index++) {
this.context.push({ [itemName]: array[index] })
this.context.push(createScope({ [itemName]: array[index] }))
const value = yield predicate.value(this.context)
this.context.pop()
if (value) return [index, array[index]]
+1 -1
View File
@@ -7,7 +7,7 @@ export { Drop } from './drop'
export type { Comparable } from './drop'
export { Emitter } from './emitters'
export { defaultOperators, Operators, evalToken, evalQuotedToken, Expression, isFalsy, isTruthy } from './render'
export { Context, Scope } from './context'
export { Context, Scope, createScope } 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'
export { Token, TopLevelToken, TagToken, ValueToken } from './tokens'
+4 -4
View File
@@ -1,4 +1,4 @@
import { BlockMode } from '../context'
import { BlockMode, createScope } from '../context'
import { isTagToken } from '../util'
import { BlockDrop } from '../drop'
import { Liquid, TagToken, TopLevelToken, Template, Context, Emitter, Tag } from '..'
@@ -23,7 +23,7 @@ export default class extends Tag {
* render (ctx: Context, emitter: Emitter) {
const blockRender = this.getBlockRender(ctx)
if (ctx.getRegister('blockMode') === BlockMode.STORE) {
ctx.getRegister('blocks', {} as Record<string, any>)[this.block] = blockRender
ctx.getRegister('blocks', createScope() as Record<string, any>)[this.block] = blockRender
} else {
yield blockRender(new BlockDrop(), emitter)
}
@@ -32,13 +32,13 @@ export default class extends Tag {
private getBlockRender (ctx: Context) {
const self = this as Tag
const { liquid, templates } = this
const renderChild = ctx.getRegister('blocks', {} as Record<string, any>)[this.block]
const renderChild = ctx.getRegister('blocks', createScope() as Record<string, any>)[this.block]
const renderCurrent = function * (superBlock: BlockDrop, emitter: Emitter) {
const stack: Tag[] = ctx.getRegister('blockStack', [])
if (stack.includes(self)) throw new Error('block tag cannot be nested')
stack.push(self)
ctx.push({ block: superBlock })
ctx.push(createScope({ block: superBlock }))
yield liquid.renderer.renderTemplates(templates, ctx, emitter)
ctx.pop()
stack.pop()
+2 -2
View File
@@ -1,4 +1,4 @@
import { TopLevelToken, Liquid, ValueToken, evalToken, Emitter, TagToken, Context, Tag } from '..'
import { TopLevelToken, Liquid, ValueToken, evalToken, Emitter, TagToken, Context, Tag, createScope } from '..'
import { Arguments } from '../template'
export default class extends Tag {
@@ -27,7 +27,7 @@ export default class extends Tag {
* render (ctx: Context, emitter: Emitter): Generator<unknown, unknown, unknown> {
const group = (yield evalToken(this.group, ctx)) as ValueToken
const fingerprint = `cycle:${group}:` + this.candidates.join(',')
const groups = ctx.getRegister('cycle', {} as Record<string, number>)
const groups = ctx.getRegister('cycle', createScope() as Record<string, number>)
let idx = groups[fingerprint]
if (idx === undefined) {
+3 -3
View File
@@ -1,4 +1,4 @@
import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream, createScope } from '..'
import { assertEmpty, isValueToken, toEnumerable } from '../util'
import { ForloopDrop } from '../drop/forloop-drop'
import { Parser } from '../parser'
@@ -50,7 +50,7 @@ export default class extends Tag {
}
const continueKey = 'continue-' + this.variable + '-' + this.collection.getText()
ctx.push({ continue: ctx.getRegister(continueKey, {}) })
ctx.push(createScope({ continue: ctx.getRegister(continueKey, 0) }))
const hash = yield this.hash.render(ctx)
ctx.pop()
@@ -65,7 +65,7 @@ export default class extends Tag {
}, collection)
ctx.setRegister(continueKey, (hash['offset'] || 0) + collection.length)
const scope = { forloop: new ForloopDrop(collection.length, this.collection.getText(), this.variable) }
const scope = createScope({ forloop: new ForloopDrop(collection.length, this.collection.getText(), this.variable) })
ctx.push(scope)
for (const item of collection) {
scope[this.variable] = item
+3 -3
View File
@@ -1,4 +1,4 @@
import { Template, ValueToken, TopLevelToken, Liquid, Tag, assert, evalToken, Hash, Emitter, TagToken, Context } from '..'
import { Template, ValueToken, TopLevelToken, Liquid, Tag, assert, evalToken, Hash, Emitter, TagToken, Context, createScope } from '..'
import { BlockMode, Scope } from '../context'
import { Parser } from '../parser'
import { Argument, Arguments, PartialScope } from '../template'
@@ -32,12 +32,12 @@ export default class extends Tag {
assert(filepath, () => `illegal file path "${filepath}"`)
const saved = ctx.saveRegister('blocks', 'blockMode')
ctx.setRegister('blocks', {})
ctx.setRegister('blocks', createScope())
ctx.setRegister('blockMode', BlockMode.OUTPUT)
const scope = (yield hash.render(ctx)) as Scope
if (withVar) scope[filepath] = yield evalToken(withVar, ctx)
const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this['currentFile'])) as Template[]
ctx.push(ctx.opts.jekyllInclude ? { include: scope } : scope)
ctx.push(ctx.opts.jekyllInclude ? createScope({ include: scope }) : Object.assign(createScope(), scope))
yield renderer.renderTemplates(templates, ctx, emitter)
ctx.pop()
ctx.restoreRegister(saved)
+3 -3
View File
@@ -1,4 +1,4 @@
import { Scope, Template, Liquid, Tag, assert, Emitter, Hash, TagToken, TopLevelToken, Context } from '..'
import { Scope, Template, Liquid, Tag, assert, Emitter, Hash, TagToken, TopLevelToken, Context, createScope } from '..'
import { BlockMode } from '../context'
import { parseFilePath, renderFilePath, ParsedFileName } from './render'
import { BlankDrop } from '../drop'
@@ -32,14 +32,14 @@ export default class extends Tag {
// render remaining contents and store rendered results
ctx.setRegister('blockMode', BlockMode.STORE)
const html = yield renderer.renderTemplates(this.templates, ctx)
const blocks = ctx.getRegister('blocks', {} as Record<string, any>)
const blocks = ctx.getRegister('blocks', createScope() as Record<string, any>)
// set whole content to anonymous block if anonymous doesn't specified
if (blocks[''] === undefined) blocks[''] = (parent: BlankDrop, emitter: Emitter) => emitter.write(html)
ctx.setRegister('blockMode', BlockMode.OUTPUT)
// render the layout file use stored blocks
ctx.push((yield args.render(ctx)) as Scope)
ctx.push(Object.assign(createScope(), (yield args.render(ctx)) as Scope))
yield renderer.renderTemplates(templates, ctx, emitter)
ctx.pop()
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { isValueToken, toEnumerable } from '../util'
import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream, createScope } from '..'
import { TablerowloopDrop } from '../drop/tablerowloop-drop'
import { Parser } from '../parser'
import { Arguments } from '../template'
@@ -48,7 +48,7 @@ export default class extends Tag {
const r = this.liquid.renderer
const tablerowloop = new TablerowloopDrop(collection.length, cols, this.collection.getText(), this.variable)
const scope = { tablerowloop }
const scope = createScope({ tablerowloop })
ctx.push(scope)
for (let idx = 0; idx < collection.length; idx++, tablerowloop.next()) {