mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-13 03:10:40 -07:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39233ba9f2 | ||
|
|
8f57d9fed8 | ||
|
|
9af92f5d8c | ||
|
|
747bdbdbee | ||
|
|
875513f4c5 | ||
|
|
f88a528e27 | ||
|
|
69b2c589f9 | ||
|
|
88ae297c1b | ||
|
|
afa5f54004 | ||
|
|
39c87437c5 | ||
|
|
050f161794 | ||
|
|
2634f9de7b | ||
|
|
f9a1316d16 |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
-1
@@ -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');
|
||||
|
||||
Generated
+3
-3
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './emitter'
|
||||
export * from './simple-emitter'
|
||||
export * from './streamed-emitter'
|
||||
export * from './keeping-type-emitter'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -12,5 +12,6 @@ export enum TokenKind {
|
||||
Quoted = 1024,
|
||||
Operator = 2048,
|
||||
FilteredValue = 4096,
|
||||
GroupedExpression = 8192,
|
||||
Delimited = Tag | Output
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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[]> {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }}',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user