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
- Jun Yang π§ π»
+ Jun Yang π§ π»
chenos π»
Zach Leatherman π
Tim Hardy π»
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 %} ' +
+ '{% block c %}default-c{% 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',