From f0b907bcf446f62e6dcd88417d05f2aa3071613e Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Tue, 7 Jul 2026 22:31:42 +0800 Subject: [PATCH] feat!: drop TagImplOptions in favor of Tag classes (#839) Remove tag-options-adapter and the registerTag object-literal overload. Custom tags must extend Tag. Co-authored-by: Cursor --- src/liquid.ts | 8 +-- src/template/index.ts | 1 - src/template/tag-options-adapter.ts | 28 ----------- test/e2e/issues.spec.ts | 34 ++++++------- test/integration/liquid/liquid.spec.ts | 7 +-- test/integration/liquid/register-tags.spec.ts | 49 ++++++++++++++----- test/integration/misc/error.spec.ts | 22 +++------ test/integration/tags/for.spec.ts | 5 +- test/stub/tags.ts | 32 ++++++++++++ 9 files changed, 99 insertions(+), 87 deletions(-) delete mode 100644 src/template/tag-options-adapter.ts create mode 100644 test/stub/tags.ts diff --git a/src/liquid.ts b/src/liquid.ts index 57ff27361..6eab36d60 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -1,6 +1,6 @@ import { Context } from './context' -import { toPromise, toValueSync, isFunction, forOwn, isString, strictUniq } from './util' -import { TagClass, createTagClass, TagImplOptions, FilterImplOptions, Template, Value, StaticAnalysisOptions, StaticAnalysis, analyze, analyzeSync, SegmentArray } from './template' +import { toPromise, toValueSync, forOwn, isString, strictUniq } from './util' +import { TagClass, FilterImplOptions, Template, Value, StaticAnalysisOptions, StaticAnalysis, analyze, analyzeSync, SegmentArray } from './template' import { LookupType } from './fs/loader' import { Render } from './render' import { Parser } from './parser' @@ -101,8 +101,8 @@ export class Liquid { public registerFilter (name: string, filter: FilterImplOptions) { this.filters[name] = filter } - public registerTag (name: string, tag: TagClass | TagImplOptions) { - this.tags[name] = isFunction(tag) ? tag : createTagClass(tag) + public registerTag (name: string, tag: TagClass) { + this.tags[name] = tag } public plugin (plugin: (this: Liquid, L: typeof Liquid) => void) { return plugin.call(this, Liquid) diff --git a/src/template/index.ts b/src/template/index.ts index 5f4f62d22..b47e1a237 100644 --- a/src/template/index.ts +++ b/src/template/index.ts @@ -1,7 +1,6 @@ export * from './template' export * from './template-impl' export * from './tag' -export * from './tag-options-adapter' export * from './filter' export * from './filter-impl-options' export * from './hash' diff --git a/src/template/tag-options-adapter.ts b/src/template/tag-options-adapter.ts deleted file mode 100644 index a8a4931ae..000000000 --- a/src/template/tag-options-adapter.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { isFunction } from '../util' -import { Hash } from './hash' -import { Tag, TagClass, TagRenderReturn } from './tag' -import { TagToken, TopLevelToken } from '../tokens' -import { Emitter } from '../emitters' -import { Context } from '../context' -import type { Liquid } from '../liquid' - -export interface TagImplOptions { - [key: string]: any - parse?: (this: Tag & TagImplOptions, token: TagToken, remainingTokens: TopLevelToken[]) => void; - render: (this: Tag & TagImplOptions, ctx: Context, emitter: Emitter, hash: Record) => TagRenderReturn; -} - -export function createTagClass (options: TagImplOptions): TagClass { - return class extends Tag { - constructor (token: TagToken, tokens: TopLevelToken[], liquid: Liquid) { - super(token, tokens, liquid) - if (isFunction(options.parse)) { - options.parse.call(this, token, tokens) - } - } - * render (ctx: Context, emitter: Emitter): TagRenderReturn { - const hash = (yield new Hash(this.token.args, ctx.opts.keyValueSeparator).render(ctx)) as Record - return yield options.render.call(this, ctx, emitter, hash) - } - } -} diff --git a/test/e2e/issues.spec.ts b/test/e2e/issues.spec.ts index c5243fe66..d2eb8b07d 100644 --- a/test/e2e/issues.spec.ts +++ b/test/e2e/issues.spec.ts @@ -1,9 +1,21 @@ -import { TopLevelToken, TagToken, Tokenizer, Context, Liquid, Drop, toValueSync, LiquidError, IfTag } from '../..' +import { TopLevelToken, TagToken, Tokenizer, Context, Liquid, Drop, toValueSync, LiquidError, IfTag, Tag } from '../..' import { spawnSync } from 'child_process' import { resolve as resolvePath } from 'path' const LiquidUMD = require('../../dist/liquid.browser.umd.js').Liquid describe('Issues', function () { + class MetadataFileTag extends Tag { + str: string + constructor (token: TagToken, remainTokens: TopLevelToken[], liquid: Liquid) { + super(token, remainTokens, liquid) + this.str = token.args + } + async render (ctx: Context) { + const content = await Promise.resolve(`{{${this.str}}}`) + return this.liquid.parseAndRender(content.toString(), ctx) + } + } + it('unicode blanks are not properly treated #221', async () => { const engine = new Liquid({ strictVariables: true, strictFilters: true }) const html = engine.parseAndRenderSync('{{huh | truncate: 11}}', { huh: 'fdsafdsafdsafdsaaaaa' }) @@ -364,15 +376,7 @@ describe('Issues', function () { }) it('tag registration compatible to v9 #570', async () => { const liquid = new Liquid() - liquid.registerTag('metadata_file', { - parse (tagToken: TagToken, remainTokens: TopLevelToken[]) { - this.str = tagToken.args - }, - async render (ctx: Context) { - const content = await Promise.resolve(`{{${this.str}}}`) - return this.liquid.parseAndRender(content.toString(), ctx) - } - }) + liquid.registerTag('metadata_file', MetadataFileTag) const tpl = '{% metadata_file foo %}' const ctx = { foo: 'FOO' } const html = await liquid.parseAndRender(tpl, ctx) @@ -380,15 +384,7 @@ describe('Issues', function () { }) it('date filter should return parsed input when no format is provided #573', async () => { const liquid = new Liquid() - liquid.registerTag('metadata_file', { - parse (tagToken: TagToken, remainTokens: TopLevelToken[]) { - this.str = tagToken.args - }, - async render (ctx: Context) { - const content = await Promise.resolve(`{{${this.str}}}`) - return this.liquid.parseAndRender(content.toString(), ctx) - } - }) + liquid.registerTag('metadata_file', MetadataFileTag) const tpl = `{{ 'now' | date }}` const html = await liquid.parseAndRender(tpl) // sample: Thursday, February 2, 2023 at 6:25 pm +0000 diff --git a/test/integration/liquid/liquid.spec.ts b/test/integration/liquid/liquid.spec.ts index e229940a2..af74c5041 100644 --- a/test/integration/liquid/liquid.spec.ts +++ b/test/integration/liquid/liquid.spec.ts @@ -1,6 +1,7 @@ import { Liquid, Context, isFalsy } from '../../../src' import { mock, restore } from '../../stub/mockfs' import { drainStream } from '../../stub/stream' +import { IntendedRenderErrorTag } from '../../stub/tags' import { resolve } from 'path' describe('Liquid', function () { @@ -231,11 +232,7 @@ describe('Liquid', function () { '/root/error.html': 'A{%throwingTag%}B' }) engine = new Liquid({ root: ['/root/'] }) - engine.registerTag('throwingTag', { - render: function () { - throw new Error('intended render error') - } - }) + engine.registerTag('throwingTag', IntendedRenderErrorTag) }) afterEach(restore) it('should render a simple value', async () => { diff --git a/test/integration/liquid/register-tags.spec.ts b/test/integration/liquid/register-tags.spec.ts index 76d6cdf4e..59ddf9520 100644 --- a/test/integration/liquid/register-tags.spec.ts +++ b/test/integration/liquid/register-tags.spec.ts @@ -1,27 +1,53 @@ import { Liquid } from '../../../src/liquid' +import { Tag } from '../../../src/template/tag' +import type { Context } from '../../../src/context' +import type { TagToken, TopLevelToken } from '../../../src/tokens' + +class SimpleStringTag extends Tag { + render () { + return 'B' + } +} + +class AsyncStringTag extends Tag { + async render () { + return 'B' + } +} + +class DynamicStringTag extends Tag { + async render (ctx: Context) { + return ctx.get(['c']) + } +} + +class ArgumentReflectorTag extends Tag { + variable: string + constructor (token: TagToken, remainTokens: TopLevelToken[], liquid: Liquid) { + super(token, remainTokens, liquid) + this.variable = token.args.split('=')[1] + } + async render (ctx: Context) { + return ctx.get([this.variable]) + } +} describe('liquid#registerTag()', function () { it('should support render to simple string', async () => { const liquid = new Liquid() - liquid.registerTag('simple-string', { - render: () => 'B' - }) + liquid.registerTag('simple-string', SimpleStringTag) const html = await liquid.parseAndRender(`A{% simple-string %}C`) return expect(html).toBe('ABC') }) it('should support async tag render', async () => { const liquid = new Liquid() - liquid.registerTag('async-string', { - render: async () => 'B' - }) + liquid.registerTag('async-string', AsyncStringTag) const html = await liquid.parseAndRender(`A{% async-string %}C`) return expect(html).toBe('ABC') }) it('should have access to ctx in render()', async () => { const liquid = new Liquid() - liquid.registerTag('dynamic-string', { - render: async (ctx) => ctx.get(['c']) - }) + liquid.registerTag('dynamic-string', DynamicStringTag) const html = await liquid.parseAndRender(`A{% dynamic-string %}C`, { c: 'B' }) @@ -29,10 +55,7 @@ describe('liquid#registerTag()', function () { }) it('should have access to tag arguments', async () => { const liquid = new Liquid() - liquid.registerTag('argument-reflector', { - parse: function (token) { this.variable = token.args.split('=')[1] }, - render: async function (ctx) { return ctx.get(this.variable) } - }) + liquid.registerTag('argument-reflector', ArgumentReflectorTag) const html = await liquid.parseAndRender(`A{% argument-reflector variable=c %}C`, { c: 'B' }) diff --git a/test/integration/misc/error.spec.ts b/test/integration/misc/error.spec.ts index ce89cf86a..4f17181c8 100644 --- a/test/integration/misc/error.spec.ts +++ b/test/integration/misc/error.spec.ts @@ -2,7 +2,8 @@ import { RenderError } from '../../../src/util/error' import { Liquid } from '../../../src/liquid' import { resolve } from 'path' import { mock, restore } from '../../stub/mockfs' -import { throwIntendedError, rejectIntendedError } from '../../stub/util' +import { throwIntendedError } from '../../stub/util' +import { ThrowingTag, RejectingTag, ThrowsOnParseTag } from '../../stub/tags' const strictEngine = new Liquid({ strictVariables: true, @@ -13,9 +14,9 @@ const strictCatchingEngine = new Liquid({ strictVariables: true, strictFilters: true }) -strictEngine.registerTag('throwingTag', { render: throwIntendedError }) +strictEngine.registerTag('throwingTag', ThrowingTag) strictEngine.registerFilter('throwingFilter', throwIntendedError) -strictCatchingEngine.registerTag('throwingTag', { render: throwIntendedError }) +strictCatchingEngine.registerTag('throwingTag', ThrowingTag) strictCatchingEngine.registerFilter('throwingFilter', throwIntendedError) describe('error', function () { @@ -83,8 +84,8 @@ describe('error', function () { engine = new Liquid({ root: '/' }) - engine.registerTag('throwingTag', { render: throwIntendedError }) - engine.registerTag('rejectingTag', { render: rejectIntendedError }) + engine.registerTag('throwingTag', ThrowingTag) + engine.registerTag('rejectingTag', RejectingTag) engine.registerFilter('throwingFilter', throwIntendedError) }) it('should throw RenderError when tag throws', async function () { @@ -244,10 +245,7 @@ describe('error', function () { let engine: Liquid beforeEach(function () { engine = new Liquid() - engine.registerTag('throwsOnParse', { - parse: throwIntendedError, - render: () => '' - }) + engine.registerTag('throwsOnParse', ThrowsOnParseTag) }) it('should throw ParseError when filter not defined', async function () { await expect(strictEngine.parseAndRender('{{1 | a}}')).rejects.toMatchObject({ @@ -337,11 +335,7 @@ describe('error', function () { engine = new Liquid({ root: '/' }) - engine.registerTag('throwingTag', { - render: function () { - throw new Error('intended error') - } - }) + engine.registerTag('throwingTag', ThrowingTag) }) it('should throw RenderError when tag throws', function () { const src = '{%throwingTag%}' diff --git a/test/integration/tags/for.spec.ts b/test/integration/tags/for.spec.ts index 034800d56..daaf2b0bc 100644 --- a/test/integration/tags/for.spec.ts +++ b/test/integration/tags/for.spec.ts @@ -2,14 +2,13 @@ import { Liquid } from '../../../src/liquid' import { Drop } from '../../../src/drop/drop' import { Scope } from '../../../src/context/scope' import { mock, restore } from '../../stub/mockfs' +import { IntendedRenderErrorTag } from '../../stub/tags' describe('tags/for', function () { let liquid: Liquid, scope: Scope beforeEach(function () { liquid = new Liquid() - liquid.registerTag('throwingTag', { - render: function () { throw new Error('intended render error') } - }) + liquid.registerTag('throwingTag', IntendedRenderErrorTag) scope = { one: 1, // eslint-disable-next-line diff --git a/test/stub/tags.ts b/test/stub/tags.ts new file mode 100644 index 000000000..90995238e --- /dev/null +++ b/test/stub/tags.ts @@ -0,0 +1,32 @@ +import { throwIntendedError, rejectIntendedError } from './util' +import { Tag } from '../../src/template/tag' +import type { TagToken, TopLevelToken } from '../../src/tokens' +import type { Liquid } from '../../src/liquid' + +export class ThrowingTag extends Tag { + render () { + throwIntendedError() + } +} + +export class RejectingTag extends Tag { + async render () { + await rejectIntendedError() + } +} + +export class ThrowsOnParseTag extends Tag { + constructor (token: TagToken, remainTokens: TopLevelToken[], liquid: Liquid) { + super(token, remainTokens, liquid) + throwIntendedError() + } + render () { + return '' + } +} + +export class IntendedRenderErrorTag extends Tag { + render () { + throw new Error('intended render error') + } +}