diff --git a/.all-contributorsrc b/.all-contributorsrc index ad5c1bf1b..b29f4c2bd 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -14,7 +14,7 @@ "login": "harttle", "name": "Jun Yang", "avatar_url": "https://avatars3.githubusercontent.com/u/4427974?v=4", - "profile": "https://harttle.land", + "profile": "https://github.com/harttle", "contributions": [ "maintenance", "code" diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc new file mode 100644 index 000000000..6c27faae0 --- /dev/null +++ b/.cursor/rules/testing.mdc @@ -0,0 +1,17 @@ +--- +description: Testing conventions β€” e2e uses built dist, integration uses src +globs: test/**/*.ts +alwaysApply: false +--- + +# Testing + +## End-to-end tests (`test/e2e`) + +- **Use the built package**, not TypeScript sources under `src/`. +- Import the public API from the package root (for example `import { Liquid } from '../..'`), which resolves through `package.json` to **`dist/`** (`main`, `module`, etc.). +- **Avoid** `import … from '../../src/liquid'` (or other `src/` paths) in `test/e2e/**` so e2e matches what consumers get from npm and you do not depend on an unbuilt tree. + +## Integration and unit tests + +- Tests under `test/integration/`, `src/**/*.spec.ts`, and similar may import from **`src/`** when the suite is meant to run against the current TypeScript sources (typical for this repo’s Jest setup). diff --git a/README.md b/README.md index 31f473426..f8e7fb129 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to - + diff --git a/src/context/context.ts b/src/context/context.ts index ce396fe36..5a0fe576a 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -48,8 +48,8 @@ export class Context { this.memoryLimit = memoryLimit ?? new Limiter('memory alloc', renderOptions.memoryLimit ?? opts.memoryLimit) this.renderLimit = renderLimit ?? new Limiter('template render', getPerformance().now() + (renderOptions.renderLimit ?? opts.renderLimit)) } - public getRegister (key: string) { - return (this.registers[key] = this.registers[key] || {}) + public getRegister (key: string, defaultValue: T = undefined as T): T { + return (this.registers[key] = this.registers[key] || defaultValue) } public setRegister (key: string, value: any) { return (this.registers[key] = value) diff --git a/src/tags/block.ts b/src/tags/block.ts index 6ae147ac0..947f0b3e5 100644 --- a/src/tags/block.ts +++ b/src/tags/block.ts @@ -23,20 +23,25 @@ export default class extends Tag { * render (ctx: Context, emitter: Emitter) { const blockRender = this.getBlockRender(ctx) if (ctx.getRegister('blockMode') === BlockMode.STORE) { - ctx.getRegister('blocks')[this.block] = blockRender + ctx.getRegister('blocks', {} as Record)[this.block] = blockRender } else { yield blockRender(new BlockDrop(), emitter) } } private getBlockRender (ctx: Context) { + const self = this as Tag const { liquid, templates } = this - const renderChild = ctx.getRegister('blocks')[this.block] + const renderChild = ctx.getRegister('blocks', {} as Record)[this.block] const renderCurrent = function * (superBlock: BlockDrop, emitter: Emitter) { - // add {{ block.super }} support when rendering + 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 }) yield liquid.renderer.renderTemplates(templates, ctx, emitter) ctx.pop() + stack.pop() } return renderChild ? (superBlock: BlockDrop, emitter: Emitter) => renderChild( diff --git a/src/tags/cycle.ts b/src/tags/cycle.ts index 3f5ce9674..063c01b01 100644 --- a/src/tags/cycle.ts +++ b/src/tags/cycle.ts @@ -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') + const groups = ctx.getRegister('cycle', {} as Record) let idx = groups[fingerprint] if (idx === undefined) { diff --git a/src/tags/for.ts b/src/tags/for.ts index 779eee207..0d29dee78 100644 --- a/src/tags/for.ts +++ b/src/tags/for.ts @@ -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({ continue: ctx.getRegister(continueKey, {}) }) const hash = yield this.hash.render(ctx) ctx.pop() diff --git a/src/tags/layout.ts b/src/tags/layout.ts index f1da55f78..cf1e7270f 100644 --- a/src/tags/layout.ts +++ b/src/tags/layout.ts @@ -32,7 +32,7 @@ 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') + const blocks = ctx.getRegister('blocks', {} as Record) // set whole content to anonymous block if anonymous doesn't specified if (blocks[''] === undefined) blocks[''] = (parent: BlankDrop, emitter: Emitter) => emitter.write(html) diff --git a/test/e2e/parse-and-render.spec.ts b/test/e2e/parse-and-render.spec.ts index cfca9c2b2..81803d15c 100644 --- a/test/e2e/parse-and-render.spec.ts +++ b/test/e2e/parse-and-render.spec.ts @@ -82,4 +82,39 @@ describe('.parseAndRender()', function () { expect(() => e.parseAndRenderSync('{% render "link" %}')).toThrow(/ENOENT|Failed to lookup/) }) }) + describe('layout: nested {% block %} regression', function () { + let root: string + beforeEach(function () { + root = mkdtempSync(join(tmpdir(), 'liquid-e2e-layout-nested-')) + }) + afterEach(function () { + rmSync(root, { recursive: true, force: true }) + }) + it('should reject same-name {% block %} nested in child template (no hang / OOM)', async function () { + writeFileSync( + join(root, 'layout.html'), + '
{% block a %}default-a{% endblock %}
' + + '
{% block b %}default-b{% endblock %}
' + + '
{% block c %}default-c{% endblock %}
' + ) + writeFileSync( + join(root, 'template.html'), + '{% layout "layout" %}' + + '{% block a %}outer-a {% block a %}inner-a{% endblock %}{% endblock %}' + + '{% block b %}content-b{% endblock %}' + + '{% block c %}content-c{% endblock %}' + ) + const liquid = new Liquid({ root, extname: '.html' }) + await expect(liquid.renderFile('template')).rejects.toThrow(/block tag cannot be nested/) + }) + it('should reject nested anonymous {% block %} in child template (no hang / OOM)', async function () { + writeFileSync(join(root, 'parent.html'), 'X{%block%}{%endblock%}Y') + writeFileSync( + join(root, 'template.html'), + '{% layout "parent" %}{%block%}A{%block%}B{%endblock%}{%endblock%}' + ) + const liquid = new Liquid({ root, extname: '.html' }) + await expect(liquid.renderFile('template')).rejects.toThrow(/block tag cannot be nested/) + }) + }) }) diff --git a/test/integration/tags/include.spec.ts b/test/integration/tags/include.spec.ts index d72bab861..9698cab5e 100644 --- a/test/integration/tags/include.spec.ts +++ b/test/integration/tags/include.spec.ts @@ -50,7 +50,7 @@ describe('tags/include', function () { }) return liquid.renderFile('/parent.html').catch(function (e) { expect(e.name).toBe('TokenizationError') - expect(e.message).toMatch('illegal file path, file:/parent.html, line:1, col:11') + expect(e.message).toMatch(/illegal file path, file:.*parent.html, line:1, col:11/) }) }) diff --git a/test/integration/tags/layout.spec.ts b/test/integration/tags/layout.spec.ts index 5f0e4beb8..b7a5d3ce8 100644 --- a/test/integration/tags/layout.spec.ts +++ b/test/integration/tags/layout.spec.ts @@ -166,6 +166,27 @@ describe('tags/layout', function () { const html = await liquid.renderFile('/main.html') return expect(html).toBe('XAY') }) + it('should reject nested {% block %} with the same name (no OOM / hang)', function () { + mock({ + '/layout.html': + '
{% block a %}default-a{% endblock %}
' + + '
{% block b %}default-b{% endblock %}
' + + '', + '/template.html': + '{% layout "layout" %}' + + '{% block a %}outer-a {% block a %}inner-a{% endblock %}{% endblock %}' + + '{% block b %}content-b{% endblock %}' + + '{% block c %}content-c{% endblock %}' + }) + return expect(liquid.renderFile('/template.html')).rejects.toThrow(/block tag cannot be nested/) + }) + it('should reject nested anonymous {% block %} (no OOM / hang)', function () { + mock({ + '/parent.html': 'X{%block%}{%endblock%}Y' + }) + const src = '{% layout "parent.html" %}{%block%}A{%block%}B{%endblock%}{%endblock%}' + return expect(liquid.parseAndRender(src)).rejects.toThrow(/block tag cannot be nested/) + }) it('should not bleed scope into `include` layout', async function () { mock({ '/parent.html': 'X{%block a%}{%endblock%}Y{%block b%}{%endblock%}Z',
Jun Yang
Jun Yang

🚧 πŸ’»
Jun Yang
Jun Yang

🚧 πŸ’»
chenos
chenos

πŸ’»
Zach Leatherman
Zach Leatherman

πŸ›
Tim Hardy
Tim Hardy

πŸ’»