Compare commits

..
Author SHA1 Message Date
Yang Jun 8a7791be9e docs: add lambdatest to sponsors 2025-10-26 14:53:32 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2858271c8f docs: add skynetigor as a contributor for code (#825)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-10-23 22:23:52 +08:00
semantic-release-bot d22945ed5f chore(release): 10.23.0 [skip ci]
# [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))
2025-10-23 13:38:41 +00:00
Ihor PanasiukandGitHub 4f7d2fd84a feat: Export specific tokens as types (#824)
* Export specific tokens as types

* Update index.ts
2025-10-23 21:37:20 +08:00
semantic-release-bot 597ce7305f chore(release): 10.22.0 [skip ci]
# [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))
2025-10-06 14:45:55 +00:00
Yang Jun d7fa8ba5f1 chore: update node version for release workflow 2025-10-06 22:44:24 +08:00
Yang JunandGitHub 1b356d350d chore: fix Github artifact name (#821) 2025-10-06 22:32:34 +08:00
Yang JunandGitHub e55128850e feat: allow context access in liquidMethodMissing, #808 (#820) 2025-10-06 18:34:08 +08:00
Yang JunandGitHub e8e502c585 fix: math filters coerce invalid string to 0, #813 (#819) 2025-10-06 18:31:43 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
b8bc4db46c docs: add StreakingMan as a contributor for doc (#812)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-08-13 19:15:43 +08:00
裸奔狂甩丁丁andGitHub 68d500c18a docs: operators.md zh-cn translation (#811) 2025-08-13 19:14:47 +08:00
semantic-release-bot de12359bfd chore(release): 10.21.1 [skip ci]
## [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))
2025-05-14 17:49:27 +00:00
Yang JunandGitHub 025c40f0f2 fix: block.super with strictVariables, #806 (#807) 2025-05-15 01:47:57 +08:00
Vlad GURDIGAandGitHub 2f414f8e40 docs: Fix formatting bug in echo.md (#805)
DISCLAIMER: This may not be the proper way to approach the issue.

Although the Markdown code is proper, on the website itself it is incorrectly rendered as "{{` and `}}".

The reason for the disclaimer above is that I’m imagining this may be an issue at the content rendering level, and my fix here is just a workaround of that issue. — Given this, I’ll not be offended if this PR of mine is rejected and closed. 🙂
2025-05-10 20:35:07 +08:00
Vlad GURDIGAandGitHub 40c52124d7 docs: Fix typo in options.md (#802)
Looks like just a typo.
2025-05-09 21:16:08 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
ae0c07e60b docs: add gurdiga as a contributor for doc (#804)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-05-09 20:37:59 +08:00
Vlad GURDIGAandGitHub 3b9202465e docs: A lil Markdown fix (#803)
Replaced square brackets with round ones in a link markup.
2025-05-09 20:36:49 +08:00
Harttle fc42ad7548 docs: update financial contributors 2025-04-03 21:27:48 +08:00
allcontributors[bot]andGitHub 050a7fc68e docs: add edh649 as a contributor for doc (#801) 2025-04-02 21:02:42 +08:00
Ed HantonandYang Jun 0fdc5c79da Update register-filters-tags.md
Improve documentation
2025-04-02 21:01:20 +08:00
semantic-release-bot 90d2ecb107 chore(release): 10.21.0 [skip ci]
# [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))
2025-02-23 14:59:29 +00:00
Bruno CarvalhoandGitHub 0deb93eeae feat: add find_index, has, and reject filters (#799)
* feat: add find_index, has, and reject filters

* Minor tweaks

* Change semantics of jekyllStyle, add more tests

* Some docs improvements
2025-02-23 22:57:17 +08:00
semantic-release-bot b0facc71f7 chore(release): 10.20.3 [skip ci]
## [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))
2025-02-09 14:51:09 +00:00
HarttleandYang Jun 5cb843f162 chore: migrate actions/upload-artifact to v4 2025-02-09 22:49:40 +08:00
HarttleandYang Jun 38a0f510b0 fix: empty tagToken.args since 10.20.0, fixes #796 2025-02-09 22:49:40 +08:00
Harttle 1a893f8023 docs: migrate to algolia app QJ35YOZTU4, #795 2025-02-09 22:12:04 +08:00
semantic-release-bot bf3bd54051 chore(release): 10.20.2 [skip ci]
## [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))
2025-01-19 09:59:48 +00:00
HarttleandJun Yang a490a70da1 fix: consistent range syntax parsing, #791 2025-01-19 17:58:00 +08:00
HarttleandJun Yang a5070af3e4 fix: context for group_by_exp/where_exp/find_exp, #790 2025-01-19 17:53:25 +08:00
semantic-release-bot b070594fc7 chore(release): 10.20.1 [skip ci]
## [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))
2025-01-04 15:42:58 +00:00
Jun YangandGitHub 25ef104446 fix: enumerate plain objects in where/where_exp, #785 (#788) 2025-01-04 23:41:25 +08:00
HarttleandJun Yang 59cf3c08db fix: preserveTimezones support for RFC2822 date, #784 2025-01-04 22:46:07 +08:00
HarttleandJun Yang 5f1a4cfdc9 fix: break/continue stops whole template, #783 2025-01-04 22:15:39 +08:00
semantic-release-bot 8c32ab4f42 chore(release): 10.20.0 [skip ci]
# [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))
2024-12-28 13:53:20 +00:00
Harttle 94a6715667 docs: zh-cn translation for static analyze features 2024-12-28 21:49:29 +08:00
JamesandGitHub 3492ff63f4 feat: static variable analysis (#770)
* feat: static variable analysis

* Accept any iterable from `children`, `arguments`, etc.

* Test analysis of standard tags

* Use `TagToken.tokenizer` instead of creating a new one

* Test analysis of netsted tags

* Group variables by their root value

* Test analysis of nested globals and locals

* Analyze included and rendered templates WIP

* Use existing tokenizer when constructing `Hash`

* Improve test coverage

* Analyze variables from `layout` and `block` tags

* Test analysis of Jekyll style includes

* Handle variables that start with a nested variable

* Async analysis

* Test non-standard tag end to end

* Implement convenience analysis methods on the `Liquid` class

* More analysis convenience methods

* Accept string or template array

* Draft static analysis docs

* Deduplicate variables names

* Fix isolated scope global variable map

* Coerce variables to strings instead of extending String

* Private map instead of extending Map

* Fix e2e test

* Tentatively implement analysis of aliased variables

* Fix nested variable segments array

* Update docs sidebar
2024-12-28 21:35:28 +08:00
HarttleandJun Yang 35a84421a6 feat: size, first, last support arraylike objects, #781 2024-12-28 16:10:06 +08:00
semantic-release-bot bb08cfab1f chore(release): 10.19.1 [skip ci]
## [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))
2024-12-22 08:33:44 +00:00
HarttleandJun Yang e3ef574674 fix: inconsistent continue behaviour, fixes #779 2024-12-22 16:32:08 +08:00
Harttle 2af297f81a fix: memoryLimit doesn't work in for tag, #776 2024-12-22 15:46:56 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
d61855bf72 docs: add timvandam as a contributor for code (#775)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-12-01 09:55:27 +08:00
Tim van DamandJun Yang 734eb52b98 fix: add sideEffects=false to package.json 2024-12-01 09:54:45 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2980260fc1 docs: add santialbo as a contributor for code (#772)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-11-17 21:38:50 +08:00
semantic-release-bot 062b351598 chore(release): 10.19.0 [skip ci]
# [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))
2024-11-17 13:35:17 +00:00
HarttleandJun Yang 9107eb1b93 feat: support Jekyll style where, #768 2024-11-17 21:33:38 +08:00
Santi AlboandGitHub 11f013bf24 feat: allow drops in property access (#769) 2024-11-17 20:24:45 +08:00
Yang Jun ad039abea0 docs: add Syntax Podcast & Cartelli Emmanuel as contributor 2024-11-12 23:59:40 +08:00
Yang Jun 0b195b21eb test: case for "." path handling 2024-11-12 23:59:40 +08:00
98 changed files with 12422 additions and 5056 deletions
+47 -1
View File
@@ -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,
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+82
View File
@@ -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)
+33 -28
View File
@@ -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,33 +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>
<td align="center" valign="top" width="14.28%"><a href="https://syntax.fm/"><img src="https://avatars.githubusercontent.com/u/130389858?v=4&s=100" width="100px;" alt="Syntax Podcast"/><br /><sub><b>Syntax Podcast</b></sub></a></td>
<td align="center" valign="top" width="14.28%"><a href="https://opencollective.com/cartelli-emmanuel"><img src="https://images.opencollective.com/cartelli-emmanuel/avatar/256.png?height=100" width="100px;" alt="Cartelli Emmanuel"/><br /><sub><b>Cartelli Emmanuel</b></sub></a></td>
</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 ✨
@@ -175,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>
@@ -208,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>
-1
View File
@@ -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
View File
@@ -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
View File
@@ -34,4 +34,4 @@
"engines": {
"node": ">=8.10.0"
}
}
}
+7
View File
@@ -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
+25
View File
@@ -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
```
+25
View File
@@ -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
```
+25
View File
@@ -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
```
+25
View File
@@ -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
```
+118
View File
@@ -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
+37
View File
@@ -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
+29 -1
View File
@@ -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
+1
View File
@@ -10,6 +10,7 @@ const urlsToCache = [
]
const blackList = [
/chrome-extension:/,
/algolia.net/,
/google-analytics.com.*collect/
]
+1 -1
View File
@@ -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/
+2 -2
View File
@@ -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'));
```
```
+290
View File
@@ -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>&copy; {{ "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
+27
View File
@@ -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
+1 -1
View File
@@ -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>&copy; {{ "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
+1
View File
@@ -52,6 +52,7 @@ sidebar:
operators: Operators
truth: Truthy and Falsy
dos: DoS
static_analysis: Static Analysis
miscellaneous: Miscellaneous
migration9: 'Migrate to LiquidJS 9'
+1
View File
@@ -52,6 +52,7 @@ sidebar:
operators: 运算符
truth: 真和假
dos: DoS
static_analysis: 静态分析
miscellaneous: 其他
migration9: '迁移到 LiquidJS 9'
+15 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -1,6 +1,7 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
maxWorkers: '50%',
testEnvironment: 'node',
testMatch: ['**/*.spec.ts'],
collectCoverageFrom: [
+8340 -4794
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -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
View File
@@ -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]
+3
View File
@@ -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
}
}
+6 -3
View File
@@ -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
View File
@@ -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
}
}
+3
View File
@@ -25,4 +25,7 @@ export class EmptyDrop extends Drop implements Comparable {
public valueOf () {
return ''
}
static is (value: unknown) {
return value instanceof EmptyDrop
}
}
+85 -23
View File
@@ -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
View File
@@ -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
View File
@@ -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'
+2
View File
@@ -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
View File
@@ -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()))))
}
}
+4 -2
View File
@@ -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)
}
+34 -5
View 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)
+5 -3
View File
@@ -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)
}
+4
View File
@@ -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))
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+14
View File
@@ -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
}
}
+2 -2
View File
@@ -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
}
}
+9
View File
@@ -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
}
}
}
+8 -1
View File
@@ -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
}
}
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
}
+8 -1
View File
@@ -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
View File
@@ -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) }
}
}
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+51
View File
@@ -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([])
})
})
+448
View File
@@ -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('')
}
+7
View File
@@ -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)
})
})
+4 -2
View File
@@ -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)) {
+1
View File
@@ -8,3 +8,4 @@ export * from './hash'
export * from './value'
export * from './output'
export * from './html'
export * from './analysis'
+5 -1
View File
@@ -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
}
}
+33
View File
@@ -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;
}
+2
View File
@@ -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}`)
+4 -4
View File
@@ -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])
}
}
+2 -3
View File
@@ -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
View File
@@ -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)
}
}
+6 -2
View File
@@ -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)
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 () {
+42 -1
View File
@@ -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')
})
})
+70
View File
@@ -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')
})
})
+326 -10
View File
@@ -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 },
``)
})
})
})
+8 -4
View File
@@ -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)
+3 -2
View File
@@ -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'))
})
+109
View File
@@ -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']])
})
})
})
@@ -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
+58
View File
@@ -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 () {
+20
View File
@@ -101,6 +101,26 @@ describe('tags/layout', function () {
const output = '<link href="base.css" rel="stylesheet"><link href="extra.css" rel="stylesheet">'
return expect(html).toBe(output)
})
it('should pass block.super as a string', async function () {
mock({
'/parent.html': '{% block css %}<link href="base.css" rel="stylesheet">{% endblock %}'
})
const src = '{% layout "parent.html" %}' +
'{%block css%}{{block.super}}<meta basesize={{block.super | size}}>{%endblock%}'
const html = await liquid.parseAndRender(src)
const output = '<link href="base.css" rel="stylesheet"><meta basesize=39>'
return expect(html).toBe(output)
})
it('should support block.super while strictVariables', async function () {
mock({
'/parent.html': '{% block css %}<link href="base.css" rel="stylesheet">{% endblock %}'
})
const src = '{% layout "parent.html" %}' +
'{%block css%}{{block.super}}<link href="extra.css" rel="stylesheet">{%endblock%}'
const html = await liquid.parseAndRender(src, undefined, { strictVariables: true })
const output = '<link href="base.css" rel="stylesheet"><link href="extra.css" rel="stylesheet">'
return expect(html).toBe(output)
})
it('should render block.super to empty if no parent exists', async function () {
mock({
'/parent.html': '{% block css %}{{block.super}}<link href="base.css" rel="stylesheet">{% endblock %}'