-

{{__('playground.title')}}

+
+
+

{{__('playground.title')}}

+

{{__('playground.lead')}}

+
+

+
-
-

Template

-
{{ raw('partial/demo.liquid') }}
-
-
-

Context

-
{{ raw('partial/demo.json') }}
-
-
-

Output

-
{{__('playground.loading')}}
+
+
+
+ +

Template

+
+
+
{{ raw('partial/demo.liquid') }}
+
+
+
+
+ +

Context

+
+
+
{{ raw('partial/demo.json') }}
+
+
+
+
+ +

Output

+
+
+
+
{{__('playground.loading')}}
+
+
+
-

diff --git a/docs/themes/navy/source/css/_partial/index.styl b/docs/themes/navy/source/css/_partial/index.styl index 082d7a927..9a1ab78e3 100644 --- a/docs/themes/navy/source/css/_partial/index.styl +++ b/docs/themes/navy/source/css/_partial/index.styl @@ -126,7 +126,7 @@ background: var(--color-link-hover) color: #fff -#sponsors-wrap, #contributors-wrap +#used-by-wrap, #sponsors-wrap, #contributors-wrap background: var(--color-navy-lighter) border-top: 1px solid #161d24 border-bottom: 1px solid #161d24 @@ -181,7 +181,7 @@ #contributors-wrap border: none - overflow: hidden; + overflow: hidden .contributors tr diff --git a/docs/themes/navy/source/css/_partial/playground.styl b/docs/themes/navy/source/css/_partial/playground.styl index 953dd1e88..11d9ca349 100644 --- a/docs/themes/navy/source/css/_partial/playground.styl +++ b/docs/themes/navy/source/css/_partial/playground.styl @@ -1,107 +1,312 @@ #playground + --playground-gap: 12px + --playground-radius: 10px + --playground-inset: 16px background: var(--color-content-bg) overflow: hidden box-shadow: var(--panel-shadow) .wrapper - margin-bottom: 40px + margin-bottom: 32px + @media mq-mobile + margin-bottom: 20px + + .playground-hero + display: flex + flex-wrap: wrap + align-items: flex-end + justify-content: space-between + gap: 16px 24px + padding-top: 32px + padding-bottom: 20px + @media mq-mobile + padding-top: 16px + padding-bottom: 12px + gap: 10px + align-items: flex-start + + .playground-hero-text + flex: 1 1 280px + min-width: 0 h1 - font-size: 36px - font-weight: 300 - margin-top: 40px - margin-bottom: 24px - color: var(--color-default) - - h2 - font-size: 0.8125rem + font-size: 28px font-weight: 600 - text-transform: uppercase - letter-spacing: 0.04em - color: var(--color-gray) + letter-spacing: -0.02em + margin: 0 0 8px + color: var(--color-default) + @media mq-mobile + font-size: 22px + margin-bottom: 4px + + .playground-lead margin: 0 + font-size: 15px + line-height: 1.5 + color: var(--color-gray) + @media mq-mobile + font-size: 14px + line-height: 1.45 + + .playground-version + flex: 0 0 auto + margin: 0 + font-size: 12px + line-height: 1.4 + font-family: font-mono + padding: 6px 12px + border-radius: 999px + background: var(--playground-surface) + border: 1px solid var(--color-border) + color: var(--color-gray) + a + color: var(--color-default) + text-decoration: none + font-weight: 500 + &:hover + color: var(--color-link) + text-decoration: none #editors - display: grid - overflow: hidden margin-bottom: 0 - height: 75vh - min-height: 480px + + .playground-workspace + display: grid + gap: var(--playground-gap) grid-template-columns: 1fr 1fr grid-template-rows: 3fr 2fr - grid-gap: 16px align-items: stretch + @media mq-normal + overflow: hidden + height: 75vh + max-height: unquote('calc(100vh - 200px)') + min-height: 520px @media mq-mobile - height: auto - min-height: 0 grid-template-columns: 1fr grid-template-rows: auto - grid-gap: 20px + gap: 12px .area-tpl grid-row: 1 grid-column: 1 min-height: 0 + --pane-dot: var(--color-link) .area-data grid-row: 2 grid-column: 1 min-height: 0 + --pane-dot: var(--highlight-orange) .area-output grid-column: 2 grid-row: 1 / -1 min-height: 0 + min-width: 0 + --pane-dot: var(--highlight-green) @media mq-mobile grid-row: auto grid-column: 1 - .editor-wrapper + .playground-pane display: flex - gap: 8px flex-direction: column min-height: 0 overflow: hidden - .editor - flex: 1 1 auto - min-height: 0 - position: relative - code-block-chrome() - overflow: hidden - @media mq-mobile - min-height: 240px - .ace_editor - font-family: font-mono - font-size: 14px - line-height: 1.5 - border-radius: 6px - .ace_scrollbar - z-index: 2 + background: var(--playground-pane-head) + border: 1px solid var(--code-border) + border-radius: var(--playground-radius) + box-shadow: var(--code-shadow) - .version - font-size: 0.8125rem - line-height: 1.5 + .pane-head + display: flex + align-items: center + gap: 10px + flex-shrink: 0 + height: 36px + padding: 0 var(--playground-inset) + border-bottom: 1px solid var(--code-border) + @media mq-mobile + height: 32px + padding: 0 10px + h2 + font-size: 13px + font-weight: 600 + letter-spacing: 0.01em + text-transform: none + color: var(--color-default) + margin: 0 + @media mq-mobile + font-size: 12px + + .pane-indicator + width: 8px + height: 8px + border-radius: 50% + flex-shrink: 0 + background: unquote('color-mix(in srgb, var(--pane-dot) 38%, var(--color-border))') + transition: background 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease + + &[data-state="active"] + background: var(--pane-dot) + animation: playground-dot-typing 0.85s ease-in-out infinite + + &[data-state="pending"] + background: var(--highlight-yellow) + + &[data-state="ok"] + background: var(--highlight-green) + animation: playground-dot-ok 0.45s ease-out + + &[data-state="error"] + background: var(--highlight-red) + animation: playground-dot-error 0.35s ease-out + + .area-output .pane-indicator + &[data-state="pending"] + animation: playground-dot-pending 0.55s ease-in-out infinite + + @keyframes playground-dot-typing + 0%, 100% + transform: scale(1) + box-shadow: 0 0 0 0 unquote('color-mix(in srgb, var(--pane-dot) 0%, transparent)') + 50% + transform: scale(1.2) + box-shadow: 0 0 0 4px unquote('color-mix(in srgb, var(--pane-dot) 28%, transparent)') + + @keyframes playground-dot-pending + 0%, 100% + transform: scale(1) + opacity: 0.75 + 50% + transform: scale(1.12) + opacity: 1 + + @keyframes playground-dot-ok + 0% + transform: scale(0.85) + box-shadow: 0 0 0 0 unquote('color-mix(in srgb, var(--highlight-green) 50%, transparent)') + 70% + transform: scale(1.15) + box-shadow: 0 0 0 5px unquote('color-mix(in srgb, var(--highlight-green) 0%, transparent)') + 100% + transform: scale(1) + box-shadow: none + + @keyframes playground-dot-error + 0%, 100% + transform: translateX(0) + 20% + transform: translateX(-2px) + 40% + transform: translateX(2px) + 60% + transform: translateX(-1px) + 80% + transform: translateX(1px) + + .pane-body + flex: 1 1 auto + min-height: 0 + min-width: 0 + display: flex + flex-direction: column + overflow: hidden + background: var(--highlight-background) + + .area-tpl .pane-body, + .area-data .pane-body + padding: var(--playground-inset) + box-sizing: border-box + @media mq-mobile + padding: 12px + + .area-tpl .ace_gutter, + .area-data .ace_gutter + display: none + width: 0 + min-width: 0 + + .area-tpl .ace_editor, + .area-data .ace_editor, + .area-tpl .ace_scroller, + .area-data .ace_scroller, + .area-tpl .ace_content, + .area-data .ace_content, + .area-tpl .ace_text-layer, + .area-data .ace_text-layer + background: transparent + + .editor + flex: 1 1 auto + min-height: 0 + position: relative + overflow: hidden + @media mq-mobile + min-height: 180px + + .output-preview + flex: 1 1 auto + min-height: 0 + min-width: 0 + width: 100% + overflow: auto + @media mq-mobile + min-height: 120px + pre.highlight + margin: 0 + min-height: 100% + width: 100% + box-sizing: border-box + padding: var(--playground-inset) + border: none + box-shadow: none + border-radius: 0 + background: transparent + color: var(--highlight-foreground) + overflow-x: hidden + overflow-y: auto + white-space: pre-wrap + overflow-wrap: break-word + @media mq-mobile + padding: 12px + code + display: block + width: 100% + box-sizing: border-box + font-family: font-mono + font-size: 14px + line-height: 1.55 + color: var(--highlight-foreground) + background: transparent + padding: 0 + white-space: inherit + overflow-wrap: inherit + @media mq-mobile + font-size: 13px + + .ace_editor font-family: font-mono - color: var(--color-gray) - margin-top: 20px - margin-bottom: 32px - a - color: inherit - text-decoration: none - &:hover - color: var(--color-link) - text-decoration: underline + font-size: 14px + line-height: 1.55 + border-radius: 0 + @media mq-mobile + font-size: 13px + .ace_scrollbar + z-index: 2 .hide display: none .loader - width: 48px - height: 48px - margin: 150px auto 200px - border: 3px solid var(--color-border) + width: 40px + height: 40px + margin: 120px auto 160px + border: 2px solid var(--color-border) border-top-color: var(--color-link) border-radius: 50% - animation: spin 0.8s infinite linear + animation: playground-spin 0.7s infinite linear + @media mq-mobile + margin: 60px auto 80px - @keyframes spin + @keyframes playground-spin 100% transform: rotate(360deg) diff --git a/docs/themes/navy/source/css/_variables.styl b/docs/themes/navy/source/css/_variables.styl index cac6b94fe..27363cc6b 100644 --- a/docs/themes/navy/source/css/_variables.styl +++ b/docs/themes/navy/source/css/_variables.styl @@ -37,6 +37,8 @@ vendor-prefixes = webkit moz ms official --highlight-aqua: #0550ae --highlight-blue: #0550ae --highlight-purple: #8250df + --playground-surface: #f3f4f6 + --playground-pane-head: #fff } @media (prefers-color-scheme: dark) { @@ -75,6 +77,8 @@ vendor-prefixes = webkit moz ms official --highlight-aqua: #79c0ff --highlight-blue: #79c0ff --highlight-purple: #d2a8ff + --playground-surface: hsl(218, 26%, 10%) + --playground-pane-head: hsl(218, 22%, 16%) } } diff --git a/docs/themes/navy/source/js/main.js b/docs/themes/navy/source/js/main.js index b113fb27b..1d4e5eb4c 100644 --- a/docs/themes/navy/source/js/main.js +++ b/docs/themes/navy/source/js/main.js @@ -38,8 +38,8 @@ (function() { // playground - /* global liquidjs, ace */ - if (!location.pathname.match(/playground.html$/)) return; + /* global liquidjs, ace, Prism */ + if (!/\/playground(?:\.html)?$/.test(location.pathname)) return; updateVersion(liquidjs.version); const engine = new liquidjs.Liquid({ memoryLimit: 1e5, @@ -48,14 +48,19 @@ const colorScheme = window.matchMedia('(prefers-color-scheme: dark)'); const editor = createEditor('editorEl', 'liquid'); const dataEditor = createEditor('dataEl', 'json'); - const preview = createEditor('previewEl', 'html'); - preview.setReadOnly(true); - preview.renderer.setShowGutter(false); - preview.renderer.setPadding(16); + const previewCode = document.getElementById('previewCode'); + const indicatorTpl = document.querySelector('.area-tpl .pane-indicator'); + const indicatorData = document.querySelector('.area-data .pane-indicator'); + const indicatorOutput = document.querySelector('.area-output .pane-indicator'); - const editors = [editor, dataEditor, preview]; + const editors = [editor, dataEditor]; + let previewValue = ''; + let hadPreview = false; + let renderTimer = null; + const RENDER_DELAY = 180; colorScheme.addEventListener('change', function() { editors.forEach(applyEditorTheme); + if (previewValue) setPreview(previewValue); }); const init = parseArgs(location.hash.slice(1)); @@ -63,9 +68,11 @@ editor.setValue(init.tpl, 1); dataEditor.setValue(init.data, 1); } - editor.on('change', update); - dataEditor.on('change', update); - update(); + editor.on('change', onTemplateChange); + dataEditor.on('change', onContextChange); + editor.on('focus', function () { setIndicator(indicatorTpl, 'active'); }); + dataEditor.on('focus', function () { setIndicator(indicatorData, 'active'); }); + scheduleUpdate(); ready(); function ready() { @@ -87,6 +94,8 @@ function applyEditorTheme(editor) { editor.setTheme(getEditorTheme()); + editor.renderer.setPadding(0); + editor.container.style.background = 'transparent'; } function createEditor(id, lang) { @@ -96,15 +105,61 @@ fontFamily: '"Source Code Pro", ui-monospace, Monaco, Menlo, Consolas, monospace', fontSize: '14px', showPrintMargin: false, + showGutter: false, + highlightActiveLine: false, tabSize: 2, useSoftTabs: true, - scrollPastEnd: 0.25 + scrollPastEnd: 0 }); editor.getSession().setMode('ace/mode/' + lang); - editor.renderer.setScrollMargin(8, 8, 0, 0); + editor.renderer.setShowGutter(false); + if (editor.renderer.$gutter) { + editor.renderer.$gutter.style.display = 'none'; + } + editor.renderer.setScrollMargin(0, 0, 0, 0); + bindClipboard(editor); return editor; } + function bindClipboard(editor) { + editor.commands.addCommand({ + name: 'copy', + bindKey: {win: 'Ctrl-C', mac: 'Command-C'}, + exec: function (ed) { + const text = ed.getCopyText(); + if (!text) return; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text); + } + }, + readOnly: true + }); + editor.commands.addCommand({ + name: 'cut', + bindKey: {win: 'Ctrl-X', mac: 'Command-X'}, + exec: function (ed) { + const text = ed.getCopyText(); + if (!text) return; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(function () { + ed.insert(''); + }); + } + } + }); + editor.commands.addCommand({ + name: 'paste', + bindKey: {win: 'Ctrl-V', mac: 'Command-V'}, + exec: function (ed) { + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.readText().then(function (text) { + ed.insert(text); + }); + } + } + }); + } + function parseArgs(hash) { if (!hash) return; try { @@ -118,16 +173,64 @@ return utoa(obj.tpl) + ',' + utoa(obj.data); } + function setPreview(value) { + previewValue = value; + previewCode.textContent = value; + if (window.Prism) { + delete previewCode.dataset.highlighted; + window.Prism.highlightElement(previewCode); + } + } + + function setIndicator(indicator, state) { + if (indicator) indicator.dataset.state = state; + } + + function onTemplateChange() { + setIndicator(indicatorTpl, 'active'); + if (indicatorData.dataset.state !== 'error') setIndicator(indicatorData, 'idle'); + setIndicator(indicatorOutput, 'pending'); + scheduleUpdate(); + } + + function onContextChange() { + setIndicator(indicatorData, 'active'); + if (indicatorTpl.dataset.state !== 'error') setIndicator(indicatorTpl, 'idle'); + setIndicator(indicatorOutput, 'pending'); + scheduleUpdate(); + } + + function scheduleUpdate() { + clearTimeout(renderTimer); + renderTimer = setTimeout(update, RENDER_DELAY); + } + async function update() { const tpl = editor.getValue(); const data = dataEditor.getValue(); history.replaceState({}, '', '#' + serializeArgs({tpl, data})); + let parsed; try { - const html = await engine.parseAndRender(tpl, JSON.parse(data)); - preview.setValue(html, 1); + parsed = JSON.parse(data); } catch (err) { - preview.setValue(err.stack, 1); - throw err; + setIndicator(indicatorData, 'error'); + setIndicator(indicatorTpl, 'idle'); + setIndicator(indicatorOutput, 'error'); + return; + } + try { + const html = await engine.parseAndRender(tpl, parsed); + if (html !== '' || !hadPreview) { + setPreview(html); + if (html !== '') hadPreview = true; + } + setIndicator(indicatorTpl, 'idle'); + setIndicator(indicatorData, 'idle'); + setIndicator(indicatorOutput, 'ok'); + } catch (err) { + setIndicator(indicatorTpl, 'error'); + setIndicator(indicatorData, 'idle'); + setIndicator(indicatorOutput, 'error'); } } diff --git a/package.json b/package.json index a99acadc7..97220efd4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "liquidjs", "version": "10.27.0", "sideEffects": false, - "description": "A simple, expressive and safe Shopify / Github Pages compatible template engine in pure JavaScript.", + "description": "A simple, expressive, extensible Liquid template engine for JavaScript — Shopify, Jekyll and GitHub Pages compatible, for Node.js, browsers, and the CLI, with TypeScript support.", "main": "dist/liquid.node.js", "module": "dist/liquid.node.mjs", "es2015": "dist/liquid.browser.mjs", @@ -29,12 +29,15 @@ "build:min": "BUNDLES=min rollup -c rollup.config.mjs", "build:umd": "BUNDLES=umd rollup -c rollup.config.mjs", "build:charmap": "./bin/character-gen.js > src/util/character.ts", - "build:docs": "run-s build:docs-liquid build:contributors build:apidoc build:changelog build:docs-hexo", + "prepare:docs": "run-s build:docs-liquid build:contributors build:apidoc build:changelog", + "build:docs": "run-s prepare:docs build:docs-hexo", "build:docs-liquid": "cross-env BUNDLES=min rollup -c rollup.config.mjs && shx cp dist/liquid.browser.min.js docs/themes/navy/source/js/", "build:contributors": "node bin/build-contributors.js", "build:apidoc": "shx rm -rf docs/source/api && typedoc --plugin typedoc-plugin-missing-exports ./src --gitRevision master --out docs/source/api", "build:changelog": "node bin/build-changelog.js", - "build:docs-hexo": "cd docs && npm ci && npm run build && shx cp CNAME public/" + "build:docs-hexo": "cd docs && npm ci && npm run build && shx cp CNAME public/", + "serve:docs": "cd docs && npm run start", + "dev:docs": "run-s prepare:docs serve:docs" }, "bin": { "liquidjs": "./bin/liquid.js", From ed489865b64ce8f7cae2f14875e1a05742e043b3 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 23 Jun 2026 10:43:45 +0000 Subject: [PATCH 60/64] chore(release): 10.27.1 [skip ci] ## [10.27.1](https://github.com/harttle/liquidjs/compare/v10.27.0...v10.27.1) (2026-06-23) ### Bug Fixes * improve round function; improvement to [#873](https://github.com/harttle/liquidjs/issues/873) ([#901](https://github.com/harttle/liquidjs/issues/901)) ([956b51e](https://github.com/harttle/liquidjs/commit/956b51ea953eb52d9eba7409b7f51e379023fec4)) * **security:** charge pop filter allocation to memoryLimit ([#907](https://github.com/harttle/liquidjs/issues/907)) ([8a0c74a](https://github.com/harttle/liquidjs/commit/8a0c74a7fcb1671aa1dcb71ec82ba0602dc90d04)) * **strip_html:** infinite loop for strip_html ([5c3522f](https://github.com/harttle/liquidjs/commit/5c3522f33928aae66f0fe85c36e1d9015c768fe2)) ### Performance Improvements * **parser:** memoize createTrie to avoid rebuilding tries per Tokenizer ([#911](https://github.com/harttle/liquidjs/issues/911)) ([3a0d80d](https://github.com/harttle/liquidjs/commit/3a0d80d1f4526af0fbca2bb2e0a9c51669d2fd3e)) --- CHANGELOG.md | 14 ++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c3cbb8ff..2b7c59531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [10.27.1](https://github.com/harttle/liquidjs/compare/v10.27.0...v10.27.1) (2026-06-23) + + +### Bug Fixes + +* improve round function; improvement to [#873](https://github.com/harttle/liquidjs/issues/873) ([#901](https://github.com/harttle/liquidjs/issues/901)) ([956b51e](https://github.com/harttle/liquidjs/commit/956b51ea953eb52d9eba7409b7f51e379023fec4)) +* **security:** charge pop filter allocation to memoryLimit ([#907](https://github.com/harttle/liquidjs/issues/907)) ([8a0c74a](https://github.com/harttle/liquidjs/commit/8a0c74a7fcb1671aa1dcb71ec82ba0602dc90d04)) +* **strip_html:** infinite loop for strip_html ([5c3522f](https://github.com/harttle/liquidjs/commit/5c3522f33928aae66f0fe85c36e1d9015c768fe2)) + + +### Performance Improvements + +* **parser:** memoize createTrie to avoid rebuilding tries per Tokenizer ([#911](https://github.com/harttle/liquidjs/issues/911)) ([3a0d80d](https://github.com/harttle/liquidjs/commit/3a0d80d1f4526af0fbca2bb2e0a9c51669d2fd3e)) + # [10.27.0](https://github.com/harttle/liquidjs/compare/v10.26.0...v10.27.0) (2026-05-15) diff --git a/package-lock.json b/package-lock.json index e0154c1ba..c9f33eabd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "liquidjs", - "version": "10.27.0", + "version": "10.27.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "liquidjs", - "version": "10.27.0", + "version": "10.27.1", "license": "MIT", "dependencies": { "commander": "^10.0.0" diff --git a/package.json b/package.json index 97220efd4..df58cffe1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "liquidjs", - "version": "10.27.0", + "version": "10.27.1", "sideEffects": false, "description": "A simple, expressive, extensible Liquid template engine for JavaScript — Shopify, Jekyll and GitHub Pages compatible, for Node.js, browsers, and the CLI, with TypeScript support.", "main": "dist/liquid.node.js", From 568bd5f9cb99f596292c09fd70b00284b8216f0c Mon Sep 17 00:00:00 2001 From: spokodev Date: Thu, 25 Jun 2026 18:19:59 +0100 Subject: [PATCH 61/64] fix(filters): modulo should follow divisor sign for negative operands (#922) The `modulo` filter used JavaScript's `%` (truncated remainder, sign follows the dividend). Shopify/Ruby Liquid uses floored modulo, where the result takes the sign of the divisor. Since liquidjs advertises Shopify compatibility, negative operands produced the wrong sign. Use `((v % arg) + arg) % arg` to match Ruby's `%`. Positive-operand results are unchanged. --- src/filters/math.ts | 2 +- test/integration/filters/math.spec.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/filters/math.ts b/src/filters/math.ts index cb6775bff..61b8e5a95 100644 --- a/src/filters/math.ts +++ b/src/filters/math.ts @@ -8,7 +8,7 @@ export const divided_by = argumentsToNumber((dividend: number, divisor: number, export const floor = argumentsToNumber(Math.floor) export const minus = argumentsToNumber((v: number, arg: number) => v - arg) export const plus = argumentsToNumber((lhs: number, rhs: number) => lhs + rhs) -export const modulo = argumentsToNumber((v: number, arg: number) => v % arg) +export const modulo = argumentsToNumber((v: number, arg: number) => ((v % arg) + arg) % arg) export const times = argumentsToNumber((v: number, arg: number) => v * arg) export function round (v: number, arg = 0) { diff --git a/test/integration/filters/math.spec.ts b/test/integration/filters/math.spec.ts index ab52cb4be..3f4f7ba0a 100644 --- a/test/integration/filters/math.spec.ts +++ b/test/integration/filters/math.spec.ts @@ -50,6 +50,9 @@ describe('filters/math', function () { expect(Number(html)).toBeCloseTo(3.357, 3) }) it('should convert string', () => test('{{ "24" | modulo: "7" }}', '3')) + it('should follow divisor sign for negative dividend', () => test('{{ -7 | modulo: 3 }}', '2')) + it('should follow divisor sign for negative divisor', () => test('{{ 7 | modulo: -3 }}', '-2')) + it('should follow divisor sign for negative float', () => test('{{ -4.5 | modulo: 3 }}', '1.5')) }) describe('plus', function () { it('should return "6" for 4,2', () => test('{{ 4 | plus: 2 }}', '6')) From 8bfb6428ae7a1cb414a77acec30d26dfd28af9e2 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:21:05 +0800 Subject: [PATCH 62/64] docs: add spokodev as a contributor for code (#923) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 +++ 2 files changed, 12 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 90bcb6ab2..f3725b733 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -838,6 +838,15 @@ "contributions": [ "code" ] + }, + { + "login": "spokodev", + "name": "spokodev", + "avatar_url": "https://avatars.githubusercontent.com/u/239690017?v=4", + "profile": "https://github.com/spokodev", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 852d6c034..d3f638c5f 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,9 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to Tal
Tal

