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 <[email protected]>
This commit is contained in:
Yang Jun
2026-07-07 22:34:14 +08:00
co-authored by Cursor
parent 9aed43ab2b
commit f0b907bcf4
9 changed files with 99 additions and 87 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
import { Context } from './context' import { Context } from './context'
import { toPromise, toValueSync, isFunction, forOwn, isString, strictUniq } from './util' import { toPromise, toValueSync, forOwn, isString, strictUniq } from './util'
import { TagClass, createTagClass, TagImplOptions, FilterImplOptions, Template, Value, StaticAnalysisOptions, StaticAnalysis, analyze, analyzeSync, SegmentArray } from './template' import { TagClass, FilterImplOptions, Template, Value, StaticAnalysisOptions, StaticAnalysis, analyze, analyzeSync, SegmentArray } from './template'
import { LookupType } from './fs/loader' import { LookupType } from './fs/loader'
import { Render } from './render' import { Render } from './render'
import { Parser } from './parser' import { Parser } from './parser'
@@ -101,8 +101,8 @@ export class Liquid {
public registerFilter (name: string, filter: FilterImplOptions) { public registerFilter (name: string, filter: FilterImplOptions) {
this.filters[name] = filter this.filters[name] = filter
} }
public registerTag (name: string, tag: TagClass | TagImplOptions) { public registerTag (name: string, tag: TagClass) {
this.tags[name] = isFunction(tag) ? tag : createTagClass(tag) this.tags[name] = tag
} }
public plugin (plugin: (this: Liquid, L: typeof Liquid) => void) { public plugin (plugin: (this: Liquid, L: typeof Liquid) => void) {
return plugin.call(this, Liquid) return plugin.call(this, Liquid)
-1
View File
@@ -1,7 +1,6 @@
export * from './template' export * from './template'
export * from './template-impl' export * from './template-impl'
export * from './tag' export * from './tag'
export * from './tag-options-adapter'
export * from './filter' export * from './filter'
export * from './filter-impl-options' export * from './filter-impl-options'
export * from './hash' export * from './hash'
-28
View File
@@ -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<string, any>) => 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<string, any>
return yield options.render.call(this, ctx, emitter, hash)
}
}
}
+15 -19
View File
@@ -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 { spawnSync } from 'child_process'
import { resolve as resolvePath } from 'path' import { resolve as resolvePath } from 'path'
const LiquidUMD = require('../../dist/liquid.browser.umd.js').Liquid const LiquidUMD = require('../../dist/liquid.browser.umd.js').Liquid
describe('Issues', function () { 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 () => { it('unicode blanks are not properly treated #221', async () => {
const engine = new Liquid({ strictVariables: true, strictFilters: true }) const engine = new Liquid({ strictVariables: true, strictFilters: true })
const html = engine.parseAndRenderSync('{{huh | truncate: 11}}', { huh: 'fdsafdsafdsafdsaaaaa' }) const html = engine.parseAndRenderSync('{{huh | truncate: 11}}', { huh: 'fdsafdsafdsafdsaaaaa' })
@@ -364,15 +376,7 @@ describe('Issues', function () {
}) })
it('tag registration compatible to v9 #570', async () => { it('tag registration compatible to v9 #570', async () => {
const liquid = new Liquid() const liquid = new Liquid()
liquid.registerTag('metadata_file', { liquid.registerTag('metadata_file', MetadataFileTag)
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)
}
})
const tpl = '{% metadata_file foo %}' const tpl = '{% metadata_file foo %}'
const ctx = { foo: 'FOO' } const ctx = { foo: 'FOO' }
const html = await liquid.parseAndRender(tpl, ctx) 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 () => { it('date filter should return parsed input when no format is provided #573', async () => {
const liquid = new Liquid() const liquid = new Liquid()
liquid.registerTag('metadata_file', { liquid.registerTag('metadata_file', MetadataFileTag)
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)
}
})
const tpl = `{{ 'now' | date }}` const tpl = `{{ 'now' | date }}`
const html = await liquid.parseAndRender(tpl) const html = await liquid.parseAndRender(tpl)
// sample: Thursday, February 2, 2023 at 6:25 pm +0000 // sample: Thursday, February 2, 2023 at 6:25 pm +0000
+2 -5
View File
@@ -1,6 +1,7 @@
import { Liquid, Context, isFalsy } from '../../../src' import { Liquid, Context, isFalsy } from '../../../src'
import { mock, restore } from '../../stub/mockfs' import { mock, restore } from '../../stub/mockfs'
import { drainStream } from '../../stub/stream' import { drainStream } from '../../stub/stream'
import { IntendedRenderErrorTag } from '../../stub/tags'
import { resolve } from 'path' import { resolve } from 'path'
describe('Liquid', function () { describe('Liquid', function () {
@@ -231,11 +232,7 @@ describe('Liquid', function () {
'/root/error.html': 'A{%throwingTag%}B' '/root/error.html': 'A{%throwingTag%}B'
}) })
engine = new Liquid({ root: ['/root/'] }) engine = new Liquid({ root: ['/root/'] })
engine.registerTag('throwingTag', { engine.registerTag('throwingTag', IntendedRenderErrorTag)
render: function () {
throw new Error('intended render error')
}
})
}) })
afterEach(restore) afterEach(restore)
it('should render a simple value', async () => { it('should render a simple value', async () => {
+36 -13
View File
@@ -1,27 +1,53 @@
import { Liquid } from '../../../src/liquid' 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 () { describe('liquid#registerTag()', function () {
it('should support render to simple string', async () => { it('should support render to simple string', async () => {
const liquid = new Liquid() const liquid = new Liquid()
liquid.registerTag('simple-string', { liquid.registerTag('simple-string', SimpleStringTag)
render: () => 'B'
})
const html = await liquid.parseAndRender(`A{% simple-string %}C`) const html = await liquid.parseAndRender(`A{% simple-string %}C`)
return expect(html).toBe('ABC') return expect(html).toBe('ABC')
}) })
it('should support async tag render', async () => { it('should support async tag render', async () => {
const liquid = new Liquid() const liquid = new Liquid()
liquid.registerTag('async-string', { liquid.registerTag('async-string', AsyncStringTag)
render: async () => 'B'
})
const html = await liquid.parseAndRender(`A{% async-string %}C`) const html = await liquid.parseAndRender(`A{% async-string %}C`)
return expect(html).toBe('ABC') return expect(html).toBe('ABC')
}) })
it('should have access to ctx in render()', async () => { it('should have access to ctx in render()', async () => {
const liquid = new Liquid() const liquid = new Liquid()
liquid.registerTag('dynamic-string', { liquid.registerTag('dynamic-string', DynamicStringTag)
render: async (ctx) => ctx.get(['c'])
})
const html = await liquid.parseAndRender(`A{% dynamic-string %}C`, { const html = await liquid.parseAndRender(`A{% dynamic-string %}C`, {
c: 'B' c: 'B'
}) })
@@ -29,10 +55,7 @@ describe('liquid#registerTag()', function () {
}) })
it('should have access to tag arguments', async () => { it('should have access to tag arguments', async () => {
const liquid = new Liquid() const liquid = new Liquid()
liquid.registerTag('argument-reflector', { liquid.registerTag('argument-reflector', ArgumentReflectorTag)
parse: function (token) { this.variable = token.args.split('=')[1] },
render: async function (ctx) { return ctx.get(this.variable) }
})
const html = await liquid.parseAndRender(`A{% argument-reflector variable=c %}C`, { const html = await liquid.parseAndRender(`A{% argument-reflector variable=c %}C`, {
c: 'B' c: 'B'
}) })
+8 -14
View File
@@ -2,7 +2,8 @@ import { RenderError } from '../../../src/util/error'
import { Liquid } from '../../../src/liquid' import { Liquid } from '../../../src/liquid'
import { resolve } from 'path' import { resolve } from 'path'
import { mock, restore } from '../../stub/mockfs' 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({ const strictEngine = new Liquid({
strictVariables: true, strictVariables: true,
@@ -13,9 +14,9 @@ const strictCatchingEngine = new Liquid({
strictVariables: true, strictVariables: true,
strictFilters: true strictFilters: true
}) })
strictEngine.registerTag('throwingTag', { render: throwIntendedError }) strictEngine.registerTag('throwingTag', ThrowingTag)
strictEngine.registerFilter('throwingFilter', throwIntendedError) strictEngine.registerFilter('throwingFilter', throwIntendedError)
strictCatchingEngine.registerTag('throwingTag', { render: throwIntendedError }) strictCatchingEngine.registerTag('throwingTag', ThrowingTag)
strictCatchingEngine.registerFilter('throwingFilter', throwIntendedError) strictCatchingEngine.registerFilter('throwingFilter', throwIntendedError)
describe('error', function () { describe('error', function () {
@@ -83,8 +84,8 @@ describe('error', function () {
engine = new Liquid({ engine = new Liquid({
root: '/' root: '/'
}) })
engine.registerTag('throwingTag', { render: throwIntendedError }) engine.registerTag('throwingTag', ThrowingTag)
engine.registerTag('rejectingTag', { render: rejectIntendedError }) engine.registerTag('rejectingTag', RejectingTag)
engine.registerFilter('throwingFilter', throwIntendedError) engine.registerFilter('throwingFilter', throwIntendedError)
}) })
it('should throw RenderError when tag throws', async function () { it('should throw RenderError when tag throws', async function () {
@@ -244,10 +245,7 @@ describe('error', function () {
let engine: Liquid let engine: Liquid
beforeEach(function () { beforeEach(function () {
engine = new Liquid() engine = new Liquid()
engine.registerTag('throwsOnParse', { engine.registerTag('throwsOnParse', ThrowsOnParseTag)
parse: throwIntendedError,
render: () => ''
})
}) })
it('should throw ParseError when filter not defined', async function () { it('should throw ParseError when filter not defined', async function () {
await expect(strictEngine.parseAndRender('{{1 | a}}')).rejects.toMatchObject({ await expect(strictEngine.parseAndRender('{{1 | a}}')).rejects.toMatchObject({
@@ -337,11 +335,7 @@ describe('error', function () {
engine = new Liquid({ engine = new Liquid({
root: '/' root: '/'
}) })
engine.registerTag('throwingTag', { engine.registerTag('throwingTag', ThrowingTag)
render: function () {
throw new Error('intended error')
}
})
}) })
it('should throw RenderError when tag throws', function () { it('should throw RenderError when tag throws', function () {
const src = '{%throwingTag%}' const src = '{%throwingTag%}'
+2 -3
View File
@@ -2,14 +2,13 @@ import { Liquid } from '../../../src/liquid'
import { Drop } from '../../../src/drop/drop' import { Drop } from '../../../src/drop/drop'
import { Scope } from '../../../src/context/scope' import { Scope } from '../../../src/context/scope'
import { mock, restore } from '../../stub/mockfs' import { mock, restore } from '../../stub/mockfs'
import { IntendedRenderErrorTag } from '../../stub/tags'
describe('tags/for', function () { describe('tags/for', function () {
let liquid: Liquid, scope: Scope let liquid: Liquid, scope: Scope
beforeEach(function () { beforeEach(function () {
liquid = new Liquid() liquid = new Liquid()
liquid.registerTag('throwingTag', { liquid.registerTag('throwingTag', IntendedRenderErrorTag)
render: function () { throw new Error('intended render error') }
})
scope = { scope = {
one: 1, one: 1,
// eslint-disable-next-line // eslint-disable-next-line
+32
View File
@@ -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')
}
}