From a69c3ea2a69697ea2ec0b3530ef6e3ecc60519bf Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Tue, 12 May 2026 22:50:51 +0800 Subject: [PATCH] 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 --- src/context/context.spec.ts | 8 ++++++++ src/context/context.ts | 12 ++++++------ src/context/scope.ts | 10 ++++++++++ src/filters/array.ts | 8 ++++---- src/index.ts | 2 +- src/tags/block.ts | 8 ++++---- src/tags/cycle.ts | 4 ++-- src/tags/for.ts | 6 +++--- src/tags/include.ts | 6 +++--- src/tags/layout.ts | 6 +++--- src/tags/tablerow.ts | 4 ++-- 11 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index f122174c9..20f8883e4 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -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() + }) + }) }) diff --git a/src/context/context.ts b/src/context/context.ts index 205a57622..b4f9a9d42 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -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 = 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 (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, diff --git a/src/context/scope.ts b/src/context/scope.ts index 9fcc06dae..7d21a5f2d 100644 --- a/src/context/scope.ts +++ b/src/context/scope.ts @@ -5,3 +5,13 @@ interface ScopeObject extends Record { } 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): ScopeObject { + return props == null + ? Object.create(null) + : Object.assign(Object.create(null), props) +} diff --git a/src/filters/array.ts b/src/filters/array.ts index f22fcce20..640aea7d6 100644 --- a/src/filters/array.ts +++ b/src/filters/array.ts @@ -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 (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 (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 (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]] diff --git a/src/index.ts b/src/index.ts index 721c21b50..7e869552f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/tags/block.ts b/src/tags/block.ts index 947f0b3e5..b6aca1acf 100644 --- a/src/tags/block.ts +++ b/src/tags/block.ts @@ -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)[this.block] = blockRender + ctx.getRegister('blocks', createScope() as Record)[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)[this.block] + const renderChild = ctx.getRegister('blocks', createScope() as Record)[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() diff --git a/src/tags/cycle.ts b/src/tags/cycle.ts index 063c01b01..db81e9b51 100644 --- a/src/tags/cycle.ts +++ b/src/tags/cycle.ts @@ -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 { const group = (yield evalToken(this.group, ctx)) as ValueToken const fingerprint = `cycle:${group}:` + this.candidates.join(',') - const groups = ctx.getRegister('cycle', {} as Record) + const groups = ctx.getRegister('cycle', createScope() as Record) let idx = groups[fingerprint] if (idx === undefined) { diff --git a/src/tags/for.ts b/src/tags/for.ts index 0d29dee78..775229f5b 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -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 diff --git a/src/tags/include.ts b/src/tags/include.ts index 3ad785b90..ffecd93d3 100644 --- a/src/tags/include.ts +++ b/src/tags/include.ts @@ -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) diff --git a/src/tags/layout.ts b/src/tags/layout.ts index cf1e7270f..b73d7637c 100644 --- a/src/tags/layout.ts +++ b/src/tags/layout.ts @@ -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) + const blocks = ctx.getRegister('blocks', createScope() as Record) // 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() } diff --git a/src/tags/tablerow.ts b/src/tags/tablerow.ts index 91f840445..c836b0d32 100644 --- a/src/tags/tablerow.ts +++ b/src/tags/tablerow.ts @@ -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()) {