mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-13 03:10:40 -07:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a7791be9e | ||
|
|
2858271c8f | ||
|
|
d22945ed5f | ||
|
|
4f7d2fd84a | ||
|
|
597ce7305f | ||
|
|
d7fa8ba5f1 | ||
|
|
1b356d350d | ||
|
|
e55128850e | ||
|
|
e8e502c585 | ||
|
|
b8bc4db46c | ||
|
|
68d500c18a | ||
|
|
de12359bfd | ||
|
|
025c40f0f2 | ||
|
|
2f414f8e40 | ||
|
|
40c52124d7 | ||
|
|
ae0c07e60b | ||
|
|
3b9202465e | ||
|
|
fc42ad7548 | ||
|
|
050a7fc68e | ||
|
|
0fdc5c79da | ||
|
|
90d2ecb107 | ||
|
|
0deb93eeae | ||
|
|
b0facc71f7 | ||
|
|
5cb843f162 | ||
|
|
38a0f510b0 | ||
|
|
1a893f8023 | ||
|
|
bf3bd54051 | ||
|
|
a490a70da1 | ||
|
|
a5070af3e4 | ||
|
|
b070594fc7 | ||
|
|
25ef104446 | ||
|
|
59cf3c08db | ||
|
|
5f1a4cfdc9 | ||
|
|
8c32ab4f42 | ||
|
|
94a6715667 | ||
|
|
3492ff63f4 | ||
|
|
35a84421a6 | ||
|
|
bb08cfab1f | ||
|
|
e3ef574674 | ||
|
|
2af297f81a | ||
|
|
d61855bf72 | ||
|
|
734eb52b98 | ||
|
|
2980260fc1 | ||
|
|
062b351598 | ||
|
|
9107eb1b93 | ||
|
|
11f013bf24 | ||
|
|
ad039abea0 | ||
|
|
0b195b21eb |
+47
-1
@@ -483,7 +483,8 @@
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1557563?v=4",
|
||||
"profile": "http://santialbo.com",
|
||||
"contributions": [
|
||||
"doc"
|
||||
"doc",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -711,6 +712,51 @@
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "timvandam",
|
||||
"name": "Tim van Dam",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/35376389?v=4",
|
||||
"profile": "https://tovd.dev",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "edh649",
|
||||
"name": "Ed Hanton",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/527604?v=4",
|
||||
"profile": "https://github.com/edh649",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "gurdiga",
|
||||
"name": "Vlad GURDIGA",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/53922?v=4",
|
||||
"profile": "https://gurdiga.com",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "StreakingMan",
|
||||
"name": "裸奔狂甩丁丁",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/30397306?v=4",
|
||||
"profile": "https://www.streakingman.com",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "skynetigor",
|
||||
"name": "Ihor Panasiuk",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/20903171?v=4",
|
||||
"profile": "https://github.com/skynetigor",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
@@ -28,13 +28,13 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Archive artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-${{ inputs.os }}
|
||||
path: dist
|
||||
- name: Archive npm failure logs
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: npm-logs
|
||||
name: npm-logs-${{ inputs.os }}
|
||||
path: ~/.npm/_logs
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Archive npm failure logs
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: npm-logs
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
branch: gh-pages
|
||||
folder: docs/public
|
||||
- name: Archive npm failure logs
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: npm-logs
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
- name: Archive npm failure logs
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: npm-logs
|
||||
|
||||
@@ -19,14 +19,14 @@ jobs:
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-${{ inputs.os }}
|
||||
path: dist
|
||||
- name: Check Performance
|
||||
run: npm run perf:diff
|
||||
- name: Archive npm failure logs
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: npm-logs
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '22'
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
- name: Release
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
npx semantic-release --dry-run
|
||||
fi
|
||||
- name: Archive npm failure logs
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: npm-logs
|
||||
|
||||
+12
-12
@@ -14,13 +14,13 @@ jobs:
|
||||
node-versoin: 22
|
||||
- os: ubuntu-latest
|
||||
timezone: Etc/GMT
|
||||
node-version: 20
|
||||
- os: ubuntu-latest
|
||||
timezone: Asia/Shanghai
|
||||
node-version: 18
|
||||
- os: ubuntu-latest
|
||||
timezone: Asia/Shanghai
|
||||
node-version: 16
|
||||
- os: ubuntu-latest
|
||||
timezone: Asia/Shanghai
|
||||
node-version: 15
|
||||
- os: ubuntu-latest
|
||||
timezone: Asia/Shanghai
|
||||
node-version: 14
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -34,17 +34,17 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-${{ matrix.os }}
|
||||
path: dist
|
||||
- name: Run Test
|
||||
run: TZ=${{ matrix.timezone }} npm test
|
||||
- name: Archive npm failure logs
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: npm-logs
|
||||
name: test-npm-logs-${{ matrix.os }}-${{ matrix.node-version }}
|
||||
path: ~/.npm/_logs
|
||||
demo:
|
||||
name: Demo Check
|
||||
@@ -59,15 +59,15 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-ubuntu-latest
|
||||
path: dist
|
||||
- name: Run Demo Test
|
||||
run: npm run test:demo
|
||||
- name: Archive npm failure logs
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: npm-logs
|
||||
name: demo-npm-logs
|
||||
path: ~/.npm/_logs
|
||||
|
||||
@@ -1,3 +1,85 @@
|
||||
# [10.23.0](https://github.com/harttle/liquidjs/compare/v10.22.0...v10.23.0) (2025-10-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Export specific tokens as types ([#824](https://github.com/harttle/liquidjs/issues/824)) ([4f7d2fd](https://github.com/harttle/liquidjs/commit/4f7d2fd84a8884e1009b13346d331a99b9721149))
|
||||
|
||||
# [10.22.0](https://github.com/harttle/liquidjs/compare/v10.21.1...v10.22.0) (2025-10-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* math filters coerce invalid string to 0, [#813](https://github.com/harttle/liquidjs/issues/813) ([#819](https://github.com/harttle/liquidjs/issues/819)) ([e8e502c](https://github.com/harttle/liquidjs/commit/e8e502c5854c9649bf7611a671a068dc260011d1))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow context access in liquidMethodMissing, [#808](https://github.com/harttle/liquidjs/issues/808) ([#820](https://github.com/harttle/liquidjs/issues/820)) ([e551288](https://github.com/harttle/liquidjs/commit/e55128850e507687f9d85a012fc3a72ac2550f3b))
|
||||
|
||||
## [10.21.1](https://github.com/harttle/liquidjs/compare/v10.21.0...v10.21.1) (2025-05-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* block.super with strictVariables, [#806](https://github.com/harttle/liquidjs/issues/806) ([#807](https://github.com/harttle/liquidjs/issues/807)) ([025c40f](https://github.com/harttle/liquidjs/commit/025c40f0f2f13efa62193c61d2fa56943917ac3c))
|
||||
|
||||
# [10.21.0](https://github.com/harttle/liquidjs/compare/v10.20.3...v10.21.0) (2025-02-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add find_index, has, and reject filters ([#799](https://github.com/harttle/liquidjs/issues/799)) ([0deb93e](https://github.com/harttle/liquidjs/commit/0deb93eeae4f530901e9a7d099bcc47207ad7385))
|
||||
|
||||
## [10.20.3](https://github.com/harttle/liquidjs/compare/v10.20.2...v10.20.3) (2025-02-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* empty tagToken.args since 10.20.0, fixes [#796](https://github.com/harttle/liquidjs/issues/796) ([38a0f51](https://github.com/harttle/liquidjs/commit/38a0f510b0a14baf35a368e9f07b536253394d06))
|
||||
|
||||
## [10.20.2](https://github.com/harttle/liquidjs/compare/v10.20.1...v10.20.2) (2025-01-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* consistent range syntax parsing, [#791](https://github.com/harttle/liquidjs/issues/791) ([a490a70](https://github.com/harttle/liquidjs/commit/a490a70da1ca2b479065c6618207bf4789db6b4f))
|
||||
* context for group_by_exp/where_exp/find_exp, [#790](https://github.com/harttle/liquidjs/issues/790) ([a5070af](https://github.com/harttle/liquidjs/commit/a5070af3e4b4d1ae3b6398c6638b130e50e1cf6e))
|
||||
|
||||
## [10.20.1](https://github.com/harttle/liquidjs/compare/v10.20.0...v10.20.1) (2025-01-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* break/continue stops whole template, [#783](https://github.com/harttle/liquidjs/issues/783) ([5f1a4cf](https://github.com/harttle/liquidjs/commit/5f1a4cfdc9d6bde31ce86ddc88b8f4bdf52f7893))
|
||||
* enumerate plain objects in where/where_exp, [#785](https://github.com/harttle/liquidjs/issues/785) ([#788](https://github.com/harttle/liquidjs/issues/788)) ([25ef104](https://github.com/harttle/liquidjs/commit/25ef104446731f4b6cb3a2e78f4d3b99efb635f4))
|
||||
* preserveTimezones support for RFC2822 date, [#784](https://github.com/harttle/liquidjs/issues/784) ([59cf3c0](https://github.com/harttle/liquidjs/commit/59cf3c08dbc5f2e5b109ffcb5375ae738b5ac386))
|
||||
|
||||
# [10.20.0](https://github.com/harttle/liquidjs/compare/v10.19.1...v10.20.0) (2024-12-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* `size`, `first`, `last` support arraylike objects, [#781](https://github.com/harttle/liquidjs/issues/781) ([35a8442](https://github.com/harttle/liquidjs/commit/35a84421a622b3a6657946b9395839da2b8e154a))
|
||||
* static variable analysis ([#770](https://github.com/harttle/liquidjs/issues/770)) ([3492ff6](https://github.com/harttle/liquidjs/commit/3492ff63f40abb8ff8adb8b6b0ce29408f99e19b))
|
||||
|
||||
## [10.19.1](https://github.com/harttle/liquidjs/compare/v10.19.0...v10.19.1) (2024-12-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add sideEffects=false to package.json ([734eb52](https://github.com/harttle/liquidjs/commit/734eb52b987d46d33cf8f03281a3773a0f1f0e4a))
|
||||
* inconsistent continue behaviour, fixes [#779](https://github.com/harttle/liquidjs/issues/779) ([e3ef574](https://github.com/harttle/liquidjs/commit/e3ef574674c5a21a37b3ffc929f514c8a3d0b866))
|
||||
* memoryLimit doesn't work in for tag, [#776](https://github.com/harttle/liquidjs/issues/776) ([2af297f](https://github.com/harttle/liquidjs/commit/2af297f81ac465feb3277ba7b92f7236409370b0))
|
||||
|
||||
# [10.19.0](https://github.com/harttle/liquidjs/compare/v10.18.0...v10.19.0) (2024-11-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow drops in property access ([#769](https://github.com/harttle/liquidjs/issues/769)) ([11f013b](https://github.com/harttle/liquidjs/commit/11f013bf249f1366306c1f0d728d45a95eb2df1a))
|
||||
* support Jekyll style `where`, [#768](https://github.com/harttle/liquidjs/issues/768) ([9107eb1](https://github.com/harttle/liquidjs/commit/9107eb1b93f5c11dca0ee0f0d2c1c8243849c3ab))
|
||||
|
||||
# [10.18.0](https://github.com/harttle/liquidjs/compare/v10.17.0...v10.18.0) (2024-10-16)
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ For more details, refer to the [Setup Guide][setup].
|
||||
## Who's Using LiquidJS?
|
||||
|
||||
- [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.
|
||||
- [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.
|
||||
@@ -71,31 +72,30 @@ Feel free to create a PR or contact me to add your use case into this list!
|
||||
If you personally love LiquidJS or it's benefiting your business, please consider financially support us via [GitHub Sponsors](https://github.com/sponsors/harttle). Special thanks to our sponsors!
|
||||
|
||||
<!-- FINANCIAL-CONTRIBUTORS-BEGIN -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.opensense.com/"><img src="https://images.opencollective.com/opensense-inc/bf840ae/logo/256.png?height=100" width="100px;" alt="Opensense Inc."/><br /><sub><b>Opensense</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.11ty.dev/"><img src="https://avatars.githubusercontent.com/u/35147177?v=4?s=100" width="100px;" alt="Eleventy"/><br /><sub><b>Eleventy</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://about.me/peterdehaan"><img src="https://avatars2.githubusercontent.com/u/557895?v=4?s=100" width="100px;" alt="Peter deHaan"/><br /><sub><b>Peter deHaan</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://opencollective.com/touchless"><img src="https://images.opencollective.com/touchless/273bc74/logo/256.png?height=100" width="100px;" alt="Touchless"/><br /><sub><b>Touchless</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.dropkiq.com/"><img src="https://images.opencollective.com/1bertlol/43a8ea8/logo/256.png?height=100" width="100px;" alt="Adam Darrah"/><br /><sub><b>Dropkiq</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://dailycontributors.com/"><img src="https://images.opencollective.com/dailycontributors/3c2e057/logo/256.png?height=100&width=100" width="100px;" alt="Dailycontributors"/><br /><sub><b>Dailycontributors</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/coni2k"><img src="https://avatars0.githubusercontent.com/u/1284601?v=4?s=100" width="100px;" alt="coni2k"/><br /><sub><b>Serkan Holat</b></sub></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/amit777"><img src="https://avatars0.githubusercontent.com/u/2703309?v=4?s=100" width="100px;" alt="amit777"/><br /><sub><b>amit777</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://opencollective.com/khaled-salem"><img src="https://images.opencollective.com/khaled-salem/avatar/256.png?height=256" width="100px;" alt="Khaled Salem"/><br /><sub><b>Khaled Salem</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://sentry.io/"><img src="https://avatars.githubusercontent.com/u/1396951?v=4?s=100" width="100px;" alt="Sentry"/><br /><sub><b>Sentry</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.checkoutblocks.com/"><img src="https://avatars.githubusercontent.com/u/114603307?v=4?s=100" width="100px;" alt="Checkout Blocks"/><br /><sub><b>Checkout Blocks</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://customer.io/"><img src="https://avatars.githubusercontent.com/u/1152079?v=4?s=100" width="100px;" alt="Customer IO"/><br /><sub><b>Customer IO</b></sub></a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/15fathoms"><img src="https://avatars.githubusercontent.com/u/79156039?v=4?s=100" width="100px;" alt="Emmanuel Cartelli"/><br /><sub><b>Emmanuel Cartelli</b></sub></a><br /></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/microsoft"><img src="https://avatars.githubusercontent.com/u/6154722?v=4?s=100" width="100px;" alt="Microsoft"/><br /><sub><b>Microsoft</b></sub></a><br /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://www.pakstyle.pk/"><img src="https://images.opencollective.com/pakstyle/2b81605/logo/256.png?height=100" width="100px;" alt="PakStyle.pk"/><br /><sub><b>PakStyle.pk</b></sub></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p align="center" style="line-height: 2.5;">
|
||||
<a href="https://www.11ty.dev/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/35147177?v=4&s=100" height="80" style="vertical-align: middle;" alt="Eleventy" title="Eleventy"/></a>
|
||||
<a href="https://www.opensense.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/opensense-inc/bf840ae/logo/256.png?height=100" height="80" style="vertical-align: middle;" alt="Opensense Inc." title="Opensense"/></a>
|
||||
<a href="https://github.com/microsoft" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/6154722?v=4&s=100" height="80" style="vertical-align: middle;" alt="Microsoft" title="Microsoft"/></a>
|
||||
<a href="https://sentry.io/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/1396951?v=4&s=100" height="80" style="vertical-align: middle;" alt="Sentry" title="Sentry"/></a>
|
||||
<a href="https://www.checkoutblocks.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/114603307?v=4&s=100" height="80" style="vertical-align: middle;" alt="Checkout Blocks" title="Checkout Blocks"/></a>
|
||||
<a href="https://customer.io/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/1152079?v=4&s=100" height="80" style="vertical-align: middle;" alt="Customer IO" title="Customer IO"/></a>
|
||||
<a href="https://syntax.fm/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/130389858?v=4&s=100" height="80" style="vertical-align: middle;" alt="Syntax Podcast" title="Syntax Podcast"/></a>
|
||||
<br/>
|
||||
<a href="https://www.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>
|
||||
<a href="https://www.escorta.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/escortacom/avatar/256.png?height=100" height="45" style="vertical-align: middle;" alt="EscortA.com" title="EscortA.com"/></a>
|
||||
<br/>
|
||||
<a href="https://opencollective.com/touchless" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/touchless/273bc74/logo/256.png?height=100" height="80" style="vertical-align: middle;" alt="Touchless" title="Touchless"/></a>
|
||||
<a href="https://www.dropkiq.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/1bertlol/43a8ea8/logo/256.png?height=100" height="80" style="vertical-align: middle;" alt="Dropkiq" title="Dropkiq"/></a>
|
||||
<a href="https://about.me/peterdehaan" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars2.githubusercontent.com/u/557895?v=4&s=100" height="80" style="vertical-align: middle;" alt="Peter deHaan" title="Peter deHaan"/></a>
|
||||
<a href="https://github.com/coni2k" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars0.githubusercontent.com/u/1284601?v=4&s=100" height="80" style="vertical-align: middle;" alt="Serkan Holat" title="Serkan Holat"/></a>
|
||||
<a href="https://github.com/amit777" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars0.githubusercontent.com/u/2703309?v=4&s=100" height="80" style="vertical-align: middle;" alt="amit777" title="amit777"/></a>
|
||||
<a href="https://opencollective.com/khaled-salem" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/khaled-salem/avatar/256.png?height=256" height="80" style="vertical-align: middle;" alt="Khaled Salem" title="Khaled Salem"/></a>
|
||||
<a href="https://github.com/15fathoms" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/79156039?v=4&s=100" height="80" style="vertical-align: middle;" alt="Emmanuel Cartelli" title="Emmanuel Cartelli"/></a>
|
||||
<a href="https://opencollective.com/cartelli-emmanuel" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://images.opencollective.com/cartelli-emmanuel/avatar/256.png?height=100" height="80" style="vertical-align: middle;" alt="Cartelli Emmanuel" title="Cartelli Emmanuel"/></a>
|
||||
</p>
|
||||
<!-- FINANCIAL-CONTRIBUTORS-END -->
|
||||
|
||||
## Contributors ✨
|
||||
@@ -173,7 +173,7 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ameyaapte1"><img src="https://avatars.githubusercontent.com/u/16054747?v=4?s=100" width="100px;" alt="Ameya Apte"/><br /><sub><b>Ameya Apte</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=ameyaapte1" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tbdrz"><img src="https://avatars.githubusercontent.com/u/50599116?v=4?s=100" width="100px;" alt="tbdrz"/><br /><sub><b>tbdrz</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=tbdrz" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://santialbo.com"><img src="https://avatars.githubusercontent.com/u/1557563?v=4?s=100" width="100px;" alt="Santi Albo"/><br /><sub><b>Santi Albo</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=santialbo" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://santialbo.com"><img src="https://avatars.githubusercontent.com/u/1557563?v=4?s=100" width="100px;" alt="Santi Albo"/><br /><sub><b>Santi Albo</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=santialbo" title="Documentation">📖</a> <a href="https://github.com/harttle/liquidjs/commits?author=santialbo" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/YahangWu"><img src="https://avatars.githubusercontent.com/u/12295975?v=4?s=100" width="100px;" alt="Yahang Wu"/><br /><sub><b>Yahang Wu</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=YahangWu" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hongl-1"><img src="https://avatars.githubusercontent.com/u/101576612?v=4?s=100" width="100px;" alt="hongl"/><br /><sub><b>hongl</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=hongl-1" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zxx-457"><img src="https://avatars.githubusercontent.com/u/114141362?v=4?s=100" width="100px;" alt="zxx-457"/><br /><sub><b>zxx-457</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=zxx-457" title="Documentation">📖</a></td>
|
||||
@@ -206,6 +206,13 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://vrugtehagel.nl"><img src="https://avatars.githubusercontent.com/u/41021050?v=4?s=100" width="100px;" alt="Koen"/><br /><sub><b>Koen</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=vrugtehagel" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://neamar.fr"><img src="https://avatars.githubusercontent.com/u/536844?v=4?s=100" width="100px;" alt="Matthieu Bacconnier"/><br /><sub><b>Matthieu Bacconnier</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=Neamar" title="Documentation">📖</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://tovd.dev"><img src="https://avatars.githubusercontent.com/u/35376389?v=4?s=100" width="100px;" alt="Tim van Dam"/><br /><sub><b>Tim van Dam</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=timvandam" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/edh649"><img src="https://avatars.githubusercontent.com/u/527604?v=4?s=100" width="100px;" alt="Ed Hanton"/><br /><sub><b>Ed Hanton</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=edh649" title="Documentation">📖</a></td>
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ awk '/ALL-CONTRIBUTORS-LIST:START/{flag=1;next}/ALL-CONTRIBUTORS-LIST:END/{flag=
|
||||
# create docs/themes/navy/layout/partial/financial-contributors.swig
|
||||
awk '/FINANCIAL-CONTRIBUTORS-BEGIN/{flag=1;next}/FINANCIAL-CONTRIBUTORS-END/{flag=0}flag' README.md | \
|
||||
sed 's/<br \/>.*<\/td>/<\/a><\/td>/g' | \
|
||||
sed 's/width="[^"]*"//g' | \
|
||||
tr -d '\n' | \
|
||||
sed 's/<\/tr>\s*<tr>//g' \
|
||||
> docs/themes/navy/layout/partial/financial-contributors.swig
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ prismjs:
|
||||
tab_replace: ""
|
||||
|
||||
algolia:
|
||||
applicationID: 0X19J927JZ
|
||||
apiKey: 533161d821384919672e4ce8a39451b3
|
||||
applicationID: QJ35YOZTU4
|
||||
apiKey: 8c6cbb824b4c5023f0bb2ef29e228bef
|
||||
indexName: liquidjs
|
||||
twitter: harttleharttle
|
||||
github: harttle/liquidjs
|
||||
|
||||
+1
-1
@@ -34,4 +34,4 @@
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ tutorials:
|
||||
operators: operators.html
|
||||
truth: truthy-and-falsy.html
|
||||
dos: dos.html
|
||||
static_analysis: static-analysis.html
|
||||
miscellaneous:
|
||||
migration9: migrate-to-9.html
|
||||
changelog: changelog.html
|
||||
@@ -50,10 +51,14 @@ filters:
|
||||
escape_once: escape_once.html
|
||||
find: find.html
|
||||
find_exp: find_exp.html
|
||||
find_index: find_index.html
|
||||
find_index_exp: find_index_exp.html
|
||||
first: first.html
|
||||
floor: floor.html
|
||||
group_by: group_by.html
|
||||
group_by_exp: group_by_exp.html
|
||||
has: has.html
|
||||
has_exp: has_exp.html
|
||||
inspect: inspect.html
|
||||
join: join.html
|
||||
json: json.html
|
||||
@@ -71,6 +76,8 @@ filters:
|
||||
push: push.html
|
||||
prepend: prepend.html
|
||||
raw: raw.html
|
||||
reject: reject.html
|
||||
reject_exp: reject_exp.html
|
||||
remove: remove.html
|
||||
remove_first: remove_first.html
|
||||
remove_last: remove_last.html
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: find_index
|
||||
---
|
||||
|
||||
{% since %}v10.21.0{% endsince %}
|
||||
|
||||
Return the 0-based index of the first object in an array for which the queried attribute has the given value or return `nil` if no item in the array satisfies the given criteria. For the following `members` array:
|
||||
|
||||
```javascript
|
||||
const members = [
|
||||
{ graduation_year: 2013, name: 'Jay' },
|
||||
{ graduation_year: 2014, name: 'John' },
|
||||
{ graduation_year: 2014, name: 'Jack' }
|
||||
]
|
||||
```
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{{ members | find_index: "graduation_year", 2014 | json }}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
1
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: find_index_exp
|
||||
---
|
||||
|
||||
{% since %}v10.21.0{% endsince %}
|
||||
|
||||
Return the 0-based index of the first object in an array for which the given expression evaluates to true or return `nil` if no item in the array satisfies the evaluated expression.
|
||||
|
||||
```javascript
|
||||
const members = [
|
||||
{ graduation_year: 2013, name: 'Jay' },
|
||||
{ graduation_year: 2014, name: 'John' },
|
||||
{ graduation_year: 2014, name: 'Jack' }
|
||||
]
|
||||
```
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{{ members | find_index_exp: "item", "item.graduation_year == 2014" | json }}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
1
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: has
|
||||
---
|
||||
|
||||
{% since %}v10.21.0{% endsince %}
|
||||
|
||||
Return `true` if the array includes an item for which the queried attribute has the given value or return `false` if no item in the array satisfies the given criteria. For the following `members` array:
|
||||
|
||||
```javascript
|
||||
const members = [
|
||||
{ graduation_year: 2013, name: 'Jay' },
|
||||
{ graduation_year: 2014, name: 'John' },
|
||||
{ graduation_year: 2014, name: 'Jack' }
|
||||
]
|
||||
```
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{{ members | has: "graduation_year", 2014 | json }}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
true
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: has_exp
|
||||
---
|
||||
|
||||
{% since %}v10.21.0{% endsince %}
|
||||
|
||||
Return `true` if an item exists in an array for which the given expression evaluates to true or return `false` if no item in the array satisfies the evaluated expression.
|
||||
|
||||
```javascript
|
||||
const members = [
|
||||
{ graduation_year: 2013, name: 'Jay' },
|
||||
{ graduation_year: 2014, name: 'John' },
|
||||
{ graduation_year: 2014, name: 'Jack' }
|
||||
]
|
||||
```
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{{ members | has_exp: "item", "item.graduation_year == 2014" | json }}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
true
|
||||
```
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: reject
|
||||
---
|
||||
|
||||
{% since %}v10.21.0{% endsince %}
|
||||
|
||||
Creates an array excluding the objects with a given property value, or excluding [truthy][truthy] values by default when a property is not given.
|
||||
|
||||
In this example, assume you have a list of products and you want to filter out kitchen products. Using `reject`, you can create an array excluding only the products that have a `"type"` of `"kitchen"`.
|
||||
|
||||
Input
|
||||
```liquid
|
||||
All products:
|
||||
{% for product in products %}
|
||||
- {{ product.title }}
|
||||
{% endfor %}
|
||||
|
||||
{% assign non_kitchen_products = products | reject: "type", "kitchen" %}
|
||||
|
||||
Kitchen products:
|
||||
{% for product in non_kitchen_products %}
|
||||
- {{ product.title }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
All products:
|
||||
- Vacuum
|
||||
- Spatula
|
||||
- Television
|
||||
- Garlic press
|
||||
|
||||
Kitchen products:
|
||||
- Vacuum
|
||||
- Television
|
||||
```
|
||||
|
||||
Say instead you have a list of products and you want to exclude taxable products. You can `reject` with a property name but no target value to reject all products with a [truthy][truthy] `"taxable"` value.
|
||||
|
||||
Input
|
||||
```liquid
|
||||
All products:
|
||||
{% for product in products %}
|
||||
- {{ product.title }}
|
||||
{% endfor %}
|
||||
|
||||
{% assign not_taxed_products = products | reject: "taxable" %}
|
||||
|
||||
Available products:
|
||||
{% for product in not_taxed_products %}
|
||||
- {{ product.title }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
All products:
|
||||
- Vacuum
|
||||
- Spatula
|
||||
- Television
|
||||
- Garlic press
|
||||
|
||||
Available products:
|
||||
- Spatula
|
||||
- Television
|
||||
```
|
||||
|
||||
Additionally, `property` can be any valid Liquid variable expression as used in output syntax, except that the scope of this expression is within each item. For the following `products` array:
|
||||
|
||||
```javascript
|
||||
const products = [
|
||||
{ meta: { details: { class: 'A' } }, order: 1 },
|
||||
{ meta: { details: { class: 'B' } }, order: 2 },
|
||||
{ meta: { details: { class: 'B' } }, order: 3 }
|
||||
]
|
||||
```
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{% assign selected = products | reject: 'meta.details["class"]', "B" %}
|
||||
{% for item in selected -%}
|
||||
- {{ item.order }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
- 1
|
||||
```
|
||||
|
||||
## Jekyll style
|
||||
|
||||
{% since %}v10.21.0{% endsince %}
|
||||
|
||||
For Liquid users migrating from Jekyll, there's a `jekyllWhere` option to mimic the behavior of Jekyll's `where` filter. This option is set to `false` by default. When enabled, if `property` is an array, the target value is matched using `Array.includes` instead of `==`, which is particularly useful for excluding tags.
|
||||
|
||||
```javascript
|
||||
const pages = [
|
||||
{ tags: ["cat", "food"], title: 'Cat Food' },
|
||||
{ tags: ["dog", "food"], title: 'Dog Food' },
|
||||
]
|
||||
```
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{% assign selected = pages | reject: 'tags', "cat" %}
|
||||
{% for item in selected -%}
|
||||
- {{ item.title }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
Dog Food
|
||||
```
|
||||
|
||||
[truthy]: ../tutorials/truthy-and-falsy.html
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: reject_exp
|
||||
---
|
||||
|
||||
{% since %}v10.21.0{% endsince %}
|
||||
|
||||
Select all the objects in an array where the expression is false. In this example, assume you have a list of products and you want to hide your kitchen products. Using `reject_exp`, you can create an array that omits only the products that have a `"type"` of `"kitchen"`.
|
||||
|
||||
Input
|
||||
```liquid
|
||||
All products:
|
||||
{% for product in products %}
|
||||
- {{ product.title }}
|
||||
{% endfor %}
|
||||
|
||||
{% assign non_kitchen_products = products | reject_exp: "item", "item.type == 'kitchen'" %}
|
||||
|
||||
Kitchen products:
|
||||
{% for product in non_kitchen_products %}
|
||||
- {{ product.title }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
All products:
|
||||
- Vacuum
|
||||
- Spatula
|
||||
- Television
|
||||
- Garlic press
|
||||
|
||||
Kitchen products:
|
||||
- Vacuum
|
||||
- Television
|
||||
```
|
||||
|
||||
[truthy]: ../tutorials/truthy-and-falsy.html
|
||||
@@ -37,6 +37,7 @@ Kitchen products:
|
||||
```
|
||||
|
||||
Say instead you have a list of products and you only want to show those that are available to buy. You can `where` with a property name but no target value to include all products with a [truthy][truthy] `"available"` value.
|
||||
As a special case, the same will happen if the target value is given but evaluates to `undefined`.
|
||||
|
||||
Input
|
||||
```liquid
|
||||
@@ -70,7 +71,6 @@ The `where` filter can also be used to find a single object in an array when com
|
||||
Input
|
||||
```liquid
|
||||
{% assign new_shirt = products | where: "type", "shirt" | first %}
|
||||
|
||||
Featured product: {{ new_shirt.title }}
|
||||
```
|
||||
|
||||
@@ -103,4 +103,32 @@ Output
|
||||
- 3
|
||||
```
|
||||
|
||||
## Jekyll style
|
||||
|
||||
{% since %}v10.21.0{% endsince %}
|
||||
|
||||
For Liquid users migrating from Jekyll, there's a `jekyllWhere` option to mimic the behavior of Jekyll's `where` filter. This option is set to `false` by default. When enabled, if `property` is an array, the target value is matched using `Array.includes` instead of `==`, which is particularly useful for filtering tags. Additionally, a target value of `undefined` is treated normally, entries matched are exactly those which are themselves `undefined`.
|
||||
|
||||
This option affects other array selection filters as well, such as `reject` and `find`.
|
||||
|
||||
```javascript
|
||||
const pages = [
|
||||
{ tags: ["cat", "food"], title: 'Cat Food' },
|
||||
{ tags: ["dog", "food"], title: 'Dog Food' },
|
||||
]
|
||||
```
|
||||
|
||||
Input
|
||||
```liquid
|
||||
{% assign selected = pages | where: 'tags', "cat" %}
|
||||
{% for item in selected -%}
|
||||
- {{ item.title }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
Output
|
||||
```text
|
||||
Cat Food
|
||||
```
|
||||
|
||||
[truthy]: ../tutorials/truthy-and-falsy.html
|
||||
|
||||
@@ -10,6 +10,7 @@ const urlsToCache = [
|
||||
]
|
||||
const blackList = [
|
||||
/chrome-extension:/,
|
||||
/algolia.net/,
|
||||
/google-analytics.com.*collect/
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Echo
|
||||
|
||||
{% since %}v9.31.0{% endsince %}
|
||||
|
||||
Outputs an expression in the rendered HTML. This is identical to wrapping an expression in `{{` and `}}`, but works inside liquid tags and supports filters.
|
||||
Outputs an expression in the rendered HTML. This is identical to wrapping an expression in <code>{{</code> and <code>}}</code>, but works inside liquid tags and supports filters.
|
||||
|
||||
## echo
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ Getting started and building is described in [CONTRIBUTING.md](https://github.co
|
||||
|
||||
## Financial Support
|
||||
|
||||
LiquidJS is Open Source and Free. To help it live and thrive, especially when LiquidJS is benefiting your business, please consider contribute on [GitHub Sponsors](https://github.com/sponsors/harttle) or [Open Collective][oc]. If I'm missing anything, find me via Twitter (harttleharttle) or email (harttleharttle at gmail), to add you into [the contributors table](https://github.com/harttle/liquidjs#contributors-).
|
||||
LiquidJS is Open Source and Free. To help it live and thrive, especially when LiquidJS is benefiting your business, please consider contribute on [GitHub Sponsors](https://github.com/sponsors/harttle) or [Open Collective][oc].
|
||||
|
||||
I'll add all financial contributors into [README.md](https://github.com/harttle/liquidjs#financial-support) and it'll be also shown on https://liquidjs.com after next GitHub Actions build.
|
||||
|
||||
If I'm missing anything or you observed it not working, please don't hesitate to file an issue or find me via email (harttleharttle at gmail).
|
||||
|
||||
[oc]: https://opencollective.com/liquidjs/contribute/backer-10665/checkout
|
||||
[shopify/liquid]: https://shopify.github.io/liquid/
|
||||
|
||||
@@ -116,7 +116,7 @@ it defaults to false. For example, when set to true, a blank string would evalu
|
||||
|
||||
**preserveTimezones** is a boolean effects only literal timestamps. When set to `true`, all literal timestamps will remain the same when output. This is a parser option, so Date objects passed to LiquidJS as data will not be affected. Note that `preserveTimezones` has a higher priority than `timezoneOffset`.
|
||||
|
||||
**dateFormat** is used to specify a default format to output dates. `%A, %B %-e, %Y at %-l:%M %P %z` will be used if not specified. For example, set `dateFormat: %Y-%m-%dT%H:%M:%S:%LZ` to output all dates in [JavaScript Date.toJson()][https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toJSON] format.
|
||||
**dateFormat** is used to specify a default format to output dates. `%A, %B %-e, %Y at %-l:%M %P %z` will be used if not specified. For example, set `dateFormat: %Y-%m-%dT%H:%M:%S:%LZ` to output all dates in [JavaScript Date.toJson()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toJSON) format.
|
||||
|
||||
## Trimming
|
||||
|
||||
@@ -146,7 +146,7 @@ Nonexistent tags always throw errors during parsing and this behavior cannot be
|
||||
|
||||
## Parameter Order
|
||||
|
||||
Parameter orders are ignored by default, for ea `{% for i in (1..8) reversed limit:3 %}` will always perform `limit` before `reversed`, even if `reversed` occurs before `limit`. To make parameter order respected, set **orderedFilterParameters** to `true`. Its default value is `false`.
|
||||
Parameter orders are ignored by default, for example `{% for i in (1..8) reversed limit:3 %}` will always perform `limit` before `reversed`, even if `reversed` occurs before `limit`. To make parameter order respected, set **orderedFilterParameters** to `true`. Its default value is `false`.
|
||||
|
||||
[liquid]: /api/classes/Liquid.html
|
||||
[caching]: ./caching.html
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Value, TagToken, Context, Emitter, TopLevelToken } from 'liquidjs'
|
||||
|
||||
engine.registerTag('upper', {
|
||||
parse: function(tagToken: TagToken, remainTokens: TopLevelToken[]) {
|
||||
this.value = new Value(token.args, liquid)
|
||||
this.value = new Value(tagToken.args, engine)
|
||||
},
|
||||
render: function*(ctx: Context) {
|
||||
const str = yield this.value.value(ctx); // 'alice'
|
||||
@@ -80,4 +80,4 @@ function disabledFilter(name) {
|
||||
}
|
||||
}
|
||||
engine.registerFilter('plus', disabledFilter('plus'));
|
||||
```
|
||||
```
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
---
|
||||
title: Static Template Analysis
|
||||
---
|
||||
|
||||
{% since %}v10.20.0{% endsince %}
|
||||
|
||||
{% note warn Experimental %}
|
||||
Note that this is an experimental feature and future APIs are subject to change. And internal structures returned can be changed w/o a major version bump.
|
||||
{% endnote %}
|
||||
|
||||
{% note info Sync and Async %}
|
||||
There are synchronous and asynchronous versions of each of the methods demonstrated on this page. See the [Liquid API][liquid-api] for a complete reference.
|
||||
{% endnote %}
|
||||
|
||||
## Variables
|
||||
|
||||
Retrieve the names of variables used in a template with `Liquid.variables(template)`. It returns an array of strings, one string for each distinct variable, without its properties.
|
||||
|
||||
```javascript
|
||||
import { Liquid } from 'liquidjs'
|
||||
|
||||
const engine = new Liquid()
|
||||
|
||||
const template = engine.parse(`
|
||||
<p>
|
||||
{% assign title = user.title | capitalize %}
|
||||
{{ title }} {{ user.first_name | default: user.name }} {{ user.last_name }}
|
||||
{% if user.address %}
|
||||
{{ user.address.line1 }}
|
||||
{% else %}
|
||||
{{ user.email_addresses[0] }}
|
||||
{% for email in user.email_addresses %}
|
||||
- {{ email }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{{ a[b.c].d }}
|
||||
<p>
|
||||
`)
|
||||
|
||||
console.log(engine.variablesSync(template))
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
```javascript
|
||||
[ 'user', 'title', 'email', 'a', 'b' ]
|
||||
```
|
||||
|
||||
Notice that variables from tag and filter arguments are included, as well as nested variables like `b` in the example. Alternatively, use `Liquid.fullVariables(template)` to get a list of variables including their properties as strings.
|
||||
|
||||
```javascript
|
||||
// continued from above
|
||||
engine.fullVariables(template).then(console.log)
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
```javascript
|
||||
[
|
||||
'user.title',
|
||||
'user.first_name',
|
||||
'user.name',
|
||||
'user.last_name',
|
||||
'user.address',
|
||||
'user.address.line1',
|
||||
'user.email_addresses[0]',
|
||||
'user.email_addresses',
|
||||
'title',
|
||||
'email',
|
||||
'a[b.c].d',
|
||||
'b.c'
|
||||
]
|
||||
```
|
||||
|
||||
Or use `Liquid.variableSegments(template)` to get an array of strings and numbers that make up each variable's path.
|
||||
|
||||
```javascript
|
||||
// continued from above
|
||||
engine.variableSegments(template).then(console.log)
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
```javascript
|
||||
[
|
||||
[ 'user', 'title' ],
|
||||
[ 'user', 'first_name' ],
|
||||
[ 'user', 'name' ],
|
||||
[ 'user', 'last_name' ],
|
||||
[ 'user', 'address' ],
|
||||
[ 'user', 'address', 'line1' ],
|
||||
[ 'user', 'email_addresses', 0 ],
|
||||
[ 'user', 'email_addresses' ],
|
||||
[ 'title' ],
|
||||
[ 'email' ],
|
||||
[ 'a', [ 'b', 'c' ], 'd' ],
|
||||
[ 'b', 'c' ]
|
||||
]
|
||||
```
|
||||
|
||||
### Global Variables
|
||||
|
||||
Notice, in the examples above, that `title` and `email` are included in the results. Often you'll want to exclude names that are in scope from `{% assign %}` tags, and temporary variables like those introduced by a `{% for %}` tag.
|
||||
|
||||
To get names that are expected to be _global_, that is, provided by application developers rather than template authors, use the `globalVariables`, `globalFullVariables` or `globalVariableSegments` methods (or their synchronous equivalents) of a `Liquid` class instance.
|
||||
|
||||
```javascript
|
||||
// continued from above
|
||||
engine.globalVariableSegments(template).then(console.log)
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
```javascript
|
||||
[
|
||||
[ 'user', 'title' ],
|
||||
[ 'user', 'first_name' ],
|
||||
[ 'user', 'name' ],
|
||||
[ 'user', 'last_name' ],
|
||||
[ 'user', 'address' ],
|
||||
[ 'user', 'address', 'line1' ],
|
||||
[ 'user', 'email_addresses', 0 ],
|
||||
[ 'user', 'email_addresses' ],
|
||||
[ 'a', [ 'b', 'c' ], 'd' ],
|
||||
[ 'b', 'c' ]
|
||||
]
|
||||
```
|
||||
|
||||
### Partial Templates
|
||||
|
||||
By default, LiquidJS will try to load and analyze any included and rendered templates too.
|
||||
|
||||
```javascript
|
||||
import { Liquid } from 'liquidjs'
|
||||
|
||||
const footer = `
|
||||
<footer>
|
||||
<p>© {{ "now" | date: "%Y" }} {{ site_name }}</p>
|
||||
<p>{{ site_description }}</p>
|
||||
</footer>`
|
||||
|
||||
const engine = new Liquid({ templates: { footer } })
|
||||
|
||||
const template = engine.parse(`
|
||||
<body>
|
||||
<h1>Hi, {{ you | default: 'World' }}!</h1>
|
||||
{% assign some = 'thing' %}
|
||||
{% include 'footer' %}
|
||||
</body>
|
||||
`)
|
||||
|
||||
engine.globalVariables(template).then(console.log)
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
```javascript
|
||||
[ 'you', 'site_name', 'site_description' ]
|
||||
```
|
||||
|
||||
You can disable analysis of partial templates by setting the `partials` options to `false`.
|
||||
|
||||
```javascript
|
||||
// continue from above
|
||||
engine.globalVariables(template, { partials: false }).then(console.log)
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
```javascript
|
||||
[ 'you' ]
|
||||
```
|
||||
|
||||
If an `{% include %}` tag uses a dynamic template name (one that can't be determined without rendering the template) it will be ignored, even if `partials` is set to `true`.
|
||||
|
||||
### Advanced Usage
|
||||
|
||||
The examples so far all use convenience methods of the `Liquid` class, intended to cover the most common use cases. Instead, you can work with [analysis results][static-analysis-interface] directly, which expose the row, column and file name for every occurrence of each variable.
|
||||
|
||||
This is an example of an object returned from `Liquid.analyze()`, passing it the template from the [Partial Template](#partial-templates) section above.
|
||||
|
||||
```javascript
|
||||
{
|
||||
variables: {
|
||||
you: [
|
||||
[String (Variable): 'you'] {
|
||||
segments: [ 'you' ],
|
||||
location: { row: 2, col: 14, file: undefined }
|
||||
}
|
||||
],
|
||||
site_name: [
|
||||
[String (Variable): 'site_name'] {
|
||||
segments: [ 'site_name' ],
|
||||
location: { row: 2, col: 41, file: 'footer' }
|
||||
}
|
||||
],
|
||||
site_description: [
|
||||
[String (Variable): 'site_description'] {
|
||||
segments: [ 'site_description' ],
|
||||
location: { row: 3, col: 9, file: 'footer' }
|
||||
}
|
||||
]
|
||||
},
|
||||
globals: {
|
||||
you: [
|
||||
[String (Variable): 'you'] {
|
||||
segments: [ 'you' ],
|
||||
location: { row: 2, col: 14, file: undefined }
|
||||
}
|
||||
],
|
||||
site_name: [
|
||||
[String (Variable): 'site_name'] {
|
||||
segments: [ 'site_name' ],
|
||||
location: { row: 2, col: 41, file: 'footer' }
|
||||
}
|
||||
],
|
||||
site_description: [
|
||||
[String (Variable): 'site_description'] {
|
||||
segments: [ 'site_description' ],
|
||||
location: { row: 3, col: 9, file: 'footer' }
|
||||
}
|
||||
]
|
||||
},
|
||||
locals: {
|
||||
some: [
|
||||
[String (Variable): 'some'] {
|
||||
segments: [ 'some' ],
|
||||
location: { row: 3, col: 13, file: undefined }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Analyzing Custom Tags
|
||||
|
||||
For static analysis to include results from custom tags, those tags must implement some additional methods defined on the [Template interface]( /api/interfaces/Template.html). LiquidJS will use the information returned from these methods to traverse the template and report variable usage.
|
||||
|
||||
Not all methods are required, depending in the kind of tag. If it's a block with a start tag, end tag and any amount of Liquid markup in between, it will need to implement the [`children()`](/api/interfaces/Template.html#children) method. `children()` is defined as a generator, so that we can use it in synchronous and asynchronous contexts, just like `render()`. It should return HTML content, output statements and tags that are child nodes of the current tag.
|
||||
|
||||
The [`blockScope()`](/api/interfaces/Template.html#blockScope) method is responsible for telling LiquidJS which names will be in scope for the duration of the tag's block. Some of these names could depend on the tag's arguments, and some will be fixed, like `forloop` from the `{% for %}` tag.
|
||||
|
||||
Whether a tag is an inline tag or a block tag, if it accepts arguments it should implement [`arguments()`](/api/interfaces/Template.html#arguments), which is responsible for returning the tag's arguments as a sequence of [`Value`](/api/classes/Value.html) instances or tokens of type [`ValueToken`](/api/types/ValueToken.html).
|
||||
|
||||
This example demonstrates these methods for a block tag. See LiquidJS's [built-in tags][built-in] for more examples.
|
||||
|
||||
```javascript
|
||||
import { Liquid, Tag, Hash } from 'liquidjs'
|
||||
|
||||
class ExampleTag extends Tag {
|
||||
args
|
||||
templates
|
||||
|
||||
constructor (token, remainTokens, liquid, parser) {
|
||||
super(token, remainTokens, liquid)
|
||||
this.args = new Hash(token.tokenizer)
|
||||
this.templates = []
|
||||
|
||||
const stream = parser.parseStream(remainTokens)
|
||||
.on('tag:endexample', () => { stream.stop() })
|
||||
.on('template', (tpl) => this.templates.push(tpl))
|
||||
.on('end', () => { throw new Error(`tag ${token.getText()} not closed`) })
|
||||
|
||||
stream.start()
|
||||
}
|
||||
|
||||
* render (ctx, emitter) {
|
||||
const scope = (yield this.args.render(ctx))
|
||||
ctx.push(scope)
|
||||
yield this.liquid.renderer.renderTemplates(this.templates, ctx, emitter)
|
||||
ctx.pop()
|
||||
}
|
||||
|
||||
* children () {
|
||||
return this.templates
|
||||
}
|
||||
|
||||
* arguments () {
|
||||
yield * Object.values(this.args.hash).filter((el) => el !== undefined)
|
||||
}
|
||||
|
||||
blockScope () {
|
||||
return Object.keys(this.args.hash)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[liquid-api]: /api/classes/Liquid.html
|
||||
[static-analysis-interface]: /api/interfaces/StaticAnalysis.html
|
||||
[built-in]: https://github.com/harttle/liquidjs/tree/master/src/tags
|
||||
@@ -103,5 +103,32 @@ const products = [
|
||||
- 3
|
||||
```
|
||||
|
||||
## Jekyll 风格
|
||||
|
||||
{% since %}v10.19.0{% endsince %}
|
||||
|
||||
对于从 Jekyll 迁移到 Liquid 的用户,有一个 `jekyllWhere` 选项可以模拟 Jekyll 的 `where` 过滤器的行为。该选项默认设置为 `false`。启用后,如果 `property` 是一个数组,目标值将使用 `Array.includes` 而不是 `==` 进行匹配,这在过滤标签时特别有用。
|
||||
|
||||
例如,以下代码:
|
||||
|
||||
```javascript
|
||||
const pages = [
|
||||
{ tags: ["cat", "food"], title: 'Cat Food' },
|
||||
{ tags: ["dog", "food"], title: 'Dog Food' },
|
||||
]
|
||||
```
|
||||
|
||||
输入
|
||||
```liquid
|
||||
{% assign selected = pages | where: 'tags', "cat" %}
|
||||
{% for item in selected -%}
|
||||
- {{ item.title }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
输出
|
||||
```text
|
||||
Cat Food
|
||||
```
|
||||
|
||||
[truthy]: ../tutorials/truthy-and-falsy.html
|
||||
|
||||
@@ -16,6 +16,6 @@ LiquidJS 运算符非常简单也很特别,只支持两类运算符:
|
||||
|
||||
## 结合性
|
||||
|
||||
逻辑运算符是又结合的,所以连续的逻辑运算时计算顺序是从右向左,参考 [Shopify][operator-order] 的文档。
|
||||
逻辑运算符是右结合的,所以连续的逻辑运算时计算顺序是从右向左,参考 [Shopify][operator-order] 的文档。
|
||||
|
||||
[operator-order]: https://help.shopify.com/en/themes/liquid/basics/operators#order-of-operations
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
---
|
||||
title: 静态模板分析
|
||||
---
|
||||
|
||||
{% since %}v10.20.0{% endsince %}
|
||||
|
||||
{% note warn 实验性功能 %}
|
||||
这是一个实验性功能,未来的 API 可能会发生变化,返回的内部结构也可能在不进行主要版本更新的情况下更改。
|
||||
{% endnote %}
|
||||
|
||||
{% note info 同步与异步 %}
|
||||
本文中的每种方法都提供了同步和异步版本。请参阅 [Liquid API][liquid-api] 了解完整的参考信息。
|
||||
{% endnote %}
|
||||
|
||||
## 变量
|
||||
|
||||
可以使用 `Liquid.variables(template)` 方法获取模板中使用的变量名称。它会返回一个字符串数组,每个字符串代表一个不同的变量,不包括其属性。
|
||||
|
||||
```javascript
|
||||
import { Liquid } from 'liquidjs'
|
||||
|
||||
const engine = new Liquid()
|
||||
|
||||
const template = engine.parse(`
|
||||
<p>
|
||||
{% assign title = user.title | capitalize %}
|
||||
{{ title }} {{ user.first_name | default: user.name }} {{ user.last_name }}
|
||||
{% if user.address %}
|
||||
{{ user.address.line1 }}
|
||||
{% else %}
|
||||
{{ user.email_addresses[0] }}
|
||||
{% for email in user.email_addresses %}
|
||||
- {{ email }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{{ a[b.c].d }}
|
||||
<p>
|
||||
`)
|
||||
|
||||
console.log(engine.variablesSync(template))
|
||||
```
|
||||
|
||||
**输出**
|
||||
|
||||
```javascript
|
||||
[ 'user', 'title', 'email', 'a', 'b' ]
|
||||
```
|
||||
|
||||
可以看到,标签和过滤器参数中的变量也会包含在内,例如示例中的嵌套变量 `b`。
|
||||
另外,可以使用 `Liquid.fullVariables(template)` 方法获取包含其属性的完整变量列表。
|
||||
|
||||
```javascript
|
||||
// 上例继续
|
||||
engine.fullVariables(template).then(console.log)
|
||||
```
|
||||
|
||||
**输出**
|
||||
|
||||
```javascript
|
||||
[
|
||||
'user.title',
|
||||
'user.first_name',
|
||||
'user.name',
|
||||
'user.last_name',
|
||||
'user.address',
|
||||
'user.address.line1',
|
||||
'user.email_addresses[0]',
|
||||
'user.email_addresses',
|
||||
'title',
|
||||
'email',
|
||||
'a[b.c].d',
|
||||
'b.c'
|
||||
]
|
||||
```
|
||||
|
||||
或者,使用 `Liquid.variableSegments(template)` 获取每个变量路径的字符串和数字数组。
|
||||
|
||||
```javascript
|
||||
// 上例继续
|
||||
engine.variableSegments(template).then(console.log)
|
||||
```
|
||||
|
||||
**输出**
|
||||
|
||||
```javascript
|
||||
[
|
||||
[ 'user', 'title' ],
|
||||
[ 'user', 'first_name' ],
|
||||
[ 'user', 'name' ],
|
||||
[ 'user', 'last_name' ],
|
||||
[ 'user', 'address' ],
|
||||
[ 'user', 'address', 'line1' ],
|
||||
[ 'user', 'email_addresses', 0 ],
|
||||
[ 'user', 'email_addresses' ],
|
||||
[ 'title' ],
|
||||
[ 'email' ],
|
||||
[ 'a', [ 'b', 'c' ], 'd' ],
|
||||
[ 'b', 'c' ]
|
||||
]
|
||||
```
|
||||
|
||||
### 全局变量
|
||||
|
||||
注意在上述示例中,`title` 和 `email` 被包含在结果中。通常你可能希望排除 `{% assign %}` 标签中定义的变量名,以及由 `{% for %}` 标签引入的临时变量。
|
||||
|
||||
为了获取 _全局_ 变量(即由应用开发者提供,而不是模板作者定义的变量)的名称,可以使用 `globalVariables`、`globalFullVariables` 或 `globalVariableSegments` 方法(及其同步版本)。
|
||||
|
||||
```javascript
|
||||
// 上例继续
|
||||
engine.globalVariableSegments(template).then(console.log)
|
||||
```
|
||||
|
||||
**输出**
|
||||
|
||||
```javascript
|
||||
[
|
||||
[ 'user', 'title' ],
|
||||
[ 'user', 'first_name' ],
|
||||
[ 'user', 'name' ],
|
||||
[ 'user', 'last_name' ],
|
||||
[ 'user', 'address' ],
|
||||
[ 'user', 'address', 'line1' ],
|
||||
[ 'user', 'email_addresses', 0 ],
|
||||
[ 'user', 'email_addresses' ],
|
||||
[ 'a', [ 'b', 'c' ], 'd' ],
|
||||
[ 'b', 'c' ]
|
||||
]
|
||||
```
|
||||
|
||||
### 部分模板
|
||||
|
||||
默认情况下,LiquidJS 还会尝试加载和分析任何被包含和渲染的模板。
|
||||
|
||||
```javascript
|
||||
import { Liquid } from 'liquidjs'
|
||||
|
||||
const footer = `
|
||||
<footer>
|
||||
<p>© {{ "now" | date: "%Y" }} {{ site_name }}</p>
|
||||
<p>{{ site_description }}</p>
|
||||
</footer>`
|
||||
|
||||
const engine = new Liquid({ templates: { footer } })
|
||||
|
||||
const template = engine.parse(`
|
||||
<body>
|
||||
<h1>Hi, {{ you | default: 'World' }}!</h1>
|
||||
{% assign some = 'thing' %}
|
||||
{% include 'footer' %}
|
||||
</body>
|
||||
`)
|
||||
|
||||
engine.globalVariables(template).then(console.log)
|
||||
```
|
||||
|
||||
**输出**
|
||||
|
||||
```javascript
|
||||
[ 'you', 'site_name', 'site_description' ]
|
||||
```
|
||||
|
||||
可以通过将 `partials` 选项设置为 `false` 来禁用部分模板的分析。
|
||||
|
||||
```javascript
|
||||
// 上例继续
|
||||
engine.globalVariables(template, { partials: false }).then(console.log)
|
||||
```
|
||||
|
||||
**输出**
|
||||
|
||||
```javascript
|
||||
[ 'you' ]
|
||||
```
|
||||
|
||||
如果 `{% include %}` 标签使用了动态模板名称(无法在渲染模板之前确定的模板名称),即使 `partials` 设置为 `true`,也会被忽略。
|
||||
|
||||
### 高级用法
|
||||
|
||||
上述示例使用的是 `Liquid` 类的便捷方法,适用于最常见的使用场景。
|
||||
如果需要更详细的信息,可以直接处理 [分析结果][static-analysis-interface],其中每个变量的每次出现都会记录行、列和文件名等信息。
|
||||
|
||||
此处是对 [部分模板](#部分模板) 中模板进行 `Liquid.analyze()` 调用后返回的对象示例。
|
||||
|
||||
```javascript
|
||||
{
|
||||
variables: {
|
||||
you: [
|
||||
[String (Variable): 'you'] {
|
||||
segments: [ 'you' ],
|
||||
location: { row: 2, col: 14, file: undefined }
|
||||
}
|
||||
],
|
||||
site_name: [
|
||||
[String (Variable): 'site_name'] {
|
||||
segments: [ 'site_name' ],
|
||||
location: { row: 2, col: 41, file: 'footer' }
|
||||
}
|
||||
],
|
||||
site_description: [
|
||||
[String (Variable): 'site_description'] {
|
||||
segments: [ 'site_description' ],
|
||||
location: { row: 3, col: 9, file: 'footer' }
|
||||
}
|
||||
]
|
||||
},
|
||||
globals: {
|
||||
you: [
|
||||
[String (Variable): 'you'] {
|
||||
segments: [ 'you' ],
|
||||
location: { row: 2, col: 14, file: undefined }
|
||||
}
|
||||
],
|
||||
site_name: [
|
||||
[String (Variable): 'site_name'] {
|
||||
segments: [ 'site_name' ],
|
||||
location: { row: 2, col: 41, file: 'footer' }
|
||||
}
|
||||
],
|
||||
site_description: [
|
||||
[String (Variable): 'site_description'] {
|
||||
segments: [ 'site_description' ],
|
||||
location: { row: 3, col: 9, file: 'footer' }
|
||||
}
|
||||
]
|
||||
},
|
||||
locals: {
|
||||
some: [
|
||||
[String (Variable): 'some'] {
|
||||
segments: [ 'some' ],
|
||||
location: { row: 3, col: 13, file: undefined }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义标签的分析
|
||||
|
||||
为了在静态分析中包含自定义标签的结果,这些标签必须实现 [Template 接口]( /api/interfaces/Template.html) 中定义的一些附加方法。LiquidJS 会使用这些方法返回的信息来遍历模板并报告变量使用情况。
|
||||
|
||||
并非所有方法都是必须的,这取决于标签的类型。如果标签是一个块标签,具有起始标签、结束标签以及内容,那么它需要实现 [`children()`](/api/interfaces/Template.html#children) 方法。`children()` 需要返回一个生成器,这是为了像 `render()` 一样既可以同步也可以异步调用。该方法应返回当前标签的子节点,例如 HTML 内容、输出语句和标签。
|
||||
|
||||
[`blockScope()`](/api/interfaces/Template.html#blockScope) 方法用于告知 LiquidJS 在标签块的持续时间内哪些名称会处于作用域中。这些名称可能依赖于标签的参数,也可能是固定的,例如 `{% for %}` 标签生成的 `forloop`。
|
||||
|
||||
无论标签是行内标签还是块标签,如果它接受参数,则应实现 [`arguments()`](/api/interfaces/Template.html#arguments) 方法,该方法负责将标签的参数作为 [`Value`](/api/classes/Value.html) 实例或类型为 [`ValueToken`](/api/types/ValueToken.html) 的标记序列返回。
|
||||
|
||||
以下示例展示了块标签如何实现这些方法。有关更多示例,请参见 LiquidJS 的[内置标签][built-in]。
|
||||
|
||||
```javascript
|
||||
import { Liquid, Tag, Hash } from 'liquidjs'
|
||||
|
||||
class ExampleTag extends Tag {
|
||||
args
|
||||
templates
|
||||
|
||||
constructor (token, remainTokens, liquid, parser) {
|
||||
super(token, remainTokens, liquid)
|
||||
this.args = new Hash(token.tokenizer)
|
||||
this.templates = []
|
||||
|
||||
const stream = parser.parseStream(remainTokens)
|
||||
.on('tag:endexample', () => { stream.stop() })
|
||||
.on('template', (tpl) => this.templates.push(tpl))
|
||||
.on('end', () => { throw new Error(`tag ${token.getText()} not closed`) })
|
||||
|
||||
stream.start()
|
||||
}
|
||||
|
||||
* render (ctx, emitter) {
|
||||
const scope = (yield this.args.render(ctx))
|
||||
ctx.push(scope)
|
||||
yield this.liquid.renderer.renderTemplates(this.templates, ctx, emitter)
|
||||
ctx.pop()
|
||||
}
|
||||
|
||||
* children () {
|
||||
return this.templates
|
||||
}
|
||||
|
||||
* arguments () {
|
||||
yield * Object.values(this.args.hash).filter((el) => el !== undefined)
|
||||
}
|
||||
|
||||
blockScope () {
|
||||
return Object.keys(this.args.hash)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[liquid-api]: /api/classes/Liquid.html
|
||||
[static-analysis-interface]: /api/interfaces/StaticAnalysis.html
|
||||
[built-in]: https://github.com/harttle/liquidjs/tree/master/src/tags
|
||||
Vendored
+1
@@ -52,6 +52,7 @@ sidebar:
|
||||
operators: Operators
|
||||
truth: Truthy and Falsy
|
||||
dos: DoS
|
||||
static_analysis: Static Analysis
|
||||
|
||||
miscellaneous: Miscellaneous
|
||||
migration9: 'Migrate to LiquidJS 9'
|
||||
|
||||
Vendored
+1
@@ -52,6 +52,7 @@ sidebar:
|
||||
operators: 运算符
|
||||
truth: 真和假
|
||||
dos: DoS
|
||||
static_analysis: 静态分析
|
||||
|
||||
miscellaneous: 其他
|
||||
migration9: '迁移到 LiquidJS 9'
|
||||
|
||||
Vendored
+15
-15
@@ -1,5 +1,5 @@
|
||||
<header id="banner" class="wrapper">
|
||||
<div class="inner">
|
||||
<div class="inner inner-content">
|
||||
<h2 id="banner-title">{{ page.subtitle }}</h2>
|
||||
<div id="banner-start">
|
||||
<code id="banner-start-command">npm install liquidjs</code><a id="banner-start-link" href="./tutorials/setup.html"><i class="icon-arrow-right"></i></a>
|
||||
@@ -9,7 +9,7 @@
|
||||
</header>
|
||||
<div id="intro-news-list">
|
||||
<div class="wrapper">
|
||||
<div class="inner">
|
||||
<div class="inner inner-content">
|
||||
<div id="intro-news-flex">
|
||||
{% for news in site.data.news %}
|
||||
<a href="{{news.url}}" class="intro-news-wrap">
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div id="content-wrap">
|
||||
<div class="wrapper">
|
||||
<div class="inner">
|
||||
<div class="inner inner-content">
|
||||
{{ page.content }}
|
||||
<div id="intro-cmd-wrap" class="highlight typescript"><pre><span class="line"><span class="keyword">import</span> { Liquid } <span class="keyword">from</span> <span class="string">'liquidjs'</span></span><br><span class="line"><span class="keyword">const</span> engine = <span class="keyword">new</span> Liquid()</span><br><span class="line"><span class="keyword">const</span> tpl = engine.parse(<span class="string">'Welcome to {% raw %}{{v}}{% endraw %}!'</span>)</span><br><span class="line">engine.render(tpl, {<span class="attr">v</span>: <span class="string">"Liquid"</span>}).then(<span class="built_in">console</span>.log)</span><br><span class="line"><span class="comment">// Outputs "Welcome to Liquid!"</span></span><br></pre></div>
|
||||
<div id="intro-get-started-wrap">
|
||||
@@ -32,20 +32,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="contributors-wrap">
|
||||
<div class="wrapper">
|
||||
<div class="inner">
|
||||
<h3>{{__('index.contributors.title')}}</h3>
|
||||
<p class="description">{{__('index.contributors.description')}}</p>
|
||||
<div class="contributors">
|
||||
{{ partial('partial/all-contributors') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sponsors-wrap">
|
||||
<div class="wrapper">
|
||||
<div class="inner">
|
||||
<div class="inner inner-content">
|
||||
<h3>{{__('index.sponsors.title')}}</h3>
|
||||
<p class="description">{{__('index.sponsors.description')}}</p>
|
||||
<div class="contributors">
|
||||
@@ -54,3 +43,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="contributors-wrap">
|
||||
<div class="wrapper">
|
||||
<div class="inner inner-content">
|
||||
<h3>{{__('index.contributors.title')}}</h3>
|
||||
<p class="description">{{__('index.contributors.description')}}</p>
|
||||
<div class="contributors">
|
||||
{{ partial('partial/all-contributors') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -9,7 +9,8 @@
|
||||
<script>
|
||||
document.getElementById('search-input-wrap').classList.add('on');
|
||||
docsearch({
|
||||
apiKey: '01f36cc168657a26a385308b9e721bc6',
|
||||
appId: '{{ config.algolia.applicationID }}',
|
||||
apiKey: '{{ config.algolia.apiKey }}',
|
||||
indexName: 'liquidjs',
|
||||
inputSelector: '#search-input',
|
||||
debug: false
|
||||
|
||||
+5
-1
@@ -54,7 +54,11 @@ a
|
||||
|
||||
.inner
|
||||
@media screen
|
||||
padding: 0 gutter-width
|
||||
margin: 0 gutter-width
|
||||
.inner.inner-content
|
||||
margin: 0 32px
|
||||
@media mq-tablet
|
||||
margin: 0 48px
|
||||
|
||||
#content-wrap
|
||||
background: var(--color-content-bg)
|
||||
|
||||
+21
-17
@@ -66,6 +66,10 @@
|
||||
.intro-news-wrap
|
||||
padding: 24px 30px
|
||||
text-decoration: none
|
||||
&:first-child
|
||||
padding-left: 0
|
||||
&:last-child
|
||||
padding-right: 0
|
||||
&:hover
|
||||
background-color: var(--color-link-hover)
|
||||
@media mq-normal
|
||||
@@ -105,7 +109,7 @@
|
||||
text-align: center
|
||||
@media mq-normal
|
||||
text-align: left
|
||||
padding-left: 70px
|
||||
padding-left: 48px
|
||||
|
||||
.intro-feature-icon
|
||||
color: var(--color-link)
|
||||
@@ -116,7 +120,7 @@
|
||||
margin-bottom: 26px
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 20px
|
||||
left: 0
|
||||
font-size: 24px
|
||||
width: @font-size
|
||||
|
||||
@@ -177,9 +181,11 @@
|
||||
border-bottom: 1px solid #161d24
|
||||
margin: -1px 0
|
||||
.inner
|
||||
margin: 48px 16px
|
||||
margin-top: 48px
|
||||
margin-bottom: 48px
|
||||
@media mq-tablet
|
||||
margin: 64px 32px
|
||||
margin-top: 64px
|
||||
margin-bottom: 64px
|
||||
h3
|
||||
color: #fff
|
||||
font-size: 32px
|
||||
@@ -192,20 +198,7 @@
|
||||
text-decoration: none
|
||||
|
||||
#contributors-wrap
|
||||
.description a
|
||||
color: #fff
|
||||
&:hover
|
||||
background: #fff
|
||||
color: var(--color-navy-lighter)
|
||||
|
||||
#sponsors-wrap
|
||||
border: none
|
||||
text-align: right
|
||||
background: var(--color-content-bg)
|
||||
overflow: hidden;
|
||||
.contributors
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
.inner
|
||||
h3
|
||||
color: var(--color-default)
|
||||
@@ -227,10 +220,20 @@
|
||||
background: #fff
|
||||
color: var(--color-navy-lighter)
|
||||
|
||||
#sponsors-wrap
|
||||
border: none
|
||||
overflow: hidden;
|
||||
.description a
|
||||
color: #fff
|
||||
&:hover
|
||||
background: #fff
|
||||
color: var(--color-navy-lighter)
|
||||
|
||||
.contributors
|
||||
tr
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: center;
|
||||
td
|
||||
margin: 10px 2px 0
|
||||
a img
|
||||
@@ -240,3 +243,4 @@
|
||||
background: var(--color-gray)
|
||||
border-radius: 50%
|
||||
margin-bottom: 10px
|
||||
object-fit: contain
|
||||
|
||||
Vendored
+4
-1
@@ -60,7 +60,10 @@
|
||||
/* global liquidjs, ace */
|
||||
if (!location.pathname.match(/playground.html$/)) return;
|
||||
updateVersion(liquidjs.version);
|
||||
const engine = new liquidjs.Liquid();
|
||||
const engine = new liquidjs.Liquid({
|
||||
memoryLimit: 1e5,
|
||||
renderLimit: 1e5
|
||||
});
|
||||
const editor = createEditor('editorEl', 'liquid');
|
||||
const dataEditor = createEditor('dataEl', 'json');
|
||||
const preview = createEditor('previewEl', 'html');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
maxWorkers: '50%',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/*.spec.ts'],
|
||||
collectCoverageFrom: [
|
||||
|
||||
Generated
+8340
-4794
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "liquidjs",
|
||||
"version": "10.18.0",
|
||||
"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",
|
||||
"module": "dist/liquid.node.mjs",
|
||||
@@ -11,7 +12,7 @@
|
||||
},
|
||||
"types": "dist/index.d.ts",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
"node": ">=16"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint \"**/*.mjs\" \"**/*.ts\" .",
|
||||
|
||||
+20
-17
@@ -3,7 +3,7 @@ import { Drop } from '../drop/drop'
|
||||
import { __assign } from 'tslib'
|
||||
import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options'
|
||||
import { Scope } from './scope'
|
||||
import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter } from '../util'
|
||||
import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue } from '../util'
|
||||
|
||||
type PropertyKey = string | number;
|
||||
|
||||
@@ -25,6 +25,8 @@ export class Context {
|
||||
*/
|
||||
public globals: Scope
|
||||
public sync: boolean
|
||||
public breakCalled = false
|
||||
public continueCalled = false
|
||||
/**
|
||||
* The normalized liquid options object
|
||||
*/
|
||||
@@ -71,8 +73,8 @@ export class Context {
|
||||
public getSync (paths: PropertyKey[]): unknown {
|
||||
return toValueSync(this._get(paths))
|
||||
}
|
||||
public * _get (paths: PropertyKey[]): IterableIterator<unknown> {
|
||||
const scope = this.findScope(paths[0])
|
||||
public * _get (paths: (PropertyKey | Drop)[]): IterableIterator<unknown> {
|
||||
const scope = this.findScope(paths[0] as string) // first prop should always be a string
|
||||
return yield this._getFromScope(scope, paths)
|
||||
}
|
||||
/**
|
||||
@@ -81,10 +83,10 @@ export class Context {
|
||||
public getFromScope (scope: unknown, paths: PropertyKey[] | string): IterableIterator<unknown> {
|
||||
return toValueSync(this._getFromScope(scope, paths))
|
||||
}
|
||||
public * _getFromScope (scope: unknown, paths: PropertyKey[] | string, strictVariables = this.strictVariables): IterableIterator<unknown> {
|
||||
public * _getFromScope (scope: unknown, paths: (PropertyKey | Drop)[] | string, strictVariables = this.strictVariables): IterableIterator<unknown> {
|
||||
if (isString(paths)) paths = paths.split('.')
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
scope = yield readProperty(scope as object, paths[i], this.ownPropertyOnly)
|
||||
scope = yield this.readProperty(scope as object, paths[i])
|
||||
if (strictVariables && isUndefined(scope)) {
|
||||
throw new InternalUndefinedVariableError((paths as string[]).slice(0, i + 1).join!('.'))
|
||||
}
|
||||
@@ -118,20 +120,21 @@ export class Context {
|
||||
if (key in this.environments) return this.environments
|
||||
return this.globals
|
||||
}
|
||||
readProperty (obj: Scope, key: (PropertyKey | Drop)) {
|
||||
obj = toLiquid(obj)
|
||||
key = toValue(key) as PropertyKey
|
||||
if (isNil(obj)) return obj
|
||||
if (isArray(obj) && (key as number) < 0) return obj[obj.length + +key]
|
||||
const value = readJSProperty(obj, key, this.ownPropertyOnly)
|
||||
if (value === undefined && obj instanceof Drop) return obj.liquidMethodMissing(key, this)
|
||||
if (isFunction(value)) return value.call(obj)
|
||||
if (key === 'size') return readSize(obj)
|
||||
else if (key === 'first') return readFirst(obj)
|
||||
else if (key === 'last') return readLast(obj)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function readProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) {
|
||||
obj = toLiquid(obj)
|
||||
if (isNil(obj)) return obj
|
||||
if (isArray(obj) && (key as number) < 0) return obj[obj.length + +key]
|
||||
const value = readJSProperty(obj, key, ownPropertyOnly)
|
||||
if (value === undefined && obj instanceof Drop) return obj.liquidMethodMissing(key)
|
||||
if (isFunction(value)) return value.call(obj)
|
||||
if (key === 'size') return readSize(obj)
|
||||
else if (key === 'first') return readFirst(obj)
|
||||
else if (key === 'last') return readLast(obj)
|
||||
return value
|
||||
}
|
||||
export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: boolean) {
|
||||
if (ownPropertyOnly && !hasOwnProperty.call(obj, key) && !(obj instanceof Drop)) return undefined
|
||||
return obj[key]
|
||||
|
||||
@@ -8,4 +8,7 @@ export class BlankDrop extends EmptyDrop {
|
||||
if (isString(value)) return /^\s*$/.test(value)
|
||||
return super.equals(value)
|
||||
}
|
||||
static is (value: unknown) {
|
||||
return value instanceof BlankDrop
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Emitter, SimpleEmitter } from '../emitters'
|
||||
import { Drop } from './drop'
|
||||
|
||||
export class BlockDrop extends Drop {
|
||||
constructor (
|
||||
// the block render from layout template
|
||||
private superBlockRender: () => Iterable<any> = () => ''
|
||||
private superBlockRender: (emitter: Emitter) => IterableIterator<unknown> | string = () => ''
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -11,7 +12,9 @@ export class BlockDrop extends Drop {
|
||||
* Provide parent access in child block by
|
||||
* {{ block.super }}
|
||||
*/
|
||||
public super () {
|
||||
return this.superBlockRender()
|
||||
public * super (): IterableIterator<unknown> {
|
||||
const emitter = new SimpleEmitter()
|
||||
yield this.superBlockRender(emitter)
|
||||
return emitter.buffer
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
import { Context } from '../context'
|
||||
|
||||
export abstract class Drop {
|
||||
public liquidMethodMissing (key: string | number): Promise<any> | any {
|
||||
public liquidMethodMissing (key: string | number, context: Context): Promise<any> | any {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,7 @@ export class EmptyDrop extends Drop implements Comparable {
|
||||
public valueOf () {
|
||||
return ''
|
||||
}
|
||||
static is (value: unknown) {
|
||||
return value instanceof EmptyDrop
|
||||
}
|
||||
}
|
||||
|
||||
+85
-23
@@ -1,8 +1,9 @@
|
||||
import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, isArray, isNil, last as arrayLast } from '../util'
|
||||
import { equals, evalToken, isTruthy } from '../render'
|
||||
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'
|
||||
import type { Scope } from '../context'
|
||||
import { EmptyDrop } from '../drop'
|
||||
|
||||
export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: string) {
|
||||
const array = toArray(v)
|
||||
@@ -11,8 +12,8 @@ export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg:
|
||||
this.context.memoryLimit.use(complexity)
|
||||
return array.join(sep)
|
||||
})
|
||||
export const last = argumentsToValue((v: any) => isArray(v) ? arrayLast(v) : '')
|
||||
export const first = argumentsToValue((v: any) => isArray(v) ? v[0] : '')
|
||||
export const last = argumentsToValue((v: any) => isArrayLike(v) ? arrayLast(v) : '')
|
||||
export const first = argumentsToValue((v: any) => isArrayLike(v) ? v[0] : '')
|
||||
export const reverse = argumentsToValue(function (this: FilterImpl, v: any[]) {
|
||||
const array = toArray(v)
|
||||
this.context.memoryLimit.use(array.length)
|
||||
@@ -116,7 +117,17 @@ export function slice<T> (this: FilterImpl, v: T[] | string, begin: number, leng
|
||||
return v.slice(begin, begin + length)
|
||||
}
|
||||
|
||||
export function * where<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
|
||||
function expectedMatcher (this: FilterImpl, expected: any): (v: any) => boolean {
|
||||
if (this.context.opts.jekyllWhere) {
|
||||
return (v: any) => EmptyDrop.is(expected) ? equals(v, expected) : (isArray(v) ? arrayIncludes(v, expected) : equals(v, expected))
|
||||
} else if (expected === undefined) {
|
||||
return (v: any) => isTruthy(v, this.context)
|
||||
} else {
|
||||
return (v: any) => equals(v, expected)
|
||||
}
|
||||
}
|
||||
|
||||
function * filter<T extends object> (this: FilterImpl, include: boolean, arr: T[], property: string, expected: any): IterableIterator<unknown> {
|
||||
const values: unknown[] = []
|
||||
arr = toArray(arr)
|
||||
this.context.memoryLimit.use(arr.length)
|
||||
@@ -124,27 +135,43 @@ export function * where<T extends object> (this: FilterImpl, arr: T[], property:
|
||||
for (const item of arr) {
|
||||
values.push(yield evalToken(token, this.context.spawn(item)))
|
||||
}
|
||||
return arr.filter((_, i) => {
|
||||
if (expected === undefined) return isTruthy(values[i], this.context)
|
||||
return equals(values[i], expected)
|
||||
})
|
||||
const matcher = expectedMatcher.call(this, expected)
|
||||
return arr.filter((_, i) => matcher(values[i]) === include)
|
||||
}
|
||||
|
||||
export function * where_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
|
||||
function * filter_exp<T extends object> (this: FilterImpl, include: boolean, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
|
||||
const filtered: unknown[] = []
|
||||
const keyTemplate = new Value(stringify(exp), this.liquid)
|
||||
const array = toArray(arr)
|
||||
this.context.memoryLimit.use(array.length)
|
||||
for (const item of array) {
|
||||
const value = yield keyTemplate.value(this.context.spawn({ [itemName]: item }))
|
||||
if (value) filtered.push(item)
|
||||
this.context.push({ [itemName]: item })
|
||||
const value = yield keyTemplate.value(this.context)
|
||||
this.context.pop()
|
||||
if (value === include) filtered.push(item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
export function * where<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
|
||||
return yield * filter.call(this, true, arr, property, expected)
|
||||
}
|
||||
|
||||
export function * reject<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
|
||||
return yield * filter.call(this, false, arr, property, expected)
|
||||
}
|
||||
|
||||
export function * where_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
|
||||
return yield * filter_exp.call(this, true, arr, itemName, exp)
|
||||
}
|
||||
|
||||
export function * reject_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
|
||||
return yield * filter_exp.call(this, false, arr, itemName, exp)
|
||||
}
|
||||
|
||||
export function * group_by<T extends object> (this: FilterImpl, arr: T[], property: string): IterableIterator<unknown> {
|
||||
const map = new Map()
|
||||
arr = toArray(arr)
|
||||
arr = toEnumerable(arr)
|
||||
const token = new Tokenizer(stringify(property)).readScopeValue()
|
||||
this.context.memoryLimit.use(arr.length)
|
||||
for (const item of arr) {
|
||||
@@ -158,34 +185,69 @@ export function * group_by<T extends object> (this: FilterImpl, arr: T[], proper
|
||||
export function * group_by_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
|
||||
const map = new Map()
|
||||
const keyTemplate = new Value(stringify(exp), this.liquid)
|
||||
arr = toArray(arr)
|
||||
arr = toEnumerable(arr)
|
||||
this.context.memoryLimit.use(arr.length)
|
||||
for (const item of arr) {
|
||||
const key = yield keyTemplate.value(this.context.spawn({ [itemName]: item }))
|
||||
this.context.push({ [itemName]: item })
|
||||
const key = yield keyTemplate.value(this.context)
|
||||
this.context.pop()
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key).push(item)
|
||||
}
|
||||
return [...map.entries()].map(([name, items]) => ({ name, items }))
|
||||
}
|
||||
|
||||
export function * find<T extends object> (this: FilterImpl, arr: T[], property: string, expected: string): IterableIterator<unknown> {
|
||||
function * search<T extends object> (this: FilterImpl, arr: T[], property: string, expected: string): IterableIterator<unknown> {
|
||||
const token = new Tokenizer(stringify(property)).readScopeValue()
|
||||
const array = toArray(arr)
|
||||
for (const item of array) {
|
||||
const value = yield evalToken(token, this.context.spawn(item))
|
||||
if (equals(value, expected)) return item
|
||||
const matcher = expectedMatcher.call(this, expected)
|
||||
for (let index = 0; index < array.length; index++) {
|
||||
const value = yield evalToken(token, this.context.spawn(array[index]))
|
||||
if (matcher(value)) return [index, array[index]]
|
||||
}
|
||||
}
|
||||
|
||||
export function * find_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
|
||||
function * search_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
|
||||
const predicate = new Value(stringify(exp), this.liquid)
|
||||
const array = toArray(arr)
|
||||
for (const item of array) {
|
||||
const value = yield predicate.value(this.context.spawn({ [itemName]: item }))
|
||||
if (value) return item
|
||||
for (let index = 0; index < array.length; index++) {
|
||||
this.context.push({ [itemName]: array[index] })
|
||||
const value = yield predicate.value(this.context)
|
||||
this.context.pop()
|
||||
if (value) return [index, array[index]]
|
||||
}
|
||||
}
|
||||
|
||||
export function * has<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
|
||||
const result = yield * search.call(this, arr, property, expected)
|
||||
return !!result
|
||||
}
|
||||
|
||||
export function * has_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
|
||||
const result = yield * search_exp.call(this, arr, itemName, exp)
|
||||
return !!result
|
||||
}
|
||||
|
||||
export function * find_index<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
|
||||
const result = yield * search.call(this, arr, property, expected)
|
||||
return result ? result[0] : undefined
|
||||
}
|
||||
|
||||
export function * find_index_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
|
||||
const result = yield * search_exp.call(this, arr, itemName, exp)
|
||||
return result ? result[0] : undefined
|
||||
}
|
||||
|
||||
export function * find<T extends object> (this: FilterImpl, arr: T[], property: string, expected?: any): IterableIterator<unknown> {
|
||||
const result = yield * search.call(this, arr, property, expected)
|
||||
return result ? result[1] : undefined
|
||||
}
|
||||
|
||||
export function * find_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
|
||||
const result = yield * search_exp.call(this, arr, itemName, exp)
|
||||
return result ? result[1] : undefined
|
||||
}
|
||||
|
||||
export function uniq<T> (this: FilterImpl, arr: T[]): T[] {
|
||||
arr = toArray(arr)
|
||||
this.context.memoryLimit.use(arr.length)
|
||||
|
||||
+13
-18
@@ -1,24 +1,19 @@
|
||||
import { toValue, argumentsToValue } from '../util/underscore'
|
||||
import { toNumber, argumentsToNumber } from '../util/underscore'
|
||||
|
||||
export const abs = argumentsToValue(Math.abs)
|
||||
export const at_least = argumentsToValue(Math.max)
|
||||
export const at_most = argumentsToValue(Math.min)
|
||||
export const ceil = argumentsToValue(Math.ceil)
|
||||
export const divided_by = argumentsToValue((dividend: number, divisor: number, integerArithmetic = false) => integerArithmetic ? Math.floor(dividend / divisor) : dividend / divisor)
|
||||
export const floor = argumentsToValue(Math.floor)
|
||||
export const minus = argumentsToValue((v: number, arg: number) => v - arg)
|
||||
export const modulo = argumentsToValue((v: number, arg: number) => v % arg)
|
||||
export const times = argumentsToValue((v: number, arg: number) => v * arg)
|
||||
export const abs = argumentsToNumber(Math.abs)
|
||||
export const at_least = argumentsToNumber(Math.max)
|
||||
export const at_most = argumentsToNumber(Math.min)
|
||||
export const ceil = argumentsToNumber(Math.ceil)
|
||||
export const divided_by = argumentsToNumber((dividend: number, divisor: number, integerArithmetic = false) => integerArithmetic ? Math.floor(dividend / divisor) : dividend / divisor)
|
||||
export const floor = argumentsToNumber(Math.floor)
|
||||
export const minus = argumentsToNumber((v: number, arg: number) => v - arg)
|
||||
export const plus = argumentsToNumber((lhs: number, rhs: number) => lhs + rhs)
|
||||
export const modulo = argumentsToNumber((v: number, arg: number) => v % arg)
|
||||
export const times = argumentsToNumber((v: number, arg: number) => v * arg)
|
||||
|
||||
export function round (v: number, arg = 0) {
|
||||
v = toValue(v)
|
||||
arg = toValue(arg)
|
||||
v = toNumber(v)
|
||||
arg = toNumber(arg)
|
||||
const amp = Math.pow(10, arg)
|
||||
return Math.round(v * amp) / amp
|
||||
}
|
||||
|
||||
export function plus (v: number, arg: number) {
|
||||
v = toValue(v)
|
||||
arg = toValue(arg)
|
||||
return Number(v) + Number(arg)
|
||||
}
|
||||
|
||||
+3
-2
@@ -6,9 +6,10 @@ export { Drop } 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 } from './template'
|
||||
export { Value, Hash, Template, FilterImplOptions, Tag, Filter, Output, Variable, VariableLocation, VariableSegments, Variables, StaticAnalysis, StaticAnalysisOptions, analyze, analyzeSync, Arguments, PartialScope } from './template'
|
||||
export { Token, TopLevelToken, TagToken, ValueToken } from './tokens'
|
||||
export { TokenKind, Tokenizer, ParseStream } from './parser'
|
||||
export type { RangeToken, LiteralToken, QuotedToken, PropertyAccessToken, NumberToken } from './tokens'
|
||||
export { TokenKind, Tokenizer, ParseStream, Parser } from './parser'
|
||||
export { filters } from './filters'
|
||||
export * from './tags'
|
||||
export { defaultOptions, LiquidOptions } from './liquid-options'
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface LiquidOptions {
|
||||
relativeReference?: boolean;
|
||||
/** Use jekyll style include, pass parameters to `include` variable of current scope. Defaults to `false`. */
|
||||
jekyllInclude?: boolean;
|
||||
/** Use jekyll style where filter, enables array item match. Defaults to `false`. */
|
||||
jekyllWhere?: boolean;
|
||||
/** Add a extname (if filepath doesn't include one) before template file lookup. Eg: setting to `".html"` will allow including file by basename. Defaults to `""`. */
|
||||
extname?: string;
|
||||
/** Whether or not to cache resolved templates. Defaults to `false`. */
|
||||
|
||||
+90
-2
@@ -1,6 +1,6 @@
|
||||
import { Context } from './context'
|
||||
import { toPromise, toValueSync, isFunction, forOwn } from './util'
|
||||
import { TagClass, createTagClass, TagImplOptions, FilterImplOptions, Template, Value } from './template'
|
||||
import { toPromise, toValueSync, isFunction, forOwn, isString, strictUniq } from './util'
|
||||
import { TagClass, createTagClass, TagImplOptions, FilterImplOptions, Template, Value, StaticAnalysisOptions, StaticAnalysis, analyze, analyzeSync, SegmentArray } from './template'
|
||||
import { LookupType } from './fs/loader'
|
||||
import { Render } from './render'
|
||||
import { Parser } from './parser'
|
||||
@@ -122,4 +122,92 @@ export class Liquid {
|
||||
self.renderFile(filePath, ctx).then(html => callback(null, html) as any, callback as any)
|
||||
}
|
||||
}
|
||||
|
||||
public async analyze (template: Template[], options: StaticAnalysisOptions = {}): Promise<StaticAnalysis> {
|
||||
return analyze(template, options)
|
||||
}
|
||||
|
||||
public analyzeSync (template: Template[], options: StaticAnalysisOptions = {}): StaticAnalysis {
|
||||
return analyzeSync(template, options)
|
||||
}
|
||||
|
||||
public async parseAndAnalyze (html: string, filename?: string, options: StaticAnalysisOptions = {}): Promise<StaticAnalysis> {
|
||||
return analyze(this.parse(html, filename), options)
|
||||
}
|
||||
|
||||
public parseAndAnalyzeSync (html: string, filename?: string, options: StaticAnalysisOptions = {}): StaticAnalysis {
|
||||
return analyzeSync(this.parse(html, filename), options)
|
||||
}
|
||||
|
||||
/** Return an array of all variables without their properties. */
|
||||
public async variables (template: string | Template[], options: StaticAnalysisOptions = {}): Promise<string[]> {
|
||||
const analysis = await analyze(isString(template) ? this.parse(template) : template, options)
|
||||
return Object.keys(analysis.variables)
|
||||
}
|
||||
|
||||
/** Return an array of all variables without their properties. */
|
||||
public variablesSync (template: string | Template[], options: StaticAnalysisOptions = {}): string[] {
|
||||
const analysis = analyzeSync(isString(template) ? this.parse(template) : template, options)
|
||||
return Object.keys(analysis.variables)
|
||||
}
|
||||
|
||||
/** Return an array of all variables including their properties/paths. */
|
||||
public async fullVariables (template: string | Template[], options: StaticAnalysisOptions = {}): Promise<string[]> {
|
||||
const analysis = await analyze(isString(template) ? this.parse(template) : template, options)
|
||||
return Array.from(new Set(Object.values(analysis.variables).flatMap((a) => a.map((v) => String(v)))))
|
||||
}
|
||||
|
||||
/** Return an array of all variables including their properties/paths. */
|
||||
public fullVariablesSync (template: string | Template[], options: StaticAnalysisOptions = {}): string[] {
|
||||
const analysis = analyzeSync(isString(template) ? this.parse(template) : template, options)
|
||||
return Array.from(new Set(Object.values(analysis.variables).flatMap((a) => a.map((v) => String(v)))))
|
||||
}
|
||||
|
||||
/** Return an array of all variables, each as an array of properties/segments. */
|
||||
public async variableSegments (template: string | Template[], options: StaticAnalysisOptions = {}): Promise<Array<SegmentArray>> {
|
||||
const analysis = await analyze(isString(template) ? this.parse(template) : template, options)
|
||||
return Array.from(strictUniq(Object.values(analysis.variables).flatMap((a) => a.map((v) => v.toArray()))))
|
||||
}
|
||||
|
||||
/** Return an array of all variables, each as an array of properties/segments. */
|
||||
public variableSegmentsSync (template: string | Template[], options: StaticAnalysisOptions = {}): Array<SegmentArray> {
|
||||
const analysis = analyzeSync(isString(template) ? this.parse(template) : template, options)
|
||||
return Array.from(strictUniq(Object.values(analysis.variables).flatMap((a) => a.map((v) => v.toArray()))))
|
||||
}
|
||||
|
||||
/** Return an array of all expected context variables without their properties. */
|
||||
public async globalVariables (template: string | Template[], options: StaticAnalysisOptions = {}): Promise<string[]> {
|
||||
const analysis = await analyze(isString(template) ? this.parse(template) : template, options)
|
||||
return Object.keys(analysis.globals)
|
||||
}
|
||||
|
||||
/** Return an array of all expected context variables without their properties. */
|
||||
public globalVariablesSync (template: string | Template[], options: StaticAnalysisOptions = {}): string[] {
|
||||
const analysis = analyzeSync(isString(template) ? this.parse(template) : template, options)
|
||||
return Object.keys(analysis.globals)
|
||||
}
|
||||
|
||||
/** Return an array of all expected context variables including their properties/paths. */
|
||||
public async globalFullVariables (template: string | Template[], options: StaticAnalysisOptions = {}): Promise<string[]> {
|
||||
const analysis = await analyze(isString(template) ? this.parse(template) : template, options)
|
||||
return Array.from(new Set(Object.values(analysis.globals).flatMap((a) => a.map((v) => String(v)))))
|
||||
}
|
||||
|
||||
/** Return an array of all expected context variables including their properties/paths. */
|
||||
public globalFullVariablesSync (template: string | Template[], options: StaticAnalysisOptions = {}): string[] {
|
||||
const analysis = analyzeSync(isString(template) ? this.parse(template) : template, options)
|
||||
return Array.from(new Set(Object.values(analysis.globals).flatMap((a) => a.map((v) => String(v)))))
|
||||
}
|
||||
|
||||
/** Return an array of all expected context variables, each as an array of properties/segments. */
|
||||
public async globalVariableSegments (template: string | Template[], options: StaticAnalysisOptions = {}): Promise<Array<SegmentArray>> {
|
||||
const analysis = await analyze(isString(template) ? this.parse(template) : template, options)
|
||||
return Array.from(strictUniq(Object.values(analysis.globals).flatMap((a) => a.map((v) => v.toArray()))))
|
||||
}
|
||||
|
||||
/** Return an array of all expected context variables, each as an array of properties/segments. */
|
||||
public globalVariableSegmentsSync (template: string | Template[], options: StaticAnalysisOptions = {}): Array<SegmentArray> {
|
||||
const analysis = analyzeSync(isString(template) ? this.parse(template) : template, options)
|
||||
return Array.from(strictUniq(Object.values(analysis.globals).flatMap((a) => a.map((v) => v.toArray()))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,9 +391,11 @@ export class Tokenizer {
|
||||
if (this.peek() !== '(') return
|
||||
++this.p
|
||||
const lhs = this.readValueOrThrow()
|
||||
this.p += 2
|
||||
this.skipBlank()
|
||||
this.assert(this.read() === '.' && this.read() === '.', 'invalid range syntax')
|
||||
const rhs = this.readValueOrThrow()
|
||||
++this.p
|
||||
this.skipBlank()
|
||||
this.assert(this.read() === ')', 'invalid range syntax')
|
||||
return new RangeToken(this.input, begin, this.p, lhs, rhs, this.file)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,6 @@ describe('Expression', function () {
|
||||
expect(await toPromise(create('"foo"').evaluate(ctx, false))).toBe('foo')
|
||||
expect(await toPromise(create('false').evaluate(ctx, false))).toBe(false)
|
||||
})
|
||||
it('should eval range expression', async function () {
|
||||
const ctx = new Context({ two: 2 })
|
||||
expect(await toPromise(create('(2..4)').evaluate(ctx, false))).toEqual([2, 3, 4])
|
||||
expect(await toPromise(create('(two..4)').evaluate(ctx, false))).toEqual([2, 3, 4])
|
||||
})
|
||||
it('should eval literal', async function () {
|
||||
expect(await toPromise(create('2.4').evaluate(ctx, false))).toBe(2.4)
|
||||
expect(await toPromise(create('"foo"').evaluate(ctx, false))).toBe('foo')
|
||||
@@ -50,6 +45,16 @@ describe('Expression', function () {
|
||||
expect(await toPromise(create('foo[doo["foo"]]').evaluate(ctx, false))).toBe('BAR')
|
||||
expect(await toPromise(create('doo[coo].foo').evaluate(ctx, false))).toBe('bar')
|
||||
})
|
||||
it('should support drops in property access', async function () {
|
||||
class TemplateDrop extends Drop {
|
||||
valueOf () { return 'bar' }
|
||||
}
|
||||
const ctx = new Context({
|
||||
foo: { bar: 'BAR' },
|
||||
coo: new TemplateDrop()
|
||||
})
|
||||
expect(await toPromise(create('foo[coo]').evaluate(ctx, false))).toBe('BAR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('simple expression', function () {
|
||||
@@ -211,6 +216,30 @@ describe('Expression', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('range', function () {
|
||||
const ctx = new Context({ two: 2, num: { one: 1, two: 2 } })
|
||||
it('should eval range expression', async function () {
|
||||
expect(await toPromise(create('(2..4)').evaluate(ctx, false))).toEqual([2, 3, 4])
|
||||
expect(await toPromise(create('(two..4)').evaluate(ctx, false))).toEqual([2, 3, 4])
|
||||
})
|
||||
it('should allow property access expression as variables', async function () {
|
||||
expect(await toPromise(create('(num.one..num.two)').evaluate(ctx))).toEqual([1, 2])
|
||||
expect(await toPromise(create('(num.one .. two)').evaluate(ctx))).toEqual([1, 2])
|
||||
})
|
||||
it('should allow blanks in range', async function () {
|
||||
expect(await toPromise(create('(3 ..5)').evaluate(ctx))).toEqual([3, 4, 5])
|
||||
expect(await toPromise(create('(3 .. 5)').evaluate(ctx))).toEqual([3, 4, 5])
|
||||
expect(await toPromise(create('( 3 .. 5 )').evaluate(ctx))).toEqual([3, 4, 5])
|
||||
})
|
||||
it('should throw if .. not matched', async function () {
|
||||
expect(() => create('(3.5')).toThrow('invalid range syntax')
|
||||
expect(() => create('(3 5')).toThrow('invalid range syntax')
|
||||
})
|
||||
it('should throw if ( not patched', async function () {
|
||||
expect(() => create('(3..5')).toThrow('invalid range syntax')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync', function () {
|
||||
it('should eval literal', function () {
|
||||
expect(toValueSync(create('2.4').evaluate(ctx, false))).toBe(2.4)
|
||||
|
||||
@@ -2,9 +2,10 @@ import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, Ope
|
||||
import { isRangeToken, isPropertyAccessToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util'
|
||||
import type { Context } from '../context'
|
||||
import type { UnaryOperatorHandler } from '../render'
|
||||
import { Drop } from '../drop'
|
||||
|
||||
export class Expression {
|
||||
private postfix: Token[]
|
||||
readonly postfix: Token[]
|
||||
|
||||
public constructor (tokens: IterableIterator<Token>) {
|
||||
this.postfix = [...toPostfix(tokens)]
|
||||
@@ -42,9 +43,9 @@ export function * evalToken (token: Token | undefined, ctx: Context, lenient = f
|
||||
}
|
||||
|
||||
function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator<unknown> {
|
||||
const props: string[] = []
|
||||
const props: (string | number | Drop)[] = []
|
||||
for (const prop of token.props) {
|
||||
props.push((yield evalToken(prop, ctx, false)) as unknown as string)
|
||||
props.push((yield evalToken(prop, ctx, false)) as unknown as string | number | Drop)
|
||||
}
|
||||
try {
|
||||
if (token.variable) {
|
||||
@@ -66,6 +67,7 @@ export function evalQuotedToken (token: QuotedToken) {
|
||||
function * evalRangeToken (token: RangeToken, ctx: Context) {
|
||||
const low: number = yield evalToken(token.lhs, ctx)
|
||||
const high: number = yield evalToken(token.rhs, ctx)
|
||||
ctx.memoryLimit.use(high - low + 1)
|
||||
return range(+low, +high + 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -58,3 +58,7 @@ function arrayEquals (lhs: any[], rhs: any[]): boolean {
|
||||
if (lhs.length !== rhs.length) return false
|
||||
return !lhs.some((value, i) => !equals(value, rhs[i]))
|
||||
}
|
||||
|
||||
export function arrayIncludes (arr: any[], item: any): boolean {
|
||||
return arr.some(value => equals(value, item))
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export class Render {
|
||||
const html = yield tpl.render(ctx, emitter)
|
||||
// if not, it'll return an `html`, write to the emitter for it
|
||||
html && emitter.write(html)
|
||||
if (emitter['break'] || emitter['continue']) break
|
||||
if (ctx.breakCalled || ctx.continueCalled) break
|
||||
} catch (e) {
|
||||
const err = LiquidError.is(e) ? e : new RenderError(e as Error, tpl)
|
||||
if (ctx.opts.catchAllErrors) errors.push(err)
|
||||
|
||||
+14
-1
@@ -1,11 +1,16 @@
|
||||
import { Value, Liquid, TopLevelToken, TagToken, Context, Tag } from '..'
|
||||
import { Arguments } from '../template'
|
||||
import { IdentifierToken } from '../tokens'
|
||||
|
||||
export default class extends Tag {
|
||||
private key: string
|
||||
private value: Value
|
||||
private identifier: IdentifierToken
|
||||
|
||||
constructor (token: TagToken, remainTokens: TopLevelToken[], liquid: Liquid) {
|
||||
super(token, remainTokens, liquid)
|
||||
this.key = this.tokenizer.readIdentifier().content
|
||||
this.identifier = this.tokenizer.readIdentifier()
|
||||
this.key = this.identifier.content
|
||||
this.tokenizer.assert(this.key, 'expected variable name')
|
||||
|
||||
this.tokenizer.skipBlank()
|
||||
@@ -17,4 +22,12 @@ export default class extends Tag {
|
||||
* render (ctx: Context): Generator<unknown, void, unknown> {
|
||||
ctx.bottom()[this.key] = yield this.value.value(ctx, this.liquid.options.lenientIf)
|
||||
}
|
||||
|
||||
public * arguments (): Arguments {
|
||||
yield this.value
|
||||
}
|
||||
|
||||
public * localScope (): Iterable<IdentifierToken> {
|
||||
yield this.identifier
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -39,7 +39,19 @@ export default class extends Tag {
|
||||
ctx.pop()
|
||||
}
|
||||
return renderChild
|
||||
? (superBlock: BlockDrop, emitter: Emitter) => renderChild(new BlockDrop(() => renderCurrent(superBlock, emitter)), emitter)
|
||||
? (superBlock: BlockDrop, emitter: Emitter) => renderChild(
|
||||
new BlockDrop(
|
||||
(emitter: Emitter) => renderCurrent(superBlock, emitter)
|
||||
),
|
||||
emitter)
|
||||
: renderCurrent
|
||||
}
|
||||
|
||||
public * children (): Generator<unknown, Template[]> {
|
||||
return this.templates
|
||||
}
|
||||
|
||||
public blockScope (): Iterable<string> {
|
||||
return ['block']
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { Context, Emitter, Tag } from '..'
|
||||
|
||||
export default class extends Tag {
|
||||
render (ctx: Context, emitter: Emitter) {
|
||||
emitter['break'] = true
|
||||
render (ctx: Context, _emitter: Emitter) {
|
||||
ctx.breakCalled = true
|
||||
}
|
||||
}
|
||||
|
||||
+20
-8
@@ -1,14 +1,16 @@
|
||||
import { Liquid, Tag, Template, Context, TagToken, TopLevelToken } from '..'
|
||||
import { Parser } from '../parser'
|
||||
import { evalQuotedToken } from '../render'
|
||||
import { IdentifierToken, QuotedToken } from '../tokens'
|
||||
import { isTagToken } from '../util'
|
||||
|
||||
export default class extends Tag {
|
||||
identifier: IdentifierToken | QuotedToken
|
||||
variable: string
|
||||
templates: Template[] = []
|
||||
constructor (tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid, parser: Parser) {
|
||||
super(tagToken, remainTokens, liquid)
|
||||
this.variable = this.readVariableName()
|
||||
this.identifier = this.readVariable()
|
||||
this.variable = this.identifier.content
|
||||
|
||||
while (remainTokens.length) {
|
||||
const token = remainTokens.shift()!
|
||||
@@ -17,16 +19,26 @@ export default class extends Tag {
|
||||
}
|
||||
throw new Error(`tag ${tagToken.getText()} not closed`)
|
||||
}
|
||||
|
||||
private readVariable (): IdentifierToken | QuotedToken {
|
||||
let ident: IdentifierToken | QuotedToken | undefined = this.tokenizer.readIdentifier()
|
||||
if (ident.content) return ident
|
||||
ident = this.tokenizer.readQuoted()
|
||||
if (ident) return ident
|
||||
throw this.tokenizer.error('invalid capture name')
|
||||
}
|
||||
|
||||
* render (ctx: Context): Generator<unknown, void, string> {
|
||||
const r = this.liquid.renderer
|
||||
const html = yield r.renderTemplates(this.templates, ctx)
|
||||
ctx.bottom()[this.variable] = html
|
||||
}
|
||||
private readVariableName () {
|
||||
const word = this.tokenizer.readIdentifier().content
|
||||
if (word) return word
|
||||
const quoted = this.tokenizer.readQuoted()
|
||||
if (quoted) return evalQuotedToken(quoted)
|
||||
throw this.tokenizer.error('invalid capture name')
|
||||
|
||||
public * children (): Generator<unknown, Template[]> {
|
||||
return this.templates
|
||||
}
|
||||
|
||||
public * localScope (): Iterable<string | IdentifierToken | QuotedToken> {
|
||||
yield this.identifier
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ValueToken, Liquid, toValue, evalToken, Value, Emitter, TagToken, TopLevelToken, Context, Template, Tag, ParseStream } from '..'
|
||||
import { Parser } from '../parser'
|
||||
import { equals } from '../render'
|
||||
import { Arguments } from '../template'
|
||||
|
||||
export default class extends Tag {
|
||||
value: Value
|
||||
@@ -71,4 +72,17 @@ export default class extends Tag {
|
||||
yield r.renderTemplates(this.elseTemplates, ctx, emitter)
|
||||
}
|
||||
}
|
||||
|
||||
public * arguments (): Arguments {
|
||||
yield this.value
|
||||
yield * this.branches.flatMap(b => b.values)
|
||||
}
|
||||
|
||||
public * children (): Generator<unknown, Template[]> {
|
||||
const templates = this.branches.flatMap(b => b.templates)
|
||||
if (this.elseTemplates) {
|
||||
templates.push(...this.elseTemplates)
|
||||
}
|
||||
return templates
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tag, Emitter, Context } from '..'
|
||||
|
||||
export default class extends Tag {
|
||||
render (ctx: Context, emitter: Emitter) {
|
||||
emitter['continue'] = true
|
||||
render (ctx: Context, _emitter: Emitter) {
|
||||
ctx.continueCalled = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TopLevelToken, Liquid, ValueToken, evalToken, Emitter, TagToken, Context, Tag } from '..'
|
||||
import { Arguments } from '../template'
|
||||
|
||||
export default class extends Tag {
|
||||
private candidates: ValueToken[] = []
|
||||
@@ -38,4 +39,12 @@ export default class extends Tag {
|
||||
groups[fingerprint] = idx
|
||||
return yield evalToken(candidate, ctx)
|
||||
}
|
||||
|
||||
public * arguments (): Arguments {
|
||||
yield * this.candidates
|
||||
|
||||
if (this.group) {
|
||||
yield this.group
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Tag, Liquid, TopLevelToken, Emitter, TagToken, Context } from '..'
|
||||
import { IdentifierToken } from '../tokens'
|
||||
import { isNumber, stringify } from '../util'
|
||||
|
||||
export default class extends Tag {
|
||||
private identifier: IdentifierToken
|
||||
private variable: string
|
||||
constructor (token: TagToken, remainTokens: TopLevelToken[], liquid: Liquid) {
|
||||
super(token, remainTokens, liquid)
|
||||
this.variable = this.tokenizer.readIdentifier().content
|
||||
this.identifier = this.tokenizer.readIdentifier()
|
||||
this.variable = this.identifier.content
|
||||
}
|
||||
render (context: Context, emitter: Emitter) {
|
||||
const scope = context.environments
|
||||
@@ -14,4 +17,8 @@ export default class extends Tag {
|
||||
}
|
||||
emitter.write(stringify(--scope[this.variable]))
|
||||
}
|
||||
|
||||
public * localScope (): Iterable<string | IdentifierToken> {
|
||||
yield this.identifier
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Liquid, TopLevelToken, Emitter, Value, TagToken, Context, Tag } from '..'
|
||||
import { Arguments } from '../template'
|
||||
|
||||
export default class extends Tag {
|
||||
private value?: Value
|
||||
@@ -15,4 +16,10 @@ export default class extends Tag {
|
||||
const val = yield this.value.value(ctx, false)
|
||||
emitter.write(val)
|
||||
}
|
||||
|
||||
public * arguments (): Arguments {
|
||||
if (this.value) {
|
||||
yield this.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-7
@@ -1,7 +1,8 @@
|
||||
import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
|
||||
import { assertEmpty, toEnumerable } from '../util'
|
||||
import { assertEmpty, isValueToken, toEnumerable } from '../util'
|
||||
import { ForloopDrop } from '../drop/forloop-drop'
|
||||
import { Parser } from '../parser'
|
||||
import { Arguments } from '../template'
|
||||
|
||||
const MODIFIERS = ['offset', 'limit', 'reversed']
|
||||
|
||||
@@ -25,7 +26,7 @@ export default class extends Tag {
|
||||
|
||||
this.variable = variable.content
|
||||
this.collection = collection
|
||||
this.hash = new Hash(this.tokenizer.remaining(), liquid.options.keyValueSeparator)
|
||||
this.hash = new Hash(this.tokenizer, liquid.options.keyValueSeparator)
|
||||
this.templates = []
|
||||
this.elseTemplates = []
|
||||
|
||||
@@ -68,16 +69,36 @@ export default class extends Tag {
|
||||
ctx.push(scope)
|
||||
for (const item of collection) {
|
||||
scope[this.variable] = item
|
||||
ctx.continueCalled = ctx.breakCalled = false
|
||||
yield r.renderTemplates(this.templates, ctx, emitter)
|
||||
if (emitter['break']) {
|
||||
emitter['break'] = false
|
||||
break
|
||||
}
|
||||
emitter['continue'] = false
|
||||
if (ctx.breakCalled) break
|
||||
scope.forloop.next()
|
||||
}
|
||||
ctx.continueCalled = ctx.breakCalled = false
|
||||
ctx.pop()
|
||||
}
|
||||
|
||||
public * children (): Generator<unknown, Template[]> {
|
||||
const templates = this.templates.slice()
|
||||
if (this.elseTemplates) {
|
||||
templates.push(...this.elseTemplates)
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
public * arguments (): Arguments {
|
||||
yield this.collection
|
||||
|
||||
for (const v of Object.values(this.hash.hash)) {
|
||||
if (isValueToken(v)) {
|
||||
yield v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public blockScope (): Iterable<string> {
|
||||
return [this.variable, 'forloop']
|
||||
}
|
||||
}
|
||||
|
||||
function reversed<T> (arr: Array<T>) {
|
||||
|
||||
+15
-2
@@ -1,5 +1,6 @@
|
||||
import { Liquid, Tag, Value, Emitter, isTruthy, TagToken, TopLevelToken, Context, Template } from '..'
|
||||
import { Parser } from '../parser'
|
||||
import { Arguments } from '../template'
|
||||
import { assert, assertEmpty } from '../util'
|
||||
|
||||
export default class extends Tag {
|
||||
@@ -11,13 +12,13 @@ export default class extends Tag {
|
||||
let p: Template[] = []
|
||||
parser.parseStream(remainTokens)
|
||||
.on('start', () => this.branches.push({
|
||||
value: new Value(tagToken.args, this.liquid),
|
||||
value: new Value(tagToken.tokenizer.readFilteredValue(), this.liquid),
|
||||
templates: (p = [])
|
||||
}))
|
||||
.on('tag:elsif', (token: TagToken) => {
|
||||
assert(!this.elseTemplates, 'unexpected elsif after else')
|
||||
this.branches.push({
|
||||
value: new Value(token.args, this.liquid),
|
||||
value: new Value(token.tokenizer.readFilteredValue(), this.liquid),
|
||||
templates: (p = [])
|
||||
})
|
||||
})
|
||||
@@ -44,4 +45,16 @@ export default class extends Tag {
|
||||
}
|
||||
yield r.renderTemplates(this.elseTemplates || [], ctx, emitter)
|
||||
}
|
||||
|
||||
public * children (): Generator<unknown, Template[]> {
|
||||
const templates = this.branches.flatMap(b => b.templates)
|
||||
if (this.elseTemplates) {
|
||||
templates.push(...this.elseTemplates)
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
public arguments (): Arguments {
|
||||
return this.branches.map(b => b.value)
|
||||
}
|
||||
}
|
||||
|
||||
+39
-1
@@ -1,6 +1,8 @@
|
||||
import { Template, ValueToken, TopLevelToken, Liquid, Tag, assert, evalToken, Hash, Emitter, TagToken, Context } from '..'
|
||||
import { BlockMode, Scope } from '../context'
|
||||
import { Parser } from '../parser'
|
||||
import { Argument, Arguments, PartialScope } from '../template'
|
||||
import { isString, isValueToken } from '../util'
|
||||
import { parseFilePath, renderFilePath } from './render'
|
||||
|
||||
export default class extends Tag {
|
||||
@@ -21,7 +23,7 @@ export default class extends Tag {
|
||||
} else tokenizer.p = begin
|
||||
} else tokenizer.p = begin
|
||||
|
||||
this.hash = new Hash(tokenizer.remaining(), liquid.options.jekyllInclude || liquid.options.keyValueSeparator)
|
||||
this.hash = new Hash(tokenizer, liquid.options.jekyllInclude || liquid.options.keyValueSeparator)
|
||||
}
|
||||
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
|
||||
const { liquid, hash, withVar } = this
|
||||
@@ -40,4 +42,40 @@ export default class extends Tag {
|
||||
ctx.pop()
|
||||
ctx.restoreRegister(saved)
|
||||
}
|
||||
|
||||
public * children (partials: boolean, sync: boolean): Generator<unknown, Template[]> {
|
||||
if (partials && isString(this['file'])) {
|
||||
return (yield this.liquid._parsePartialFile(this['file'], sync, this['currentFile'])) as Template[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
public partialScope (): PartialScope | undefined {
|
||||
if (isString(this['file'])) {
|
||||
let names: Array<string | [string, Argument]>
|
||||
|
||||
if (this.liquid.options.jekyllInclude) {
|
||||
names = ['include']
|
||||
} else {
|
||||
names = Object.keys(this.hash.hash)
|
||||
if (this.withVar) {
|
||||
names.push([this['file'], this.withVar])
|
||||
}
|
||||
}
|
||||
|
||||
return { name: this['file'], isolated: false, scope: names }
|
||||
}
|
||||
}
|
||||
|
||||
public * arguments (): Arguments {
|
||||
yield * Object.values(this.hash.hash).filter(isValueToken)
|
||||
|
||||
if (isValueToken(this['file'])) {
|
||||
yield this['file']
|
||||
}
|
||||
|
||||
if (isValueToken(this.withVar)) {
|
||||
yield this.withVar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { isNumber, stringify } from '../util'
|
||||
import { Tag, Liquid, TopLevelToken, Emitter, TagToken, Context } from '..'
|
||||
import { IdentifierToken } from '../tokens'
|
||||
|
||||
export default class extends Tag {
|
||||
private identifier: IdentifierToken
|
||||
private variable: string
|
||||
constructor (token: TagToken, remainTokens: TopLevelToken[], liquid: Liquid) {
|
||||
super(token, remainTokens, liquid)
|
||||
this.variable = this.tokenizer.readIdentifier().content
|
||||
this.identifier = this.tokenizer.readIdentifier()
|
||||
this.variable = this.identifier.content
|
||||
}
|
||||
render (context: Context, emitter: Emitter) {
|
||||
const scope = context.environments
|
||||
@@ -16,4 +19,8 @@ export default class extends Tag {
|
||||
scope[this.variable]++
|
||||
emitter.write(stringify(val))
|
||||
}
|
||||
|
||||
public * localScope (): Iterable<string | IdentifierToken> {
|
||||
yield this.identifier
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -3,6 +3,8 @@ import { BlockMode } from '../context'
|
||||
import { parseFilePath, renderFilePath, ParsedFileName } from './render'
|
||||
import { BlankDrop } from '../drop'
|
||||
import { Parser } from '../parser'
|
||||
import { Arguments, PartialScope } from '../template'
|
||||
import { isString, isValueToken } from '../util'
|
||||
|
||||
export default class extends Tag {
|
||||
args: Hash
|
||||
@@ -12,7 +14,7 @@ export default class extends Tag {
|
||||
super(token, remainTokens, liquid)
|
||||
this.file = parseFilePath(this.tokenizer, this.liquid, parser)
|
||||
this['currentFile'] = token.file
|
||||
this.args = new Hash(this.tokenizer.remaining(), liquid.options.keyValueSeparator)
|
||||
this.args = new Hash(this.tokenizer, liquid.options.keyValueSeparator)
|
||||
this.templates = parser.parseTokens(remainTokens)
|
||||
}
|
||||
* render (ctx: Context, emitter: Emitter): Generator<unknown, unknown, unknown> {
|
||||
@@ -41,4 +43,32 @@ export default class extends Tag {
|
||||
yield renderer.renderTemplates(templates, ctx, emitter)
|
||||
ctx.pop()
|
||||
}
|
||||
|
||||
public * children (partials: boolean): Generator<unknown, Template[]> {
|
||||
const templates = this.templates.slice()
|
||||
|
||||
if (partials && isString(this.file)) {
|
||||
templates.push(...(yield this.liquid._parsePartialFile(this.file, true, this['currentFile'])) as Template[])
|
||||
}
|
||||
|
||||
return templates
|
||||
}
|
||||
|
||||
public * arguments (): Arguments {
|
||||
for (const v of Object.values(this.args.hash)) {
|
||||
if (isValueToken(v)) {
|
||||
yield v
|
||||
}
|
||||
}
|
||||
|
||||
if (isValueToken(this.file)) {
|
||||
yield this.file
|
||||
}
|
||||
}
|
||||
|
||||
public partialScope (): PartialScope | undefined {
|
||||
if (isString(this.file)) {
|
||||
return { name: this.file, isolated: false, scope: Object.keys(this.args.hash) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,8 @@ export default class extends Tag {
|
||||
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
|
||||
yield this.liquid.renderer.renderTemplates(this.templates, ctx, emitter)
|
||||
}
|
||||
|
||||
public * children (): Generator<unknown, Template[]> {
|
||||
return this.templates
|
||||
}
|
||||
}
|
||||
|
||||
+58
-2
@@ -1,8 +1,9 @@
|
||||
import { __assign } from 'tslib'
|
||||
import { ForloopDrop } from '../drop'
|
||||
import { toEnumerable } from '../util'
|
||||
import { isString, isValueToken, toEnumerable } from '../util'
|
||||
import { TopLevelToken, assert, Liquid, Token, Template, evalQuotedToken, TypeGuards, Tokenizer, evalToken, Hash, Emitter, TagToken, Context, Tag } from '..'
|
||||
import { Parser } from '../parser'
|
||||
import { Argument, Arguments, PartialScope } from '../template'
|
||||
|
||||
export type ParsedFileName = Template[] | Token | string | undefined
|
||||
|
||||
@@ -45,7 +46,7 @@ export default class extends Tag {
|
||||
tokenizer.p = begin
|
||||
break
|
||||
}
|
||||
this.hash = new Hash(tokenizer.remaining(), liquid.options.keyValueSeparator)
|
||||
this.hash = new Hash(tokenizer, liquid.options.keyValueSeparator)
|
||||
}
|
||||
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
|
||||
const { liquid, hash } = this
|
||||
@@ -75,6 +76,61 @@ export default class extends Tag {
|
||||
yield liquid.renderer.renderTemplates(templates, childCtx, emitter)
|
||||
}
|
||||
}
|
||||
|
||||
public * children (partials: boolean, sync: boolean): Generator<unknown, Template[]> {
|
||||
if (partials && isString(this['file'])) {
|
||||
return (yield this.liquid._parsePartialFile(this['file'], sync, this['currentFile'])) as Template[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
public partialScope (): PartialScope | undefined {
|
||||
if (isString(this['file'])) {
|
||||
const names: Array<string | [string, Argument]> = Object.keys(this.hash.hash)
|
||||
|
||||
if (this['with']) {
|
||||
const { value, alias } = this['with']
|
||||
if (isString(alias)) {
|
||||
names.push([alias, value])
|
||||
} else if (isString(this.file)) {
|
||||
names.push([this.file, value])
|
||||
}
|
||||
}
|
||||
|
||||
if (this['for']) {
|
||||
const { value, alias } = this['for']
|
||||
if (isString(alias)) {
|
||||
names.push([alias, value])
|
||||
} else if (isString(this.file)) {
|
||||
names.push([this.file, value])
|
||||
}
|
||||
}
|
||||
|
||||
return { name: this['file'], isolated: true, scope: names }
|
||||
}
|
||||
}
|
||||
|
||||
public * arguments (): Arguments {
|
||||
for (const v of Object.values(this.hash.hash)) {
|
||||
if (isValueToken(v)) {
|
||||
yield v
|
||||
}
|
||||
}
|
||||
|
||||
if (this['with']) {
|
||||
const { value } = this['with']
|
||||
if (isValueToken(value)) {
|
||||
yield value
|
||||
}
|
||||
}
|
||||
|
||||
if (this['for']) {
|
||||
const { value } = this['for']
|
||||
if (isValueToken(value)) {
|
||||
yield value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+21
-2
@@ -1,7 +1,8 @@
|
||||
import { toEnumerable } from '../util'
|
||||
import { isValueToken, toEnumerable } from '../util'
|
||||
import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
|
||||
import { TablerowloopDrop } from '../drop/tablerowloop-drop'
|
||||
import { Parser } from '../parser'
|
||||
import { Arguments } from '../template'
|
||||
|
||||
export default class extends Tag {
|
||||
variable: string
|
||||
@@ -21,7 +22,7 @@ export default class extends Tag {
|
||||
|
||||
this.variable = variable.content
|
||||
this.collection = collectionToken
|
||||
this.args = new Hash(this.tokenizer.remaining(), liquid.options.keyValueSeparator)
|
||||
this.args = new Hash(this.tokenizer, liquid.options.keyValueSeparator)
|
||||
this.templates = []
|
||||
|
||||
let p
|
||||
@@ -63,4 +64,22 @@ export default class extends Tag {
|
||||
if (collection.length) emitter.write('</tr>')
|
||||
ctx.pop()
|
||||
}
|
||||
|
||||
public * children (): Generator<unknown, Template[]> {
|
||||
return this.templates
|
||||
}
|
||||
|
||||
public * arguments (): Arguments {
|
||||
yield this.collection
|
||||
|
||||
for (const v of Object.values(this.args.hash)) {
|
||||
if (isValueToken(v)) {
|
||||
yield v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public blockScope (): string[] {
|
||||
return [this.variable, 'tablerowloop']
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -1,5 +1,6 @@
|
||||
import { Liquid, Tag, Value, TopLevelToken, Template, Emitter, isTruthy, isFalsy, Context, TagToken } from '..'
|
||||
import { Parser } from '../parser'
|
||||
import { Arguments } from '../template'
|
||||
|
||||
export default class extends Tag {
|
||||
branches: { value: Value, test: (val: any, ctx: Context) => boolean, templates: Template[] }[] = []
|
||||
@@ -10,7 +11,7 @@ export default class extends Tag {
|
||||
let elseCount = 0
|
||||
parser.parseStream(remainTokens)
|
||||
.on('start', () => this.branches.push({
|
||||
value: new Value(tagToken.args, this.liquid),
|
||||
value: new Value(tagToken.tokenizer.readFilteredValue(), this.liquid),
|
||||
test: isFalsy,
|
||||
templates: (p = [])
|
||||
}))
|
||||
@@ -20,7 +21,7 @@ export default class extends Tag {
|
||||
return
|
||||
}
|
||||
this.branches.push({
|
||||
value: new Value(token.args, this.liquid),
|
||||
value: new Value(token.tokenizer.readFilteredValue(), this.liquid),
|
||||
test: isTruthy,
|
||||
templates: (p = [])
|
||||
})
|
||||
@@ -52,4 +53,16 @@ export default class extends Tag {
|
||||
|
||||
yield r.renderTemplates(this.elseTemplates, ctx, emitter)
|
||||
}
|
||||
|
||||
public * children (): Generator<unknown, Template[]> {
|
||||
const children = this.branches.flatMap(b => b.templates)
|
||||
if (this.elseTemplates) {
|
||||
children.push(...this.elseTemplates)
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
public arguments (): Arguments {
|
||||
return this.branches.map(b => b.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Variable, VariableMap } from './analysis'
|
||||
|
||||
describe('Analysis variable', () => {
|
||||
const mockLocation = { row: 1, col: 1, file: undefined }
|
||||
|
||||
it('should coerce to a string', () => {
|
||||
const v = new Variable(['foo', 'bar'], mockLocation)
|
||||
expect(String(v)).toBe('foo.bar')
|
||||
})
|
||||
|
||||
it('should represent nested variables', () => {
|
||||
const nested = new Variable(['bar', 1], mockLocation)
|
||||
const v = new Variable(['foo', nested], mockLocation)
|
||||
expect(`${v}`).toBe('foo[bar[1]]')
|
||||
})
|
||||
|
||||
it('should represent bracketed segments', () => {
|
||||
const v = new Variable(['foo', 'bar baz'], mockLocation)
|
||||
expect(`${v}`).toBe("foo['bar baz']")
|
||||
})
|
||||
|
||||
it('should represent bracketed root', () => {
|
||||
const v = new Variable(['foo bar'], mockLocation)
|
||||
expect(`${v}`).toBe("['foo bar']")
|
||||
})
|
||||
|
||||
it('should have a segments property', () => {
|
||||
const v = new Variable(['foo', 'bar'], mockLocation)
|
||||
expect(v.segments).toStrictEqual(['foo', 'bar'])
|
||||
})
|
||||
|
||||
it('should have a location property', () => {
|
||||
const v = new Variable(['foo', 'bar'], mockLocation)
|
||||
expect(v.location).toStrictEqual(mockLocation)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variable map', () => {
|
||||
it('should coerce variables to their string representation', () => {
|
||||
const v = new Variable(['foo', 'bar'], { row: 1, col: 1, file: undefined })
|
||||
const mapping = new VariableMap()
|
||||
mapping.push(v)
|
||||
expect(mapping.has(v)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return an empty array if a variable is not in the map', () => {
|
||||
const v = new Variable(['foo', 'bar'], { row: 1, col: 1, file: undefined })
|
||||
const mapping = new VariableMap()
|
||||
expect(mapping.get(v)).toStrictEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,448 @@
|
||||
import { Argument, Template, Value } from '.'
|
||||
import { isKeyValuePair } from '../parser/filter-arg'
|
||||
import { PropertyAccessToken, ValueToken } from '../tokens'
|
||||
import {
|
||||
isNumberToken,
|
||||
isPropertyAccessToken,
|
||||
isQuotedToken,
|
||||
isRangeToken,
|
||||
isString,
|
||||
isValueToken,
|
||||
isWordToken,
|
||||
toPromise,
|
||||
toValueSync
|
||||
} from '../util'
|
||||
|
||||
/**
|
||||
* Row, column and file name where a variable was found.
|
||||
*/
|
||||
export interface VariableLocation {
|
||||
row: number;
|
||||
col: number;
|
||||
file?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A variable's segments as an array, possibly with nested arrays of segments.
|
||||
*/
|
||||
export type SegmentArray = Array<string | number | SegmentArray>
|
||||
|
||||
/**
|
||||
* A variable's segments and location, which can be coerced to a string.
|
||||
*/
|
||||
export class Variable {
|
||||
constructor (
|
||||
readonly segments: Array<string | number | Variable>,
|
||||
readonly location: VariableLocation
|
||||
) {}
|
||||
|
||||
public toString (): string {
|
||||
return segmentsString(this.segments, true)
|
||||
}
|
||||
|
||||
/** Return this variable's segments as an array, possibly with nested arrays for nested paths. */
|
||||
public toArray (): SegmentArray {
|
||||
function * _visit (...segments: Array<string | number | Variable>): Generator<string | number | SegmentArray> {
|
||||
for (const segment of segments) {
|
||||
if (segment instanceof Variable) {
|
||||
yield Array.from(_visit(...segment.segments))
|
||||
} else {
|
||||
yield segment
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(_visit(...this.segments))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Property names and array indexes that make up a path to a variable.
|
||||
*/
|
||||
export type VariableSegments = Array<string | number | Variable>;
|
||||
|
||||
/**
|
||||
* A mapping of variable names to an array of locations at which the variable was found.
|
||||
*/
|
||||
export type Variables = { [key: string]: Variable[] };
|
||||
|
||||
/**
|
||||
* Group variables by the string representation of their root segment.
|
||||
*/
|
||||
export class VariableMap {
|
||||
private map: Map<string, Variable[]>
|
||||
|
||||
constructor () {
|
||||
this.map = new Map()
|
||||
}
|
||||
|
||||
public get (key: Variable): Variable[] {
|
||||
const k = segmentsString([key.segments[0]])
|
||||
if (!this.map.has(k)) {
|
||||
this.map.set(k, [])
|
||||
}
|
||||
return this.map.get(k) as Variable[]
|
||||
}
|
||||
|
||||
public has (key: Variable): boolean {
|
||||
return this.map.has(segmentsString([key.segments[0]]))
|
||||
}
|
||||
|
||||
public push (variable: Variable): void {
|
||||
this.get(variable).push(variable)
|
||||
}
|
||||
|
||||
public asObject (): Variables {
|
||||
return Object.fromEntries(this.map)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of calling `analyze()` or `analyzeSync()`.
|
||||
*/
|
||||
export interface StaticAnalysis {
|
||||
/**
|
||||
* All variables, whether they are in scope or not. Including references to names
|
||||
* such as `forloop` from the `for` tag.
|
||||
*/
|
||||
variables: Variables;
|
||||
|
||||
/**
|
||||
* Variables that are not in scope. These could be a "global" variables that are
|
||||
* expected to be provided by the application developer, or possible mistakes
|
||||
* from the template author.
|
||||
*
|
||||
* If a variable is referenced before and after assignment, you should expect
|
||||
* that variable to be included in `globals`, `variables` and `locals`, each with
|
||||
* a different location.
|
||||
*/
|
||||
globals: Variables;
|
||||
|
||||
/**
|
||||
* Template variables that are added to the template local scope using tags like
|
||||
* `assign`, `capture` or `increment`.
|
||||
*/
|
||||
locals: Variables;
|
||||
}
|
||||
|
||||
export interface StaticAnalysisOptions {
|
||||
/**
|
||||
* When `true` (the default), try to load partial templates and analyze them too.
|
||||
*/
|
||||
partials?: boolean;
|
||||
}
|
||||
|
||||
export const defaultStaticAnalysisOptions: StaticAnalysisOptions = {
|
||||
partials: true
|
||||
}
|
||||
|
||||
function * _analyze (templates: Template[], partials: boolean, sync: boolean): Generator<unknown, StaticAnalysis> {
|
||||
const variables = new VariableMap()
|
||||
const globals = new VariableMap()
|
||||
const locals = new VariableMap()
|
||||
|
||||
const rootScope = new DummyScope(new Set())
|
||||
|
||||
// Names of partial templates that we've already analyzed.
|
||||
const seen: Set<string | undefined> = new Set()
|
||||
|
||||
function updateVariables (variable: Variable, scope: DummyScope) {
|
||||
variables.push(variable)
|
||||
const aliased = scope.alias(variable)
|
||||
|
||||
if (aliased !== undefined) {
|
||||
const root = aliased.segments[0]
|
||||
// TODO: What if a a template renders a rendered template? Do we need scope.parent?
|
||||
if (isString(root) && !rootScope.has(root)) {
|
||||
globals.push(aliased)
|
||||
}
|
||||
} else {
|
||||
const root = variable.segments[0]
|
||||
if (isString(root) && !scope.has(root)) {
|
||||
globals.push(variable)
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse for nested Variables
|
||||
for (const segment of variable.segments) {
|
||||
if (segment instanceof Variable) {
|
||||
updateVariables(segment, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function * visit (template: Template, scope: DummyScope): Generator<unknown, void> {
|
||||
if (template.arguments) {
|
||||
for (const arg of template.arguments()) {
|
||||
for (const variable of extractVariables(arg)) {
|
||||
updateVariables(variable, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (template.localScope) {
|
||||
for (const ident of template.localScope()) {
|
||||
scope.add(ident.content)
|
||||
scope.deleteAlias(ident.content)
|
||||
const [row, col] = ident.getPosition()
|
||||
locals.push(new Variable([ident.content], { row, col, file: ident.file }))
|
||||
}
|
||||
}
|
||||
|
||||
if (template.children) {
|
||||
if (template.partialScope) {
|
||||
const partial = template.partialScope()
|
||||
|
||||
if (partial === undefined) {
|
||||
// Layouts, for example, can have children that are not partials.
|
||||
for (const child of (yield template.children(partials, sync)) as Template[]) {
|
||||
yield visit(child, scope)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (seen.has(partial.name)) return
|
||||
|
||||
const partialScopeNames: Set<string> = new Set()
|
||||
const partialScope = partial.isolated
|
||||
? new DummyScope(partialScopeNames)
|
||||
: scope.push(partialScopeNames)
|
||||
|
||||
for (const name of partial.scope) {
|
||||
if (isString(name)) {
|
||||
partialScopeNames.add(name)
|
||||
} else {
|
||||
const [alias, argument] = name
|
||||
partialScopeNames.add(alias)
|
||||
const variables = Array.from(extractVariables(argument))
|
||||
if (variables.length) {
|
||||
partialScope.setAlias(alias, variables[0].segments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of (yield template.children(partials, sync)) as Template[]) {
|
||||
yield visit(child, partialScope)
|
||||
seen.add(partial.name)
|
||||
}
|
||||
|
||||
partialScope.pop()
|
||||
} else {
|
||||
if (template.blockScope) {
|
||||
scope.push(new Set(template.blockScope()))
|
||||
}
|
||||
|
||||
for (const child of (yield template.children(partials, sync)) as Template[]) {
|
||||
yield visit(child, scope)
|
||||
}
|
||||
|
||||
if (template.blockScope) {
|
||||
scope.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const template of templates) {
|
||||
yield visit(template, rootScope)
|
||||
}
|
||||
|
||||
return {
|
||||
variables: variables.asObject(),
|
||||
globals: globals.asObject(),
|
||||
locals: locals.asObject()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statically analyze a template and report variable usage.
|
||||
*/
|
||||
export function analyze (template: Template[], options: StaticAnalysisOptions = {}): Promise<StaticAnalysis> {
|
||||
const opts = { ...defaultStaticAnalysisOptions, ...options } as Required<StaticAnalysisOptions>
|
||||
return toPromise(_analyze(template, opts.partials, false))
|
||||
}
|
||||
|
||||
/**
|
||||
* Statically analyze a template and report variable usage.
|
||||
*/
|
||||
export function analyzeSync (template: Template[], options: StaticAnalysisOptions = {}): StaticAnalysis {
|
||||
const opts = { ...defaultStaticAnalysisOptions, ...options } as Required<StaticAnalysisOptions>
|
||||
return toValueSync(_analyze(template, opts.partials, true))
|
||||
}
|
||||
|
||||
interface ScopeStackItem {
|
||||
names: Set<string>;
|
||||
aliases: Map<string, VariableSegments>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stack to manage scopes while traversing templates during static analysis.
|
||||
*/
|
||||
class DummyScope {
|
||||
private stack: Array<ScopeStackItem>
|
||||
|
||||
constructor (globals: Set<string>) {
|
||||
this.stack = [{ names: globals, aliases: new Map() }]
|
||||
}
|
||||
|
||||
/** Return true if `name` is in scope. */
|
||||
public has (name: string): boolean {
|
||||
for (const scope of this.stack) {
|
||||
if (scope.names.has(name)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public push (scope: Set<string>): DummyScope {
|
||||
this.stack.push({ names: scope, aliases: new Map() })
|
||||
return this
|
||||
}
|
||||
|
||||
public pop (): Set<string> | undefined {
|
||||
return this.stack.pop()?.names
|
||||
}
|
||||
|
||||
// Add a name to the template scope.
|
||||
public add (name: string): void {
|
||||
this.stack[0].names.add(name)
|
||||
}
|
||||
|
||||
/** Return the variable that `variable` aliases, or `variable` if it doesn't alias anything. */
|
||||
public alias (variable: Variable): Variable | undefined {
|
||||
const root = variable.segments[0]
|
||||
if (!isString(root)) return undefined
|
||||
const alias = this.getAlias(root)
|
||||
if (alias === undefined) return undefined
|
||||
return new Variable([...alias, ...variable.segments.slice(1)], variable.location)
|
||||
}
|
||||
|
||||
// TODO: `from` could be a path with multiple segments, like `include.x`.
|
||||
public setAlias (from: string, to: VariableSegments): void {
|
||||
this.stack[this.stack.length - 1].aliases.set(from, to)
|
||||
}
|
||||
|
||||
public deleteAlias (name: string): void {
|
||||
this.stack[this.stack.length - 1].aliases.delete(name)
|
||||
}
|
||||
|
||||
private getAlias (name: string): VariableSegments | undefined {
|
||||
for (const scope of this.stack) {
|
||||
if (scope.aliases.has(name)) {
|
||||
return scope.aliases.get(name)
|
||||
}
|
||||
|
||||
// If a scope has defined `name`, then it masks aliases in parent scopes.
|
||||
if (scope.names.has(name)) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function * extractVariables (value: Argument): Generator<Variable> {
|
||||
if (isValueToken(value)) {
|
||||
yield * extractValueTokenVariables(value)
|
||||
} else if (value instanceof Value) {
|
||||
yield * extractFilteredValueVariables(value)
|
||||
}
|
||||
}
|
||||
|
||||
function * extractFilteredValueVariables (value: Value): Generator<Variable> {
|
||||
for (const token of value.initial.postfix) {
|
||||
if (isValueToken(token)) {
|
||||
yield * extractValueTokenVariables(token)
|
||||
}
|
||||
}
|
||||
|
||||
for (const filter of value.filters) {
|
||||
for (const arg of filter.args) {
|
||||
if (isKeyValuePair(arg) && arg[1]) {
|
||||
yield * extractValueTokenVariables(arg[1])
|
||||
} else if (isValueToken(arg)) {
|
||||
yield * extractValueTokenVariables(arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function * extractValueTokenVariables (token: ValueToken): Generator<Variable> {
|
||||
if (isRangeToken(token)) {
|
||||
yield * extractValueTokenVariables(token.lhs)
|
||||
yield * extractValueTokenVariables(token.rhs)
|
||||
} else if (isPropertyAccessToken(token)) {
|
||||
yield extractPropertyAccessVariable(token)
|
||||
}
|
||||
}
|
||||
|
||||
function extractPropertyAccessVariable (token: PropertyAccessToken): Variable {
|
||||
const segments: VariableSegments = []
|
||||
|
||||
// token is not guaranteed to have `file` set. We'll try to get it from a prop if not.
|
||||
let file: string | undefined = token.file
|
||||
|
||||
// Here we're flattening the first segment of a path if it is a nested path.
|
||||
const root = token.props[0]
|
||||
file = file || root.file
|
||||
if (isQuotedToken(root) || isNumberToken(root) || isWordToken(root)) {
|
||||
segments.push(root.content)
|
||||
} else if (isPropertyAccessToken(root)) {
|
||||
// Flatten paths that start with a nested path.
|
||||
segments.push(...extractPropertyAccessVariable(root).segments)
|
||||
}
|
||||
|
||||
for (const prop of token.props.slice(1)) {
|
||||
file = file || prop.file
|
||||
if (isQuotedToken(prop) || isNumberToken(prop) || isWordToken(prop)) {
|
||||
segments.push(prop.content)
|
||||
} else if (isPropertyAccessToken(prop)) {
|
||||
segments.push(extractPropertyAccessVariable(prop))
|
||||
}
|
||||
}
|
||||
|
||||
const [row, col] = token.getPosition()
|
||||
return new Variable(segments, {
|
||||
row,
|
||||
col,
|
||||
file
|
||||
})
|
||||
}
|
||||
|
||||
// This is used to detect segments that can be represented with dot notation
|
||||
// when creating a string representation of VariableSegments.
|
||||
const RE_PROPERTY = /^[\u0080-\uFFFFa-zA-Z_][\u0080-\uFFFFa-zA-Z0-9_-]*$/
|
||||
|
||||
/**
|
||||
* Return a string representation of segments using dot notation where possible.
|
||||
* @param segments - The property names and array indices that make up a path to a variable.
|
||||
* @param bracketedRoot - If false (the default), don't surround the root segment with square brackets.
|
||||
*/
|
||||
function segmentsString (segments: VariableSegments, bracketedRoot = false): string {
|
||||
const buf: string[] = []
|
||||
|
||||
const root = segments[0]
|
||||
if (isString(root)) {
|
||||
if (!bracketedRoot || root.match(RE_PROPERTY)) {
|
||||
buf.push(`${root}`)
|
||||
} else {
|
||||
buf.push(`['${root}']`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const segment of segments.slice(1)) {
|
||||
if (segment instanceof Variable) {
|
||||
buf.push(`[${segmentsString(segment.segments)}]`)
|
||||
} else if (isString(segment)) {
|
||||
if (segment.match(RE_PROPERTY)) {
|
||||
buf.push(`.${segment}`)
|
||||
} else {
|
||||
buf.push(`['${segment}']`)
|
||||
}
|
||||
} else {
|
||||
buf.push(`[${segment}]`)
|
||||
}
|
||||
}
|
||||
|
||||
return buf.join('')
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { toPromise } from '../util'
|
||||
import { Hash } from './hash'
|
||||
import { Context } from '../context'
|
||||
import { Tokenizer } from '../parser'
|
||||
|
||||
describe('Hash', function () {
|
||||
it('should parse "reverse"', async function () {
|
||||
@@ -43,4 +44,10 @@ describe('Hash', function () {
|
||||
const hash = await toPromise(new Hash('num=2.3', '=').render(new Context()))
|
||||
expect(hash.num).toBe(2.3)
|
||||
})
|
||||
it('should accept an existing tokenizer', async function () {
|
||||
const tokenizer = new Tokenizer('a:1, b:2')
|
||||
const hash = await toPromise(new Hash(tokenizer).render(new Context()))
|
||||
expect(hash.a).toBe(1)
|
||||
expect(hash.b).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,12 +15,14 @@ type HashValueTokens = Record<string, Token | undefined>
|
||||
*/
|
||||
export class Hash {
|
||||
hash: HashValueTokens = {}
|
||||
constructor (markup: string, jekyllStyle?: boolean | string) {
|
||||
const tokenizer = new Tokenizer(markup, {})
|
||||
|
||||
constructor (input: string | Tokenizer, jekyllStyle?: boolean | string) {
|
||||
const tokenizer = input instanceof Tokenizer ? input : new Tokenizer(input, {})
|
||||
for (const hash of tokenizer.readHashes(jekyllStyle)) {
|
||||
this.hash[hash.name.content] = hash.value
|
||||
}
|
||||
}
|
||||
|
||||
* render (ctx: Context): Generator<unknown, Record<string, any>, unknown> {
|
||||
const hash = {}
|
||||
for (const key of Object.keys(this.hash)) {
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './hash'
|
||||
export * from './value'
|
||||
export * from './output'
|
||||
export * from './html'
|
||||
export * from './analysis'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Value } from './value'
|
||||
import { Template, TemplateImpl } from '../template'
|
||||
import { Arguments, Template, TemplateImpl } from '../template'
|
||||
import { Context } from '../context/context'
|
||||
import { Emitter } from '../emitters/emitter'
|
||||
import { OutputToken } from '../tokens/output-token'
|
||||
@@ -25,4 +25,8 @@ export class Output extends TemplateImpl<OutputToken> implements Template {
|
||||
const val = yield this.value.value(ctx, false)
|
||||
emitter.write(val)
|
||||
}
|
||||
|
||||
public * arguments (): Arguments {
|
||||
yield this.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
import { Context } from '../context/context'
|
||||
import { Token } from '../tokens/token'
|
||||
import { Emitter } from '../emitters/emitter'
|
||||
import { IdentifierToken, QuotedToken, ValueToken } from '../tokens'
|
||||
import { Value } from './value'
|
||||
|
||||
export type Argument = Value | ValueToken
|
||||
export type Arguments = Iterable<Argument>
|
||||
|
||||
/** Scope information used when analyzing partial templates. */
|
||||
export interface PartialScope {
|
||||
/**
|
||||
* The name of the partial template. We need this to make sure we only analyze
|
||||
* each template once.
|
||||
* */
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* If `true`, names in `scope` will be added to a new, isolated scope before
|
||||
* analyzing any child templates, without access to the parent template's scope.
|
||||
*/
|
||||
isolated: boolean;
|
||||
|
||||
/**
|
||||
* A list of names that will be in scope for the child template.
|
||||
*
|
||||
* If an item is a [string, Argument] tuple, the string is considered an alias
|
||||
* for the argument.
|
||||
*/
|
||||
scope: Iterable<string | [string, Argument]>;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
token: Token;
|
||||
render(ctx: Context, emitter: Emitter): any;
|
||||
children?(partials: boolean, sync: boolean): Generator<unknown, Template[]> ;
|
||||
arguments?(): Arguments;
|
||||
blockScope?(): Iterable<string>;
|
||||
localScope?(): Iterable<IdentifierToken | QuotedToken>;
|
||||
partialScope?(): PartialScope | undefined;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export class Value {
|
||||
this.initial = token.initial
|
||||
this.filters = token.filters.map(token => new Filter(token, this.getFilter(liquid, token.name), liquid))
|
||||
}
|
||||
|
||||
public * value (ctx: Context, lenient?: boolean): Generator<unknown, unknown, unknown> {
|
||||
lenient = lenient || (ctx.opts.lenientIf && this.filters.length > 0 && this.filters[0].name === 'default')
|
||||
let val = yield this.initial.evaluate(ctx, lenient)
|
||||
@@ -29,6 +30,7 @@ export class Value {
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
private getFilter (liquid: Liquid, name: string) {
|
||||
const impl = liquid.filters[name]
|
||||
assert(impl || !liquid.options.strictFilters, () => `undefined filter: ${name}`)
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Tokenizer, TokenKind } from '../parser'
|
||||
*/
|
||||
export class LiquidTagToken extends DelimitedToken {
|
||||
public name: string
|
||||
public args: string
|
||||
public tokenizer: Tokenizer
|
||||
public constructor (
|
||||
input: string,
|
||||
@@ -17,12 +16,13 @@ export class LiquidTagToken extends DelimitedToken {
|
||||
file?: string
|
||||
) {
|
||||
super(TokenKind.Tag, [begin, end], input, begin, end, false, false, file)
|
||||
|
||||
this.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange)
|
||||
this.name = this.tokenizer.readTagName()
|
||||
this.tokenizer.assert(this.name, 'illegal liquid tag syntax')
|
||||
|
||||
this.tokenizer.skipBlank()
|
||||
this.args = this.tokenizer.remaining()
|
||||
}
|
||||
|
||||
get args (): string {
|
||||
return this.tokenizer.input.slice(this.tokenizer.p, this.contentRange[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { NormalizedFullOptions } from '../liquid-options'
|
||||
export class TagToken extends DelimitedToken {
|
||||
public name: string
|
||||
public tokenizer: Tokenizer
|
||||
public readonly args: string;
|
||||
public constructor (
|
||||
input: string,
|
||||
begin: number,
|
||||
@@ -20,8 +21,6 @@ export class TagToken extends DelimitedToken {
|
||||
this.name = this.tokenizer.readTagName()
|
||||
this.tokenizer.assert(this.name, `illegal tag syntax, tag name expected`)
|
||||
this.tokenizer.skipBlank()
|
||||
}
|
||||
get args (): string {
|
||||
return this.tokenizer.input.slice(this.tokenizer.p, this.contentRange[1])
|
||||
this.args = this.tokenizer.input.slice(this.tokenizer.p, this.contentRange[1])
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,5 +1,4 @@
|
||||
import { assert } from './assert'
|
||||
import { toNumber } from './underscore'
|
||||
|
||||
export class Limiter {
|
||||
private message: string
|
||||
@@ -10,12 +9,12 @@ export class Limiter {
|
||||
this.limit = limit
|
||||
}
|
||||
use (count: number) {
|
||||
count = toNumber(count)
|
||||
count = +count || 0
|
||||
assert(this.base + count <= this.limit, this.message)
|
||||
this.base += count
|
||||
}
|
||||
check (count: number) {
|
||||
count = toNumber(count)
|
||||
count = +count || 0
|
||||
assert(count <= this.limit, this.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { isString } from './underscore'
|
||||
|
||||
// one minute in milliseconds
|
||||
const OneMinute = 60000
|
||||
const ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/
|
||||
/**
|
||||
* Need support both ISO8601 and RFC2822 as in major browsers & NodeJS
|
||||
* RFC2822: https://datatracker.ietf.org/doc/html/rfc2822#section-3.3
|
||||
*/
|
||||
const TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):?(\d{2}))$/
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
|
||||
'September', 'October', 'November', 'December'
|
||||
@@ -126,7 +130,7 @@ export class LiquidDate {
|
||||
* Given that a template is expected to be parsed fewer times than rendered.
|
||||
*/
|
||||
static createDateFixedToTimezone (dateString: string, locale: string): LiquidDate {
|
||||
const m = dateString.match(ISO8601_TIMEZONE_PATTERN)
|
||||
const m = dateString.match(TIMEZONE_PATTERN)
|
||||
// representing a UTC timestamp
|
||||
if (m && m[1] === 'Z') {
|
||||
return new LiquidDate(+new Date(dateString), locale, 0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RangeToken, NumberToken, QuotedToken, LiteralToken, PropertyAccessToken, OutputToken, HTMLToken, TagToken, IdentifierToken, DelimitedToken, OperatorToken } from '../tokens'
|
||||
import { RangeToken, NumberToken, QuotedToken, LiteralToken, PropertyAccessToken, OutputToken, HTMLToken, TagToken, IdentifierToken, DelimitedToken, OperatorToken, ValueToken } from '../tokens'
|
||||
import { TokenKind } from '../parser'
|
||||
|
||||
export function isDelimitedToken (val: any): val is DelimitedToken {
|
||||
@@ -45,6 +45,11 @@ export function isRangeToken (val: any): val is RangeToken {
|
||||
return getKind(val) === TokenKind.Range
|
||||
}
|
||||
|
||||
export function isValueToken (val: any): val is ValueToken {
|
||||
// valueTokenBitMask = TokenKind.Number | TokenKind.Literal | TokenKind.Quoted | TokenKind.PropertyAccess | TokenKind.Range
|
||||
return (getKind(val) & 1667) > 0
|
||||
}
|
||||
|
||||
function getKind (val: any) {
|
||||
return val ? val.kind : -1
|
||||
}
|
||||
|
||||
+22
-2
@@ -67,8 +67,7 @@ export function toValue (value: any): any {
|
||||
}
|
||||
|
||||
export function toNumber (value: any): number {
|
||||
value = Number(value)
|
||||
return isNaN(value) ? 0 : value
|
||||
return +toValue(value) || 0
|
||||
}
|
||||
|
||||
export function isNumber (value: any): value is number {
|
||||
@@ -93,6 +92,10 @@ export function isArray (value: any): value is any[] {
|
||||
return toString.call(value) === '[object Array]'
|
||||
}
|
||||
|
||||
export function isArrayLike (value: any): value is any[] {
|
||||
return value && isNumber(value.length)
|
||||
}
|
||||
|
||||
export function isIterable (value: any): value is Iterable<any> {
|
||||
return isObject(value) && Symbol.iterator in value
|
||||
}
|
||||
@@ -187,6 +190,23 @@ export function argumentsToValue<F extends (...args: any) => any, T> (fn: F) {
|
||||
return function (this: T, ...args: Parameters<F>) { return fn.call(this, ...args.map(toValue)) }
|
||||
}
|
||||
|
||||
export function argumentsToNumber<F extends (...args: any) => any, T> (fn: F) {
|
||||
return function (this: T, ...args: Parameters<F>) { return fn.call(this, ...args.map(toNumber)) }
|
||||
}
|
||||
|
||||
export function escapeRegExp (text: string) {
|
||||
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
|
||||
}
|
||||
|
||||
/** Return an array containing unique elements from _array_. Works with nested arrays and objects. */
|
||||
export function * strictUniq<T> (array: Array<T>): Generator<T> {
|
||||
const seen = new Set()
|
||||
|
||||
for (const element of array) {
|
||||
const key = JSON.stringify(element)
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
yield element
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-16
@@ -1,25 +1,37 @@
|
||||
import { Drop, Liquid } from '../..'
|
||||
|
||||
class SettingsDrop extends Drop {
|
||||
public foo = 'FOO'
|
||||
public bar () {
|
||||
return 'BAR'
|
||||
}
|
||||
public liquidMethodMissing (key: string) {
|
||||
return key.toUpperCase()
|
||||
}
|
||||
}
|
||||
import { Context, Drop, Liquid } from '../..'
|
||||
|
||||
describe('drop', function () {
|
||||
const settings = new SettingsDrop()
|
||||
let engine: Liquid
|
||||
beforeEach(function () {
|
||||
engine = new Liquid()
|
||||
})
|
||||
it('should support liquidMethodMissing', async function () {
|
||||
const src = `{{settings.foo}},{{settings.bar}},{{settings.coo}}`
|
||||
const html = await engine.parseAndRender(src, { settings })
|
||||
return expect(html).toBe('FOO,BAR,COO')
|
||||
|
||||
describe('liquidMethodMissing', () => {
|
||||
it('should support liquidMethodMissing', async function () {
|
||||
class SettingsDrop extends Drop {
|
||||
public foo = 'FOO'
|
||||
public bar () {
|
||||
return 'BAR'
|
||||
}
|
||||
public liquidMethodMissing (key: string) {
|
||||
return key.toUpperCase()
|
||||
}
|
||||
}
|
||||
const src = `{{settings.foo}},{{settings.bar}},{{settings.coo}}`
|
||||
const html = await engine.parseAndRender(src, { settings: new SettingsDrop() })
|
||||
return expect(html).toBe('FOO,BAR,COO')
|
||||
})
|
||||
|
||||
it('should expose context', async function () {
|
||||
class SettingsDrop extends Drop {
|
||||
public liquidMethodMissing (key: string, context: Context) {
|
||||
return key + ':' + context.getSync([key])
|
||||
}
|
||||
}
|
||||
const src = `{{settings.foo}}`
|
||||
const html = await engine.parseAndRender(src, { settings: new SettingsDrop(), foo: 'FOO' })
|
||||
return expect(html).toBe('foo:FOO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BlandDrop', function () {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('express()', function () {
|
||||
it('should respect express views(string)', function (done) {
|
||||
app.set('views', views)
|
||||
request(app).get('/include/bar')
|
||||
.expect('bar')
|
||||
.expect('BAR')
|
||||
.expect(200, done)
|
||||
})
|
||||
it('should pass error when file not found', function (done) {
|
||||
@@ -63,7 +63,7 @@ describe('express()', function () {
|
||||
it('should respect express views (Array) when lookup', function (done) {
|
||||
app.set('views', [views, partials])
|
||||
request(app).get('/include/bar')
|
||||
.expect('bar')
|
||||
.expect('BAR')
|
||||
.expect(200, done)
|
||||
})
|
||||
})
|
||||
|
||||
+42
-1
@@ -1,4 +1,4 @@
|
||||
import { TopLevelToken, TagToken, Tokenizer, Context, Liquid, Drop, toValueSync, LiquidError } from '../..'
|
||||
import { TopLevelToken, TagToken, Tokenizer, Context, Liquid, Drop, toValueSync, LiquidError, IfTag } from '../..'
|
||||
const LiquidUMD = require('../../dist/liquid.browser.umd.js').Liquid
|
||||
|
||||
describe('Issues', function () {
|
||||
@@ -524,4 +524,45 @@ describe('Issues', function () {
|
||||
const result = engine.parseAndRenderSync(`\n{{ "foo" | pos }}`)
|
||||
expect(result).toEqual('\n[2,12] foo')
|
||||
})
|
||||
it("memoryLimit doesn't work in for tag #776", () => {
|
||||
const engine = new Liquid({
|
||||
memoryLimit: 1e5
|
||||
})
|
||||
const tpl = `{% for i in (1..1000000000) %} {{'a'}} {% endfor %}`
|
||||
expect(() => engine.parseAndRenderSync(tpl)).toThrow('memory alloc limit exceeded, line:1, col:1')
|
||||
})
|
||||
it('group_by_exp fails with object as input #785', () => {
|
||||
const site = {
|
||||
tags: {
|
||||
CPP: [ 'page0' ],
|
||||
PHP: [ 'page0', 'page2' ],
|
||||
JavaScript: [ 'page1', 'page2', 'page3' ],
|
||||
CSharp: [ 'page2', 'page4' ]
|
||||
}
|
||||
}
|
||||
const tpl = `
|
||||
{%- assign tags_by_size = site.tags | group_by_exp: 'tag', 'tag[1].size' | sort: 'name' | reverse -%}
|
||||
{%- for tags_with_size in tags_by_size -%}
|
||||
{%- for tag in tags_with_size.items -%}
|
||||
{%- assign tag_name = tag[0] %}
|
||||
{{ tag_name }} <sup>{{ tags_with_size.name }}</sup> Posts:
|
||||
{%- for post in tag[1] -%}{{post}},{%- endfor -%}
|
||||
{%- endfor -%}
|
||||
{%- endfor -%}
|
||||
`
|
||||
const engine = new Liquid()
|
||||
const html = engine.parseAndRenderSync(tpl, { site })
|
||||
expect(html).toEqual(`
|
||||
JavaScript <sup>3</sup> Posts:page1,page2,page3,
|
||||
PHP <sup>2</sup> Posts:page0,page2,
|
||||
CSharp <sup>2</sup> Posts:page2,page4,
|
||||
CPP <sup>1</sup> Posts:page0,`)
|
||||
})
|
||||
it('if tag condition, the token args is empty #796', () => {
|
||||
const tpl = 'Hello, {% if name %} {{ name }} {% else %} user {% endif %}'
|
||||
const engine = new Liquid()
|
||||
const parsed = engine.parse(tpl)
|
||||
const ifToken = parsed[1]
|
||||
expect((ifToken as IfTag).token.args).toEqual('name')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Context, Emitter, Hash, Liquid, Scope, Tag, TagToken, Template, TopLevelToken, ParseStream, Parser, Arguments, analyzeSync, Variable, StaticAnalysisOptions, StaticAnalysis } from '../..'
|
||||
|
||||
class MockTag extends Tag {
|
||||
private args: Hash
|
||||
private templates: Template[]
|
||||
|
||||
constructor (token: TagToken, remainTokens: TopLevelToken[], liquid: Liquid, parser: Parser) {
|
||||
super(token, remainTokens, liquid)
|
||||
this.args = new Hash(token.tokenizer)
|
||||
this.templates = []
|
||||
|
||||
const stream: ParseStream = parser.parseStream(remainTokens)
|
||||
.on<TagToken>('tag:endmock', () => { stream.stop() })
|
||||
.on('template', (tpl: Template) => this.templates.push(tpl))
|
||||
.on('end', () => { throw new Error(`tag ${token.getText()} not closed`) })
|
||||
|
||||
stream.start()
|
||||
}
|
||||
|
||||
public * render (ctx: Context, emitter: Emitter): Generator<unknown, void, string> {
|
||||
const scope = (yield this.args.render(ctx)) as unknown as Scope
|
||||
ctx.push(scope)
|
||||
yield this.liquid.renderer.renderTemplates(this.templates, ctx, emitter)
|
||||
ctx.pop()
|
||||
}
|
||||
|
||||
public * children (): Generator<unknown, Template[]> {
|
||||
return this.templates
|
||||
}
|
||||
|
||||
public * arguments (): Arguments {
|
||||
// XXX: tokens and type guards are not exported
|
||||
yield * Object.values(this.args.hash).filter((el) => el !== undefined) as Arguments
|
||||
}
|
||||
|
||||
public blockScope (): Iterable<string> {
|
||||
return Object.keys(this.args.hash)
|
||||
}
|
||||
}
|
||||
|
||||
describe('Static analysis', () => {
|
||||
it('should report variables from non-standard tags', () => {
|
||||
const engine = new Liquid()
|
||||
engine.registerTag('mock', MockTag)
|
||||
|
||||
const template = engine.parse('{% mock a:b x:y %}{{ x }}{{ z }}{% endmock %}')
|
||||
const analysis = analyzeSync(template)
|
||||
|
||||
const b = [new Variable(['b'], { row: 1, col: 11, file: undefined })]
|
||||
const x = [new Variable(['x'], { row: 1, col: 22, file: undefined })]
|
||||
const y = [new Variable(['y'], { row: 1, col: 15, file: undefined })]
|
||||
const z = [new Variable(['z'], { row: 1, col: 29, file: undefined })]
|
||||
|
||||
expect(analysis).toStrictEqual({
|
||||
variables: { b, x, y, z },
|
||||
globals: { b, y, z },
|
||||
locals: { }
|
||||
})
|
||||
})
|
||||
|
||||
it('should export analysis interfaces', () => {
|
||||
const engine = new Liquid()
|
||||
const template = engine.parse('{% include nothing %}')
|
||||
const options: StaticAnalysisOptions = { partials: false }
|
||||
const analysis: StaticAnalysis = analyzeSync(template, options)
|
||||
const vars: Variable[] = analysis.variables['nothing'] || []
|
||||
const v: Variable = vars[0]
|
||||
expect(String(v)).toBe('nothing')
|
||||
})
|
||||
})
|
||||
@@ -52,4 +52,14 @@ describe('#renderFile()', function () {
|
||||
})
|
||||
return expect(engine.renderFile('/not/exist.html')).rejects.toThrow(/Failed to lookup "\/not\/exist.html" in "\/boo,\/root\/"/)
|
||||
})
|
||||
it('should handle "." as cwd', async () => {
|
||||
engine = new Liquid({
|
||||
root: ['.'],
|
||||
extname: '.html'
|
||||
})
|
||||
process.chdir(resolve(__dirname, '../stub/views'))
|
||||
await expect(engine.renderFile('bar')).resolves.toEqual('BAR')
|
||||
process.chdir(resolve(__dirname, '../stub/partials'))
|
||||
await expect(engine.renderFile('bar')).resolves.toEqual('bar')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,13 +27,6 @@ describe('filters/array', function () {
|
||||
return expect(render(src)).rejects.toThrow('expected ":" after filter name, line:1, col:83')
|
||||
})
|
||||
})
|
||||
describe('last', () => {
|
||||
it('should support last', function () {
|
||||
const src = '{{ arr | last }}'
|
||||
const scope = { arr: ['zebra', 'octopus', 'giraffe', 'tiger'] }
|
||||
return test(src, scope, 'tiger')
|
||||
})
|
||||
})
|
||||
describe('split', () => {
|
||||
it('should support split', function () {
|
||||
const src = '{% assign my_array = "zebra, octopus, giraffe, tiger" | split: ", " %}' +
|
||||
@@ -263,6 +256,7 @@ describe('filters/array', function () {
|
||||
it('should return 0 for false', () => test('{{ false | size }}', '0'))
|
||||
it('should return 0 for nil', () => test('{{ nil | size }}', '0'))
|
||||
it('should return 0 for undefined', () => test('{{ foo | size }}', '0'))
|
||||
it('should work for string', () => test('{{ "foo" | size }}', {}, '3'))
|
||||
})
|
||||
describe('first', function () {
|
||||
it('should support first', () => test(
|
||||
@@ -273,7 +267,7 @@ describe('filters/array', function () {
|
||||
it('should return empty for nil', () => test('{{nil | first}}', ''))
|
||||
it('should return empty for undefined', () => test('{{foo | first}}', ''))
|
||||
it('should return empty for false', () => test('{{false | first}}', ''))
|
||||
it('should return empty for string', () => test('{{"zebra" | first}}', ''))
|
||||
it('should work for string', () => test('{{ "foo" | first }}', 'f'))
|
||||
})
|
||||
describe('last', function () {
|
||||
it('should support last', () => test(
|
||||
@@ -284,7 +278,7 @@ describe('filters/array', function () {
|
||||
it('should return empty for nil', () => test('{{nil | last}}', ''))
|
||||
it('should return empty for undefined', () => test('{{foo | last}}', ''))
|
||||
it('should return empty for false', () => test('{{false | last}}', ''))
|
||||
it('should return empty for string', () => test('{{"zebra" | last}}', ''))
|
||||
it('should work for string', () => test('{{ "foo" | last }}', {}, 'o'))
|
||||
})
|
||||
describe('slice', function () {
|
||||
it('should slice first char by 0', () => test('{{ "Liquid" | slice: 0 }}', 'L'))
|
||||
@@ -423,6 +417,32 @@ describe('filters/array', function () {
|
||||
- Boring sneakers
|
||||
`)
|
||||
})
|
||||
it('should support filter with undefined target', function () {
|
||||
return test(`{% assign typed_products = products | where: "type", notdefined %}
|
||||
Typed products:
|
||||
{% for product in typed_products -%}
|
||||
- {{ product.title }}
|
||||
{% endfor %}`, { products }, `
|
||||
Typed products:
|
||||
- Vacuum
|
||||
- Spatula
|
||||
- Television
|
||||
- Garlic press
|
||||
`)
|
||||
})
|
||||
it('should support no target', function () {
|
||||
return test(`{% assign typed_products = products | where: "type" %}
|
||||
Typed products:
|
||||
{% for product in typed_products -%}
|
||||
- {{ product.title }}
|
||||
{% endfor %}`, { products }, `
|
||||
Typed products:
|
||||
- Vacuum
|
||||
- Spatula
|
||||
- Television
|
||||
- Garlic press
|
||||
`)
|
||||
})
|
||||
it('should support nested property', async function () {
|
||||
const authors = [
|
||||
{ name: 'Alice', books: { year: 2019 } },
|
||||
@@ -484,6 +504,36 @@ describe('filters/array', function () {
|
||||
Kitchen products:
|
||||
`)
|
||||
})
|
||||
it('should support nil as target', () => {
|
||||
const scope = { list: [{ foo: 'FOO' }, { bar: 'BAR', type: 2 }] }
|
||||
return test('{{list | where: "type", nil | json}}', scope, '[{"foo":"FOO"}]')
|
||||
})
|
||||
it('should support empty as target', async () => {
|
||||
const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] }
|
||||
await test('{{pages | where: "tags", empty | json}}', scope, '[{"tags":[]}]')
|
||||
})
|
||||
it('should not match string with array', async () => {
|
||||
const scope = { objs: [{ foo: ['FOO', 'bar'] }] }
|
||||
await test('{{objs | where: "foo", "FOO" | json}}', scope, '[]')
|
||||
})
|
||||
describe('jekyll style', () => {
|
||||
it('should filter arrays by inclusion', async () => {
|
||||
const scope = { objs: [{ foo: ['FOO', 'bar'] }] }
|
||||
await test('{{objs | where: "foo", "FOO" | json}}', scope, '[{"foo":["FOO","bar"]}]', { jekyllWhere: true })
|
||||
})
|
||||
it('should support empty as target', async () => {
|
||||
const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] }
|
||||
await test('{{pages | where: "tags", empty | json}}', scope, '[{"tags":[]}]', { jekyllWhere: true })
|
||||
})
|
||||
it('should filter by undefined when target is omitted', async () => {
|
||||
await test('{{products | where: "type" | map: "title" | join: ","}}', { products },
|
||||
'Coffee mug,Limited edition sneakers,Boring sneakers', { jekyllWhere: true })
|
||||
})
|
||||
it('should filter plainly when target is undefined', async () => {
|
||||
await test('{{products | where: "type", notdefined | map: "title" | join: ","}}', { products },
|
||||
'Coffee mug,Limited edition sneakers,Boring sneakers', { jekyllWhere: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('where_exp', function () {
|
||||
const products = [
|
||||
@@ -506,6 +556,103 @@ describe('filters/array', function () {
|
||||
- Garlic press
|
||||
`)
|
||||
})
|
||||
it('should be aware context', function () {
|
||||
const tpl = `{% assign kitchen_products = products | where_exp: "item", "item.type == target" %}
|
||||
Kitchen products:
|
||||
{% for product in kitchen_products -%}
|
||||
- {{ product.title }}
|
||||
{% endfor %}`
|
||||
const scope = { products, target: 'kitchen' }
|
||||
const html = `
|
||||
Kitchen products:
|
||||
- Spatula
|
||||
- Garlic press
|
||||
`
|
||||
return test(tpl, scope, html)
|
||||
})
|
||||
})
|
||||
describe('reject', function () {
|
||||
const products = [
|
||||
{ title: 'Vacuum', type: 'living room' },
|
||||
{ title: 'Spatula', type: 'kitchen' },
|
||||
{ title: 'Television', type: 'living room' },
|
||||
{ title: 'Garlic press', type: 'kitchen' },
|
||||
{ title: 'Coffee mug', available: true },
|
||||
{ title: 'Limited edition sneakers', available: false },
|
||||
{ title: 'Boring sneakers', available: true }
|
||||
]
|
||||
it('should support reject by property value', function () {
|
||||
return test(`{% assign kitchen_products = products | reject: "type", "kitchen" %}
|
||||
Kitchen products:
|
||||
{% for product in kitchen_products -%}
|
||||
- {{ product.title }}
|
||||
{% endfor %}`, { products }, `
|
||||
Kitchen products:
|
||||
- Vacuum
|
||||
- Television
|
||||
- Coffee mug
|
||||
- Limited edition sneakers
|
||||
- Boring sneakers
|
||||
`)
|
||||
})
|
||||
it('should support reject truthy property', function () {
|
||||
return test(`{% assign unavailable_products = products | reject: "available" %}
|
||||
Unavailable products:
|
||||
{% for product in unavailable_products -%}
|
||||
- {{ product.title }}
|
||||
{% endfor %}`, { products }, `
|
||||
Unavailable products:
|
||||
- Vacuum
|
||||
- Spatula
|
||||
- Television
|
||||
- Garlic press
|
||||
- Limited edition sneakers
|
||||
`)
|
||||
})
|
||||
it('should support reject by string property', function () {
|
||||
return test(`{% assign untyped_products = products | reject: "type" %}
|
||||
Untyped products:
|
||||
{% for product in untyped_products -%}
|
||||
- {{ product.title }}
|
||||
{% endfor %}`, { products }, `
|
||||
Untyped products:
|
||||
- Coffee mug
|
||||
- Limited edition sneakers
|
||||
- Boring sneakers
|
||||
`)
|
||||
})
|
||||
describe('jekyll style', () => {
|
||||
it('should filter arrays by exclusion', async () => {
|
||||
const scope = { objs: [{ foo: ['FOO', 'bar'] }, { foo: ['bar', 'baz'] }, { foo: ['FOO'] }] }
|
||||
await test('{{objs | reject: "foo", "FOO" | json}}', scope, '[{"foo":["bar","baz"]}]', { jekyllWhere: true })
|
||||
})
|
||||
it('should filter by undefined when target is omitted', async () => {
|
||||
await test('{{products | reject: "type" | map: "title" | join: ","}}', { products },
|
||||
'Vacuum,Spatula,Television,Garlic press', { jekyllWhere: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('reject_exp', function () {
|
||||
const products = [
|
||||
{ title: 'Vacuum', type: 'living room' },
|
||||
{ title: 'Spatula', type: 'kitchen' },
|
||||
{ title: 'Television', type: 'living room' },
|
||||
{ title: 'Garlic press', type: 'kitchen' },
|
||||
{ title: 'Coffee mug', available: true },
|
||||
{ title: 'Limited edition sneakers', available: false },
|
||||
{ title: 'Boring sneakers', available: true }
|
||||
]
|
||||
it('should support reject by exp', function () {
|
||||
return test(`{% assign kitchen_products = products | reject_exp: "item", "item.type != 'kitchen'" %}
|
||||
Kitchen products:
|
||||
{% for product in kitchen_products -%}
|
||||
- {{ product.title }}
|
||||
{% endfor %}`, { products }, `
|
||||
Kitchen products:
|
||||
- Spatula
|
||||
- Garlic press
|
||||
`)
|
||||
})
|
||||
})
|
||||
describe('group_by', function () {
|
||||
const members = [
|
||||
@@ -538,6 +685,12 @@ describe('filters/array', function () {
|
||||
{ graduation_year: 2014, name: 'John' },
|
||||
{ graduation_year: 2009, name: 'Jack' }
|
||||
]
|
||||
const postsByTags = {
|
||||
CPP: [ 'page0' ],
|
||||
PHP: [ 'page0', 'page2' ],
|
||||
JavaScript: [ 'page1', 'page2', 'page3' ],
|
||||
CSharp: [ 'page2', 'page4' ]
|
||||
}
|
||||
it('should support group by expression', function () {
|
||||
const expected = [{
|
||||
name: '201',
|
||||
@@ -556,13 +709,100 @@ describe('filters/array', function () {
|
||||
{ members },
|
||||
JSON.stringify(expected))
|
||||
})
|
||||
it('should group key/values in plain object', function () {
|
||||
const expected = [{
|
||||
name: 3,
|
||||
items: [
|
||||
['JavaScript', ['page1', 'page2', 'page3']]
|
||||
]
|
||||
}, {
|
||||
name: 2,
|
||||
items: [
|
||||
['PHP', ['page0', 'page2']],
|
||||
['CSharp', ['page2', 'page4']]
|
||||
]
|
||||
}, {
|
||||
name: 1,
|
||||
items: [
|
||||
['CPP', ['page0']]
|
||||
]
|
||||
}]
|
||||
return test(
|
||||
`{{ postsByTags | group_by_exp: "tag", "tag[1].size" | sort: 'name' | reverse | json}}`,
|
||||
{ postsByTags },
|
||||
JSON.stringify(expected))
|
||||
})
|
||||
})
|
||||
describe('find', function () {
|
||||
describe('has', function () {
|
||||
const members = [
|
||||
{ graduation_year: 2013, name: 'Jay' },
|
||||
{ graduation_year: 2014, name: 'John' },
|
||||
{ graduation_year: 2014, name: 'Jack', age: 13 }
|
||||
]
|
||||
it('should support has with no value', function () {
|
||||
return test(
|
||||
`{{ members | has: "age" | json }}, {{ members | has: "height" | json }}`,
|
||||
{ members },
|
||||
`true, false`)
|
||||
})
|
||||
it('should support has by property', function () {
|
||||
return test(
|
||||
`{{ members | has: "graduation_year", 2014 | json }}`,
|
||||
{ members },
|
||||
`true`)
|
||||
})
|
||||
it('should return false if not found', function () {
|
||||
return test(
|
||||
`{{ members | has: "graduation_year", 2018 | json }}`,
|
||||
{ members },
|
||||
`false`)
|
||||
})
|
||||
describe('jekyll style', () => {
|
||||
it('should select array by inclusion', async () => {
|
||||
const scope = { objs: [{ foo: ['FOO', 'bar'] }] }
|
||||
await test('{{objs | has: "foo", "FOO" | json}}', scope, 'true', { jekyllWhere: true })
|
||||
})
|
||||
it('should support empty as target', async () => {
|
||||
const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] }
|
||||
await test('{{pages | has: "tags", empty | json}}', scope, 'true', { jekyllWhere: true })
|
||||
})
|
||||
it('should search plainly when target is undefined', async () => {
|
||||
await test('{{members | has: "age", notdefined}}', { members }, 'true', { jekyllWhere: true })
|
||||
await test('{{members | has: "name", notdefined}}', { members }, 'false', { jekyllWhere: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('has_exp', function () {
|
||||
const members = [
|
||||
{ graduation_year: 2013, name: 'Jay' },
|
||||
{ graduation_year: 2014, name: 'John' },
|
||||
{ graduation_year: 2014, name: 'Jack' }
|
||||
]
|
||||
it('should support has by expression', function () {
|
||||
return test(
|
||||
`{{ members | has_exp: "item", "item.graduation_year == 2014" | json }}`,
|
||||
{ members },
|
||||
`true`)
|
||||
})
|
||||
it('should return false if not found', function () {
|
||||
return test(
|
||||
`{{ members | has_exp: "item", "item.graduation_year == 2018" | json }}`,
|
||||
{ members },
|
||||
`false`)
|
||||
})
|
||||
})
|
||||
describe('find', function () {
|
||||
const members = [
|
||||
{ graduation_year: 2013, name: 'Jay' },
|
||||
{ graduation_year: 2014, name: 'John' },
|
||||
{ graduation_year: 2014, name: 'Jack', age: 13 }
|
||||
]
|
||||
it('should support find with no value', function () {
|
||||
return test(
|
||||
`{{ members | find: "age" | json }}`,
|
||||
{ members },
|
||||
`{"graduation_year":2014,"name":"Jack","age":13}`)
|
||||
})
|
||||
it('should support find by property', function () {
|
||||
return test(
|
||||
`{{ members | find: "graduation_year", 2014 | json }}`,
|
||||
@@ -575,6 +815,24 @@ describe('filters/array', function () {
|
||||
{ members },
|
||||
``)
|
||||
})
|
||||
describe('jekyll style', () => {
|
||||
it('should select array by inclusion', async () => {
|
||||
const scope = { objs: [{ foo: ['FOO', 'bar'] }] }
|
||||
await test('{{objs | find: "foo", "FOO" | json}}', scope, '{"foo":["FOO","bar"]}', { jekyllWhere: true })
|
||||
})
|
||||
it('should support empty as target', async () => {
|
||||
const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] }
|
||||
await test('{{pages | find: "tags", empty | json}}', scope, '{"tags":[]}', { jekyllWhere: true })
|
||||
})
|
||||
it('should search plainly when target is undefined', async () => {
|
||||
await test(
|
||||
'{{members | find: "age", notdefined | json}}',
|
||||
{ members },
|
||||
'{"graduation_year":2013,"name":"Jay"}',
|
||||
{ jekyllWhere: true })
|
||||
await test('{{members | find: "name", notdefined | json}}', { members }, '', { jekyllWhere: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('find_exp', function () {
|
||||
const members = [
|
||||
@@ -595,4 +853,62 @@ describe('filters/array', function () {
|
||||
``)
|
||||
})
|
||||
})
|
||||
describe('find_index', function () {
|
||||
const members = [
|
||||
{ graduation_year: 2013, name: 'Jay' },
|
||||
{ graduation_year: 2014, name: 'John' },
|
||||
{ graduation_year: 2014, name: 'Jack', age: 13 }
|
||||
]
|
||||
it('should support find_index with no value', function () {
|
||||
return test(
|
||||
`{{ members | find_index: "age" | json }}`,
|
||||
{ members },
|
||||
`2`)
|
||||
})
|
||||
it('should support find_index by property', function () {
|
||||
return test(
|
||||
`{{ members | find_index: "graduation_year", 2014 | json }}`,
|
||||
{ members },
|
||||
`1`)
|
||||
})
|
||||
it('should render none if not found', function () {
|
||||
return test(
|
||||
`{{ members | find_index: "graduation_year", 2018 | json }}`,
|
||||
{ members },
|
||||
``)
|
||||
})
|
||||
describe('jekyll style', () => {
|
||||
it('should select array by inclusion', async () => {
|
||||
const scope = { objs: [{ foo: ['FOO', 'bar'] }] }
|
||||
await test('{{objs | find_index: "foo", "FOO" | json}}', scope, '0', { jekyllWhere: true })
|
||||
})
|
||||
it('should support empty as target', async () => {
|
||||
const scope = { pages: [{ tags: ['FOO'] }, { tags: [] }, { title: 'foo' }] }
|
||||
await test('{{pages | find_index: "tags", empty | json}}', scope, '1', { jekyllWhere: true })
|
||||
})
|
||||
it('should search plainly when target is undefined', async () => {
|
||||
await test('{{members | find_index: "age", notdefined | json}}', { members }, '0', { jekyllWhere: true })
|
||||
await test('{{members | find_index: "name", notdefined | json}}', { members }, '', { jekyllWhere: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('find_index_exp', function () {
|
||||
const members = [
|
||||
{ graduation_year: 2013, name: 'Jay' },
|
||||
{ graduation_year: 2014, name: 'John' },
|
||||
{ graduation_year: 2014, name: 'Jack' }
|
||||
]
|
||||
it('should support find_index by expression', function () {
|
||||
return test(
|
||||
`{{ members | find_index_exp: "item", "item.graduation_year == 2014" | json }}`,
|
||||
{ members },
|
||||
`1`)
|
||||
})
|
||||
it('should render none if not found', function () {
|
||||
return test(
|
||||
`{{ members | find_index_exp: "item", "item.graduation_year == 2018" | json }}`,
|
||||
{ members },
|
||||
``)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,19 +80,23 @@ describe('filters/date', function () {
|
||||
return test('{{ "1991-02-22T00:00:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1991-02-22T00:00:00')
|
||||
})
|
||||
describe('when preserveTimezones is enabled', function () {
|
||||
const opts: LiquidOptions = { preserveTimezones: true }
|
||||
const opts: LiquidOptions = { preserveTimezones: true, locale: 'en-US' }
|
||||
|
||||
it('should not change the timezone between input and output', function () {
|
||||
return test('{{ "1990-12-31T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T23:00:00', undefined, opts)
|
||||
})
|
||||
it('should apply numeric timezone offset (0)', function () {
|
||||
return test('{{ "1990-12-31T23:00:00+00:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T23:00:00', undefined, opts)
|
||||
return test('{{ "1990-12-31T23:00:00+00:00" | date: "%Y-%m-%dT%H:%M:%S %z"}}', '1990-12-31T23:00:00 +0000', undefined, opts)
|
||||
})
|
||||
it('should apply numeric timezone offset (-1)', function () {
|
||||
return test('{{ "1990-12-31T23:00:00-01:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T23:00:00', undefined, opts)
|
||||
return test('{{ "1990-12-31T23:00:00-01:00" | date: "%Y-%m-%dT%H:%M:%S %z"}}', '1990-12-31T23:00:00 -0100', undefined, opts)
|
||||
})
|
||||
it('should apply numeric timezone offset (+2.30)', function () {
|
||||
return test('{{ "1990-12-31T23:00:00+02:30" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T23:00:00', undefined, opts)
|
||||
return test('{{ "1990-12-31T23:00:00+02:30" | date: "%Y-%m-%dT%H:%M:%S %z"}}', '1990-12-31T23:00:00 +0230', undefined, opts)
|
||||
})
|
||||
it('should support timezone in more casual JavaScript Date', async () => {
|
||||
await test('{{ "2025-01-02 03:04:05 -0100" | date: "%Y-%m-%dT%H:%M:%S %z" }}', '2025-01-02T03:04:05 -0100', undefined, opts)
|
||||
await test('{{ "2025-01-02 03:04:05 -0100" | date }}', 'Thursday, January 2, 2025 at 3:04 am -0100', undefined, opts)
|
||||
})
|
||||
it('should automatically work when timezone not specified', function () {
|
||||
return test('{{ "1990-12-31T23:00:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T23:00:00', undefined, opts)
|
||||
|
||||
@@ -56,8 +56,9 @@ describe('filters/math', function () {
|
||||
it('should return "20" for 16,4', () => test('{{ 16 | plus: 4 }}', '20'))
|
||||
it('should return "195.357" for 183.357,12',
|
||||
() => test('{{ 183.357 | plus: 12 }}', '195.357'))
|
||||
it('should convert first arg as number', () => test('{{ "4" | plus: 2 }}', '6'))
|
||||
it('should convert both args as number', () => test('{{ "4" | plus: "2" }}', '6'))
|
||||
it('should convert first arg to number', () => test('{{ "4" | plus: 2 }}', '6'))
|
||||
it('should convert both args to numbers', () => test('{{ "4" | plus: "2" }}', '6'))
|
||||
it('should convert invalid string to 0', () => test('{{ "abc" | plus: "2" }}', '2'))
|
||||
it('should support variable', () => test('{{ 4 | plus: b }}', { b: 2 }, '6'))
|
||||
})
|
||||
|
||||
|
||||
@@ -229,4 +229,113 @@ describe('Liquid', function () {
|
||||
expect(drainStream(stream)).rejects.toThrow(/intended render error/)
|
||||
})
|
||||
})
|
||||
describe('#analyze', () => {
|
||||
const engine = new Liquid()
|
||||
it('should analyze templates asynchronously', () => {
|
||||
const template = engine.parse('{{ a }}{{ b }}')
|
||||
expect(engine.analyze(template).then((a) => Object.keys(a.variables))).resolves.toStrictEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
describe('#analyzeSync', () => {
|
||||
const engine = new Liquid()
|
||||
it('should analyze templates synchronously', () => {
|
||||
const template = engine.parse('{{ a }}{{ b }}')
|
||||
expect(Object.keys(engine.analyzeSync(template).variables)).toStrictEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
describe('#parseAndAnalyze', () => {
|
||||
const engine = new Liquid()
|
||||
it('should parse and analyze templates asynchronously', () => {
|
||||
expect(engine.parseAndAnalyze('{{ a }}{{ b }}').then((a) => Object.keys(a.variables))).resolves.toStrictEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
describe('#parseAndAnalyzeSync', () => {
|
||||
const engine = new Liquid()
|
||||
it('should analyze templates synchronously', () => {
|
||||
expect(Object.keys(engine.parseAndAnalyzeSync('{{ a }}{{ b }}').variables)).toStrictEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
describe('Convenience analysis', () => {
|
||||
const engine = new Liquid()
|
||||
|
||||
it('should list all variables without their properties', () => {
|
||||
expect(engine.variables('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).resolves.toStrictEqual(['a', 'c'])
|
||||
expect(engine.variables(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).resolves.toStrictEqual(['a', 'c'])
|
||||
})
|
||||
|
||||
it('should list all variables without their properties synchronously', () => {
|
||||
expect(engine.variablesSync('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).toStrictEqual(['a', 'c'])
|
||||
expect(engine.variablesSync(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).toStrictEqual(['a', 'c'])
|
||||
})
|
||||
|
||||
it('should list global variables without their properties', () => {
|
||||
expect(engine.globalVariables('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).resolves.toStrictEqual(['a'])
|
||||
expect(engine.globalVariables(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).resolves.toStrictEqual(['a'])
|
||||
})
|
||||
|
||||
it('should list global variables without their properties synchronously', () => {
|
||||
expect(engine.globalVariablesSync('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).toStrictEqual(['a'])
|
||||
expect(engine.globalVariablesSync(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).toStrictEqual(['a'])
|
||||
})
|
||||
|
||||
it('should list all variables with their properties', () => {
|
||||
expect(engine.fullVariables('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).resolves.toStrictEqual(['a.b', 'c'])
|
||||
expect(engine.fullVariables(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).resolves.toStrictEqual(['a.b', 'c'])
|
||||
})
|
||||
|
||||
it('should list all variables with their properties synchronously', () => {
|
||||
expect(engine.fullVariablesSync('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).toStrictEqual(['a.b', 'c'])
|
||||
expect(engine.fullVariablesSync(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).toStrictEqual(['a.b', 'c'])
|
||||
})
|
||||
|
||||
it('should list global variables with their properties', () => {
|
||||
expect(engine.globalFullVariables('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).resolves.toStrictEqual(['a.b'])
|
||||
expect(engine.globalFullVariables(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).resolves.toStrictEqual(['a.b'])
|
||||
})
|
||||
|
||||
it('should list global variables with their properties synchronously', () => {
|
||||
expect(engine.globalFullVariablesSync('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).toStrictEqual(['a.b'])
|
||||
expect(engine.globalFullVariablesSync(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).toStrictEqual(['a.b'])
|
||||
})
|
||||
|
||||
it('should list all variables as an array of segments', () => {
|
||||
expect(engine.variableSegments('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).resolves.toStrictEqual([['a', 'b'], ['c']])
|
||||
expect(engine.variableSegments(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).resolves.toStrictEqual([['a', 'b'], ['c']])
|
||||
})
|
||||
|
||||
it('should list all variables as an array of segments synchronously', () => {
|
||||
expect(engine.variableSegmentsSync('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).toStrictEqual([['a', 'b'], ['c']])
|
||||
expect(engine.variableSegmentsSync(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).toStrictEqual([['a', 'b'], ['c']])
|
||||
})
|
||||
|
||||
it('should list all variables as an array of segments with nested variables as arrays', () => {
|
||||
expect(engine.variableSegments('{{ a[b.c].d }}')).resolves.toStrictEqual([['a', ['b', 'c'], 'd'], ['b', 'c']])
|
||||
expect(engine.variableSegments(engine.parse('{{ a[b.c].d }}'))).resolves.toStrictEqual([['a', ['b', 'c'], 'd'], ['b', 'c']])
|
||||
})
|
||||
|
||||
it('should list all variables synchronously as an array of segments with nested variables as arrays', () => {
|
||||
expect(engine.variableSegmentsSync('{{ a[b.c].d }}')).toStrictEqual([['a', ['b', 'c'], 'd'], ['b', 'c']])
|
||||
expect(engine.variableSegmentsSync(engine.parse('{{ a[b.c].d }}'))).toStrictEqual([['a', ['b', 'c'], 'd'], ['b', 'c']])
|
||||
})
|
||||
|
||||
it('should list global variables as an array of segments', () => {
|
||||
expect(engine.globalVariableSegments('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).resolves.toStrictEqual([['a', 'b']])
|
||||
expect(engine.globalVariableSegments(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).resolves.toStrictEqual([['a', 'b']])
|
||||
})
|
||||
|
||||
it('should list global variables as an array of segments synchronously', () => {
|
||||
expect(engine.globalVariableSegmentsSync('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}')).toStrictEqual([['a', 'b']])
|
||||
expect(engine.globalVariableSegmentsSync(engine.parse('{% assign c = 1 %}{{ a.b }}{{ c }}{{ c }}'))).toStrictEqual([['a', 'b']])
|
||||
})
|
||||
|
||||
it('should list global variables as an array of segments with nested variables as arrays', () => {
|
||||
expect(engine.globalVariableSegments('{{ a[b.c].d }}')).resolves.toStrictEqual([['a', ['b', 'c'], 'd'], ['b', 'c']])
|
||||
expect(engine.globalVariableSegments(engine.parse('{{ a[b.c].d }}'))).resolves.toStrictEqual([['a', ['b', 'c'], 'd'], ['b', 'c']])
|
||||
})
|
||||
|
||||
it('should list global variables synchronously as an array of segments with nested variables as arrays', () => {
|
||||
expect(engine.globalVariableSegmentsSync('{{ a[b.c].d }}')).toStrictEqual([['a', ['b', 'c'], 'd'], ['b', 'c']])
|
||||
expect(engine.globalVariableSegmentsSync(engine.parse('{{ a[b.c].d }}'))).toStrictEqual([['a', ['b', 'c'], 'd'], ['b', 'c']])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { normalize } from '../../../src/liquid-options'
|
||||
|
||||
describe('LiquidOptions#root', function () {
|
||||
describe('#normalize ()', function () {
|
||||
describe('#normalize()', function () {
|
||||
it('should normalize string typed root array', function () {
|
||||
const options = normalize({ root: 'foo' })
|
||||
expect(options.root).toEqual(['foo'])
|
||||
|
||||
@@ -264,7 +264,7 @@ describe('error', function () {
|
||||
it('should throw ParseError when tag value not specified', async function () {
|
||||
await expect(engine.parseAndRender('{% if %}{% endif %}')).rejects.toMatchObject({
|
||||
name: 'TokenizationError',
|
||||
message: 'invalid value expression: "", line:1, col:1'
|
||||
message: 'invalid value expression: "", line:1, col:6'
|
||||
})
|
||||
})
|
||||
it('should throw ParseError when tag parse throws', async function () {
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { Liquid } from '../../../src/liquid'
|
||||
import { Drop } from '../../../src/drop/drop'
|
||||
import { Scope } from '../../../src/context/scope'
|
||||
import { mock, restore } from '../../stub/mockfs'
|
||||
|
||||
describe('tags/for', function () {
|
||||
let liquid: Liquid, scope: Scope
|
||||
@@ -139,6 +140,7 @@ describe('tags/for', function () {
|
||||
})
|
||||
|
||||
describe('continue', function () {
|
||||
afterEach(restore)
|
||||
it('should support for with continue', async function () {
|
||||
const src = '{% for i in (1..5) %}' +
|
||||
'{% if i == 4 %}continue{% continue %}{% endif %}{{i}}' +
|
||||
@@ -154,6 +156,41 @@ describe('tags/for', function () {
|
||||
const html = await liquid.parseAndRender(src, scope)
|
||||
return expect(html).toBe('123continue5')
|
||||
})
|
||||
it('should skip snippet for rendered continue', async function () {
|
||||
mock({
|
||||
'snippet.liquid': ' before{% continue %}skipped'
|
||||
})
|
||||
const src = '{% for i in (1..2) %}' +
|
||||
'{% render "snippet.liquid" %}' +
|
||||
' after' +
|
||||
'{% endfor %}'
|
||||
const html = await liquid.parseAndRender(src, scope)
|
||||
return expect(html).toBe(' before after before after')
|
||||
})
|
||||
it('should skip `for` body for included continue', async function () {
|
||||
mock({
|
||||
'snippet.liquid': ' before{% continue %}skipped'
|
||||
})
|
||||
const src = '{% for i in (1..2) %}' +
|
||||
'{% include "snippet.liquid" %}' +
|
||||
' after' +
|
||||
'{% endfor %}'
|
||||
const html = await liquid.parseAndRender(src, scope)
|
||||
return expect(html).toBe(' before before')
|
||||
})
|
||||
it('should continue current forloop only', async () => {
|
||||
const src = `
|
||||
{%- for i in (1..2) -%}
|
||||
i:{{ i }},
|
||||
{%- for j in (1..2) -%}
|
||||
j:{{ j }},
|
||||
{%- continue -%}
|
||||
after
|
||||
{%- endfor -%}
|
||||
{%- endfor -%}`
|
||||
const html = await liquid.parseAndRender(src, scope)
|
||||
return expect(html).toBe('i:1,j:1,j:2,i:2,j:1,j:2,')
|
||||
})
|
||||
})
|
||||
describe('break', function () {
|
||||
it('should support break', async function () {
|
||||
@@ -172,6 +209,27 @@ describe('tags/for', function () {
|
||||
const html = await liquid.parseAndRender(src, scope)
|
||||
return expect(html).toBe('123breaking')
|
||||
})
|
||||
it('should not break template outside of forloop', async () => {
|
||||
const src = '{% for i in (1..5) %}' +
|
||||
'{{ i }}' +
|
||||
'{% break %}' +
|
||||
'{% endfor %}' +
|
||||
' after'
|
||||
const html = await liquid.parseAndRender(src, scope)
|
||||
return expect(html).toBe('1 after')
|
||||
})
|
||||
it('should not break parent forloop', async function () {
|
||||
const src = `
|
||||
{%- for i in (1..3) -%}
|
||||
i:{{ i }},
|
||||
{%- for j in (1..3) -%}
|
||||
j:{{ j }},
|
||||
{%- break -%}
|
||||
{%- endfor -%}
|
||||
{%- endfor -%}`
|
||||
const html = await liquid.parseAndRender(src, scope)
|
||||
return expect(html).toBe('i:1,j:1,i:2,j:1,i:3,j:1,')
|
||||
})
|
||||
})
|
||||
|
||||
describe('limit', function () {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user