Compare commits

..
Author SHA1 Message Date
39233ba9f2 docs: publish .nojekyll so GitHub Pages serves underscore API pages (#952)
EOF

Co-authored-by: Cursor <[email protected]>
2026-09-06 19:38:56 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
8f57d9fed8 docs: add sarathfrancis90 as a contributor for code (#951)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2026-09-06 19:16:55 +08:00
Sarath FrancisandGitHub 9af92f5d8c fix(url_decode): keep %2B as a literal plus when decoding (#939)
url_decode decoded the percent-encoding first and only then replaced
"+" with a space, so a "%2B" became "+" and was immediately turned into
a space. Any literal "+" was therefore lost when round-tripped through
url_encode. I now replace "+" with a space before decodeURIComponent,
which lines up with Ruby's CGI.unescape used by Shopify.
2026-09-06 19:16:16 +08:00
semantic-release-bot 747bdbdbee chore(release): 10.29.0 [skip ci]
# [10.29.0](https://github.com/harttle/liquidjs/compare/v10.28.0...v10.29.0) (2026-08-11)

### Features

* add unregisterFilter method ([#946](https://github.com/harttle/liquidjs/issues/946)) ([69b2c58](https://github.com/harttle/liquidjs/commit/69b2c589f9b69a34427cb8533ddb938bd997914f))
* **filters:** add squish filter ([#943](https://github.com/harttle/liquidjs/issues/943)) ([875513f](https://github.com/harttle/liquidjs/commit/875513f4c5136bed0c64562cccabb21a7db8d36c))
2026-08-11 12:53:56 +00:00
MildlyMeticulousandGitHub 875513f4c5 feat(filters): add squish filter (#943) 2026-08-11 20:52:07 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
f88a528e27 docs: add YacovGold as a contributor for code (#947)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2026-08-11 20:04:09 +08:00
69b2c589f9 feat: add unregisterFilter method (#946)
* feat: add unregisterFilter method

* docs: show how to re-register built-in filters

---------

Co-authored-by: Yacov <yacov@noemail>
2026-08-11 19:58:25 +08:00
semantic-release-bot 88ae297c1b chore(release): 10.28.0 [skip ci]
# [10.28.0](https://github.com/harttle/liquidjs/compare/v10.27.2...v10.28.0) (2026-08-01)

### Bug Fixes

* **date:** %s returns Unix epoch unaffected by display timezone ([#932](https://github.com/harttle/liquidjs/issues/932)) ([39c8743](https://github.com/harttle/liquidjs/commit/39c87437c5ef38ede9a208c9d55cd13231c6c023)), closes [#931](https://github.com/harttle/liquidjs/issues/931)

### Features

* Add support of inner expressions enclosed by parentheses ([#863](https://github.com/harttle/liquidjs/issues/863)) ([afa5f54](https://github.com/harttle/liquidjs/commit/afa5f5400428fc1ec935aca0282e579224660c95))
2026-08-01 10:18:25 +00:00
afa5f54004 feat: Add support of inner expressions enclosed by parentheses (#863)
* Add support of inner expressions enclosed by parentheses

* Add support of inner expressions enclosed by parentheses

Made-with: Cursor

* simplify implementation

* fix lint

* fix test

* Enhance tests for parenthesized filter chains in Liquid tags. Added scenarios for enabled and disabled grouped expressions in case, for, if, unless tags, ensuring proper handling of expressions and error throwing for invalid syntax.

* test: remove duplicate readGroupedExpression test block

The readGroupedExpression() test suite was duplicated twice in the spec file. Removed the duplicate block to avoid redundant test execution.

* refactor: extract extractGroupedExpressionTokenVariables helper

Extract inline grouped expression variable extraction logic into a dedicated
function for consistency with other extractors (extractFilteredValueVariables,
extractPropertyAccessVariable).

This addresses PR #863 comment 7 - improves code organization and
maintainability.

* refactor(types): explicit type for collection in for tag

collection: ValueToken | GroupedExpressionToken

Addresses PR #863 comment 5.

* refactor: evaluate grouped expressions at render time with resolvedFilters

Addresses PR review comments 4, 6, 8, 9 - moves grouped expression evaluation
from parse-time resolution to render-time lazy evaluation following the
generator-based async/sync duality pattern used throughout liquidjs.

Key changes:
- Replace resolvedValue (Value instance) with resolvedFilters (Filter[])
- Rename resolveGroupedExpressions() to resolveGroupedExpressionFilters()
- Move evaluation logic to evalGroupedExpressionToken() at render time
- Build Filter instances at parse time (carry liquid reference for render)
- Evaluate expression and apply filters lazily via generators
- Add support for tablerow tag with grouped expressions
- Remove duplicate getFilter() method in Value class

Maintains proper layering (tokens → render → templates) and consistency
with Value.value() pattern. Filter resolution still happens at parse time
since it requires liquid.filters access, but actual evaluation is deferred
to render time.

Tags that store raw ValueToken (for, case when-values, tablerow) still need
explicit resolveGroupedExpressionFilters() calls. Tags that wrap with
new Value() get automatic recursive resolution via Value constructor.

* refactor: reuse FilteredValueToken and fix architectural layering

Replace GroupedExpressionToken with existing FilteredValueToken to avoid
code duplication and fix layering violation where tokens depended on
templates (Filter instances).

Key changes:
- Reuse FilteredValueToken instead of GroupedExpressionToken
- Simplify readGroupOrRange() to return FilteredValueToken | RangeToken
- Add liquid reference to Context for runtime filter resolution
- Build Filter instances at render time in evalFilteredValueToken()
- Remove resolveGroupedExpressionFilters() and parse-time resolution
- Remove explicit resolution calls from tag constructors

This maintains proper architectural layering (tokens → render → templates)
with no backward dependencies, as requested in PR review feedback.

All 1537 tests pass.

* revert redundant'

* refactor: make getFilter private and improve code organization

* test: fix test name in case.spec.ts for when disabled block

* refactor: no need for Deprecated flag

* test: fix test name and logic to properly test if tag with nested expressions

* feat: support real parenthesis grouping in grouped expressions

Allow arbitrary expressions inside parentheses (e.g. ((a | upcase) > 3)
and (1 < 3)) when groupedExpressions is enabled, reusing readFilteredValue
for the general case while keeping range and filter-chain fast paths.

* feat: enhance expression tokenization with new generator methods

Added `readExpressionTokensFromHere` and `readGroupedExpressionTokens` methods to improve the handling of expression tokens. This refactor simplifies the token reading process and maintains compatibility with existing grouped expressions, ensuring proper evaluation and filtering.

* add tests

* address comments

---------

Co-authored-by: Omri Rosner <[email protected]>
2026-08-01 18:15:54 +08:00
amit777andGitHub 39c87437c5 fix(date): %s returns Unix epoch unaffected by display timezone (#932)
The %s handler read LiquidDate.getTime(), which returns the
displayDate deliberately shifted by the display timezone offset for
wall-clock getters. With a timezone argument or timezoneOffset
option set, %s produced an epoch shifted by (server offset - display
offset) instead of the true Unix timestamp.

Expose the unshifted time as LiquidDate.dateValue() and use it for
%s. Also switch Math.round to Math.floor so fractional seconds
truncate toward the epoch like Ruby strftime.

Fixes #931
2026-07-10 23:13:07 +08:00
semantic-release-bot 050f161794 chore(release): 10.27.2 [skip ci]
## [10.27.2](https://github.com/harttle/liquidjs/compare/v10.27.1...v10.27.2) (2026-07-09)

### Bug Fixes

* charge join/json/inspect filters by produced output size ([#925](https://github.com/harttle/liquidjs/issues/925)) ([7ab49f9](https://github.com/harttle/liquidjs/commit/7ab49f999ac045ec1e87f3a7a9fd68dd9e8602b3))
* **date:** zero-pad milliseconds when formatting %N fractional seconds ([#929](https://github.com/harttle/liquidjs/issues/929)) ([2634f9d](https://github.com/harttle/liquidjs/commit/2634f9de7b1228cd887b7cab880af8a795c77053))
* enforce ownPropertyOnly for inherited array indices ([#924](https://github.com/harttle/liquidjs/issues/924)) ([552819a](https://github.com/harttle/liquidjs/commit/552819a84b80c62306fe61072628a756272dc749))
* **filters:** modulo should follow divisor sign for negative operands ([#922](https://github.com/harttle/liquidjs/issues/922)) ([568bd5f](https://github.com/harttle/liquidjs/commit/568bd5f9cb99f596292c09fd70b00284b8216f0c))
* **filters:** return empty for out-of-range slice begin or negative length ([#928](https://github.com/harttle/liquidjs/issues/928)) ([f9a1316](https://github.com/harttle/liquidjs/commit/f9a1316d161f4f20018c833160f42dfcf0cde507))
2026-07-09 15:18:38 +00:00
spokodevandGitHub 2634f9de7b fix(date): zero-pad milliseconds when formatting %N fractional seconds (#929)
%N renders the fractional part of the second. The milliseconds returned by
getMilliseconds() are the three most significant digits of that fraction and
must be zero-padded to three digits before use, otherwise sub-100ms values
lose their leading zeros:

  50ms => strftime("%N")  returned "500000000", expected "050000000"
   5ms => strftime("%3N") returned "500",       expected "005"

Pad the milliseconds to three digits before slicing to the requested width.
2026-07-09 23:16:44 +08:00
spokodevandGitHub f9a1316d16 fix(filters): return empty for out-of-range slice begin or negative length (#928)
Ruby/Shopify `slice` returns nil (rendered as an empty string or array) when
the begin offset falls outside the negative range or when the length is
negative. liquidjs forwarded the adjusted indices straight to
Array/String.prototype.slice, whose own negative-index handling produced
non-empty, incorrect output:

  {{ "hello" | slice: -10, 2 }}  => "he"   (expected "")
  {{ "Liquid" | slice: 1, -2 }}  => "iqui" (expected "")

Guard the adjusted begin and the length before slicing.
2026-07-09 22:41:46 +08:00
94 changed files with 1261 additions and 665 deletions
+18
View File
@@ -847,6 +847,24 @@
"contributions": [
"code"
]
},
{
"login": "YacovGold",
"name": "YacovGold",
"avatar_url": "https://avatars.githubusercontent.com/u/8984042?v=4",
"profile": "https://github.com/YacovGold",
"contributions": [
"code"
]
},
{
"login": "sarathfrancis90",
"name": "Sarath Francis",
"avatar_url": "https://avatars.githubusercontent.com/u/9289498?v=4",
"profile": "https://github.com/sarathfrancis90",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 'latest'
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 'latest'
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Test
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 'latest'
node-version: '20'
- name: Build
run: |
npm ci
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 'latest'
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Lint
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 'latest'
node-version: '20'
- name: Download artifacts
uses: actions/download-artifact@v4
with:
+6 -1
View File
@@ -24,7 +24,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release
run: |
if [ ${{ github.ref == 'refs/heads/master' }} ]; then
npx semantic-release
else
npx semantic-release --dry-run
fi
- name: Archive npm failure logs
uses: actions/upload-artifact@v4
if: failure()
+14 -4
View File
@@ -7,10 +7,20 @@ jobs:
matrix:
os: [ubuntu-latest]
timezone: [Etc/GMT, Asia/Shanghai, America/New_York]
node-version: [latest]
node-version: [22]
include:
- os: macos-latest
timezone: America/New_York
node-versoin: 22
- os: ubuntu-latest
node-version: lts/*
timezone: Etc/GMT
node-version: 20
- os: ubuntu-latest
timezone: Asia/Shanghai
node-version: 18
- os: ubuntu-latest
timezone: Asia/Shanghai
node-version: 16
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
@@ -29,7 +39,7 @@ jobs:
name: dist-${{ matrix.os }}
path: dist
- name: Run Test
run: TZ=${{ matrix.timezone || 'Etc/GMT' }} npm test
run: TZ=${{ matrix.timezone }} npm test
- name: Archive npm failure logs
uses: actions/upload-artifact@v4
if: failure()
@@ -47,7 +57,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: latest
node-version: 22
- name: Download artifacts
uses: actions/download-artifact@v4
with:
-50
View File
@@ -1,50 +0,0 @@
const gitPlugin = [
'@semantic-release/git',
{
assets: ['package.json', 'package-lock.json', 'CHANGELOG.md'],
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}'
}
]
const githubPlugin = [
'@semantic-release/github',
{
assets: [
{ path: 'dist/*.umd.js', label: 'liquid.js' },
{ path: 'dist/*.min.js', label: 'liquid.min.js' },
{ path: 'dist/*.min.js.map', label: 'liquid.min.js.map' }
]
}
]
// next is branch-protected (PR-only); skip @semantic-release/git there and publish to npm only.
const onMaster = process.env.GITHUB_REF === 'refs/heads/master'
const onNext = process.env.GITHUB_REF === 'refs/heads/next'
// On next, breaking changes are v11 WIP — bump alpha prerelease only, not major.
const commitAnalyzer = onNext
? ['@semantic-release/commit-analyzer', {
releaseRules: [
{ breaking: true, release: 'patch' }
]
}]
: '@semantic-release/commit-analyzer'
const basePlugins = [
commitAnalyzer,
'@semantic-release/release-notes-generator',
'@semantic-release/changelog',
'@semantic-release/npm'
]
module.exports = {
branches: [
'master',
{ name: 'next', prerelease: 'alpha' }
],
plugins: [
...basePlugins,
...(onMaster ? [gitPlugin] : []),
githubPlugin
]
}
+31
View File
@@ -1,3 +1,34 @@
# [10.29.0](https://github.com/harttle/liquidjs/compare/v10.28.0...v10.29.0) (2026-08-11)
### Features
* add unregisterFilter method ([#946](https://github.com/harttle/liquidjs/issues/946)) ([69b2c58](https://github.com/harttle/liquidjs/commit/69b2c589f9b69a34427cb8533ddb938bd997914f))
* **filters:** add squish filter ([#943](https://github.com/harttle/liquidjs/issues/943)) ([875513f](https://github.com/harttle/liquidjs/commit/875513f4c5136bed0c64562cccabb21a7db8d36c))
# [10.28.0](https://github.com/harttle/liquidjs/compare/v10.27.2...v10.28.0) (2026-08-01)
### Bug Fixes
* **date:** %s returns Unix epoch unaffected by display timezone ([#932](https://github.com/harttle/liquidjs/issues/932)) ([39c8743](https://github.com/harttle/liquidjs/commit/39c87437c5ef38ede9a208c9d55cd13231c6c023)), closes [#931](https://github.com/harttle/liquidjs/issues/931)
### Features
* Add support of inner expressions enclosed by parentheses ([#863](https://github.com/harttle/liquidjs/issues/863)) ([afa5f54](https://github.com/harttle/liquidjs/commit/afa5f5400428fc1ec935aca0282e579224660c95))
## [10.27.2](https://github.com/harttle/liquidjs/compare/v10.27.1...v10.27.2) (2026-07-09)
### Bug Fixes
* charge join/json/inspect filters by produced output size ([#925](https://github.com/harttle/liquidjs/issues/925)) ([7ab49f9](https://github.com/harttle/liquidjs/commit/7ab49f999ac045ec1e87f3a7a9fd68dd9e8602b3))
* **date:** zero-pad milliseconds when formatting %N fractional seconds ([#929](https://github.com/harttle/liquidjs/issues/929)) ([2634f9d](https://github.com/harttle/liquidjs/commit/2634f9de7b1228cd887b7cab880af8a795c77053))
* enforce ownPropertyOnly for inherited array indices ([#924](https://github.com/harttle/liquidjs/issues/924)) ([552819a](https://github.com/harttle/liquidjs/commit/552819a84b80c62306fe61072628a756272dc749))
* **filters:** modulo should follow divisor sign for negative operands ([#922](https://github.com/harttle/liquidjs/issues/922)) ([568bd5f](https://github.com/harttle/liquidjs/commit/568bd5f9cb99f596292c09fd70b00284b8216f0c))
* **filters:** return empty for out-of-range slice begin or negative length ([#928](https://github.com/harttle/liquidjs/issues/928)) ([f9a1316](https://github.com/harttle/liquidjs/commit/f9a1316d161f4f20018c833160f42dfcf0cde507))
## [10.27.1](https://github.com/harttle/liquidjs/compare/v10.27.0...v10.27.1) (2026-06-23)
+2
View File
@@ -242,6 +242,8 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/spokodev"><img src="https://avatars.githubusercontent.com/u/239690017?v=4?s=100" width="100px;" alt="spokodev"/><br /><sub><b>spokodev</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=spokodev" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/YacovGold"><img src="https://avatars.githubusercontent.com/u/8984042?v=4?s=100" width="100px;" alt="YacovGold"/><br /><sub><b>YacovGold</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=YacovGold" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sarathfrancis90"><img src="https://avatars.githubusercontent.com/u/9289498?v=4?s=100" width="100px;" alt="Sarath Francis"/><br /><sub><b>Sarath Francis</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=sarathfrancis90" title="Code">💻</a></td>
</tr>
</tbody>
</table>
+2 -2
View File
@@ -7,13 +7,13 @@ while ! grep -q "Express running" "$LOG_FILE"; do
if ! kill -0 $SERVER_PID; then
echo "Server exited unexpectedly."
cat $LOG_FILE
exit 1
return 1
fi
sleep 1
done
curl http://127.0.0.1:3000 | grep -q 'Welcome to LiquidJS'
RESULT=$?
killall node || true
killall node
rm $LOG_FILE
if [ $RESULT != 0 ]; then
exit 1
+1
View File
@@ -94,6 +94,7 @@ filters:
sort: sort.html
sort_natural: sort_natural.html
split: split.html
squish: squish.html
strip: strip.html
strip_html: strip_html.html
strip_newlines: strip_newlines.html
+1
View File
@@ -34,6 +34,7 @@ The `date` filter is used to convert a timestamp into the specified format.
* minutes: `-360` means `'+06:00'` and `360` means `'-06:00'`
* timeZone ID: `Asia/Colombo` or `America/New_York`
* See [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for TZ database values
* `%s` (seconds since the Unix epoch) identifies an instant rather than a wall-clock time, so it's not affected by the display timezone.
### Examples
```liquid
+18
View File
@@ -0,0 +1,18 @@
---
title: squish
---
{% since %}v10.28.0{% endsince %}
Removes leading and trailing whitespace from a string, and replaces every run of whitespace inside it with a single space.
Input
```liquid
{{ " Hello there,
Major Tom. " | squish }}
```
Output
```text
Hello there, Major Tom.
```
+1 -1
View File
@@ -138,7 +138,7 @@ It defaults to `false`. For example, when set to `true`, a blank string would ev
**lenientIf** modifies the behavior of `strictVariables` to allow handling optional variables. If set to `true`, an undefined variable will *not* cause an exception in the following two situations: a) it is the condition to an `if`, `elsif`, or `unless` tag; b) it occurs right before a `default` filter. Irrelevant if `strictVariables` is not set. Defaults to `false`.
**ownPropertyOnly** limits template property reads on plain scope objects to own properties. Defaults to `true`. See [Security Model](./security-model.html).
**ownPropertyOnly** hides scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. Defaults to `true`.
{% note info Nonexistent Tags %}
Nonexistent tags always throw errors during parsing and this behavior cannot be customized.
+19 -1
View File
@@ -62,7 +62,23 @@ See existing filter implementations here: <https://github.com/harttle/liquidjs/t
## Unregister Tags/Filters
In some cases it's desirable to disable some tags/filters (see [#324](https://github.com/harttle/liquidjs/issues/324)). You'll need to register a dummy tag/filter that throws a corresponding Error.
Filters can be unregistered by name:
```javascript
engine.unregisterFilter('plus')
```
With [`strictFilters`][strict-filters] enabled, using an unregistered filter will throw an error. Otherwise, the filter will be skipped.
Built-in filters can be registered again using the exported `filters` object:
```javascript
import { filters } from 'liquidjs'
engine.registerFilter('plus', filters.plus)
```
To disable a tag, or to make a disabled filter throw regardless of `strictFilters`, register a dummy implementation that throws a corresponding error (see [#324](https://github.com/harttle/liquidjs/issues/324)):
```javascript
// disable a tag
@@ -81,3 +97,5 @@ function disabledFilter(name) {
}
engine.registerFilter('plus', disabledFilter('plus'));
```
[strict-filters]: /tutorials/options.html#strict
+18 -25
View File
@@ -22,7 +22,7 @@ Expected output:
</div>
```
Firstly, [register][register-tags] a tag named `wrap` and parse the content into `this.tpls`. In the tag `constructor(tagToken, remainTokens, liquid)`:
Firstly, [register][register-tags] a tag named `wrap` and parse the content into `this.tpls`. Here in `parse(tagToken, remainTokens)`:
- `tagToken` is current token `{%raw%}{% wrap %}{%endraw%}`, and
- `remainTokens` is an array of all tokens following `{%raw%}{% wrap %}{%endraw%}` until the end of this template file.
@@ -30,14 +30,11 @@ Firstly, [register][register-tags] a tag named `wrap` and parse the content into
Basically, what we need to do is take/`.shift()` enough tags from `remainTokens` until we get an `endwrap` token (the name can be arbitrary, but by convention it should be `endwrap`). And if there's no `endwrap` until the end of the template file, we need to throw a tag-not-closed `Error`.
```javascript
const { Tag } = require('liquidjs')
engine.registerTag('wrap', class WrapTag extends Tag {
tpls = []
constructor(tagToken, remainTokens, liquid) {
super(tagToken, remainTokens, liquid)
engine.registerTag('wrap', {
parse(tagToken, remainTokens) {
this.tpls = []
let closed = false
while (remainTokens.length) {
while(remainTokens.length) {
let token = remainTokens.shift()
// we got the end tag! stop taking tokens
if (token.name === 'endwrap') {
@@ -47,11 +44,11 @@ engine.registerTag('wrap', class WrapTag extends Tag {
// parse token into template
// parseToken() may consume more than 1 tokens
// e.g. {% if %}...{% endif %}
let tpl = liquid.parser.parseToken(token, remainTokens)
let tpl = this.liquid.parser.parseToken(token, remainTokens)
this.tpls.push(tpl)
}
if (!closed) throw new Error(`tag ${tagToken.getText()} not closed`)
}
},
* render(context, emitter) {
emitter.write("<div class='wrapper'>")
yield this.liquid.renderer.renderTemplates(this.tpls, context, emitter)
@@ -60,17 +57,16 @@ engine.registerTag('wrap', class WrapTag extends Tag {
})
```
`.renderTemplates()` can be async; we need `yield` to wait for it to complete. For more details on async in LiquidJS, see [Sync and Async][async]. Here's a JSFiddle version: <https://jsfiddle.net/por0zcn1/3/>
`.renderTemplates()` can be async; we need `yield` to wait for it to complete. For more details on async in LiquidJS, see [Sync and Async][async]. Other parts of the `render()` method are quite straightforward. Here's a JSFiddle version: <https://jsfiddle.net/por0zcn1/3/>
## Using ParseStream
For more complex tags such as [for][for] and [if][if], constructor parsing can get unwieldy. [ParseStream][ParseStream] offers an event-based API for this. The constructor below is equivalent to the example above:
When it comes to complex tags like [for][for] and [if][if], the `parse()` can be very complicated. There's a [ParseStream][ParseStream] utility to organize the `parse()` in event-based style. Following is a re-written `parse()` using `ParseStream` that does exactly the same as the example above.
```javascript
tpls = []
constructor(tagToken, remainTokens, liquid) {
super(tagToken, remainTokens, liquid)
liquid.parser.parseStream(remainTokens)
parse(tagToken, remainTokens) {
this.tpls = []
this.liquid.parser.parseStream(remainTokens)
.on('template', tpl => this.tpls.push(tpl))
// note that we cannot use arrow function because we need `this`
.on('tag:endwrap', function () { this.stop() })
@@ -107,18 +103,15 @@ As you've noticed, there's an additional `repeat.i` in the context of `repeat`.
Each time we enter a new *Context*, we need to push a new *Scope*. And when we finish rendering and exit the *Context*, we pop the *Scope* from the *Context*. As you can see in the following implementation:
```javascript
const { Tag } = require('liquidjs')
engine.registerTag('repeat', class RepeatTag extends Tag {
tpls = []
constructor(tagToken, remainTokens, liquid) {
super(tagToken, remainTokens, liquid)
liquid.parser.parseStream(remainTokens)
engine.registerTag('repeat', {
parse(tagToken, remainTokens) {
this.tpls = []
this.liquid.parser.parseStream(remainTokens)
.on('template', tpl => this.tpls.push(tpl))
.on('tag:endrepeat', function () { this.stop() })
.on('end', () => { throw new Error(`tag ${tagToken.getText()} not closed`) })
.start()
}
},
* render(context, emitter) {
const repeat = { i: 1 }
context.push({ repeat })
@@ -130,7 +123,7 @@ engine.registerTag('repeat', class RepeatTag extends Tag {
})
```
The constructor is the same as in the `wrap` tag; we repeat the content by calling `.renderTemplates(this.tpls)` twice during `render()`. Here's the JSFiddle: <https://jsfiddle.net/por0zcn1/2/>
The `parse()` is exactly the same as `wrap` tag, we repeat the content simply by calling `.renderTemplates(this.tpls)` twice during `render()`. Here's the JSFiddle: <https://jsfiddle.net/por0zcn1/2/>
{% note warn Use Push & Pop in Pairs %}
`context.push()` and `context.pop()` have to be used in pairs. Failing to `pop()` the *Scope* you pushed will leak the *Scope* to latter templates and may corrupt the *Context* stack.
+40 -33
View File
@@ -2,19 +2,21 @@
title: Security Model
---
LiquidJS provides DoS-oriented limits (`parseLimit`, `templateLimit`, `outputLengthLimit`, `maxDepth`) to reduce risk. This page summarizes those limits, [`ownPropertyOnly`][ownPropertyOnly], custom [`Drop`][drop] usage, and the security boundary to assume in production.
LiquidJS provides DoS-oriented limits (`parseLimit`, `renderLimit`, `memoryLimit`) to reduce risk. This page summarizes those limits, [`ownPropertyOnly`][ownPropertyOnly], custom [`Drop`][drop] usage, and the security boundary to assume in production.
## At a glance
## Security boundary
LiquidJS ships a thin cooperative DoS layer:
The built-in limits are cooperative safeguards, not strict runtime isolation.
- They do **not** equal process RSS/heap usage.
- They do **not** sandbox JavaScript execution.
- They should be combined with process/container limits and request timeouts for defense in depth.
## Limits at a glance
- [parseLimit][parseLimit]: limit total template size per `parse()` call.
- [templateLimit][templateLimit]: limit total tag/HTML/output nodes rendered per `render()` call.
- [outputLengthLimit][outputLengthLimit]: limit total output length per `render()` call.
- [maxDepth][maxDepth]: limit nesting depth of `{% render %}`, `{% include %}`, and `{% layout %}`.
- Strftime numeric pad widths in the `date` filter are capped at `1_000_000` (1M) per conversion.
These are cooperative safeguards, not runtime isolation—see [Production guidance](#production-guidance) below for host-level limits and online-service hardening.
- [renderLimit][renderLimit]: limit total render time per `render()` call.
- [memoryLimit][memoryLimit]: cooperatively limit memory-sensitive allocations counted by LiquidJS.
## Limit details
@@ -24,9 +26,9 @@ These are cooperative safeguards, not runtime isolation—see [Production guidan
A typical PC handles `1e8` (100M) characters without issues.
### templateLimit
### renderLimit
Restricting template size alone is insufficient because dynamic loops with large counts can occur during rendering. [templateLimit][templateLimit] mitigates this by limiting the number of tag, HTML literal, and output nodes rendered in each `render()` call.
Restricting template size alone is insufficient because dynamic loops with large counts can occur during rendering. [renderLimit][renderLimit] mitigates this by limiting the time consumed by each `render()` call.
```liquid
{%- for i in (1..10000000) -%}
@@ -34,47 +36,52 @@ Restricting template size alone is insufficient because dynamic loops with large
{%- endfor -%}
```
Each template node (the `for` tag, literal `order: `, output `{{i}}`, and so on) counts toward the limit. In the above example, a limit of `30000000` would be exceeded before the loop finishes.
Render time is checked on a per-template basis (before rendering each template). In the above example, there are 2 templates in the loop: `order: ` and `{{i}}`, render time will be checked 10000000x2 times.
`templateLimit` is checked before each node render, so compute-intensive filters/tags/user-defined functions between checks can still cause DoS.
`renderLimit` is not a hard CPU limiter. It is checked between template renders, so compute-intensive filters/tags/user-defined functions or deeply nested template execution between checks can still cause DoS.
### outputLengthLimit
### memoryLimit
[outputLengthLimit][outputLengthLimit] caps the cumulative length of output written during a `render()` call, including output from partials rendered via `{% render %}`.
`memoryLimit` only limits operations that LiquidJS explicitly counts.
### maxDepth
- Counted: memory-sensitive LiquidJS operations that call internal memory accounting.
- Not guaranteed counted: arbitrary user object behavior such as custom `toValue()`/`toString()` chains, or other host-side code that allocates outside LiquidJS accounting points.
[maxDepth][maxDepth] limits how deeply `{% render %}`, `{% include %}`, and `{% layout %}` can nest. Defaults to `128`. In sync rendering (`renderSync`), nested tags are driven by `toValueSync`, which recursively resumes each yielded generator on the call stack—deep nesting can overflow it, and `maxDepth` caps that depth. Async `render()` resumes the same tag generators via `toPromise`/`yield` without a deep synchronous call chain, so stack overflow is not a concern there (the limit still applies as a DoS guard).
In other words, `memoryLimit` limits what LiquidJS counts, not every byte your process may allocate.
The `memoryLimit` option was removed in v11; enforce memory limits at the host or process level instead.
Even with a small number of templates and iterations, memory usage can grow exponentially. In the following example, memory doubles with each iteration:
```liquid
{% assign array = "1,2,3" | split: "," %}
{% for i in (1..32) %}
{% assign array = array | concat: array %}
{% endfor %}
```
As [JavaScript uses GC to manage memory](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management), `memoryLimit` may not reflect the actual memory footprint.
## `ownPropertyOnly` and scope data
With [`ownPropertyOnly`][ownPropertyOnly] `true` (default), plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys), and reads of `__proto__`, `constructor`, and `prototype` are blocked (own and inherited) as a prototype-pollution defense. With `false`, inherited properties and those keys are allowed—sanitize untrusted scope data (e.g. with [bourne](https://www.npmjs.com/package/bourne)) before passing it as scope. LiquidJS also uses null-prototype objects for managed scope frames (e.g. `{% capture %}`, `{% assign %}`) so internal frames do not inherit from `Object.prototype`.
Not restricted: [`Drop`][drop] values, iteration via `Symbol.iterator`, `.size`/`.first`/`.last`, filters, and custom tags.
Use `true` for untrusted objects; add [`strictVariables`][strictVariables] if missing paths should error. Override per render via [`RenderOptions`][renderOwnPropertyOnly]. This is a read policy for scope data—not a sandbox for filters, tags, or your code.
With [`ownPropertyOnly`][ownPropertyOnly] `true`, plain scope objects only expose **own** properties (no inherited / `Object.prototype` keys). Default `false` follows normal JS property access. Use `true` for untrusted or polluted objects; add [`strictVariables`][strictVariables] if missing paths should error. Override per render via [`RenderOptions`][renderOwnPropertyOnly]. This is a read policy for scope data—not a sandbox for filters, tags, or your code.
## Custom `Drop` classes
[`Drop`][drop] values are not restricted the same way: LiquidJS still reads the prototype chain and may call [`liquidMethodMissing`][liquidMethodMissing]. **You** control what a drop exposes; narrow APIs and never feed unsafe data into drops unless the class is built for template access. `ownPropertyOnly` alone does not harden custom drops—audit them like any privileged code.
## Production guidance
## Online service guidance
LiquidJS does not sandbox template code—custom filters, tags, and scope helpers run as ordinary JavaScript with your process privileges. Built-in DoS limits are one layer; production deployments, especially online services that accept template input, need additional hardening:
If you run an online service, avoid rendering fully user-defined templates whenever possible.
- **Prefer curated templates** over fully user-defined Liquid when possible; if users need customization, offer a restricted subset rather than open template editing.
- Run each render in a **worker thread or child process** with a wall-clock timeout; **kill** the worker on expiry. Libraries such as [paralleljs][paralleljs] can help for heavy single-template work.
- Enforce **container/Kubernetes cgroup limits**, `ulimit`, or equivalent on the renderer process for memory and CPU.
- Apply **request rate limits** at the API or gateway layer.
- **`node:vm`, `isolated-vm`, and Jinja/Twig-style sandbox modes are not a security boundary**—template logic runs in the same JS runtime as your app, with your privileges.
- Prefer curated templates or a restricted template subset.
- If user-defined templates are required, isolate rendering (worker/process/container), enforce OS/container memory and CPU limits, and apply request rate limits.
- Treat `parseLimit`/`renderLimit`/`memoryLimit` as one layer in a broader DoS defense strategy.
For heavy single-template operations, process-level isolation is still recommended (for example with [paralleljs][paralleljs]).
[paralleljs]: https://www.npmjs.com/package/paralleljs
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
[templateLimit]: /api/interfaces/LiquidOptions.html#templateLimit
[outputLengthLimit]: /api/interfaces/LiquidOptions.html#outputLengthLimit
[maxDepth]: /api/interfaces/LiquidOptions.html#maxDepth
[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
[ownPropertyOnly]: /api/interfaces/LiquidOptions.html#ownPropertyOnly
[renderOwnPropertyOnly]: /api/interfaces/RenderOptions.html#ownPropertyOnly
[strictVariables]: /api/interfaces/LiquidOptions.html#strictVariables
+2 -1
View File
@@ -42,7 +42,8 @@
if (!/\/playground(?:\.html)?$/.test(location.pathname)) return;
updateVersion(liquidjs.version);
const engine = new liquidjs.Liquid({
templateLimit: 1e5
memoryLimit: 1e5,
renderLimit: 1e5
});
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)');
const editor = createEditor('editorEl', 'liquid');
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "liquidjs",
"version": "10.27.1",
"version": "10.29.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "liquidjs",
"version": "10.27.1",
"version": "10.29.0",
"license": "MIT",
"dependencies": {
"commander": "^10.0.0"
@@ -64,7 +64,7 @@
"typescript": "^4.5.3"
},
"engines": {
"node": ">=20"
"node": ">=16"
},
"funding": {
"type": "opencollective",
+42 -3
View File
@@ -1,6 +1,6 @@
{
"name": "liquidjs",
"version": "10.27.1",
"version": "10.29.0",
"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",
@@ -12,7 +12,7 @@
},
"types": "dist/index.d.ts",
"engines": {
"node": ">=20"
"node": ">=16"
},
"scripts": {
"lint": "eslint \"**/*.mjs\" \"**/*.ts\" .",
@@ -35,7 +35,7 @@
"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 .nojekyll public/",
"serve:docs": "cd docs && npm run start",
"dev:docs": "run-s prepare:docs serve:docs"
},
@@ -121,6 +121,45 @@
"dependencies": {
"commander": "^10.0.0"
},
"release": {
"branch": "master",
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
[
"@semantic-release/git",
{
"assets": [
"package.json",
"package-lock.json",
"CHANGELOG.md"
],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
[
"@semantic-release/github",
{
"assets": [
{
"path": "dist/*.umd.js",
"label": "liquid.js"
},
{
"path": "dist/*.min.js",
"label": "liquid.min.js"
},
{
"path": "dist/*.min.js.map",
"label": "liquid.min.js.map"
}
]
}
]
]
},
"publishConfig": {
"provenance": true
},
+6 -5
View File
@@ -23,7 +23,8 @@ const tsconfig = (target) => ({
compilerOptions: {
target,
module: 'ES2015',
rootDir: 'src'
rootDir: 'src',
downlevelIteration: true
}
}
})
@@ -88,7 +89,7 @@ const nodeEsm = {
plugins: [
versionInjection,
replace(esmRequire),
typescript(tsconfig('ES2020'))
typescript(tsconfig('es6'))
],
treeshake,
input
@@ -107,7 +108,7 @@ const browserEsm = {
replace(browserBase64),
replace(browserCrypto),
replace(browserStream),
typescript(tsconfig('ES2020'))
typescript(tsconfig('es6'))
],
treeshake,
input
@@ -127,7 +128,7 @@ const browserUmd = {
replace(browserBase64),
replace(browserCrypto),
replace(browserStream),
typescript(tsconfig('ES2020'))
typescript(tsconfig('es5'))
],
treeshake,
input
@@ -147,7 +148,7 @@ const browserMin = {
replace(browserBase64),
replace(browserCrypto),
replace(browserStream),
typescript(tsconfig('ES2020')),
typescript(tsconfig('es5')),
uglify()
],
treeshake,
+5
View File
@@ -2,6 +2,11 @@ import * as base64 from './base64-impl-browser'
import { JSDOM } from 'jsdom'
describe('base64-impl/browser', function () {
if (+(process.version.match(/^v(\d+)/) as RegExpMatchArray)[1] < 8) {
console.info('jsdom not supported, skipping base64-impl-browser...')
return
}
beforeEach(function () {
const dom = new JSDOM(``, {
url: 'https://example.com/',
+4
View File
@@ -3,6 +3,10 @@ import * as sinon from 'sinon'
import { JSDOM } from 'jsdom'
describe('fs/browser', function () {
if (+(process.version.match(/^v(\d+)/) as RegExpMatchArray)[1] < 8) {
console.info('jsdom not supported, skipping template-browser...')
return
}
beforeEach(function () {
const dom = new JSDOM(``, {
url: 'https://example.com/foo/bar/',
-42
View File
@@ -58,9 +58,6 @@ describe('Context', function () {
it('should return map size as size', async function () {
expect(ctx.get(['map', 'size'])).toEqual(1)
})
it('should return own size property', async function () {
expect(ctx.get(['zoo', 'size'])).toEqual(4)
})
it('should return undefined if not have a size', async function () {
expect(ctx.get(['one', 'size'])).toBeUndefined()
expect(ctx.get(['non-exist', 'size'])).toBeUndefined()
@@ -133,10 +130,6 @@ describe('Context', function () {
ctx = new Context({ foo: Object.create({ bar: 'BAR' }) }, { ownPropertyOnly: false } as any)
return expect(ctx.getSync(['foo', 'bar'])).toEqual('BAR')
})
it('should read inherited size when ownPropertyOnly=false', function () {
ctx = new Context({ foo: Object.create({ size: 99 }) }, { ownPropertyOnly: false } as any)
return expect(ctx.getSync(['foo', 'size'])).toEqual(99)
})
it('renderOptions.ownPropertyOnly should override options.ownPropertyOnly', function () {
ctx = new Context({ foo: Object.create({ bar: 'BAR' }) }, { ownPropertyOnly: false } as any, { ownPropertyOnly: true })
return expect(ctx.getSync(['foo', 'bar'])).toEqual(undefined)
@@ -205,36 +198,6 @@ describe('Context', function () {
delete (Array.prototype as any)[0]
}
})
it('should allow own blocked keys when ownPropertyOnly=false', function () {
ctx = new Context({
foo: {
...JSON.parse('{"__proto__": {"bar": "BAR"}}'),
constructor: { name: 'Custom' },
prototype: { x: 1 }
}
}, { ownPropertyOnly: false } as any)
expect(ctx.getSync(['foo', '__proto__', 'bar'])).toEqual('BAR')
expect(ctx.getSync(['foo', 'constructor', 'name'])).toEqual('Custom')
expect(ctx.getSync(['foo', 'prototype', 'x'])).toEqual(1)
})
it('should allow inherited properties when ownPropertyOnly=false', function () {
ctx = new Context({ foo: Object.create({ __proto__: { bar: 'BAR' }, constructor: { name: 'Evil' } }) }, { ownPropertyOnly: false } as any)
expect(ctx.getSync(['foo', '__proto__', '__proto__', 'bar'])).toEqual('BAR')
expect(ctx.getSync(['foo', 'constructor', 'name'])).toEqual('Evil')
})
it('should block own constructor when ownPropertyOnly=true', function () {
ctx.push({ foo: { constructor: { name: 'Evil' } } })
expect(ctx.getSync(['foo', 'constructor'])).toEqual(undefined)
})
it('should block own prototype when ownPropertyOnly=true', function () {
ctx.push({ foo: { prototype: { bar: 'BAR' } } })
expect(ctx.getSync(['foo', 'prototype'])).toEqual(undefined)
})
it('should block own top-level __proto__ variable when ownPropertyOnly=true', function () {
ctx = new Context(JSON.parse('{"__proto__": {"bar": "BAR"}, "bar": "BAR"}'))
expect(ctx.getSync(['__proto__'])).toEqual(undefined)
expect(ctx.getSync(['bar'])).toEqual('BAR')
})
})
describe('.getAll()', function () {
@@ -258,11 +221,6 @@ describe('Context', function () {
expect(ctx.getSync(['bar', 'foo'])).toEqual('foo')
expect(ctx.getSync(['bar', 'bar'])).toEqual(undefined)
})
it('should return pushed scope for in-place mutation', function () {
const scope = ctx.push({})
scope.item = 'ITEM'
expect(ctx.getSync(['item'])).toEqual('ITEM')
})
})
describe('.pop()', function () {
it('should pop scope', async function () {
+37 -37
View File
@@ -1,3 +1,4 @@
import { getPerformance } from '../util/performance'
import { Drop } from '../drop/drop'
import { __assign } from 'tslib'
import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options'
@@ -6,8 +7,6 @@ import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, isNu
type PropertyKey = string | number;
const BLOCKED_SCOPE_KEYS: ReadonlySet<PropertyKey> = new Set(['__proto__', 'constructor', 'prototype'])
export class Context {
/**
* insert a Context-level empty scope,
@@ -32,24 +31,27 @@ export class Context {
* The normalized liquid options object
*/
public opts: NormalizedFullOptions
/**
* Reference to the Liquid instance for filter resolution
*/
public liquid?: any
/**
* Throw when accessing undefined variable?
*/
public strictVariables: boolean;
public ownPropertyOnly: boolean;
public templateLimit: Limiter;
public outputLengthLimit: Limiter;
public depthLimit: Limiter;
public constructor (env: object = {}, opts: NormalizedFullOptions = defaultOptions, renderOptions: RenderOptions = {}, { templateLimit, outputLengthLimit, depthLimit }: { templateLimit?: Limiter, outputLengthLimit?: Limiter, depthLimit?: Limiter } = {}) {
public memoryLimit: Limiter;
public renderLimit: Limiter;
public constructor (env: object = {}, opts: NormalizedFullOptions = defaultOptions, renderOptions: RenderOptions = {}, { memoryLimit, renderLimit, liquid }: { memoryLimit?: Limiter, renderLimit?: Limiter, liquid?: any } = {}) {
this.sync = !!renderOptions.sync
this.opts = opts
this.globals = renderOptions.globals ?? opts.globals
this.environments = isObject(env) ? env : Object(env)
this.strictVariables = renderOptions.strictVariables ?? this.opts.strictVariables
this.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly
this.templateLimit = templateLimit ?? new Limiter('template', renderOptions.templateLimit ?? opts.templateLimit)
this.outputLengthLimit = outputLengthLimit ?? new Limiter('output length', renderOptions.outputLengthLimit ?? opts.outputLengthLimit)
this.depthLimit = depthLimit ?? new Limiter('template depth', opts.maxDepth)
this.memoryLimit = memoryLimit ?? new Limiter('memory alloc', renderOptions.memoryLimit ?? opts.memoryLimit)
this.renderLimit = renderLimit ?? new Limiter('template render', getPerformance().now() + (renderOptions.renderLimit ?? opts.renderLimit))
this.liquid = liquid
}
public getRegister<T> (key: string, defaultValue: T = undefined as T): T {
return (this.registers[key] = this.registers[key] || defaultValue)
@@ -96,10 +98,8 @@ export class Context {
}
return scope
}
public push (ctx: Scope): Scope {
const scope = createScope(ctx)
this.scopes.push(scope)
return scope
public push (ctx: object) {
return this.scopes.push(ctx)
}
public pop () {
return this.scopes.pop()
@@ -114,17 +114,17 @@ export class Context {
strictVariables: this.strictVariables,
ownPropertyOnly: this.ownPropertyOnly
}, {
templateLimit: this.templateLimit,
outputLengthLimit: this.outputLengthLimit,
depthLimit: this.depthLimit
renderLimit: this.renderLimit,
memoryLimit: this.memoryLimit,
liquid: this.liquid
})
}
private findScope (key: string | number) {
for (let i = this.scopes.length - 1; i >= 0; i--) {
const candidate = this.scopes[i]
if (this.ownPropertyOnly ? hasOwnProperty.call(candidate, key) : key in candidate) return candidate
if (key in candidate) return candidate
}
if (this.ownPropertyOnly ? hasOwnProperty.call(this.environments, key) : key in this.environments) return this.environments
if (key in this.environments) return this.environments
return this.globals
}
readProperty (obj: Scope, key: (PropertyKey | Drop)) {
@@ -135,30 +135,30 @@ export class Context {
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 this.readSize(obj)
else if (key === 'first') return this.readFirst(obj)
else if (key === 'last') return this.readLast(obj)
if (key === 'size') return readSize(obj)
else if (key === 'first') return readFirst(obj, this.ownPropertyOnly)
else if (key === 'last') return readLast(obj, this.ownPropertyOnly)
return value
}
private readFirst (obj: Scope) {
if (isArray(obj)) return readArrayElement(obj, 0, this.ownPropertyOnly)
return readJSProperty(obj, 'first', this.ownPropertyOnly)
}
private readLast (obj: Scope) {
if (isArray(obj)) return readArrayElement(obj, -1, this.ownPropertyOnly)
return readJSProperty(obj, 'last', this.ownPropertyOnly)
}
private readSize (obj: Scope) {
if (hasOwnProperty.call(obj, 'size')) return obj['size']
if (!this.ownPropertyOnly && obj['size'] !== undefined) return obj['size']
if (isArray(obj) || isString(obj)) return obj.length
if (obj instanceof Map || obj instanceof Set) return obj.size
if (typeof obj === 'object') return Object.keys(obj).length
}
}
export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) {
if (BLOCKED_SCOPE_KEYS.has(key) && ownPropertyOnly) return undefined
if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined
return obj[key]
}
function readFirst (obj: Scope, ownPropertyOnly: boolean) {
if (isArray(obj)) return readArrayElement(obj, 0, ownPropertyOnly)
return readJSProperty(obj, 'first', ownPropertyOnly)
}
function readLast (obj: Scope, ownPropertyOnly: boolean) {
if (isArray(obj)) return readArrayElement(obj, -1, ownPropertyOnly)
return readJSProperty(obj, 'last', ownPropertyOnly)
}
function readSize (obj: Scope) {
if (hasOwnProperty.call(obj, 'size') || obj['size'] !== undefined) return obj['size']
if (isArray(obj) || isString(obj)) return obj.length
if (typeof obj === 'object') return Object.keys(obj).length
}
+4 -3
View File
@@ -6,7 +6,8 @@ export interface ScopeObject extends Record<string | number | symbol, any> {
export type Scope = ScopeObject | Drop
export function createScope (from?: Scope): Scope {
if (from instanceof Drop) return from
return Object.assign(Object.create(null), from)
export function createScope (from?: ScopeObject): ScopeObject {
const scope = Object.create(null)
if (from) Object.assign(scope, from)
return scope
}
+1
View File
@@ -1,3 +1,4 @@
export * from './emitter'
export * from './simple-emitter'
export * from './streamed-emitter'
export * from './keeping-type-emitter'
+19
View File
@@ -0,0 +1,19 @@
import { stringify, toValue } from '../util'
import { Emitter } from './emitter'
export class KeepingTypeEmitter implements Emitter {
public buffer: any = '';
public write (html: any) {
html = toValue(html)
// This will only preserve the type if the value is isolated.
// I.E:
// {{ my-port }} -> 42
// {{ my-host }}:{{ my-port }} -> 'host:42'
if (typeof html !== 'string' && this.buffer === '') {
this.buffer = html
} else {
this.buffer = stringify(this.buffer) + stringify(html)
}
}
}
+2 -9
View File
@@ -1,17 +1,10 @@
import { stringify, Limiter } from '../util'
import { stringify } from '../util'
import { Emitter } from './emitter'
export class SimpleEmitter implements Emitter {
public buffer = '';
private outputLengthLimit?: Limiter
constructor (outputLengthLimit?: Limiter) {
this.outputLengthLimit = outputLengthLimit
}
public write (html: any) {
const str = stringify(html)
this.outputLengthLimit?.use(str.length)
this.buffer += str
this.buffer += stringify(html)
}
}
+2 -10
View File
@@ -1,20 +1,12 @@
import { stringify, Limiter } from '../util'
import { stringify } from '../util'
import { Emitter } from './emitter'
import { PassThrough } from 'stream'
export class StreamedEmitter implements Emitter {
public buffer = '';
public stream: NodeJS.ReadWriteStream = new PassThrough()
private outputLengthLimit?: Limiter
constructor (outputLengthLimit?: Limiter) {
this.outputLengthLimit = outputLengthLimit
}
public write (html: any) {
const str = stringify(html)
this.outputLengthLimit?.use(str.length)
this.stream.write(str)
this.stream.write(stringify(html))
}
public error (err: Error) {
this.stream.emit('error', err)
+20 -1
View File
@@ -8,6 +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)
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) {
@@ -18,12 +21,14 @@ export const first = argumentsToValue(function (this: FilterImpl, v: any) {
})
export const reverse = argumentsToValue(function (this: FilterImpl, v: any[]) {
const array = toArray(v)
this.context.memoryLimit.use(array.length)
return [...array].reverse()
})
function * sortBy<T> (this: FilterImpl, arr: T[], property: string | undefined, comparator: (a: unknown, b: unknown) => number): IterableIterator<unknown> {
const values: [T, unknown][] = []
const array = toArray(arr)
this.context.memoryLimit.use(array.length)
for (const item of array) {
values.push([
item,
@@ -41,11 +46,12 @@ export function * sort_natural<T> (this: FilterImpl, arr: T[], property?: string
return yield * sortBy.call(this, arr, property, caseInsensitiveCompare)
}
export const size = (v: string | any[]) => v?.length || 0
export const size = (v: string | any[]) => (v && v.length) || 0
export function * map (this: FilterImpl, arr: Scope[], property: string): IterableIterator<unknown> {
const results = []
const array = toArray(arr)
this.context.memoryLimit.use(array.length)
for (const item of array) {
results.push(yield this.context._getFromScope(item, stringify(property), false))
}
@@ -64,12 +70,14 @@ export function * sum (this: FilterImpl, arr: Scope[], property?: string): Itera
export function compact<T> (this: FilterImpl, arr: T[]) {
const array = toArray(arr)
this.context.memoryLimit.use(array.length)
return Array.prototype.filter.call(array, x => !isNil(toValue(x)))
}
export function concat<T1, T2> (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 Array.prototype.concat.call(lhs, rhs)
}
@@ -79,6 +87,7 @@ export function push<T> (this: FilterImpl, v: T[], arg: T): T[] {
export function unshift<T> (this: FilterImpl, v: T[], arg: T): T[] {
const array = toArray(v)
this.context.memoryLimit.use(array.length)
const clone = [...array]
clone.unshift(arg)
return clone
@@ -86,6 +95,7 @@ export function unshift<T> (this: FilterImpl, v: T[], arg: T): T[] {
export function pop<T> (this: FilterImpl, v: T[]): T[] {
const array = toArray(v)
this.context.memoryLimit.use(array.length)
const clone = [...array]
clone.pop()
return clone
@@ -93,6 +103,7 @@ export function pop<T> (this: FilterImpl, v: T[]): T[] {
export function shift<T> (this: FilterImpl, v: T[]): T[] {
const array = toArray(v)
this.context.memoryLimit.use(array.length)
const clone = [...array]
clone.shift()
return clone
@@ -103,6 +114,8 @@ export function slice<T> (this: FilterImpl, v: T[] | string, begin: number, leng
if (isNil(v)) return []
if (!isArray(v)) v = stringify(v)
begin = begin < 0 ? v.length + begin : begin
if (begin < 0 || length < 0) return isArray(v) ? [] : ''
this.context.memoryLimit.use(length)
return isArray(v)
? Array.prototype.slice.call(v, begin, begin + length)
: String.prototype.slice.call(v, begin, begin + length)
@@ -121,6 +134,7 @@ function expectedMatcher (this: FilterImpl, expected: any): (v: any) => boolean
function * filter<T extends object> (this: FilterImpl, include: boolean, arr: T[], property: string, expected: any): IterableIterator<unknown> {
const values: unknown[] = []
arr = toArray(arr)
this.context.memoryLimit.use(arr.length)
const token = new Tokenizer(stringify(property)).readScopeValue()
for (const item of arr) {
values.push(yield evalToken(token, this.context.spawn(item)))
@@ -133,6 +147,7 @@ function * filter_exp<T extends object> (this: FilterImpl, include: boolean, arr
const filtered: unknown[] = []
const keyTemplate = new Value(stringify(exp), this.liquid)
const array = toArray(arr)
this.context.memoryLimit.use(array.length)
for (const item of array) {
this.context.push({ [itemName]: item })
const value = yield keyTemplate.value(this.context)
@@ -162,6 +177,7 @@ export function * group_by<T extends object> (this: FilterImpl, arr: T[], proper
const map = new Map()
arr = toEnumerable(arr)
const token = new Tokenizer(stringify(property)).readScopeValue()
this.context.memoryLimit.use(arr.length)
for (const item of arr) {
const key = yield evalToken(token, this.context.spawn(item))
if (!map.has(key)) map.set(key, [])
@@ -174,6 +190,7 @@ export function * group_by_exp<T extends object> (this: FilterImpl, arr: T[], it
const map = new Map()
const keyTemplate = new Value(stringify(exp), this.liquid)
arr = toEnumerable(arr)
this.context.memoryLimit.use(arr.length)
for (const item of arr) {
this.context.push({ [itemName]: item })
const key = yield keyTemplate.value(this.context)
@@ -237,6 +254,7 @@ export function * find_exp<T extends object> (this: FilterImpl, arr: T[], itemNa
export function uniq<T> (this: FilterImpl, arr: T[]): T[] {
arr = toArray(arr)
this.context.memoryLimit.use(arr.length)
return [...new Set(arr)]
}
@@ -244,6 +262,7 @@ export function sample<T> (this: FilterImpl, v: T[] | string, count = 1): T | st
v = toValue(v)
if (isNil(v)) return []
if (!isArray(v)) v = stringify(v)
this.context.memoryLimit.use(v.length)
const shuffled = [...v].sort(() => Math.random() - 0.5)
if (count === 1) return shuffled[0]
return shuffled.slice(0, count)
+3
View File
@@ -10,13 +10,16 @@ import { base64Encode, base64Decode } from './base64-impl'
export function base64_encode (this: FilterImpl, value: string | Buffer): string {
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) {
this.context.memoryLimit.use(value.byteLength)
return value.toString('base64')
}
const str = stringify(value)
this.context.memoryLimit.use(str.length)
return base64Encode(str)
}
export function base64_decode (this: FilterImpl, value: string): string {
const str = stringify(value)
this.context.memoryLimit.use(str.length)
return base64Decode(str)
}
+2
View File
@@ -10,11 +10,13 @@ import { sha256 as sha256Impl, hmacSha256 as hmacSha256Impl } from './crypto-imp
export function sha256 (this: FilterImpl, value: unknown): string | Promise<string> {
const str = stringify(value)
this.context.memoryLimit.use(str.length)
return sha256Impl(str)
}
export function hmac_sha256 (this: FilterImpl, value: unknown, key: unknown): string | Promise<string> {
const str = stringify(value)
const keyStr = stringify(key)
this.context.memoryLimit.use(str.length + keyStr.length)
return hmacSha256Impl(str, keyStr)
}
+8 -4
View File
@@ -3,11 +3,14 @@ import { FilterImpl } from '../template'
import { NormalizedFullOptions } from '../liquid-options'
export function date (this: FilterImpl, v: string | Date, format?: string, timezoneOffset?: number | string) {
const size = ((v as string)?.length ?? 0) + ((timezoneOffset as string)?.length ?? 0)
this.context.memoryLimit.use(size)
const date = parseDate(v, this.context.opts, timezoneOffset)
if (!date) return v
format = toValue(format)
format = isNil(format) ? this.context.opts.dateFormat : stringify(format)
return strftime(date, format)
this.context.memoryLimit.use(format.length)
return strftime(date, format, this.context.memoryLimit)
}
export function date_to_xmlschema (this: FilterImpl, v: string | Date) {
@@ -29,13 +32,14 @@ export function date_to_long_string (this: FilterImpl, v: string | Date, type?:
function stringify_date (this: FilterImpl, v: string | Date, month_type: string, type?: string, style?: string) {
const date = parseDate(v, this.context.opts)
if (!date) return v
const ml = this.context.memoryLimit
if (type === 'ordinal') {
const d = date.getDate()
return style === 'US'
? strftime(date, `${month_type} ${d}%q, %Y`)
: strftime(date, `${d}%q ${month_type} %Y`)
? strftime(date, `${month_type} ${d}%q, %Y`, ml)
: strftime(date, `${d}%q ${month_type} %Y`, ml)
}
return strftime(date, `%d ${month_type} %Y`)
return strftime(date, `%d ${month_type} %Y`, ml)
}
function parseDate (v: string | Date, opts: NormalizedFullOptions, timezoneOffset?: number | string): LiquidDate | undefined {
+4
View File
@@ -18,6 +18,7 @@ const unescapeMap: Record<string, string> = {
export function escape (this: FilterImpl, str: string) {
str = stringify(str)
this.context.memoryLimit.use(str.length)
return str.replace(/&|<|>|"|'/g, m => escapeMap[m])
}
@@ -27,6 +28,7 @@ export function xml_escape (this: FilterImpl, str: string) {
function unescape (this: FilterImpl, str: string) {
str = stringify(str)
this.context.memoryLimit.use(str.length)
return str.replace(/&(amp|lt|gt|#34|#39);/g, m => unescapeMap[m])
}
@@ -36,6 +38,7 @@ export function escape_once (this: FilterImpl, str: string) {
export function newline_to_br (this: FilterImpl, v: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return str.replace(/\r?\n/gm, '<br />\n')
}
@@ -43,6 +46,7 @@ export function newline_to_br (this: FilterImpl, v: string) {
// equivalent is O(n^2) in V8 on unclosed openers.
export function strip_html (this: FilterImpl, v: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
const blocks = new Map([['<script', '</script>'], ['<style', '</style>'], ['<!--', '-->'], ['<', '>']])
let out = ''
let i = 0
+21 -1
View File
@@ -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<T1 extends boolean, T2> (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
@@ -10,21 +22,29 @@ function defaultFilter<T1 extends boolean, T2> (this: FilterImpl, value: T1, def
}
function json (this: FilterImpl, value: any, space = 0) {
return JSON.stringify(value, undefined, space)
const memoryLimit = this.context.memoryLimit
return JSON.stringify(value, (_key, val) => {
chargeJsonReplacerValue(memoryLimit, val)
return val
}, space)
}
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) {
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)) {
memoryLimit.use('[Circular]'.length)
return '[Circular]'
}
ancestors.push(value)
chargeJsonReplacerValue(memoryLimit, value)
return value
}, space)
}
+33
View File
@@ -22,6 +22,7 @@ export function append (this: FilterImpl, v: string, arg: string) {
assert(arguments.length === 2, 'append expect 2 arguments')
const lhs = stringify(v)
const rhs = stringify(arg)
this.context.memoryLimit.use(lhs.length + rhs.length)
return lhs + rhs
}
@@ -29,13 +30,16 @@ export function prepend (this: FilterImpl, v: string, arg: string) {
assert(arguments.length === 2, 'prepend expect 2 arguments')
const lhs = stringify(v)
const rhs = stringify(arg)
this.context.memoryLimit.use(lhs.length + rhs.length)
return rhs + lhs
}
export function lstrip (this: FilterImpl, v: string, chars?: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
if (chars) {
chars = stringify(chars)
this.context.memoryLimit.use(chars.length)
for (let i = 0, set = new Set(chars); i < str.length; i++) {
if (!set.has(str[i])) return str.slice(i)
}
@@ -46,29 +50,34 @@ export function lstrip (this: FilterImpl, v: string, chars?: string) {
export function downcase (this: FilterImpl, v: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return str.toLowerCase()
}
export function upcase (this: FilterImpl, v: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return stringify(str).toUpperCase()
}
export function remove (this: FilterImpl, v: string, arg: string) {
const str = stringify(v)
arg = stringify(arg)
this.context.memoryLimit.use(str.length + arg.length)
return str.split(arg).join('')
}
export function remove_first (this: FilterImpl, v: string, l: string) {
const str = stringify(v)
l = stringify(l)
this.context.memoryLimit.use(str.length + l.length)
return str.replace(l, '')
}
export function remove_last (this: FilterImpl, v: string, l: string) {
const str = stringify(v)
const pattern = stringify(l)
this.context.memoryLimit.use(str.length + pattern.length)
const index = str.lastIndexOf(pattern)
if (index === -1) return str
return str.substring(0, index) + str.substring(index + pattern.length)
@@ -76,8 +85,10 @@ export function remove_last (this: FilterImpl, v: string, l: string) {
export function rstrip (this: FilterImpl, str: string, chars?: string) {
str = stringify(str)
this.context.memoryLimit.use(str.length)
if (chars) {
chars = stringify(chars)
this.context.memoryLimit.use(chars.length)
for (let i = str.length - 1, set = new Set(chars); i >= 0; i--) {
if (!set.has(str[i])) return str.slice(0, i + 1)
}
@@ -88,6 +99,7 @@ export function rstrip (this: FilterImpl, str: string, chars?: string) {
export function split (this: FilterImpl, v: string, arg: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
const arr = str.split(stringify(arg))
// align to ruby split, which is the behavior of shopify/liquid
// see: https://ruby-doc.org/core-2.4.0/String.html#method-i-split
@@ -97,8 +109,10 @@ export function split (this: FilterImpl, v: string, arg: string) {
export function strip (this: FilterImpl, v: string, chars?: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
if (chars) {
const set = new Set(stringify(chars))
this.context.memoryLimit.use(set.size)
let i = 0
let j = str.length - 1
while (set.has(str[i])) i++
@@ -110,11 +124,19 @@ export function strip (this: FilterImpl, v: string, chars?: string) {
export function strip_newlines (this: FilterImpl, v: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return str.replace(/\r?\n/gm, '')
}
export function squish (this: FilterImpl, v: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return str.replace(/\s+/g, ' ').trim()
}
export function capitalize (this: FilterImpl, str: string) {
str = stringify(str)
this.context.memoryLimit.use(str.length)
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}
@@ -123,6 +145,8 @@ export function replace (this: FilterImpl, v: string, pattern: string, replaceme
pattern = stringify(pattern)
replacement = stringify(replacement)
const parts = str.split(pattern)
const outputSize = str.length + (parts.length - 1) * (replacement.length - pattern.length)
this.context.memoryLimit.use(outputSize)
return parts.join(replacement)
}
@@ -130,6 +154,7 @@ export function replace_first (this: FilterImpl, v: string, arg1: string, arg2:
const str = stringify(v)
arg1 = stringify(arg1)
arg2 = stringify(arg2)
this.context.memoryLimit.use(str.length + arg1.length + arg2.length)
return str.replace(arg1, () => arg2)
}
@@ -137,6 +162,7 @@ export function replace_last (this: FilterImpl, v: string, arg1: string, arg2: s
const str = stringify(v)
const pattern = stringify(arg1)
const replacement = stringify(arg2)
this.context.memoryLimit.use(str.length + pattern.length + replacement.length)
const index = str.lastIndexOf(pattern)
if (index === -1) return str
return str.substring(0, index) + replacement + str.substring(index + pattern.length)
@@ -145,6 +171,7 @@ export function replace_last (this: FilterImpl, v: string, arg1: string, arg2: s
export function truncate (this: FilterImpl, v: string, l = 50, o = '...') {
const str = stringify(v)
o = stringify(o)
this.context.memoryLimit.use(str.length + o.length)
if (str.length <= l) return v
return str.substring(0, l - o.length) + o
}
@@ -152,6 +179,7 @@ export function truncate (this: FilterImpl, v: string, l = 50, o = '...') {
export function truncatewords (this: FilterImpl, v: string, words = 15, o = '...') {
const str = stringify(v)
o = stringify(o)
this.context.memoryLimit.use(str.length + o.length)
const arr = str.split(/\s+/)
if (words <= 0) words = 1
let ret = arr.slice(0, words).join(' ')
@@ -161,11 +189,13 @@ export function truncatewords (this: FilterImpl, v: string, words = 15, o = '...
export function normalize_whitespace (this: FilterImpl, v: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return str.replace(/\s+/g, ' ')
}
export function number_of_words (this: FilterImpl, input: string, mode?: 'cjk' | 'auto') {
const str = stringify(input)
this.context.memoryLimit.use(str.length)
input = str.trim()
if (!input) return 0
switch (mode) {
@@ -185,6 +215,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)
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 ''
+1 -1
View File
@@ -1,6 +1,6 @@
import { stringify } from '../util/underscore'
export const url_decode = (x: string) => decodeURIComponent(stringify(x)).replace(/\+/g, ' ')
export const url_decode = (x: string) => decodeURIComponent(stringify(x).replace(/\+/g, ' '))
export const url_encode = (x: string) => encodeURIComponent(stringify(x)).replace(/%20/g, '+')
export const cgi_escape = (x: string) => encodeURIComponent(stringify(x))
.replace(/%20/g, '+')
+1 -1
View File
@@ -11,7 +11,7 @@ export { Context, Scope } from './context'
export { Value, Hash, Template, FilterImplOptions, Tag, Filter, Output, Variable, VariableLocation, VariableSegments, Variables, StaticAnalysis, StaticAnalysisOptions, analyze, analyzeSync, Arguments, PartialScope } from './template'
export type { TagRenderReturn } from './template'
export { Token, TopLevelToken, TagToken, ValueToken } from './tokens'
export type { RangeToken, LiteralToken, QuotedToken, PropertyAccessToken, NumberToken } from './tokens'
export type { RangeToken, LiteralToken, QuotedToken, PropertyAccessToken, NumberToken, FilteredValueToken } from './tokens'
export { TokenKind, Tokenizer, ParseStream, Parser } from './parser'
export { filters } from './filters'
export * from './tags'
+29 -20
View File
@@ -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;
/** Limit template property reads on plain scope objects to own properties. Defaults to `true`. See https://liquidjs.com/tutorials/security-model.html */
/**
* 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;
@@ -76,20 +79,22 @@ export interface LiquidOptions {
templates?: {[key: string]: string};
/** the global scope passed down to all partial and layout templates, i.e. templates included by `include`, `layout` and `render` tags. */
globals?: object;
/** Whether or not to keep value type when writing the Output, not working for streamed rendering. Defaults to `false`. */
keepOutputType?: boolean;
/** Default escape filter applied to output values, when set, you'll have to add `| raw` for values don't need to be escaped. Defaults to `undefined`. */
outputEscape?: OutputEscapeOption;
/** An object of operators for conditional statements. Defaults to the regular Liquid operators. */
operators?: Operators;
/** Respect parameter order when using filters like "for ... reversed limit", Defaults to `false`. */
orderedFilterParameters?: boolean;
/** Allow parenthesized expressions as operands in conditions and loops, e.g. `{% if (foo | upcase) == "BAR" %}`. This is a non-standard extension to Liquid. Defaults to `false`. */
groupedExpressions?: boolean;
/** For DoS handling, limit total length of templates parsed in one `parse()` call. A typical PC can handle 1e8 (100M) characters without issues. */
parseLimit?: number;
/** For DoS handling, limit total renders of tag/HTML/output in one `render()` call. */
templateLimit?: number;
/** For DoS handling, limit total output length in one `render()` call. */
outputLengthLimit?: number;
/** For DoS handling, limit nesting depth of `{% render %}`, `{% include %}`, and `{% layout %}` tags. Defaults to `128`. */
maxDepth?: number;
/** For DoS handling, limit total time (in ms) for each `render()` call. */
renderLimit?: number;
/** For DoS handling, limit new objects creation, including array concat/join/strftime, etc. A typical PC can handle 1e9 (1G) memory without issue. */
memoryLimit?: number;
}
export interface RenderOptions {
@@ -109,10 +114,12 @@ export interface RenderOptions {
* Same as `ownPropertyOnly` on LiquidOptions, but only for current render() call
*/
ownPropertyOnly?: boolean;
/** For DoS handling, limit total renders of tag/HTML/output in one `render()` call. */
/** For DoS handling, limit total renders of tag/HTML/output in one `render()` call. A typical PC can handle 1e5 renders of typical templates per second. */
templateLimit?: number;
/** For DoS handling, limit total output length in one `render()` call. */
outputLengthLimit?: number;
/** For DoS handling, limit total time (in ms) for each `render()` call. */
renderLimit?: number;
/** For DoS handling, limit new objects creation, including array concat/join/strftime, etc. A typical PC can handle 1e9 (1G) memory without issue.. */
memoryLimit?: number;
}
export interface RenderFileOptions extends RenderOptions {
@@ -155,11 +162,12 @@ export interface NormalizedFullOptions extends NormalizedOptions {
preserveTimezones: boolean;
greedy: boolean;
globals: object;
keepOutputType: boolean;
operators: Operators;
groupedExpressions: boolean;
parseLimit: number;
templateLimit: number;
outputLengthLimit: number;
maxDepth: number;
renderLimit: number;
memoryLimit: number;
}
export const defaultOptions: NormalizedFullOptions = {
@@ -191,19 +199,20 @@ export const defaultOptions: NormalizedFullOptions = {
ownPropertyOnly: true,
lenientIf: false,
globals: {},
keepOutputType: false,
operators: defaultOperators,
groupedExpressions: false,
memoryLimit: Infinity,
parseLimit: Infinity,
templateLimit: Infinity,
outputLengthLimit: Infinity,
maxDepth: 128
renderLimit: Infinity
}
export function normalize (options: LiquidOptions): NormalizedFullOptions {
if ('root' in options) {
if (!('partials' in options)) options.partials = options.root
if (!('layouts' in options)) options.layouts = options.root
if (options.hasOwnProperty('root')) {
if (!options.hasOwnProperty('partials')) options.partials = options.root
if (!options.hasOwnProperty('layouts')) options.layouts = options.root
}
if ('cache' in options) {
if (options.hasOwnProperty('cache')) {
let cache: LiquidCache | undefined
if (typeof options.cache === 'number') cache = options.cache > 0 ? new LRU(options.cache) : undefined
else if (typeof options.cache === 'object') cache = options.cache
+10 -7
View File
@@ -1,6 +1,6 @@
import { Context } from './context'
import { toPromise, toValueSync, forOwn, isString, strictUniq } from './util'
import { TagClass, FilterImplOptions, Template, Value, StaticAnalysisOptions, StaticAnalysis, analyze, analyzeSync, SegmentArray } from './template'
import { toPromise, toValueSync, isFunction, forOwn, isString, strictUniq } from './util'
import { TagClass, createTagClass, TagImplOptions, FilterImplOptions, Template, Value, StaticAnalysisOptions, StaticAnalysis, analyze, analyzeSync, SegmentArray } from './template'
import { LookupType } from './fs/loader'
import { Render } from './render'
import { Parser } from './parser'
@@ -31,7 +31,7 @@ export class Liquid {
}
public _render (tpl: Template[], scope: Context | object | undefined, renderOptions: RenderOptions): IterableIterator<any> {
const ctx = scope instanceof Context ? scope : new Context(scope, this.options, renderOptions)
const ctx = scope instanceof Context ? scope : new Context(scope, this.options, renderOptions, { liquid: this })
return this.renderer.renderTemplates(tpl, ctx)
}
public async render (tpl: Template[], scope?: object, renderOptions?: RenderOptions): Promise<any> {
@@ -41,7 +41,7 @@ export class Liquid {
return toValueSync(this._render(tpl, scope, { ...renderOptions, sync: true }))
}
public renderToNodeStream (tpl: Template[], scope?: object, renderOptions: RenderOptions = {}): NodeJS.ReadableStream {
const ctx = new Context(scope, this.options, renderOptions)
const ctx = new Context(scope, this.options, renderOptions, { liquid: this })
return this.renderer.renderTemplatesToNodeStream(tpl, ctx)
}
@@ -88,7 +88,7 @@ export class Liquid {
public _evalValue (str: string, scope?: object | Context): IterableIterator<any> {
const value = new Value(str, this)
const ctx = scope instanceof Context ? scope : new Context(scope, this.options)
const ctx = scope instanceof Context ? scope : new Context(scope, this.options, {}, { liquid: this })
return value.value(ctx)
}
public async evalValue (str: string, scope?: object | Context): Promise<any> {
@@ -101,8 +101,11 @@ export class Liquid {
public registerFilter (name: string, filter: FilterImplOptions) {
this.filters[name] = filter
}
public registerTag (name: string, tag: TagClass) {
this.tags[name] = tag
public unregisterFilter (name: string) {
delete this.filters[name]
}
public registerTag (name: string, tag: TagClass | TagImplOptions) {
this.tags[name] = isFunction(tag) ? tag : createTagClass(tag)
}
public plugin (plugin: (this: Liquid, L: typeof Liquid) => void) {
return plugin.call(this, Liquid)
+1 -1
View File
@@ -33,7 +33,7 @@ export class Parser {
public parse (html: string, filepath?: string): Template[] {
html = String(html)
this.parseLimit.use(html.length)
const tokenizer = new Tokenizer(html, this.liquid.options.operators, filepath)
const tokenizer = new Tokenizer(html, this.liquid.options.operators, filepath, undefined, this.liquid.options.groupedExpressions)
const tokens = tokenizer.readTopLevelTokens(this.liquid.options)
return this.parseTokens(tokens)
}
+1
View File
@@ -12,5 +12,6 @@ export enum TokenKind {
Quoted = 1024,
Operator = 2048,
FilteredValue = 4096,
GroupedExpression = 8192,
Delimited = Tag | Output
}
+100 -9
View File
@@ -1,4 +1,4 @@
import { LiquidTagToken, HTMLToken, QuotedToken, OutputToken, TagToken, OperatorToken, RangeToken, PropertyAccessToken, NumberToken, IdentifierToken } from '../tokens'
import { LiquidTagToken, HTMLToken, QuotedToken, OutputToken, TagToken, OperatorToken, RangeToken, PropertyAccessToken, NumberToken, IdentifierToken, FilteredValueToken } from '../tokens'
import { Tokenizer } from './tokenizer'
import { defaultOperators } from '../render/operator'
import { createTrie } from '../util/operator-trie'
@@ -229,24 +229,115 @@ describe('Tokenizer', function () {
})
describe('#readRange()', () => {
it('should read `(1..3)`', () => {
const range = new Tokenizer('(1..3)').readRange()
const range = new Tokenizer('(1..3)').readGroupOrRange()
expect(range).toBeDefined()
expect(range).toBeInstanceOf(RangeToken)
expect(range!.getText()).toEqual('(1..3)')
const { lhs, rhs } = range!
expect(lhs).toBeInstanceOf(NumberToken)
expect(lhs.getText()).toBe('1')
expect(rhs).toBeInstanceOf(NumberToken)
expect(rhs.getText()).toBe('3')
expect((range as RangeToken).lhs).toBeInstanceOf(NumberToken)
expect((range as RangeToken).lhs.getText()).toBe('1')
expect((range as RangeToken).rhs).toBeInstanceOf(NumberToken)
expect((range as RangeToken).rhs.getText()).toBe('3')
})
it('should throw for `(..3)`', () => {
expect(() => new Tokenizer('(..3)').readRange()).toThrow('unexpected token "..3)", value expected')
expect(() => new Tokenizer('(..3)').readGroupOrRange()).toThrow('unexpected token "..3)", value expected')
})
it('should read `(a.b..c["..d"])`', () => {
const range = new Tokenizer('(a.b..c["..d"])').readRange()
const range = new Tokenizer('(a.b..c["..d"])').readGroupOrRange()
expect(range).toBeDefined()
expect(range).toBeInstanceOf(RangeToken)
expect(range!.getText()).toEqual('(a.b..c["..d"])')
})
})
describe('#readGroupedExpression()', () => {
function createGrouped (input: string): Tokenizer {
const t = new Tokenizer(input, defaultOperators)
t.groupedExpressions = true
return t
}
it('should read `(foo | upcase)` as FilteredValueToken', () => {
const token = createGrouped('(foo | upcase)').readValue()
expect(token).toBeInstanceOf(FilteredValueToken)
const grouped = token as FilteredValueToken
expect(grouped.getText()).toBe('(foo | upcase)')
expect(grouped.initial.postfix).toHaveLength(1)
expect(grouped.filters).toHaveLength(1)
expect(grouped.filters[0].name).toBe('upcase')
})
it('should read `(foo | append: "!")` with filter argument', () => {
const token = createGrouped('(foo | append: "!")').readValue()
expect(token).toBeInstanceOf(FilteredValueToken)
const grouped = token as FilteredValueToken
expect(grouped.filters).toHaveLength(1)
expect(grouped.filters[0].name).toBe('append')
expect(grouped.filters[0].args).toHaveLength(1)
})
it('should read nested `((foo | append: "!") | upcase)`', () => {
const token = createGrouped('((foo | append: "!") | upcase)').readValue()
expect(token).toBeInstanceOf(FilteredValueToken)
const grouped = token as FilteredValueToken
expect(grouped.filters).toHaveLength(1)
expect(grouped.filters[0].name).toBe('upcase')
expect(grouped.initial.postfix).toHaveLength(1)
expect(grouped.initial.postfix[0]).toBeInstanceOf(FilteredValueToken)
})
it('should parse `(a | upcase) == "BAR"` as expression', () => {
const exp = [...createGrouped('(a | upcase) == "BAR"').readExpressionTokens()]
expect(exp).toHaveLength(3)
expect(exp[0]).toBeInstanceOf(FilteredValueToken)
expect(exp[1]).toBeInstanceOf(OperatorToken)
expect(exp[1].getText()).toBe('==')
expect(exp[2]).toBeInstanceOf(QuotedToken)
})
it('should read `((a | upcase) > 3)` as outer FilteredValueToken with comparison inside parens', () => {
const token = createGrouped('((a | upcase) > 3)').readValue()
expect(token).toBeInstanceOf(FilteredValueToken)
const outer = token as FilteredValueToken
expect(outer.filters).toHaveLength(0)
expect(outer.getText()).toBe('((a | upcase) > 3)')
const [first, second, third] = outer.initial.postfix
expect(first).toBeInstanceOf(FilteredValueToken)
expect(second).toBeInstanceOf(NumberToken)
expect(third).toBeInstanceOf(OperatorToken)
expect((first as FilteredValueToken).filters[0].name).toBe('upcase')
})
it('should read `(1 < 3)` as grouped comparison with no filters', () => {
const token = createGrouped('(1 < 3)').readValue() as FilteredValueToken
expect(token.filters).toHaveLength(0)
expect(token.initial.postfix).toHaveLength(3)
expect(token.initial.postfix[0]).toBeInstanceOf(NumberToken)
expect(token.initial.postfix[1]).toBeInstanceOf(NumberToken)
expect((token.initial.postfix[2] as OperatorToken).operator).toBe('<')
})
it('should read redundant parens `(x)` as FilteredValueToken', () => {
const token = createGrouped('(x)').readValue() as FilteredValueToken
expect(token.filters).toHaveLength(0)
expect(token.initial.postfix).toHaveLength(1)
})
it('should read expression plus filters inside parens `(a == b | default: "x")`', () => {
const token = createGrouped('(a == b | default: "x")').readValue() as FilteredValueToken
expect(token.filters).toHaveLength(1)
expect(token.filters[0].name).toBe('default')
expect(token.initial.postfix.map((t) => t.getText()).join(' ')).toMatch(/a.*b.*==/)
})
it('should parse `((a | upcase) > 3) and (1 < 3)` as three expression tokens', () => {
const exp = [...createGrouped('((a | upcase) > 3) and (1 < 3)').readExpressionTokens()]
expect(exp).toHaveLength(3)
expect(exp[0]).toBeInstanceOf(FilteredValueToken)
expect(exp[1]).toBeInstanceOf(OperatorToken)
expect(exp[1].getText()).toBe('and')
expect(exp[2]).toBeInstanceOf(FilteredValueToken)
})
it('should still parse `(1..3)` as RangeToken', () => {
const token = createGrouped('(1..3)').readValue()
expect(token).toBeInstanceOf(RangeToken)
})
it('should throw for unclosed parens', () => {
expect(() => createGrouped('(foo | upcase').readValue()).toThrow('unbalanced parentheses')
})
it('should fall back to readRange when flag is off', () => {
expect(() => new Tokenizer('(foo | upcase)', defaultOperators).readValue()).toThrow('invalid range syntax')
})
})
describe('#readFilter()', () => {
it('should read a simple filter', function () {
const tokenizer = new Tokenizer('| plus')
+32 -11
View File
@@ -9,6 +9,7 @@ import { whiteSpaceCtrl } from './whitespace-ctrl'
export class Tokenizer {
p: number
N: number
public groupedExpressions: boolean
private rawBeginAt = -1
private opTrie: Trie<OperatorHandler>
private literalTrie: Trie<LiteralValue>
@@ -17,12 +18,14 @@ export class Tokenizer {
public input: string,
operators: Operators = defaultOptions.operators,
public file?: string,
range?: [number, number]
range?: [number, number],
groupedExpressions = false
) {
this.p = range ? range[0] : 0
this.N = range ? range[1] : input.length
this.opTrie = createTrie(operators)
this.literalTrie = createTrie(literalValues)
this.groupedExpressions = groupedExpressions
}
readExpression () {
@@ -80,6 +83,7 @@ export class Tokenizer {
readFilter (): FilterToken | null {
this.skipBlank()
if (this.end()) return null
if (this.peek() === ')') return null
this.assert(this.read() === '|', `expected "|" before filter`)
const name = this.readIdentifier()
if (!name.size()) {
@@ -94,9 +98,9 @@ export class Tokenizer {
const arg = this.readFilterArg()
arg && args.push(arg)
this.skipBlank()
this.assert(this.end() || this.peek() === ',' || this.peek() === '|', () => `unexpected character ${this.snapshot()}`)
this.assert(this.end() || this.peek() === ',' || this.peek() === '|' || this.peek() === ')', () => `unexpected character ${this.snapshot()}`)
} while (this.peek() === ',')
} else if (this.peek() === '|' || this.end()) {
} else if (this.peek() === '|' || this.peek() === ')' || this.end()) {
// do nothing
} else {
throw this.error('expected ":" after filter name')
@@ -307,10 +311,13 @@ export class Tokenizer {
return -1
}
readValue (): ValueToken | undefined {
readValue (): ValueToken | FilteredValueToken | undefined {
this.skipBlank()
const begin = this.p
const variable = this.readLiteral() || this.readQuoted() || this.readRange() || this.readNumber()
let variable: ValueToken | FilteredValueToken | undefined = this.readLiteral() || this.readQuoted() || this.readNumber()
if (!variable && this.peek() === '(') {
variable = this.readGroupOrRange()
}
const props = this.readProperties(!variable)
if (!props.length) return variable
return new PropertyAccessToken(variable, props, this.input, begin, this.p)
@@ -385,18 +392,32 @@ export class Tokenizer {
return literal
}
readRange (): RangeToken | undefined {
readGroupOrRange (): FilteredValueToken | RangeToken | undefined {
this.skipBlank()
const begin = this.p
if (this.peek() !== '(') return
++this.p
const lhs = this.readValueOrThrow()
this.skipBlank()
this.assert(this.read() === '.' && this.read() === '.', 'invalid range syntax')
const rhs = this.readValueOrThrow()
this.skipBlank()
this.assert(this.read() === ')', 'invalid range syntax')
return new RangeToken(this.input, begin, this.p, lhs, rhs, this.file)
if (this.peek() === '.' && this.peek(1) === '.') {
this.p += 2
const rhs = this.readValueOrThrow()
this.skipBlank()
this.assert(this.read() === ')', 'invalid range syntax')
return new RangeToken(this.input, begin, this.p, lhs, rhs, this.file)
}
if (this.groupedExpressions) {
const initial = new Expression([lhs, ...this.readExpressionTokens()])
this.assert(initial.valid(), () => `invalid value expression: ${this.snapshot()}`)
const filters = this.readFilters()
this.skipBlank()
this.assert(this.read() === ')', 'unbalanced parentheses')
return new FilteredValueToken(initial, filters, this.input, begin, this.p, this.file)
}
throw this.error('invalid range syntax')
}
readValueOrThrow (): ValueToken {
+22 -4
View File
@@ -1,13 +1,14 @@
import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, OperatorType, operatorTypes } from '../tokens'
import { isRangeToken, isPropertyAccessToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util'
import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, OperatorType, operatorTypes, FilteredValueToken } from '../tokens'
import { isRangeToken, isPropertyAccessToken, isFilteredValueToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util'
import type { Context } from '../context'
import type { UnaryOperatorHandler } from '../render'
import { Drop } from '../drop'
import { Filter } from '../template/filter'
export class Expression {
readonly postfix: Token[]
public constructor (tokens: IterableIterator<Token>) {
public constructor (tokens: Iterable<Token>) {
this.postfix = [...toPostfix(tokens)]
}
public * evaluate (ctx: Context, lenient?: boolean): Generator<unknown, unknown, unknown> {
@@ -40,6 +41,22 @@ export function * evalToken (token: Token | undefined, ctx: Context, lenient = f
if ('content' in token) return token.content
if (isPropertyAccessToken(token)) return yield evalPropertyAccessToken(token, ctx, lenient)
if (isRangeToken(token)) return yield evalRangeToken(token, ctx)
if (isFilteredValueToken(token)) return yield evalFilteredValueToken(token, ctx, lenient)
}
function * evalFilteredValueToken (token: FilteredValueToken, ctx: Context, lenient: boolean): IterableIterator<unknown> {
assert(ctx.liquid, 'FilteredValueToken evaluation requires liquid instance in context')
lenient = lenient || (ctx.opts.lenientIf && token.filters.length > 0 && token.filters[0].name === 'default')
let val = yield token.initial.evaluate(ctx, lenient)
for (const filterToken of token.filters) {
const filterImpl = ctx.liquid.filters[filterToken.name]
assert(filterImpl || !ctx.liquid.options.strictFilters, () => `undefined filter: ${filterToken.name}`)
const filter = new Filter(filterToken, filterImpl, ctx.liquid)
val = yield filter.render(val, ctx)
}
return val
}
function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator<unknown> {
@@ -67,10 +84,11 @@ export function evalQuotedToken (token: QuotedToken) {
function * evalRangeToken (token: RangeToken, ctx: Context) {
const low: number = yield evalToken(token.lhs, ctx)
const high: number = yield evalToken(token.rhs, ctx)
ctx.memoryLimit.use(high - low + 1)
return range(+low, +high + 1)
}
function * toPostfix (tokens: IterableIterator<Token>): IterableIterator<Token> {
function * toPostfix (tokens: Iterable<Token>): IterableIterator<Token> {
const ops: OperatorToken[] = []
for (const token of tokens) {
if (isOperatorToken(token)) {
+11 -4
View File
@@ -1,21 +1,28 @@
import { getPerformance } from '../util/performance'
import { toPromise, RenderError, LiquidErrors, LiquidError } from '../util'
import { Context } from '../context'
import { Template } from '../template'
import { Emitter, StreamedEmitter, SimpleEmitter } from '../emitters'
import { Emitter, KeepingTypeEmitter, StreamedEmitter, SimpleEmitter } from '../emitters'
export class Render {
public renderTemplatesToNodeStream (templates: Template[], ctx: Context): NodeJS.ReadableStream {
const emitter = new StreamedEmitter(ctx.outputLengthLimit)
const emitter = new StreamedEmitter()
Promise.resolve().then(() => toPromise(this.renderTemplates(templates, ctx, emitter)))
.then(() => emitter.end(), err => emitter.error(err))
return emitter.stream
}
public * renderTemplates (templates: Template[], ctx: Context, emitter: Emitter = new SimpleEmitter(ctx.outputLengthLimit)): IterableIterator<any> {
public * renderTemplates (templates: Template[], ctx: Context, emitter?: Emitter): IterableIterator<any> {
if (!emitter) {
emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()
}
ctx.renderLimit.check(getPerformance().now())
const errors = []
for (const tpl of templates) {
ctx.templateLimit.use(1)
ctx.renderLimit.check(getPerformance().now())
try {
// if tpl.render supports emitter, it'll return empty `html`
const html = yield tpl.render(ctx, emitter)
// if not, it'll return an `html`, write to the emitter for it
html && emitter.write(html)
if (ctx.breakCalled || ctx.continueCalled) break
} catch (e) {
+2 -2
View File
@@ -1,4 +1,4 @@
import { BlockMode } from '../context'
import { BlockMode, createScope } from '../context'
import { isTagToken } from '../util'
import { BlockDrop } from '../drop'
import { Liquid, TagToken, TopLevelToken, Template, Context, Emitter, Tag } from '..'
@@ -38,7 +38,7 @@ export default class extends Tag {
if (stack.includes(self)) throw new Error('block tag cannot be nested')
stack.push(self)
ctx.push({ block: superBlock })
ctx.push(createScope({ block: superBlock }))
yield liquid.renderer.renderTemplates(templates, ctx, emitter)
ctx.pop()
stack.pop()
+3 -3
View File
@@ -1,11 +1,11 @@
import { ValueToken, Liquid, toValue, evalToken, Value, Emitter, TagToken, TopLevelToken, Context, Template, Tag, ParseStream } from '..'
import { ValueToken, Liquid, toValue, evalToken, Value, Emitter, TagToken, TopLevelToken, Context, Template, Tag, ParseStream, FilteredValueToken } from '..'
import { Parser } from '../parser'
import { equals } from '../render'
import { Arguments } from '../template'
export default class extends Tag {
value: Value
branches: { values: ValueToken[], templates: Template[] }[] = []
branches: { values: (ValueToken | FilteredValueToken)[], templates: Template[] }[] = []
elseTemplates: Template[] = []
constructor (tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid, parser: Parser) {
super(tagToken, remainTokens, liquid)
@@ -22,7 +22,7 @@ export default class extends Tag {
p = []
const values: ValueToken[] = []
const values: (ValueToken | FilteredValueToken)[] = []
while (!token.tokenizer.end()) {
values.push(token.tokenizer.readValueOrThrow())
token.tokenizer.skipBlank()
+13 -13
View File
@@ -1,5 +1,6 @@
import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream, FilteredValueToken } from '..'
import { assertEmpty, isValueToken, toEnumerable } from '../util'
import { createScope } from '../context/scope'
import { ForloopDrop } from '../drop/forloop-drop'
import { Parser } from '../parser'
import { Arguments } from '../template'
@@ -10,7 +11,7 @@ type valueOf<T> = T[keyof T]
export default class extends Tag {
variable: string
collection: ValueToken
collection: ValueToken | FilteredValueToken
hash: Hash
templates: Template[]
elseTemplates: Template[]
@@ -42,8 +43,15 @@ export default class extends Tag {
}
* render (ctx: Context, emitter: Emitter): Generator<unknown, void | string, Template[]> {
const r = this.liquid.renderer
let collection = toEnumerable(yield evalToken(this.collection, ctx))
if (!collection.length) {
yield r.renderTemplates(this.elseTemplates, ctx, emitter)
return
}
const continueKey = 'continue-' + this.variable + '-' + this.collection.getText()
ctx.push({ continue: ctx.getRegister(continueKey, {}) })
ctx.push(createScope({ continue: ctx.getRegister(continueKey, {}) }))
const hash = (yield this.hash.render(ctx)) as Record<string, any>
ctx.pop()
@@ -51,7 +59,6 @@ export default class extends Tag {
? Object.keys(hash).filter(x => MODIFIERS.includes(x))
: MODIFIERS.filter(x => hash[x] !== undefined)
let collection = toEnumerable(yield evalToken(this.collection, ctx))
collection = modifiers.reduce((collection, modifier: valueOf<typeof MODIFIERS>) => {
if (modifier === 'offset') return offset(collection, hash['offset'])
if (modifier === 'limit') return limit(collection, hash['limit'])
@@ -59,15 +66,8 @@ export default class extends Tag {
}, collection)
ctx.setRegister(continueKey, (hash['offset'] || 0) + collection.length)
if (!collection.length) {
yield r.renderTemplates(this.elseTemplates, ctx, emitter)
return
}
if (!this.templates.length) return
const scope = ctx.push({ forloop: new ForloopDrop(collection.length, this.collection.getText(), this.variable) })
const scope = createScope({ forloop: new ForloopDrop(collection.length, this.collection.getText(), this.variable) })
ctx.push(scope)
for (const item of collection) {
scope[this.variable] = item
ctx.continueCalled = ctx.breakCalled = false
+3 -5
View File
@@ -1,5 +1,5 @@
import { Template, ValueToken, TopLevelToken, Liquid, Tag, assert, evalToken, Hash, Emitter, TagToken, Context } from '..'
import { BlockMode, Scope } from '../context'
import { BlockMode, createScope, Scope } from '../context'
import { Parser } from '../parser'
import { Argument, Arguments, PartialScope } from '../template'
import { isString, isValueToken } from '../util'
@@ -28,7 +28,6 @@ export default class extends Tag {
this.hash = new Hash(tokenizer, liquid.options.jekyllInclude || liquid.options.keyValueSeparator)
}
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
ctx.depthLimit.use(1)
const { liquid, hash, withVar } = this
const { renderer } = liquid
const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string
@@ -37,14 +36,13 @@ export default class extends Tag {
const saved = ctx.saveRegister('blocks', 'blockMode')
ctx.setRegister('blocks', {})
ctx.setRegister('blockMode', BlockMode.OUTPUT)
const scope = (yield hash.render(ctx)) as Scope
const scope = createScope((yield hash.render(ctx)) as Scope)
if (withVar) scope[filepath] = yield evalToken(withVar, ctx)
const templates = (yield liquid._parsePartialFile(filepath, ctx.sync, this.currentFile)) as Template[]
ctx.push(ctx.opts.jekyllInclude ? { include: scope } : scope)
ctx.push(ctx.opts.jekyllInclude ? createScope({ include: scope }) : scope)
yield renderer.renderTemplates(templates, ctx, emitter)
ctx.pop()
ctx.restoreRegister(saved)
ctx.depthLimit.release(1)
}
public * children (partials: boolean, sync: boolean): Generator<unknown, Template[]> {
+2 -4
View File
@@ -1,5 +1,5 @@
import { Scope, Template, Liquid, Tag, assert, Emitter, Hash, TagToken, TopLevelToken, Context } from '..'
import { BlockMode } from '../context'
import { BlockMode, createScope } from '../context'
import { parseFilePath, renderFilePath, ParsedFileName } from './render'
import { BlankDrop } from '../drop'
import { Parser } from '../parser'
@@ -26,7 +26,6 @@ export default class extends Tag {
yield renderer.renderTemplates(this.templates, ctx, emitter)
return
}
ctx.depthLimit.use(1)
const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string
assert(filepath, () => `illegal file path "${filepath}"`)
const templates = (yield liquid._parseLayoutFile(filepath, ctx.sync, this.currentFile)) as Template[]
@@ -41,10 +40,9 @@ export default class extends Tag {
ctx.setRegister('blockMode', BlockMode.OUTPUT)
// render the layout file use stored blocks
ctx.push((yield args.render(ctx)) as Scope)
ctx.push(createScope((yield args.render(ctx)) as Scope))
yield renderer.renderTemplates(templates, ctx, emitter)
ctx.pop()
ctx.depthLimit.release(1)
}
public * children (partials: boolean): Generator<unknown, Template[]> {
-2
View File
@@ -55,7 +55,6 @@ export default class extends Tag {
this.hash = new Hash(tokenizer, liquid.options.keyValueSeparator)
}
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
ctx.depthLimit.use(1)
const { liquid, hash } = this
const filepath = (yield renderFilePath(this.file, ctx, liquid)) as string
assert(filepath, () => `illegal file path "${filepath}"`)
@@ -82,7 +81,6 @@ export default class extends Tag {
const templates = (yield liquid._parsePartialFile(filepath, childCtx.sync, this.currentFile)) as Template[]
yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
}
ctx.depthLimit.release(1)
}
public * children (partials: boolean, sync: boolean): Generator<unknown, Template[]> {
+5 -7
View File
@@ -1,5 +1,6 @@
import { isValueToken, toEnumerable } from '../util'
import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
import { createScope } from '../context/scope'
import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream, FilteredValueToken } from '..'
import { TablerowloopDrop } from '../drop/tablerowloop-drop'
import { Parser } from '../parser'
import { Arguments } from '../template'
@@ -8,7 +9,7 @@ export default class extends Tag {
variable: string
args: Hash
templates: Template[]
collection: ValueToken
collection: ValueToken | FilteredValueToken
constructor (tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid, parser: Parser) {
super(tagToken, remainTokens, liquid)
const variable = this.tokenizer.readIdentifier()
@@ -44,15 +45,12 @@ export default class extends Tag {
const limit = (args.limit === undefined) ? collection.length : args.limit
collection = collection.slice(offset, offset + limit)
if (!collection.length) return
if (!this.templates.length) return
const cols = args.cols || collection.length
const r = this.liquid.renderer
const tablerowloop = new TablerowloopDrop(collection.length, cols, this.collection.getText(), this.variable)
const scope = ctx.push({ tablerowloop })
const scope = createScope({ tablerowloop })
ctx.push(scope)
for (let idx = 0; idx < collection.length; idx++, tablerowloop.next()) {
scope[this.variable] = collection[idx]
+21
View File
@@ -2,6 +2,7 @@ import { Argument, Template, Value } from '.'
import { isKeyValuePair } from '../parser/filter-arg'
import { PropertyAccessToken, ValueToken } from '../tokens'
import {
isFilteredValueToken,
isNumberToken,
isPropertyAccessToken,
isQuotedToken,
@@ -371,11 +372,31 @@ function * extractValueTokenVariables (token: ValueToken): Generator<Variable> {
if (isRangeToken(token)) {
yield * extractValueTokenVariables(token.lhs)
yield * extractValueTokenVariables(token.rhs)
} else if (isFilteredValueToken(token)) {
yield * extractGroupedExpressionTokenVariables(token)
} else if (isPropertyAccessToken(token)) {
yield extractPropertyAccessVariable(token)
}
}
function * extractGroupedExpressionTokenVariables (token: ValueToken): Generator<Variable> {
if (!isFilteredValueToken(token)) return
for (const t of token.initial.postfix) {
if (isValueToken(t)) yield * extractValueTokenVariables(t)
}
for (const filter of token.filters) {
for (const arg of filter.args) {
if (isKeyValuePair(arg) && arg[1]) {
yield * extractValueTokenVariables(arg[1])
} else if (isValueToken(arg)) {
yield * extractValueTokenVariables(arg)
}
}
}
}
function extractPropertyAccessVariable (token: PropertyAccessToken): Variable {
const segments: VariableSegments = []
+1
View File
@@ -1,6 +1,7 @@
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'
+56
View File
@@ -2,6 +2,7 @@ import { toPromise } from '../util'
import { Context } from '../context'
import { Output } from '../template'
import { OutputToken } from '../tokens'
import { defaultOptions } from '../liquid-options'
describe('Output', function () {
const emitter: any = { write: (html: string) => (emitter.html += html), html: '' }
@@ -29,4 +30,59 @@ describe('Output', function () {
await toPromise(output.render(scope, emitter))
return expect(emitter.html).toBe('FOO')
})
it('should respect to .toString()', async () => {
const scope = new Context({ obj: { toString: () => 'FOO' } })
const output = new Output(token, liquid)
await toPromise(output.render(scope, emitter))
return expect(emitter.html).toBe('FOO')
})
describe('when keepOutputType is enabled', () => {
const emitter: any = {
write: (html: any) => {
if (emitter.keepOutputType && typeof html !== 'string') {
emitter.html = html
} else {
emitter.html += html as string
}
},
html: '',
keepOutputType: true
}
const token = { content: 'foo', input: 'foo' } as OutputToken
beforeEach(() => { emitter.html = '' })
it('should respect output variable number type', async () => {
const scope = new Context({
foo: 42
}, { ...defaultOptions, keepOutputType: true })
const output = new Output(token, liquid)
await toPromise(output.render(scope, emitter))
return expect(emitter.html).toBe(42)
})
it('should respect output variable boolean type', async () => {
const scope = new Context({
foo: true
}, { ...defaultOptions, keepOutputType: true })
const output = new Output(token, liquid)
await toPromise(output.render(scope, emitter))
return expect(emitter.html).toBe(true)
})
it('should respect output variable object type', async () => {
const scope = new Context({
foo: 'test'
}, { ...defaultOptions, keepOutputType: true })
const output = new Output(token, liquid)
await toPromise(output.render(scope, emitter))
return expect(emitter.html).toBe('test')
})
it('should respect output variable string type', async () => {
const scope = new Context({
foo: { a: { b: 42 } }
}, { ...defaultOptions, keepOutputType: true })
const output = new Output(token, liquid)
await toPromise(output.render(scope, emitter))
return expect(emitter.html).toEqual({ a: { b: 42 } })
})
})
})
+1 -1
View File
@@ -12,7 +12,7 @@ export class Output extends TemplateImpl<OutputToken> implements Template {
value: Value
public constructor (token: OutputToken, liquid: Liquid) {
super(token)
const tokenizer = new Tokenizer(token.input, liquid.options.operators, token.file, token.contentRange)
const tokenizer = new Tokenizer(token.input, liquid.options.operators, token.file, token.contentRange, liquid.options.groupedExpressions)
this.value = new Value(tokenizer.readFilteredValue(), liquid)
const filters = this.value.filters
const outputEscape = liquid.options.outputEscape
+28
View File
@@ -0,0 +1,28 @@
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)
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ export class Value {
*/
public constructor (input: string | FilteredValueToken, liquid: Liquid) {
const token: FilteredValueToken = typeof input === 'string'
? new Tokenizer(input, liquid.options.operators).readFilteredValue()
? new Tokenizer(input, liquid.options.operators, undefined, undefined, liquid.options.groupedExpressions).readFilteredValue()
: input
this.initial = token.initial
this.filters = token.filters.map(token => new Filter(token, this.getFilter(liquid, token.name), liquid))
+1 -1
View File
@@ -16,7 +16,7 @@ export class LiquidTagToken extends DelimitedToken {
file?: string
) {
super(TokenKind.Tag, [begin, end], input, begin, end, false, false, file)
this.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange)
this.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange, options.groupedExpressions)
this.name = this.tokenizer.readTagName()
this.tokenizer.assert(this.name, 'illegal liquid tag syntax')
this.tokenizer.skipBlank()
+2 -1
View File
@@ -5,11 +5,12 @@ import { IdentifierToken } from './identifier-token'
import { NumberToken } from './number-token'
import { RangeToken } from './range-token'
import { QuotedToken } from './quoted-token'
import { FilteredValueToken } from './filtered-value-token'
import { TokenKind } from '../parser'
export class PropertyAccessToken extends Token {
constructor (
public variable: QuotedToken | RangeToken | LiteralToken | NumberToken | undefined,
public variable: QuotedToken | RangeToken | LiteralToken | NumberToken | FilteredValueToken | undefined,
public props: (ValueToken | IdentifierToken)[],
input: string,
begin: number,
+1 -1
View File
@@ -17,7 +17,7 @@ export class TagToken extends DelimitedToken {
const [valueBegin, valueEnd] = [begin + tagDelimiterLeft.length, end - tagDelimiterRight.length]
super(TokenKind.Tag, [valueBegin, valueEnd], input, begin, end, trimTagLeft, trimTagRight, file)
this.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange)
this.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange, options.groupedExpressions)
this.name = this.tokenizer.readTagName()
this.tokenizer.assert(this.name, `illegal tag syntax, tag name expected`)
this.tokenizer.skipBlank()
+2 -1
View File
@@ -3,5 +3,6 @@ import { LiteralToken } from './literal-token'
import { NumberToken } from './number-token'
import { QuotedToken } from './quoted-token'
import { PropertyAccessToken } from './property-access-token'
import { FilteredValueToken } from './filtered-value-token'
export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken | NumberToken
export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken | NumberToken | FilteredValueToken
-5
View File
@@ -14,11 +14,6 @@ export class Limiter {
this.base += +count
}
}
release (count: number) {
if (+count > 0) {
this.base -= +count
}
}
check (count: number) {
if (+count > 0) {
assert(+count <= this.limit, this.message)
+9
View File
@@ -53,6 +53,15 @@ export class LiquidDate {
getTime () {
return this.displayDate.getTime()
}
/**
* The underlying UTC timestamp in milliseconds, unaffected by the display
* timezone. Use this (not `getTime()`) for timezone-invariant values like
* `%s`: `getTime()` reads `displayDate`, which is deliberately shifted by
* the display timezone offset so wall-clock getters can delegate to Date.
*/
dateValue () {
return this.date.getTime()
}
getMilliseconds () {
return this.displayDate.getMilliseconds()
}
+9 -8
View File
@@ -87,6 +87,15 @@ describe('util/strftime', function () {
expect(t(time, '%10N')).toBe('1290000000')
expect(t(time, '%0N')).toBe('129000000')
})
it('should zero pad %N for sub-100ms fractional seconds', function () {
const time = new TestDate('2019-12-15 01:21:00.005')
expect(t(time, '%N')).toBe('005000000')
expect(t(time, '%3N')).toBe('005')
expect(t(time, '%6N')).toBe('005000')
const tens = new TestDate('2019-12-15 01:21:00.050')
expect(t(tens, '%N')).toBe('050000000')
expect(t(tens, '%2N')).toBe('05')
})
it('should format %p as upper cased am/pm', function () {
expect(t(now, '%p')).toBe('PM')
expect(t(then, '%p')).toBe('AM')
@@ -188,14 +197,6 @@ describe('util/strftime', function () {
it('should have higher priority than H', () => {
expect(t(then, '%0H')).toBe('03')
})
it('should allow pad width up to MAX_STRFTIME_PAD', () => {
expect(t(now, '%100000d').length).toBe(100000)
expect(t(now, `%${1_000_000}d`).length).toBe(1_000_000)
})
it('should throw when pad width exceeds MAX_STRFTIME_PAD', () => {
expect(() => t(now, `%${1024 * 1024 + 1}d`)).toThrow('strftime pad width limit exceeded')
expect(() => t(now, '%5000000d')).toThrow('strftime pad width limit exceeded')
})
})
describe('modifier field', () => {
it('should ignore E modifier', () => {
+10 -16
View File
@@ -1,15 +1,13 @@
import { changeCase, padStart, padEnd } from './underscore'
import { LiquidDate } from './liquid-date'
import { assert } from './assert'
/** Per-conversion numeric width cap for strftime (%N, %15d, …). */
export const MAX_STRFTIME_PAD = 1024 * 1024
import type { Limiter } from './limiter'
const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/
interface FormatOptions {
flags: Record<string, boolean>;
width?: string;
modifier?: string;
memoryLimit?: Pick<Limiter, 'use'>;
}
// prototype extensions
@@ -100,14 +98,14 @@ const formatCodes: Record<string, FormatCodeHandler> = {
M: (d: LiquidDate) => d.getMinutes(),
N: (d: LiquidDate, opts: FormatOptions) => {
const width = Number(opts.width) || 9
assertPadWidth(width)
const str = String(d.getMilliseconds()).slice(0, width)
const str = padStart(String(d.getMilliseconds()), 3, '0').slice(0, width)
opts.memoryLimit?.use(width - str.length)
return padEnd(str, width, '0')
},
p: (d: LiquidDate) => (d.getHours() < 12 ? 'AM' : 'PM'),
P: (d: LiquidDate) => (d.getHours() < 12 ? 'am' : 'pm'),
q: (d: LiquidDate) => ordinal(d),
s: (d: LiquidDate) => Math.round(d.getTime() / 1000),
s: (d: LiquidDate) => Math.floor(d.dateValue() / 1000),
S: (d: LiquidDate) => d.getSeconds(),
u: (d: LiquidDate) => d.getDay() || 7,
U: (d: LiquidDate) => getWeekOfYear(d, 0),
@@ -125,29 +123,25 @@ const formatCodes: Record<string, FormatCodeHandler> = {
}
formatCodes.h = formatCodes.b
export function strftime (d: LiquidDate, formatStr: string) {
export function strftime (d: LiquidDate, formatStr: string, memoryLimit?: Pick<Limiter, 'use'>) {
let output = ''
let remaining = formatStr
let match
while ((match = rFormat.exec(remaining))) {
output += remaining.slice(0, match.index)
remaining = remaining.slice(match.index + match[0].length)
output += format(d, match)
output += format(d, match, memoryLimit)
}
return output + remaining
}
function assertPadWidth (width: number) {
assert(width <= MAX_STRFTIME_PAD, 'strftime pad width limit exceeded')
}
function format (d: LiquidDate, match: RegExpExecArray) {
function format (d: LiquidDate, match: RegExpExecArray, memoryLimit?: Pick<Limiter, 'use'>) {
const [input, flagStr = '', width, modifier, conversion] = match
const convert = formatCodes[conversion]
if (!convert) return input
const flags: Record<string, boolean> = {}
for (const flag of flagStr) flags[flag] = true
let ret = String(convert(d, { flags, width, modifier }))
let ret = String(convert(d, { flags, width, modifier, memoryLimit }))
let padChar = padSpaceChars.has(conversion) ? ' ' : '0'
let padWidth = Number(width) || padWidths[conversion] || 0
if (flags['^']) ret = ret.toUpperCase()
@@ -155,6 +149,6 @@ function format (d: LiquidDate, match: RegExpExecArray) {
if (flags['_']) padChar = ' '
else if (flags['0']) padChar = '0'
if (flags['-']) padWidth = 0
else assertPadWidth(padWidth)
memoryLimit?.use(Number(padWidth) - ret.length)
return padStart(ret, padWidth, padChar)
}
+7 -3
View File
@@ -1,4 +1,4 @@
import { RangeToken, NumberToken, QuotedToken, LiteralToken, PropertyAccessToken, OutputToken, HTMLToken, TagToken, IdentifierToken, DelimitedToken, OperatorToken, ValueToken } from '../tokens'
import { RangeToken, NumberToken, QuotedToken, LiteralToken, PropertyAccessToken, OutputToken, HTMLToken, TagToken, IdentifierToken, DelimitedToken, OperatorToken, ValueToken, FilteredValueToken } from '../tokens'
import { TokenKind } from '../parser'
export function isDelimitedToken (val: any): val is DelimitedToken {
@@ -45,9 +45,13 @@ export function isRangeToken (val: any): val is RangeToken {
return getKind(val) === TokenKind.Range
}
export function isFilteredValueToken (val: any): val is FilteredValueToken {
return getKind(val) === TokenKind.FilteredValue
}
export function isValueToken (val: any): val is ValueToken {
// valueTokenBitMask = TokenKind.Number | TokenKind.Literal | TokenKind.Quoted | TokenKind.PropertyAccess | TokenKind.Range
return (getKind(val) & 1667) > 0
// valueTokenBitMask = TokenKind.Number | TokenKind.Literal | TokenKind.Quoted | TokenKind.PropertyAccess | TokenKind.Range | TokenKind.FilteredValue
return (getKind(val) & 5763) > 0
}
function getKind (val: any) {
+4 -2
View File
@@ -1,9 +1,10 @@
import { Drop } from '../drop/drop'
export const toString = Object.prototype.toString
export const hasOwnProperty = Object.prototype.hasOwnProperty
const toLowerCase = String.prototype.toLowerCase
export const hasOwnProperty = Object.hasOwnProperty
export function isString (value: any): value is string {
return typeof value === 'string'
}
@@ -89,7 +90,8 @@ export function isUndefined (value: any): boolean {
}
export function isArray (value: any): value is any[] {
return Array.isArray(value)
// be compatible with IE 8
return toString.call(value) === '[object Array]'
}
export function isArrayLike (value: any): value is any[] {
+2 -2
View File
@@ -14,9 +14,9 @@ for demo in $(ls demo); do
npm link liquidjs
if npm test; then
echo "[success] demo/$demo"
echo [success] demo/webpack
else
echo "[fail] demo/$demo"
echo [fail] demo/webpack
exit 1
fi
cd -
+33 -1
View File
@@ -1,4 +1,4 @@
import { Tokenizer, Context, Liquid, Drop, toValueSync, LiquidError, IfTag } from '../..'
import { TopLevelToken, TagToken, Tokenizer, Context, Liquid, Drop, toValueSync, LiquidError, IfTag } from '../..'
import { spawnSync } from 'child_process'
import { resolve as resolvePath } from 'path'
const LiquidUMD = require('../../dist/liquid.browser.umd.js').Liquid
@@ -362,8 +362,33 @@ describe('Issues', function () {
const html = await liquid.parseAndRender(tpl)
expect(html).toMatch(/^\s*This is a love or luck potion.\s+This is a strength or health or love potion.\s*$/)
})
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)
}
})
const tpl = '{% metadata_file foo %}'
const ctx = { foo: 'FOO' }
const html = await liquid.parseAndRender(tpl, ctx)
expect(html).toBe('FOO')
})
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)
}
})
const tpl = `{{ 'now' | date }}`
const html = await liquid.parseAndRender(tpl)
// sample: Thursday, February 2, 2023 at 6:25 pm +0000
@@ -519,6 +544,13 @@ describe('Issues', function () {
const result = engine.parseAndRenderSync(`\n{{ "foo" | pos }}`)
expect(result).toEqual('\n[2,12] foo')
})
it("memoryLimit doesn't work in for tag #776", () => {
const engine = new Liquid({
memoryLimit: 1e5
})
const tpl = `{% for i in (1..1000000000) %} {{'a'}} {% endfor %}`
expect(() => engine.parseAndRenderSync(tpl)).toThrow('memory alloc limit exceeded, line:1, col:1')
})
it('group_by_exp fails with object as input #785', () => {
const site = {
tags: {
+3
View File
@@ -288,6 +288,9 @@ describe('filters/array', function () {
it('should slice substr by -2,2', () => test('{{ "abc" | slice: -2, 2 }}', 'bc'))
it('should support array', () => test('{{ "1,2,3,4" | split: "," | slice: 1,2 | join }}', '2 3'))
it('should return empty array for nil value', () => test('{{ nil | slice: 0 }}', ''))
it('should return empty when begin is out of negative range', () => test('{{ "hello" | slice: -10, 2 }}', ''))
it('should return empty when length is negative', () => test('{{ "Liquid" | slice: 1, -2 }}', ''))
it('should return empty array when begin is out of negative range', () => test('{{ "1,2,3,4,5" | split: "," | slice: -10, 2 | join: "," }}', ''))
})
describe('sort', function () {
it('should support sort', function () {
+35 -13
View File
@@ -140,6 +140,18 @@ describe('filters/date', function () {
it('should support timezone name argument when DST is active', function () {
return test('{{ "2021-06-01T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S", "America/New_York" }}', '2021-06-01T19:00:00')
})
it('should not shift %s by the timezone name argument', function () {
return test('{{ "2026-06-30T21:00:00Z" | date: "%s", "America/Toronto" }}', '1782853200')
})
it('should not shift %s by the timezone offset argument', function () {
return test('{{ "2026-06-30T21:00:00Z" | date: "%s", 360 }}', '1782853200')
})
it('should not shift %s by the timezoneOffset option', function () {
return test('{{ "2026-06-30T21:00:00Z" | date: "%s" }}', '1782853200', undefined, opts)
})
it('should truncate %s toward the epoch like Ruby strftime', function () {
return test('{{ "2026-06-30T17:00:00.500Z" | date: "%s" }}', '1782838800')
})
it('should offset date literal with timezone 00:00 specified', function () {
return test('{{ "1990-12-31T23:00:00+00:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T17:00:00', undefined, opts)
})
@@ -204,21 +216,31 @@ describe('filters/date', function () {
return test('{{ "1990-12-31T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S" }}', '1991-01-01T04:30:00', undefined, optsWithDateFormat)
})
})
describe('strftime width', () => {
it('should honor numeric strftime pad width', () => {
const liquid = new Liquid()
describe('strftime width / memoryLimit', () => {
it('should charge memoryLimit for huge numeric strftime widths', () => {
const liquid = new Liquid({ memoryLimit: 500 })
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000000d' }))
.toThrow('memory alloc limit exceeded')
})
it('should charge memoryLimit for array format PoC', () => {
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 1e9 })
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: ['a'.repeat(2000000)] }))
.toThrow('memory alloc limit exceeded')
})
it('should charge memoryLimit for object toString format PoC', () => {
const liquid = new Liquid({ memoryLimit: 50, renderLimit: 1e9 })
const huge = 'a'.repeat(2000000)
const f = { toString: () => huge }
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f }))
.toThrow('memory alloc limit exceeded')
})
it('should honor numeric strftime pad width when memoryLimit allows', () => {
const liquid = new Liquid({ memoryLimit: 1e7 })
const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000d' })
expect(out.length).toBe(5000)
})
it('should honor large numeric strftime pad width up to the cap', () => {
const liquid = new Liquid()
const out = liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%100000d' })
expect(out.length).toBe(100000)
})
it('should throw when numeric strftime pad width is too large', () => {
const liquid = new Liquid()
expect(() => liquid.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000000d' }))
.toThrow('strftime pad width limit exceeded')
const tight = new Liquid({ memoryLimit: 100 })
expect(() => tight.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000d' }))
.toThrow('memory alloc limit exceeded')
})
})
})
+20
View File
@@ -153,6 +153,26 @@ describe('filters/string', function () {
'{{ string_with_newlines | strip_newlines }}',
'Hellothere')
})
describe('squish', function () {
it('should collapse whitespace between words', function () {
return test('{{ "Hello World!" | squish }}', 'Hello World!')
})
it('should strip leading and trailing whitespace', function () {
return test('{{ " HelloWorld! " | squish }}', 'HelloWorld!')
})
it('should treat newlines and tabs as whitespace', function () {
return test('{{ " \n\t\r\nHello \n\t World! \n" | squish }}', 'Hello World!')
})
it('should return empty string for whitespace only', function () {
return test('{{ " \n\t " | squish }}', '')
})
it('should stringify a number', function () {
return test('{{ 5 | squish }}', '5')
})
it('should return empty string for undefined', function () {
return test('{{ nosuchthing | squish }}', '')
})
})
describe('truncate', function () {
it('should truncate when string too long', function () {
return test('{{ "Ground control to Major Tom." | truncate: 20 }}',
+8
View File
@@ -7,6 +7,14 @@ describe('filters/url', () => {
const html = liquid.parseAndRenderSync('{{ "%27Stop%21%27+said+Fred" | url_decode }}')
expect(html).toEqual("'Stop!' said Fred")
})
it('should decode %2B to a literal plus', () => {
const html = liquid.parseAndRenderSync('{{ "1%2B1" | url_decode }}')
expect(html).toEqual('1+1')
})
it('should keep a literal plus when round-tripped through url_encode', () => {
const html = liquid.parseAndRenderSync('{{ "a+b c" | url_encode | url_decode }}')
expect(html).toEqual('a+b c')
})
})
describe('url_encode', () => {
+90 -127
View File
@@ -4,18 +4,15 @@ import { mock, restore } from '../../stub/mockfs'
describe('DoS related', function () {
describe('#parseLimit', function () {
afterEach(restore)
it('should throw when parse limit exceeded', async () => {
const noLimit = new Liquid()
const limit10 = new Liquid({ parseLimit: 10 })
const limit90 = new Liquid({ parseLimit: 90 })
const template = '{% capture bar %}{{ foo | bar: 3, a[3] }}{% endcapture %}'
await expect(noLimit.parseAndRender(template)).resolves.toBe('')
await expect(limit10.parseAndRender(template)).rejects.toThrow('parse length limit exceeded')
await expect(limit90.parseAndRender(template)).resolves.toBe('')
})
it('should take included template into account', async () => {
mock({
'/small': 'Lorem ipsum',
@@ -26,151 +23,119 @@ describe('DoS related', function () {
await expect(liquid.parseAndRender('{% include "large" %}')).rejects.toThrow('parse length limit exceeded')
})
})
describe('#templateLimit', () => {
describe('#renderLimit', () => {
it('should throw when rendering too many templates', async () => {
const src = '{% for i in (1..1000) %}{{i}},{% endfor %}'
const noLimit = new Liquid()
const limitSmall = new Liquid({ templateLimit: 100 })
const limitLarge = new Liquid({ templateLimit: 2001 })
const limitSmall = new Liquid({ renderLimit: 0.01 })
const limitLarge = new Liquid({ renderLimit: 2e4 })
await expect(noLimit.parseAndRender(src)).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
await expect(limitSmall.parseAndRender(src)).rejects.toThrow('template limit exceeded')
await expect(limitSmall.parseAndRender(src)).rejects.toThrow('template render limit exceeded')
await expect(limitLarge.parseAndRender(src)).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
})
it('should support reset when calling render', async () => {
const src = '{% for i in (1..1000) %}{{i}},{% endfor %}'
const liquid = new Liquid({ templateLimit: 100 })
await expect(liquid.parseAndRender(src)).rejects.toThrow('template limit exceeded')
await expect(liquid.parseAndRender(src, {}, { templateLimit: 2001 })).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
const liquid = new Liquid({ renderLimit: 0.01 })
await expect(liquid.parseAndRender(src)).rejects.toThrow('template render limit exceeded')
await expect(liquid.parseAndRender(src, {}, { renderLimit: 1e6 })).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
})
it('should take partials into account', async () => {
mock({
'/small': '{% for i in (1..5) %}{{i}}{% endfor %}',
'/large': '{% for i in (1..50000000) %}{{i}}{% endfor %}'
})
const liquid = new Liquid({ root: '/', templateLimit: 1000 })
await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('template limit exceeded')
const liquid = new Liquid({ root: '/', renderLimit: 1000 })
await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('template render limit exceeded')
await expect(liquid.parseAndRender('{% render "small" %}')).resolves.toBe('12345')
})
})
describe('#outputLengthLimit', () => {
it('should throw when output length exceeded', async () => {
const src = '{% for i in (1..1000) %}{{i}},{% endfor %}'
const noLimit = new Liquid()
const limitSmall = new Liquid({ outputLengthLimit: 10 })
const limitLarge = new Liquid({ outputLengthLimit: 5000 })
await expect(noLimit.parseAndRender(src)).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
await expect(limitSmall.parseAndRender(src)).rejects.toThrow('output length limit exceeded')
await expect(limitLarge.parseAndRender(src)).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
it('should enforce renderLimit when for body has no template nodes', () => {
const liquid = new Liquid({ memoryLimit: 1e9, renderLimit: 1 })
expect(() => liquid.parseAndRenderSync('{%- for i in (1..5000000) -%}{%- endfor -%}', {}))
.toThrow('template render limit exceeded')
})
it('should enforce renderLimit when tablerow body has no template nodes', () => {
const liquid = new Liquid({ memoryLimit: 1e9, renderLimit: 1 })
expect(() => liquid.parseAndRenderSync('{%- tablerow i in (1..1000000) cols:1 -%}{%- endtablerow -%}', {}))
.toThrow('template render limit exceeded')
})
})
describe('#memoryLimit', () => {
it('should throw for too many array creation in filters', async () => {
const array = Array(1e3).fill(0)
const liquid = new Liquid({ memoryLimit: 100 })
await expect(liquid.parseAndRender('{{ array | slice: 0, 3 | join }}', { array })).resolves.toBe('0 0 0')
await expect(liquid.parseAndRender('{{ array | slice: 0, 300 | join }}', { array })).rejects.toThrow('memory alloc limit exceeded, line:1, col:1')
})
it('should support reset when calling render', async () => {
const src = '{% for i in (1..1000) %}{{i}},{% endfor %}'
const liquid = new Liquid({ outputLengthLimit: 10 })
await expect(liquid.parseAndRender(src)).rejects.toThrow('output length limit exceeded')
await expect(liquid.parseAndRender(src, {}, { outputLengthLimit: 5000 })).resolves.toMatch(/^1,2,3,4,5,.*,999,1000,$/)
const array = Array(1e3).fill(0)
const liquid = new Liquid({ memoryLimit: 100 })
await expect(liquid.parseAndRender('{{ array | slice: 0, 300 | join }}', { array })).rejects.toThrow('memory alloc limit exceeded, line:1, col:1')
await expect(liquid.parseAndRender('{{ array | slice: 0, 300 | join }}', { array }, { memoryLimit: 1e3 })).resolves.toBe(Array(300).fill(0).join(' '))
})
it('should take partials into account', async () => {
mock({
'/small': 'abc',
'/large': '{% for i in (1..1000) %}{{i}}{% endfor %}'
})
const liquid = new Liquid({ root: '/', outputLengthLimit: 10 })
await expect(liquid.parseAndRender('{% render "small" %}')).resolves.toBe('abc')
await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('output length limit exceeded')
it('should throw for too many array iteration in tags', async () => {
const array = ['a']
const liquid = new Liquid({ memoryLimit: 100 })
const src = '{% for i in (1..count) %}{% assign array = array | concat: array %}{% endfor %}{{ array | join }}'
await expect(liquid.parseAndRender(src, { array, count: 3 })).resolves.toBe('a a a a a a a a')
await expect(liquid.parseAndRender(src, { array, count: 100 })).rejects.toThrow('memory alloc limit exceeded, line:1, col:26')
})
it('should enforce outputLengthLimit in sync render', () => {
const liquid = new Liquid({ outputLengthLimit: 5 })
expect(() => liquid.parseAndRenderSync('{% for i in (1..100) %}{{i}}{% endfor %}'))
.toThrow('output length limit exceeded')
it('should charge pop allocation to memoryLimit', async () => {
const array = Array(1e3).fill(0)
const liquid = new Liquid({ memoryLimit: 100 })
await expect(liquid.parseAndRender('{{ array | pop | size }}', { array })).rejects.toThrow('memory alloc limit exceeded')
})
it('should enforce outputLengthLimit in stream render', async () => {
const liquid = new Liquid({ outputLengthLimit: 5 })
const tpl = liquid.parse('{% for i in (1..100) %}{{i}}{% endfor %}')
const stream = liquid.renderToNodeStream(tpl)
await expect(new Promise((resolve, reject) => {
stream.on('error', reject)
stream.on('end', resolve)
})).rejects.toThrow('output length limit exceeded')
it('should charge sample allocation to memoryLimit', async () => {
const array = Array(1e3).fill(0)
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) }))
.toThrow('memory alloc limit exceeded')
})
})
describe('#maxDepth', () => {
function chain (depth: number, tag: string) {
const templates: Record<string, string> = {}
for (let i = 0; i < depth; i++) {
templates[`t${i}`] = i === depth - 1 ? 'done' : `{% ${tag} "t${i + 1}" %}`
}
return templates
}
it('should throw when include depth exceeded', async () => {
const liquid = new Liquid({ templates: chain(3, 'include'), maxDepth: 2 })
await expect(liquid.parseAndRender('{% include "t0" %}')).rejects.toThrow('template depth limit exceeded')
})
it('should allow include within maxDepth', async () => {
const liquid = new Liquid({ templates: chain(2, 'include'), maxDepth: 2 })
await expect(liquid.parseAndRender('{% include "t0" %}')).resolves.toBe('done')
})
it('should throw when render depth exceeded', async () => {
const liquid = new Liquid({ templates: chain(3, 'render'), maxDepth: 2 })
await expect(liquid.parseAndRender('{% render "t0" %}')).rejects.toThrow('template depth limit exceeded')
})
it('should allow render within maxDepth', async () => {
const liquid = new Liquid({ templates: chain(2, 'render'), maxDepth: 2 })
await expect(liquid.parseAndRender('{% render "t0" %}')).resolves.toBe('done')
})
it('should throw when layout depth exceeded', async () => {
const liquid = new Liquid({
templates: {
a: '{% layout "b" %}body-a',
b: '{% layout "c" %}body-b',
c: 'body-c'
},
maxDepth: 2
})
await expect(liquid.parseAndRender('{% layout "a" %}root')).rejects.toThrow('template depth limit exceeded')
})
it('should allow layout within maxDepth', async () => {
const liquid = new Liquid({
templates: {
a: '{% layout "b" %}body-a',
b: 'body-b'
},
maxDepth: 2
})
await expect(liquid.parseAndRender('{% layout "a" %}root')).resolves.toBe('body-b')
})
it('should not count layout none toward depth', async () => {
const liquid = new Liquid({ maxDepth: 0 })
await expect(liquid.parseAndRender('{% layout none %}ok')).resolves.toBe('ok')
})
it('should default maxDepth to 128', async () => {
const liquid = new Liquid({ templates: chain(128, 'include') })
await expect(liquid.parseAndRender('{% include "t0" %}')).resolves.toBe('done')
const overflow = new Liquid({ templates: chain(129, 'include') })
await expect(overflow.parseAndRender('{% include "t0" %}')).rejects.toThrow('template depth limit exceeded')
})
it('should enforce maxDepth in sync render', () => {
const liquid = new Liquid({ templates: chain(3, 'include'), maxDepth: 2 })
expect(() => liquid.parseAndRenderSync('{% include "t0" %}')).toThrow('template depth limit exceeded')
})
})
describe('strip_html ReDoS', () => {
// Regression for O(n^2) backtracking on unclosed `<script` / `<style` openers.
// The previous regex stalled the event loop for ~10s on 350KB of `'<script'.repeat`.
@@ -180,13 +145,11 @@ describe('DoS related', function () {
const payload = '<script'.repeat(50000)
expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
}, 1000)
it('should handle many unclosed <style openers in linear time', () => {
const liquid = new Liquid()
const payload = '<style'.repeat(50000)
expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe(payload)
}, 1000)
it('should handle <script openers that have > but no </script> in linear time', () => {
const liquid = new Liquid()
const payload = '<script>foo'.repeat(50000)
@@ -0,0 +1,39 @@
import { Liquid } from '../../../src/liquid'
describe('LiquidOptions#*keepOutputType*', function () {
it('should respect keepOutputType', async function () {
const engine = new Liquid({
keepOutputType: true
})
const context = {
'my-boolean': true,
'my-number': 42,
'my-string': 'test'
}
const booleanHtml = await engine.parseAndRender('{{my-boolean}}', context)
expect(booleanHtml).toBe(true)
const numberHtml = await engine.parseAndRender('{{my-number}}', context)
expect(numberHtml).toBe(42)
const html = await engine.parseAndRender('{{my-string}}', context)
expect(html).toBe('test')
const composedHtml = await engine.parseAndRender('{{my-string}}:{{my-number}}', context)
expect(composedHtml).toBe('test:42')
})
it('should respect keepOutputType = false as default', async function () {
const engine = new Liquid()
const context = {
'my-boolean': true,
'my-number': 42,
'my-string': 'test'
}
const booleanHtml = await engine.parseAndRender('{{my-boolean}}', context)
expect(booleanHtml).toBe('true')
const numberHtml = await engine.parseAndRender('{{my-number}}', context)
expect(numberHtml).toBe('42')
const html = await engine.parseAndRender('{{my-string}}', context)
expect(html).toBe('test')
const composedHtml = await engine.parseAndRender('{{my-string}}:{{my-number}}', context)
expect(composedHtml).toBe('test:42')
})
})
+6 -3
View File
@@ -1,7 +1,6 @@
import { Liquid, Context, isFalsy } from '../../../src'
import { mock, restore } from '../../stub/mockfs'
import { drainStream } from '../../stub/stream'
import { ThrowingTag } from '../../stub/tags'
import { resolve } from 'path'
describe('Liquid', function () {
@@ -232,7 +231,11 @@ describe('Liquid', function () {
'/root/error.html': 'A{%throwingTag%}B'
})
engine = new Liquid({ root: ['/root/'] })
engine.registerTag('throwingTag', ThrowingTag)
engine.registerTag('throwingTag', {
render: function () {
throw new Error('intended render error')
}
})
})
afterEach(restore)
it('should render a simple value', async () => {
@@ -241,7 +244,7 @@ describe('Liquid', function () {
})
it('should throw RenderError when tag throws', async () => {
const stream = await engine.renderFileToNodeStream('error.html')
expect(drainStream(stream)).rejects.toThrow(/intended error/)
expect(drainStream(stream)).rejects.toThrow(/intended render error/)
})
})
describe('#analyze', () => {
@@ -1,4 +1,4 @@
import { Liquid } from '../../../src/liquid'
import { Liquid, filters } from '../../../src'
describe('liquid#registerFilter()', function () {
let liquid: Liquid
@@ -67,3 +67,32 @@ describe('liquid#registerFilter()', function () {
await expect(new Liquid({ strictFilters: true }).parseAndRender('{{ 1 | constructor }}')).rejects.toThrow('undefined filter')
})
})
describe('liquid#unregisterFilter()', function () {
let liquid: Liquid
beforeEach(() => { liquid = new Liquid() })
it('should unregister a custom filter', async () => {
liquid.registerFilter('greet', value => `hello ${value}`)
liquid.unregisterFilter('greet')
const html = await liquid.parseAndRender('{{ "world" | greet }}')
return expect(html).toBe('world')
})
it('should unregister a built-in filter', () => {
liquid = new Liquid({ strictFilters: true })
liquid.unregisterFilter('upcase')
return expect(liquid.parseAndRender('{{ "foo" | upcase }}')).rejects.toThrow('undefined filter: upcase')
})
it('should support re-registering a built-in filter', async () => {
liquid.unregisterFilter('upcase')
liquid.registerFilter('upcase', filters.upcase)
const html = await liquid.parseAndRender('{{ "foo" | upcase }}')
return expect(html).toBe('FOO')
})
it('should not throw for an unknown filter', () => {
expect(() => liquid.unregisterFilter('unknown')).not.toThrow()
})
})
+13 -32
View File
@@ -1,57 +1,38 @@
import { Liquid } from '../../../src/liquid'
import { Tag } from '../../../src/template/tag'
import type { Context } from '../../../src/context'
import type { TagToken, TopLevelToken } from '../../../src/tokens'
describe('liquid#registerTag()', function () {
it('should support render to simple string', async () => {
class SimpleStringTag extends Tag {
render () {
return 'B'
}
}
const liquid = new Liquid()
liquid.registerTag('simple-string', SimpleStringTag)
liquid.registerTag('simple-string', {
render: () => 'B'
})
const html = await liquid.parseAndRender(`A{% simple-string %}C`)
return expect(html).toBe('ABC')
})
it('should support async tag render', async () => {
class AsyncStringTag extends Tag {
async render () {
return 'B'
}
}
const liquid = new Liquid()
liquid.registerTag('async-string', AsyncStringTag)
liquid.registerTag('async-string', {
render: async () => 'B'
})
const html = await liquid.parseAndRender(`A{% async-string %}C`)
return expect(html).toBe('ABC')
})
it('should have access to ctx in render()', async () => {
class DynamicStringTag extends Tag {
async render (ctx: Context) {
return ctx.get(['c'])
}
}
const liquid = new Liquid()
liquid.registerTag('dynamic-string', DynamicStringTag)
liquid.registerTag('dynamic-string', {
render: async (ctx) => ctx.get(['c'])
})
const html = await liquid.parseAndRender(`A{% dynamic-string %}C`, {
c: 'B'
})
return expect(html).toBe('ABC')
})
it('should have access to tag arguments', async () => {
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])
}
}
const liquid = new Liquid()
liquid.registerTag('argument-reflector', ArgumentReflectorTag)
liquid.registerTag('argument-reflector', {
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`, {
c: 'B'
})
@@ -1,61 +0,0 @@
import { Liquid } from '../../../src/liquid'
import { Drop } from '../../../src/drop/drop'
describe('scope security', function () {
let liquid: Liquid
beforeEach(function () {
liquid = new Liquid()
})
it('should iterate plain objects via inherited Symbol.iterator (ownPropertyOnly exception)', async function () {
// eslint-disable-next-line no-extend-native
(Object.prototype as any)[Symbol.iterator] = function * () { yield 'inherited' }
try {
await expect(liquid.parseAndRender(
'{% for x in obj %}{{ x }}{% endfor %}',
{ obj: {} }
)).resolves.toBe('inherited')
} finally {
delete (Object.prototype as any)[Symbol.iterator]
}
})
it('should not read inherited size on plain objects', async function () {
const obj = Object.create({ size: 99 })
obj.own = 'yes'
await expect(liquid.parseAndRender('{{ obj.size }}', { obj })).resolves.toBe('1')
})
it('should read inherited size when ownPropertyOnly=false', async function () {
liquid = new Liquid({ ownPropertyOnly: false })
const obj = Object.create({ size: 99 })
await expect(liquid.parseAndRender('{{ obj.size }}', { obj })).resolves.toBe('99')
})
it('should still iterate Drop with Symbol.iterator', async function () {
class IterableDrop extends Drop {
* [Symbol.iterator] () {
yield 'a'
yield 'b'
}
}
await expect(liquid.parseAndRender(
'{% for x in drop %}{{ x }}{% endfor %}',
{ drop: new IterableDrop() }
)).resolves.toBe('ab')
})
it('should block own blocked keys when ownPropertyOnly=true', async function () {
const scope = JSON.parse('{"__proto__": {"polluted": true}, "constructor": {"name": "Custom"}, "name": "Alice"}')
await expect(liquid.parseAndRender('{{ __proto__.polluted }}', scope)).resolves.toBe('')
await expect(liquid.parseAndRender('{{ constructor.name }}', scope)).resolves.toBe('')
await expect(liquid.parseAndRender('{{ name }}', scope)).resolves.toBe('Alice')
})
it('should block inherited properties when ownPropertyOnly=true', async function () {
const scope = { foo: Object.create({ __proto__: { bar: 'BAR' }, constructor: { name: 'Evil' } }) }
await expect(liquid.parseAndRender('{{ foo.__proto__ }}', scope)).resolves.toBe('')
await expect(liquid.parseAndRender('{{ foo.constructor }}', scope)).resolves.toBe('')
})
})
+14 -8
View File
@@ -2,8 +2,7 @@ import { RenderError } from '../../../src/util/error'
import { Liquid } from '../../../src/liquid'
import { resolve } from 'path'
import { mock, restore } from '../../stub/mockfs'
import { throwIntendedError } from '../../stub/util'
import { ThrowingTag, RejectingTag, ThrowsOnParseTag } from '../../stub/tags'
import { throwIntendedError, rejectIntendedError } from '../../stub/util'
const strictEngine = new Liquid({
strictVariables: true,
@@ -14,9 +13,9 @@ const strictCatchingEngine = new Liquid({
strictVariables: true,
strictFilters: true
})
strictEngine.registerTag('throwingTag', ThrowingTag)
strictEngine.registerTag('throwingTag', { render: throwIntendedError })
strictEngine.registerFilter('throwingFilter', throwIntendedError)
strictCatchingEngine.registerTag('throwingTag', ThrowingTag)
strictCatchingEngine.registerTag('throwingTag', { render: throwIntendedError })
strictCatchingEngine.registerFilter('throwingFilter', throwIntendedError)
describe('error', function () {
@@ -84,8 +83,8 @@ describe('error', function () {
engine = new Liquid({
root: '/'
})
engine.registerTag('throwingTag', ThrowingTag)
engine.registerTag('rejectingTag', RejectingTag)
engine.registerTag('throwingTag', { render: throwIntendedError })
engine.registerTag('rejectingTag', { render: rejectIntendedError })
engine.registerFilter('throwingFilter', throwIntendedError)
})
it('should throw RenderError when tag throws', async function () {
@@ -245,7 +244,10 @@ describe('error', function () {
let engine: Liquid
beforeEach(function () {
engine = new Liquid()
engine.registerTag('throwsOnParse', ThrowsOnParseTag)
engine.registerTag('throwsOnParse', {
parse: throwIntendedError,
render: () => ''
})
})
it('should throw ParseError when filter not defined', async function () {
await expect(strictEngine.parseAndRender('{{1 | a}}')).rejects.toMatchObject({
@@ -335,7 +337,11 @@ describe('error', function () {
engine = new Liquid({
root: '/'
})
engine.registerTag('throwingTag', ThrowingTag)
engine.registerTag('throwingTag', {
render: function () {
throw new Error('intended error')
}
})
})
it('should throw RenderError when tag throws', function () {
const src = '{%throwingTag%}'
@@ -1085,4 +1085,24 @@ describe('Variable analysis', () => {
locals: { y: [new Variable(['y'], { row: 1, col: 11, file: 'a' })] }
})
})
describe('grouped expressions', () => {
const ge = new Liquid({ groupedExpressions: true })
it('should report variables inside a grouped output expression', () => {
const analysis = analyzeSync(ge.parse('{{ (a | append: b) }}'))
expect(Object.keys(analysis.variables).sort()).toStrictEqual(['a', 'b'])
})
it('should report variables inside a grouped condition', () => {
const analysis = analyzeSync(ge.parse('{% if (a | append: b) == c %}{% endif %}'))
expect(Object.keys(analysis.globals).sort()).toStrictEqual(['a', 'b', 'c'])
})
it('should separate locals from globals for grouped assign', () => {
const analysis = analyzeSync(ge.parse('{% assign x = (a | upcase) %}{{ x }}'))
expect(Object.keys(analysis.globals)).toStrictEqual(['a'])
expect(Object.keys(analysis.locals)).toStrictEqual(['x'])
})
})
})
+9
View File
@@ -99,4 +99,13 @@ describe('tags/assign', function () {
const html = liquid.parseAndRenderSync(src)
return expect(html).toBe('bar')
})
describe('grouped expressions', function () {
const ge = new Liquid({ groupedExpressions: true })
it('should assign a grouped expression', async () => {
expect(await ge.parseAndRender('{% assign x = (name | upcase) %}{{ x }}', { name: 'bar' })).toBe('BAR')
})
it('should assign a grouped range', async () => {
expect(await ge.parseAndRender('{% assign x = (1..(items | size)) %}{{ x }}', { items: ['a', 'b', 'c'] })).toBe('123')
})
})
})
+26
View File
@@ -132,4 +132,30 @@ describe('tags/case', function () {
TRUE
`)
})
describe('parenthesized filter chains', function () {
describe('when enabled', () => {
const ge = new Liquid({ groupedExpressions: true })
it('should support grouped expression in case value', () => {
const src = '{% case (status | downcase) %}{% when "active" %}active{% when "pending" %}pending{% else %}other{% endcase %}'
const html = ge.parseAndRenderSync(src, { status: 'ACTIVE' })
expect(html).toBe('active')
})
it('should support grouped expression in when value', () => {
const src = '{% case status %}{% when (expected | downcase) %}match{% else %}no match{% endcase %}'
const html = ge.parseAndRenderSync(src, { status: 'active', expected: 'ACTIVE' })
expect(html).toBe('match')
})
})
describe('when disabled', () => {
const ge = new Liquid({ groupedExpressions: false })
it('should throw error for grouped expression in case value', () => {
const src = '{% case (status | downcase) %}{% when "active" %}active{% when "pending" %}pending{% else %}other{% endcase %}'
expect(() => ge.parseAndRenderSync(src, { status: 'ACTIVE' })).toThrow('invalid range syntax')
})
it('should throw error for grouped expression in when value', () => {
const src = '{% case status %}{% when (expected | downcase) %}match{% else %}no match{% endcase %}'
expect(() => ge.parseAndRenderSync(src, { status: 'active', expected: 'ACTIVE' })).toThrow('invalid range syntax')
})
})
})
})
+10
View File
@@ -36,4 +36,14 @@ describe('tags/echo', function () {
const html = await liquid.parseAndRender(src, { user: { name: 'Sally' } })
return expect(html).toBe('Hello, SALLY!')
})
describe('grouped expressions', function () {
const ge = new Liquid({ groupedExpressions: true })
it('should echo a grouped expression', async () => {
expect(await ge.parseAndRender('{% echo (name | upcase) %}', { name: 'bar' })).toBe('BAR')
})
it('should render a grouped expression in an output statement', async () => {
expect(await ge.parseAndRender('{{ (name | upcase | append: "!") }}', { name: 'bar' })).toBe('BAR!')
})
})
})
+20 -12
View File
@@ -7,6 +7,9 @@ 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') }
})
scope = {
one: 1,
// eslint-disable-next-line
@@ -120,18 +123,6 @@ describe('tags/for', function () {
const html = await liquid.parseAndRender(src, scope)
return expect(html).toBe('b')
})
it('should goto else when limit empties collection', async function () {
const src = '{%for c in alpha limit:0%}a{%else%}b{%endfor%}'
const html = await liquid.parseAndRender(src, scope)
return expect(html).toBe('b')
})
it('should goto else when offset past end', async function () {
const src = '{%for c in alpha offset:10%}a{%else%}b{%endfor%}'
const html = await liquid.parseAndRender(src, scope)
return expect(html).toBe('b')
})
})
it('should support for with forloop', async function () {
@@ -435,4 +426,21 @@ describe('tags/for', function () {
return expect(html).toBe('i-someDrop i-someDrop i-someDrop ')
})
})
describe('parenthesized filter chains', function () {
describe('when enabled', function () {
const ge = new Liquid({ groupedExpressions: true })
it('should support range with filtered RHS', function () {
const src = '{% for i in (1..(items | size)) %}{{i}} {% endfor %}'
const html = ge.parseAndRenderSync(src, { items: ['a', 'b', 'c'] })
expect(html).toBe('1 2 3 ')
})
})
describe('when disabled', function () {
const ge = new Liquid({ groupedExpressions: false })
it('should throw for range with filtered RHS', function () {
const src = '{% for i in (1..(items | size)) %}{{i}} {% endfor %}'
expect(() => ge.parseAndRenderSync(src, { items: ['a', 'b', 'c'] })).toThrow('invalid range syntax')
})
})
})
})
+80
View File
@@ -169,4 +169,84 @@ describe('tags/if', function () {
expect(() => liquid.parseAndRenderSync('{% if false %}{% else %}{% elsif true %}{% endif %}'))
.toThrow(`unexpected elsif after else`)
})
describe('parenthesized filter chains', function () {
describe('when enabled', function () {
const ge = new Liquid({ groupedExpressions: true })
it('should support (foo | upcase) == "BAR"', async function () {
const src = '{% if (foo | upcase) == "BAR" %}yes{% else %}no{% endif %}'
const html = await ge.parseAndRender(src, { foo: 'bar' })
return expect(html).toBe('yes')
})
it('should support both sides parenthesized', async function () {
const src = '{% if (a | upcase) == (b | upcase) %}yes{% else %}no{% endif %}'
const html = await ge.parseAndRender(src, { a: 'hi', b: 'hi' })
return expect(html).toBe('yes')
})
it('should support with logical operators', async function () {
const src = '{% if (a | upcase) == "FOO" and (b | downcase) == "bar" %}yes{% else %}no{% endif %}'
const html = await ge.parseAndRender(src, { a: 'foo', b: 'BAR' })
return expect(html).toBe('yes')
})
it('should support standalone parenthesized filter via evalValueSync', function () {
const result = ge.evalValueSync('(foo | upcase)', { foo: 'bar' })
return expect(result).toBe('BAR')
})
it('should support comparison via evalValueSync', function () {
const result = ge.evalValueSync('(foo | upcase) == "BAR"', { foo: 'bar' })
return expect(result).toBe(true)
})
it('should keep range syntax working', function () {
const result = ge.evalValueSync('(1..5)', {})
return expect(result).toEqual([1, 2, 3, 4, 5])
})
it('should support chained filters in condition', async function () {
const src = '{% if (name | downcase | size) > 3 %}long{% else %}short{% endif %}'
const html = await ge.parseAndRender(src, { name: 'Alice' })
return expect(html).toBe('long')
})
it('should support real parenthesis grouping with comparisons and and', async function () {
const src = '{% if (((name | downcase | size) > 3) and (one < three)) %}long{% else %}short{% endif %}'
const html = await ge.parseAndRender(src, { name: 'Alice', one: 1, three: 3 })
return expect(html).toBe('long')
})
it('should support nested parenthesized expressions in if condition', async function () {
const src = '{% if ((foo | append: "!") | upcase) == "BAR!" %}match{% else %}no match{% endif %}'
const html = await ge.parseAndRender(src, { foo: 'bar' })
return expect(html).toBe('match')
})
it('should support or with grouped operands', async function () {
const src = '{% if (a | upcase) == "X" or (b | upcase) == "B" %}yes{% else %}no{% endif %}'
expect(await ge.parseAndRender(src, { a: 'z', b: 'b' })).toBe('yes')
})
it('should support not with a grouped operand', async function () {
const src = '{% if not (a | upcase) == "B" %}no{% else %}yes{% endif %}'
expect(await ge.parseAndRender(src, { a: 'b' })).toBe('yes')
})
it('should support contains with a grouped operand', async function () {
const src = '{% if (csv | split: ",") contains "b" %}yes{% else %}no{% endif %}'
expect(await ge.parseAndRender(src, { csv: 'a,b,c' })).toBe('yes')
})
it('should support property access on a grouped result', function () {
expect(ge.evalValueSync('(items | first).name', { items: [{ name: 'Sally' }] })).toBe('Sally')
})
it('should support a grouped expression inside a bracket index', function () {
expect(ge.evalValueSync('arr[(i | plus: 1)]', { arr: [10, 20, 30], i: 1 })).toBe(30)
})
it('should support an async filter inside a grouped expression', async function () {
ge.registerFilter('asyncUpcase', (v: string) => Promise.resolve(String(v).toUpperCase()))
const src = '{% if (name | asyncUpcase) == "BAR" %}yes{% else %}no{% endif %}'
expect(await ge.parseAndRender(src, { name: 'bar' })).toBe('yes')
})
})
describe('when disabled', function () {
const ge = new Liquid({ groupedExpressions: false })
it('should throw for parenthesized filter in condition', () => {
const src = '{% if (foo | upcase) == "BAR" %}yes{% else %}no{% endif %}'
expect(() => ge.parseAndRenderSync(src, { foo: 'bar' })).toThrow('invalid range syntax')
})
it('should throw for parenthesized filter via evalValueSync', () => {
expect(() => ge.evalValueSync('(foo | upcase)', { foo: 'bar' })).toThrow('invalid range syntax')
})
})
})
})
+17
View File
@@ -83,4 +83,21 @@ describe('tags/unless', function () {
expect(html).toBe('yes')
})
})
describe('parenthesized filter chains', function () {
describe('when enabled', function () {
const ge = new Liquid({ groupedExpressions: true })
it('should support grouped expression in unless condition', function () {
const src = '{% unless (content | size) == 0 %}has content{% else %}empty{% endunless %}'
const html = ge.parseAndRenderSync(src, { content: 'hello' })
expect(html).toBe('has content')
})
})
describe('when disabled', function () {
const ge = new Liquid({ groupedExpressions: false })
it('should throw for grouped expression in unless condition', function () {
const src = '{% unless (content | size) == 0 %}has content{% else %}empty{% endunless %}'
expect(() => ge.parseAndRenderSync(src, { content: 'hello' })).toThrow('invalid range syntax')
})
})
})
})
-26
View File
@@ -1,26 +0,0 @@
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 ''
}
}
+3 -4
View File
@@ -1,9 +1,8 @@
{
"compilerOptions": {
"target": "ES2020",
"module":"ES2020",
"lib": ["ES2020", "dom"],
"moduleResolution": "node",
"target": "es6",
"module":"CommonJS",
"lib": ["es2015", "es2016", "es2017", "dom"],
"sourceMap": true,
"outDir": "dist",
"declaration": true,