mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-13 03:10:40 -07:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e5d0cc9a1 | ||
|
|
7a77fa4f64 | ||
|
|
0681ff843c | ||
|
|
fae50bd72c | ||
|
|
45b48bc8fe | ||
|
|
2803730a14 | ||
|
|
3129d46dc9 | ||
|
|
5b9c346908 | ||
|
|
dbbf628803 | ||
|
|
26ea2856c7 | ||
|
|
a55f543f49 | ||
|
|
d1d517d1ec | ||
|
|
1c816d4fc3 | ||
|
|
34877950bf | ||
|
|
75c815a4d7 | ||
|
|
f1f896c29d | ||
|
|
0ee6dbb511 | ||
|
|
30e04ba16d | ||
|
|
e2311dfd6e | ||
|
|
2def22c85e | ||
|
|
4af7be695c | ||
|
|
05c47da46d | ||
|
|
66011d14b0 | ||
|
|
1cdf10b57d | ||
|
|
4f9a49988a | ||
|
|
f41c1fc02f | ||
|
|
db4348507e | ||
|
|
e743da0020 | ||
|
|
8f69a08399 | ||
|
|
529dd67eeb | ||
|
|
abc058be0f | ||
|
|
521177e3f6 | ||
|
|
75e06eff92 | ||
|
|
0ad2b11ab1 | ||
|
|
97d829116c | ||
|
|
35d5230263 | ||
|
|
94440a0653 | ||
|
|
95ddefc056 | ||
|
|
1b85fdaa9c | ||
|
|
93c38c7c6d | ||
|
|
c7a291b46b | ||
|
|
eb4683ee3f | ||
|
|
f1fc573a65 | ||
|
|
524cd92cfe | ||
|
|
0d9e797889 | ||
|
|
3cd024d652 | ||
|
|
85233e0568 | ||
|
|
02403a1879 | ||
|
|
1c6316111d | ||
|
|
71aa1b1998 | ||
|
|
350f95c8f7 | ||
|
|
955b7971c0 | ||
|
|
8686876067 | ||
|
|
3a02eb12bf | ||
|
|
906707833e | ||
|
|
a2da822cb8 | ||
|
|
86fc135d9e | ||
|
|
5d953132e8 |
+82
-1
@@ -14,7 +14,7 @@
|
||||
"login": "harttle",
|
||||
"name": "Jun Yang",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/4427974?v=4",
|
||||
"profile": "https://harttle.land",
|
||||
"profile": "https://github.com/harttle",
|
||||
"contributions": [
|
||||
"maintenance",
|
||||
"code"
|
||||
@@ -757,6 +757,87 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "rosomri",
|
||||
"name": "Omri Rosner",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/68001413?v=4",
|
||||
"profile": "https://github.com/rosomri",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "immerrr",
|
||||
"name": "immerrr again",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/579798?v=4",
|
||||
"profile": "https://github.com/immerrr",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "rongjiecomputer",
|
||||
"name": "Loo Rong Jie",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/13115060?v=4",
|
||||
"profile": "https://github.com/rongjiecomputer",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "MorielHarush",
|
||||
"name": "MorielHarush",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/93482738?v=4",
|
||||
"profile": "https://github.com/MorielHarush",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "peaktwilight",
|
||||
"name": "Peak Twilight",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/77903714?v=4",
|
||||
"profile": "https://doruk.ch",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "joecottam",
|
||||
"name": "Joe Cottam",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/44173086?v=4",
|
||||
"profile": "https://github.com/joecottam",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "timbze",
|
||||
"name": "Timmy Braun",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/35117769?v=4",
|
||||
"profile": "https://github.com/timbze",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "talboren",
|
||||
"name": "Tal",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/68807791?v=4",
|
||||
"profile": "https://github.com/talboren",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "VladimirFilonov",
|
||||
"name": "Vladimir Filonov",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/813224?v=4",
|
||||
"profile": "https://filonov.dev",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
description: Architecture overview for liquidjs internals
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## Async/sync duality via generators
|
||||
|
||||
All core logic is written once as a `Generator` function (`function *`). Use `yield` where you'd normally `await` a potentially async value.
|
||||
|
||||
- `toPromise(generator)` drives it **asynchronously** — awaits yielded promises.
|
||||
- `toValueSync(generator)` drives it **synchronously** — passes yielded values through as-is.
|
||||
|
||||
Never duplicate logic into separate async and sync methods. A single generator serves both paths.
|
||||
|
||||
When wrapping an async+sync function pair (e.g. `contains`/`containsSync`, `exists`/`existsSync`, `readFile`/`readFileSync`), use `toLiquidAsync(asyncFn, syncFn?)` which returns a `LiquidAsync<F>` — one function that picks the sync or async implementation based on a leading `sync: boolean` arg. Then `yield` the result inside a generator to let the driver handle it in both modes.
|
||||
|
||||
See `src/util/async.ts`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Project conventions for liquidjs
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
- Keep edits minimal: change only what the task requires, match existing style.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
description: Testing conventions — e2e uses built dist, integration uses src
|
||||
globs: test/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
## End-to-end tests (`test/e2e`)
|
||||
|
||||
- **Use the built package**, not TypeScript sources under `src/`.
|
||||
- Import the public API from the package root (for example `import { Liquid } from '../..'`), which resolves through `package.json` to **`dist/`** (`main`, `module`, etc.).
|
||||
- **Avoid** `import … from '../../src/liquid'` (or other `src/` paths) in `test/e2e/**` so e2e matches what consumers get from npm and you do not depend on an unbuilt tree.
|
||||
|
||||
## Integration and unit tests
|
||||
|
||||
- Tests under `test/integration/`, `src/**/*.spec.ts`, and similar may import from **`src/`** when the suite is meant to run against the current TypeScript sources (typical for this repo’s Jest setup).
|
||||
@@ -4,6 +4,11 @@ jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
@@ -1,3 +1,75 @@
|
||||
## [10.25.7](https://github.com/harttle/liquidjs/compare/v10.25.6...v10.25.7) (2026-04-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **filters:** support Buffer input in base64_encode to prevent binary data corruption ([#881](https://github.com/harttle/liquidjs/issues/881)) ([0ee6dbb](https://github.com/harttle/liquidjs/commit/0ee6dbb511aa926f6d490293282060abf3bab37f))
|
||||
|
||||
## [10.25.6](https://github.com/harttle/liquidjs/compare/v10.25.5...v10.25.6) (2026-04-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* nested block for layout ([#883](https://github.com/harttle/liquidjs/issues/883)) ([e2311df](https://github.com/harttle/liquidjs/commit/e2311dfd6e82f73509308aa8a3a1fafc92e226f0))
|
||||
|
||||
## [10.25.5](https://github.com/harttle/liquidjs/compare/v10.25.4...v10.25.5) (2026-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce root containment for renderFile/parseFile lookups ([#870](https://github.com/harttle/liquidjs/issues/870)) ([f41c1fc](https://github.com/harttle/liquidjs/commit/f41c1fc02fe901598f3328118b42b13bc6bc9b04))
|
||||
* null date should return empty ([#868](https://github.com/harttle/liquidjs/issues/868)) ([#872](https://github.com/harttle/liquidjs/issues/872)) ([4f9a499](https://github.com/harttle/liquidjs/commit/4f9a49988a93c156524981e189a4fec238e682b8))
|
||||
* rounding negative away from zero when half ([#873](https://github.com/harttle/liquidjs/issues/873)) ([1cdf10b](https://github.com/harttle/liquidjs/commit/1cdf10b57d82f0592414efbfca19e204b37aea9f))
|
||||
|
||||
## [10.25.4](https://github.com/harttle/liquidjs/compare/v10.25.3...v10.25.4) (2026-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* sort and sort_natural filters bypass ownPropertyOnly ([#869](https://github.com/harttle/liquidjs/issues/869)) ([e743da0](https://github.com/harttle/liquidjs/commit/e743da0020d34e2ee547e1cc1a86b58377ebe1ce))
|
||||
|
||||
## [10.25.3](https://github.com/harttle/liquidjs/compare/v10.25.2...v10.25.3) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* precise memoryLimit for string replace ([abc058b](https://github.com/harttle/liquidjs/commit/abc058be0f33d6372cd2216f4945183167abeb25))
|
||||
* use realpath for fs.contains ([#867](https://github.com/harttle/liquidjs/issues/867)) ([529dd67](https://github.com/harttle/liquidjs/commit/529dd67eeb6b125637623d6a723601f0938d3613))
|
||||
|
||||
## [10.25.2](https://github.com/harttle/liquidjs/compare/v10.25.1...v10.25.2) (2026-03-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle undefined replacement argument in replace filter ([#864](https://github.com/harttle/liquidjs/issues/864)) ([0ad2b11](https://github.com/harttle/liquidjs/commit/0ad2b11ab15e7da608a9ef936b2a00a6a6517038))
|
||||
|
||||
## [10.25.1](https://github.com/harttle/liquidjs/compare/v10.25.0...v10.25.1) (2026-03-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mem limiter for invalid ranges ([95ddefc](https://github.com/harttle/liquidjs/commit/95ddefc056a11a44d9e753fd47a39db2c241e578))
|
||||
* treat args for replace_first as literal ([35d5230](https://github.com/harttle/liquidjs/commit/35d523026345d80458df24c72e653db78b5d061d))
|
||||
|
||||
# [10.25.0](https://github.com/harttle/liquidjs/compare/v10.24.0...v10.25.0) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path traversal vulnerability, [#851](https://github.com/harttle/liquidjs/issues/851) ([#855](https://github.com/harttle/liquidjs/issues/855)) ([3cd024d](https://github.com/harttle/liquidjs/commit/3cd024d652dc883c46307581e979fe32302adbac))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* export error types, resolving [#837](https://github.com/harttle/liquidjs/issues/837) ([#840](https://github.com/harttle/liquidjs/issues/840)) ([71aa1b1](https://github.com/harttle/liquidjs/commit/71aa1b1998a3a66e536af67c6ea8947a28616eaf))
|
||||
|
||||
# [10.24.0](https://github.com/harttle/liquidjs/compare/v10.23.0...v10.24.0) (2025-10-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **filters:** Add base64_encode and base64_decode filters for Shopify compatibility ([#828](https://github.com/harttle/liquidjs/issues/828)) ([86fc135](https://github.com/harttle/liquidjs/commit/86fc135d9ec0137689faf150535b9315e75ecc30))
|
||||
|
||||
# [10.23.0](https://github.com/harttle/liquidjs/compare/v10.22.0...v10.23.0) (2025-10-23)
|
||||
|
||||
|
||||
|
||||
@@ -54,9 +54,9 @@ For more details, refer to the [Setup Guide][setup].
|
||||
|
||||
- [Eleventy](https://www.11ty.dev/): Eleventy, a simpler static site generator.
|
||||
- [Github Docs](https://github.com/github/docs): The open-source repo for docs.github.com.
|
||||
- [Kibana](https://github.com/elastic/kibana): Elastic's analytics and visualization platform for Elasticsearch; workflow features use LiquidJS for Liquid templates.
|
||||
- [Opensense](https://www.opensense.com/): The smarter way to send email.
|
||||
- [Directus](https://docs.directus.io/): an instant REST+GraphQL API and intuitive no-code data collaboration app for any SQL database.
|
||||
- [Semgrep](https://github.com/returntocorp/semgrep): Lightweight static analysis for many languages.
|
||||
- [Rock](https://www.rockrms.com/): An open source CMS, Relationship Management System (RMS) and Church Management System (ChMS) all rolled into one.
|
||||
- [Mitosis](https://github.com/BuilderIO/mitosis): Write components once, run everywhere. Compiles to React, Vue, Qwik, Solid, Angular, Svelte, and more.
|
||||
- [Pattern Lab](https://patternlab.io/): a frontend workshop environment that helps you build, view, test, and showcase your design system's UI components.
|
||||
@@ -64,6 +64,7 @@ For more details, refer to the [Setup Guide][setup].
|
||||
- [Microsoft Power Pages](https://learn.microsoft.com/en-us/power-pages/introduction): a secure, enterprise-grade, low-code software as a service (SaaS) platform for creating, hosting, and administering modern external-facing business websites.
|
||||
- [Azure API Management developer portal](https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-developer-portal): an automatically generated, fully customizable website with the documentation of your APIs.
|
||||
- [WISMOlabs](https://wismolabs.com/): Post Purchase Experience platform for eCommerce retailers enhancing customer satisfaction by using LiquidJS to provide customizable post-purchase experiences through programmable email, SMS, order tracking pages, and webhooks.
|
||||
- [Freshet](https://chromewebstore.google.com/detail/freshet/mpclplhdencffbilobpcapccnihpelcg): *JSON in, page out* — a Chrome extension that uses LiquidJS templates per URL pattern, so the JSON becomes a rendered, useful page.
|
||||
|
||||
Feel free to create a PR or contact me to add your use case into this list!
|
||||
|
||||
@@ -72,35 +73,31 @@ Feel free to create a PR or contact me to add your use case into this list!
|
||||
If you personally love LiquidJS or it's benefiting your business, please consider financially support us via [GitHub Sponsors](https://github.com/sponsors/harttle). Special thanks to our sponsors!
|
||||
|
||||
<!-- FINANCIAL-CONTRIBUTORS-BEGIN -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.opensense.com/"><img src="https://images.opencollective.com/opensense-inc/bf840ae/logo/256.png?height=100" width="100px;" alt="Opensense Inc."/><br /><sub><b>Opensense</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.11ty.dev/"><img src="https://avatars.githubusercontent.com/u/35147177?v=4&s=100" width="100px;" alt="Eleventy"/><br /><sub><b>Eleventy</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://about.me/peterdehaan"><img src="https://avatars2.githubusercontent.com/u/557895?v=4&s=100" width="100px;" alt="Peter deHaan"/><br /><sub><b>Peter deHaan</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://opencollective.com/touchless"><img src="https://images.opencollective.com/touchless/273bc74/logo/256.png?height=100" width="100px;" alt="Touchless"/><br /><sub><b>Touchless</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.dropkiq.com/"><img src="https://images.opencollective.com/1bertlol/43a8ea8/logo/256.png?height=100" width="100px;" alt="Adam Darrah"/><br /><sub><b>Dropkiq</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://dailycontributors.com/"><img src="https://images.opencollective.com/dailycontributors/3c2e057/logo/256.png?height=100&width=100" width="100px;" alt="Dailycontributors"/><br /><sub><b>Dailycontributors</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/coni2k"><img src="https://avatars0.githubusercontent.com/u/1284601?v=4&s=100" width="100px;" alt="coni2k"/><br /><sub><b>Serkan Holat</b></sub></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/amit777"><img src="https://avatars0.githubusercontent.com/u/2703309?v=4&s=100" width="100px;" alt="amit777"/><br /><sub><b>amit777</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://opencollective.com/khaled-salem"><img src="https://images.opencollective.com/khaled-salem/avatar/256.png?height=256" width="100px;" alt="Khaled Salem"/><br /><sub><b>Khaled Salem</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://sentry.io/"><img src="https://avatars.githubusercontent.com/u/1396951?v=4&s=100" width="100px;" alt="Sentry"/><br /><sub><b>Sentry</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.checkoutblocks.com/"><img src="https://avatars.githubusercontent.com/u/114603307?v=4&s=100" width="100px;" alt="Checkout Blocks"/><br /><sub><b>Checkout Blocks</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://customer.io/"><img src="https://avatars.githubusercontent.com/u/1152079?v=4&s=100" width="100px;" alt="Customer IO"/><br /><sub><b>Customer IO</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/15fathoms"><img src="https://avatars.githubusercontent.com/u/79156039?v=4&s=100" width="100px;" alt="Emmanuel Cartelli"/><br /><sub><b>Emmanuel Cartelli</b></sub></a><br /></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/microsoft"><img src="https://avatars.githubusercontent.com/u/6154722?v=4&s=100" width="100px;" alt="Microsoft"/><br /><sub><b>Microsoft</b></sub></a><br /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.pakstyle.pk/"><img src="https://images.opencollective.com/pakstyle/2b81605/logo/256.png?height=100" width="100px;" alt="PakStyle.pk"/><br /><sub><b>PakStyle.pk</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://syntax.fm/"><img src="https://avatars.githubusercontent.com/u/130389858?v=4&s=100" width="100px;" alt="Syntax Podcast"/><br /><sub><b>Syntax Podcast</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://opencollective.com/cartelli-emmanuel"><img src="https://images.opencollective.com/cartelli-emmanuel/avatar/256.png?height=100" width="100px;" alt="Cartelli Emmanuel"/><br /><sub><b>Cartelli Emmanuel</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.escorta.com/"><img src="https://images.opencollective.com/escortacom/avatar/256.png?height=100" width="100px;" alt="EscortA.com"/><br /><sub><b>EscortA.com</b></sub></a></td>
|
||||
<td align="center" valign="middle" width="14.28%"><a href="https://chudovo.com/"><img src="https://images.opencollective.com/Chudovo/avatar/256.png?height=100" style="max-width:100px;max-height:100px;" alt="Chudovo"/><br /><sub><b>Chudovo</b></sub></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p align="center" style="line-height: 2.5;">
|
||||
<a href="https://www.11ty.dev/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/35147177?v=4&s=100" height="80" style="vertical-align: middle;" alt="Eleventy" title="Eleventy"/></a>
|
||||
<a href="https://www.opensense.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/opensense-inc/bf840ae/logo/256.png?height=100" height="80" style="vertical-align: middle;" alt="Opensense Inc." title="Opensense"/></a>
|
||||
<a href="https://github.com/microsoft" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/6154722?v=4&s=100" height="80" style="vertical-align: middle;" alt="Microsoft" title="Microsoft"/></a>
|
||||
<a href="https://sentry.io/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/1396951?v=4&s=100" height="80" style="vertical-align: middle;" alt="Sentry" title="Sentry"/></a>
|
||||
<a href="https://www.checkoutblocks.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/114603307?v=4&s=100" height="80" style="vertical-align: middle;" alt="Checkout Blocks" title="Checkout Blocks"/></a>
|
||||
<a href="https://customer.io/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/1152079?v=4&s=100" height="80" style="vertical-align: middle;" alt="Customer IO" title="Customer IO"/></a>
|
||||
<a href="https://syntax.fm/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/130389858?v=4&s=100" height="80" style="vertical-align: middle;" alt="Syntax Podcast" title="Syntax Podcast"/></a>
|
||||
<br/>
|
||||
<a href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=liquidjs" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/27130435?s=200&v=4" width="80" style="vertical-align: middle;" alt="TestMu AI" title="TestMu AI"/></a>
|
||||
<a href="https://github.com/talboren" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/68807791?v=4&s=100" height="80" style="vertical-align: middle;" alt="Tal" title="Tal (@talboren)"/></a>
|
||||
<a href="https://chudovo.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/Chudovo/avatar/256.png?height=100" width="160" style="vertical-align: middle;background: white;padding: 8px 16px;" alt="Chudovo" title="Chudovo"/></a>
|
||||
<a href="https://dailycontributors.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/dailycontributors/3c2e057/logo/256.png?height=50&width=100" width="120" style="vertical-align: middle;" alt="Dailycontributors" title="Dailycontributors"/></a>
|
||||
<a href="https://www.pakstyle.pk/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/pakstyle/2b81605/logo/256.png?height=100" height="80" style="vertical-align: middle;" alt="PakStyle.pk" title="PakStyle.pk"/></a>
|
||||
<a href="https://www.escorta.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/escortacom/avatar/256.png?height=100" height="45" style="vertical-align: middle;" alt="EscortA.com" title="EscortA.com"/></a>
|
||||
<br/>
|
||||
<a href="https://opencollective.com/touchless" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/touchless/273bc74/logo/256.png?height=100" height="80" style="vertical-align: middle;" alt="Touchless" title="Touchless"/></a>
|
||||
<a href="https://www.dropkiq.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/1bertlol/43a8ea8/logo/256.png?height=100" height="80" style="vertical-align: middle;" alt="Dropkiq" title="Dropkiq"/></a>
|
||||
<a href="https://about.me/peterdehaan" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars2.githubusercontent.com/u/557895?v=4&s=100" height="80" style="vertical-align: middle;" alt="Peter deHaan" title="Peter deHaan"/></a>
|
||||
<a href="https://github.com/coni2k" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars0.githubusercontent.com/u/1284601?v=4&s=100" height="80" style="vertical-align: middle;" alt="Serkan Holat" title="Serkan Holat"/></a>
|
||||
<a href="https://github.com/amit777" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars0.githubusercontent.com/u/2703309?v=4&s=100" height="80" style="vertical-align: middle;" alt="amit777" title="amit777"/></a>
|
||||
<a href="https://opencollective.com/khaled-salem" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/khaled-salem/avatar/256.png?height=256" height="80" style="vertical-align: middle;" alt="Khaled Salem" title="Khaled Salem"/></a>
|
||||
<a href="https://github.com/15fathoms" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/79156039?v=4&s=100" height="80" style="vertical-align: middle;" alt="Emmanuel Cartelli" title="Emmanuel Cartelli"/></a>
|
||||
<a href="https://opencollective.com/cartelli-emmanuel" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/cartelli-emmanuel/avatar/256.png?height=100" height="80" style="vertical-align: middle;" alt="Cartelli Emmanuel" title="Cartelli Emmanuel"/></a>
|
||||
</p>
|
||||
<!-- FINANCIAL-CONTRIBUTORS-END -->
|
||||
|
||||
## Contributors ✨
|
||||
@@ -113,7 +110,7 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://harttle.land"><img src="https://avatars3.githubusercontent.com/u/4427974?v=4?s=100" width="100px;" alt="Jun Yang"/><br /><sub><b>Jun Yang</b></sub></a><br /><a href="#maintenance-harttle" title="Maintenance">🚧</a> <a href="https://github.com/harttle/liquidjs/commits?author=harttle" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/harttle"><img src="https://avatars3.githubusercontent.com/u/4427974?v=4?s=100" width="100px;" alt="Jun Yang"/><br /><sub><b>Jun Yang</b></sub></a><br /><a href="#maintenance-harttle" title="Maintenance">🚧</a> <a href="https://github.com/harttle/liquidjs/commits?author=harttle" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/chenos"><img src="https://avatars0.githubusercontent.com/u/2993310?v=4?s=100" width="100px;" alt="chenos"/><br /><sub><b>chenos</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=chenos" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://zachleat.com/"><img src="https://avatars2.githubusercontent.com/u/39355?v=4?s=100" width="100px;" alt="Zach Leatherman"/><br /><sub><b>Zach Leatherman</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/issues?q=author%3Azachleat" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/thardy"><img src="https://avatars3.githubusercontent.com/u/120636?v=4?s=100" width="100px;" alt="Tim Hardy"/><br /><sub><b>Tim Hardy</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=thardy" title="Code">💻</a></td>
|
||||
@@ -217,6 +214,17 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://gurdiga.com"><img src="https://avatars.githubusercontent.com/u/53922?v=4?s=100" width="100px;" alt="Vlad GURDIGA"/><br /><sub><b>Vlad GURDIGA</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=gurdiga" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.streakingman.com"><img src="https://avatars.githubusercontent.com/u/30397306?v=4?s=100" width="100px;" alt="裸奔狂甩丁丁"/><br /><sub><b>裸奔狂甩丁丁</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=StreakingMan" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/skynetigor"><img src="https://avatars.githubusercontent.com/u/20903171?v=4?s=100" width="100px;" alt="Ihor Panasiuk"/><br /><sub><b>Ihor Panasiuk</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=skynetigor" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rosomri"><img src="https://avatars.githubusercontent.com/u/68001413?v=4?s=100" width="100px;" alt="Omri Rosner"/><br /><sub><b>Omri Rosner</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=rosomri" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/immerrr"><img src="https://avatars.githubusercontent.com/u/579798?v=4?s=100" width="100px;" alt="immerrr again"/><br /><sub><b>immerrr again</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=immerrr" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/rongjiecomputer"><img src="https://avatars.githubusercontent.com/u/13115060?v=4?s=100" width="100px;" alt="Loo Rong Jie"/><br /><sub><b>Loo Rong Jie</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=rongjiecomputer" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/MorielHarush"><img src="https://avatars.githubusercontent.com/u/93482738?v=4?s=100" width="100px;" alt="MorielHarush"/><br /><sub><b>MorielHarush</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=MorielHarush" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://doruk.ch"><img src="https://avatars.githubusercontent.com/u/77903714?v=4?s=100" width="100px;" alt="Peak Twilight"/><br /><sub><b>Peak Twilight</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=peaktwilight" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/joecottam"><img src="https://avatars.githubusercontent.com/u/44173086?v=4?s=100" width="100px;" alt="Joe Cottam"/><br /><sub><b>Joe Cottam</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=joecottam" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/timbze"><img src="https://avatars.githubusercontent.com/u/35117769?v=4?s=100" width="100px;" alt="Timmy Braun"/><br /><sub><b>Timmy Braun</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=timbze" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/talboren"><img src="https://avatars.githubusercontent.com/u/68807791?v=4?s=100" width="100px;" alt="Tal"/><br /><sub><b>Tal</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=talboren" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://filonov.dev"><img src="https://avatars.githubusercontent.com/u/813224?v=4?s=100" width="100px;" alt="Vladimir Filonov"/><br /><sub><b>Vladimir Filonov</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=VladimirFilonov" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Only the latest major version is supported with security updates. It can be chan
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please contact yangjvn@126.com to report a vulnerability or change request.
|
||||
Please contact harttleharttle@gmail.com to report a vulnerability or change request.
|
||||
|
||||
- If the vulnerability in question affects common use cases, it will be treated as a bug and fixed very soon (typically within 1 week).
|
||||
- Otherwise, it'll be scheduled in the same priority of feature request (which is lower than bugs).
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
rm -rf docs/source/api
|
||||
typedoc --plugin typedoc-plugin-missing-exports ./src --gitRevision master --out docs/source/api
|
||||
@@ -0,0 +1,24 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const src = path.join(root, 'CHANGELOG.md')
|
||||
|
||||
let content = fs.readFileSync(src, 'utf8').replace(/\r\n/g, '\n')
|
||||
|
||||
const lines = content.split('\n')
|
||||
lines[0] = lines[0]
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
content = lines.join('\n')
|
||||
|
||||
content = content
|
||||
.replace(/{%/g, '{% raw %}{%{% endraw %}')
|
||||
.replace(/\{\{/g, '{% raw %}{{{% endraw %}')
|
||||
|
||||
const enFrontmatter = '---\ntitle: Changelog\nauto: true\n---\n\n'
|
||||
const zhFrontmatter = '---\ntitle: 更新日志\nauto: true\n---\n\n'
|
||||
|
||||
fs.writeFileSync(path.join(root, 'docs/source/tutorials/changelog.md'), enFrontmatter + content)
|
||||
fs.writeFileSync(path.join(root, 'docs/source/zh-cn/tutorials/changelog.md'), zhFrontmatter + content)
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cd docs
|
||||
cp ../CHANGELOG.md source/tutorials/changelog.md
|
||||
sed -i \
|
||||
-e 's/{%/{% raw %}{%{% endraw %}/g' \
|
||||
-e 's/{{/{% raw %}{{{% endraw %}/g' \
|
||||
-e '1 s/"/\"/g' \
|
||||
-e '1 s/</\</g' \
|
||||
-e '1 s/>/\>/g' \
|
||||
source/tutorials/changelog.md
|
||||
cp source/tutorials/changelog.md source/zh-cn/tutorials/changelog.md
|
||||
|
||||
sed -i -e '1i\---\ntitle: Changelog\nauto: true\n---\n' source/tutorials/changelog.md
|
||||
sed -i -e '1i\---\ntitle: 更新日志\nauto: true\n---\n' source/zh-cn/tutorials/changelog.md
|
||||
@@ -0,0 +1,39 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8').replace(/\r\n/g, '\n')
|
||||
|
||||
function extractSection (text, beginMarker, endMarker) {
|
||||
const lines = text.split('\n')
|
||||
let inside = false
|
||||
const result = []
|
||||
for (const line of lines) {
|
||||
if (line.includes(endMarker)) inside = false
|
||||
if (inside) result.push(line)
|
||||
if (line.includes(beginMarker)) inside = true
|
||||
}
|
||||
return result.join('\n')
|
||||
}
|
||||
|
||||
function transformContributors (html) {
|
||||
return html
|
||||
.replace(/<br \/>.*?<\/td>/g, '</a></td>')
|
||||
.replace(/width="[^"]*"/g, '')
|
||||
.replace(/\n/g, '')
|
||||
.replace(/<\/tr>\s*<tr>/g, '')
|
||||
}
|
||||
|
||||
function transformFinancial (html) {
|
||||
return html
|
||||
.replace(/<br \/>.*?<\/td>/g, '</a></td>')
|
||||
.replace(/\n/g, '')
|
||||
.replace(/<\/tr>\s*<tr>/g, '')
|
||||
}
|
||||
|
||||
const allContributors = transformContributors(extractSection(readme, 'ALL-CONTRIBUTORS-LIST:START', 'ALL-CONTRIBUTORS-LIST:END'))
|
||||
const financialContributors = transformFinancial(extractSection(readme, 'FINANCIAL-CONTRIBUTORS-BEGIN', 'FINANCIAL-CONTRIBUTORS-END'))
|
||||
|
||||
const outDir = path.join(root, 'docs/themes/navy/layout/partial')
|
||||
fs.writeFileSync(path.join(outDir, 'all-contributors.swig'), allContributors)
|
||||
fs.writeFileSync(path.join(outDir, 'financial-contributors.swig'), financialContributors)
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Run `sed` in a way that's compatible with both macOS (BSD) and Linux (GNU)
|
||||
sedi() {
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
/usr/bin/sed -i '' "$@"
|
||||
else
|
||||
sed -i "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# create docs/themes/navy/layout/partial/all-contributors.swig
|
||||
awk '/ALL-CONTRIBUTORS-LIST:START/{flag=1;next}/ALL-CONTRIBUTORS-LIST:END/{flag=0}flag' README.md | \
|
||||
sed 's/<br \/>.*<\/td>/<\/a><\/td>/g' | \
|
||||
sed 's/width="[^"]*"//g' | \
|
||||
tr -d '\n' | \
|
||||
sed 's/<\/tr>\s*<tr>//g' \
|
||||
> docs/themes/navy/layout/partial/all-contributors.swig
|
||||
|
||||
# create docs/themes/navy/layout/partial/financial-contributors.swig
|
||||
awk '/FINANCIAL-CONTRIBUTORS-BEGIN/{flag=1;next}/FINANCIAL-CONTRIBUTORS-END/{flag=0}flag' README.md | \
|
||||
sed 's/<br \/>.*<\/td>/<\/a><\/td>/g' | \
|
||||
sed 's/width="[^"]*"//g' | \
|
||||
tr -d '\n' | \
|
||||
sed 's/<\/tr>\s*<tr>//g' \
|
||||
> docs/themes/navy/layout/partial/financial-contributors.swig
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
BUNDLES=min npm run build
|
||||
mkdir -p docs/public/js/
|
||||
cp dist/liquid.browser.min.js docs/public/js/
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -ex
|
||||
|
||||
./bin/build-docs-liquid.sh
|
||||
./bin/build-contributors.sh
|
||||
./bin/build-apidoc.sh
|
||||
./bin/build-changelog.sh
|
||||
|
||||
cd docs
|
||||
npm ci
|
||||
npm run build
|
||||
cp CNAME public/
|
||||
|
||||
if [ "$HEXO_ALGOLIA_INDEXING_KEY" != "" ]; then
|
||||
npm run index
|
||||
fi
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Prerequisites:
|
||||
# 1.imagemagick. try brew install imagemagick
|
||||
# 2. logo.png in size 512x512
|
||||
# 3. this script should be run with cwd docs/source/icon/
|
||||
|
||||
echo creating apple touch icons...
|
||||
apple=(57x57 60x60 72x72 76x76 114x114 120x120 144x144 152x152)
|
||||
for size in "${apple[@]}"; do
|
||||
echo $size
|
||||
convert logo.png -resize $size apple-touch-icon-$size.png
|
||||
done
|
||||
cp logo.png apple-touch-icon.png
|
||||
convert logo.png \
|
||||
\( +clone -alpha extract \
|
||||
-draw 'fill black polygon 0,0 0,80 80,0 fill white circle 80,80 80,0' \
|
||||
\( +clone -flip \) -compose Multiply -composite \
|
||||
\( +clone -flop \) -compose Multiply -composite \
|
||||
\) -alpha off -compose CopyOpacity -composite apple-touch-icon-precomposed.png
|
||||
|
||||
echo creating favicon...
|
||||
convert logo.png -resize 48x48 ../favicon.ico
|
||||
favicon=(16x16 32x32 96x96 160x160 196x196)
|
||||
for size in "${favicon[@]}"; do
|
||||
echo $size
|
||||
convert logo.png -resize $size favicon-$size.png
|
||||
done
|
||||
|
||||
echo creating mstile icons...
|
||||
mstile=(70x70 144x144 150x150 310x310)
|
||||
for size in "${mstile[@]}"; do
|
||||
echo $size
|
||||
convert logo.png -resize $size mstile-$size.png
|
||||
done
|
||||
convert mstile-150x150.png -gravity center -background white -extent 310x150 mstile-310x150.png
|
||||
@@ -0,0 +1,17 @@
|
||||
const { execSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const version = require(path.join(root, 'package.json')).version
|
||||
|
||||
const fileLocal = path.join(root, 'dist/liquid.node.js')
|
||||
const fileLatest = path.join(root, `dist/liquid.node.${version}.js`)
|
||||
|
||||
if (!fs.existsSync(fileLatest)) {
|
||||
const url = `https://unpkg.com/liquidjs@${version}/dist/liquid.node.js`
|
||||
console.log(`Downloading liquidjs@${version}...`)
|
||||
execSync(`curl -sL -o "${fileLatest}" "${url}"`)
|
||||
}
|
||||
|
||||
execSync(`node benchmark/diff.js "${fileLocal}" "${fileLatest}"`, { cwd: root, stdio: 'inherit' })
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
VERSION_LATEST=$(cat package.json | grep '"version":' | head -1 | awk -F'"' '{print $4}')
|
||||
FILE_LOCAL=dist/liquid.node.js
|
||||
FILE_LATEST=dist/liquid.node.$VERSION_LATEST.js
|
||||
URL_LATEST=https://unpkg.com/liquidjs@$VERSION_LATEST/dist/liquid.node.js
|
||||
|
||||
if [ ! -f "$FILE_LATEST" ]; then
|
||||
curl $URL_LATEST > $FILE_LATEST
|
||||
fi
|
||||
|
||||
exec node benchmark/diff.js $FILE_LOCAL $FILE_LATEST
|
||||
@@ -8,7 +8,7 @@
|
||||
"test": "echo not implemented",
|
||||
"start": "http-server -c-1 "
|
||||
},
|
||||
"author": "harttle <yangjvn@126.com>",
|
||||
"author": "harttle <harttleharttle@gmail.com>",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"http-server": "^0.11.1",
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ const engine = new Liquid({
|
||||
// layout files for `{% layout %}`
|
||||
layouts: process.cwd() + '/layouts',
|
||||
// partial files for `{% include %}` and `{% render %}`
|
||||
partials: process.cwd() + '/partials'
|
||||
partials: [process.cwd() + '/partials', 'node_modules']
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
set -ex
|
||||
set -e
|
||||
|
||||
npm start | grep 'LiquidJS Demo'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
set -x
|
||||
set -e
|
||||
|
||||
LOG_FILE=$(mktemp)
|
||||
npm start > $LOG_FILE 2>&1 &
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
set -ex
|
||||
set -e
|
||||
|
||||
npm start | grep 'NodeJS Demo for LiquidJS'
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
set -ex
|
||||
set -e
|
||||
|
||||
npm start | grep '\[11:8] {{ todo }}'
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
set -ex
|
||||
set -e
|
||||
|
||||
npm run build && npm start | grep 'TypeScript Demo for LiquidJS'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
set -ex
|
||||
set -e
|
||||
|
||||
npm run build
|
||||
npm start | grep 'Webpack Demo for LiquidJS'
|
||||
|
||||
@@ -19,7 +19,7 @@ tutorials:
|
||||
plugins: plugins.html
|
||||
operators: operators.html
|
||||
truth: truthy-and-falsy.html
|
||||
dos: dos.html
|
||||
security_model: security-model.html
|
||||
static_analysis: static-analysis.html
|
||||
miscellaneous:
|
||||
migration9: migrate-to-9.html
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: base64_decode
|
||||
---
|
||||
|
||||
{% since %}v10.24.0{% endsince %}
|
||||
|
||||
Decodes a Base64-formatted string back to its original text.
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{{ "b25lIHR3byB0aHJlZQ==" | base64_decode }}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
one two three
|
||||
```
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{{ "SGVsbG8sIFdvcmxkISBAIyQl" | base64_decode }}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
Hello, World! @#$%
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: base64_encode
|
||||
---
|
||||
|
||||
{% since %}v10.24.0{% endsince %}
|
||||
|
||||
Encodes a string into Base64 format.
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{{ "one two three" | base64_encode }}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
b25lIHR3byB0aHJlZQ==
|
||||
```
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{{ "Hello, World! @#$%" | base64_encode }}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
SGVsbG8sIFdvcmxkISBAIyQl
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: hmac_sha256
|
||||
---
|
||||
|
||||
{% since %}vNEXT{% endsince %}
|
||||
|
||||
Converts a string into an SHA-256 hash using a hash message authentication code (HMAC). The secret key is passed as the filter argument. The output is a lowercase hexadecimal string.
|
||||
|
||||
Input
|
||||
|
||||
```liquid
|
||||
{%- assign secret_potion = 'Polyjuice' | hmac_sha256: 'Polina' -%}
|
||||
My secret potion: {{ secret_potion }}
|
||||
```
|
||||
|
||||
Output
|
||||
|
||||
```text
|
||||
My secret potion: 8e0d5d65cff1242a4af66c8f4a32854fd5fb80edcc8aabe9b302b29c7c71dc20
|
||||
```
|
||||
@@ -15,5 +15,7 @@ HTML/URI | escape, escape_once, url_encode, url_decode, strip_html, newline_to_b
|
||||
Array | slice, map, sort, sort_natural, uniq, where, where_exp, group_by, group_by_exp, find, find_exp, first, last, join, reverse, concat, compact, size, push, pop, shift, unshift
|
||||
Date | date, date_to_xmlschema, date_to_rfc822, date_to_string, date_to_long_string
|
||||
Misc | default, json, jsonify, inspect, raw, to_integer
|
||||
Base64 | base64_encode, base64_decode
|
||||
Crypto | sha256, hmac_sha256
|
||||
|
||||
[shopify/liquid]: https://github.com/Shopify/liquid
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: sha256
|
||||
---
|
||||
|
||||
{% since %}vNEXT{% endsince %}
|
||||
|
||||
Converts a string into an SHA-256 hash. The output is a lowercase hexadecimal string.
|
||||
|
||||
Input
|
||||
|
||||
```liquid
|
||||
{%- assign secret_potion = 'Polyjuice' | sha256 -%}
|
||||
My secret potion: {{ secret_potion }}
|
||||
```
|
||||
|
||||
Output
|
||||
|
||||
```text
|
||||
My secret potion: 44ac1d7a2936e30a5de07082fd65d6fe9b1fb658a1a98bfe65bc5959beac5dd0
|
||||
```
|
||||
@@ -24,6 +24,7 @@ Though we're trying to be compatible with the Ruby version, there are still some
|
||||
|
||||
* Truthy and Falsy. All values except `undefined`, `null`, `false` are truthy, whereas in Ruby Liquid all except `nil` and `false` are truthy. See [#26][#26].
|
||||
* Number. In JavaScript we cannot distinguish or convert between `float` and `integer`, see [#59][#59]. And when applied `size` filter, numbers always return 0, which is 8 for integer in ruby, cause they do not have a `length` property.
|
||||
* Stringify: We've aligned string coercion for primitive types. While some differences remain; for example, in Shopify/liquid, `strip` returns the "inspected" string of an input array, whereas in LiquidJS, the `strip` filter simply stringifies the input array [#852][#852].
|
||||
* [.to_liquid()](https://github.com/Shopify/liquid/wiki/Introduction-to-Drops) is replaced by `.toLiquid()`
|
||||
* [.to_s()](https://www.rubydoc.info/gems/liquid/Liquid/Drop) is replaced by JavaScript `.toString()`
|
||||
* Iteration order for objects. The iteration order of JavaScript objects, and thus LiquidJS objects, is a combination of the insertion order for string keys, and ascending order for number-like keys, while the iteration order of Ruby Hash is simply the insertion order.
|
||||
@@ -47,6 +48,7 @@ Though we're trying to be compatible with the Ruby version, there are still some
|
||||
[#236]: https://github.com/harttle/liquidjs/issues/236
|
||||
[#414]: https://github.com/harttle/liquidjs/discussions/414
|
||||
[#485]: https://github.com/harttle/liquidjs/discussions/485
|
||||
[#852]: https://github.com/harttle/liquidjs/discussions/852
|
||||
[sort]: https://liquidjs.com/filters/sort.html
|
||||
[stable-sort]: https://v8.dev/features/stable-sort
|
||||
[plugins]: ./plugins.html#Plugin-List
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
title: DoS Prevention
|
||||
---
|
||||
|
||||
When the template or data context cannot be trusted, enabling DoS prevention options is crucial. LiquidJS provides 3 options for this purpose: `parseLimit`, `renderLimit`, and `memoryLimit`.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Setting these options can largely ensure that your LiquidJS instance won't hang for extended periods or consume excessive memory. These limits are based on the available JavaScript APIs, so they are not precise hard limits but thresholds to help prevent your process from failing or hanging.
|
||||
|
||||
```typescript
|
||||
const liquid = new Liquid({
|
||||
parseLimit: 1e8, // typical size of your templates in each render
|
||||
renderLimit: 1000, // limit each render to be completed in 1s
|
||||
memoryLimit: 1e9, // memory available for LiquidJS (1e9 for 1GB)
|
||||
})
|
||||
```
|
||||
|
||||
When a `parse()` or `render()` cannot be completed within given resource, it throws.
|
||||
|
||||
## parseLimit
|
||||
|
||||
[parseLimit][parseLimit] restricts the size (character length) of templates parsed in each `.parse()` call, including referenced partials and layouts. Since LiquidJS parses template strings in near O(n) time, limiting total template length is usually sufficient.
|
||||
|
||||
A typical PC handles `1e8` (100M) characters without issues.
|
||||
|
||||
## renderLimit
|
||||
|
||||
Restricting template size alone is insufficient because dynamic loops with large counts can occur in render time. [renderLimit][renderLimit] mitigates this by limiting the time consumed by each `render()` call.
|
||||
|
||||
```liquid
|
||||
{%- for i in (1..10000000) -%}
|
||||
order: {{i}}
|
||||
{%- endfor -%}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
For time-consuming tags and filters within a single template, the process can still hang. For fully controlled rendering, consider using a process manager like [paralleljs][paralleljs].
|
||||
|
||||
## memoryLimit
|
||||
|
||||
Even with 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 %}
|
||||
```
|
||||
|
||||
[memoryLimit][memoryLimit] restricts memory-sensitive filters to prevent excessive memory allocation. As [JavaScript uses GC to manage memory](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management), `memoryLimit` limits only the total number of objects allocated by memory sensitive filters in LiquidJS thus may not reflect the actual memory footprint.
|
||||
|
||||
[paralleljs]: https://www.npmjs.com/package/paralleljs
|
||||
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
|
||||
[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
|
||||
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
|
||||
@@ -5,17 +5,64 @@ title: Operators
|
||||
LiquidJS operators are very simple and different. There're 2 types of operators supported:
|
||||
|
||||
* Comparison operators: `==`, `!=`, `>`, `<`, `>=`, `<=`
|
||||
* Logic operators: `or`, `and`, `contains`
|
||||
* Logic operators: `not`, `or`, `and`, `contains`
|
||||
|
||||
Thus numerical operators are not supported and you cannot even plus two numbers like this `{% raw %}{{a + b}}{% endraw %}`, instead we need a filter `{% raw %}{{ a | plus: b}}{% endraw %}`. Actually `+` is a valid variable name in LiquidJS.
|
||||
|
||||
## Logic Operators
|
||||
|
||||
### not
|
||||
|
||||
Negates a condition. Returns `true` if the condition is false, and `false` if the condition is true.
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{% if not user.active %}
|
||||
User is inactive
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### and
|
||||
|
||||
Returns `true` if both conditions are true.
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{% if user.age >= 18 and user.verified %}
|
||||
Access granted
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### or
|
||||
|
||||
Returns `true` if at least one condition is true.
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{% if user.isAdmin or user.isModerator %}
|
||||
You have elevated privileges
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### contains
|
||||
|
||||
Checks if a string contains a substring, or if an array contains an element.
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{% if product.title contains "Pack" %}
|
||||
This is a pack
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
## Precedence
|
||||
|
||||
1. Comparison operators. All comparison operations have the same precedence and higher than logic operators.
|
||||
2. Logic operators. All logic operators have the same precedence.
|
||||
1. Comparison operators, and `contains`. All comparison operators alongside `contains` have the same (highest) precedence.
|
||||
2. `not` operator. It has slightly more precedence than `or` and `and`.
|
||||
3. `or` and `and` operators. These logic operators have the same (lowest) precedence.
|
||||
|
||||
## Associativity
|
||||
|
||||
Logic operators are evaluated from right to left, see [shopify docs][operator-order].
|
||||
|
||||
[operator-order]: https://help.shopify.com/en/themes/liquid/basics/operators#order-of-operations
|
||||
[operator-order]: https://shopify.dev/docs/api/liquid/basics#order-of-operations
|
||||
|
||||
@@ -45,26 +45,20 @@ It can be a string-typed path (see above example), or a list of root directories
|
||||
|
||||
```javascript
|
||||
var engine = new Liquid({
|
||||
root: ['views/', 'views/partials/'],
|
||||
root: ['views/'],
|
||||
partials: ['views/partials/'],
|
||||
layouts: ['views/layouts/'],
|
||||
extname: '.liquid'
|
||||
});
|
||||
```
|
||||
|
||||
{% note tip Relative Paths %}Relative paths in <code>root</code> will be resolved against <code>cwd()</code>.{% endnote %}
|
||||
|
||||
When `{% raw %}{% render "foo" %}{% endraw %}` is rendered or `liquid.renderFile('foo')` is called, the following files will be looked up and the first existing file will be used:
|
||||
- When `parse()`, `render()` functions are called, for example `liquid.renderFile('foo')`, templates under `root` will be looked up.
|
||||
- When a partial is requested, for example `{% raw %}{% render "foo" %}{% endraw %}`, templates under `partials` will be looked up.
|
||||
- When a layout is requested, for example `{% raw %}{% layout "foo" %}{% endraw %}`, templates under `layouts` will be looked up.
|
||||
|
||||
- `cwd()`/views/foo.liquid
|
||||
- `cwd()`/views/partials/foo.liquid
|
||||
|
||||
If none of the above files exists, an `ENOENT` error will be thrown. Here's a demo for Node.js: [demo/nodejs](https://github.com/harttle/liquidjs/tree/master/demo/nodejs).
|
||||
|
||||
When LiquidJS is used in browser, say current location is <https://example.com/bar/index.html>, only the first `root` will be used and the file to be fetched is:
|
||||
|
||||
- <https://example.com/bar/foo.liquid>
|
||||
|
||||
If fetch fails, a 404/500 error or network failures for example, an `ENOENT` error will be thrown.
|
||||
Here's a demo for browsers: [demo/browser](https://github.com/harttle/liquidjs/tree/master/demo/browser).
|
||||
When LiquidJS is used in browser, the paths will be resolved based on current location. Here's a demo for browsers: [demo/browser](https://github.com/harttle/liquidjs/tree/master/demo/browser).
|
||||
|
||||
## Abstract File System
|
||||
|
||||
@@ -98,7 +92,7 @@ var engine = new Liquid({
|
||||
});
|
||||
```
|
||||
|
||||
{% note warn Path Traversal Vulnerability %}The default value of <code>contains()</code> always returns true. That means when specifying an abstract file system, you'll need to provide a proper <code>contains()</code> to avoid expose such vulnerabilities.{% endnote %}
|
||||
{% note warn Path Traversal Vulnerability %}The built-in Node <code>fs</code> implements <code>contains()</code> with realpath so templates cannot escape the root via symlinks. The browser bundle omits <code>contains</code> (loader treats paths as allowed). For a custom abstract <code>fs</code>, implement <code>contains</code> unless every resolved path is trusted.{% endnote %}
|
||||
|
||||
## In-memory Template
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: Security Model
|
||||
---
|
||||
|
||||
LiquidJS provides DoS-oriented limits (`parseLimit`, `renderLimit`, `memoryLimit`) to reduce risk. This page explains what each limit protects, and the security boundary you should assume in production.
|
||||
|
||||
## Security boundary
|
||||
|
||||
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.
|
||||
- [renderLimit][renderLimit]: limit total render time per `render()` call.
|
||||
- [memoryLimit][memoryLimit]: cooperatively limit memory-sensitive allocations counted by LiquidJS.
|
||||
|
||||
## Limit details
|
||||
|
||||
### parseLimit
|
||||
|
||||
[parseLimit][parseLimit] restricts the size (character length) of templates parsed in each `.parse()` call, including referenced partials and layouts. Since LiquidJS parses template strings in near O(n) time, limiting total template length is usually sufficient.
|
||||
|
||||
A typical PC handles `1e8` (100M) characters without issues.
|
||||
|
||||
### renderLimit
|
||||
|
||||
Restricting template size alone is insufficient because dynamic loops with large counts can occur in render time. [renderLimit][renderLimit] mitigates this by limiting the time consumed by each `render()` call.
|
||||
|
||||
```liquid
|
||||
{%- for i in (1..10000000) -%}
|
||||
order: {{i}}
|
||||
{%- endfor -%}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
### memoryLimit
|
||||
|
||||
`memoryLimit` only limits operations that LiquidJS explicitly counts.
|
||||
|
||||
- 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.
|
||||
|
||||
In other words, `memoryLimit` limits what LiquidJS counts, not every byte your process may allocate.
|
||||
|
||||
Even with 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.
|
||||
|
||||
## Online service guidance
|
||||
|
||||
If you run an online service, avoid rendering fully user-defined templates whenever possible.
|
||||
|
||||
- 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
|
||||
[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
|
||||
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
|
||||
@@ -24,6 +24,7 @@ LiquidJS 一直很重视兼容于 Ruby 版本的 Liquid。Liquid 模板语言最
|
||||
|
||||
* 真和假。在 LiquidJS 中 `undefined`, `null`, `false` 是假,之外的都是真;在 Ruby 中 `nil` 和 `false` 是假,其他都是真。见 [#26][#26]。
|
||||
* 数字。JavaScript 不区分浮点数和整数,因此缺失一部分整数算术,见 [#59][#59]。此外 `size` 过滤器作用于数字时总是返回零,而不是 Ruby 中的浮点数或整数的内存大小。
|
||||
* 输出字符串。基本类型的输出已经和 Shopify/liquid 对齐,但是仍然存在一些区别。比如在 Shopify/liquid 中 `strip` 会返回 inspect 字符串,但 LiquidJS `strip` 只是简单地把输入转换为字符串 [#852][#852]。
|
||||
* Drop 中的 [.to_liquid()](https://github.com/Shopify/liquid/wiki/Introduction-to-Drops) 替换为 `.toLiquid()`。
|
||||
* 数据的 [.to_s()](https://www.rubydoc.info/gems/liquid/Liquid/Drop) 替换为 `.toString()`。
|
||||
* 对象的迭代顺序。JavaScript 对象的迭代顺序是插入顺序和数字键递增顺序的组合,但 Ruby Hash 中只是插入顺序(JavaScript 字面量 Object 和 Ruby 字面量 Hash 的插入顺序解释也不同)。
|
||||
@@ -46,6 +47,7 @@ LiquidJS 一直很重视兼容于 Ruby 版本的 Liquid。Liquid 模板语言最
|
||||
[#236]: https://github.com/harttle/liquidjs/issues/236
|
||||
[#414]: https://github.com/harttle/liquidjs/discussions/414
|
||||
[#485]: https://github.com/harttle/liquidjs/discussions/485
|
||||
[#852]: https://github.com/harttle/liquidjs/discussions/852
|
||||
[sort]: https://liquidjs.com/filters/sort.html
|
||||
[stable-sort]: https://v8.dev/features/stable-sort
|
||||
[plugins]: ./plugins.html#插件列表
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
title: 防止 DoS 攻击
|
||||
---
|
||||
|
||||
当模板或数据上下文不可信时,启用DoS预防选项至关重要。LiquidJS 提供了三个选项用于此目的:`parseLimit`、`renderLimit` 和 `memoryLimit`。
|
||||
|
||||
## TL;DR
|
||||
|
||||
设置这些选项可以在很大程度上确保你的 LiquidJS 实例不会长时间挂起或消耗过多内存。这些限制基于可用的 JavaScript API,因此它们不是精确的硬性限制,而是确保你的进程不会失败或挂起的阈值。
|
||||
|
||||
```typescript
|
||||
const liquid = new Liquid({
|
||||
parseLimit: 1e8, // 每次渲染的模板的典型大小
|
||||
renderLimit: 1000, // 每次渲染最多 1s
|
||||
memoryLimit: 1e9, // LiquidJS 可用的内存(1e9 对应 1GB)
|
||||
})
|
||||
```
|
||||
|
||||
## parseLimit
|
||||
|
||||
[parseLimit][parseLimit] 限制每次 `.parse()` 调用中解析的模板大小(字符长度),包括引用的 partials 和 layouts。由于 LiquidJS 解析模板字符串的时间复杂度接近 O(n),限制模板总长度通常就足够了。
|
||||
|
||||
普通电脑可以很容易处理 `1e8`(100M)个字符的模板。
|
||||
|
||||
## renderLimit
|
||||
|
||||
仅限制模板大小是不够的,因为在渲染时可能会出现动态的数组和循环。[renderLimit][renderLimit] 通过限制每次 `render()` 调用的时间来缓解这些问题。
|
||||
|
||||
```liquid
|
||||
{%- for i in (1..10000000) -%}
|
||||
order: {{i}}
|
||||
{%- endfor -%}
|
||||
```
|
||||
|
||||
渲染时间是在渲染每个模板之前检查的。在上面的例子中,循环中有两个模板:`order: ` 和 `{{i}}`,因此会检查 2x10000000 次。
|
||||
|
||||
单个模板内的标签和过滤器仍然可能把进程挂起。要完全控制渲染过程,建议使用类似 [paralleljs][paralleljs] 的进程管理器。
|
||||
|
||||
## memoryLimit
|
||||
|
||||
即使模板和迭代次数较少,内存使用量也可能呈指数增长。在下面的示例中,内存会在每次迭代中翻倍:
|
||||
|
||||
```liquid
|
||||
{% assign array = "1,2,3" | split: "," %}
|
||||
{% for i in (1..32) %}
|
||||
{% assign array = array | concat: array %}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
[memoryLimit][memoryLimit] 限制内存敏感的过滤器,以防止过度的内存分配。由于 [JavaScript 使用 GC 来管理内存](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management),`memoryLimit` 仅限制 LiquidJS 中内存敏感过滤器分配的对象总数,因此可能无法反映实际的内存占用。
|
||||
|
||||
[paralleljs]: https://www.npmjs.com/package/paralleljs
|
||||
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
|
||||
[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
|
||||
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
|
||||
@@ -5,14 +5,61 @@ title: 运算符
|
||||
LiquidJS 运算符非常简单也很特别,只支持两类运算符:
|
||||
|
||||
* 比较运算符:`==`, `!=`, `>`, `<`, `>=`, `<=`
|
||||
* 逻辑运算符:`or`, `and`, `contains`
|
||||
* 逻辑运算符:`not`, `or`, `and`, `contains`
|
||||
|
||||
因此普通的数学运算是不支持的,比如 `{% raw %}{{a + b}}{% endraw %}`。它的替代方案是过滤器 `{% raw %}{{ a | plus: b}}{% endraw %}`。事实上 `+` 在 LiquidJS 中是一个合法的变量名。
|
||||
|
||||
## 逻辑运算符
|
||||
|
||||
### not
|
||||
|
||||
对条件取反。如果条件为假则返回 `true`,如果条件为真则返回 `false`。
|
||||
|
||||
输入
|
||||
```liquid
|
||||
{% if not user.active %}
|
||||
用户未激活
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### and
|
||||
|
||||
当两个条件都为真时返回 `true`。
|
||||
|
||||
输入
|
||||
```liquid
|
||||
{% if user.age >= 18 and user.verified %}
|
||||
允许访问
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### or
|
||||
|
||||
当至少一个条件为真时返回 `true`。
|
||||
|
||||
输入
|
||||
```liquid
|
||||
{% if user.isAdmin or user.isModerator %}
|
||||
您拥有提升的权限
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### contains
|
||||
|
||||
检查字符串是否包含子字符串,或数组是否包含元素。
|
||||
|
||||
输入
|
||||
```liquid
|
||||
{% if product.title contains "Pack" %}
|
||||
这是一个套装
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
## 优先级
|
||||
|
||||
1. 比较运算符。所有比较运算符具有同样的优先级,且高于逻辑运算符。
|
||||
2. 逻辑运算符。所有逻辑运算符具有同样的有衔接。
|
||||
1. 比较运算符和 `contains`。所有比较运算符和 `contains` 具有同样的(最高)优先级。
|
||||
2. `not` 运算符。它的优先级略高于 `or` 和 `and`。
|
||||
3. `or` 和 `and` 运算符。这些逻辑运算符具有同样的(最低)优先级。
|
||||
|
||||
## 结合性
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: 安全模型
|
||||
---
|
||||
|
||||
LiquidJS 提供了面向 DoS 的限制选项(`parseLimit`、`renderLimit`、`memoryLimit`)来降低风险。本文按统一结构说明每个限制的作用范围,以及你在生产环境应采用的安全边界。
|
||||
|
||||
## 安全边界
|
||||
|
||||
内置限制是协作式防护,不是严格的运行时隔离。
|
||||
|
||||
- 它**不等于**进程的 RSS/heap 实际占用。
|
||||
- 它**不是** JavaScript 沙箱。
|
||||
- 在生产环境中应结合进程/容器资源限制和请求超时做分层防护。
|
||||
|
||||
## 限制速览
|
||||
|
||||
- [parseLimit][parseLimit]:限制每次 `parse()` 的模板总长度。
|
||||
- [renderLimit][renderLimit]:限制每次 `render()` 的总渲染时间。
|
||||
- [memoryLimit][memoryLimit]:协作式限制 LiquidJS 已记账的内存敏感分配。
|
||||
|
||||
## 限制详解
|
||||
|
||||
### parseLimit
|
||||
|
||||
[parseLimit][parseLimit] 限制每次 `.parse()` 调用中解析的模板大小(字符长度),包括引用的 partials 和 layouts。由于 LiquidJS 解析模板字符串的时间复杂度接近 O(n),限制模板总长度通常就足够了。
|
||||
|
||||
普通电脑可以很容易处理 `1e8`(100M)个字符的模板。
|
||||
|
||||
### renderLimit
|
||||
|
||||
仅限制模板大小是不够的,因为在渲染时可能会出现动态的数组和循环。[renderLimit][renderLimit] 通过限制每次 `render()` 调用的时间来缓解这些问题。
|
||||
|
||||
```liquid
|
||||
{%- for i in (1..10000000) -%}
|
||||
order: {{i}}
|
||||
{%- endfor -%}
|
||||
```
|
||||
|
||||
渲染时间是在渲染每个模板之前检查的。在上面的例子中,循环中有两个模板:`order: ` 和 `{{i}}`,因此会检查 2x10000000 次。
|
||||
|
||||
`renderLimit` 不是硬性的 CPU 限制器。它是在模板渲染边界做检查,因此检查点之间的高计算开销过滤器/标签/用户自定义函数,或深层模板嵌套,仍可能导致 DoS。
|
||||
|
||||
### memoryLimit
|
||||
|
||||
`memoryLimit` 只限制 LiquidJS 显式记账到的操作。
|
||||
|
||||
- 会被统计:LiquidJS 内部调用了内存记账逻辑的内存敏感操作。
|
||||
- 不保证被统计:任意用户对象行为(例如自定义 `toValue()` / `toString()` 链)以及其他发生在 LiquidJS 记账点之外的宿主侧分配。
|
||||
|
||||
换句话说,`memoryLimit` 限制的是 LiquidJS 的“已记账分配”,而不是进程里每一个字节的分配。
|
||||
|
||||
即使模板和迭代次数较少,内存使用量也可能呈指数增长。在下面的示例中,内存会在每次迭代中翻倍:
|
||||
|
||||
```liquid
|
||||
{% assign array = "1,2,3" | split: "," %}
|
||||
{% for i in (1..32) %}
|
||||
{% assign array = array | concat: array %}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
由于 [JavaScript 使用 GC 来管理内存](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management),`memoryLimit` 可能无法反映实际的内存占用。
|
||||
|
||||
## 在线服务建议
|
||||
|
||||
如果你运行在线服务,建议尽量避免渲染完全由用户定义的模板。
|
||||
|
||||
- 优先使用受控模板或受限模板子集。
|
||||
- 如果必须支持用户自定义模板,请隔离渲染(worker/进程/容器),并同时配置操作系统或容器级的内存/CPU 限额与请求限流。
|
||||
- 将 `parseLimit` / `renderLimit` / `memoryLimit` 视为 DoS 防护体系中的一层,而不是唯一防线。
|
||||
|
||||
对于单个模板中的重型操作,仍建议使用进程级隔离(例如 [paralleljs][paralleljs])。
|
||||
|
||||
[paralleljs]: https://www.npmjs.com/package/paralleljs
|
||||
[parseLimit]: /api/interfaces/LiquidOptions.html#parseLimit
|
||||
[renderLimit]: /api/interfaces/LiquidOptions.html#renderLimit
|
||||
[memoryLimit]: /api/interfaces/LiquidOptions.html#memoryLimit
|
||||
Vendored
+1
-1
@@ -51,7 +51,7 @@ sidebar:
|
||||
plugins: Plugins
|
||||
operators: Operators
|
||||
truth: Truthy and Falsy
|
||||
dos: DoS
|
||||
security_model: Security Model
|
||||
static_analysis: Static Analysis
|
||||
|
||||
miscellaneous: Miscellaneous
|
||||
|
||||
Vendored
+1
-1
@@ -51,7 +51,7 @@ sidebar:
|
||||
plugins: 插件
|
||||
operators: 运算符
|
||||
truth: 真和假
|
||||
dos: DoS
|
||||
security_model: 安全模型
|
||||
static_analysis: 静态分析
|
||||
|
||||
miscellaneous: 其他
|
||||
|
||||
Vendored
+15
-15
@@ -1,5 +1,5 @@
|
||||
<header id="banner" class="wrapper">
|
||||
<div class="inner">
|
||||
<div class="inner inner-content">
|
||||
<h2 id="banner-title">{{ page.subtitle }}</h2>
|
||||
<div id="banner-start">
|
||||
<code id="banner-start-command">npm install liquidjs</code><a id="banner-start-link" href="./tutorials/setup.html"><i class="icon-arrow-right"></i></a>
|
||||
@@ -9,7 +9,7 @@
|
||||
</header>
|
||||
<div id="intro-news-list">
|
||||
<div class="wrapper">
|
||||
<div class="inner">
|
||||
<div class="inner inner-content">
|
||||
<div id="intro-news-flex">
|
||||
{% for news in site.data.news %}
|
||||
<a href="{{news.url}}" class="intro-news-wrap">
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div id="content-wrap">
|
||||
<div class="wrapper">
|
||||
<div class="inner">
|
||||
<div class="inner inner-content">
|
||||
{{ page.content }}
|
||||
<div id="intro-cmd-wrap" class="highlight typescript"><pre><span class="line"><span class="keyword">import</span> { Liquid } <span class="keyword">from</span> <span class="string">'liquidjs'</span></span><br><span class="line"><span class="keyword">const</span> engine = <span class="keyword">new</span> Liquid()</span><br><span class="line"><span class="keyword">const</span> tpl = engine.parse(<span class="string">'Welcome to {% raw %}{{v}}{% endraw %}!'</span>)</span><br><span class="line">engine.render(tpl, {<span class="attr">v</span>: <span class="string">"Liquid"</span>}).then(<span class="built_in">console</span>.log)</span><br><span class="line"><span class="comment">// Outputs "Welcome to Liquid!"</span></span><br></pre></div>
|
||||
<div id="intro-get-started-wrap">
|
||||
@@ -32,20 +32,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="contributors-wrap">
|
||||
<div class="wrapper">
|
||||
<div class="inner">
|
||||
<h3>{{__('index.contributors.title')}}</h3>
|
||||
<p class="description">{{__('index.contributors.description')}}</p>
|
||||
<div class="contributors">
|
||||
{{ partial('partial/all-contributors') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sponsors-wrap">
|
||||
<div class="wrapper">
|
||||
<div class="inner">
|
||||
<div class="inner inner-content">
|
||||
<h3>{{__('index.sponsors.title')}}</h3>
|
||||
<p class="description">{{__('index.sponsors.description')}}</p>
|
||||
<div class="contributors">
|
||||
@@ -54,3 +43,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="contributors-wrap">
|
||||
<div class="wrapper">
|
||||
<div class="inner inner-content">
|
||||
<h3>{{__('index.contributors.title')}}</h3>
|
||||
<p class="description">{{__('index.contributors.description')}}</p>
|
||||
<div class="contributors">
|
||||
{{ partial('partial/all-contributors') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-1
@@ -54,7 +54,11 @@ a
|
||||
|
||||
.inner
|
||||
@media screen
|
||||
padding: 0 gutter-width
|
||||
margin: 0 gutter-width
|
||||
.inner.inner-content
|
||||
margin: 0 32px
|
||||
@media mq-tablet
|
||||
margin: 0 48px
|
||||
|
||||
#content-wrap
|
||||
background: var(--color-content-bg)
|
||||
|
||||
+20
-17
@@ -66,6 +66,10 @@
|
||||
.intro-news-wrap
|
||||
padding: 24px 30px
|
||||
text-decoration: none
|
||||
&:first-child
|
||||
padding-left: 0
|
||||
&:last-child
|
||||
padding-right: 0
|
||||
&:hover
|
||||
background-color: var(--color-link-hover)
|
||||
@media mq-normal
|
||||
@@ -105,7 +109,7 @@
|
||||
text-align: center
|
||||
@media mq-normal
|
||||
text-align: left
|
||||
padding-left: 70px
|
||||
padding-left: 48px
|
||||
|
||||
.intro-feature-icon
|
||||
color: var(--color-link)
|
||||
@@ -116,7 +120,7 @@
|
||||
margin-bottom: 26px
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 20px
|
||||
left: 0
|
||||
font-size: 24px
|
||||
width: @font-size
|
||||
|
||||
@@ -177,9 +181,11 @@
|
||||
border-bottom: 1px solid #161d24
|
||||
margin: -1px 0
|
||||
.inner
|
||||
margin: 48px 16px
|
||||
margin-top: 48px
|
||||
margin-bottom: 48px
|
||||
@media mq-tablet
|
||||
margin: 64px 32px
|
||||
margin-top: 64px
|
||||
margin-bottom: 64px
|
||||
h3
|
||||
color: #fff
|
||||
font-size: 32px
|
||||
@@ -192,20 +198,7 @@
|
||||
text-decoration: none
|
||||
|
||||
#contributors-wrap
|
||||
.description a
|
||||
color: #fff
|
||||
&:hover
|
||||
background: #fff
|
||||
color: var(--color-navy-lighter)
|
||||
|
||||
#sponsors-wrap
|
||||
border: none
|
||||
text-align: right
|
||||
background: var(--color-content-bg)
|
||||
overflow: hidden;
|
||||
.contributors
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
.inner
|
||||
h3
|
||||
color: var(--color-default)
|
||||
@@ -227,10 +220,20 @@
|
||||
background: #fff
|
||||
color: var(--color-navy-lighter)
|
||||
|
||||
#sponsors-wrap
|
||||
border: none
|
||||
overflow: hidden;
|
||||
.description a
|
||||
color: #fff
|
||||
&:hover
|
||||
background: #fff
|
||||
color: var(--color-navy-lighter)
|
||||
|
||||
.contributors
|
||||
tr
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: center;
|
||||
td
|
||||
margin: 10px 2px 0
|
||||
a img
|
||||
|
||||
Generated
+3729
-1913
File diff suppressed because it is too large
Load Diff
+15
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "liquidjs",
|
||||
"version": "10.23.0",
|
||||
"version": "10.25.7",
|
||||
"sideEffects": false,
|
||||
"description": "A simple, expressive and safe Shopify / Github Pages compatible template engine in pure JavaScript.",
|
||||
"main": "dist/liquid.node.js",
|
||||
@@ -21,7 +21,7 @@
|
||||
"test:coverage": "jest --coverage src test/integration",
|
||||
"test:e2e": "jest test/e2e",
|
||||
"test:demo": "./test/demo/test.sh",
|
||||
"perf:diff": "bin/perf-diff.sh",
|
||||
"perf:diff": "node bin/perf-diff.js",
|
||||
"perf:engines": "cd benchmark && npm run engines",
|
||||
"version": "npm run build && npm test",
|
||||
"build": "rollup -c rollup.config.mjs",
|
||||
@@ -29,7 +29,12 @@
|
||||
"build:min": "BUNDLES=min rollup -c rollup.config.mjs",
|
||||
"build:umd": "BUNDLES=umd rollup -c rollup.config.mjs",
|
||||
"build:charmap": "./bin/character-gen.js > src/util/character.ts",
|
||||
"build:docs": "bin/build-docs.sh"
|
||||
"build:docs": "run-s build:docs-liquid build:contributors build:apidoc build:changelog build:docs-hexo",
|
||||
"build:docs-liquid": "cross-env BUNDLES=min rollup -c rollup.config.mjs && shx mkdir -p docs/public/js && shx cp dist/liquid.browser.min.js docs/public/js/",
|
||||
"build:contributors": "node bin/build-contributors.js",
|
||||
"build:apidoc": "shx rm -rf docs/source/api && typedoc --plugin typedoc-plugin-missing-exports ./src --gitRevision master --out docs/source/api",
|
||||
"build:changelog": "node bin/build-changelog.js",
|
||||
"build:docs-hexo": "cd docs && npm ci && npm run build && shx cp CNAME public/"
|
||||
},
|
||||
"bin": {
|
||||
"liquidjs": "./bin/liquid.js",
|
||||
@@ -68,7 +73,7 @@
|
||||
"@semantic-release/changelog": "^6.0.2",
|
||||
"@semantic-release/commit-analyzer": "^9.0.2",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/npm": "^9.0.2",
|
||||
"@semantic-release/npm": "^13.1.5",
|
||||
"@semantic-release/release-notes-generator": "^10.0.3",
|
||||
"@types/benchmark": "^1.0.31",
|
||||
"@types/express": "^4.17.2",
|
||||
@@ -95,12 +100,14 @@
|
||||
"husky": "^4.2.5",
|
||||
"jest": "^29.5.0",
|
||||
"jsdom": "^16.5.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"rollup": "^1.26.3",
|
||||
"rollup-plugin-replace": "^2.1.0",
|
||||
"rollup-plugin-typescript2": "^0.31.1",
|
||||
"rollup-plugin-uglify": "^6.0.4",
|
||||
"rollup-plugin-version-injector": "^1.3.3",
|
||||
"semantic-release": "^19.0.3",
|
||||
"semantic-release": "^25.0.3",
|
||||
"shx": "^0.4.0",
|
||||
"sinon": "^15.0.2",
|
||||
"supertest": "^3.4.2",
|
||||
"ts-jest": "^29.0.5",
|
||||
@@ -151,6 +158,9 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"publishConfig": {
|
||||
"provenance": true
|
||||
},
|
||||
"nyc": {
|
||||
"extension": [
|
||||
".ts"
|
||||
|
||||
+18
-2
@@ -45,6 +45,16 @@ const browserFS = {
|
||||
delimiters: ['', ''],
|
||||
'./fs/fs-impl': './build/fs-impl-browser'
|
||||
}
|
||||
const browserBase64 = {
|
||||
include: './src/filters/base64.ts',
|
||||
delimiters: ['', ''],
|
||||
'./base64-impl': '../build/base64-impl-browser'
|
||||
}
|
||||
const browserCrypto = {
|
||||
include: './src/filters/crypto.ts',
|
||||
delimiters: ['', ''],
|
||||
'./crypto-impl': '../build/crypto-impl-browser'
|
||||
}
|
||||
const browserStream = {
|
||||
include: './src/emitters/index.ts',
|
||||
delimiters: ['', ''],
|
||||
@@ -62,7 +72,7 @@ const nodeCjs = {
|
||||
format: 'cjs',
|
||||
banner
|
||||
}],
|
||||
external: ['path', 'fs', 'stream'],
|
||||
external: ['path', 'fs', 'stream', 'crypto'],
|
||||
plugins: [versionInjection, typescript(tsconfig('ES2020'))],
|
||||
treeshake,
|
||||
input
|
||||
@@ -74,7 +84,7 @@ const nodeEsm = {
|
||||
format: 'esm',
|
||||
banner
|
||||
}],
|
||||
external: ['path', 'fs', 'stream'],
|
||||
external: ['path', 'fs', 'stream', 'crypto'],
|
||||
plugins: [
|
||||
versionInjection,
|
||||
replace(esmRequire),
|
||||
@@ -94,6 +104,8 @@ const browserEsm = {
|
||||
plugins: [
|
||||
versionInjection,
|
||||
replace(browserFS),
|
||||
replace(browserBase64),
|
||||
replace(browserCrypto),
|
||||
replace(browserStream),
|
||||
typescript(tsconfig('es6'))
|
||||
],
|
||||
@@ -112,6 +124,8 @@ const browserUmd = {
|
||||
plugins: [
|
||||
versionInjection,
|
||||
replace(browserFS),
|
||||
replace(browserBase64),
|
||||
replace(browserCrypto),
|
||||
replace(browserStream),
|
||||
typescript(tsconfig('es5'))
|
||||
],
|
||||
@@ -130,6 +144,8 @@ const browserMin = {
|
||||
plugins: [
|
||||
versionInjection,
|
||||
replace(browserFS),
|
||||
replace(browserBase64),
|
||||
replace(browserCrypto),
|
||||
replace(browserStream),
|
||||
typescript(tsconfig('es5')),
|
||||
uglify()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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/',
|
||||
contentType: 'text/html',
|
||||
includeNodeLocations: true
|
||||
})
|
||||
|
||||
// Mock btoa and atob on global object
|
||||
Object.defineProperty(global, 'btoa', {
|
||||
value: dom.window.btoa,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(global, 'atob', {
|
||||
value: dom.window.atob,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
delete (global as any).btoa
|
||||
delete (global as any).atob
|
||||
})
|
||||
|
||||
describe('#base64Encode()', function () {
|
||||
it('should encode a simple string', function () {
|
||||
expect(base64.base64Encode('one two three')).toBe('b25lIHR3byB0aHJlZQ==')
|
||||
})
|
||||
|
||||
it('should encode an empty string', function () {
|
||||
expect(base64.base64Encode('')).toBe('')
|
||||
})
|
||||
|
||||
it('should encode a string with special characters', function () {
|
||||
expect(base64.base64Encode('Hello, World! @#$%')).toBe('SGVsbG8sIFdvcmxkISBAIyQl')
|
||||
})
|
||||
|
||||
it('should encode numeric strings', function () {
|
||||
expect(base64.base64Encode('123')).toBe('MTIz')
|
||||
})
|
||||
|
||||
it('should encode boolean strings', function () {
|
||||
expect(base64.base64Encode('true')).toBe('dHJ1ZQ==')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#base64Decode()', function () {
|
||||
it('should decode a simple string', function () {
|
||||
expect(base64.base64Decode('b25lIHR3byB0aHJlZQ==')).toBe('one two three')
|
||||
})
|
||||
|
||||
it('should decode an empty string', function () {
|
||||
expect(base64.base64Decode('')).toBe('')
|
||||
})
|
||||
|
||||
it('should decode a string with special characters', function () {
|
||||
expect(base64.base64Decode('SGVsbG8sIFdvcmxkISBAIyQl')).toBe('Hello, World! @#$%')
|
||||
})
|
||||
|
||||
it('should decode numeric strings', function () {
|
||||
expect(base64.base64Decode('MTIz')).toBe('123')
|
||||
})
|
||||
|
||||
it('should decode boolean strings', function () {
|
||||
expect(base64.base64Decode('dHJ1ZQ==')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('round-trip encoding/decoding', function () {
|
||||
it('should encode and decode back to original', function () {
|
||||
const original = 'Hello, World!'
|
||||
const encoded = base64.base64Encode(original)
|
||||
const decoded = base64.base64Decode(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle complex strings with special characters', function () {
|
||||
const original = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
const encoded = base64.base64Encode(original)
|
||||
const decoded = base64.base64Decode(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
|
||||
it('should handle mixed unicode and ASCII', function () {
|
||||
const original = 'Hello 🌍'
|
||||
const encoded = base64.base64Encode(original)
|
||||
const decoded = base64.base64Decode(encoded)
|
||||
expect(decoded).toBe(original)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
export function base64Encode (str: string): string {
|
||||
return btoa(String.fromCharCode(...new TextEncoder().encode(str)))
|
||||
}
|
||||
|
||||
export function base64Decode (str: string): string {
|
||||
return new TextDecoder().decode(
|
||||
Uint8Array.from(atob(str), c => c.charCodeAt(0))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { webcrypto } from 'crypto'
|
||||
import * as cryptoImpl from './crypto-impl-browser'
|
||||
|
||||
describe('crypto-impl/browser', function () {
|
||||
beforeEach(function () {
|
||||
Object.defineProperty(global, 'crypto', {
|
||||
value: webcrypto,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
delete (global as any).crypto
|
||||
})
|
||||
|
||||
describe('#sha256()', function () {
|
||||
it('should hash the Shopify reference example', async function () {
|
||||
expect(await cryptoImpl.sha256('Polyjuice'))
|
||||
.toBe('44ac1d7a2936e30a5de07082fd65d6fe9b1fb658a1a98bfe65bc5959beac5dd0')
|
||||
})
|
||||
|
||||
it('should hash an empty string', async function () {
|
||||
expect(await cryptoImpl.sha256(''))
|
||||
.toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
||||
})
|
||||
|
||||
it('should hash unicode characters', async function () {
|
||||
expect(await cryptoImpl.sha256('你好世界'))
|
||||
.toBe('beca6335b20ff57ccc47403ef4d9e0b8fccb4442b3151c2e7d50050673d43172')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#hmacSha256()', function () {
|
||||
it('should hash the Shopify reference example', async function () {
|
||||
expect(await cryptoImpl.hmacSha256('Polyjuice', 'Polina'))
|
||||
.toBe('8e0d5d65cff1242a4af66c8f4a32854fd5fb80edcc8aabe9b302b29c7c71dc20')
|
||||
})
|
||||
|
||||
it('should hash an empty message with a key', async function () {
|
||||
expect(await cryptoImpl.hmacSha256('', 'key'))
|
||||
.toBe('5d5d139563c95b5967b9bd9a8c9b233a9dedb45072794cd232dc1b74832607d0')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
function bufferToHex (buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let hex = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
hex += bytes[i].toString(16).padStart(2, '0')
|
||||
}
|
||||
return hex
|
||||
}
|
||||
|
||||
export async function sha256 (str: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(str)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return bufferToHex(digest)
|
||||
}
|
||||
|
||||
export async function hmacSha256 (str: string, key: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(key),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(str))
|
||||
return bufferToHex(signature)
|
||||
}
|
||||
@@ -48,8 +48,8 @@ export class Context {
|
||||
this.memoryLimit = memoryLimit ?? new Limiter('memory alloc', renderOptions.memoryLimit ?? opts.memoryLimit)
|
||||
this.renderLimit = renderLimit ?? new Limiter('template render', getPerformance().now() + (renderOptions.renderLimit ?? opts.renderLimit))
|
||||
}
|
||||
public getRegister (key: string) {
|
||||
return (this.registers[key] = this.registers[key] || {})
|
||||
public getRegister<T> (key: string, defaultValue: T = undefined as T): T {
|
||||
return (this.registers[key] = this.registers[key] || defaultValue)
|
||||
}
|
||||
public setRegister (key: string, value: any) {
|
||||
return (this.registers[key] = value)
|
||||
@@ -106,7 +106,8 @@ export class Context {
|
||||
return new Context(scope, this.opts, {
|
||||
sync: this.sync,
|
||||
globals: this.globals,
|
||||
strictVariables: this.strictVariables
|
||||
strictVariables: this.strictVariables,
|
||||
ownPropertyOnly: this.ownPropertyOnly
|
||||
}, {
|
||||
renderLimit: this.renderLimit,
|
||||
memoryLimit: this.memoryLimit
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Drop } from '../drop/drop'
|
||||
|
||||
interface ScopeObject extends Record<string, any> {
|
||||
interface ScopeObject extends Record<string | number | symbol, any> {
|
||||
toLiquid?: () => any;
|
||||
}
|
||||
|
||||
|
||||
+10
-16
@@ -1,4 +1,4 @@
|
||||
import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, isArray, isNil, last as arrayLast, isArrayLike, toEnumerable } from '../util'
|
||||
import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, orderedCompare, isArray, isNil, last as arrayLast, isArrayLike, toEnumerable } from '../util'
|
||||
import { arrayIncludes, equals, evalToken, isTruthy } from '../render'
|
||||
import { Value, FilterImpl } from '../template'
|
||||
import { Tokenizer } from '../parser'
|
||||
@@ -20,8 +20,8 @@ export const reverse = argumentsToValue(function (this: FilterImpl, v: any[]) {
|
||||
return [...array].reverse()
|
||||
})
|
||||
|
||||
export function * sort<T> (this: FilterImpl, arr: T[], property?: string): IterableIterator<unknown> {
|
||||
const values: [T, string | number][] = []
|
||||
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) {
|
||||
@@ -30,21 +30,15 @@ export function * sort<T> (this: FilterImpl, arr: T[], property?: string): Itera
|
||||
property ? yield this.context._getFromScope(item, stringify(property).split('.'), false) : item
|
||||
])
|
||||
}
|
||||
return values.sort((lhs, rhs) => {
|
||||
const lvalue = lhs[1]
|
||||
const rvalue = rhs[1]
|
||||
return lvalue < rvalue ? -1 : (lvalue > rvalue ? 1 : 0)
|
||||
}).map(tuple => tuple[0])
|
||||
return values.sort((lhs, rhs) => comparator(lhs[1], rhs[1])).map(tuple => tuple[0])
|
||||
}
|
||||
|
||||
export function sort_natural<T> (this: FilterImpl, input: T[], property?: string) {
|
||||
const propertyString = stringify(property)
|
||||
const compare = property === undefined
|
||||
? caseInsensitiveCompare
|
||||
: (lhs: T, rhs: T) => caseInsensitiveCompare(lhs[propertyString], rhs[propertyString])
|
||||
const array = toArray(input)
|
||||
this.context.memoryLimit.use(array.length)
|
||||
return [...array].sort(compare)
|
||||
export function * sort<T> (this: FilterImpl, arr: T[], property?: string): IterableIterator<unknown> {
|
||||
return yield * sortBy.call(this, arr, property, orderedCompare)
|
||||
}
|
||||
|
||||
export function * sort_natural<T> (this: FilterImpl, arr: T[], property?: string): IterableIterator<unknown> {
|
||||
return yield * sortBy.call(this, arr, property, caseInsensitiveCompare)
|
||||
}
|
||||
|
||||
export const size = (v: string | any[]) => (v && v.length) || 0
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function base64Encode (str: string): string {
|
||||
return Buffer.from(str, 'utf8').toString('base64')
|
||||
}
|
||||
|
||||
export function base64Decode (str: string): string {
|
||||
return Buffer.from(str, 'base64').toString('utf8')
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Base64 related filters
|
||||
*
|
||||
* Implements base64_encode and base64_decode filters for Shopify compatibility
|
||||
*/
|
||||
|
||||
import { FilterImpl } from '../template'
|
||||
import { stringify } from '../util'
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createHash, createHmac } from 'crypto'
|
||||
|
||||
export function sha256 (str: string): string {
|
||||
return createHash('sha256').update(str, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
export function hmacSha256 (str: string, key: string): string {
|
||||
return createHmac('sha256', key).update(str, 'utf8').digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Crypto related filters
|
||||
*
|
||||
* Implements sha256 and hmac_sha256 filters for Shopify compatibility
|
||||
*/
|
||||
|
||||
import { FilterImpl } from '../template'
|
||||
import { stringify } from '../util'
|
||||
import { sha256 as sha256Impl, hmacSha256 as hmacSha256Impl } from './crypto-impl'
|
||||
|
||||
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)
|
||||
}
|
||||
+10
-6
@@ -3,13 +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) + (format?.length ?? 0) + ((timezoneOffset as string)?.length ?? 0)
|
||||
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) {
|
||||
@@ -31,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 {
|
||||
@@ -45,7 +47,9 @@ function parseDate (v: string | Date, opts: NormalizedFullOptions, timezoneOffse
|
||||
const defaultTimezoneOffset = timezoneOffset ?? opts.timezoneOffset
|
||||
const locale = opts.locale
|
||||
v = toValue(v)
|
||||
if (v === 'now' || v === 'today') {
|
||||
if (isNil(v)) {
|
||||
return undefined
|
||||
} else if (v === 'now' || v === 'today') {
|
||||
date = new LiquidDate(Date.now(), locale, defaultTimezoneOffset)
|
||||
} else if (isNumber(v)) {
|
||||
date = new LiquidDate(v * 1000, locale, defaultTimezoneOffset)
|
||||
|
||||
+18
-1
@@ -42,8 +42,25 @@ export function newline_to_br (this: FilterImpl, v: string) {
|
||||
return str.replace(/\r?\n/gm, '<br />\n')
|
||||
}
|
||||
|
||||
// Raw-text blocks (HTML5) plus '<...>' as the catch-all kind; a regex
|
||||
// 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)
|
||||
return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<.*?>|<!--[\s\S]*?-->/g, '')
|
||||
const blocks = new Map([['<script', '</script>'], ['<style', '</style>'], ['<!--', '-->'], ['<', '>']])
|
||||
let out = ''
|
||||
let i = 0
|
||||
while (i < str.length) {
|
||||
const lt = str.indexOf('<', i)
|
||||
if (lt < 0) return out + str.slice(i)
|
||||
out += str.slice(i, lt)
|
||||
for (const [opener, closer] of blocks) {
|
||||
if (!str.startsWith(opener, lt)) continue
|
||||
const e = str.indexOf(closer, lt + opener.length)
|
||||
if (e >= 0) { i = e + closer.length; break }
|
||||
blocks.delete(opener)
|
||||
}
|
||||
if (i === lt) return out + str.slice(lt)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as urlFilters from './url'
|
||||
import * as arrayFilters from './array'
|
||||
import * as dateFilters from './date'
|
||||
import * as stringFilters from './string'
|
||||
import * as base64Filters from './base64'
|
||||
import * as cryptoFilters from './crypto'
|
||||
import misc from './misc'
|
||||
import { FilterImplOptions } from '../template'
|
||||
|
||||
@@ -14,5 +16,7 @@ export const filters: Record<string, FilterImplOptions> = {
|
||||
...arrayFilters,
|
||||
...dateFilters,
|
||||
...stringFilters,
|
||||
...base64Filters,
|
||||
...cryptoFilters,
|
||||
...misc
|
||||
}
|
||||
|
||||
+3
-1
@@ -15,5 +15,7 @@ export function round (v: number, arg = 0) {
|
||||
v = toNumber(v)
|
||||
arg = toNumber(arg)
|
||||
const amp = Math.pow(10, arg)
|
||||
return Math.round(v * amp) / amp
|
||||
const scaled = v * amp
|
||||
// Round half away from zero
|
||||
return Math.sign(v) * Math.round(Math.abs(scaled)) / amp
|
||||
}
|
||||
|
||||
+47
-25
@@ -11,7 +11,7 @@
|
||||
// Hiragana (Japanese): \u3040-\u309F
|
||||
// Hangul (Korean): \uAC00-\uD7AF
|
||||
import { FilterImpl } from '../template'
|
||||
import { assert, escapeRegExp, stringify } from '../util'
|
||||
import { assert, stringify } from '../util'
|
||||
|
||||
const rCJKWord = /[\u4E00-\u9FFF\uF900-\uFAFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/gu
|
||||
|
||||
@@ -38,10 +38,14 @@ export function lstrip (this: FilterImpl, v: string, chars?: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
if (chars) {
|
||||
chars = escapeRegExp(stringify(chars))
|
||||
return str.replace(new RegExp(`^[${chars}]+`, 'g'), '')
|
||||
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)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return str.replace(/^\s+/, '')
|
||||
return str.trimStart()
|
||||
}
|
||||
|
||||
export function downcase (this: FilterImpl, v: string) {
|
||||
@@ -58,20 +62,22 @@ export function upcase (this: FilterImpl, v: string) {
|
||||
|
||||
export function remove (this: FilterImpl, v: string, arg: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
return str.split(stringify(arg)).join('')
|
||||
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)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
return str.replace(stringify(l), '')
|
||||
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)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
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)
|
||||
@@ -81,10 +87,14 @@ export function rstrip (this: FilterImpl, str: string, chars?: string) {
|
||||
str = stringify(str)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
if (chars) {
|
||||
chars = escapeRegExp(stringify(chars))
|
||||
return str.replace(new RegExp(`[${chars}]+$`, 'g'), '')
|
||||
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)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return str.replace(/\s+$/, '')
|
||||
return str.trimEnd()
|
||||
}
|
||||
|
||||
export function split (this: FilterImpl, v: string, arg: string) {
|
||||
@@ -101,10 +111,13 @@ export function strip (this: FilterImpl, v: string, chars?: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
if (chars) {
|
||||
chars = escapeRegExp(stringify(chars))
|
||||
return str
|
||||
.replace(new RegExp(`^[${chars}]+`, 'g'), '')
|
||||
.replace(new RegExp(`[${chars}]+$`, 'g'), '')
|
||||
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++
|
||||
while (j >= i && set.has(str[j])) j--
|
||||
return str.slice(i, j + 1)
|
||||
}
|
||||
return str.trim()
|
||||
}
|
||||
@@ -123,36 +136,44 @@ export function capitalize (this: FilterImpl, str: string) {
|
||||
|
||||
export function replace (this: FilterImpl, v: string, pattern: string, replacement: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
return str.split(stringify(pattern)).join(replacement)
|
||||
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)
|
||||
}
|
||||
|
||||
export function replace_first (this: FilterImpl, v: string, arg1: string, arg2: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
return str.replace(stringify(arg1), arg2)
|
||||
arg1 = stringify(arg1)
|
||||
arg2 = stringify(arg2)
|
||||
this.context.memoryLimit.use(str.length + arg1.length + arg2.length)
|
||||
return str.replace(arg1, () => arg2)
|
||||
}
|
||||
|
||||
export function replace_last (this: FilterImpl, v: string, arg1: string, arg2: string) {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
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
|
||||
const replacement = stringify(arg2)
|
||||
return str.substring(0, index) + replacement + str.substring(index + pattern.length)
|
||||
}
|
||||
|
||||
export function truncate (this: FilterImpl, v: string, l = 50, o = '...') {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
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
|
||||
}
|
||||
|
||||
export function truncatewords (this: FilterImpl, v: string, words = 15, o = '...') {
|
||||
const str = stringify(v)
|
||||
this.context.memoryLimit.use(str.length)
|
||||
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(' ')
|
||||
@@ -187,7 +208,8 @@ export function number_of_words (this: FilterImpl, input: string, mode?: 'cjk' |
|
||||
}
|
||||
|
||||
export function array_to_sentence_string (this: FilterImpl, array: unknown[], connector = 'and') {
|
||||
this.context.memoryLimit.use(array.length)
|
||||
connector = stringify(connector)
|
||||
this.context.memoryLimit.use(array.length + connector.length)
|
||||
switch (array.length) {
|
||||
case 0:
|
||||
return ''
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as fs from './fs-impl'
|
||||
import * as path from 'path'
|
||||
import { mkdtempSync, writeFileSync, symlinkSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
const { join } = path
|
||||
|
||||
describe('fs-impl', function () {
|
||||
describe('.resolve()', function () {
|
||||
@@ -50,4 +54,36 @@ describe('fs-impl', function () {
|
||||
expect(content).toContain('should read content if exists')
|
||||
})
|
||||
})
|
||||
describe('.contains()', () => {
|
||||
const canSymlink = process.platform !== 'win32'
|
||||
;(canSymlink ? it : it.skip)('should return false when path is a symlink to outside root', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'liquid-contains-'))
|
||||
const outside = join(tmpdir(), `secret-${Date.now()}.liquid`)
|
||||
writeFileSync(outside, 'x')
|
||||
const link = join(root, 'link.liquid')
|
||||
symlinkSync(outside, link)
|
||||
try {
|
||||
expect(await fs.contains(root, link)).toBe(false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
rmSync(outside, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
describe('.containsSync()', () => {
|
||||
const canSymlink = process.platform !== 'win32'
|
||||
;(canSymlink ? it : it.skip)('should return false when path is a symlink to outside root', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'liquid-contains-'))
|
||||
const outside = join(tmpdir(), `secret-${Date.now()}.liquid`)
|
||||
writeFileSync(outside, 'x')
|
||||
const link = join(root, 'link.liquid')
|
||||
symlinkSync(outside, link)
|
||||
try {
|
||||
expect(fs.containsSync(root, link)).toBe(false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
rmSync(outside, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+22
-5
@@ -1,6 +1,6 @@
|
||||
import { promisify } from '../util'
|
||||
import { sep, resolve as nodeResolve, extname, dirname as nodeDirname } from 'path'
|
||||
import { stat, statSync, readFile as nodeReadFile, readFileSync as nodeReadFileSync } from 'fs'
|
||||
import { stat, statSync, readFile as nodeReadFile, readFileSync as nodeReadFileSync, realpath, realpathSync } from 'fs'
|
||||
import { requireResolve } from './node-require'
|
||||
|
||||
type NodeReadFile = (file: string, encoding: string, cb: ((err: Error | null, result: string) => void)) => void
|
||||
@@ -41,10 +41,27 @@ export function fallback (file: string) {
|
||||
export function dirname (filepath: string) {
|
||||
return nodeDirname(filepath)
|
||||
}
|
||||
export function contains (root: string, file: string) {
|
||||
root = nodeResolve(root)
|
||||
root = root.endsWith(sep) ? root : root + sep
|
||||
return file.startsWith(root)
|
||||
const realpathAsync = promisify(realpath)
|
||||
|
||||
export async function contains (root: string, file: string) {
|
||||
try {
|
||||
const realRoot = await realpathAsync(root)
|
||||
const realFile = await realpathAsync(file)
|
||||
const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep
|
||||
return realFile.startsWith(prefix)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
export function containsSync (root: string, file: string) {
|
||||
try {
|
||||
const realRoot = realpathSync(root)
|
||||
const realFile = realpathSync(file)
|
||||
const prefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep
|
||||
return realFile.startsWith(prefix)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export { sep } from 'path'
|
||||
|
||||
+4
-2
@@ -9,8 +9,10 @@ export interface FS {
|
||||
readFileSync: (filepath: string) => string;
|
||||
/** resolve a file against directory, for given `ext` option */
|
||||
resolve: (dir: string, file: string, ext: string) => string;
|
||||
/** check if file is contained in `root`, always return `true` by default. Warning: not setting this could expose path traversal vulnerabilities. */
|
||||
contains?: (root: string, file: string) => boolean;
|
||||
/** check if file is contained in `root`. Node default fs uses realpath; if omitted, loader assumes contained. */
|
||||
contains?: (root: string, file: string) => Promise<boolean>;
|
||||
/** sync check if file is contained in `root`, allows both renderSync and render. */
|
||||
containsSync?: (root: string, file: string) => boolean;
|
||||
/** defaults to "/" */
|
||||
sep?: string;
|
||||
/** required for relative path resolving */
|
||||
|
||||
+29
-20
@@ -1,31 +1,40 @@
|
||||
import * as fs from './fs-impl'
|
||||
import { Loader } from './loader'
|
||||
import { resolve } from 'path'
|
||||
import { Loader, LookupType } from './loader'
|
||||
import { toValueSync } from '../util/async'
|
||||
|
||||
describe('fs/loader', function () {
|
||||
describe('.candidates()', function () {
|
||||
it('should resolve relatively', async function () {
|
||||
it('should resolve relatively', function () {
|
||||
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
|
||||
const candidates = [...loader.candidates('./foo/bar', ['/root', '/root/foo'], '/root/current', true)]
|
||||
expect(candidates).toContain('/root/foo/bar')
|
||||
const candidates = [...loader.candidates('./foo/bar', ['/root', '/root/foo'], '/root/current')]
|
||||
expect(candidates).toContain(resolve('/root/foo/bar'))
|
||||
})
|
||||
it('should not include out of root candidates', async function () {
|
||||
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
|
||||
const candidates = [...loader.candidates('../foo/bar', ['/root'], '/root/current', true)]
|
||||
expect(candidates).toHaveLength(0)
|
||||
})
|
||||
describe('.lookup()', function () {
|
||||
it('should not include out of root candidates', function () {
|
||||
const mockFs = { ...fs, existsSync: () => true, exists: async () => true }
|
||||
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
|
||||
expect(() => toValueSync(loader.lookup('../foo/bar', LookupType.Partials, true, '/root/current')))
|
||||
.toThrow(/ENOENT/)
|
||||
})
|
||||
it('should treat root as a terminated path', async function () {
|
||||
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
|
||||
const candidates = [...loader.candidates('../root-dir/bar', ['/root'], '/root/current', true)]
|
||||
expect(candidates).toHaveLength(0)
|
||||
it('should treat root as a terminated path', function () {
|
||||
const mockFs = { ...fs, existsSync: () => true, exists: async () => true }
|
||||
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
|
||||
expect(() => toValueSync(loader.lookup('../root-dir/bar', LookupType.Partials, true, '/root/current')))
|
||||
.toThrow(/ENOENT/)
|
||||
})
|
||||
it('should default `.contains()` to () => true', async function () {
|
||||
const customFs = {
|
||||
...fs,
|
||||
contains: undefined
|
||||
}
|
||||
const loader = new Loader({ relativeReference: true, fs: customFs, extname: '' } as any)
|
||||
const candidates = [...loader.candidates('../foo/bar', ['/root'], '/root/current', true)]
|
||||
expect(candidates).toContain('/foo/bar')
|
||||
it('should use permissive contains when fs.contains is omitted', function () {
|
||||
const mockFs = { ...fs, existsSync: () => true, exists: async () => true, contains: undefined, containsSync: undefined }
|
||||
const loader = new Loader({ relativeReference: true, fs: mockFs, extname: '', partials: ['/root'] } as any)
|
||||
const result = toValueSync(loader.lookup('./foo/bar', LookupType.Partials, true, '/root/current'))
|
||||
expect(result).toBe(resolve('/root/foo/bar'))
|
||||
})
|
||||
it('should enforce containment for LookupType.Root', function () {
|
||||
const mockFs = { ...fs, existsSync: () => true, exists: async () => true }
|
||||
const loader = new Loader({ relativeReference: false, fs: mockFs, extname: '', root: ['/safe'] } as any)
|
||||
expect(() => toValueSync(loader.lookup('/etc/hosts', LookupType.Root, true)))
|
||||
.toThrow(/ENOENT/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+26
-19
@@ -1,5 +1,5 @@
|
||||
import { FS } from './fs'
|
||||
import { assert, escapeRegex } from '../util'
|
||||
import { assert, LiquidAsync, toLiquidAsync } from '../util'
|
||||
|
||||
export interface LoaderOptions {
|
||||
fs: FS;
|
||||
@@ -17,48 +17,55 @@ export enum LookupType {
|
||||
export class Loader {
|
||||
public shouldLoadRelative: (referencedFile: string) => boolean
|
||||
private options: LoaderOptions
|
||||
private contains: (root: string, file: string) => boolean
|
||||
private contains: LiquidAsync<NonNullable<FS['containsSync']>>
|
||||
private exists: LiquidAsync<FS['existsSync']>
|
||||
|
||||
constructor (options: LoaderOptions) {
|
||||
this.options = options
|
||||
if (options.relativeReference) {
|
||||
const sep = options.fs.sep
|
||||
assert(sep, '`fs.sep` is required for relative reference')
|
||||
const rRelativePath = new RegExp(['.' + sep, '..' + sep, './', '../'].map(prefix => escapeRegex(prefix)).join('|'))
|
||||
this.shouldLoadRelative = (referencedFile: string) => rRelativePath.test(referencedFile)
|
||||
const prefixes = ['.' + sep, '..' + sep, './', '../']
|
||||
this.shouldLoadRelative = (referencedFile: string) => prefixes.some(prefix => referencedFile.startsWith(prefix))
|
||||
} else {
|
||||
this.shouldLoadRelative = (_referencedFile: string) => false
|
||||
}
|
||||
this.contains = this.options.fs.contains || (() => true)
|
||||
const fs = options.fs
|
||||
this.contains = toLiquidAsync(
|
||||
fs.contains?.bind(fs) || (async () => true),
|
||||
fs.containsSync?.bind(fs) || (() => true)
|
||||
)
|
||||
this.exists = toLiquidAsync(
|
||||
fs.exists?.bind(fs) || (async () => false),
|
||||
fs.existsSync?.bind(fs)
|
||||
)
|
||||
}
|
||||
|
||||
public * lookup (file: string, type: LookupType, sync?: boolean, currentFile?: string): Generator<unknown, string, string> {
|
||||
const { fs } = this.options
|
||||
const dirs = this.options[type]
|
||||
for (const filepath of this.candidates(file, dirs, currentFile, type !== LookupType.Root)) {
|
||||
if (sync ? fs.existsSync(filepath) : yield fs.exists(filepath)) return filepath
|
||||
for (const filepath of this.candidates(file, dirs, currentFile)) {
|
||||
let allowed = false
|
||||
for (const dir of dirs) {
|
||||
if (yield this.contains(!!sync, dir, filepath)) { allowed = true; break }
|
||||
}
|
||||
if (!allowed) continue
|
||||
if (yield this.exists(!!sync, filepath)) return filepath
|
||||
}
|
||||
throw this.lookupError(file, dirs)
|
||||
}
|
||||
|
||||
public * candidates (file: string, dirs: string[], currentFile?: string, enforceRoot?: boolean) {
|
||||
public * candidates (file: string, dirs: string[], currentFile?: string) {
|
||||
const { fs, extname } = this.options
|
||||
|
||||
if (this.shouldLoadRelative(file) && currentFile) {
|
||||
const referenced = fs.resolve(this.dirname(currentFile), file, extname)
|
||||
for (const dir of dirs) {
|
||||
if (!enforceRoot || this.contains(dir, referenced)) {
|
||||
// the relatively referenced file is within one of root dirs
|
||||
yield referenced
|
||||
break
|
||||
}
|
||||
}
|
||||
yield referenced
|
||||
}
|
||||
for (const dir of dirs) {
|
||||
const referenced = fs.resolve(dir, file, extname)
|
||||
if (!enforceRoot || this.contains(dir, referenced)) {
|
||||
yield referenced
|
||||
}
|
||||
yield referenced
|
||||
}
|
||||
|
||||
if (fs.fallback !== undefined) {
|
||||
const filepath = fs.fallback(file)
|
||||
if (filepath !== undefined) yield filepath
|
||||
|
||||
+6
-2
@@ -2,16 +2,20 @@
|
||||
export const version = '[VI]{version}[/VI]'
|
||||
export * as TypeGuards from './util/type-guards'
|
||||
export { toValue, createTrie, Trie, toPromise, toValueSync, assert, LiquidError, ParseError, RenderError, UndefinedVariableError, TokenizationError, AssertionError } from './util'
|
||||
export type { LiquidErrors } from './util/error'
|
||||
export { Drop } from './drop'
|
||||
export type { Comparable } from './drop'
|
||||
export { Emitter } from './emitters'
|
||||
export { defaultOperators, Operators, evalToken, evalQuotedToken, Expression, isFalsy, isTruthy } from './render'
|
||||
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 { TokenKind, Tokenizer, ParseStream, Parser } from './parser'
|
||||
export { filters } from './filters'
|
||||
export * from './tags'
|
||||
export { defaultOptions, LiquidOptions } from './liquid-options'
|
||||
export { FS } from './fs'
|
||||
export { defaultOptions } from './liquid-options'
|
||||
export type { LiquidOptions, RenderOptions, RenderFileOptions } from './liquid-options'
|
||||
export { FS, LookupType } from './fs'
|
||||
export { Liquid } from './liquid'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Limiter, toPromise, assert, isTagToken, isOutputToken, ParseError } from '../util'
|
||||
import { Limiter, toPromise, assert, isTagToken, isOutputToken, ParseError, toLiquidAsync, LiquidAsync } from '../util'
|
||||
import { Tokenizer } from './tokenizer'
|
||||
import { ParseStream } from './parse-stream'
|
||||
import { TopLevelToken, OutputToken } from '../tokens'
|
||||
@@ -16,6 +16,7 @@ export class Parser {
|
||||
private cache?: LiquidCache
|
||||
private loader: Loader
|
||||
private parseLimit: Limiter
|
||||
private readFile: LiquidAsync<FS['readFileSync']>
|
||||
|
||||
public constructor (liquid: Liquid) {
|
||||
this.liquid = liquid
|
||||
@@ -24,6 +25,10 @@ export class Parser {
|
||||
this.parseFile = this.cache ? this._parseFileCached : this._parseFile
|
||||
this.loader = new Loader(this.liquid.options)
|
||||
this.parseLimit = new Limiter('parse length', liquid.options.parseLimit)
|
||||
this.readFile = toLiquidAsync(
|
||||
this.fs.readFile?.bind(this.fs) || (async () => { throw new Error('readFile not implemented') }),
|
||||
this.fs.readFileSync?.bind(this.fs)
|
||||
)
|
||||
}
|
||||
public parse (html: string, filepath?: string): Template[] {
|
||||
html = String(html)
|
||||
@@ -82,6 +87,6 @@ export class Parser {
|
||||
}
|
||||
private * _parseFile (file: string, sync?: boolean, type: LookupType = LookupType.Root, currentFile?: string): Generator<unknown, Template[], string> {
|
||||
const filepath = yield this.loader.lookup(file, type, sync, currentFile)
|
||||
return this.parse(sync ? this.fs.readFileSync(filepath) : yield this.fs.readFile(filepath), filepath)
|
||||
return this.parse(yield this.readFile(!!sync, filepath), filepath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,7 +422,7 @@ export class Tokenizer {
|
||||
|
||||
* readFileNameTemplate (options: NormalizedFullOptions): IterableIterator<TopLevelToken> {
|
||||
const { outputDelimiterLeft } = options
|
||||
const htmlStopStrings = [',', ' ', outputDelimiterLeft]
|
||||
const htmlStopStrings = [',', ' ', '\r', '\n', '\t', outputDelimiterLeft]
|
||||
const htmlStopStringSet = new Set(htmlStopStrings)
|
||||
// break on ',' and ' ', outputDelimiterLeft only stops HTML token
|
||||
while (this.p < this.N && !htmlStopStringSet.has(this.peek())) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export class Render {
|
||||
if (!emitter) {
|
||||
emitter = ctx.opts.keepOutputType ? new KeepingTypeEmitter() : new SimpleEmitter()
|
||||
}
|
||||
ctx.renderLimit.check(getPerformance().now())
|
||||
const errors = []
|
||||
for (const tpl of templates) {
|
||||
ctx.renderLimit.check(getPerformance().now())
|
||||
|
||||
+8
-3
@@ -23,20 +23,25 @@ export default class extends Tag {
|
||||
* render (ctx: Context, emitter: Emitter) {
|
||||
const blockRender = this.getBlockRender(ctx)
|
||||
if (ctx.getRegister('blockMode') === BlockMode.STORE) {
|
||||
ctx.getRegister('blocks')[this.block] = blockRender
|
||||
ctx.getRegister('blocks', {} as Record<string, any>)[this.block] = blockRender
|
||||
} else {
|
||||
yield blockRender(new BlockDrop(), emitter)
|
||||
}
|
||||
}
|
||||
|
||||
private getBlockRender (ctx: Context) {
|
||||
const self = this as Tag
|
||||
const { liquid, templates } = this
|
||||
const renderChild = ctx.getRegister('blocks')[this.block]
|
||||
const renderChild = ctx.getRegister('blocks', {} as Record<string, any>)[this.block]
|
||||
const renderCurrent = function * (superBlock: BlockDrop, emitter: Emitter) {
|
||||
// add {{ block.super }} support when rendering
|
||||
const stack: Tag[] = ctx.getRegister('blockStack', [])
|
||||
if (stack.includes(self)) throw new Error('block tag cannot be nested')
|
||||
|
||||
stack.push(self)
|
||||
ctx.push({ block: superBlock })
|
||||
yield liquid.renderer.renderTemplates(templates, ctx, emitter)
|
||||
ctx.pop()
|
||||
stack.pop()
|
||||
}
|
||||
return renderChild
|
||||
? (superBlock: BlockDrop, emitter: Emitter) => renderChild(
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export default class extends Tag {
|
||||
* render (ctx: Context, emitter: Emitter): Generator<unknown, unknown, unknown> {
|
||||
const group = (yield evalToken(this.group, ctx)) as ValueToken
|
||||
const fingerprint = `cycle:${group}:` + this.candidates.join(',')
|
||||
const groups = ctx.getRegister('cycle')
|
||||
const groups = ctx.getRegister('cycle', {} as Record<string, number>)
|
||||
let idx = groups[fingerprint]
|
||||
|
||||
if (idx === undefined) {
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ export default class extends Tag {
|
||||
}
|
||||
|
||||
const continueKey = 'continue-' + this.variable + '-' + this.collection.getText()
|
||||
ctx.push({ continue: ctx.getRegister(continueKey) })
|
||||
ctx.push({ continue: ctx.getRegister(continueKey, {}) })
|
||||
const hash = yield this.hash.render(ctx)
|
||||
ctx.pop()
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export default class extends Tag {
|
||||
// render remaining contents and store rendered results
|
||||
ctx.setRegister('blockMode', BlockMode.STORE)
|
||||
const html = yield renderer.renderTemplates(this.templates, ctx)
|
||||
const blocks = ctx.getRegister('blocks')
|
||||
const blocks = ctx.getRegister('blocks', {} as Record<string, any>)
|
||||
|
||||
// set whole content to anonymous block if anonymous doesn't specified
|
||||
if (blocks[''] === undefined) blocks[''] = (parent: BlankDrop, emitter: Emitter) => emitter.write(html)
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { isPromise, isIterator } from './underscore'
|
||||
|
||||
export type LiquidAsync<F extends (...args: any[]) => any> =
|
||||
(sync: boolean, ...args: Parameters<F>) => ReturnType<F> | Promise<ReturnType<F>>
|
||||
|
||||
export function toLiquidAsync<F extends (...args: any[]) => any> (
|
||||
asyncFn: (...args: Parameters<F>) => Promise<ReturnType<F>>,
|
||||
syncFn?: F
|
||||
): LiquidAsync<F> {
|
||||
const syncImpl = syncFn || asyncFn as any
|
||||
return (sync: boolean, ...args: any[]) => {
|
||||
return sync ? syncImpl(...args as Parameters<F>) : asyncFn(...args as Parameters<F>)
|
||||
}
|
||||
}
|
||||
|
||||
// convert an async iterator to a Promise
|
||||
export async function toPromise<T> (val: Generator<unknown, T, unknown> | Promise<T> | T): Promise<T> {
|
||||
if (!isIterator(val)) return val
|
||||
|
||||
+7
-5
@@ -9,12 +9,14 @@ export class Limiter {
|
||||
this.limit = limit
|
||||
}
|
||||
use (count: number) {
|
||||
count = +count || 0
|
||||
assert(this.base + count <= this.limit, this.message)
|
||||
this.base += count
|
||||
if (+count > 0) {
|
||||
assert(this.base + +count <= this.limit, this.message)
|
||||
this.base += +count
|
||||
}
|
||||
}
|
||||
check (count: number) {
|
||||
count = +count || 0
|
||||
assert(count <= this.limit, this.message)
|
||||
if (+count > 0) {
|
||||
assert(+count <= this.limit, this.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { changeCase, padStart, padEnd } from './underscore'
|
||||
import { LiquidDate } from './liquid-date'
|
||||
import type { Limiter } from './limiter'
|
||||
|
||||
const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/
|
||||
interface FormatOptions {
|
||||
flags: object;
|
||||
width?: string;
|
||||
modifier?: string;
|
||||
memoryLimit?: Pick<Limiter, 'use'>;
|
||||
}
|
||||
|
||||
// prototype extensions
|
||||
@@ -95,6 +97,7 @@ const formatCodes = {
|
||||
N: (d: LiquidDate, opts: FormatOptions) => {
|
||||
const width = Number(opts.width) || 9
|
||||
const str = String(d.getMilliseconds()).slice(0, width)
|
||||
opts.memoryLimit?.use(width - str.length)
|
||||
return padEnd(str, width, '0')
|
||||
},
|
||||
p: (d: LiquidDate) => (d.getHours() < 12 ? 'AM' : 'PM'),
|
||||
@@ -118,25 +121,25 @@ const formatCodes = {
|
||||
};
|
||||
(formatCodes as any).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 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 = {}
|
||||
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 = width || padWidths[conversion] || 0
|
||||
if (flags['^']) ret = ret.toUpperCase()
|
||||
@@ -144,5 +147,6 @@ function format (d: LiquidDate, match: RegExpExecArray) {
|
||||
if (flags['_']) padChar = ' '
|
||||
else if (flags['0']) padChar = '0'
|
||||
if (flags['-']) padWidth = 0
|
||||
memoryLimit?.use(Number(padWidth) - ret.length)
|
||||
return padStart(ret, padWidth, padChar)
|
||||
}
|
||||
|
||||
+15
-10
@@ -22,10 +22,6 @@ export function isIterator (val: any): val is IterableIterator<any> {
|
||||
return val && isFunction(val.next) && isFunction(val.throw) && isFunction(val.return)
|
||||
}
|
||||
|
||||
export function escapeRegex (str: string) {
|
||||
return str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
|
||||
}
|
||||
|
||||
export function promisify<T1, T2> (fn: (arg1: T1, cb: (err: Error | null, result: T2) => void) => void): (arg1: T1) => Promise<T2>;
|
||||
export function promisify<T1, T2, T3> (fn: (arg1: T1, arg2: T2, cb: (err: Error | null, result: T3) => void) => void): (arg1: T1, arg2: T2) => Promise<T3>;
|
||||
export function promisify (fn: any) {
|
||||
@@ -156,9 +152,9 @@ export function padEnd (str: any, length: number, ch = ' ') {
|
||||
|
||||
export function pad (str: any, length: number, ch: string, add: (str: string, ch: string) => string) {
|
||||
str = String(str)
|
||||
let n = length - str.length
|
||||
while (n-- > 0) str = add(str, ch)
|
||||
return str
|
||||
const n = length - str.length
|
||||
if (n <= 0) return str
|
||||
return add(str, ch.repeat(n))
|
||||
}
|
||||
|
||||
export function identify<T> (val: T): T {
|
||||
@@ -174,11 +170,20 @@ export function ellipsis (str: string, N: number): string {
|
||||
return str.length > N ? str.slice(0, N - 3) + '...' : str
|
||||
}
|
||||
|
||||
export function orderedCompare (a: any, b: any) {
|
||||
if (isNil(a) && isNil(b)) return 0
|
||||
if (isNil(a)) return 1
|
||||
if (isNil(b)) return -1
|
||||
if (a < b) return -1
|
||||
if (a > b) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
// compare string in case-insensitive way, undefined values to the tail
|
||||
export function caseInsensitiveCompare (a: any, b: any) {
|
||||
if (a == null && b == null) return 0
|
||||
if (a == null) return 1
|
||||
if (b == null) return -1
|
||||
if (isNil(a) && isNil(b)) return 0
|
||||
if (isNil(a)) return 1
|
||||
if (isNil(b)) return -1
|
||||
a = toLowerCase.call(a)
|
||||
b = toLowerCase.call(b)
|
||||
if (a < b) return -1
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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
|
||||
|
||||
describe('Issues', function () {
|
||||
@@ -173,6 +175,24 @@ describe('Issues', function () {
|
||||
const html = await engine.render(tpl, { my_variable: 'foo' })
|
||||
expect(html).toBe('CONTENT for /tmp/prefix/foo-bar/suffix')
|
||||
})
|
||||
it('should prevent path traversal in dynamic include with restricted root, #851', () => {
|
||||
const projectRoot = resolvePath(__dirname, '../..')
|
||||
const poc = `
|
||||
const { Liquid } = require('./dist/liquid.node.js');
|
||||
const e = new Liquid({ root: ['/tmp'], partials: ['/tmp'], dynamicPartials: true });
|
||||
e.parseAndRender('{% include page %}', { page: '../../../etc/passwd' })
|
||||
.then(() => { console.log('OK'); })
|
||||
.catch(err => { console.error('ERR:' + err.message); process.exit(1); });
|
||||
`
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['-e', poc],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stderr).toContain('Failed to lookup')
|
||||
})
|
||||
it('Implement liquid/echo tags #428', () => {
|
||||
const template = `{%- liquid
|
||||
for value in array
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Liquid } from '../..'
|
||||
import { mkdtempSync, writeFileSync, symlinkSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
describe('.parseAndRender()', function () {
|
||||
var engine: Liquid, strictEngine: Liquid
|
||||
@@ -57,4 +60,61 @@ describe('.parseAndRender()', function () {
|
||||
const html = await engine.parseAndRender(src)
|
||||
expect(html).toBe('true')
|
||||
})
|
||||
const canSymlink = process.platform !== 'win32'
|
||||
;(canSymlink ? describe : describe.skip)('symlink outside root', function () {
|
||||
let root: string, secret: string
|
||||
beforeAll(function () {
|
||||
root = mkdtempSync(join(tmpdir(), 'liquid-e2e-root-'))
|
||||
secret = join(tmpdir(), `liquid-e2e-secret-${Date.now()}.liquid`)
|
||||
writeFileSync(secret, 'SECRET_OUTSIDE')
|
||||
symlinkSync(secret, join(root, 'link.liquid'))
|
||||
})
|
||||
afterAll(function () {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
rmSync(secret, { force: true })
|
||||
})
|
||||
it('should not render a symlink partial whose target is outside root', async function () {
|
||||
const e = new Liquid({ root: [root], extname: '.liquid', relativeReference: false })
|
||||
await expect(e.parseAndRender('{% render "link" %}')).rejects.toThrow(/ENOENT|Failed to lookup/)
|
||||
})
|
||||
it('should not render a symlink partial via parseAndRenderSync', function () {
|
||||
const e = new Liquid({ root: [root], extname: '.liquid', relativeReference: false })
|
||||
expect(() => e.parseAndRenderSync('{% render "link" %}')).toThrow(/ENOENT|Failed to lookup/)
|
||||
})
|
||||
})
|
||||
describe('layout: nested {% block %} regression', function () {
|
||||
let root: string
|
||||
beforeEach(function () {
|
||||
root = mkdtempSync(join(tmpdir(), 'liquid-e2e-layout-nested-'))
|
||||
})
|
||||
afterEach(function () {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
it('should reject same-name {% block %} nested in child template (no hang / OOM)', async function () {
|
||||
writeFileSync(
|
||||
join(root, 'layout.html'),
|
||||
'<header>{% block a %}default-a{% endblock %}</header>' +
|
||||
'<main>{% block b %}default-b{% endblock %}</main>' +
|
||||
'<footer>{% block c %}default-c{% endblock %}</footer>'
|
||||
)
|
||||
writeFileSync(
|
||||
join(root, 'template.html'),
|
||||
'{% layout "layout" %}' +
|
||||
'{% block a %}outer-a {% block a %}inner-a{% endblock %}{% endblock %}' +
|
||||
'{% block b %}content-b{% endblock %}' +
|
||||
'{% block c %}content-c{% endblock %}'
|
||||
)
|
||||
const liquid = new Liquid({ root, extname: '.html' })
|
||||
await expect(liquid.renderFile('template')).rejects.toThrow(/block tag cannot be nested/)
|
||||
})
|
||||
it('should reject nested anonymous {% block %} in child template (no hang / OOM)', async function () {
|
||||
writeFileSync(join(root, 'parent.html'), 'X{%block%}{%endblock%}Y')
|
||||
writeFileSync(
|
||||
join(root, 'template.html'),
|
||||
'{% layout "parent" %}{%block%}A{%block%}B{%endblock%}{%endblock%}'
|
||||
)
|
||||
const liquid = new Liquid({ root, extname: '.html' })
|
||||
await expect(liquid.renderFile('template')).rejects.toThrow(/block tag cannot be nested/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('#renderFile()', function () {
|
||||
return expect(html).toContain('"name": "liquidjs"')
|
||||
})
|
||||
it('should render file with context', async function () {
|
||||
engine = new Liquid({ root: views, extname: '.html' })
|
||||
const html = await engine.renderFile(resolve(views, 'name.html'), { name: 'harttle' })
|
||||
return expect(html).toBe('My name is harttle.')
|
||||
})
|
||||
|
||||
@@ -4,8 +4,8 @@ import { drainStream } from '../stub/stream'
|
||||
describe('.renderToNodeStream()', function () {
|
||||
it('should render to stream in Node.js', done => {
|
||||
const cjs = require('../../dist/liquid.node')
|
||||
const engine = new cjs.Liquid()
|
||||
const tpl = engine.parseFileSync(resolve(__dirname, '../stub/root/foo.html'))
|
||||
const engine = new cjs.Liquid({ root: resolve(__dirname, '../stub/root/') })
|
||||
const tpl = engine.parseFileSync('foo.html')
|
||||
const stream = engine.renderToNodeStream(tpl)
|
||||
let html = ''
|
||||
stream.on('data', (data: string) => { html += data })
|
||||
|
||||
@@ -315,6 +315,26 @@ describe('filters/array', function () {
|
||||
it('should return empty array for nil value', () => {
|
||||
return test('{{notDefined | sort | size}}', {}, '0')
|
||||
})
|
||||
it('should respect ownPropertyOnly', async () => {
|
||||
const engine = new Liquid({ ownPropertyOnly: true })
|
||||
const a = Object.create({ secret: 'ccc' })
|
||||
a.name = 'a'
|
||||
const b = Object.create({ secret: 'aaa' })
|
||||
b.name = 'b'
|
||||
const html = await engine.parseAndRender(
|
||||
'{{ arr | sort: "secret" | map: "name" | join: "," }}',
|
||||
{ arr: [a, b] }
|
||||
)
|
||||
expect(html).toBe('a,b')
|
||||
})
|
||||
it('should handle nil property values', async () => {
|
||||
const arr = [{ age: 'cc' }, { name: 'x' }, { age: 'aa' }, { age: 'bb' }]
|
||||
await test('{% assign sorted = arr | sort: "age" %}{% for item in sorted %}[{{ item.age }}]{% endfor %}', { arr }, '[aa][bb][cc][]')
|
||||
})
|
||||
it('should handle mixed-type items', async () => {
|
||||
const arr = ['40', null, 30, undefined, true, false, 0, 'str', 50]
|
||||
await test('{% assign sorted = arr | sort %}{% for item in sorted %}[{{ item }}]{% endfor %}', { arr }, '[false][0][true][30][40][str][50][][]')
|
||||
})
|
||||
})
|
||||
describe('sort_natural', function () {
|
||||
it('should sort alphabetically', () => {
|
||||
@@ -360,6 +380,23 @@ describe('filters/array', function () {
|
||||
{ students: undefined },
|
||||
'0'
|
||||
))
|
||||
it('should respect ownPropertyOnly', async () => {
|
||||
const engine = new Liquid({ ownPropertyOnly: true })
|
||||
const target = Object.create({ secret: 'bbb' })
|
||||
const html = await engine.parseAndRender(
|
||||
'{{ arr | sort_natural: "secret" | map: "secret" | join: "," }}',
|
||||
{ arr: [{ secret: 'ccc' }, target, { secret: 'aaa' }] }
|
||||
)
|
||||
expect(html).toBe('aaa,ccc,')
|
||||
})
|
||||
it('should handle nil property values', async () => {
|
||||
const arr = [{ age: '40' }, { name: 'x' }, { age: 30 }, { age: 50 }]
|
||||
await test('{% assign sorted = arr | sort_natural: "age" %}{% for item in sorted %}[{{ item.age }}]{% endfor %}', { arr }, '[30][40][50][]')
|
||||
})
|
||||
it('should handle mixed-type items', async () => {
|
||||
const arr = ['40', null, 30, undefined, true, false, 0, 'str', 50]
|
||||
await test('{% assign sorted = arr | sort_natural %}{% for item in sorted %}[{{ item }}]{% endfor %}', { arr }, '[0][30][40][50][false][str][true][][]')
|
||||
})
|
||||
})
|
||||
describe('uniq', function () {
|
||||
it('should uniq string list', function () {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { test, liquid } from '../../stub/render'
|
||||
|
||||
describe('filters/base64', function () {
|
||||
describe('base64_encode', function () {
|
||||
it('should encode a simple string', () => {
|
||||
return test('{{ "one two three" | base64_encode }}', 'b25lIHR3byB0aHJlZQ==')
|
||||
})
|
||||
|
||||
it('should encode an empty string', () => {
|
||||
return test('{{ "" | base64_encode }}', '')
|
||||
})
|
||||
|
||||
it('should encode a string with special characters', () => {
|
||||
return test('{{ "Hello, World! @#$%" | base64_encode }}', 'SGVsbG8sIFdvcmxkISBAIyQl')
|
||||
})
|
||||
|
||||
it('should encode unicode characters', () => {
|
||||
return test('{{ "你好世界" | base64_encode }}', '5L2g5aW95LiW55WM')
|
||||
})
|
||||
|
||||
it('should handle undefined input', () => {
|
||||
return test('{{ foo | base64_encode }}', '')
|
||||
})
|
||||
|
||||
it('should handle null input', () => {
|
||||
return test('{{ null | base64_encode }}', '')
|
||||
})
|
||||
|
||||
it('should handle numeric input', () => {
|
||||
return test('{{ 123 | base64_encode }}', 'MTIz')
|
||||
})
|
||||
|
||||
it('should handle boolean input', () => {
|
||||
return test('{{ true | base64_encode }}', 'dHJ1ZQ==')
|
||||
})
|
||||
})
|
||||
|
||||
describe('base64_decode', function () {
|
||||
it('should decode a simple string', () => {
|
||||
return test('{{ "b25lIHR3byB0aHJlZQ==" | base64_decode }}', 'one two three')
|
||||
})
|
||||
|
||||
it('should decode an empty string', () => {
|
||||
return test('{{ "" | base64_decode }}', '')
|
||||
})
|
||||
|
||||
it('should decode a string with special characters', () => {
|
||||
return test('{{ "SGVsbG8sIFdvcmxkISBAIyQl" | base64_decode }}', 'Hello, World! @#$%')
|
||||
})
|
||||
|
||||
it('should handle undefined input', () => {
|
||||
return test('{{ foo | base64_decode }}', '')
|
||||
})
|
||||
|
||||
it('should handle null input', () => {
|
||||
return test('{{ null | base64_decode }}', '')
|
||||
})
|
||||
|
||||
it('should handle numeric input', () => {
|
||||
return test('{{ "MTIz" | base64_decode }}', '123')
|
||||
})
|
||||
|
||||
it('should handle boolean input', () => {
|
||||
return test('{{ "dHJ1ZQ==" | base64_decode }}', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('base64_encode with Buffer input', function () {
|
||||
it('should encode a Buffer to base64 without data corruption', async () => {
|
||||
const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe])
|
||||
const result = await liquid.parseAndRender('{{ data | base64_encode }}', { data: buf })
|
||||
expect(result).toBe(buf.toString('base64'))
|
||||
})
|
||||
|
||||
it('should preserve bytes that are invalid UTF-8', async () => {
|
||||
const buf = Buffer.from([0x80, 0xff, 0xfe, 0x00, 0x01])
|
||||
const result = await liquid.parseAndRender('{{ data | base64_encode }}', { data: buf })
|
||||
const decoded = Buffer.from(result, 'base64')
|
||||
expect(decoded).toEqual(buf)
|
||||
})
|
||||
|
||||
it('should handle an empty Buffer', async () => {
|
||||
const buf = Buffer.alloc(0)
|
||||
const result = await liquid.parseAndRender('{{ data | base64_encode }}', { data: buf })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should handle a Buffer containing valid UTF-8 text', async () => {
|
||||
const buf = Buffer.from('Hello World', 'utf8')
|
||||
const result = await liquid.parseAndRender('{{ data | base64_encode }}', { data: buf })
|
||||
expect(result).toBe(Buffer.from('Hello World').toString('base64'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('base64 round-trip', function () {
|
||||
it('should encode and decode back to original', () => {
|
||||
return test('{{ "Hello, World!" | base64_encode | base64_decode }}', 'Hello, World!')
|
||||
})
|
||||
|
||||
it('should handle complex strings', () => {
|
||||
const complexString = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
return test(`{{ "${complexString}" | base64_encode | base64_decode }}`, complexString)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test } from '../../stub/render'
|
||||
|
||||
const SHA256_EMPTY = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
||||
|
||||
describe('filters/crypto', function () {
|
||||
describe('sha256', function () {
|
||||
it('should hash the Shopify reference example', () => {
|
||||
return test(
|
||||
'{{ "Polyjuice" | sha256 }}',
|
||||
'44ac1d7a2936e30a5de07082fd65d6fe9b1fb658a1a98bfe65bc5959beac5dd0'
|
||||
)
|
||||
})
|
||||
|
||||
it('should hash an empty string', () => {
|
||||
return test('{{ "" | sha256 }}', SHA256_EMPTY)
|
||||
})
|
||||
|
||||
it('should treat undefined as empty string', () => {
|
||||
return test('{{ foo | sha256 }}', SHA256_EMPTY)
|
||||
})
|
||||
|
||||
it('should treat null as empty string', () => {
|
||||
return test('{{ null | sha256 }}', SHA256_EMPTY)
|
||||
})
|
||||
|
||||
it('should stringify numeric input', () => {
|
||||
return test(
|
||||
'{{ 123 | sha256 }}',
|
||||
'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3'
|
||||
)
|
||||
})
|
||||
|
||||
it('should stringify boolean input', () => {
|
||||
return test(
|
||||
'{{ true | sha256 }}',
|
||||
'b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hmac_sha256', function () {
|
||||
it('should hash the Shopify reference example', () => {
|
||||
return test(
|
||||
"{{ 'Polyjuice' | hmac_sha256: 'Polina' }}",
|
||||
'8e0d5d65cff1242a4af66c8f4a32854fd5fb80edcc8aabe9b302b29c7c71dc20'
|
||||
)
|
||||
})
|
||||
|
||||
it('should accept a numeric key (stringified)', () => {
|
||||
return test(
|
||||
"{{ 'hello' | hmac_sha256: 42 }}",
|
||||
'3bdadea6ed0e95ededc15dc4421ce7654c970156843dfd997be3fef5358168ca'
|
||||
)
|
||||
})
|
||||
|
||||
it('should hash an empty message with an empty key', () => {
|
||||
return test(
|
||||
"{{ '' | hmac_sha256: '' }}",
|
||||
'b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad'
|
||||
)
|
||||
})
|
||||
|
||||
it('should treat undefined input as empty string', () => {
|
||||
return test(
|
||||
"{{ foo | hmac_sha256: '' }}",
|
||||
'b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -21,8 +21,12 @@ describe('filters/date', function () {
|
||||
const time = String(new Date('2017-03-07T12:00:00').getTime() / 1000)
|
||||
return test('{{ time | date: "%Y-%m-%dT%H:%M:%S" }}', { time }, '2017-03-07T12:00:00')
|
||||
})
|
||||
it('should treat nil as 0', () => {
|
||||
expect(liquid.parseAndRenderSync('{{ nil | date: "%Y-%m-%dT%H:%M:%S", "Asia/Shanghai" }}')).toEqual('1970-01-01T08:00:00')
|
||||
it('should treat null as invalid', () => {
|
||||
const time = null
|
||||
return test('{{ time | date: "%Y-%m-%dT%H:%M:%S" }}', { time }, '')
|
||||
})
|
||||
it('should treat nil as invalid', () => {
|
||||
expect(liquid.parseAndRenderSync('{{ nil | date: "%Y-%m-%dT%H:%M:%S", "Asia/Shanghai" }}')).toEqual('')
|
||||
})
|
||||
it('should treat undefined as invalid', () => {
|
||||
expect(liquid.parseAndRenderSync('{{ num | date: "%Y-%m-%dT%H:%M:%S", "Asia/Shanghai" }}', { num: undefined })).toEqual('')
|
||||
@@ -200,6 +204,33 @@ 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 / 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)
|
||||
const tight = new Liquid({ memoryLimit: 100 })
|
||||
expect(() => tight.parseAndRenderSync('{{ d | date: f }}', { d: 'now', f: '%5000d' }))
|
||||
.toThrow('memory alloc limit exceeded')
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('filters/date_to_xmlschema', function () {
|
||||
const liquid = new Liquid()
|
||||
|
||||
@@ -57,6 +57,9 @@ describe('filters/html', function () {
|
||||
it('should strip multiline comments', function () {
|
||||
expect(liquid.parseAndRenderSync('{{"<!--foo\r\nbar \ncoo\t \r\n -->"|strip_html}}')).toBe('')
|
||||
})
|
||||
it('should treat > inside comments as comment content (not a tag end)', function () {
|
||||
expect(liquid.parseAndRenderSync('{{ "<!-- a > b -->after" | strip_html }}')).toBe('after')
|
||||
})
|
||||
it('should strip all style tags and their contents', function () {
|
||||
return test('{{ "<style>cite { font-style: italic; }</style><cite>Ulysses<cite>?" | strip_html }}',
|
||||
'Ulysses?')
|
||||
@@ -77,5 +80,10 @@ describe('filters/html', function () {
|
||||
it('should strip until empty', function () {
|
||||
return test('{{"<br/><br />< p ></p></ p >" | strip_html }}', '')
|
||||
})
|
||||
it('should strip generic tags spanning ASCII newlines inside the tag', function () {
|
||||
expect(liquid.parseAndRenderSync('{{"<img\nsrc=x\nonerror=alert(1)>" | strip_html}}')).toBe('')
|
||||
expect(liquid.parseAndRenderSync('{{"<img\rsrc=x\ronerror=alert(1)>" | strip_html}}')).toBe('')
|
||||
expect(liquid.parseAndRenderSync('{{"<svg\nonload=alert(1)>" | strip_html}}')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,6 +65,8 @@ describe('filters/math', function () {
|
||||
describe('round', function () {
|
||||
it('should return "1" for 1.2', () => test('{{1.2|round}}', '1'))
|
||||
it('should return "3" for 2.7', () => test('{{2.7|round}}', '3'))
|
||||
it('should return "-3" for -2.5 (away from zero)', () => test('{{num|round}}', { num: -2.5 }, '-3'))
|
||||
it('should return "-2" for -2.49 (closest integer)', () => test('{{num|round}}', { num: -2.49 }, '-2'))
|
||||
it('should return "183.36" for 183.357,2',
|
||||
() => test('{{183.357|round: 2}}', '183.36'))
|
||||
it('should convert string to number', () => test('{{"2.7"|round}}', '3'))
|
||||
|
||||
@@ -109,6 +109,14 @@ describe('filters/string', function () {
|
||||
return test('{{ "Take my protein pills and put my helmet on" | replace: "my", "your" }}',
|
||||
'Take your protein pills and put your helmet on')
|
||||
})
|
||||
it('should support replace with undefined replacement', function () {
|
||||
return test('{{ "Take my protein pills and put my helmet on" | replace: "my" }}',
|
||||
'Take protein pills and put helmet on')
|
||||
})
|
||||
it('should support replace with undefined variable as replacement', function () {
|
||||
return test('{{ "Take my protein pills and put my helmet on" | replace: "my", missing_variable }}',
|
||||
'Take protein pills and put helmet on')
|
||||
})
|
||||
it('should support replace_first', function () {
|
||||
return test('{% assign my_string = "Take my protein pills and put my helmet on" %}\n' +
|
||||
'{{ my_string | replace_first: "my", "your" }}',
|
||||
|
||||
@@ -48,6 +48,16 @@ describe('DoS related', function () {
|
||||
await expect(liquid.parseAndRender('{% render "large" %}')).rejects.toThrow('template render limit exceeded')
|
||||
await expect(liquid.parseAndRender('{% render "small" %}')).resolves.toBe('12345')
|
||||
})
|
||||
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 () => {
|
||||
@@ -69,5 +79,30 @@ describe('DoS related', function () {
|
||||
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 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('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`.
|
||||
// The per-test timeout below caps total time; an O(n^2) regression would blow it.
|
||||
it('should handle many unclosed <script openers in linear time', () => {
|
||||
const liquid = new Liquid()
|
||||
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)
|
||||
expect(liquid.parseAndRenderSync('{{ x | strip_html }}', { x: payload })).toBe('foo'.repeat(50000))
|
||||
}, 1000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,6 +109,7 @@ describe('Liquid', function () {
|
||||
})
|
||||
})
|
||||
describe('#renderFile', function () {
|
||||
afterEach(restore)
|
||||
it('should throw with lookup list when file not exist', function () {
|
||||
const engine = new Liquid({
|
||||
root: ['/boo', '/root/'],
|
||||
@@ -116,6 +117,22 @@ describe('Liquid', function () {
|
||||
})
|
||||
return expect(engine.renderFile('/not/exist.html')).rejects.toThrow(/Failed to lookup "\/not\/exist.html" in "\/boo,\/root\/"/)
|
||||
})
|
||||
it('should reject absolute paths outside root', async function () {
|
||||
mock({
|
||||
'/safe/foo.html': 'safe',
|
||||
'/etc/secret': 'SECRET'
|
||||
})
|
||||
const engine = new Liquid({ root: ['/safe'] })
|
||||
await expect(engine.renderFile('/etc/secret')).rejects.toThrow(/Failed to lookup/)
|
||||
})
|
||||
it('should reject absolute paths outside root (sync)', function () {
|
||||
mock({
|
||||
'/safe/foo.html': 'safe',
|
||||
'/etc/secret': 'SECRET'
|
||||
})
|
||||
const engine = new Liquid({ root: ['/safe'] })
|
||||
expect(() => engine.renderFileSync('/etc/secret')).toThrow(/Failed to lookup/)
|
||||
})
|
||||
})
|
||||
describe('#parseFile', function () {
|
||||
it('should throw with lookup list when file not exist', function () {
|
||||
@@ -127,7 +144,7 @@ describe('Liquid', function () {
|
||||
})
|
||||
it('should fallback to require.resolve in Node.js', async function () {
|
||||
const engine = new Liquid({
|
||||
root: ['/root/'],
|
||||
root: [process.cwd()],
|
||||
extname: '.html'
|
||||
})
|
||||
const tpls = await engine.parseFileSync('jest')
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('tags/include', function () {
|
||||
})
|
||||
return liquid.renderFile('/parent.html').catch(function (e) {
|
||||
expect(e.name).toBe('TokenizationError')
|
||||
expect(e.message).toMatch('illegal file path, file:/parent.html, line:1, col:11')
|
||||
expect(e.message).toMatch(/illegal file path, file:.*parent.html, line:1, col:11/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -265,6 +265,14 @@ describe('tags/include', function () {
|
||||
const html = liquid.renderFileSync('/current.html')
|
||||
return expect(html).toBe('FOO-')
|
||||
})
|
||||
it('should support Jekyll style include with other whitespace before filename', function () {
|
||||
mock({
|
||||
'/current.html': '{% include bar/foo.html\r\n\ntitle="TITLE"\tcontent="FOO" %}',
|
||||
'/bar/foo.html': '{{include.title}}={{include.content}}-{{content}}'
|
||||
})
|
||||
const html = liquid.renderFileSync('/current.html')
|
||||
return expect(html).toBe('TITLE=FOO-')
|
||||
})
|
||||
it('should support multiple parameters', function () {
|
||||
mock({
|
||||
'/current.html': '{% include bar/foo.html header="HEADER" content="CONTENT" %}',
|
||||
|
||||
@@ -166,6 +166,27 @@ describe('tags/layout', function () {
|
||||
const html = await liquid.renderFile('/main.html')
|
||||
return expect(html).toBe('XAY')
|
||||
})
|
||||
it('should reject nested {% block %} with the same name (no OOM / hang)', function () {
|
||||
mock({
|
||||
'/layout.html':
|
||||
'<header>{% block a %}default-a{% endblock %}</header>' +
|
||||
'<main>{% block b %}default-b{% endblock %}</main>' +
|
||||
'<footer>{% block c %}default-c{% endblock %}</footer>',
|
||||
'/template.html':
|
||||
'{% layout "layout" %}' +
|
||||
'{% block a %}outer-a {% block a %}inner-a{% endblock %}{% endblock %}' +
|
||||
'{% block b %}content-b{% endblock %}' +
|
||||
'{% block c %}content-c{% endblock %}'
|
||||
})
|
||||
return expect(liquid.renderFile('/template.html')).rejects.toThrow(/block tag cannot be nested/)
|
||||
})
|
||||
it('should reject nested anonymous {% block %} (no OOM / hang)', function () {
|
||||
mock({
|
||||
'/parent.html': 'X{%block%}{%endblock%}Y'
|
||||
})
|
||||
const src = '{% layout "parent.html" %}{%block%}A{%block%}B{%endblock%}{%endblock%}'
|
||||
return expect(liquid.parseAndRender(src)).rejects.toThrow(/block tag cannot be nested/)
|
||||
})
|
||||
it('should not bleed scope into `include` layout', async function () {
|
||||
mock({
|
||||
'/parent.html': 'X{%block a%}{%endblock%}Y{%block b%}{%endblock%}Z',
|
||||
|
||||
@@ -271,6 +271,27 @@ describe('tags/render', function () {
|
||||
return expect(staticLiquid.renderFile('parent.html')).rejects.toThrow(/Failed to lookup "..\/bar\/child.html"/)
|
||||
})
|
||||
|
||||
describe('per-render ownPropertyOnly', function () {
|
||||
it('should propagate to {% render %} partial (spawned context)', async function () {
|
||||
mock({
|
||||
'/_user.liquid': '{{ user.passwordHash }}'
|
||||
})
|
||||
const engine = new Liquid({ ownPropertyOnly: false, root: '/' })
|
||||
class User {
|
||||
name: string
|
||||
constructor (n: string) {
|
||||
this.name = n
|
||||
}
|
||||
}
|
||||
Object.assign(User.prototype, { passwordHash: 'secret-from-prototype' })
|
||||
const u = new User('alice')
|
||||
const tpl = 'Direct:[{{ user.passwordHash }}] Render:[{% render "_user.liquid", user: user %}]'
|
||||
const html = await engine.parseAndRender(tpl, { user: u }, { ownPropertyOnly: true })
|
||||
expect(html).toBe('Direct:[] Render:[]')
|
||||
expect(engine.parseAndRenderSync(tpl, { user: u }, { ownPropertyOnly: true })).toBe('Direct:[] Render:[]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('static partial', function () {
|
||||
let staticLiquid: Liquid
|
||||
beforeEach(() => {
|
||||
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
import { isString, forOwn } from '../../src/util/underscore'
|
||||
import * as fs from '../../src/fs/fs-impl'
|
||||
import { resolve } from 'path'
|
||||
import { resolve, sep } from 'path'
|
||||
|
||||
interface FileDescriptor {
|
||||
mode: string;
|
||||
@@ -8,7 +8,7 @@ interface FileDescriptor {
|
||||
}
|
||||
|
||||
let files: { [path: string]: FileDescriptor } = {}
|
||||
const { readFile, exists, readFileSync, existsSync } = fs
|
||||
const { readFile, exists, readFileSync, existsSync, contains, containsSync } = fs
|
||||
|
||||
export function mock (options: { [path: string]: (string | FileDescriptor) }) {
|
||||
forOwn(options, (val, key) => {
|
||||
@@ -30,6 +30,16 @@ export function mock (options: { [path: string]: (string | FileDescriptor) }) {
|
||||
};
|
||||
(fs as any).existsSync = function (path: string) {
|
||||
return !!files[path]
|
||||
};
|
||||
(fs as any).contains = async (root: string, file: string) => {
|
||||
root = resolve(root)
|
||||
if (!root.endsWith(sep)) root += sep
|
||||
return file.startsWith(root)
|
||||
};
|
||||
(fs as any).containsSync = (root: string, file: string) => {
|
||||
root = resolve(root)
|
||||
if (!root.endsWith(sep)) root += sep
|
||||
return file.startsWith(root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,5 +48,7 @@ export function restore () {
|
||||
(fs as any).readFileSync = readFileSync;
|
||||
(fs as any).existsSync = existsSync;
|
||||
(fs as any).readFile = readFile;
|
||||
(fs as any).exists = exists
|
||||
(fs as any).exists = exists;
|
||||
(fs as any).contains = contains;
|
||||
(fs as any).containsSync = containsSync
|
||||
}
|
||||
|
||||
+1
-2
@@ -10,8 +10,7 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"downlevelIteration": true,
|
||||
"strict": true,
|
||||
"suppressImplicitAnyIndexErrors": true
|
||||
"strict": true
|
||||
},
|
||||
"all": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user