Compare commits

...
Author SHA1 Message Date
39233ba9f2 docs: publish .nojekyll so GitHub Pages serves underscore API pages (#952)
EOF

Co-authored-by: Cursor <[email protected]>
2026-09-06 19:38:56 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
8f57d9fed8 docs: add sarathfrancis90 as a contributor for code (#951)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2026-09-06 19:16:55 +08:00
Sarath FrancisandGitHub 9af92f5d8c fix(url_decode): keep %2B as a literal plus when decoding (#939)
url_decode decoded the percent-encoding first and only then replaced
"+" with a space, so a "%2B" became "+" and was immediately turned into
a space. Any literal "+" was therefore lost when round-tripped through
url_encode. I now replace "+" with a space before decodeURIComponent,
which lines up with Ruby's CGI.unescape used by Shopify.
2026-09-06 19:16:16 +08:00
semantic-release-bot 747bdbdbee chore(release): 10.29.0 [skip ci]
# [10.29.0](https://github.com/harttle/liquidjs/compare/v10.28.0...v10.29.0) (2026-08-11)

### Features

* add unregisterFilter method ([#946](https://github.com/harttle/liquidjs/issues/946)) ([69b2c58](https://github.com/harttle/liquidjs/commit/69b2c589f9b69a34427cb8533ddb938bd997914f))
* **filters:** add squish filter ([#943](https://github.com/harttle/liquidjs/issues/943)) ([875513f](https://github.com/harttle/liquidjs/commit/875513f4c5136bed0c64562cccabb21a7db8d36c))
2026-08-11 12:53:56 +00:00
MildlyMeticulousandGitHub 875513f4c5 feat(filters): add squish filter (#943) 2026-08-11 20:52:07 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
f88a528e27 docs: add YacovGold as a contributor for code (#947)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2026-08-11 20:04:09 +08:00
69b2c589f9 feat: add unregisterFilter method (#946)
* feat: add unregisterFilter method

* docs: show how to re-register built-in filters

---------

Co-authored-by: Yacov <yacov@noemail>
2026-08-11 19:58:25 +08:00
semantic-release-bot 88ae297c1b chore(release): 10.28.0 [skip ci]
# [10.28.0](https://github.com/harttle/liquidjs/compare/v10.27.2...v10.28.0) (2026-08-01)

### Bug Fixes

* **date:** %s returns Unix epoch unaffected by display timezone ([#932](https://github.com/harttle/liquidjs/issues/932)) ([39c8743](https://github.com/harttle/liquidjs/commit/39c87437c5ef38ede9a208c9d55cd13231c6c023)), closes [#931](https://github.com/harttle/liquidjs/issues/931)

### Features

* Add support of inner expressions enclosed by parentheses ([#863](https://github.com/harttle/liquidjs/issues/863)) ([afa5f54](https://github.com/harttle/liquidjs/commit/afa5f5400428fc1ec935aca0282e579224660c95))
2026-08-01 10:18:25 +00:00
afa5f54004 feat: Add support of inner expressions enclosed by parentheses (#863)
* Add support of inner expressions enclosed by parentheses

* Add support of inner expressions enclosed by parentheses

Made-with: Cursor

* simplify implementation

* fix lint

* fix test

* Enhance tests for parenthesized filter chains in Liquid tags. Added scenarios for enabled and disabled grouped expressions in case, for, if, unless tags, ensuring proper handling of expressions and error throwing for invalid syntax.

* test: remove duplicate readGroupedExpression test block

The readGroupedExpression() test suite was duplicated twice in the spec file. Removed the duplicate block to avoid redundant test execution.

* refactor: extract extractGroupedExpressionTokenVariables helper

Extract inline grouped expression variable extraction logic into a dedicated
function for consistency with other extractors (extractFilteredValueVariables,
extractPropertyAccessVariable).

This addresses PR #863 comment 7 - improves code organization and
maintainability.

* refactor(types): explicit type for collection in for tag

collection: ValueToken | GroupedExpressionToken

Addresses PR #863 comment 5.

* refactor: evaluate grouped expressions at render time with resolvedFilters

Addresses PR review comments 4, 6, 8, 9 - moves grouped expression evaluation
from parse-time resolution to render-time lazy evaluation following the
generator-based async/sync duality pattern used throughout liquidjs.

Key changes:
- Replace resolvedValue (Value instance) with resolvedFilters (Filter[])
- Rename resolveGroupedExpressions() to resolveGroupedExpressionFilters()
- Move evaluation logic to evalGroupedExpressionToken() at render time
- Build Filter instances at parse time (carry liquid reference for render)
- Evaluate expression and apply filters lazily via generators
- Add support for tablerow tag with grouped expressions
- Remove duplicate getFilter() method in Value class

Maintains proper layering (tokens → render → templates) and consistency
with Value.value() pattern. Filter resolution still happens at parse time
since it requires liquid.filters access, but actual evaluation is deferred
to render time.

Tags that store raw ValueToken (for, case when-values, tablerow) still need
explicit resolveGroupedExpressionFilters() calls. Tags that wrap with
new Value() get automatic recursive resolution via Value constructor.

* refactor: reuse FilteredValueToken and fix architectural layering

Replace GroupedExpressionToken with existing FilteredValueToken to avoid
code duplication and fix layering violation where tokens depended on
templates (Filter instances).

Key changes:
- Reuse FilteredValueToken instead of GroupedExpressionToken
- Simplify readGroupOrRange() to return FilteredValueToken | RangeToken
- Add liquid reference to Context for runtime filter resolution
- Build Filter instances at render time in evalFilteredValueToken()
- Remove resolveGroupedExpressionFilters() and parse-time resolution
- Remove explicit resolution calls from tag constructors

This maintains proper architectural layering (tokens → render → templates)
with no backward dependencies, as requested in PR review feedback.

All 1537 tests pass.

* revert redundant'

* refactor: make getFilter private and improve code organization

* test: fix test name in case.spec.ts for when disabled block

* refactor: no need for Deprecated flag

* test: fix test name and logic to properly test if tag with nested expressions

* feat: support real parenthesis grouping in grouped expressions

Allow arbitrary expressions inside parentheses (e.g. ((a | upcase) > 3)
and (1 < 3)) when groupedExpressions is enabled, reusing readFilteredValue
for the general case while keeping range and filter-chain fast paths.

* feat: enhance expression tokenization with new generator methods

Added `readExpressionTokensFromHere` and `readGroupedExpressionTokens` methods to improve the handling of expression tokens. This refactor simplifies the token reading process and maintains compatibility with existing grouped expressions, ensuring proper evaluation and filtering.

* add tests

* address comments

---------

Co-authored-by: Omri Rosner <[email protected]>
2026-08-01 18:15:54 +08:00
amit777andGitHub 39c87437c5 fix(date): %s returns Unix epoch unaffected by display timezone (#932)
The %s handler read LiquidDate.getTime(), which returns the
displayDate deliberately shifted by the display timezone offset for
wall-clock getters. With a timezone argument or timezoneOffset
option set, %s produced an epoch shifted by (server offset - display
offset) instead of the true Unix timestamp.

Expose the unshifted time as LiquidDate.dateValue() and use it for
%s. Also switch Math.round to Math.floor so fractional seconds
truncate toward the epoch like Ruby strftime.

Fixes #931
2026-07-10 23:13:07 +08:00
semantic-release-bot 050f161794 chore(release): 10.27.2 [skip ci]
## [10.27.2](https://github.com/harttle/liquidjs/compare/v10.27.1...v10.27.2) (2026-07-09)

### Bug Fixes

* charge join/json/inspect filters by produced output size ([#925](https://github.com/harttle/liquidjs/issues/925)) ([7ab49f9](https://github.com/harttle/liquidjs/commit/7ab49f999ac045ec1e87f3a7a9fd68dd9e8602b3))
* **date:** zero-pad milliseconds when formatting %N fractional seconds ([#929](https://github.com/harttle/liquidjs/issues/929)) ([2634f9d](https://github.com/harttle/liquidjs/commit/2634f9de7b1228cd887b7cab880af8a795c77053))
* enforce ownPropertyOnly for inherited array indices ([#924](https://github.com/harttle/liquidjs/issues/924)) ([552819a](https://github.com/harttle/liquidjs/commit/552819a84b80c62306fe61072628a756272dc749))
* **filters:** modulo should follow divisor sign for negative operands ([#922](https://github.com/harttle/liquidjs/issues/922)) ([568bd5f](https://github.com/harttle/liquidjs/commit/568bd5f9cb99f596292c09fd70b00284b8216f0c))
* **filters:** return empty for out-of-range slice begin or negative length ([#928](https://github.com/harttle/liquidjs/issues/928)) ([f9a1316](https://github.com/harttle/liquidjs/commit/f9a1316d161f4f20018c833160f42dfcf0cde507))
2026-07-09 15:18:38 +00:00
spokodevandGitHub 2634f9de7b fix(date): zero-pad milliseconds when formatting %N fractional seconds (#929)
%N renders the fractional part of the second. The milliseconds returned by
getMilliseconds() are the three most significant digits of that fraction and
must be zero-padded to three digits before use, otherwise sub-100ms values
lose their leading zeros:

  50ms => strftime("%N")  returned "500000000", expected "050000000"
   5ms => strftime("%3N") returned "500",       expected "005"

Pad the milliseconds to three digits before slicing to the requested width.
2026-07-09 23:16:44 +08:00
spokodevandGitHub f9a1316d16 fix(filters): return empty for out-of-range slice begin or negative length (#928)
Ruby/Shopify `slice` returns nil (rendered as an empty string or array) when
the begin offset falls outside the negative range or when the length is
negative. liquidjs forwarded the adjusted indices straight to
Array/String.prototype.slice, whose own negative-index handling produced
non-empty, incorrect output:

  {{ "hello" | slice: -10, 2 }}  => "he"   (expected "")
  {{ "Liquid" | slice: 1, -2 }}  => "iqui" (expected "")

Guard the adjusted begin and the length before slicing.
2026-07-09 22:41:46 +08:00
7ab49f999a fix: charge join/json/inspect filters by produced output size (#925)
* fix(filters): charge join/array_to_sentence_string by output size

join charged memoryLimit by array element count, not by the string it
produces, letting concat doubling (cheap reference copies) inflate an
array's element count and then materialize a huge string via join far
past the configured memoryLimit (GHSA-4r6h-5v86-94p3). Charge by the
sum of stringified element lengths plus separators before allocating.
Apply the same fix to the sibling array_to_sentence_string filter.

Co-authored-by: Cursor <[email protected]>

* refactor(filters): simplify join output-size accounting

Sum stringified element lengths in a single pass and keep the guarded
Array.prototype.join for the result, instead of building an intermediate
parts array.

Co-authored-by: Cursor <[email protected]>

* fix(filters): charge json/jsonify/inspect serialization to memoryLimit

json/jsonify/inspect serialized values without charging memoryLimit, so
a concat-doubled array (cheap reference copies) could be materialized
into a huge JSON string past the configured limit — the same unbounded
class as the join bug (GHSA-4r6h-5v86-94p3). Charge via a JSON.stringify
replacer that accounts string lengths as it walks, aborting mid-
serialization instead of allocating the full blob first.

Co-authored-by: Cursor <[email protected]>

* fix(memory): charge rendered output to memoryLimit at emission

Move output-length accounting into the emitters, which charge each
written chunk against ctx.memoryLimit right before it reaches the
result string or stream. Filters/tags now only pre-charge the extra
working memory they allocate apart from that output, so join drops its
bespoke output-size counting and charges array.length like its siblings.

The block.super capture emitter intentionally omits the limiter to
avoid double-counting content that is re-emitted through the final
emitter.

Co-authored-by: Cursor <[email protected]>

* refactor(filters): rely on emitter output charge for json/inspect/array_to_sentence_string

With rendered output charged at emission, these filters no longer need
bespoke output-size counting: the emitted case is covered by the final
emitter. Revert json/inspect to their original form and array_to_sentence_string
to its element-count charge, dropping the non-emitted `| size` guards.

Co-authored-by: Cursor <[email protected]>

* revert(memory): drop emitter output charge, restore filter output-size accounting

join/array_to_sentence_string/json/inspect charge memoryLimit by the
string they materialize (not element count), so discarded results like
{% assign out = a | join %}{{ out | size }} are still bounded.

Remove the emitter-level limiter added in 2f343f063; it cannot catch
materialized-but-not-emitted values.

Co-authored-by: Cursor <[email protected]>

* fix(filters): charge json/inspect replacer by serialized node size

Replace the flat 1-unit charge for non-string JSON nodes with per-type
estimates (primitives via JSON.stringify length, containers by structure).

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-07-06 23:54:00 +08:00
552819a84b fix: enforce ownPropertyOnly for inherited array indices (#924)
* fix: enforce ownPropertyOnly for inherited array indices

Route array index access (including negative indices, first/last, and the
first/last filters) through a shared readArrayElement helper so that
ownPropertyOnly hides prototype-inherited array indices, closing the
GHSA-fwxr-j5w2-587m bypass. The option's scope (property/index access
only, not filter transforms or iteration) is documented on the option.

Co-authored-by: Cursor <[email protected]>

* fix(filters): invoke Array.prototype methods on unsanitized array values

Call built-ins via Array.prototype.<m>.call(...) for values that come
from scope (join, compact, concat, slice, where/reject) so an overridden
instance method on unsanitized data cannot hijack filter behavior.
Methods on freshly-created arrays are left as-is.

Co-authored-by: Cursor <[email protected]>

* fix(filters): use String.prototype.slice for the string branch of slice

Route the non-array branch through String.prototype.slice.call so the
slice filter never dispatches through a possibly-overridden instance
method, matching the Array.prototype guard.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-07-06 20:12:09 +08:00
allcontributors[bot]GitHuballcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
8bfb6428ae docs: add spokodev as a contributor for code (#923)
* docs: update README.md [skip ci]

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2026-06-26 01:21:05 +08:00
spokodevandGitHub 568bd5f9cb fix(filters): modulo should follow divisor sign for negative operands (#922)
The `modulo` filter used JavaScript's `%` (truncated remainder, sign
follows the dividend). Shopify/Ruby Liquid uses floored modulo, where the
result takes the sign of the divisor. Since liquidjs advertises Shopify
compatibility, negative operands produced the wrong sign.

Use `((v % arg) + arg) % arg` to match Ruby's `%`. Positive-operand
results are unchanged.
2026-06-26 01:19:59 +08:00
semantic-release-bot ed489865b6 chore(release): 10.27.1 [skip ci]
## [10.27.1](https://github.com/harttle/liquidjs/compare/v10.27.0...v10.27.1) (2026-06-23)

### Bug Fixes

* improve round function; improvement to [#873](https://github.com/harttle/liquidjs/issues/873) ([#901](https://github.com/harttle/liquidjs/issues/901)) ([956b51e](https://github.com/harttle/liquidjs/commit/956b51ea953eb52d9eba7409b7f51e379023fec4))
* **security:** charge pop filter allocation to memoryLimit ([#907](https://github.com/harttle/liquidjs/issues/907)) ([8a0c74a](https://github.com/harttle/liquidjs/commit/8a0c74a7fcb1671aa1dcb71ec82ba0602dc90d04))
* **strip_html:** infinite loop for strip_html ([5c3522f](https://github.com/harttle/liquidjs/commit/5c3522f33928aae66f0fe85c36e1d9015c768fe2))

### Performance Improvements

* **parser:** memoize createTrie to avoid rebuilding tries per Tokenizer ([#911](https://github.com/harttle/liquidjs/issues/911)) ([3a0d80d](https://github.com/harttle/liquidjs/commit/3a0d80d1f4526af0fbca2bb2e0a9c51669d2fd3e))
2026-06-23 10:43:45 +00:00
afec88b04c docs(readme): README overhaul, used-by grid, and docs homepage (#914)
* docs(readme): lead with quick start and scannable structure

Restructure the README to match common OSS conventions: tagline and
badges above the fold, copy-paste Quick start, Features list, and a
compact Used by section. Remove the star plea, centered logo, and
per-project marketing blurbs that pushed useful content down.

Co-authored-by: Cursor <[email protected]>

* docs(readme): playground GIF, used-by grid, and docs homepage sync

Add data/used-by.json with build:used-by for README and docs homepage, playground demo capture, and shared home-section layout. Used by lists products with site logos; Financial Support keeps org and individual sponsors.

Co-authored-by: Cursor <[email protected]>

* chore: use .local for playground capture scratch files

Co-authored-by: Cursor <[email protected]>

* fix: satisfy eslint in build-used-by and capture scripts

Co-authored-by: Cursor <[email protected]>

* chore: drop one-off playground capture script

Co-authored-by: Cursor <[email protected]>

* refactor(docs): copy Used by from README like financial contributors

Drop data/used-by.json and build-used-by.js; build-contributors.js now extracts USED-BY-BEGIN/END to used-by.swig.

Co-authored-by: Cursor <[email protected]>

* refactor(docs): inline Used by section, drop home-section partial

Co-authored-by: Cursor <[email protected]>

* fix(docs): drop redundant logo styles from .contributors

Co-authored-by: Cursor <[email protected]>

* fix(docs): build liquid bundle before hexo serve

Co-authored-by: Cursor <[email protected]>

* refactor(docs): drop playground window chrome from capture demo

Co-authored-by: Cursor <[email protected]>

* refactor(docs): revert playground capture changes to master behavior

Restore Ace output pane, drop Prism and output-preview styling. Simplify docs:dev to rely on docs prestart.

Co-authored-by: Cursor <[email protected]>

* refactor(docs): rely on docs prebuild for liquid bundle and contributors

Co-authored-by: Cursor <[email protected]>

* feat(docs): show playground output as Prism-highlighted HTML code

Co-authored-by: Cursor <[email protected]>

* feat(docs): polish playground layout and regenerate README demo GIF

* fix(docs): align playground GIF capture with live editor styling

* fix(docs): unify playground pane padding and hold output on errors

Match editor inset to the output panel, drop Prism from output preview,
keep the last render while typing invalid template/context, and refresh
the README demo GIF.

* fix(docs): regenerate playground GIF with held output during typing

* feat(docs): sync Used by logos and polish playground

Inline README Used by grid on the docs homepage, refine playground layout and live output behavior, and drop the unused build-used-by script from package scripts.

Co-authored-by: Cursor <[email protected]>

* fix(docs): restore Rock RMS logo and remove duplicate entry

Restore the official Rock RMS wordmark (GetImage.ashx?id=72534) instead of the SparkDevNetwork GitHub org avatar that was wrongly substituted for it.

Co-authored-by: Cursor <[email protected]>

* fix(docs): regenerate playground GIF with live indicator states

Restore the capture script for the new pane-indicator layout so the README demo shows correct idle/active/pending/ok colors and pulsing animations while typing.

* fix(docs): static playground GIF with correct indicator colors

Capture one frame per keystroke with animations disabled so dot states
(idle/active/pending/ok) match the live playground without pulsing.

Co-authored-by: Cursor <[email protected]>

* docs: use square Rock RMS icon in Used by section

Co-authored-by: Cursor <[email protected]>

* fix(docs): ensure capture indicator colors apply instantly

Disable indicator transitions and cancel active animations before
setting data-state so pending yellow is not stuck on the prior ok green.

Co-authored-by: Cursor <[email protected]>

* docs: point Microsoft Used by link to microsoft.com

The merged tile title covers Power Pages and Azure API Management; href should go to Microsoft home, not Power Pages only.

Co-authored-by: Cursor <[email protected]>

* docs: remove Dailycontributors from Used by section

No evidence they run on LiquidJS; they are an OpenCollective sponsor only.

Co-authored-by: Cursor <[email protected]>

* docs: reword intro to say Liquid, not Shopify Liquid

Move Shopify into the compatibility list and drop the shopify/liquid link from README; align package.json description.

Co-authored-by: Cursor <[email protected]>

* fix(docs): restore playground output as Prism-highlighted HTML

Co-authored-by: Cursor <[email protected]>

* docs: add extensible to README intro and package description

EOF

Co-authored-by: Cursor <[email protected]>

* chore: move playground capture script to .local

Co-authored-by: Cursor <[email protected]>

* fix(docs): drop unused Ace mode-html from playground

Output pane uses Prism, not Ace; template and context editors still need liquid/json modes and basePath for themes.

Co-authored-by: Cursor <[email protected]>

* docs: sync intro sentence across package and site metadata

Align package.json, docs config, manifest, llms.txt, and AGENTS.md tagline to the README canonical description.

Co-authored-by: Cursor <[email protected]>

* docs: trim verbose intro in intro-to-liquid tutorial

Remove README tagline and repo-purpose copy duplicated by the recent metadata sync.

Co-authored-by: Cursor <[email protected]>

* docs: shorten homepage banner subtitle

Trim docs site banner and short taglines after the em dash; keep full description for meta tags and npm/README.

* docs: simplify playground GIF caption in README

Co-authored-by: Cursor <[email protected]>

* docs: dedupe homepage subtitle and description into _config.yml

Remove redundant front matter from index.pug; theme falls back to site config for banner and meta tags.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-06-23 17:39:19 +08:00
Max MedveandGitHub 3a0d80d1f4 perf(parser): memoize createTrie to avoid rebuilding tries per Tokenizer (#911)
The Tokenizer constructor calls createTrie(operators) and
createTrie(literalValues) on every instantiation, and liquidjs builds a
fresh Tokenizer per output/tag while parsing. On typical templates this
rebuilt the same prefix-tries dozens of times and showed up as a large
share of parse CPU in profiling.

Memoize createTrie with a module-level WeakMap keyed on the input object.
The inputs (operators, literalValues) are stable references and the trie
is only ever read afterward (via matchTrie), never mutated, so caching by
reference is behavior-preserving. WeakMap (not Map) lets short-lived,
per-instance operator objects and their tries be garbage collected.
2026-06-22 20:11:06 +08:00
Timmy BraunandGitHub 956b51ea95 fix: improve round function; improvement to #873 (#901) 2026-06-22 20:07:27 +08:00
74 changed files with 1349 additions and 247 deletions
+27
View File
@@ -838,6 +838,33 @@
"contributions": [
"code"
]
},
{
"login": "spokodev",
"name": "spokodev",
"avatar_url": "https://avatars.githubusercontent.com/u/239690017?v=4",
"profile": "https://github.com/spokodev",
"contributions": [
"code"
]
},
{
"login": "YacovGold",
"name": "YacovGold",
"avatar_url": "https://avatars.githubusercontent.com/u/8984042?v=4",
"profile": "https://github.com/YacovGold",
"contributions": [
"code"
]
},
{
"login": "sarathfrancis90",
"name": "Sarath Francis",
"avatar_url": "https://avatars.githubusercontent.com/u/9289498?v=4",
"profile": "https://github.com/sarathfrancis90",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
+1
View File
@@ -15,6 +15,7 @@ node_modules/
docs/themes/navy/source/js/liquid.browser.min.js
docs/themes/navy/layout/partial/all-contributors.swig
docs/themes/navy/layout/partial/financial-contributors.swig
docs/themes/navy/layout/partial/used-by.swig
dist/
demo/*/yarn.json
+1 -1
View File
@@ -1,6 +1,6 @@
# LiquidJS
Shopify / GitHub Pages compatible Liquid template engine. TypeScript in `src/`, bundles in `dist/`. Docs site in `docs/` (Hexo, `navy` theme).
A simple, expressive, extensible Liquid template engine for JavaScript — Shopify, Jekyll and GitHub Pages compatible, for Node.js, browsers, and the CLI, with TypeScript support. TypeScript in `src/`, bundles in `dist/`. Docs site in `docs/` (Hexo, `navy` theme).
## Layout
+45
View File
@@ -1,3 +1,48 @@
# [10.29.0](https://github.com/harttle/liquidjs/compare/v10.28.0...v10.29.0) (2026-08-11)
### Features
* add unregisterFilter method ([#946](https://github.com/harttle/liquidjs/issues/946)) ([69b2c58](https://github.com/harttle/liquidjs/commit/69b2c589f9b69a34427cb8533ddb938bd997914f))
* **filters:** add squish filter ([#943](https://github.com/harttle/liquidjs/issues/943)) ([875513f](https://github.com/harttle/liquidjs/commit/875513f4c5136bed0c64562cccabb21a7db8d36c))
# [10.28.0](https://github.com/harttle/liquidjs/compare/v10.27.2...v10.28.0) (2026-08-01)
### Bug Fixes
* **date:** %s returns Unix epoch unaffected by display timezone ([#932](https://github.com/harttle/liquidjs/issues/932)) ([39c8743](https://github.com/harttle/liquidjs/commit/39c87437c5ef38ede9a208c9d55cd13231c6c023)), closes [#931](https://github.com/harttle/liquidjs/issues/931)
### Features
* Add support of inner expressions enclosed by parentheses ([#863](https://github.com/harttle/liquidjs/issues/863)) ([afa5f54](https://github.com/harttle/liquidjs/commit/afa5f5400428fc1ec935aca0282e579224660c95))
## [10.27.2](https://github.com/harttle/liquidjs/compare/v10.27.1...v10.27.2) (2026-07-09)
### Bug Fixes
* charge join/json/inspect filters by produced output size ([#925](https://github.com/harttle/liquidjs/issues/925)) ([7ab49f9](https://github.com/harttle/liquidjs/commit/7ab49f999ac045ec1e87f3a7a9fd68dd9e8602b3))
* **date:** zero-pad milliseconds when formatting %N fractional seconds ([#929](https://github.com/harttle/liquidjs/issues/929)) ([2634f9d](https://github.com/harttle/liquidjs/commit/2634f9de7b1228cd887b7cab880af8a795c77053))
* enforce ownPropertyOnly for inherited array indices ([#924](https://github.com/harttle/liquidjs/issues/924)) ([552819a](https://github.com/harttle/liquidjs/commit/552819a84b80c62306fe61072628a756272dc749))
* **filters:** modulo should follow divisor sign for negative operands ([#922](https://github.com/harttle/liquidjs/issues/922)) ([568bd5f](https://github.com/harttle/liquidjs/commit/568bd5f9cb99f596292c09fd70b00284b8216f0c))
* **filters:** return empty for out-of-range slice begin or negative length ([#928](https://github.com/harttle/liquidjs/issues/928)) ([f9a1316](https://github.com/harttle/liquidjs/commit/f9a1316d161f4f20018c833160f42dfcf0cde507))
## [10.27.1](https://github.com/harttle/liquidjs/compare/v10.27.0...v10.27.1) (2026-06-23)
### Bug Fixes
* improve round function; improvement to [#873](https://github.com/harttle/liquidjs/issues/873) ([#901](https://github.com/harttle/liquidjs/issues/901)) ([956b51e](https://github.com/harttle/liquidjs/commit/956b51ea953eb52d9eba7409b7f51e379023fec4))
* **security:** charge pop filter allocation to memoryLimit ([#907](https://github.com/harttle/liquidjs/issues/907)) ([8a0c74a](https://github.com/harttle/liquidjs/commit/8a0c74a7fcb1671aa1dcb71ec82ba0602dc90d04))
* **strip_html:** infinite loop for strip_html ([5c3522f](https://github.com/harttle/liquidjs/commit/5c3522f33928aae66f0fe85c36e1d9015c768fe2))
### Performance Improvements
* **parser:** memoize createTrie to avoid rebuilding tries per Tokenizer ([#911](https://github.com/harttle/liquidjs/issues/911)) ([3a0d80d](https://github.com/harttle/liquidjs/commit/3a0d80d1f4526af0fbca2bb2e0a9c51669d2fd3e))
# [10.27.0](https://github.com/harttle/liquidjs/compare/v10.26.0...v10.27.0) (2026-05-15)
+63 -42
View File
@@ -1,72 +1,86 @@
# liquidjs
# LiquidJS
A simple, expressive, extensible Liquid template engine for JavaScript — Shopify, Jekyll and GitHub Pages compatible, for Node.js, browsers, and the CLI, with TypeScript support.
[![npm version](https://img.shields.io/npm/v/liquidjs.svg?logo=npm&style=flat-square)](https://www.npmjs.org/package/liquidjs)
[![npm downloads](https://img.shields.io/npm/dm/liquidjs.svg?style=flat-square)](https://www.npmjs.org/package/liquidjs)
[![Coverage](https://img.shields.io/coveralls/harttle/liquidjs.svg?style=flat-square)](https://coveralls.io/github/harttle/liquidjs?branch=master)
[![Build Status](https://img.shields.io/github/actions/workflow/status/harttle/liquidjs/ci-build.yml?branch=master&style=flat-square)](https://github.com/harttle/liquidjs/actions/workflows/ci-build.yml?query=branch%3Amaster)
[![DUB license](https://img.shields.io/dub/l/vibe-d.svg?style=flat-square)](https://github.com/harttle/liquidjs/blob/master/LICENSE)
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg?style=flat-square)](https://github.com/harttle/liquidjs)
[![Coverage](https://img.shields.io/coveralls/harttle/liquidjs.svg?style=flat-square)](https://coveralls.io/github/harttle/liquidjs?branch=master)
[![License: MIT](https://img.shields.io/github/license/harttle/liquidjs?style=flat-square)](https://github.com/harttle/liquidjs/blob/master/LICENSE)
A simple, expressive and safe [Shopify][shopify/liquid] / GitHub Pages compatible template engine in pure JavaScript.
**The purpose of this repo** is to provide a standard Liquid implementation for the JavaScript community so that [Jekyll sites](https://jekyllrb.com), [GitHub Pages](https://pages.github.com/) and [Shopify templates](https://themes.shopify.com/) can be ported to Node.js without pain.
[Documentation][doc] · [Playground](https://liquidjs.com/playground.html) · [Setup guide][setup] · [Contributing][contribution]
* [Documentation][doc]
* Please star [LiquidJS on GitHub][github]!
* Financial support via [GitHub Sponsors](https://github.com/sponsors/harttle).
<a href="https://liquidjs.com/playground.html">
<img src="docs/source/playground-demo.gif" alt="LiquidJS playground: edit a template and context, see live HTML output" width="980" style="display: block; margin: 0 auto;" />
</a>
<p align="center"><a href="https://liquidjs.com"><img height="155px" width="155px" src="https://liquidjs.com/icon/mstile-310x310.png" alt="logo"></a></p>
<p align="center"><sub>Try the <a href="https://liquidjs.com/playground.html">online playground</a>.</sub></p>
## What's it like?
## Quick start
Basically there're two types of Liquid syntax: tags enclosed by `{% %}` and outputs enclosed by `{{ }}`. A Liquid template looks like:
```js
import { Liquid } from 'liquidjs'
```liquid
{% if username %}
{{ username | append: ", welcome to LiquidJS!" | capitalize }}
{% endif %}
const engine = new Liquid()
const html = await engine.parseAndRender(
'Hello, {{ name | capitalize }}!',
{ name: 'liquid' }
)
//=> 'Hello, Liquid!'
```
[A live demo](https://liquidjs.com/playground.html) is also available and here's a [quick tutorial](https://liquidjs.com/tutorials/intro-to-liquid.html) for Liquid syntax.
## Installation
Install from npm in Node.js:
**Node.js**
```bash
npm install liquidjs
```
Or use the UMD bundle from jsDelivr:
**Browser** (jsDelivr UMD bundle)
```html
<script src="https://cdn.jsdelivr.net/npm/liquidjs/dist/liquid.browser.min.js"></script>
```
Or render directly from CLI using npx:
**CLI**
```bash
npx liquidjs --template 'Hello, {{ name }}!' --context '{"name": "Snake"}'
npx liquidjs --template 'Hello, {{ name }}!' --context '{"name": "Liquid"}'
```
For more details, refer to the [Setup Guide][setup].
See the [setup guide][setup] for partials, layouts, caching, and other options.
## Who's Using LiquidJS?
## Used by
- [Eleventy](https://www.11ty.dev/): Eleventy, a simpler static site generator.
- [Github Docs](https://github.com/github/docs): The open-source repo for docs.github.com.
- [Kibana](https://github.com/elastic/kibana): Elastic's analytics and visualization platform for Elasticsearch; workflow features use LiquidJS for Liquid templates.
- [Opensense](https://www.opensense.com/): The smarter way to send email.
- [Directus](https://docs.directus.io/): an instant REST+GraphQL API and intuitive no-code data collaboration app for any SQL database.
- [Rock](https://www.rockrms.com/): An open source CMS, Relationship Management System (RMS) and Church Management System (ChMS) all rolled into one.
- [Mitosis](https://github.com/BuilderIO/mitosis): Write components once, run everywhere. Compiles to React, Vue, Qwik, Solid, Angular, Svelte, and more.
- [Pattern Lab](https://patternlab.io/): a frontend workshop environment that helps you build, view, test, and showcase your design system's UI components.
- [Builder.io](https://www.builder.io/m/developers): the first and only headless CMS with a visual editor that lets you drag and drop with your components, directly within your current site or app. Completely API-driven, for cleaner code and simpler workflows.
- [Microsoft Power Pages](https://learn.microsoft.com/en-us/power-pages/introduction): a secure, enterprise-grade, low-code software as a service (SaaS) platform for creating, hosting, and administering modern external-facing business websites.
- [Azure API Management developer portal](https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-developer-portal): an automatically generated, fully customizable website with the documentation of your APIs.
- [WISMOlabs](https://wismolabs.com/): Post Purchase Experience platform for eCommerce retailers enhancing customer satisfaction by using LiquidJS to provide customizable post-purchase experiences through programmable email, SMS, order tracking pages, and webhooks.
- [Freshet](https://chromewebstore.google.com/detail/freshet/mpclplhdencffbilobpcapccnihpelcg): *JSON in, page out* — a Chrome extension that uses LiquidJS templates per URL pattern, so the JSON becomes a rendered, useful page.
<!-- USED-BY-BEGIN -->
<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" title="Opensense"/></a>
<a href="https://www.microsoft.com/" 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="Power Pages, Azure API Management developer portal" title="Power Pages, Azure API Management developer portal"/></a>
<a href="https://docs.github.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/9919?v=4&amp;s=100" height="80" style="vertical-align: middle;" alt="GitHub Docs" title="GitHub Docs"/></a>
<a href="https://github.com/elastic/kibana" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/6764390?v=4&s=100" height="80" style="vertical-align: middle;" alt="Kibana" title="Kibana"/></a>
<a href="https://www.shopify.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/8085?v=4&amp;s=100" height="80" style="vertical-align: middle;" alt="Shopify CLI, Checkout Blocks" title="Shopify CLI, 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>
<br/>
<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>
<a href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=liquidjs" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/27130435?s=200&v=4" width="80" style="vertical-align: middle;" alt="TestMu AI" title="TestMu AI"/></a>
<a href="https://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://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>
<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>
<br/>
<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://directus.io/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/15967950?v=4&amp;s=100" height="80" style="vertical-align: middle;" alt="Directus" title="Directus"/></a>
<a href="https://www.builder.io/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F51a002f4a17f4fc4a829b8891a1c25ee" height="80" style="vertical-align: middle;" alt="Builder.io, Mitosis" title="Builder.io, Mitosis"/></a>
<a href="https://patternlab.io/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://avatars.githubusercontent.com/u/4733935?v=4&amp;s=100" height="80" style="vertical-align: middle;" alt="Pattern Lab" title="Pattern Lab"/></a>
<a href="https://www.rockrms.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://www.rockrms.com/GetImage.ashx?id=72533" height="80" style="vertical-align: middle;" alt="Rock RMS" title="Rock RMS"/></a>
<a href="https://wismolabs.com/" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://cdn-ldlap.nitrocdn.com/DsLJMZpUUekfsitqBnNmsRAnzbwPoIzE/assets/images/optimized/rev-5df8687/wismolabs.com/wp-content/uploads/2023/03/favicon-300x300.png" height="80" style="vertical-align: middle;" alt="WISMOlabs" title="WISMOlabs"/></a>
<a href="https://chromewebstore.google.com/detail/freshet/mpclplhdencffbilobpcapccnihpelcg" style="display: inline-block; vertical-align: middle; margin: 8px;"><img src="https://raw.githubusercontent.com/MattAltermatt/freshet/main/public/icon-128.png" height="80" style="vertical-align: middle;" alt="Freshet" title="Freshet"/></a>
</p>
<!-- USED-BY-END -->
Feel free to create a PR or contact me to add your use case into this list!
Products and projects running on LiquidJS. [Open a PR](https://github.com/harttle/liquidjs/edit/master/README.md) to add yours.
## Financial Support
@@ -78,7 +92,7 @@ If you personally love LiquidJS or it's benefiting your business, please conside
<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://github.com/Checkout-Blocks" 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/>
@@ -226,6 +240,11 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
<td align="center" valign="top" width="14.28%"><a href="https://github.com/talboren"><img src="https://avatars.githubusercontent.com/u/68807791?v=4?s=100" width="100px;" alt="Tal"/><br /><sub><b>Tal</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=talboren" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://filonov.dev"><img src="https://avatars.githubusercontent.com/u/813224?v=4?s=100" width="100px;" alt="Vladimir Filonov"/><br /><sub><b>Vladimir Filonov</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=VladimirFilonov" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/spokodev"><img src="https://avatars.githubusercontent.com/u/239690017?v=4?s=100" width="100px;" alt="spokodev"/><br /><sub><b>spokodev</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=spokodev" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/YacovGold"><img src="https://avatars.githubusercontent.com/u/8984042?v=4?s=100" width="100px;" alt="YacovGold"/><br /><sub><b>YacovGold</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=YacovGold" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sarathfrancis90"><img src="https://avatars.githubusercontent.com/u/9289498?v=4?s=100" width="100px;" alt="Sarath Francis"/><br /><sub><b>Sarath Francis</b></sub></a><br /><a href="https://github.com/harttle/liquidjs/commits?author=sarathfrancis90" title="Code">💻</a></td>
</tr>
</tbody>
</table>
@@ -234,8 +253,10 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
<!-- ALL-CONTRIBUTORS-LIST:END -->
[shopify/liquid]: https://shopify.github.io/liquid/
[plugins]: https://liquidjs.com/tutorials/plugins.html#Plugin-List
## License
[MIT](LICENSE) © [Jun Yang](https://github.com/harttle)
[setup]: https://liquidjs.com/tutorials/setup.html
[doc]: https://liquidjs.com
[github]: https://github.com/harttle/liquidjs
+2
View File
@@ -33,7 +33,9 @@ function transformFinancial (html) {
const allContributors = transformContributors(extractSection(readme, 'ALL-CONTRIBUTORS-LIST:START', 'ALL-CONTRIBUTORS-LIST:END'))
const financialContributors = transformFinancial(extractSection(readme, 'FINANCIAL-CONTRIBUTORS-BEGIN', 'FINANCIAL-CONTRIBUTORS-END'))
const usedBy = transformFinancial(extractSection(readme, 'USED-BY-BEGIN', 'USED-BY-END'))
const outDir = path.join(root, 'docs/themes/navy/layout/partial')
fs.writeFileSync(path.join(outDir, 'all-contributors.swig'), allContributors)
fs.writeFileSync(path.join(outDir, 'financial-contributors.swig'), financialContributors)
fs.writeFileSync(path.join(outDir, 'used-by.swig'), usedBy)
+2 -2
View File
@@ -1,6 +1,6 @@
title: LiquidJS
subtitle: "A simple, expressive, and safe template engine for JavaScript."
description: "LiquidJS is a simple, expressive, and safe template engine for JavaScript, compatible with Shopify and GitHub Pages."
subtitle: "A simple, expressive, extensible Liquid template engine for JavaScript"
description: "A simple, expressive, extensible Liquid template engine for JavaScript — Shopify, Jekyll and GitHub Pages compatible, for Node.js, browsers, and the CLI, with TypeScript support."
author: Harttle
language: en
timezone: UTC
+1
View File
@@ -94,6 +94,7 @@ filters:
sort: sort.html
sort_natural: sort_natural.html
split: split.html
squish: squish.html
strip: strip.html
strip_html: strip_html.html
strip_newlines: strip_newlines.html
+1
View File
@@ -34,6 +34,7 @@ The `date` filter is used to convert a timestamp into the specified format.
* minutes: `-360` means `'+06:00'` and `360` means `'-06:00'`
* timeZone ID: `Asia/Colombo` or `America/New_York`
* See [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for TZ database values
* `%s` (seconds since the Unix epoch) identifies an instant rather than a wall-clock time, so it's not affected by the display timezone.
### Examples
```liquid
+18
View File
@@ -0,0 +1,18 @@
---
title: squish
---
{% since %}v10.28.0{% endsince %}
Removes leading and trailing whitespace from a string, and replaces every run of whitespace inside it with a single space.
Input
```liquid
{{ " Hello there,
Major Tom. " | squish }}
```
Output
```text
Hello there, Major Tom.
```
-2
View File
@@ -1,6 +1,4 @@
layout: index
description: LiquidJS is a simple, expressive and safe Shopify / GitHub Pages compatible template engine in pure JavaScript.
subtitle: A simple, expressive and safe template engine.
---
ul#intro-feature-list
li.intro-feature-wrap
+1 -1
View File
@@ -1,6 +1,6 @@
# LiquidJS
> A simple, expressive and safe Shopify / Github Pages compatible template engine in pure JavaScript.
> A simple, expressive, extensible Liquid template engine for JavaScript
## Tutorials
+1 -1
View File
@@ -1,7 +1,7 @@
{
"short_name": "LiquidJS",
"name": "LiquidJS",
"description": "A simple, expressive and safe Shopify / GitHub Pages compatible template engine in pure JavaScript.",
"description": "A simple, expressive, extensible Liquid template engine for JavaScript",
"icons": [
{
"src": "/icon/apple-touch-icon-57x57.png",
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+2 -3
View File
@@ -3,9 +3,9 @@ title: The Liquid Template Language
describe: A short introduction to the Liquid template language and some simple demos.
---
LiquidJS is a simple, expressive and safe [Shopify][shopify/liquid] / GitHub Pages compatible template engine in pure JavaScript. The purpose of this repo is to provide a standard Liquid implementation for the JavaScript community. Liquid is originally implemented in Ruby and used by GitHub Pages, Jekyll and Shopify, see [Differences with Shopify/liquid][diff].
Liquid is a template language originally implemented in Ruby and used by Shopify, Jekyll, and GitHub Pages. LiquidJS implements it in JavaScript; see [Differences with Shopify/liquid][diff] for compatibility notes.
LiquidJS syntax is relatively simple. There are 2 types of markups in LiquidJS:
There are 2 types of markups in LiquidJS:
- **Tags**. A tag consists of a tag name and optional arguments wrapped between `{%raw%}{%{%endraw%}` and `%}`.
- **Outputs**. An output consists of a value and a list of filters, which is optional, wrapped between `{%raw%}{{{%endraw%}` and `}}`.
@@ -56,5 +56,4 @@ Typically tags appear in pairs with a start tag and a corresponding end tag. For
A complete list of tags supported by LiquidJS can be found [here](../tags/overview.html).
[shopify/liquid]: https://github.com/Shopify/liquid
[diff]: ./differences.html
+19 -1
View File
@@ -62,7 +62,23 @@ See existing filter implementations here: <https://github.com/harttle/liquidjs/t
## Unregister Tags/Filters
In some cases it's desirable to disable some tags/filters (see [#324](https://github.com/harttle/liquidjs/issues/324)). You'll need to register a dummy tag/filter that throws a corresponding Error.
Filters can be unregistered by name:
```javascript
engine.unregisterFilter('plus')
```
With [`strictFilters`][strict-filters] enabled, using an unregistered filter will throw an error. Otherwise, the filter will be skipped.
Built-in filters can be registered again using the exported `filters` object:
```javascript
import { filters } from 'liquidjs'
engine.registerFilter('plus', filters.plus)
```
To disable a tag, or to make a disabled filter throw regardless of `strictFilters`, register a dummy implementation that throws a corresponding error (see [#324](https://github.com/harttle/liquidjs/issues/324)):
```javascript
// disable a tag
@@ -81,3 +97,5 @@ function disabledFilter(name) {
}
engine.registerFilter('plus', disabledFilter('plus'));
```
[strict-filters]: /tutorials/options.html#strict
+5 -1
View File
@@ -13,10 +13,14 @@ index:
description: 'Thanks to these wonderful people! See <a href="tutorials/contribution-guidelines.html">contribution guidelines</a> if you&#39;d like to help.'
sponsors:
title: Sponsors
description: 'If you personally love LiquidJS or it&#39;s benefiting your business, please <a href="https://github.com/sponsors/harttle">sponsor us</a>!'
description: 'Organizations and individuals who <a href="https://github.com/sponsors/harttle">sponsor LiquidJS</a>. Thank you!'
used_by:
title: Used by
description: 'Products and projects running on LiquidJS. <a href="https://github.com/harttle/liquidjs/edit/master/README.md">Open a PR</a> to add yours.'
playground:
title: Playground
lead: Edit a template and context JSON — rendered HTML updates as you type.
loading: Loading...
page:
+18 -5
View File
@@ -1,6 +1,6 @@
<header id="banner" class="wrapper">
<div class="inner inner-content">
<h2 id="banner-title">{{ page.subtitle }}</h2>
<h2 id="banner-title">{{ page.subtitle | default(config.subtitle) }}</h2>
<div id="banner-share">{{ partial('partial/share') }}</div>
<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>
@@ -14,15 +14,15 @@
</div>
</div>
</div>
<div id="contributors-wrap">
<div id="used-by-wrap">
<div class="wrapper">
<div class="inner inner-content">
<div class="section-header">
<h3>{{__('index.contributors.title')}}</h3>
<p class="description">{{__('index.contributors.description')}}</p>
<h3>{{__('index.used_by.title')}}</h3>
<p class="description">{{__('index.used_by.description')}}</p>
</div>
<div class="contributors">
{{ partial('partial/all-contributors') }}
{{ partial('partial/used-by') }}
</div>
</div>
</div>
@@ -40,3 +40,16 @@
</div>
</div>
</div>
<div id="contributors-wrap">
<div class="wrapper">
<div class="inner inner-content">
<div class="section-header">
<h3>{{__('index.contributors.title')}}</h3>
<p class="description">{{__('index.contributors.description')}}</p>
</div>
<div class="contributors">
{{ partial('partial/all-contributors') }}
</div>
</div>
</div>
</div>
+5
View File
@@ -1,6 +1,11 @@
{% if page.layout === 'playground' %}
{{ js('js/liquid.browser.min.js') }}
<script src="https://cdn.jsdelivr.net/npm/[email protected]/src-min/ace.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/src-min/mode-liquid.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/src-min/mode-json.js"></script>
<script>ace.config.set('basePath', 'https://cdn.jsdelivr.net/npm/[email protected]/src-min/');</script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/prism.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/components/prism-markup.min.js"></script>
{% endif %}
{{ js('js/main') }}
+1 -5
View File
@@ -1,7 +1,3 @@
{
"people": [
"alice",
"bob",
"carol"
]
"name": "liquid"
}
+1 -9
View File
@@ -1,9 +1 @@
<ul>
{%- for person in people %}
<li>
<a href="{{person | prepend: "https://example.com/"}}">
{{ person | capitalize }}
</a>
</li>
{%- endfor%}
</ul>
<p>Hello, {{ name | capitalize }}!</p>
+37 -13
View File
@@ -1,21 +1,45 @@
<div id="playground" role="main">
<div class="wrapper">
<h1 class="inner">{{__('playground.title')}}</h1>
<header class="playground-hero inner">
<div class="playground-hero-text">
<h1>{{__('playground.title')}}</h1>
<p class="playground-lead">{{__('playground.lead')}}</p>
</div>
<p class="playground-version version"></p>
</header>
<div class="loader" role=status aria-busy=true></div>
<div id="editors" class="inner hide" aria-hide=true>
<div class="area-tpl editor-wrapper">
<h2>Template</h2>
<div class="editor" id="editorEl">{{ raw('partial/demo.liquid') }}</div>
</div>
<div class="area-data editor-wrapper">
<h2>Context</h2>
<div class="editor" id="dataEl">{{ raw('partial/demo.json') }}</div>
</div>
<div class="area-output editor-wrapper">
<h2>Output</h2>
<div class="editor" id="previewEl">{{__('playground.loading')}}</div>
<div class="playground-workspace">
<div class="playground-pane area-tpl">
<div class="pane-head">
<span class="pane-indicator" data-state="idle" aria-hidden="true"></span>
<h2>Template</h2>
</div>
<div class="pane-body">
<div class="editor" id="editorEl">{{ raw('partial/demo.liquid') }}</div>
</div>
</div>
<div class="playground-pane area-data">
<div class="pane-head">
<span class="pane-indicator" data-state="idle" aria-hidden="true"></span>
<h2>Context</h2>
</div>
<div class="pane-body">
<div class="editor" id="dataEl">{{ raw('partial/demo.json') }}</div>
</div>
</div>
<div class="playground-pane area-output">
<div class="pane-head">
<span class="pane-indicator" data-state="idle" aria-hidden="true"></span>
<h2>Output</h2>
</div>
<div class="pane-body">
<div class="output-preview" id="previewEl">
<pre class="highlight"><code class="language-markup" id="previewCode">{{__('playground.loading')}}</code></pre>
</div>
</div>
</div>
</div>
</div>
<p class="inner version"></p>
</div>
</div>
+2 -2
View File
@@ -126,7 +126,7 @@
background: var(--color-link-hover)
color: #fff
#sponsors-wrap, #contributors-wrap
#used-by-wrap, #sponsors-wrap, #contributors-wrap
background: var(--color-navy-lighter)
border-top: 1px solid #161d24
border-bottom: 1px solid #161d24
@@ -181,7 +181,7 @@
#contributors-wrap
border: none
overflow: hidden;
overflow: hidden
.contributors
tr
+260 -55
View File
@@ -1,107 +1,312 @@
#playground
--playground-gap: 12px
--playground-radius: 10px
--playground-inset: 16px
background: var(--color-content-bg)
overflow: hidden
box-shadow: var(--panel-shadow)
.wrapper
margin-bottom: 40px
margin-bottom: 32px
@media mq-mobile
margin-bottom: 20px
.playground-hero
display: flex
flex-wrap: wrap
align-items: flex-end
justify-content: space-between
gap: 16px 24px
padding-top: 32px
padding-bottom: 20px
@media mq-mobile
padding-top: 16px
padding-bottom: 12px
gap: 10px
align-items: flex-start
.playground-hero-text
flex: 1 1 280px
min-width: 0
h1
font-size: 36px
font-weight: 300
margin-top: 40px
margin-bottom: 24px
color: var(--color-default)
h2
font-size: 0.8125rem
font-size: 28px
font-weight: 600
text-transform: uppercase
letter-spacing: 0.04em
color: var(--color-gray)
letter-spacing: -0.02em
margin: 0 0 8px
color: var(--color-default)
@media mq-mobile
font-size: 22px
margin-bottom: 4px
.playground-lead
margin: 0
font-size: 15px
line-height: 1.5
color: var(--color-gray)
@media mq-mobile
font-size: 14px
line-height: 1.45
.playground-version
flex: 0 0 auto
margin: 0
font-size: 12px
line-height: 1.4
font-family: font-mono
padding: 6px 12px
border-radius: 999px
background: var(--playground-surface)
border: 1px solid var(--color-border)
color: var(--color-gray)
a
color: var(--color-default)
text-decoration: none
font-weight: 500
&:hover
color: var(--color-link)
text-decoration: none
#editors
display: grid
overflow: hidden
margin-bottom: 0
height: 75vh
min-height: 480px
.playground-workspace
display: grid
gap: var(--playground-gap)
grid-template-columns: 1fr 1fr
grid-template-rows: 3fr 2fr
grid-gap: 16px
align-items: stretch
@media mq-normal
overflow: hidden
height: 75vh
max-height: unquote('calc(100vh - 200px)')
min-height: 520px
@media mq-mobile
height: auto
min-height: 0
grid-template-columns: 1fr
grid-template-rows: auto
grid-gap: 20px
gap: 12px
.area-tpl
grid-row: 1
grid-column: 1
min-height: 0
--pane-dot: var(--color-link)
.area-data
grid-row: 2
grid-column: 1
min-height: 0
--pane-dot: var(--highlight-orange)
.area-output
grid-column: 2
grid-row: 1 / -1
min-height: 0
min-width: 0
--pane-dot: var(--highlight-green)
@media mq-mobile
grid-row: auto
grid-column: 1
.editor-wrapper
.playground-pane
display: flex
gap: 8px
flex-direction: column
min-height: 0
overflow: hidden
.editor
flex: 1 1 auto
min-height: 0
position: relative
code-block-chrome()
overflow: hidden
@media mq-mobile
min-height: 240px
.ace_editor
font-family: font-mono
font-size: 14px
line-height: 1.5
border-radius: 6px
.ace_scrollbar
z-index: 2
background: var(--playground-pane-head)
border: 1px solid var(--code-border)
border-radius: var(--playground-radius)
box-shadow: var(--code-shadow)
.version
font-size: 0.8125rem
line-height: 1.5
.pane-head
display: flex
align-items: center
gap: 10px
flex-shrink: 0
height: 36px
padding: 0 var(--playground-inset)
border-bottom: 1px solid var(--code-border)
@media mq-mobile
height: 32px
padding: 0 10px
h2
font-size: 13px
font-weight: 600
letter-spacing: 0.01em
text-transform: none
color: var(--color-default)
margin: 0
@media mq-mobile
font-size: 12px
.pane-indicator
width: 8px
height: 8px
border-radius: 50%
flex-shrink: 0
background: unquote('color-mix(in srgb, var(--pane-dot) 38%, var(--color-border))')
transition: background 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease
&[data-state="active"]
background: var(--pane-dot)
animation: playground-dot-typing 0.85s ease-in-out infinite
&[data-state="pending"]
background: var(--highlight-yellow)
&[data-state="ok"]
background: var(--highlight-green)
animation: playground-dot-ok 0.45s ease-out
&[data-state="error"]
background: var(--highlight-red)
animation: playground-dot-error 0.35s ease-out
.area-output .pane-indicator
&[data-state="pending"]
animation: playground-dot-pending 0.55s ease-in-out infinite
@keyframes playground-dot-typing
0%, 100%
transform: scale(1)
box-shadow: 0 0 0 0 unquote('color-mix(in srgb, var(--pane-dot) 0%, transparent)')
50%
transform: scale(1.2)
box-shadow: 0 0 0 4px unquote('color-mix(in srgb, var(--pane-dot) 28%, transparent)')
@keyframes playground-dot-pending
0%, 100%
transform: scale(1)
opacity: 0.75
50%
transform: scale(1.12)
opacity: 1
@keyframes playground-dot-ok
0%
transform: scale(0.85)
box-shadow: 0 0 0 0 unquote('color-mix(in srgb, var(--highlight-green) 50%, transparent)')
70%
transform: scale(1.15)
box-shadow: 0 0 0 5px unquote('color-mix(in srgb, var(--highlight-green) 0%, transparent)')
100%
transform: scale(1)
box-shadow: none
@keyframes playground-dot-error
0%, 100%
transform: translateX(0)
20%
transform: translateX(-2px)
40%
transform: translateX(2px)
60%
transform: translateX(-1px)
80%
transform: translateX(1px)
.pane-body
flex: 1 1 auto
min-height: 0
min-width: 0
display: flex
flex-direction: column
overflow: hidden
background: var(--highlight-background)
.area-tpl .pane-body,
.area-data .pane-body
padding: var(--playground-inset)
box-sizing: border-box
@media mq-mobile
padding: 12px
.area-tpl .ace_gutter,
.area-data .ace_gutter
display: none
width: 0
min-width: 0
.area-tpl .ace_editor,
.area-data .ace_editor,
.area-tpl .ace_scroller,
.area-data .ace_scroller,
.area-tpl .ace_content,
.area-data .ace_content,
.area-tpl .ace_text-layer,
.area-data .ace_text-layer
background: transparent
.editor
flex: 1 1 auto
min-height: 0
position: relative
overflow: hidden
@media mq-mobile
min-height: 180px
.output-preview
flex: 1 1 auto
min-height: 0
min-width: 0
width: 100%
overflow: auto
@media mq-mobile
min-height: 120px
pre.highlight
margin: 0
min-height: 100%
width: 100%
box-sizing: border-box
padding: var(--playground-inset)
border: none
box-shadow: none
border-radius: 0
background: transparent
color: var(--highlight-foreground)
overflow-x: hidden
overflow-y: auto
white-space: pre-wrap
overflow-wrap: break-word
@media mq-mobile
padding: 12px
code
display: block
width: 100%
box-sizing: border-box
font-family: font-mono
font-size: 14px
line-height: 1.55
color: var(--highlight-foreground)
background: transparent
padding: 0
white-space: inherit
overflow-wrap: inherit
@media mq-mobile
font-size: 13px
.ace_editor
font-family: font-mono
color: var(--color-gray)
margin-top: 20px
margin-bottom: 32px
a
color: inherit
text-decoration: none
&:hover
color: var(--color-link)
text-decoration: underline
font-size: 14px
line-height: 1.55
border-radius: 0
@media mq-mobile
font-size: 13px
.ace_scrollbar
z-index: 2
.hide
display: none
.loader
width: 48px
height: 48px
margin: 150px auto 200px
border: 3px solid var(--color-border)
width: 40px
height: 40px
margin: 120px auto 160px
border: 2px solid var(--color-border)
border-top-color: var(--color-link)
border-radius: 50%
animation: spin 0.8s infinite linear
animation: playground-spin 0.7s infinite linear
@media mq-mobile
margin: 60px auto 80px
@keyframes spin
@keyframes playground-spin
100%
transform: rotate(360deg)
+4
View File
@@ -37,6 +37,8 @@ vendor-prefixes = webkit moz ms official
--highlight-aqua: #0550ae
--highlight-blue: #0550ae
--highlight-purple: #8250df
--playground-surface: #f3f4f6
--playground-pane-head: #fff
}
@media (prefers-color-scheme: dark) {
@@ -75,6 +77,8 @@ vendor-prefixes = webkit moz ms official
--highlight-aqua: #79c0ff
--highlight-blue: #79c0ff
--highlight-purple: #d2a8ff
--playground-surface: hsl(218, 26%, 10%)
--playground-pane-head: hsl(218, 22%, 16%)
}
}
+119 -16
View File
@@ -38,8 +38,8 @@
(function() {
// playground
/* global liquidjs, ace */
if (!location.pathname.match(/playground.html$/)) return;
/* global liquidjs, ace, Prism */
if (!/\/playground(?:\.html)?$/.test(location.pathname)) return;
updateVersion(liquidjs.version);
const engine = new liquidjs.Liquid({
memoryLimit: 1e5,
@@ -48,14 +48,19 @@
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)');
const editor = createEditor('editorEl', 'liquid');
const dataEditor = createEditor('dataEl', 'json');
const preview = createEditor('previewEl', 'html');
preview.setReadOnly(true);
preview.renderer.setShowGutter(false);
preview.renderer.setPadding(16);
const previewCode = document.getElementById('previewCode');
const indicatorTpl = document.querySelector('.area-tpl .pane-indicator');
const indicatorData = document.querySelector('.area-data .pane-indicator');
const indicatorOutput = document.querySelector('.area-output .pane-indicator');
const editors = [editor, dataEditor, preview];
const editors = [editor, dataEditor];
let previewValue = '';
let hadPreview = false;
let renderTimer = null;
const RENDER_DELAY = 180;
colorScheme.addEventListener('change', function() {
editors.forEach(applyEditorTheme);
if (previewValue) setPreview(previewValue);
});
const init = parseArgs(location.hash.slice(1));
@@ -63,9 +68,11 @@
editor.setValue(init.tpl, 1);
dataEditor.setValue(init.data, 1);
}
editor.on('change', update);
dataEditor.on('change', update);
update();
editor.on('change', onTemplateChange);
dataEditor.on('change', onContextChange);
editor.on('focus', function () { setIndicator(indicatorTpl, 'active'); });
dataEditor.on('focus', function () { setIndicator(indicatorData, 'active'); });
scheduleUpdate();
ready();
function ready() {
@@ -87,6 +94,8 @@
function applyEditorTheme(editor) {
editor.setTheme(getEditorTheme());
editor.renderer.setPadding(0);
editor.container.style.background = 'transparent';
}
function createEditor(id, lang) {
@@ -96,15 +105,61 @@
fontFamily: '"Source Code Pro", ui-monospace, Monaco, Menlo, Consolas, monospace',
fontSize: '14px',
showPrintMargin: false,
showGutter: false,
highlightActiveLine: false,
tabSize: 2,
useSoftTabs: true,
scrollPastEnd: 0.25
scrollPastEnd: 0
});
editor.getSession().setMode('ace/mode/' + lang);
editor.renderer.setScrollMargin(8, 8, 0, 0);
editor.renderer.setShowGutter(false);
if (editor.renderer.$gutter) {
editor.renderer.$gutter.style.display = 'none';
}
editor.renderer.setScrollMargin(0, 0, 0, 0);
bindClipboard(editor);
return editor;
}
function bindClipboard(editor) {
editor.commands.addCommand({
name: 'copy',
bindKey: {win: 'Ctrl-C', mac: 'Command-C'},
exec: function (ed) {
const text = ed.getCopyText();
if (!text) return;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
}
},
readOnly: true
});
editor.commands.addCommand({
name: 'cut',
bindKey: {win: 'Ctrl-X', mac: 'Command-X'},
exec: function (ed) {
const text = ed.getCopyText();
if (!text) return;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(function () {
ed.insert('');
});
}
}
});
editor.commands.addCommand({
name: 'paste',
bindKey: {win: 'Ctrl-V', mac: 'Command-V'},
exec: function (ed) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.readText().then(function (text) {
ed.insert(text);
});
}
}
});
}
function parseArgs(hash) {
if (!hash) return;
try {
@@ -118,16 +173,64 @@
return utoa(obj.tpl) + ',' + utoa(obj.data);
}
function setPreview(value) {
previewValue = value;
previewCode.textContent = value;
if (window.Prism) {
delete previewCode.dataset.highlighted;
window.Prism.highlightElement(previewCode);
}
}
function setIndicator(indicator, state) {
if (indicator) indicator.dataset.state = state;
}
function onTemplateChange() {
setIndicator(indicatorTpl, 'active');
if (indicatorData.dataset.state !== 'error') setIndicator(indicatorData, 'idle');
setIndicator(indicatorOutput, 'pending');
scheduleUpdate();
}
function onContextChange() {
setIndicator(indicatorData, 'active');
if (indicatorTpl.dataset.state !== 'error') setIndicator(indicatorTpl, 'idle');
setIndicator(indicatorOutput, 'pending');
scheduleUpdate();
}
function scheduleUpdate() {
clearTimeout(renderTimer);
renderTimer = setTimeout(update, RENDER_DELAY);
}
async function update() {
const tpl = editor.getValue();
const data = dataEditor.getValue();
history.replaceState({}, '', '#' + serializeArgs({tpl, data}));
let parsed;
try {
const html = await engine.parseAndRender(tpl, JSON.parse(data));
preview.setValue(html, 1);
parsed = JSON.parse(data);
} catch (err) {
preview.setValue(err.stack, 1);
throw err;
setIndicator(indicatorData, 'error');
setIndicator(indicatorTpl, 'idle');
setIndicator(indicatorOutput, 'error');
return;
}
try {
const html = await engine.parseAndRender(tpl, parsed);
if (html !== '' || !hadPreview) {
setPreview(html);
if (html !== '') hadPreview = true;
}
setIndicator(indicatorTpl, 'idle');
setIndicator(indicatorData, 'idle');
setIndicator(indicatorOutput, 'ok');
} catch (err) {
setIndicator(indicatorTpl, 'error');
setIndicator(indicatorData, 'idle');
setIndicator(indicatorOutput, 'error');
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "liquidjs",
"version": "10.27.0",
"version": "10.29.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "liquidjs",
"version": "10.27.0",
"version": "10.29.0",
"license": "MIT",
"dependencies": {
"commander": "^10.0.0"
+7 -4
View File
@@ -1,8 +1,8 @@
{
"name": "liquidjs",
"version": "10.27.0",
"version": "10.29.0",
"sideEffects": false,
"description": "A simple, expressive and safe Shopify / Github Pages compatible template engine in pure JavaScript.",
"description": "A simple, expressive, extensible Liquid template engine for JavaScript — Shopify, Jekyll and GitHub Pages compatible, for Node.js, browsers, and the CLI, with TypeScript support.",
"main": "dist/liquid.node.js",
"module": "dist/liquid.node.mjs",
"es2015": "dist/liquid.browser.mjs",
@@ -29,12 +29,15 @@
"build:min": "BUNDLES=min rollup -c rollup.config.mjs",
"build:umd": "BUNDLES=umd rollup -c rollup.config.mjs",
"build:charmap": "./bin/character-gen.js > src/util/character.ts",
"build:docs": "run-s build:docs-liquid build:contributors build:apidoc build:changelog build:docs-hexo",
"prepare:docs": "run-s build:docs-liquid build:contributors build:apidoc build:changelog",
"build:docs": "run-s prepare:docs build:docs-hexo",
"build:docs-liquid": "cross-env BUNDLES=min rollup -c rollup.config.mjs && shx cp dist/liquid.browser.min.js docs/themes/navy/source/js/",
"build:contributors": "node bin/build-contributors.js",
"build:apidoc": "shx rm -rf docs/source/api && typedoc --plugin typedoc-plugin-missing-exports ./src --gitRevision master --out docs/source/api",
"build:changelog": "node bin/build-changelog.js",
"build:docs-hexo": "cd docs && npm ci && npm run build && shx cp CNAME public/"
"build:docs-hexo": "cd docs && npm ci && npm run build && shx cp CNAME .nojekyll public/",
"serve:docs": "cd docs && npm run start",
"dev:docs": "run-s prepare:docs serve:docs"
},
"bin": {
"liquidjs": "./bin/liquid.js",
+15
View File
@@ -183,6 +183,21 @@ describe('Context', function () {
ctx.push({ foo: Object.create({ bar: 'BAR' }) })
return expect(() => ctx.getSync(['foo', 'bar'])).toThrow(/undefined variable: foo.bar/)
})
it('should return undefined for inherited array indices', function () {
// eslint-disable-next-line no-extend-native
Array.prototype[0] = 'POLLUTED'
try {
const a: number[] = []
a.length = 1
ctx.push({ foo: a })
expect(ctx.getSync(['foo', 0])).toEqual(undefined)
expect(ctx.getSync(['foo', -1])).toEqual(undefined)
expect(ctx.getSync(['foo', 'first'])).toEqual(undefined)
expect(ctx.getSync(['foo', 'last'])).toEqual(undefined)
} finally {
delete (Array.prototype as any)[0]
}
})
})
describe('.getAll()', function () {
+18 -12
View File
@@ -3,7 +3,7 @@ import { Drop } from '../drop/drop'
import { __assign } from 'tslib'
import { NormalizedFullOptions, defaultOptions, RenderOptions } from '../liquid-options'
import { createScope, Scope } from './scope'
import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue } from '../util'
import { hasOwnProperty, isArray, isNil, isUndefined, isString, isFunction, isNumber, toLiquid, InternalUndefinedVariableError, toValueSync, isObject, Limiter, toValue, readArrayElement } from '../util'
type PropertyKey = string | number;
@@ -31,6 +31,10 @@ export class Context {
* The normalized liquid options object
*/
public opts: NormalizedFullOptions
/**
* Reference to the Liquid instance for filter resolution
*/
public liquid?: any
/**
* Throw when accessing undefined variable?
*/
@@ -38,7 +42,7 @@ export class Context {
public ownPropertyOnly: boolean;
public memoryLimit: Limiter;
public renderLimit: Limiter;
public constructor (env: object = {}, opts: NormalizedFullOptions = defaultOptions, renderOptions: RenderOptions = {}, { memoryLimit, renderLimit }: { [key: string]: Limiter } = {}) {
public constructor (env: object = {}, opts: NormalizedFullOptions = defaultOptions, renderOptions: RenderOptions = {}, { memoryLimit, renderLimit, liquid }: { memoryLimit?: Limiter, renderLimit?: Limiter, liquid?: any } = {}) {
this.sync = !!renderOptions.sync
this.opts = opts
this.globals = renderOptions.globals ?? opts.globals
@@ -47,6 +51,7 @@ export class Context {
this.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly
this.memoryLimit = memoryLimit ?? new Limiter('memory alloc', renderOptions.memoryLimit ?? opts.memoryLimit)
this.renderLimit = renderLimit ?? new Limiter('template render', getPerformance().now() + (renderOptions.renderLimit ?? opts.renderLimit))
this.liquid = liquid
}
public getRegister<T> (key: string, defaultValue: T = undefined as T): T {
return (this.registers[key] = this.registers[key] || defaultValue)
@@ -110,7 +115,8 @@ export class Context {
ownPropertyOnly: this.ownPropertyOnly
}, {
renderLimit: this.renderLimit,
memoryLimit: this.memoryLimit
memoryLimit: this.memoryLimit,
liquid: this.liquid
})
}
private findScope (key: string | number) {
@@ -125,13 +131,13 @@ export class Context {
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]
if (isArray(obj) && isNumber(key)) return readArrayElement(obj, key, this.ownPropertyOnly)
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)
else if (key === 'first') return readFirst(obj, this.ownPropertyOnly)
else if (key === 'last') return readLast(obj, this.ownPropertyOnly)
return value
}
}
@@ -141,14 +147,14 @@ export function readJSProperty (obj: Scope, key: PropertyKey, ownPropertyOnly: b
return obj[key]
}
function readFirst (obj: Scope) {
if (isArray(obj)) return obj[0]
return obj['first']
function readFirst (obj: Scope, ownPropertyOnly: boolean) {
if (isArray(obj)) return readArrayElement(obj, 0, ownPropertyOnly)
return readJSProperty(obj, 'first', ownPropertyOnly)
}
function readLast (obj: Scope) {
if (isArray(obj)) return obj[obj.length - 1]
return obj['last']
function readLast (obj: Scope, ownPropertyOnly: boolean) {
if (isArray(obj)) return readArrayElement(obj, -1, ownPropertyOnly)
return readJSProperty(obj, 'last', ownPropertyOnly)
}
function readSize (obj: Scope) {
+18 -10
View File
@@ -1,4 +1,4 @@
import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, orderedCompare, isArray, isNil, last as arrayLast, isArrayLike, toEnumerable } from '../util'
import { toArray, argumentsToValue, toValue, stringify, caseInsensitiveCompare, orderedCompare, isArray, isNil, isArrayLike, readArrayElement, toEnumerable } from '../util'
import { arrayIncludes, equals, evalToken, isTruthy } from '../render'
import { Value, FilterImpl } from '../template'
import { Tokenizer } from '../parser'
@@ -8,12 +8,17 @@ import { EmptyDrop } from '../drop'
export const join = argumentsToValue(function (this: FilterImpl, v: any[], arg: string) {
const array = toArray(v)
const sep = isNil(arg) ? ' ' : stringify(arg)
const complexity = array.length * (1 + sep.length)
this.context.memoryLimit.use(complexity)
return array.join(sep)
let outputSize = sep.length * Math.max(array.length - 1, 0)
for (let i = 0; i < array.length; i++) outputSize += String(array[i]).length
this.context.memoryLimit.use(outputSize)
return Array.prototype.join.call(array, sep)
})
export const last = argumentsToValue(function (this: FilterImpl, v: any) {
return isArrayLike(v) ? readArrayElement(v, -1, this.context.ownPropertyOnly) : ''
})
export const first = argumentsToValue(function (this: FilterImpl, v: any) {
return isArrayLike(v) ? readArrayElement(v, 0, this.context.ownPropertyOnly) : ''
})
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)
@@ -66,14 +71,14 @@ export function * sum (this: FilterImpl, arr: Scope[], property?: string): Itera
export function compact<T> (this: FilterImpl, arr: T[]) {
const array = toArray(arr)
this.context.memoryLimit.use(array.length)
return array.filter(x => !isNil(toValue(x)))
return Array.prototype.filter.call(array, x => !isNil(toValue(x)))
}
export function concat<T1, T2> (this: FilterImpl, v: T1[], arg: T2[] = []): (T1 | T2)[] {
const lhs = toArray(v)
const rhs = toArray(arg)
this.context.memoryLimit.use(lhs.length + rhs.length)
return lhs.concat(rhs)
return Array.prototype.concat.call(lhs, rhs)
}
export function push<T> (this: FilterImpl, v: T[], arg: T): T[] {
@@ -109,8 +114,11 @@ export function slice<T> (this: FilterImpl, v: T[] | string, begin: number, leng
if (isNil(v)) return []
if (!isArray(v)) v = stringify(v)
begin = begin < 0 ? v.length + begin : begin
if (begin < 0 || length < 0) return isArray(v) ? [] : ''
this.context.memoryLimit.use(length)
return v.slice(begin, begin + length)
return isArray(v)
? Array.prototype.slice.call(v, begin, begin + length)
: String.prototype.slice.call(v, begin, begin + length)
}
function expectedMatcher (this: FilterImpl, expected: any): (v: any) => boolean {
@@ -132,7 +140,7 @@ function * filter<T extends object> (this: FilterImpl, include: boolean, arr: T[
values.push(yield evalToken(token, this.context.spawn(item)))
}
const matcher = expectedMatcher.call(this, expected)
return arr.filter((_, i) => matcher(values[i]) === include)
return Array.prototype.filter.call(arr, (_, i) => matcher(values[i]) === include)
}
function * filter_exp<T extends object> (this: FilterImpl, include: boolean, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
+3 -4
View File
@@ -8,14 +8,13 @@ export const divided_by = argumentsToNumber((dividend: number, divisor: number,
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 modulo = argumentsToNumber((v: number, arg: number) => ((v % arg) + arg) % arg)
export const times = argumentsToNumber((v: number, arg: number) => v * arg)
export function round (v: number, arg = 0) {
v = toNumber(v)
arg = toNumber(arg)
const amp = Math.pow(10, arg)
const scaled = v * amp
// Round half away from zero
return Math.sign(v) * Math.round(Math.abs(scaled)) / amp
const scaled = (v * amp) * (1 + Number.EPSILON)
return Math.round(scaled) / amp
}
+29 -5
View File
@@ -2,6 +2,18 @@ import { isFalsy } from '../render/boolean'
import { identify, isArray, isString, toValue } from '../util/underscore'
import { FilterImpl } from '../template'
function chargeJsonReplacerValue (memoryLimit: { use(count: number): void }, val: unknown) {
if (typeof val === 'string') {
memoryLimit.use(val.length)
} else if (val === null || typeof val === 'number' || typeof val === 'boolean') {
memoryLimit.use(JSON.stringify(val).length)
} else if (Array.isArray(val)) {
memoryLimit.use(val.length + 1)
} else if (typeof val === 'object') {
memoryLimit.use(2)
}
}
function defaultFilter<T1 extends boolean, T2> (this: FilterImpl, value: T1, defaultValue: T2, ...args: Array<[string, any]>): T1 | T2 {
value = toValue(value)
if (isArray(value) || isString(value)) return value.length ? value : defaultValue
@@ -9,18 +21,30 @@ function defaultFilter<T1 extends boolean, T2> (this: FilterImpl, value: T1, def
return isFalsy(value, this.context) ? defaultValue : value
}
function json (value: any, space = 0) {
return JSON.stringify(value, null, space)
function json (this: FilterImpl, value: any, space = 0) {
const memoryLimit = this.context.memoryLimit
return JSON.stringify(value, (_key, val) => {
chargeJsonReplacerValue(memoryLimit, val)
return val
}, space)
}
function inspect (value: any, space = 0) {
function inspect (this: FilterImpl, value: any, space = 0) {
const memoryLimit = this.context.memoryLimit
const ancestors: object[] = []
return JSON.stringify(value, function (this: unknown, _key: unknown, value: any) {
if (typeof value !== 'object' || value === null) return value
if (typeof value !== 'object' || value === null) {
chargeJsonReplacerValue(memoryLimit, value)
return value
}
// `this` is the object that value is contained in, i.e., its direct parent.
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop()
if (ancestors.includes(value)) return '[Circular]'
if (ancestors.includes(value)) {
memoryLimit.use('[Circular]'.length)
return '[Circular]'
}
ancestors.push(value)
chargeJsonReplacerValue(memoryLimit, value)
return value
}, space)
}
+9 -1
View File
@@ -128,6 +128,12 @@ export function strip_newlines (this: FilterImpl, v: string) {
return str.replace(/\r?\n/gm, '')
}
export function squish (this: FilterImpl, v: string) {
const str = stringify(v)
this.context.memoryLimit.use(str.length)
return str.replace(/\s+/g, ' ').trim()
}
export function capitalize (this: FilterImpl, str: string) {
str = stringify(str)
this.context.memoryLimit.use(str.length)
@@ -209,7 +215,9 @@ export function number_of_words (this: FilterImpl, input: string, mode?: 'cjk' |
export function array_to_sentence_string (this: FilterImpl, array: unknown[], connector = 'and') {
connector = stringify(connector)
this.context.memoryLimit.use(array.length + connector.length)
let outputSize = connector.length + array.length * 2
for (let i = 0; i < array.length; i++) outputSize += stringify(array[i]).length
this.context.memoryLimit.use(outputSize)
switch (array.length) {
case 0:
return ''
+1 -1
View File
@@ -1,6 +1,6 @@
import { stringify } from '../util/underscore'
export const url_decode = (x: string) => decodeURIComponent(stringify(x)).replace(/\+/g, ' ')
export const url_decode = (x: string) => decodeURIComponent(stringify(x).replace(/\+/g, ' '))
export const url_encode = (x: string) => encodeURIComponent(stringify(x)).replace(/%20/g, '+')
export const cgi_escape = (x: string) => encodeURIComponent(stringify(x))
.replace(/%20/g, '+')
+1 -1
View File
@@ -11,7 +11,7 @@ export { Context, Scope } from './context'
export { Value, Hash, Template, FilterImplOptions, Tag, Filter, Output, Variable, VariableLocation, VariableSegments, Variables, StaticAnalysis, StaticAnalysisOptions, analyze, analyzeSync, Arguments, PartialScope } from './template'
export type { TagRenderReturn } from './template'
export { Token, TopLevelToken, TagToken, ValueToken } from './tokens'
export type { RangeToken, LiteralToken, QuotedToken, PropertyAccessToken, NumberToken } from './tokens'
export type { RangeToken, LiteralToken, QuotedToken, PropertyAccessToken, NumberToken, FilteredValueToken } from './tokens'
export { TokenKind, Tokenizer, ParseStream, Parser } from './parser'
export { filters } from './filters'
export * from './tags'
+8 -1
View File
@@ -38,7 +38,10 @@ export interface LiquidOptions {
strictVariables?: boolean;
/** Catch all errors instead of exit upon one. Please note that render errors won't be reached when parse fails. */
catchAllErrors?: boolean;
/** Hide scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates. */
/**
* Hide scope variables from prototypes, useful when you're passing a not sanitized object into LiquidJS or need to hide prototypes from templates.
* This only applies to property/index access on scope objects. Filter transforms and iteration operate on the resolved value with standard JavaScript semantics, so prototype-inherited array indices may still be surfaced by them.
*/
ownPropertyOnly?: boolean;
/** Modifies the behavior of `strictVariables`. If set, a single undefined variable will *not* cause an exception in the context of the `if`/`elsif`/`unless` tag and the `default` filter. Instead, it will evaluate to `false` and `null`, respectively. Irrelevant if `strictVariables` is not set. Defaults to `false`. **/
lenientIf?: boolean;
@@ -84,6 +87,8 @@ export interface LiquidOptions {
operators?: Operators;
/** Respect parameter order when using filters like "for ... reversed limit", Defaults to `false`. */
orderedFilterParameters?: boolean;
/** Allow parenthesized expressions as operands in conditions and loops, e.g. `{% if (foo | upcase) == "BAR" %}`. This is a non-standard extension to Liquid. Defaults to `false`. */
groupedExpressions?: boolean;
/** For DoS handling, limit total length of templates parsed in one `parse()` call. A typical PC can handle 1e8 (100M) characters without issues. */
parseLimit?: number;
/** For DoS handling, limit total time (in ms) for each `render()` call. */
@@ -159,6 +164,7 @@ export interface NormalizedFullOptions extends NormalizedOptions {
globals: object;
keepOutputType: boolean;
operators: Operators;
groupedExpressions: boolean;
parseLimit: number;
renderLimit: number;
memoryLimit: number;
@@ -195,6 +201,7 @@ export const defaultOptions: NormalizedFullOptions = {
globals: {},
keepOutputType: false,
operators: defaultOperators,
groupedExpressions: false,
memoryLimit: Infinity,
parseLimit: Infinity,
renderLimit: Infinity
+6 -3
View File
@@ -31,7 +31,7 @@ export class Liquid {
}
public _render (tpl: Template[], scope: Context | object | undefined, renderOptions: RenderOptions): IterableIterator<any> {
const ctx = scope instanceof Context ? scope : new Context(scope, this.options, renderOptions)
const ctx = scope instanceof Context ? scope : new Context(scope, this.options, renderOptions, { liquid: this })
return this.renderer.renderTemplates(tpl, ctx)
}
public async render (tpl: Template[], scope?: object, renderOptions?: RenderOptions): Promise<any> {
@@ -41,7 +41,7 @@ export class Liquid {
return toValueSync(this._render(tpl, scope, { ...renderOptions, sync: true }))
}
public renderToNodeStream (tpl: Template[], scope?: object, renderOptions: RenderOptions = {}): NodeJS.ReadableStream {
const ctx = new Context(scope, this.options, renderOptions)
const ctx = new Context(scope, this.options, renderOptions, { liquid: this })
return this.renderer.renderTemplatesToNodeStream(tpl, ctx)
}
@@ -88,7 +88,7 @@ export class Liquid {
public _evalValue (str: string, scope?: object | Context): IterableIterator<any> {
const value = new Value(str, this)
const ctx = scope instanceof Context ? scope : new Context(scope, this.options)
const ctx = scope instanceof Context ? scope : new Context(scope, this.options, {}, { liquid: this })
return value.value(ctx)
}
public async evalValue (str: string, scope?: object | Context): Promise<any> {
@@ -101,6 +101,9 @@ export class Liquid {
public registerFilter (name: string, filter: FilterImplOptions) {
this.filters[name] = filter
}
public unregisterFilter (name: string) {
delete this.filters[name]
}
public registerTag (name: string, tag: TagClass | TagImplOptions) {
this.tags[name] = isFunction(tag) ? tag : createTagClass(tag)
}
+1 -1
View File
@@ -33,7 +33,7 @@ export class Parser {
public parse (html: string, filepath?: string): Template[] {
html = String(html)
this.parseLimit.use(html.length)
const tokenizer = new Tokenizer(html, this.liquid.options.operators, filepath)
const tokenizer = new Tokenizer(html, this.liquid.options.operators, filepath, undefined, this.liquid.options.groupedExpressions)
const tokens = tokenizer.readTopLevelTokens(this.liquid.options)
return this.parseTokens(tokens)
}
+1
View File
@@ -12,5 +12,6 @@ export enum TokenKind {
Quoted = 1024,
Operator = 2048,
FilteredValue = 4096,
GroupedExpression = 8192,
Delimited = Tag | Output
}
+108 -9
View File
@@ -1,4 +1,4 @@
import { LiquidTagToken, HTMLToken, QuotedToken, OutputToken, TagToken, OperatorToken, RangeToken, PropertyAccessToken, NumberToken, IdentifierToken } from '../tokens'
import { LiquidTagToken, HTMLToken, QuotedToken, OutputToken, TagToken, OperatorToken, RangeToken, PropertyAccessToken, NumberToken, IdentifierToken, FilteredValueToken } from '../tokens'
import { Tokenizer } from './tokenizer'
import { defaultOperators } from '../render/operator'
import { createTrie } from '../util/operator-trie'
@@ -229,24 +229,115 @@ describe('Tokenizer', function () {
})
describe('#readRange()', () => {
it('should read `(1..3)`', () => {
const range = new Tokenizer('(1..3)').readRange()
const range = new Tokenizer('(1..3)').readGroupOrRange()
expect(range).toBeDefined()
expect(range).toBeInstanceOf(RangeToken)
expect(range!.getText()).toEqual('(1..3)')
const { lhs, rhs } = range!
expect(lhs).toBeInstanceOf(NumberToken)
expect(lhs.getText()).toBe('1')
expect(rhs).toBeInstanceOf(NumberToken)
expect(rhs.getText()).toBe('3')
expect((range as RangeToken).lhs).toBeInstanceOf(NumberToken)
expect((range as RangeToken).lhs.getText()).toBe('1')
expect((range as RangeToken).rhs).toBeInstanceOf(NumberToken)
expect((range as RangeToken).rhs.getText()).toBe('3')
})
it('should throw for `(..3)`', () => {
expect(() => new Tokenizer('(..3)').readRange()).toThrow('unexpected token "..3)", value expected')
expect(() => new Tokenizer('(..3)').readGroupOrRange()).toThrow('unexpected token "..3)", value expected')
})
it('should read `(a.b..c["..d"])`', () => {
const range = new Tokenizer('(a.b..c["..d"])').readRange()
const range = new Tokenizer('(a.b..c["..d"])').readGroupOrRange()
expect(range).toBeDefined()
expect(range).toBeInstanceOf(RangeToken)
expect(range!.getText()).toEqual('(a.b..c["..d"])')
})
})
describe('#readGroupedExpression()', () => {
function createGrouped (input: string): Tokenizer {
const t = new Tokenizer(input, defaultOperators)
t.groupedExpressions = true
return t
}
it('should read `(foo | upcase)` as FilteredValueToken', () => {
const token = createGrouped('(foo | upcase)').readValue()
expect(token).toBeInstanceOf(FilteredValueToken)
const grouped = token as FilteredValueToken
expect(grouped.getText()).toBe('(foo | upcase)')
expect(grouped.initial.postfix).toHaveLength(1)
expect(grouped.filters).toHaveLength(1)
expect(grouped.filters[0].name).toBe('upcase')
})
it('should read `(foo | append: "!")` with filter argument', () => {
const token = createGrouped('(foo | append: "!")').readValue()
expect(token).toBeInstanceOf(FilteredValueToken)
const grouped = token as FilteredValueToken
expect(grouped.filters).toHaveLength(1)
expect(grouped.filters[0].name).toBe('append')
expect(grouped.filters[0].args).toHaveLength(1)
})
it('should read nested `((foo | append: "!") | upcase)`', () => {
const token = createGrouped('((foo | append: "!") | upcase)').readValue()
expect(token).toBeInstanceOf(FilteredValueToken)
const grouped = token as FilteredValueToken
expect(grouped.filters).toHaveLength(1)
expect(grouped.filters[0].name).toBe('upcase')
expect(grouped.initial.postfix).toHaveLength(1)
expect(grouped.initial.postfix[0]).toBeInstanceOf(FilteredValueToken)
})
it('should parse `(a | upcase) == "BAR"` as expression', () => {
const exp = [...createGrouped('(a | upcase) == "BAR"').readExpressionTokens()]
expect(exp).toHaveLength(3)
expect(exp[0]).toBeInstanceOf(FilteredValueToken)
expect(exp[1]).toBeInstanceOf(OperatorToken)
expect(exp[1].getText()).toBe('==')
expect(exp[2]).toBeInstanceOf(QuotedToken)
})
it('should read `((a | upcase) > 3)` as outer FilteredValueToken with comparison inside parens', () => {
const token = createGrouped('((a | upcase) > 3)').readValue()
expect(token).toBeInstanceOf(FilteredValueToken)
const outer = token as FilteredValueToken
expect(outer.filters).toHaveLength(0)
expect(outer.getText()).toBe('((a | upcase) > 3)')
const [first, second, third] = outer.initial.postfix
expect(first).toBeInstanceOf(FilteredValueToken)
expect(second).toBeInstanceOf(NumberToken)
expect(third).toBeInstanceOf(OperatorToken)
expect((first as FilteredValueToken).filters[0].name).toBe('upcase')
})
it('should read `(1 < 3)` as grouped comparison with no filters', () => {
const token = createGrouped('(1 < 3)').readValue() as FilteredValueToken
expect(token.filters).toHaveLength(0)
expect(token.initial.postfix).toHaveLength(3)
expect(token.initial.postfix[0]).toBeInstanceOf(NumberToken)
expect(token.initial.postfix[1]).toBeInstanceOf(NumberToken)
expect((token.initial.postfix[2] as OperatorToken).operator).toBe('<')
})
it('should read redundant parens `(x)` as FilteredValueToken', () => {
const token = createGrouped('(x)').readValue() as FilteredValueToken
expect(token.filters).toHaveLength(0)
expect(token.initial.postfix).toHaveLength(1)
})
it('should read expression plus filters inside parens `(a == b | default: "x")`', () => {
const token = createGrouped('(a == b | default: "x")').readValue() as FilteredValueToken
expect(token.filters).toHaveLength(1)
expect(token.filters[0].name).toBe('default')
expect(token.initial.postfix.map((t) => t.getText()).join(' ')).toMatch(/a.*b.*==/)
})
it('should parse `((a | upcase) > 3) and (1 < 3)` as three expression tokens', () => {
const exp = [...createGrouped('((a | upcase) > 3) and (1 < 3)').readExpressionTokens()]
expect(exp).toHaveLength(3)
expect(exp[0]).toBeInstanceOf(FilteredValueToken)
expect(exp[1]).toBeInstanceOf(OperatorToken)
expect(exp[1].getText()).toBe('and')
expect(exp[2]).toBeInstanceOf(FilteredValueToken)
})
it('should still parse `(1..3)` as RangeToken', () => {
const token = createGrouped('(1..3)').readValue()
expect(token).toBeInstanceOf(RangeToken)
})
it('should throw for unclosed parens', () => {
expect(() => createGrouped('(foo | upcase').readValue()).toThrow('unbalanced parentheses')
})
it('should fall back to readRange when flag is off', () => {
expect(() => new Tokenizer('(foo | upcase)', defaultOperators).readValue()).toThrow('invalid range syntax')
})
})
describe('#readFilter()', () => {
it('should read a simple filter', function () {
const tokenizer = new Tokenizer('| plus')
@@ -522,6 +613,14 @@ describe('Tokenizer', function () {
expect(new Tokenizer('contains b').matchTrie(opTrie)).toBe(8)
})
})
describe('#createTrie()', function () {
it('should return the same trie for the same input', () => {
expect(createTrie(defaultOperators)).toBe(createTrie(defaultOperators))
})
it('should return distinct tries for distinct inputs', () => {
expect(createTrie({ foo: 1 })).not.toBe(createTrie({ foo: 1 }))
})
})
describe('#readLiquidTagTokens', () => {
it('should read newline terminated tokens', () => {
const tokenizer = new Tokenizer('echo \'hello\'')
+32 -11
View File
@@ -9,6 +9,7 @@ import { whiteSpaceCtrl } from './whitespace-ctrl'
export class Tokenizer {
p: number
N: number
public groupedExpressions: boolean
private rawBeginAt = -1
private opTrie: Trie<OperatorHandler>
private literalTrie: Trie<LiteralValue>
@@ -17,12 +18,14 @@ export class Tokenizer {
public input: string,
operators: Operators = defaultOptions.operators,
public file?: string,
range?: [number, number]
range?: [number, number],
groupedExpressions = false
) {
this.p = range ? range[0] : 0
this.N = range ? range[1] : input.length
this.opTrie = createTrie(operators)
this.literalTrie = createTrie(literalValues)
this.groupedExpressions = groupedExpressions
}
readExpression () {
@@ -80,6 +83,7 @@ export class Tokenizer {
readFilter (): FilterToken | null {
this.skipBlank()
if (this.end()) return null
if (this.peek() === ')') return null
this.assert(this.read() === '|', `expected "|" before filter`)
const name = this.readIdentifier()
if (!name.size()) {
@@ -94,9 +98,9 @@ export class Tokenizer {
const arg = this.readFilterArg()
arg && args.push(arg)
this.skipBlank()
this.assert(this.end() || this.peek() === ',' || this.peek() === '|', () => `unexpected character ${this.snapshot()}`)
this.assert(this.end() || this.peek() === ',' || this.peek() === '|' || this.peek() === ')', () => `unexpected character ${this.snapshot()}`)
} while (this.peek() === ',')
} else if (this.peek() === '|' || this.end()) {
} else if (this.peek() === '|' || this.peek() === ')' || this.end()) {
// do nothing
} else {
throw this.error('expected ":" after filter name')
@@ -307,10 +311,13 @@ export class Tokenizer {
return -1
}
readValue (): ValueToken | undefined {
readValue (): ValueToken | FilteredValueToken | undefined {
this.skipBlank()
const begin = this.p
const variable = this.readLiteral() || this.readQuoted() || this.readRange() || this.readNumber()
let variable: ValueToken | FilteredValueToken | undefined = this.readLiteral() || this.readQuoted() || this.readNumber()
if (!variable && this.peek() === '(') {
variable = this.readGroupOrRange()
}
const props = this.readProperties(!variable)
if (!props.length) return variable
return new PropertyAccessToken(variable, props, this.input, begin, this.p)
@@ -385,18 +392,32 @@ export class Tokenizer {
return literal
}
readRange (): RangeToken | undefined {
readGroupOrRange (): FilteredValueToken | RangeToken | undefined {
this.skipBlank()
const begin = this.p
if (this.peek() !== '(') return
++this.p
const lhs = this.readValueOrThrow()
this.skipBlank()
this.assert(this.read() === '.' && this.read() === '.', 'invalid range syntax')
const rhs = this.readValueOrThrow()
this.skipBlank()
this.assert(this.read() === ')', 'invalid range syntax')
return new RangeToken(this.input, begin, this.p, lhs, rhs, this.file)
if (this.peek() === '.' && this.peek(1) === '.') {
this.p += 2
const rhs = this.readValueOrThrow()
this.skipBlank()
this.assert(this.read() === ')', 'invalid range syntax')
return new RangeToken(this.input, begin, this.p, lhs, rhs, this.file)
}
if (this.groupedExpressions) {
const initial = new Expression([lhs, ...this.readExpressionTokens()])
this.assert(initial.valid(), () => `invalid value expression: ${this.snapshot()}`)
const filters = this.readFilters()
this.skipBlank()
this.assert(this.read() === ')', 'unbalanced parentheses')
return new FilteredValueToken(initial, filters, this.input, begin, this.p, this.file)
}
throw this.error('invalid range syntax')
}
readValueOrThrow (): ValueToken {
+21 -4
View File
@@ -1,13 +1,14 @@
import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, OperatorType, operatorTypes } from '../tokens'
import { isRangeToken, isPropertyAccessToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util'
import { QuotedToken, RangeToken, OperatorToken, Token, PropertyAccessToken, OperatorType, operatorTypes, FilteredValueToken } from '../tokens'
import { isRangeToken, isPropertyAccessToken, isFilteredValueToken, UndefinedVariableError, range, isOperatorToken, assert } from '../util'
import type { Context } from '../context'
import type { UnaryOperatorHandler } from '../render'
import { Drop } from '../drop'
import { Filter } from '../template/filter'
export class Expression {
readonly postfix: Token[]
public constructor (tokens: IterableIterator<Token>) {
public constructor (tokens: Iterable<Token>) {
this.postfix = [...toPostfix(tokens)]
}
public * evaluate (ctx: Context, lenient?: boolean): Generator<unknown, unknown, unknown> {
@@ -40,6 +41,22 @@ export function * evalToken (token: Token | undefined, ctx: Context, lenient = f
if ('content' in token) return token.content
if (isPropertyAccessToken(token)) return yield evalPropertyAccessToken(token, ctx, lenient)
if (isRangeToken(token)) return yield evalRangeToken(token, ctx)
if (isFilteredValueToken(token)) return yield evalFilteredValueToken(token, ctx, lenient)
}
function * evalFilteredValueToken (token: FilteredValueToken, ctx: Context, lenient: boolean): IterableIterator<unknown> {
assert(ctx.liquid, 'FilteredValueToken evaluation requires liquid instance in context')
lenient = lenient || (ctx.opts.lenientIf && token.filters.length > 0 && token.filters[0].name === 'default')
let val = yield token.initial.evaluate(ctx, lenient)
for (const filterToken of token.filters) {
const filterImpl = ctx.liquid.filters[filterToken.name]
assert(filterImpl || !ctx.liquid.options.strictFilters, () => `undefined filter: ${filterToken.name}`)
const filter = new Filter(filterToken, filterImpl, ctx.liquid)
val = yield filter.render(val, ctx)
}
return val
}
function * evalPropertyAccessToken (token: PropertyAccessToken, ctx: Context, lenient: boolean): IterableIterator<unknown> {
@@ -71,7 +88,7 @@ function * evalRangeToken (token: RangeToken, ctx: Context) {
return range(+low, +high + 1)
}
function * toPostfix (tokens: IterableIterator<Token>): IterableIterator<Token> {
function * toPostfix (tokens: Iterable<Token>): IterableIterator<Token> {
const ops: OperatorToken[] = []
for (const token of tokens) {
if (isOperatorToken(token)) {
+3 -3
View File
@@ -1,11 +1,11 @@
import { ValueToken, Liquid, toValue, evalToken, Value, Emitter, TagToken, TopLevelToken, Context, Template, Tag, ParseStream } from '..'
import { ValueToken, Liquid, toValue, evalToken, Value, Emitter, TagToken, TopLevelToken, Context, Template, Tag, ParseStream, FilteredValueToken } from '..'
import { Parser } from '../parser'
import { equals } from '../render'
import { Arguments } from '../template'
export default class extends Tag {
value: Value
branches: { values: ValueToken[], templates: Template[] }[] = []
branches: { values: (ValueToken | FilteredValueToken)[], templates: Template[] }[] = []
elseTemplates: Template[] = []
constructor (tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid, parser: Parser) {
super(tagToken, remainTokens, liquid)
@@ -22,7 +22,7 @@ export default class extends Tag {
p = []
const values: ValueToken[] = []
const values: (ValueToken | FilteredValueToken)[] = []
while (!token.tokenizer.end()) {
values.push(token.tokenizer.readValueOrThrow())
token.tokenizer.skipBlank()
+2 -2
View File
@@ -1,4 +1,4 @@
import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
import { Hash, ValueToken, Liquid, Tag, evalToken, Emitter, TagToken, TopLevelToken, Context, Template, ParseStream, FilteredValueToken } from '..'
import { assertEmpty, isValueToken, toEnumerable } from '../util'
import { createScope } from '../context/scope'
import { ForloopDrop } from '../drop/forloop-drop'
@@ -11,7 +11,7 @@ type valueOf<T> = T[keyof T]
export default class extends Tag {
variable: string
collection: ValueToken
collection: ValueToken | FilteredValueToken
hash: Hash
templates: Template[]
elseTemplates: Template[]
+2 -2
View File
@@ -1,6 +1,6 @@
import { isValueToken, toEnumerable } from '../util'
import { createScope } from '../context/scope'
import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream } from '..'
import { ValueToken, Liquid, Tag, evalToken, Emitter, Hash, TagToken, TopLevelToken, Context, Template, ParseStream, FilteredValueToken } from '..'
import { TablerowloopDrop } from '../drop/tablerowloop-drop'
import { Parser } from '../parser'
import { Arguments } from '../template'
@@ -9,7 +9,7 @@ export default class extends Tag {
variable: string
args: Hash
templates: Template[]
collection: ValueToken
collection: ValueToken | FilteredValueToken
constructor (tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid, parser: Parser) {
super(tagToken, remainTokens, liquid)
const variable = this.tokenizer.readIdentifier()
+21
View File
@@ -2,6 +2,7 @@ import { Argument, Template, Value } from '.'
import { isKeyValuePair } from '../parser/filter-arg'
import { PropertyAccessToken, ValueToken } from '../tokens'
import {
isFilteredValueToken,
isNumberToken,
isPropertyAccessToken,
isQuotedToken,
@@ -371,11 +372,31 @@ function * extractValueTokenVariables (token: ValueToken): Generator<Variable> {
if (isRangeToken(token)) {
yield * extractValueTokenVariables(token.lhs)
yield * extractValueTokenVariables(token.rhs)
} else if (isFilteredValueToken(token)) {
yield * extractGroupedExpressionTokenVariables(token)
} else if (isPropertyAccessToken(token)) {
yield extractPropertyAccessVariable(token)
}
}
function * extractGroupedExpressionTokenVariables (token: ValueToken): Generator<Variable> {
if (!isFilteredValueToken(token)) return
for (const t of token.initial.postfix) {
if (isValueToken(t)) yield * extractValueTokenVariables(t)
}
for (const filter of token.filters) {
for (const arg of filter.args) {
if (isKeyValuePair(arg) && arg[1]) {
yield * extractValueTokenVariables(arg[1])
} else if (isValueToken(arg)) {
yield * extractValueTokenVariables(arg)
}
}
}
}
function extractPropertyAccessVariable (token: PropertyAccessToken): Variable {
const segments: VariableSegments = []
+1 -1
View File
@@ -12,7 +12,7 @@ export class Output extends TemplateImpl<OutputToken> implements Template {
value: Value
public constructor (token: OutputToken, liquid: Liquid) {
super(token)
const tokenizer = new Tokenizer(token.input, liquid.options.operators, token.file, token.contentRange)
const tokenizer = new Tokenizer(token.input, liquid.options.operators, token.file, token.contentRange, liquid.options.groupedExpressions)
this.value = new Value(tokenizer.readFilteredValue(), liquid)
const filters = this.value.filters
const outputEscape = liquid.options.outputEscape
+1 -1
View File
@@ -15,7 +15,7 @@ export class Value {
*/
public constructor (input: string | FilteredValueToken, liquid: Liquid) {
const token: FilteredValueToken = typeof input === 'string'
? new Tokenizer(input, liquid.options.operators).readFilteredValue()
? new Tokenizer(input, liquid.options.operators, undefined, undefined, liquid.options.groupedExpressions).readFilteredValue()
: input
this.initial = token.initial
this.filters = token.filters.map(token => new Filter(token, this.getFilter(liquid, token.name), liquid))
+1 -1
View File
@@ -16,7 +16,7 @@ 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.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange, options.groupedExpressions)
this.name = this.tokenizer.readTagName()
this.tokenizer.assert(this.name, 'illegal liquid tag syntax')
this.tokenizer.skipBlank()
+2 -1
View File
@@ -5,11 +5,12 @@ import { IdentifierToken } from './identifier-token'
import { NumberToken } from './number-token'
import { RangeToken } from './range-token'
import { QuotedToken } from './quoted-token'
import { FilteredValueToken } from './filtered-value-token'
import { TokenKind } from '../parser'
export class PropertyAccessToken extends Token {
constructor (
public variable: QuotedToken | RangeToken | LiteralToken | NumberToken | undefined,
public variable: QuotedToken | RangeToken | LiteralToken | NumberToken | FilteredValueToken | undefined,
public props: (ValueToken | IdentifierToken)[],
input: string,
begin: number,
+1 -1
View File
@@ -17,7 +17,7 @@ export class TagToken extends DelimitedToken {
const [valueBegin, valueEnd] = [begin + tagDelimiterLeft.length, end - tagDelimiterRight.length]
super(TokenKind.Tag, [valueBegin, valueEnd], input, begin, end, trimTagLeft, trimTagRight, file)
this.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange)
this.tokenizer = new Tokenizer(input, options.operators, file, this.contentRange, options.groupedExpressions)
this.name = this.tokenizer.readTagName()
this.tokenizer.assert(this.name, `illegal tag syntax, tag name expected`)
this.tokenizer.skipBlank()
+2 -1
View File
@@ -3,5 +3,6 @@ import { LiteralToken } from './literal-token'
import { NumberToken } from './number-token'
import { QuotedToken } from './quoted-token'
import { PropertyAccessToken } from './property-access-token'
import { FilteredValueToken } from './filtered-value-token'
export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken | NumberToken
export type ValueToken = RangeToken | LiteralToken | QuotedToken | PropertyAccessToken | NumberToken | FilteredValueToken
+9
View File
@@ -53,6 +53,15 @@ export class LiquidDate {
getTime () {
return this.displayDate.getTime()
}
/**
* The underlying UTC timestamp in milliseconds, unaffected by the display
* timezone. Use this (not `getTime()`) for timezone-invariant values like
* `%s`: `getTime()` reads `displayDate`, which is deliberately shifted by
* the display timezone offset so wall-clock getters can delegate to Date.
*/
dateValue () {
return this.date.getTime()
}
getMilliseconds () {
return this.displayDate.getMilliseconds()
}
+10
View File
@@ -10,7 +10,16 @@ export type Trie<T> = {
needBoundary?: true
} & Record<string, any>
// Tries are built once per input object and reused: the Tokenizer rebuilds them
// on every instantiation, but `input` (operators/literalValues) is a stable
// reference. WeakMap-keying by `input` lets short-lived operator objects (and
// their tries) be garbage collected. The returned trie is treated as read-only
// by callers (matchTrie only reads it); do not mutate it.
const trieCache = new WeakMap<TrieInput<any>, Trie<any>>()
export function createTrie<T = any> (input: TrieInput<T>): Trie<T> {
const cached = trieCache.get(input)
if (cached) return cached
const trie: Trie<T> = {}
for (const [name, data] of Object.entries(input)) {
let node = trie
@@ -29,5 +38,6 @@ export function createTrie<T = any> (input: TrieInput<T>): Trie<T> {
node.data = data
node.end = true
}
trieCache.set(input, trie)
return trie
}
+9
View File
@@ -87,6 +87,15 @@ describe('util/strftime', function () {
expect(t(time, '%10N')).toBe('1290000000')
expect(t(time, '%0N')).toBe('129000000')
})
it('should zero pad %N for sub-100ms fractional seconds', function () {
const time = new TestDate('2019-12-15 01:21:00.005')
expect(t(time, '%N')).toBe('005000000')
expect(t(time, '%3N')).toBe('005')
expect(t(time, '%6N')).toBe('005000')
const tens = new TestDate('2019-12-15 01:21:00.050')
expect(t(tens, '%N')).toBe('050000000')
expect(t(tens, '%2N')).toBe('05')
})
it('should format %p as upper cased am/pm', function () {
expect(t(now, '%p')).toBe('PM')
expect(t(then, '%p')).toBe('AM')
+2 -2
View File
@@ -98,14 +98,14 @@ const formatCodes: Record<string, FormatCodeHandler> = {
M: (d: LiquidDate) => d.getMinutes(),
N: (d: LiquidDate, opts: FormatOptions) => {
const width = Number(opts.width) || 9
const str = String(d.getMilliseconds()).slice(0, width)
const str = padStart(String(d.getMilliseconds()), 3, '0').slice(0, width)
opts.memoryLimit?.use(width - str.length)
return padEnd(str, width, '0')
},
p: (d: LiquidDate) => (d.getHours() < 12 ? 'AM' : 'PM'),
P: (d: LiquidDate) => (d.getHours() < 12 ? 'am' : 'pm'),
q: (d: LiquidDate) => ordinal(d),
s: (d: LiquidDate) => Math.round(d.getTime() / 1000),
s: (d: LiquidDate) => Math.floor(d.dateValue() / 1000),
S: (d: LiquidDate) => d.getSeconds(),
u: (d: LiquidDate) => d.getDay() || 7,
U: (d: LiquidDate) => getWeekOfYear(d, 0),
+7 -3
View File
@@ -1,4 +1,4 @@
import { RangeToken, NumberToken, QuotedToken, LiteralToken, PropertyAccessToken, OutputToken, HTMLToken, TagToken, IdentifierToken, DelimitedToken, OperatorToken, ValueToken } from '../tokens'
import { RangeToken, NumberToken, QuotedToken, LiteralToken, PropertyAccessToken, OutputToken, HTMLToken, TagToken, IdentifierToken, DelimitedToken, OperatorToken, ValueToken, FilteredValueToken } from '../tokens'
import { TokenKind } from '../parser'
export function isDelimitedToken (val: any): val is DelimitedToken {
@@ -45,9 +45,13 @@ export function isRangeToken (val: any): val is RangeToken {
return getKind(val) === TokenKind.Range
}
export function isFilteredValueToken (val: any): val is FilteredValueToken {
return getKind(val) === TokenKind.FilteredValue
}
export function isValueToken (val: any): val is ValueToken {
// valueTokenBitMask = TokenKind.Number | TokenKind.Literal | TokenKind.Quoted | TokenKind.PropertyAccess | TokenKind.Range
return (getKind(val) & 1667) > 0
// valueTokenBitMask = TokenKind.Number | TokenKind.Literal | TokenKind.Quoted | TokenKind.PropertyAccess | TokenKind.Range | TokenKind.FilteredValue
return (getKind(val) & 5763) > 0
}
function getKind (val: any) {
+6
View File
@@ -42,6 +42,12 @@ export function stringify (value: any): string {
return String(value)
}
export function readArrayElement (arr: any[], index: number, ownPropertyOnly: boolean) {
if (index < 0) index = arr.length + index
if (ownPropertyOnly && !hasOwnProperty.call(arr, index)) return undefined
return arr[index]
}
export function toEnumerable<T = unknown> (val: any): T[] {
val = toValue(val)
if (isArray(val)) return val
@@ -0,0 +1,54 @@
import { Liquid } from '../../../src/liquid'
describe('ownPropertyOnly / inherited array indices', function () {
const engine = new Liquid({ ownPropertyOnly: true })
function pollutedArrays () {
// eslint-disable-next-line no-extend-native
Array.prototype[0] = 'ARRAY_PROTO_POLLUTED'
;(Object.prototype as any).secret = 'OBJECT_PROTO_POLLUTED'
const a: any[] = []
a.length = 1
const o = {}
return {
a,
o,
cleanup () {
delete (Array.prototype as any)[0]
delete (Object.prototype as any).secret
}
}
}
const cases: [string, (ctx: ReturnType<typeof pollutedArrays>) => object, string][] = [
['{{ a[0] }}', ({ a }) => ({ a }), ''],
['{{ a[-1] }}', ({ a }) => ({ a }), ''],
['{{ o.secret }}', ({ o }) => ({ o }), ''],
['{{ a.first }}', ({ a }) => ({ a }), ''],
['{{ a.last }}', ({ a }) => ({ a }), ''],
['{{ a | first }}', ({ a }) => ({ a }), ''],
['{{ a | last }}', ({ a }) => ({ a }), ''],
['{% assign x = a | first %}{{ x }}', ({ a }) => ({ a }), '']
]
it.each(cases)('%s', function (src, scopeFn, expected) {
const ctx = pollutedArrays()
try {
expect(engine.parseAndRenderSync(src, scopeFn(ctx))).toBe(expected)
} finally {
ctx.cleanup()
}
})
it('still allows array length and size', function () {
const { a, cleanup } = pollutedArrays()
try {
expect(engine.parseAndRenderSync('{{ a.size }}', { a })).toBe('1')
const arr = [1, 2]
expect(engine.parseAndRenderSync('{{ arr | first }}', { arr })).toBe('1')
expect(engine.parseAndRenderSync('{{ arr[-1] }}', { arr })).toBe('2')
} finally {
cleanup()
}
})
})
+3
View File
@@ -288,6 +288,9 @@ describe('filters/array', function () {
it('should slice substr by -2,2', () => test('{{ "abc" | slice: -2, 2 }}', 'bc'))
it('should support array', () => test('{{ "1,2,3,4" | split: "," | slice: 1,2 | join }}', '2 3'))
it('should return empty array for nil value', () => test('{{ nil | slice: 0 }}', ''))
it('should return empty when begin is out of negative range', () => test('{{ "hello" | slice: -10, 2 }}', ''))
it('should return empty when length is negative', () => test('{{ "Liquid" | slice: 1, -2 }}', ''))
it('should return empty array when begin is out of negative range', () => test('{{ "1,2,3,4,5" | split: "," | slice: -10, 2 | join: "," }}', ''))
})
describe('sort', function () {
it('should support sort', function () {
+12
View File
@@ -140,6 +140,18 @@ describe('filters/date', function () {
it('should support timezone name argument when DST is active', function () {
return test('{{ "2021-06-01T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S", "America/New_York" }}', '2021-06-01T19:00:00')
})
it('should not shift %s by the timezone name argument', function () {
return test('{{ "2026-06-30T21:00:00Z" | date: "%s", "America/Toronto" }}', '1782853200')
})
it('should not shift %s by the timezone offset argument', function () {
return test('{{ "2026-06-30T21:00:00Z" | date: "%s", 360 }}', '1782853200')
})
it('should not shift %s by the timezoneOffset option', function () {
return test('{{ "2026-06-30T21:00:00Z" | date: "%s" }}', '1782853200', undefined, opts)
})
it('should truncate %s toward the epoch like Ruby strftime', function () {
return test('{{ "2026-06-30T17:00:00.500Z" | date: "%s" }}', '1782838800')
})
it('should offset date literal with timezone 00:00 specified', function () {
return test('{{ "1990-12-31T23:00:00+00:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T17:00:00', undefined, opts)
})
+7
View File
@@ -50,6 +50,9 @@ describe('filters/math', function () {
expect(Number(html)).toBeCloseTo(3.357, 3)
})
it('should convert string', () => test('{{ "24" | modulo: "7" }}', '3'))
it('should follow divisor sign for negative dividend', () => test('{{ -7 | modulo: 3 }}', '2'))
it('should follow divisor sign for negative divisor', () => test('{{ 7 | modulo: -3 }}', '-2'))
it('should follow divisor sign for negative float', () => test('{{ -4.5 | modulo: 3 }}', '1.5'))
})
describe('plus', function () {
it('should return "6" for 4,2', () => test('{{ 4 | plus: 2 }}', '6'))
@@ -70,6 +73,10 @@ describe('filters/math', function () {
it('should return "183.36" for 183.357,2',
() => test('{{183.357|round: 2}}', '183.36'))
it('should convert string to number', () => test('{{"2.7"|round}}', '3'))
it('odd number 1.005 should round correctly', () => test('{{1.005|round:2}}', '1.01'))
it('odd number -1.005 should round correctly', () => test('{{num|round:2}}', { num: -1.005 }, '-1.01'))
it('odd number 9.075 should round correctly', () => test('{{9.075|round:2}}', '9.08'))
it('odd number -9.075 should round correctly', () => test('{{num|round:2}}', { num: -9.075 }, '-9.08'))
})
describe('times', function () {
it('should return "6" for 3,2', () => test('{{ 3 | times: 2 }}', '6'))
+20
View File
@@ -153,6 +153,26 @@ describe('filters/string', function () {
'{{ string_with_newlines | strip_newlines }}',
'Hellothere')
})
describe('squish', function () {
it('should collapse whitespace between words', function () {
return test('{{ "Hello World!" | squish }}', 'Hello World!')
})
it('should strip leading and trailing whitespace', function () {
return test('{{ " HelloWorld! " | squish }}', 'HelloWorld!')
})
it('should treat newlines and tabs as whitespace', function () {
return test('{{ " \n\t\r\nHello \n\t World! \n" | squish }}', 'Hello World!')
})
it('should return empty string for whitespace only', function () {
return test('{{ " \n\t " | squish }}', '')
})
it('should stringify a number', function () {
return test('{{ 5 | squish }}', '5')
})
it('should return empty string for undefined', function () {
return test('{{ nosuchthing | squish }}', '')
})
})
describe('truncate', function () {
it('should truncate when string too long', function () {
return test('{{ "Ground control to Major Tom." | truncate: 20 }}',
+8
View File
@@ -7,6 +7,14 @@ describe('filters/url', () => {
const html = liquid.parseAndRenderSync('{{ "%27Stop%21%27+said+Fred" | url_decode }}')
expect(html).toEqual("'Stop!' said Fred")
})
it('should decode %2B to a literal plus', () => {
const html = liquid.parseAndRenderSync('{{ "1%2B1" | url_decode }}')
expect(html).toEqual('1+1')
})
it('should keep a literal plus when round-tripped through url_encode', () => {
const html = liquid.parseAndRenderSync('{{ "a+b c" | url_encode | url_decode }}')
expect(html).toEqual('a+b c')
})
})
describe('url_encode', () => {
+41
View File
@@ -89,6 +89,47 @@ describe('DoS related', function () {
const liquid = new Liquid({ memoryLimit: 100 })
await expect(liquid.parseAndRender('{{ array | sample: 1 | size }}', { array })).rejects.toThrow('memory alloc limit exceeded')
})
it('should charge join by produced output size, not element count', () => {
const array = ['a'.repeat(100), 'b'.repeat(100)]
const liquid = new Liquid({ memoryLimit: 100 })
expect(() => liquid.parseAndRenderSync('{{ array | join: "" }}', { array }))
.toThrow('memory alloc limit exceeded')
})
it('should allow join within memoryLimit', () => {
const array = ['a'.repeat(20), 'b'.repeat(20)]
const liquid = new Liquid({ memoryLimit: 100 })
expect(liquid.parseAndRenderSync('{{ array | join: "" }}', { array })).toBe('a'.repeat(20) + 'b'.repeat(20))
})
it('should prevent concat doubling from bypassing join memoryLimit', () => {
const liquid = new Liquid({ memoryLimit: 1e4 })
const src = '{%- assign a = s | split: "NOSEP" -%}' +
'{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}' +
'{{ a | join: "" | size }}'
expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) }))
.toThrow('memory alloc limit exceeded')
})
it('should charge array_to_sentence_string by produced output size', () => {
const array = ['a'.repeat(100), 'b'.repeat(100), 'c'.repeat(100)]
const liquid = new Liquid({ memoryLimit: 100 })
expect(() => liquid.parseAndRenderSync('{{ array | array_to_sentence_string }}', { array }))
.toThrow('memory alloc limit exceeded')
})
it('should charge json serialization of concat-doubled arrays', () => {
const liquid = new Liquid({ memoryLimit: 1e4 })
const src = '{%- assign a = s | split: "NOSEP" -%}' +
'{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}' +
'{{ a | json | size }}'
expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) }))
.toThrow('memory alloc limit exceeded')
})
it('should charge inspect serialization of concat-doubled arrays', () => {
const liquid = new Liquid({ memoryLimit: 1e4 })
const src = '{%- assign a = s | split: "NOSEP" -%}' +
'{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}{%- assign a = a | concat: a -%}' +
'{{ a | inspect | size }}'
expect(() => liquid.parseAndRenderSync(src, { s: 'a'.repeat(5000) }))
.toThrow('memory alloc limit exceeded')
})
it('should charge strip_html input length to memoryLimit', () => {
const liquid = new Liquid({ memoryLimit: 100 })
expect(() => liquid.parseAndRenderSync('{{ s | strip_html }}', { s: 'a'.repeat(200) }))
@@ -1,4 +1,4 @@
import { Liquid } from '../../../src/liquid'
import { Liquid, filters } from '../../../src'
describe('liquid#registerFilter()', function () {
let liquid: Liquid
@@ -67,3 +67,32 @@ describe('liquid#registerFilter()', function () {
await expect(new Liquid({ strictFilters: true }).parseAndRender('{{ 1 | constructor }}')).rejects.toThrow('undefined filter')
})
})
describe('liquid#unregisterFilter()', function () {
let liquid: Liquid
beforeEach(() => { liquid = new Liquid() })
it('should unregister a custom filter', async () => {
liquid.registerFilter('greet', value => `hello ${value}`)
liquid.unregisterFilter('greet')
const html = await liquid.parseAndRender('{{ "world" | greet }}')
return expect(html).toBe('world')
})
it('should unregister a built-in filter', () => {
liquid = new Liquid({ strictFilters: true })
liquid.unregisterFilter('upcase')
return expect(liquid.parseAndRender('{{ "foo" | upcase }}')).rejects.toThrow('undefined filter: upcase')
})
it('should support re-registering a built-in filter', async () => {
liquid.unregisterFilter('upcase')
liquid.registerFilter('upcase', filters.upcase)
const html = await liquid.parseAndRender('{{ "foo" | upcase }}')
return expect(html).toBe('FOO')
})
it('should not throw for an unknown filter', () => {
expect(() => liquid.unregisterFilter('unknown')).not.toThrow()
})
})
@@ -1085,4 +1085,24 @@ describe('Variable analysis', () => {
locals: { y: [new Variable(['y'], { row: 1, col: 11, file: 'a' })] }
})
})
describe('grouped expressions', () => {
const ge = new Liquid({ groupedExpressions: true })
it('should report variables inside a grouped output expression', () => {
const analysis = analyzeSync(ge.parse('{{ (a | append: b) }}'))
expect(Object.keys(analysis.variables).sort()).toStrictEqual(['a', 'b'])
})
it('should report variables inside a grouped condition', () => {
const analysis = analyzeSync(ge.parse('{% if (a | append: b) == c %}{% endif %}'))
expect(Object.keys(analysis.globals).sort()).toStrictEqual(['a', 'b', 'c'])
})
it('should separate locals from globals for grouped assign', () => {
const analysis = analyzeSync(ge.parse('{% assign x = (a | upcase) %}{{ x }}'))
expect(Object.keys(analysis.globals)).toStrictEqual(['a'])
expect(Object.keys(analysis.locals)).toStrictEqual(['x'])
})
})
})
+9
View File
@@ -99,4 +99,13 @@ describe('tags/assign', function () {
const html = liquid.parseAndRenderSync(src)
return expect(html).toBe('bar')
})
describe('grouped expressions', function () {
const ge = new Liquid({ groupedExpressions: true })
it('should assign a grouped expression', async () => {
expect(await ge.parseAndRender('{% assign x = (name | upcase) %}{{ x }}', { name: 'bar' })).toBe('BAR')
})
it('should assign a grouped range', async () => {
expect(await ge.parseAndRender('{% assign x = (1..(items | size)) %}{{ x }}', { items: ['a', 'b', 'c'] })).toBe('123')
})
})
})
+26
View File
@@ -132,4 +132,30 @@ describe('tags/case', function () {
TRUE
`)
})
describe('parenthesized filter chains', function () {
describe('when enabled', () => {
const ge = new Liquid({ groupedExpressions: true })
it('should support grouped expression in case value', () => {
const src = '{% case (status | downcase) %}{% when "active" %}active{% when "pending" %}pending{% else %}other{% endcase %}'
const html = ge.parseAndRenderSync(src, { status: 'ACTIVE' })
expect(html).toBe('active')
})
it('should support grouped expression in when value', () => {
const src = '{% case status %}{% when (expected | downcase) %}match{% else %}no match{% endcase %}'
const html = ge.parseAndRenderSync(src, { status: 'active', expected: 'ACTIVE' })
expect(html).toBe('match')
})
})
describe('when disabled', () => {
const ge = new Liquid({ groupedExpressions: false })
it('should throw error for grouped expression in case value', () => {
const src = '{% case (status | downcase) %}{% when "active" %}active{% when "pending" %}pending{% else %}other{% endcase %}'
expect(() => ge.parseAndRenderSync(src, { status: 'ACTIVE' })).toThrow('invalid range syntax')
})
it('should throw error for grouped expression in when value', () => {
const src = '{% case status %}{% when (expected | downcase) %}match{% else %}no match{% endcase %}'
expect(() => ge.parseAndRenderSync(src, { status: 'active', expected: 'ACTIVE' })).toThrow('invalid range syntax')
})
})
})
})
+10
View File
@@ -36,4 +36,14 @@ describe('tags/echo', function () {
const html = await liquid.parseAndRender(src, { user: { name: 'Sally' } })
return expect(html).toBe('Hello, SALLY!')
})
describe('grouped expressions', function () {
const ge = new Liquid({ groupedExpressions: true })
it('should echo a grouped expression', async () => {
expect(await ge.parseAndRender('{% echo (name | upcase) %}', { name: 'bar' })).toBe('BAR')
})
it('should render a grouped expression in an output statement', async () => {
expect(await ge.parseAndRender('{{ (name | upcase | append: "!") }}', { name: 'bar' })).toBe('BAR!')
})
})
})
+17
View File
@@ -426,4 +426,21 @@ describe('tags/for', function () {
return expect(html).toBe('i-someDrop i-someDrop i-someDrop ')
})
})
describe('parenthesized filter chains', function () {
describe('when enabled', function () {
const ge = new Liquid({ groupedExpressions: true })
it('should support range with filtered RHS', function () {
const src = '{% for i in (1..(items | size)) %}{{i}} {% endfor %}'
const html = ge.parseAndRenderSync(src, { items: ['a', 'b', 'c'] })
expect(html).toBe('1 2 3 ')
})
})
describe('when disabled', function () {
const ge = new Liquid({ groupedExpressions: false })
it('should throw for range with filtered RHS', function () {
const src = '{% for i in (1..(items | size)) %}{{i}} {% endfor %}'
expect(() => ge.parseAndRenderSync(src, { items: ['a', 'b', 'c'] })).toThrow('invalid range syntax')
})
})
})
})
+80
View File
@@ -169,4 +169,84 @@ describe('tags/if', function () {
expect(() => liquid.parseAndRenderSync('{% if false %}{% else %}{% elsif true %}{% endif %}'))
.toThrow(`unexpected elsif after else`)
})
describe('parenthesized filter chains', function () {
describe('when enabled', function () {
const ge = new Liquid({ groupedExpressions: true })
it('should support (foo | upcase) == "BAR"', async function () {
const src = '{% if (foo | upcase) == "BAR" %}yes{% else %}no{% endif %}'
const html = await ge.parseAndRender(src, { foo: 'bar' })
return expect(html).toBe('yes')
})
it('should support both sides parenthesized', async function () {
const src = '{% if (a | upcase) == (b | upcase) %}yes{% else %}no{% endif %}'
const html = await ge.parseAndRender(src, { a: 'hi', b: 'hi' })
return expect(html).toBe('yes')
})
it('should support with logical operators', async function () {
const src = '{% if (a | upcase) == "FOO" and (b | downcase) == "bar" %}yes{% else %}no{% endif %}'
const html = await ge.parseAndRender(src, { a: 'foo', b: 'BAR' })
return expect(html).toBe('yes')
})
it('should support standalone parenthesized filter via evalValueSync', function () {
const result = ge.evalValueSync('(foo | upcase)', { foo: 'bar' })
return expect(result).toBe('BAR')
})
it('should support comparison via evalValueSync', function () {
const result = ge.evalValueSync('(foo | upcase) == "BAR"', { foo: 'bar' })
return expect(result).toBe(true)
})
it('should keep range syntax working', function () {
const result = ge.evalValueSync('(1..5)', {})
return expect(result).toEqual([1, 2, 3, 4, 5])
})
it('should support chained filters in condition', async function () {
const src = '{% if (name | downcase | size) > 3 %}long{% else %}short{% endif %}'
const html = await ge.parseAndRender(src, { name: 'Alice' })
return expect(html).toBe('long')
})
it('should support real parenthesis grouping with comparisons and and', async function () {
const src = '{% if (((name | downcase | size) > 3) and (one < three)) %}long{% else %}short{% endif %}'
const html = await ge.parseAndRender(src, { name: 'Alice', one: 1, three: 3 })
return expect(html).toBe('long')
})
it('should support nested parenthesized expressions in if condition', async function () {
const src = '{% if ((foo | append: "!") | upcase) == "BAR!" %}match{% else %}no match{% endif %}'
const html = await ge.parseAndRender(src, { foo: 'bar' })
return expect(html).toBe('match')
})
it('should support or with grouped operands', async function () {
const src = '{% if (a | upcase) == "X" or (b | upcase) == "B" %}yes{% else %}no{% endif %}'
expect(await ge.parseAndRender(src, { a: 'z', b: 'b' })).toBe('yes')
})
it('should support not with a grouped operand', async function () {
const src = '{% if not (a | upcase) == "B" %}no{% else %}yes{% endif %}'
expect(await ge.parseAndRender(src, { a: 'b' })).toBe('yes')
})
it('should support contains with a grouped operand', async function () {
const src = '{% if (csv | split: ",") contains "b" %}yes{% else %}no{% endif %}'
expect(await ge.parseAndRender(src, { csv: 'a,b,c' })).toBe('yes')
})
it('should support property access on a grouped result', function () {
expect(ge.evalValueSync('(items | first).name', { items: [{ name: 'Sally' }] })).toBe('Sally')
})
it('should support a grouped expression inside a bracket index', function () {
expect(ge.evalValueSync('arr[(i | plus: 1)]', { arr: [10, 20, 30], i: 1 })).toBe(30)
})
it('should support an async filter inside a grouped expression', async function () {
ge.registerFilter('asyncUpcase', (v: string) => Promise.resolve(String(v).toUpperCase()))
const src = '{% if (name | asyncUpcase) == "BAR" %}yes{% else %}no{% endif %}'
expect(await ge.parseAndRender(src, { name: 'bar' })).toBe('yes')
})
})
describe('when disabled', function () {
const ge = new Liquid({ groupedExpressions: false })
it('should throw for parenthesized filter in condition', () => {
const src = '{% if (foo | upcase) == "BAR" %}yes{% else %}no{% endif %}'
expect(() => ge.parseAndRenderSync(src, { foo: 'bar' })).toThrow('invalid range syntax')
})
it('should throw for parenthesized filter via evalValueSync', () => {
expect(() => ge.evalValueSync('(foo | upcase)', { foo: 'bar' })).toThrow('invalid range syntax')
})
})
})
})
+17
View File
@@ -83,4 +83,21 @@ describe('tags/unless', function () {
expect(html).toBe('yes')
})
})
describe('parenthesized filter chains', function () {
describe('when enabled', function () {
const ge = new Liquid({ groupedExpressions: true })
it('should support grouped expression in unless condition', function () {
const src = '{% unless (content | size) == 0 %}has content{% else %}empty{% endunless %}'
const html = ge.parseAndRenderSync(src, { content: 'hello' })
expect(html).toBe('has content')
})
})
describe('when disabled', function () {
const ge = new Liquid({ groupedExpressions: false })
it('should throw for grouped expression in unless condition', function () {
const src = '{% unless (content | size) == 0 %}has content{% else %}empty{% endunless %}'
expect(() => ge.parseAndRenderSync(src, { content: 'hello' })).toThrow('invalid range syntax')
})
})
})
})