mirror of
https://github.com/harttle/liquidjs.git
synced 2026-09-13 11:20:40 -07:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d364c02c02 | ||
|
|
8f57d9fed8 | ||
|
|
9af92f5d8c | ||
|
|
747bdbdbee | ||
|
|
875513f4c5 | ||
|
|
f88a528e27 | ||
|
|
69b2c589f9 | ||
|
|
88ae297c1b | ||
|
|
afa5f54004 | ||
|
|
39c87437c5 | ||
|
|
050f161794 | ||
|
|
2634f9de7b | ||
|
|
f9a1316d16 |
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -12,5 +12,6 @@ export enum TokenKind {
|
||||
Quoted = 1024,
|
||||
Operator = 2048,
|
||||
FilteredValue = 4096,
|
||||
GroupedExpression = 8192,
|
||||
Delimited = Tag | Output
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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[]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 }}',
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user