💻 Vladimir Filonov
Vladimir Filonov

💻 + + spokodev
spokodev

💻 + From 552819a84b80c62306fe61072628a756272dc749 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Mon, 6 Jul 2026 20:12:09 +0800 Subject: [PATCH 63/64] fix: enforce ownPropertyOnly for inherited array indices (#924) * fix: enforce ownPropertyOnly for inherited array indices Route array index access (including negative indices, first/last, and the first/last filters) through a shared readArrayElement helper so that ownPropertyOnly hides prototype-inherited array indices, closing the GHSA-fwxr-j5w2-587m bypass. The option's scope (property/index access only, not filter transforms or iteration) is documented on the option. Co-authored-by: Cursor * fix(filters): invoke Array.prototype methods on unsanitized array values Call built-ins via Array.prototype..call(...) for values that come from scope (join, compact, concat, slice, where/reject) so an overridden instance method on unsanitized data cannot hijack filter behavior. Methods on freshly-created arrays are left as-is. Co-authored-by: Cursor * fix(filters): use String.prototype.slice for the string branch of slice Route the non-array branch through String.prototype.slice.call so the slice filter never dispatches through a possibly-overridden instance method, matching the Array.prototype guard. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- src/context/context.spec.ts | 15 ++++++ src/context/context.ts | 20 +++---- src/filters/array.ts | 22 +++++--- src/liquid-options.ts | 5 +- src/util/underscore.ts | 6 +++ .../context/own-property-only.spec.ts | 54 +++++++++++++++++++ 6 files changed, 103 insertions(+), 19 deletions(-) create mode 100644 test/integration/context/own-property-only.spec.ts diff --git a/src/context/context.spec.ts b/src/context/context.spec.ts index f122174c9..d8eeac978 100644 --- a/src/context/context.spec.ts +++ b/src/context/context.spec.ts @@ -183,6 +183,21 @@ describe('Context', function () { ctx.push({ foo: Object.create({ bar: 'BAR' }) }) return expect(() => ctx.getSync(['foo', 'bar'])).toThrow(/undefined variable: foo.bar/) }) + it('should return undefined for inherited array indices', function () { + // eslint-disable-next-line no-extend-native + Array.prototype[0] = 'POLLUTED' + try { + const a: number[] = [] + a.length = 1 + ctx.push({ foo: a }) + expect(ctx.getSync(['foo', 0])).toEqual(undefined) + expect(ctx.getSync(['foo', -1])).toEqual(undefined) + expect(ctx.getSync(['foo', 'first'])).toEqual(undefined) + expect(ctx.getSync(['foo', 'last'])).toEqual(undefined) + } finally { + delete (Array.prototype as any)[0] + } + }) }) describe('.getAll()', function () { diff --git a/src/context/context.ts b/src/context/context.ts index b8951056f..9dcb711ea 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -3,7 +3,7 @@ import { Drop } from '../drop/drop' import { __assign } from 'tslib' import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options' import { createScope, Scope } from './scope' -import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue } from '../util' +import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, isNumber, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue, readArrayElement } from '../util' type PropertyKey = string | number; @@ -125,13 +125,13 @@ export class Context { obj = toLiquid(obj) key = toValue(key) as PropertyKey if (isNil(obj)) return obj - if (isArray(obj) && (key as number) < 0) return obj[obj.length + +key] + if (isArray(obj) && isNumber(key)) return readArrayElement(obj, key, this.ownPropertyOnly) const value = readJSProperty(obj, key, this.ownPropertyOnly) if (value === undefined && obj instanceof Drop) return obj.liquidMethodMissing(key, this) if (isFunction(value)) return value.call(obj) if (key === 'size') return readSize(obj) - else if (key === 'first') return readFirst(obj) - else if (key === 'last') return readLast(obj) + else if (key === 'first') return readFirst(obj, this.ownPropertyOnly) + else if (key === 'last') return readLast(obj, this.ownPropertyOnly) return value } } @@ -141,14 +141,14 @@ export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: b return obj[key] } -function readFirst (obj: Scope) { - if (isArray(obj)) return obj[0] - return obj['first'] +function readFirst (obj: Scope, ownPropertyOnly: boolean) { + if (isArray(obj)) return readArrayElement(obj, 0, ownPropertyOnly) + return readJSProperty(obj, 'first', ownPropertyOnly) } -function readLast (obj: Scope) { - if (isArray(obj)) return obj[obj.length - 1] - return obj['last'] +function readLast (obj: Scope, ownPropertyOnly: boolean) { + if (isArray(obj)) return readArrayElement(obj, -1, ownPropertyOnly) + return readJSProperty(obj, 'last', ownPropertyOnly) } function readSize (obj: Scope) { diff --git a/src/filters/array.ts b/src/filters/array.ts index 502af5355..e714d6588 100644 --- a/src/filters/array.ts +++ b/src/filters/array.ts @@ -1,4 +1,4 @@ -import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, orderedCompare, isArray, isNil, last as arrayLast, isArrayLike, toEnumerable } from '../util' +import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, orderedCompare, isArray, isNil, isArrayLike, readArrayElement, toEnumerable } from '../util' import { arrayIncludes, equals, evalToken, isTruthy } from '../render' import { Value, FilterImpl } from '../template' import { Tokenizer } from '../parser' @@ -10,10 +10,14 @@ export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: const sep = isNil(arg) ? ' ' : stringify(arg) const complexity = array.length * (1 + sep.length) this.context.memoryLimit.use(complexity) - return array.join(sep) + return Array.prototype.join.call(array, sep) +}) +export const last = argumentsToValue(function (this: FilterImpl, v: any) { + return isArrayLike(v) ? readArrayElement(v, -1, this.context.ownPropertyOnly) : '' +}) +export const first = argumentsToValue(function (this: FilterImpl, v: any) { + return isArrayLike(v) ? readArrayElement(v, 0, this.context.ownPropertyOnly) : '' }) -export const last = argumentsToValue((v: any) => isArrayLike(v) ? arrayLast(v) : '') -export const first = argumentsToValue((v: any) => isArrayLike(v) ? v[0] : '') export const reverse = argumentsToValue(function (this: FilterImpl, v: any[]) { const array = toArray(v) this.context.memoryLimit.use(array.length) @@ -66,14 +70,14 @@ export function * sum (this: FilterImpl, arr: Scope[], property?: string): Itera export function compact (this: FilterImpl, arr: T[]) { const array = toArray(arr) this.context.memoryLimit.use(array.length) - return array.filter(x => !isNil(toValue(x))) + return Array.prototype.filter.call(array, x => !isNil(toValue(x))) } export function concat (this: FilterImpl, v: T1[], arg: T2[] = []): (T1 | T2)[] { const lhs = toArray(v) const rhs = toArray(arg) this.context.memoryLimit.use(lhs.length + rhs.length) - return lhs.concat(rhs) + return Array.prototype.concat.call(lhs, rhs) } export function push (this: FilterImpl, v: T[], arg: T): T[] { @@ -110,7 +114,9 @@ export function slice (this: FilterImpl, v: T[] | string, begin: number, leng if (!isArray(v)) v = stringify(v) begin = begin < 0 ? v.length + begin : begin this.context.memoryLimit.use(length) - return v.slice(begin, begin + length) + return isArray(v) + ? Array.prototype.slice.call(v, begin, begin + length) + : String.prototype.slice.call(v, begin, begin + length) } function expectedMatcher (this: FilterImpl, expected: any): (v: any) => boolean { @@ -132,7 +138,7 @@ function * filter (this: FilterImpl, include: boolean, arr: T[ values.push(yield evalToken(token, this.context.spawn(item))) } const matcher = expectedMatcher.call(this, expected) - return arr.filter((_, i) => matcher(values[i]) === include) + return Array.prototype.filter.call(arr, (_, i) => matcher(values[i]) === include) } function * filter_exp (this: FilterImpl, include: boolean, arr: T[], itemName: string, exp: string): IterableIterator { diff --git a/src/liquid-options.ts b/src/liquid-options.ts index e6fc5d1f0..7ab1cbe2d 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -38,7 +38,10 @@ export interface LiquidOptions { strictVariables?: boolean; /** Catch all errors instead of exit upon one. Please note that render errors won't be reached when parse fails. */ catchAllErrors?: boolean; - /** Hide scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. */ + /** + * Hide scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. + * This only applies to property/index access on scope objects. Filter transforms and iteration operate on the resolved value with standard JavaScript semantics, so prototype-inherited array indices may still be surfaced by them. + */ ownPropertyOnly?: boolean; /** Modifies the behavior of `strictVariables`. If set, a single undefined variable will *not* cause an exception in the context of the `if`/`elsif`/`unless` tag and the `default` filter. Instead, it will evaluate to `false` and `null`, respectively. Irrelevant if `strictVariables` is not set. Defaults to `false`. **/ lenientIf?: boolean; diff --git a/src/util/underscore.ts b/src/util/underscore.ts index 665fdef6c..c60a93ebb 100644 --- a/src/util/underscore.ts +++ b/src/util/underscore.ts @@ -42,6 +42,12 @@ export function stringify (value: any): string { return String(value) } +export function readArrayElement (arr: any[], index: number, ownPropertyOnly: boolean) { + if (index < 0) index = arr.length + index + if (ownPropertyOnly && !hasOwnProperty.call(arr, index)) return undefined + return arr[index] +} + export function toEnumerable (val: any): T[] { val = toValue(val) if (isArray(val)) return val diff --git a/test/integration/context/own-property-only.spec.ts b/test/integration/context/own-property-only.spec.ts new file mode 100644 index 000000000..1d5c1dfc1 --- /dev/null +++ b/test/integration/context/own-property-only.spec.ts @@ -0,0 +1,54 @@ +import { Liquid } from '../../../src/liquid' + +describe('ownPropertyOnly / inherited array indices', function () { + const engine = new Liquid({ ownPropertyOnly: true }) + + function pollutedArrays () { + // eslint-disable-next-line no-extend-native + Array.prototype[0] = 'ARRAY_PROTO_POLLUTED' + ;(Object.prototype as any).secret = 'OBJECT_PROTO_POLLUTED' + const a: any[] = [] + a.length = 1 + const o = {} + return { + a, + o, + cleanup () { + delete (Array.prototype as any)[0] + delete (Object.prototype as any).secret + } + } + } + + const cases: [string, (ctx: ReturnType) => object, string][] = [ + ['{{ a[0] }}', ({ a }) => ({ a }), ''], + ['{{ a[-1] }}', ({ a }) => ({ a }), ''], + ['{{ o.secret }}', ({ o }) => ({ o }), ''], + ['{{ a.first }}', ({ a }) => ({ a }), ''], + ['{{ a.last }}', ({ a }) => ({ a }), ''], + ['{{ a | first }}', ({ a }) => ({ a }), ''], + ['{{ a | last }}', ({ a }) => ({ a }), ''], + ['{% assign x = a | first %}{{ x }}', ({ a }) => ({ a }), ''] + ] + + it.each(cases)('%s', function (src, scopeFn, expected) { + const ctx = pollutedArrays() + try { + expect(engine.parseAndRenderSync(src, scopeFn(ctx))).toBe(expected) + } finally { + ctx.cleanup() + } + }) + + it('still allows array length and size', function () { + const { a, cleanup } = pollutedArrays() + try { + expect(engine.parseAndRenderSync('{{ a.size }}', { a })).toBe('1') + const arr = [1, 2] + expect(engine.parseAndRenderSync('{{ arr | first }}', { arr })).toBe('1') + expect(engine.parseAndRenderSync('{{ arr[-1] }}', { arr })).toBe('2') + } finally { + cleanup() + } + }) +}) From 7ab49f999ac045ec1e87f3a7a9fd68dd9e8602b3 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Mon, 6 Jul 2026 23:54:00 +0800 Subject: [PATCH 64/64] fix: charge join/json/inspect filters by produced output size (#925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(filters): charge join/array_to_sentence_string by output size join charged memoryLimit by array element count, not by the string it produces, letting concat doubling (cheap reference copies) inflate an array's element count and then materialize a huge string via join far past the configured memoryLimit (GHSA-4r6h-5v86-94p3). Charge by the sum of stringified element lengths plus separators before allocating. Apply the same fix to the sibling array_to_sentence_string filter. Co-authored-by: Cursor * refactor(filters): simplify join output-size accounting Sum stringified element lengths in a single pass and keep the guarded Array.prototype.join for the result, instead of building an intermediate parts array. Co-authored-by: Cursor * fix(filters): charge json/jsonify/inspect serialization to memoryLimit json/jsonify/inspect serialized values without charging memoryLimit, so a concat-doubled array (cheap reference copies) could be materialized into a huge JSON string past the configured limit — the same unbounded class as the join bug (GHSA-4r6h-5v86-94p3). Charge via a JSON.stringify replacer that accounts string lengths as it walks, aborting mid- serialization instead of allocating the full blob first. Co-authored-by: Cursor * fix(memory): charge rendered output to memoryLimit at emission Move output-length accounting into the emitters, which charge each written chunk against ctx.memoryLimit right before it reaches the result string or stream. Filters/tags now only pre-charge the extra working memory they allocate apart from that output, so join drops its bespoke output-size counting and charges array.length like its siblings. The block.super capture emitter intentionally omits the limiter to avoid double-counting content that is re-emitted through the final emitter. Co-authored-by: Cursor * refactor(filters): rely on emitter output charge for json/inspect/array_to_sentence_string With rendered output charged at emission, these filters no longer need bespoke output-size counting: the emitted case is covered by the final emitter. Revert json/inspect to their original form and array_to_sentence_string to its element-count charge, dropping the non-emitted `| size` guards. Co-authored-by: Cursor * revert(memory): drop emitter output charge, restore filter output-size accounting join/array_to_sentence_string/json/inspect charge memoryLimit by the string they materialize (not element count), so discarded results like {% assign out = a | join %}{{ out | size }} are still bounded. Remove the emitter-level limiter added in 2f343f063; it cannot catch materialized-but-not-emitted values. Co-authored-by: Cursor * fix(filters): charge json/inspect replacer by serialized node size Replace the flat 1-unit charge for non-string JSON nodes with per-type estimates (primitives via JSON.stringify length, containers by structure). Co-authored-by: Cursor --------- Co-authored-by: Cursor --- src/filters/array.ts | 5 ++-- src/filters/misc.ts | 34 ++++++++++++++++++++---- src/filters/string.ts | 4 ++- test/integration/liquid/dos.spec.ts | 41 +++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/filters/array.ts b/src/filters/array.ts index e714d6588..57a7d802d 100644 --- a/src/filters/array.ts +++ b/src/filters/array.ts @@ -8,8 +8,9 @@ import { EmptyDrop } from '../drop' export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: string) { const array = toArray(v) const sep = isNil(arg) ? ' ' : stringify(arg) - const complexity = array.length * (1 + sep.length) - this.context.memoryLimit.use(complexity) + let outputSize = sep.length * Math.max(array.length - 1, 0) + for (let i = 0; i < array.length; i++) outputSize += String(array[i]).length + this.context.memoryLimit.use(outputSize) return Array.prototype.join.call(array, sep) }) export const last = argumentsToValue(function (this: FilterImpl, v: any) { diff --git a/src/filters/misc.ts b/src/filters/misc.ts index 57ed57812..447376550 100644 --- a/src/filters/misc.ts +++ b/src/filters/misc.ts @@ -2,6 +2,18 @@ import { isFalsy } from '../render/boolean' import { identify, isArray, isString, toValue } from '../util/underscore' import { FilterImpl } from '../template' +function chargeJsonReplacerValue (memoryLimit: { use(count: number): void }, val: unknown) { + if (typeof val === 'string') { + memoryLimit.use(val.length) + } else if (val === null || typeof val === 'number' || typeof val === 'boolean') { + memoryLimit.use(JSON.stringify(val).length) + } else if (Array.isArray(val)) { + memoryLimit.use(val.length + 1) + } else if (typeof val === 'object') { + memoryLimit.use(2) + } +} + function defaultFilter (this: FilterImpl, value: T1, defaultValue: T2, ...args: Array<[string, any]>): T1 | T2 { value = toValue(value) if (isArray(value) || isString(value)) return value.length ? value : defaultValue @@ -9,18 +21,30 @@ function defaultFilter (this: FilterImpl, value: T1, def return isFalsy(value, this.context) ? defaultValue : value } -function json (value: any, space = 0) { - return JSON.stringify(value, null, space) +function json (this: FilterImpl, value: any, space = 0) { + const memoryLimit = this.context.memoryLimit + return JSON.stringify(value, (_key, val) => { + chargeJsonReplacerValue(memoryLimit, val) + return val + }, space) } -function inspect (value: any, space = 0) { +function inspect (this: FilterImpl, value: any, space = 0) { + const memoryLimit = this.context.memoryLimit const ancestors: object[] = [] return JSON.stringify(value, function (this: unknown, _key: unknown, value: any) { - if (typeof value !== 'object' || value === null) return value + if (typeof value !== 'object' || value === null) { + chargeJsonReplacerValue(memoryLimit, value) + return value + } // `this` is the object that value is contained in, i.e., its direct parent. while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop() - if (ancestors.includes(value)) return '[Circular]' + if (ancestors.includes(value)) { + memoryLimit.use('[Circular]'.length) + return '[Circular]' + } ancestors.push(value) + chargeJsonReplacerValue(memoryLimit, value) return value }, space) } diff --git a/src/filters/string.ts b/src/filters/string.ts index f96d4b492..d0c59708e 100644 --- a/src/filters/string.ts +++ b/src/filters/string.ts @@ -209,7 +209,9 @@ export function number_of_words (this: FilterImpl, input: string, mode?: 'cjk' | export function array_to_sentence_string (this: FilterImpl, array: unknown[], connector = 'and') { connector = stringify(connector) - this.context.memoryLimit.use(array.length + connector.length) + let outputSize = connector.length + array.length * 2 + for (let i = 0; i < array.length; i++) outputSize += stringify(array[i]).length + this.context.memoryLimit.use(outputSize) switch (array.length) { case 0: return '' diff --git a/test/integration/liquid/dos.spec.ts b/test/integration/liquid/dos.spec.ts index 2aea12c48..65d78adb5 100644 --- a/test/integration/liquid/dos.spec.ts +++ b/test/integration/liquid/dos.spec.ts @@ -89,6 +89,47 @@ describe('DoS related', function () { const liquid = new Liquid({ memoryLimit: 100 }) await expect(liquid.parseAndRender('{{ array | sample: 1 | size }}', { array })).rejects.toThrow('memory alloc limit exceeded') }) + it('should charge join by produced output size, not element count', () => { + const array = ['a'.repeat(100), 'b'.repeat(100)] + const liquid = new Liquid({ memoryLimit: 100 }) + expect(() => liquid.parseAndRenderSync('{{ array | join: "" }}', { array })) + .toThrow('memory alloc limit exceeded') + }) + it('should allow join within memoryLimit', () => { + const array = ['a'.repeat(20), 'b'.repeat(20)] + const liquid = new Liquid({ memoryLimit: 100 }) + expect(liquid.parseAndRenderSync('{{ array | join: "" }}', { array })).toBe('a'.repeat(20) + 'b'.repeat(20)) + }) + it('should prevent concat doubling from bypassing join memoryLimit', () => { + const liquid = new Liquid({ memoryLimit: 1e4 }) + const src = '{%- assign a = s | split: "NOSEP" -%}' + + '{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}' + + '{{ a | join: "" | size }}' + expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) })) + .toThrow('memory alloc limit exceeded') + }) + it('should charge array_to_sentence_string by produced output size', () => { + const array = ['a'.repeat(100), 'b'.repeat(100), 'c'.repeat(100)] + const liquid = new Liquid({ memoryLimit: 100 }) + expect(() => liquid.parseAndRenderSync('{{ array | array_to_sentence_string }}', { array })) + .toThrow('memory alloc limit exceeded') + }) + it('should charge json serialization of concat-doubled arrays', () => { + const liquid = new Liquid({ memoryLimit: 1e4 }) + const src = '{%- assign a = s | split: "NOSEP" -%}' + + '{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}' + + '{{ a | json | size }}' + expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) })) + .toThrow('memory alloc limit exceeded') + }) + it('should charge inspect serialization of concat-doubled arrays', () => { + const liquid = new Liquid({ memoryLimit: 1e4 }) + const src = '{%- assign a = s | split: "NOSEP" -%}' + + '{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}' + + '{{ a | inspect | size }}' + expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) })) + .toThrow('memory alloc limit exceeded') + }) it('should charge strip_html input length to memoryLimit', () => { const liquid = new Liquid({ memoryLimit: 100 }) expect(() => liquid.parseAndRenderSync('{{ s | strip_html }}', { s: 'a'.repeat(200) }))