Skip to content

Commit fa338ea

Browse files
committed
feat: locale support for date filter, #567
1 parent 542a75f commit fa338ea

29 files changed

+511
-318
lines changed

docs/source/filters/date.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ title: date
66
Date filter is used to convert a timestamp into the specified format.
77

88
* LiquidJS tries to conform to Shopify/Liquid, which uses Ruby's core [Time#strftime(string)](https://www.ruby-doc.org/core/Time.html#method-i-strftime). There're differences with [Ruby's format flags](https://ruby-doc.org/core/strftime_formatting_rdoc.html):
9-
* `%Z` (since v10.11.1) works when there's a passed-in timezone name from `LiquidOption` or in-place value (see TimeZone below). If passed-in timezone is an offset number instead of string, it'll behave like `%z`. If there's none passed-in timezone, it returns [the runtime's default time zone](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/resolvedOptions#timezone).
9+
* `%Z` (since v10.11.1) is replaced by the passed-in timezone name from `LiquidOption` or in-place value (see TimeZone below). If passed-in timezone is an offset number instead of string, it'll behave like `%z`. If there's none passed-in timezone, it returns [the runtime's default time zone](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/resolvedOptions#timezone).
1010
* LiquidJS provides an additional `%q` flag for date ordinals. e.g. `{{ '2023/02/02' | date: '%d%q of %b'}}` => `02nd of Feb`
1111
* Date literals are firstly converted to `Date` object via [new Date()][jsDate], that means literal values are considered in runtime's time zone by default.
1212
* The format filter argument is optional:
1313
* If not provided, it defaults to `%A, %B %-e, %Y at %-l:%M %P %z`.
1414
* The above default can be overridden by [`dateFormat`](/api/interfaces/LiquidOptions.html#dateFormat) LiquidJS option.
15+
* LiquidJS `date` supports locale specific weekdays and month names, which will fallback to English where `Intl` is not supported.
16+
* Ordinals (`%q`) and Jekyll specific date filters are English-only.
17+
* [`locale`](/api/interfaces/LiquidOptions.html#locale) can be set when creating Liquid instance. Defaults to `Intl.DateTimeFormat().resolvedOptions.locale`).
1518

1619
### Examples
1720
```liquid

src/context/context.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export class Context {
4343
this.strictVariables = renderOptions.strictVariables ?? this.opts.strictVariables
4444
this.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly
4545
this.memoryLimit = memoryLimit ?? new Limiter('memory alloc', renderOptions.memoryLimit ?? opts.memoryLimit)
46-
this.renderLimit = renderLimit ?? new Limiter('template render', performance.now() + (renderOptions.templateLimit ?? opts.renderLimit))
46+
this.renderLimit = renderLimit ?? new Limiter('template render', performance.now() + (renderOptions.renderLimit ?? opts.renderLimit))
4747
}
4848
public getRegister (key: string) {
4949
return (this.registers[key] = this.registers[key] || {})

src/filters/array.ts

-2
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,6 @@ export function * find<T extends object> (this: FilterImpl, arr: T[], property:
175175
const value = yield evalToken(token, this.context.spawn(item))
176176
if (equals(value, expected)) return item
177177
}
178-
return null
179178
}
180179

181180
export function * find_exp<T extends object> (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator<unknown> {
@@ -185,7 +184,6 @@ export function * find_exp<T extends object> (this: FilterImpl, arr: T[], itemNa
185184
const value = yield predicate.value(this.context.spawn({ [itemName]: item }))
186185
if (value) return item
187186
}
188-
return null
189187
}
190188

191189
export function uniq<T> (this: FilterImpl, arr: T[]): T[] {

src/filters/date.ts

+14-22
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { toValue, stringify, isString, isNumber, TimezoneDate, LiquidDate, strftime, isNil } from '../util'
1+
import { toValue, stringify, isString, isNumber, LiquidDate, strftime, isNil } from '../util'
22
import { FilterImpl } from '../template'
3-
import { LiquidOptions } from '../liquid-options'
3+
import { NormalizedFullOptions } from '../liquid-options'
44

55
export function date (this: FilterImpl, v: string | Date, format?: string, timezoneOffset?: number | string) {
66
const size = ((v as string)?.length ?? 0) + (format?.length ?? 0) + ((timezoneOffset as string)?.length ?? 0)
@@ -40,33 +40,25 @@ function stringify_date (this: FilterImpl, v: string | Date, month_type: string,
4040
return strftime(date, `%d ${month_type} %Y`)
4141
}
4242

43-
function parseDate (v: string | Date, opts: LiquidOptions, timezoneOffset?: number | string): LiquidDate | undefined {
44-
let date: LiquidDate
43+
function parseDate (v: string | Date, opts: NormalizedFullOptions, timezoneOffset?: number | string): LiquidDate | undefined {
44+
let date: LiquidDate | undefined
45+
const defaultTimezoneOffset = timezoneOffset ?? opts.timezoneOffset
46+
const locale = opts.locale
4547
v = toValue(v)
4648
if (v === 'now' || v === 'today') {
47-
date = new Date()
49+
date = new LiquidDate(Date.now(), locale, defaultTimezoneOffset)
4850
} else if (isNumber(v)) {
49-
date = new Date(v * 1000)
51+
date = new LiquidDate(v * 1000, locale, defaultTimezoneOffset)
5052
} else if (isString(v)) {
5153
if (/^\d+$/.test(v)) {
52-
date = new Date(+v * 1000)
53-
} else if (opts.preserveTimezones) {
54-
date = TimezoneDate.createDateFixedToTimezone(v)
54+
date = new LiquidDate(+v * 1000, locale, defaultTimezoneOffset)
55+
} else if (opts.preserveTimezones && timezoneOffset === undefined) {
56+
date = LiquidDate.createDateFixedToTimezone(v, locale)
5557
} else {
56-
date = new Date(v)
58+
date = new LiquidDate(v, locale, defaultTimezoneOffset)
5759
}
5860
} else {
59-
date = v
61+
date = new LiquidDate(v, locale, defaultTimezoneOffset)
6062
}
61-
if (!isValidDate(date)) return
62-
if (timezoneOffset !== undefined) {
63-
date = new TimezoneDate(date, timezoneOffset)
64-
} else if (!(date instanceof TimezoneDate) && opts.timezoneOffset !== undefined) {
65-
date = new TimezoneDate(date, opts.timezoneOffset)
66-
}
67-
return date
68-
}
69-
70-
function isValidDate (date: any): date is Date {
71-
return (date instanceof Date || date instanceof TimezoneDate) && !isNaN(date.getTime())
63+
return date.valid() ? date : undefined
7264
}

src/fs/loader.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export class Loader {
2727
const rRelativePath = new RegExp(['.' + sep, '..' + sep, './', '../'].map(prefix => escapeRegex(prefix)).join('|'))
2828
this.shouldLoadRelative = (referencedFile: string) => rRelativePath.test(referencedFile)
2929
} else {
30-
this.shouldLoadRelative = (referencedFile: string) => false
30+
this.shouldLoadRelative = (_referencedFile: string) => false
3131
}
3232
this.contains = this.options.fs.contains || (() => true)
3333
}

src/fs/map-fs.spec.ts

+32-19
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,38 @@ import { MapFS } from './map-fs'
22

33
describe('MapFS', () => {
44
const fs = new MapFS({})
5-
it('should resolve relative file paths', () => {
6-
expect(fs.resolve('foo/bar', 'coo', '')).toEqual('foo/bar/coo')
5+
describe('#resolve()', () => {
6+
it('should resolve relative file paths', () => {
7+
expect(fs.resolve('foo/bar', 'coo', '')).toEqual('foo/bar/coo')
8+
})
9+
it('should resolve to parent', () => {
10+
expect(fs.resolve('foo/bar', '../coo', '')).toEqual('foo/coo')
11+
})
12+
it('should resolve to root', () => {
13+
expect(fs.resolve('foo/bar', '../../coo', '')).toEqual('coo')
14+
})
15+
it('should resolve exceeding root', () => {
16+
expect(fs.resolve('foo/bar', '../../../coo', '')).toEqual('coo')
17+
})
18+
it('should resolve from absolute path', () => {
19+
expect(fs.resolve('/foo/bar', '../../coo', '')).toEqual('/coo')
20+
})
21+
it('should resolve exceeding root from absolute path', () => {
22+
expect(fs.resolve('/foo/bar', '../../../coo', '')).toEqual('/coo')
23+
})
24+
it('should resolve from invalid path', () => {
25+
expect(fs.resolve('foo//bar', '../coo', '')).toEqual('foo/coo')
26+
})
27+
it('should resolve current path', () => {
28+
expect(fs.resolve('foo/bar', '.././coo', '')).toEqual('foo/coo')
29+
})
30+
it('should resolve invalid path', () => {
31+
expect(fs.resolve('foo/bar', '..//coo', '')).toEqual('foo/coo')
32+
})
733
})
8-
it('should resolve to parent', () => {
9-
expect(fs.resolve('foo/bar', '../coo', '')).toEqual('foo/coo')
10-
})
11-
it('should resolve to root', () => {
12-
expect(fs.resolve('foo/bar', '../../coo', '')).toEqual('coo')
13-
})
14-
it('should resolve exceeding root', () => {
15-
expect(fs.resolve('foo/bar', '../../../coo', '')).toEqual('coo')
16-
})
17-
it('should resolve from absolute path', () => {
18-
expect(fs.resolve('/foo/bar', '../../coo', '')).toEqual('/coo')
19-
})
20-
it('should resolve exceeding root from absolute path', () => {
21-
expect(fs.resolve('/foo/bar', '../../../coo', '')).toEqual('/coo')
22-
})
23-
it('should resolve from invalid path', () => {
24-
expect(fs.resolve('foo//bar', '../coo', '')).toEqual('foo/coo')
34+
describe('#.readFileSync()', () => {
35+
it('should throw if not exist', () => {
36+
expect(() => fs.readFileSync('foo/bar')).toThrow('NOENT: foo/bar')
37+
})
2538
})
2639
})

src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* istanbul ignore file */
22
export const version = '[VI]{version}[/VI]'
33
export * as TypeGuards from './util/type-guards'
4-
export { toValue, TimezoneDate, createTrie, Trie, toPromise, toValueSync, assert, LiquidError, ParseError, RenderError, UndefinedVariableError, TokenizationError, AssertionError } from './util'
4+
export { toValue, createTrie, Trie, toPromise, toValueSync, assert, LiquidError, ParseError, RenderError, UndefinedVariableError, TokenizationError, AssertionError } from './util'
55
export { Drop } from './drop'
66
export { Emitter } from './emitters'
77
export { defaultOperators, Operators, evalToken, evalQuotedToken, Expression, isFalsy, isTruthy } from './render'

src/liquid-options.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { assert, isArray, isString, isFunction } from './util'
2+
import { getDateTimeFormat } from './util/intl'
23
import { LRU, LiquidCache } from './cache'
34
import { FS, LookupType } from './fs'
45
import * as fs from './fs/fs-impl'
@@ -43,6 +44,8 @@ export interface LiquidOptions {
4344
timezoneOffset?: number | string;
4445
/** Default date format to use if the date filter doesn't include a format. Defaults to `%A, %B %-e, %Y at %-l:%M %P %z`. */
4546
dateFormat?: string;
47+
/** Default locale, will be used by date filter. Defaults to system locale. */
48+
locale?: string;
4649
/** Strip blank characters (including ` `, `\t`, and `\r`) from the right of tags (`{% %}`) until `\n` (inclusive). Defaults to `false`. */
4750
trimTagRight?: boolean;
4851
/** Similar to `trimTagRight`, whereas the `\n` is exclusive. Defaults to `false`. See Whitespace Control for details. */
@@ -138,6 +141,7 @@ export interface NormalizedFullOptions extends NormalizedOptions {
138141
ownPropertyOnly: boolean;
139142
lenientIf: boolean;
140143
dateFormat: string;
144+
locale: string;
141145
trimTagRight: boolean;
142146
trimTagLeft: boolean;
143147
trimOutputRight: boolean;
@@ -168,6 +172,7 @@ export const defaultOptions: NormalizedFullOptions = {
168172
dynamicPartials: true,
169173
jsTruthy: false,
170174
dateFormat: '%A, %B %-e, %Y at %-l:%M %P %z',
175+
locale: '',
171176
trimTagRight: false,
172177
trimTagLeft: false,
173178
trimOutputRight: false,
@@ -211,9 +216,9 @@ export function normalize (options: LiquidOptions): NormalizedFullOptions {
211216
options.partials = normalizeDirectoryList(options.partials)
212217
options.layouts = normalizeDirectoryList(options.layouts)
213218
options.outputEscape = options.outputEscape && getOutputEscapeFunction(options.outputEscape)
214-
options.parseLimit = options.parseLimit || Infinity
215-
options.renderLimit = options.renderLimit || Infinity
216-
options.memoryLimit = options.memoryLimit || Infinity
219+
if (!options.locale) {
220+
options.locale = getDateTimeFormat()?.().resolvedOptions().locale ?? 'en-US'
221+
}
217222
if (options.templates) {
218223
options.fs = new MapFS(options.templates)
219224
options.relativeReference = true

src/parser/tokenizer.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export class Tokenizer {
143143
return new HTMLToken(this.input, begin, this.p, this.file)
144144
}
145145

146-
readTagToken (options: NormalizedFullOptions = defaultOptions): TagToken {
146+
readTagToken (options: NormalizedFullOptions): TagToken {
147147
const { file, input } = this
148148
const begin = this.p
149149
if (this.readToDelimiter(options.tagDelimiterRight) === -1) {

src/tokens/identifier-token.ts

-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Token } from './token'
2-
import { NUMBER, TYPES, SIGN } from '../util'
32
import { TokenKind } from '../parser'
43

54
export class IdentifierToken extends Token {
@@ -13,13 +12,4 @@ export class IdentifierToken extends Token {
1312
super(TokenKind.Word, input, begin, end, file)
1413
this.content = this.getText()
1514
}
16-
isNumber (allowSign = false) {
17-
const begin = allowSign && TYPES[this.input.charCodeAt(this.begin)] & SIGN
18-
? this.begin + 1
19-
: this.begin
20-
for (let i = begin; i < this.end; i++) {
21-
if (!(TYPES[this.input.charCodeAt(i)] & NUMBER)) return false
22-
}
23-
return true
24-
}
2515
}

src/util/error.spec.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Template } from '../template'
2+
import { NumberToken } from '../tokens'
3+
import { LiquidErrors, LiquidError, ParseError, RenderError } from './error'
4+
5+
describe('LiquidError', () => {
6+
describe('.is()', () => {
7+
it('should return true for a LiquidError instance', () => {
8+
const err = new Error('intended')
9+
const token = new NumberToken('3', 0, 1)
10+
expect(LiquidError.is(new ParseError(err, token))).toBeTruthy()
11+
})
12+
it('should return false for null', () => {
13+
expect(LiquidError.is(null)).toBeFalsy()
14+
})
15+
})
16+
})
17+
18+
describe('LiquidErrors', () => {
19+
describe('.is()', () => {
20+
it('should return true for a LiquidErrors instance', () => {
21+
const err = new Error('intended')
22+
const token = new NumberToken('3', 0, 1)
23+
const error = new ParseError(err, token)
24+
expect(LiquidErrors.is(new LiquidErrors([error]))).toBeTruthy()
25+
})
26+
})
27+
})
28+
29+
describe('RenderError', () => {
30+
describe('.is()', () => {
31+
it('should return true for a RenderError instance', () => {
32+
const err = new Error('intended')
33+
const tpl = {
34+
token: new NumberToken('3', 0, 1),
35+
render: () => ''
36+
} as any as Template
37+
expect(RenderError.is(new RenderError(err, tpl))).toBeTruthy()
38+
})
39+
})
40+
})

src/util/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@ export * from './type-guards'
88
export * from './async'
99
export * from './strftime'
1010
export * from './liquid-date'
11-
export * from './timezone-date'
1211
export * from './limiter'
12+
export * from './intl'

src/util/intl.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function getDateTimeFormat () {
2+
return (typeof Intl !== 'undefined' ? Intl.DateTimeFormat : undefined)
3+
}

src/util/liquid-date.spec.ts

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { LiquidDate } from './liquid-date'
2+
import { disableIntl } from '../../test/stub/no-intl'
3+
4+
describe('LiquidDate', () => {
5+
describe('timezone', () => {
6+
it('should respect timezone set to 00:00', () => {
7+
const date = new LiquidDate('2021-10-06T14:26:00.000+08:00', 'en-US', 0)
8+
expect(date.getTimezoneOffset()).toBe(0)
9+
expect(date.getHours()).toBe(6)
10+
expect(date.getMinutes()).toBe(26)
11+
})
12+
it('should respect timezone set to -06:00', () => {
13+
const date = new LiquidDate('2021-10-06T14:26:00.000+08:00', 'en-US', -360)
14+
expect(date.getTimezoneOffset()).toBe(-360)
15+
expect(date.getMinutes()).toBe(26)
16+
})
17+
})
18+
it('should support Date as argument', () => {
19+
const date = new LiquidDate(new Date('2021-10-06T14:26:00.000+08:00'), 'en-US', 0)
20+
expect(date.getHours()).toBe(6)
21+
})
22+
it('should support .getMilliseconds()', () => {
23+
const date = new LiquidDate('2021-10-06T14:26:00.001+00:00', 'en-US', 0)
24+
expect(date.getMilliseconds()).toBe(1)
25+
})
26+
it('should support .getDay()', () => {
27+
const date = new LiquidDate('2021-12-07T00:00:00.001+08:00', 'en-US', -480)
28+
expect(date.getDay()).toBe(2)
29+
})
30+
it('should support .toLocaleString()', () => {
31+
const date = new LiquidDate('2021-10-06T00:00:00.001+00:00', 'en-US', -480)
32+
expect(date.toLocaleString('en-US')).toMatch(/8:00:00\sAM$/)
33+
expect(date.toLocaleString('en-US', { timeZone: 'America/New_York' })).toMatch(/8:00:00\sPM$/)
34+
expect(() => date.toLocaleString()).not.toThrow()
35+
})
36+
it('should support .toLocaleTimeString()', () => {
37+
const date = new LiquidDate('2021-10-06T00:00:00.001+00:00', 'en-US', -480)
38+
expect(date.toLocaleTimeString('en-US')).toMatch(/^8:00:00\sAM$/)
39+
expect(() => date.toLocaleDateString()).not.toThrow()
40+
})
41+
it('should support .toLocaleDateString()', () => {
42+
const date = new LiquidDate('2021-10-06T22:00:00.001+00:00', 'en-US', -480)
43+
expect(date.toLocaleDateString('en-US')).toBe('10/7/2021')
44+
expect(() => date.toLocaleDateString()).not.toThrow()
45+
})
46+
describe('compatibility', () => {
47+
disableIntl()
48+
it('should use English months if Intl.DateTimeFormat not supported', () => {
49+
expect(new LiquidDate('2021-10-06T22:00:00.001+00:00', 'en-US', -480).getLongMonthName()).toEqual('October')
50+
expect(new LiquidDate('2021-10-06T22:00:00.001+00:00', 'zh-CN', -480).getLongMonthName()).toEqual('October')
51+
expect(new LiquidDate('2021-10-06T22:00:00.001+00:00', 'zh-CN', -480).getShortMonthName()).toEqual('Oct')
52+
})
53+
it('should use English weekdays if Intl.DateTimeFormat not supported', () => {
54+
expect(new LiquidDate('2024-07-21T22:00:00.001+00:00', 'en-US', 0).getLongWeekdayName()).toEqual('Sunday')
55+
expect(new LiquidDate('2024-07-21T22:00:00.001+00:00', 'zh-CN', -480).getLongWeekdayName()).toEqual('Monday')
56+
expect(new LiquidDate('2024-07-21T22:00:00.001+00:00', 'zh-CN', -480).getShortWeekdayName()).toEqual('Mon')
57+
})
58+
it('should return none for timezone if Intl.DateTimeFormat not supported', () => {
59+
expect(new LiquidDate('2024-07-21T22:00:00.001', 'en-US').getTimeZoneName()).toEqual(undefined)
60+
})
61+
})
62+
})

0 commit comments

Comments
 (0)