Compare commits

..
Author SHA1 Message Date
Harttle bfd426789c docs: state differences regarding inspect array/hash, #852, #853 2026-03-08 03:12:59 +08:00
78 changed files with 2258 additions and 4805 deletions
+1 -55
View File
@@ -14,7 +14,7 @@
"login": "harttle",
"name": "Jun Yang",
"avatar_url": "https://avatars3.githubusercontent.com/u/4427974?v=4",
"profile": "https://github.com/harttle",
"profile": "https://harttle.land",
"contributions": [
"maintenance",
"code"
@@ -784,60 +784,6 @@
"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,
-17
View File
@@ -1,17 +0,0 @@
---
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`.
-6
View File
@@ -1,6 +0,0 @@
---
description: Project conventions for liquidjs
alwaysApply: true
---
- Keep edits minimal: change only what the task requires, match existing style.
-17
View File
@@ -1,17 +0,0 @@
---
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 repos Jest setup).
-5
View File
@@ -4,11 +4,6 @@ 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
-65
View File
@@ -1,68 +1,3 @@
## [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)
+3 -11
View File
@@ -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,7 +64,6 @@ 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!
@@ -82,8 +81,7 @@ If you personally love LiquidJS or it's benefiting your business, please conside
<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://www.lambdatest.com/?utm_source=liquidjs&utm_medium=sponsor" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://www.lambdatest.com/blue-logo.png" width="240" style="vertical-align: middle;" alt="LambdaTest" title="LambdaTest"/></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>
@@ -110,7 +108,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://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://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/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>
@@ -219,12 +217,6 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/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
View File
@@ -6,7 +6,7 @@ Only the latest major version is supported with security updates. It can be chan
## Reporting a Vulnerability
Please contact harttleharttle@gmail.com to report a vulnerability or change request.
Please contact yangjvn@126.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).
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
rm -rf docs/source/api
typedoc --plugin typedoc-plugin-missing-exports ./src --gitRevision master --out docs/source/api
-24
View File
@@ -1,24 +0,0 @@
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, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
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)
+15
View File
@@ -0,0 +1,15 @@
#!/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/"/\&quot;/g' \
-e '1 s/</\&lt;/g' \
-e '1 s/>/\&gt;/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
-39
View File
@@ -1,39 +0,0 @@
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)
+25
View File
@@ -0,0 +1,25 @@
#!/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' | \
tr -d '\n' | \
sed 's/<\/tr>\s*<tr>//g' \
> docs/themes/navy/layout/partial/financial-contributors.swig
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
BUNDLES=min npm run build
mkdir -p docs/public/js/
cp dist/liquid.browser.min.js docs/public/js/
+17
View File
@@ -0,0 +1,17 @@
#!/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
+36
View File
@@ -0,0 +1,36 @@
#!/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
-17
View File
@@ -1,17 +0,0 @@
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' })
+12
View File
@@ -0,0 +1,12 @@
#!/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
+1 -1
View File
@@ -8,7 +8,7 @@
"test": "echo not implemented",
"start": "http-server -c-1 "
},
"author": "harttle <harttleharttle@gmail.com>",
"author": "harttle <yangjvn@126.com>",
"license": "ISC",
"dependencies": {
"http-server": "^0.11.1",
+1 -1
View File
@@ -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', 'node_modules']
partials: process.cwd() + '/partials'
})
const ctx = {
+1 -1
View File
@@ -1,3 +1,3 @@
set -e
set -ex
npm start | grep 'LiquidJS Demo'
+1 -1
View File
@@ -1,4 +1,4 @@
set -e
set -x
LOG_FILE=$(mktemp)
npm start > $LOG_FILE 2>&1 &
+1 -1
View File
@@ -1,3 +1,3 @@
set -e
set -ex
npm start | grep 'NodeJS Demo for LiquidJS'
+1 -1
View File
@@ -1,3 +1,3 @@
set -e
set -ex
npm start | grep '\[11:8] {{ todo }}'
+1 -1
View File
@@ -1,3 +1,3 @@
set -e
set -ex
npm run build && npm start | grep 'TypeScript Demo for LiquidJS'
+1 -1
View File
@@ -1,4 +1,4 @@
set -e
set -ex
npm run build
npm start | grep 'Webpack Demo for LiquidJS'
-20
View File
@@ -1,20 +0,0 @@
---
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
```
-1
View File
@@ -16,6 +16,5 @@ Array | slice, map, sort, sort_natural, uniq, where, where_exp, group_by, group_
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
-20
View File
@@ -1,20 +0,0 @@
---
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
```
+14 -8
View File
@@ -45,20 +45,26 @@ It can be a string-typed path (see above example), or a list of root directories
```javascript
var engine = new Liquid({
root: ['views/'],
partials: ['views/partials/'],
layouts: ['views/layouts/'],
root: ['views/', 'views/partials/'],
extname: '.liquid'
});
```
{% note tip Relative Paths %}Relative paths in <code>root</code> will be resolved against <code>cwd()</code>.{% endnote %}
- 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.
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 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).
- `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).
## Abstract File System
@@ -92,7 +98,7 @@ var engine = new Liquid({
});
```
{% 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 %}
{% 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 %}
## In-memory Template
+1982 -3798
View File
File diff suppressed because it is too large Load Diff
+5 -15
View File
@@ -1,6 +1,6 @@
{
"name": "liquidjs",
"version": "10.25.7",
"version": "10.24.0",
"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": "node bin/perf-diff.js",
"perf:diff": "bin/perf-diff.sh",
"perf:engines": "cd benchmark && npm run engines",
"version": "npm run build && npm test",
"build": "rollup -c rollup.config.mjs",
@@ -29,12 +29,7 @@
"build:min": "BUNDLES=min rollup -c rollup.config.mjs",
"build:umd": "BUNDLES=umd rollup -c rollup.config.mjs",
"build:charmap": "./bin/character-gen.js > src/util/character.ts",
"build:docs": "run-s build:docs-liquid build:contributors build:apidoc build:changelog build:docs-hexo",
"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/"
"build:docs": "bin/build-docs.sh"
},
"bin": {
"liquidjs": "./bin/liquid.js",
@@ -73,7 +68,7 @@
"@semantic-release/changelog": "^6.0.2",
"@semantic-release/commit-analyzer": "^9.0.2",
"@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "^13.1.5",
"@semantic-release/npm": "^9.0.2",
"@semantic-release/release-notes-generator": "^10.0.3",
"@types/benchmark": "^1.0.31",
"@types/express": "^4.17.2",
@@ -100,14 +95,12 @@
"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": "^25.0.3",
"shx": "^0.4.0",
"semantic-release": "^19.0.3",
"sinon": "^15.0.2",
"supertest": "^3.4.2",
"ts-jest": "^29.0.5",
@@ -158,9 +151,6 @@
]
]
},
"publishConfig": {
"provenance": true
},
"nyc": {
"extension": [
".ts"
+2 -10
View File
@@ -50,11 +50,6 @@ const browserBase64 = {
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: ['', ''],
@@ -72,7 +67,7 @@ const nodeCjs = {
format: 'cjs',
banner
}],
external: ['path', 'fs', 'stream', 'crypto'],
external: ['path', 'fs', 'stream'],
plugins: [versionInjection, typescript(tsconfig('ES2020'))],
treeshake,
input
@@ -84,7 +79,7 @@ const nodeEsm = {
format: 'esm',
banner
}],
external: ['path', 'fs', 'stream', 'crypto'],
external: ['path', 'fs', 'stream'],
plugins: [
versionInjection,
replace(esmRequire),
@@ -105,7 +100,6 @@ const browserEsm = {
versionInjection,
replace(browserFS),
replace(browserBase64),
replace(browserCrypto),
replace(browserStream),
typescript(tsconfig('es6'))
],
@@ -125,7 +119,6 @@ const browserUmd = {
versionInjection,
replace(browserFS),
replace(browserBase64),
replace(browserCrypto),
replace(browserStream),
typescript(tsconfig('es5'))
],
@@ -145,7 +138,6 @@ const browserMin = {
versionInjection,
replace(browserFS),
replace(browserBase64),
replace(browserCrypto),
replace(browserStream),
typescript(tsconfig('es5')),
uglify()
-45
View File
@@ -1,45 +0,0 @@
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')
})
})
})
-27
View File
@@ -1,27 +0,0 @@
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)
}
+3 -4
View File
@@ -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<T> (key: string, defaultValue: T = undefined as T): T {
return (this.registers[key] = this.registers[key] || defaultValue)
public getRegister (key: string) {
return (this.registers[key] = this.registers[key] || {})
}
public setRegister (key: string, value: any) {
return (this.registers[key] = value)
@@ -106,8 +106,7 @@ export class Context {
return new Context(scope, this.opts, {
sync: this.sync,
globals: this.globals,
strictVariables: this.strictVariables,
ownPropertyOnly: this.ownPropertyOnly
strictVariables: this.strictVariables
}, {
renderLimit: this.renderLimit,
memoryLimit: this.memoryLimit
+1 -1
View File
@@ -1,6 +1,6 @@
import { Drop } from '../drop/drop'
interface ScopeObject extends Record<string | number | symbol, any> {
interface ScopeObject extends Record<string, any> {
toLiquid?: () => any;
}
+16 -10
View File
@@ -1,4 +1,4 @@
import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, orderedCompare, isArray, isNil, last as arrayLast, isArrayLike, toEnumerable } from '../util'
import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, 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()
})
function * sortBy<T> (this: FilterImpl, arr: T[], property: string | undefined, comparator: (a: unknown, b: unknown) => number): IterableIterator<unknown> {
const values: [T, unknown][] = []
export function * sort<T> (this: FilterImpl, arr: T[], property?: string): IterableIterator<unknown> {
const values: [T, string | number][] = []
const array = toArray(arr)
this.context.memoryLimit.use(array.length)
for (const item of array) {
@@ -30,15 +30,21 @@ function * sortBy<T> (this: FilterImpl, arr: T[], property: string | undefined,
property ? yield this.context._getFromScope(item, stringify(property).split('.'), false) : item
])
}
return values.sort((lhs, rhs) => comparator(lhs[1], rhs[1])).map(tuple => tuple[0])
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])
}
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 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 const size = (v: string | any[]) => (v && v.length) || 0
+1 -5
View File
@@ -8,11 +8,7 @@ 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')
}
export function base64_encode (this: FilterImpl, value: string): string {
const str = stringify(value)
this.context.memoryLimit.use(str.length)
return base64Encode(str)
-9
View File
@@ -1,9 +0,0 @@
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')
}
-22
View File
@@ -1,22 +0,0 @@
/**
* 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)
}
+1 -3
View File
@@ -45,9 +45,7 @@ function parseDate (v: string | Date, opts: NormalizedFullOptions, timezoneOffse
const defaultTimezoneOffset = timezoneOffset ?? opts.timezoneOffset
const locale = opts.locale
v = toValue(v)
if (isNil(v)) {
return undefined
} else if (v === 'now' || v === 'today') {
if (v === 'now' || v === 'today') {
date = new LiquidDate(Date.now(), locale, defaultTimezoneOffset)
} else if (isNumber(v)) {
date = new LiquidDate(v * 1000, locale, defaultTimezoneOffset)
+1 -1
View File
@@ -45,5 +45,5 @@ export function newline_to_br (this: FilterImpl, v: string) {
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]*?>|<!--[\s\S]*?-->/g, '')
return str.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<.*?>|<!--[\s\S]*?-->/g, '')
}
-2
View File
@@ -5,7 +5,6 @@ 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'
@@ -17,6 +16,5 @@ export const filters: Record<string, FilterImplOptions> = {
...dateFilters,
...stringFilters,
...base64Filters,
...cryptoFilters,
...misc
}
+1 -3
View File
@@ -15,7 +15,5 @@ export function round (v: number, arg = 0) {
v = toNumber(v)
arg = toNumber(arg)
const amp = Math.pow(10, arg)
const scaled = v * amp
// Round half away from zero
return Math.sign(v) * Math.round(Math.abs(scaled)) / amp
return Math.round(v * amp) / amp
}
+25 -47
View File
@@ -11,7 +11,7 @@
// Hiragana (Japanese): \u3040-\u309F
// Hangul (Korean): \uAC00-\uD7AF
import { FilterImpl } from '../template'
import { assert, stringify } from '../util'
import { assert, escapeRegExp, stringify } from '../util'
const rCJKWord = /[\u4E00-\u9FFF\uF900-\uFAFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/gu
@@ -38,14 +38,10 @@ export function lstrip (this: FilterImpl, v: string, chars?: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
if (chars) {
chars = stringify(chars)
this.context.memoryLimit.use(chars.length)
for (let i = 0, set = new Set(chars); i < str.length; i++) {
if (!set.has(str[i])) return str.slice(i)
}
return ''
chars = escapeRegExp(stringify(chars))
return str.replace(new RegExp(`^[${chars}]+`, 'g'), '')
}
return str.trimStart()
return str.replace(/^\s+/, '')
}
export function downcase (this: FilterImpl, v: string) {
@@ -62,22 +58,20 @@ export function upcase (this: FilterImpl, v: string) {
export function remove (this: FilterImpl, v: string, arg: string) {
const str = stringify(v)
arg = stringify(arg)
this.context.memoryLimit.use(str.length + arg.length)
return str.split(arg).join('')
this.context.memoryLimit.use(str.length)
return str.split(stringify(arg)).join('')
}
export function remove_first (this: FilterImpl, v: string, l: string) {
const str = stringify(v)
l = stringify(l)
this.context.memoryLimit.use(str.length + l.length)
return str.replace(l, '')
this.context.memoryLimit.use(str.length)
return str.replace(stringify(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)
@@ -87,14 +81,10 @@ export function rstrip (this: FilterImpl, str: string, chars?: string) {
str = stringify(str)
this.context.memoryLimit.use(str.length)
if (chars) {
chars = stringify(chars)
this.context.memoryLimit.use(chars.length)
for (let i = str.length - 1, set = new Set(chars); i >= 0; i--) {
if (!set.has(str[i])) return str.slice(0, i + 1)
}
return ''
chars = escapeRegExp(stringify(chars))
return str.replace(new RegExp(`[${chars}]+$`, 'g'), '')
}
return str.trimEnd()
return str.replace(/\s+$/, '')
}
export function split (this: FilterImpl, v: string, arg: string) {
@@ -111,13 +101,10 @@ export function strip (this: FilterImpl, v: string, chars?: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
if (chars) {
const set = new Set(stringify(chars))
this.context.memoryLimit.use(set.size)
let i = 0
let j = str.length - 1
while (set.has(str[i])) i++
while (j >= i && set.has(str[j])) j--
return str.slice(i, j + 1)
chars = escapeRegExp(stringify(chars))
return str
.replace(new RegExp(`^[${chars}]+`, 'g'), '')
.replace(new RegExp(`[${chars}]+$`, 'g'), '')
}
return str.trim()
}
@@ -136,44 +123,36 @@ export function capitalize (this: FilterImpl, str: string) {
export function replace (this: FilterImpl, v: string, pattern: string, replacement: string) {
const str = stringify(v)
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)
this.context.memoryLimit.use(str.length)
return str.split(stringify(pattern)).join(replacement)
}
export function replace_first (this: FilterImpl, v: string, arg1: string, arg2: string) {
const str = stringify(v)
arg1 = stringify(arg1)
arg2 = stringify(arg2)
this.context.memoryLimit.use(str.length + arg1.length + arg2.length)
return str.replace(arg1, () => arg2)
this.context.memoryLimit.use(str.length)
return str.replace(stringify(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)
o = stringify(o)
this.context.memoryLimit.use(str.length + o.length)
this.context.memoryLimit.use(str.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)
o = stringify(o)
this.context.memoryLimit.use(str.length + o.length)
this.context.memoryLimit.use(str.length)
const arr = str.split(/\s+/)
if (words <= 0) words = 1
let ret = arr.slice(0, words).join(' ')
@@ -208,8 +187,7 @@ export function number_of_words (this: FilterImpl, input: string, mode?: 'cjk' |
}
export function array_to_sentence_string (this: FilterImpl, array: unknown[], connector = 'and') {
connector = stringify(connector)
this.context.memoryLimit.use(array.length + connector.length)
this.context.memoryLimit.use(array.length)
switch (array.length) {
case 0:
return ''
-36
View File
@@ -1,9 +1,5 @@
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 () {
@@ -54,36 +50,4 @@ 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 })
}
})
})
})
+5 -22
View File
@@ -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, realpath, realpathSync } from 'fs'
import { stat, statSync, readFile as nodeReadFile, readFileSync as nodeReadFileSync } from 'fs'
import { requireResolve } from './node-require'
type NodeReadFile = (file: string, encoding: string, cb: ((err: Error | null, result: string) => void)) => void
@@ -41,27 +41,10 @@ export function fallback (file: string) {
export function dirname (filepath: string) {
return nodeDirname(filepath)
}
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 function contains (root: string, file: string) {
root = nodeResolve(root)
root = root.endsWith(sep) ? root : root + sep
return file.startsWith(root)
}
export { sep } from 'path'
+2 -4
View File
@@ -9,10 +9,8 @@ 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`. 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;
/** 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;
/** defaults to "/" */
sep?: string;
/** required for relative path resolving */
+20 -29
View File
@@ -1,40 +1,31 @@
import * as fs from './fs-impl'
import { resolve } from 'path'
import { Loader, LookupType } from './loader'
import { toValueSync } from '../util/async'
import { Loader } from './loader'
describe('fs/loader', function () {
describe('.candidates()', function () {
it('should resolve relatively', function () {
it('should resolve relatively', async function () {
const loader = new Loader({ relativeReference: true, fs, extname: '' } as any)
const candidates = [...loader.candidates('./foo/bar', ['/root', '/root/foo'], '/root/current')]
expect(candidates).toContain(resolve('/root/foo/bar'))
const candidates = [...loader.candidates('./foo/bar', ['/root', '/root/foo'], '/root/current', true)]
expect(candidates).toContain('/root/foo/bar')
})
})
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 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)
})
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 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 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/)
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')
})
})
})
+19 -26
View File
@@ -1,5 +1,5 @@
import { FS } from './fs'
import { assert, LiquidAsync, toLiquidAsync } from '../util'
import { assert, escapeRegex } from '../util'
export interface LoaderOptions {
fs: FS;
@@ -17,55 +17,48 @@ export enum LookupType {
export class Loader {
public shouldLoadRelative: (referencedFile: string) => boolean
private options: LoaderOptions
private contains: LiquidAsync<NonNullable<FS['containsSync']>>
private exists: LiquidAsync<FS['existsSync']>
private contains: (root: string, file: string) => boolean
constructor (options: LoaderOptions) {
this.options = options
if (options.relativeReference) {
const sep = options.fs.sep
assert(sep, '`fs.sep` is required for relative reference')
const prefixes = ['.' + sep, '..' + sep, './', '../']
this.shouldLoadRelative = (referencedFile: string) => prefixes.some(prefix => referencedFile.startsWith(prefix))
const rRelativePath = new RegExp(['.' + sep, '..' + sep, './', '../'].map(prefix => escapeRegex(prefix)).join('|'))
this.shouldLoadRelative = (referencedFile: string) => rRelativePath.test(referencedFile)
} else {
this.shouldLoadRelative = (_referencedFile: string) => false
}
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)
)
this.contains = this.options.fs.contains || (() => true)
}
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)) {
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
for (const filepath of this.candidates(file, dirs, currentFile, type !== LookupType.Root)) {
if (sync ? fs.existsSync(filepath) : yield fs.exists(filepath)) return filepath
}
throw this.lookupError(file, dirs)
}
public * candidates (file: string, dirs: string[], currentFile?: string) {
public * candidates (file: string, dirs: string[], currentFile?: string, enforceRoot?: boolean) {
const { fs, extname } = this.options
if (this.shouldLoadRelative(file) && currentFile) {
const referenced = fs.resolve(this.dirname(currentFile), file, extname)
yield referenced
for (const dir of dirs) {
if (!enforceRoot || this.contains(dir, referenced)) {
// the relatively referenced file is within one of root dirs
yield referenced
break
}
}
}
for (const dir of dirs) {
const referenced = fs.resolve(dir, file, extname)
yield referenced
if (!enforceRoot || this.contains(dir, referenced)) {
yield referenced
}
}
if (fs.fallback !== undefined) {
const filepath = fs.fallback(file)
if (filepath !== undefined) yield filepath
+2 -7
View File
@@ -1,4 +1,4 @@
import { Limiter, toPromise, assert, isTagToken, isOutputToken, ParseError, toLiquidAsync, LiquidAsync } from '../util'
import { Limiter, toPromise, assert, isTagToken, isOutputToken, ParseError } from '../util'
import { Tokenizer } from './tokenizer'
import { ParseStream } from './parse-stream'
import { TopLevelToken, OutputToken } from '../tokens'
@@ -16,7 +16,6 @@ export class Parser {
private cache?: LiquidCache
private loader: Loader
private parseLimit: Limiter
private readFile: LiquidAsync<FS['readFileSync']>
public constructor (liquid: Liquid) {
this.liquid = liquid
@@ -25,10 +24,6 @@ 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)
@@ -87,6 +82,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(yield this.readFile(!!sync, filepath), filepath)
return this.parse(sync ? this.fs.readFileSync(filepath) : yield this.fs.readFile(filepath), filepath)
}
}
-1
View File
@@ -15,7 +15,6 @@ 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())
+3 -8
View File
@@ -23,25 +23,20 @@ export default class extends Tag {
* render (ctx: Context, emitter: Emitter) {
const blockRender = this.getBlockRender(ctx)
if (ctx.getRegister('blockMode') === BlockMode.STORE) {
ctx.getRegister('blocks', {} as Record<string, any>)[this.block] = blockRender
ctx.getRegister('blocks')[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', {} as Record<string, any>)[this.block]
const renderChild = ctx.getRegister('blocks')[this.block]
const renderCurrent = function * (superBlock: BlockDrop, emitter: Emitter) {
const stack: Tag[] = ctx.getRegister('blockStack', [])
if (stack.includes(self)) throw new Error('block tag cannot be nested')
stack.push(self)
// add {{ block.super }} support when rendering
ctx.push({ block: superBlock })
yield liquid.renderer.renderTemplates(templates, ctx, emitter)
ctx.pop()
stack.pop()
}
return renderChild
? (superBlock: BlockDrop, emitter: Emitter) => renderChild(
+1 -1
View File
@@ -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', {} as Record<string, number>)
const groups = ctx.getRegister('cycle')
let idx = groups[fingerprint]
if (idx === undefined) {
+1 -1
View File
@@ -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
View File
@@ -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', {} as Record<string, any>)
const blocks = ctx.getRegister('blocks')
// set whole content to anonymous block if anonymous doesn't specified
if (blocks[''] === undefined) blocks[''] = (parent: BlankDrop, emitter: Emitter) => emitter.write(html)
-13
View File
@@ -1,18 +1,5 @@
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
+5 -7
View File
@@ -9,14 +9,12 @@ export class Limiter {
this.limit = limit
}
use (count: number) {
if (+count > 0) {
assert(this.base + +count <= this.limit, this.message)
this.base += +count
}
count = +count || 0
assert(this.base + count <= this.limit, this.message)
this.base += count
}
check (count: number) {
if (+count > 0) {
assert(+count <= this.limit, this.message)
}
count = +count || 0
assert(count <= this.limit, this.message)
}
}
+7 -12
View File
@@ -22,6 +22,10 @@ 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) {
@@ -170,20 +174,11 @@ 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 (isNil(a) && isNil(b)) return 0
if (isNil(a)) return 1
if (isNil(b)) return -1
if (a == null && b == null) return 0
if (a == null) return 1
if (b == null) return -1
a = toLowerCase.call(a)
b = toLowerCase.call(b)
if (a < b) return -1
-20
View File
@@ -1,6 +1,4 @@
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 () {
@@ -175,24 +173,6 @@ 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
-60
View File
@@ -1,7 +1,4 @@
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
@@ -60,61 +57,4 @@ 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/)
})
})
})
-1
View File
@@ -38,7 +38,6 @@ 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.')
})
+2 -2
View File
@@ -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({ root: resolve(__dirname, '../stub/root/') })
const tpl = engine.parseFileSync('foo.html')
const engine = new cjs.Liquid()
const tpl = engine.parseFileSync(resolve(__dirname, '../stub/root/foo.html'))
const stream = engine.renderToNodeStream(tpl)
let html = ''
stream.on('data', (data: string) => { html += data })
-37
View File
@@ -315,26 +315,6 @@ 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', () => {
@@ -380,23 +360,6 @@ 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 () {
+1 -28
View File
@@ -1,4 +1,4 @@
import { test, liquid } from '../../stub/render'
import { test } from '../../stub/render'
describe('filters/base64', function () {
describe('base64_encode', function () {
@@ -65,33 +65,6 @@ describe('filters/base64', function () {
})
})
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!')
-70
View File
@@ -1,70 +0,0 @@
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'
)
})
})
})
+2 -6
View File
@@ -21,12 +21,8 @@ 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 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 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 undefined as invalid', () => {
expect(liquid.parseAndRenderSync('{{ num | date: "%Y-%m-%dT%H:%M:%S", "Asia/Shanghai" }}', { num: undefined })).toEqual('')
-5
View File
@@ -77,10 +77,5 @@ 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('')
})
})
})
-2
View File
@@ -65,8 +65,6 @@ 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'))
-8
View File
@@ -109,14 +109,6 @@ 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" }}',
-5
View File
@@ -48,11 +48,6 @@ 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')
})
})
describe('#memoryLimit', () => {
it('should throw for too many array creation in filters', async () => {
+1 -18
View File
@@ -109,7 +109,6 @@ 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/'],
@@ -117,22 +116,6 @@ 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 () {
@@ -144,7 +127,7 @@ describe('Liquid', function () {
})
it('should fallback to require.resolve in Node.js', async function () {
const engine = new Liquid({
root: [process.cwd()],
root: ['/root/'],
extname: '.html'
})
const tpls = await engine.parseFileSync('jest')
+1 -1
View File
@@ -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')
})
})
-21
View File
@@ -166,27 +166,6 @@ 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',
-21
View File
@@ -271,27 +271,6 @@ 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(() => {
+3 -15
View File
@@ -1,6 +1,6 @@
import { isString, forOwn } from '../../src/util/underscore'
import * as fs from '../../src/fs/fs-impl'
import { resolve, sep } from 'path'
import { resolve } from 'path'
interface FileDescriptor {
mode: string;
@@ -8,7 +8,7 @@ interface FileDescriptor {
}
let files: { [path: string]: FileDescriptor } = {}
const { readFile, exists, readFileSync, existsSync, contains, containsSync } = fs
const { readFile, exists, readFileSync, existsSync } = fs
export function mock (options: { [path: string]: (string | FileDescriptor) }) {
forOwn(options, (val, key) => {
@@ -30,16 +30,6 @@ 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)
}
}
@@ -48,7 +38,5 @@ 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).contains = contains;
(fs as any).containsSync = containsSync
(fs as any).exists = exists
}
+2 -1
View File
@@ -10,7 +10,8 @@
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"downlevelIteration": true,
"strict": true
"strict": true,
"suppressImplicitAnyIndexErrors": true
},
"all": true
}