Compare commits

..
Author SHA1 Message Date
Yang Jun 8a7791be9e docs: add lambdatest to sponsors 2025-10-26 14:53:32 +08:00
63 changed files with 2244 additions and 4770 deletions
-54
View File
@@ -757,60 +757,6 @@
"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"
]
}
],
"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.
-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
-49
View File
@@ -1,52 +1,3 @@
## [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)
+2 -10
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.
@@ -81,7 +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://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>
@@ -212,14 +212,6 @@ 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>
</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'
-27
View File
@@ -1,27 +0,0 @@
---
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! @#$%
```
-27
View File
@@ -1,27 +0,0 @@
---
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
```
-1
View File
@@ -15,6 +15,5 @@ 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
[shopify/liquid]: https://github.com/Shopify/liquid
-2
View File
@@ -24,7 +24,6 @@ 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.
@@ -48,7 +47,6 @@ 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
+4 -51
View File
@@ -5,64 +5,17 @@ title: Operators
LiquidJS operators are very simple and different. There're 2 types of operators supported:
* Comparison operators: `==`, `!=`, `>`, `<`, `>=`, `<=`
* Logic operators: `not`, `or`, `and`, `contains`
* Logic operators: `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, 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.
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.
## Associativity
Logic operators are evaluated from right to left, see [shopify docs][operator-order].
[operator-order]: https://shopify.dev/docs/api/liquid/basics#order-of-operations
[operator-order]: https://help.shopify.com/en/themes/liquid/basics/operators#order-of-operations
+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
@@ -24,7 +24,6 @@ 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 的插入顺序解释也不同)。
@@ -47,7 +46,6 @@ 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#插件列表
+3 -50
View File
@@ -5,61 +5,14 @@ title: 运算符
LiquidJS 运算符非常简单也很特别,只支持两类运算符:
* 比较运算符:`==`, `!=`, `>`, `<`, `>=`, `<=`
* 逻辑运算符:`not`, `or`, `and`, `contains`
* 逻辑运算符:`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. 比较运算符`contains`。所有比较运算符和 `contains` 具有同样的(最高)优先级
2. `not` 运算符。它的优先级略高于 `or``and`
3. `or``and` 运算符。这些逻辑运算符具有同样的(最低)优先级。
1. 比较运算符。所有比较运算符具有同样的优先级,且高于逻辑运算符
2. 逻辑运算符。所有逻辑运算符具有同样的有衔接
## 结合性
+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.4",
"version": "10.23.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"
-8
View File
@@ -45,11 +45,6 @@ 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 browserStream = {
include: './src/emitters/index.ts',
delimiters: ['', ''],
@@ -99,7 +94,6 @@ const browserEsm = {
plugins: [
versionInjection,
replace(browserFS),
replace(browserBase64),
replace(browserStream),
typescript(tsconfig('es6'))
],
@@ -118,7 +112,6 @@ const browserUmd = {
plugins: [
versionInjection,
replace(browserFS),
replace(browserBase64),
replace(browserStream),
typescript(tsconfig('es5'))
],
@@ -137,7 +130,6 @@ const browserMin = {
plugins: [
versionInjection,
replace(browserFS),
replace(browserBase64),
replace(browserStream),
typescript(tsconfig('es5')),
uglify()
-101
View File
@@ -1,101 +0,0 @@
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)
})
})
})
-10
View File
@@ -1,10 +0,0 @@
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))
)
}
+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
-7
View File
@@ -1,7 +0,0 @@
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')
}
-21
View File
@@ -1,21 +0,0 @@
/**
* 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): string {
const str = stringify(value)
this.context.memoryLimit.use(str.length)
return base64Encode(str)
}
export function base64_decode (this: FilterImpl, value: string): string {
const str = stringify(value)
this.context.memoryLimit.use(str.length)
return base64Decode(str)
}
-2
View File
@@ -4,7 +4,6 @@ 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 misc from './misc'
import { FilterImplOptions } from '../template'
@@ -15,6 +14,5 @@ export const filters: Record<string, FilterImplOptions> = {
...arrayFilters,
...dateFilters,
...stringFilters,
...base64Filters,
...misc
}
+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 -23
View File
@@ -1,34 +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 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 -29
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,58 +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]
const enforceRoot = type !== LookupType.Root
for (const filepath of this.candidates(file, dirs, currentFile)) {
if (enforceRoot) {
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 -6
View File
@@ -2,20 +2,16 @@
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 } from './liquid-options'
export type { LiquidOptions, RenderOptions, RenderFileOptions } from './liquid-options'
export { FS, LookupType } from './fs'
export { defaultOptions, LiquidOptions } from './liquid-options'
export { FS } from './fs'
export { Liquid } from './liquid'
+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 -1
View File
@@ -422,7 +422,7 @@ export class Tokenizer {
* readFileNameTemplate (options: NormalizedFullOptions): IterableIterator<TopLevelToken> {
const { outputDelimiterLeft } = options
const htmlStopStrings = [',', ' ', '\r', '\n', '\t', outputDelimiterLeft]
const htmlStopStrings = [',', ' ', outputDelimiterLeft]
const htmlStopStringSet = new Set(htmlStopStrings)
// break on ',' and ' ', outputDelimiterLeft only stops HTML token
while (this.p < this.N && !htmlStopStringSet.has(this.peek())) {
-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
-25
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,26 +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/)
})
})
})
-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 () {
-78
View File
@@ -1,78 +0,0 @@
import { test } 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 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)
})
})
})
-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" }}',
-8
View File
@@ -265,14 +265,6 @@ 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" %}',
+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
}