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
47 changed files with 591 additions and 56 deletions
+18
View File
@@ -847,6 +847,24 @@
"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,
+31
View File
@@ -1,3 +1,34 @@
# [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)
+2
View File
@@ -242,6 +242,8 @@ Want to contribute? see [Contribution Guidelines][contribution]. Thanks goes to
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/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>
+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.
```
+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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "liquidjs",
"version": "10.27.1",
"version": "10.29.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "liquidjs",
"version": "10.27.1",
"version": "10.29.0",
"license": "MIT",
"dependencies": {
"commander": "^10.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "liquidjs",
"version": "10.27.1",
"version": "10.29.0",
"sideEffects": false,
"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",
@@ -35,7 +35,7 @@
"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"
},
+8 -2
View File
@@ -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) {
+1
View File
@@ -114,6 +114,7 @@ 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 isArray(v)
? Array.prototype.slice.call(v, begin, begin + length)
+6
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)
+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'
+4
View File
@@ -87,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. */
@@ -162,6 +164,7 @@ export interface NormalizedFullOptions extends NormalizedOptions {
globals: object;
keepOutputType: boolean;
operators: Operators;
groupedExpressions: boolean;
parseLimit: number;
renderLimit: number;
memoryLimit: number;
@@ -198,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
}
+100 -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')
+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()
}
+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) {
+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)
})
+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', () => {
@@ -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')
})
})
})
})