Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: locale support for date filter, #567 #723

Merged
merged 1 commit into from
Jul 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/source/filters/date.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ title: date
Date filter is used to convert a timestamp into the specified format.

* 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):
* `%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).
* `%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).
* LiquidJS provides an additional `%q` flag for date ordinals. e.g. `{{ '2023/02/02' | date: '%d%q of %b'}}` => `02nd of Feb`
* 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.
* The format filter argument is optional:
* If not provided, it defaults to `%A, %B %-e, %Y at %-l:%M %P %z`.
* The above default can be overridden by [`dateFormat`](/api/interfaces/LiquidOptions.html#dateFormat) LiquidJS option.
* LiquidJS `date` supports locale specific weekdays and month names, which will fallback to English where `Intl` is not supported.
* Ordinals (`%q`) and Jekyll specific date filters are English-only.
* [`locale`](/api/interfaces/LiquidOptions.html#locale) can be set when creating Liquid instance. Defaults to `Intl.DateTimeFormat().resolvedOptions.locale`).

### Examples
```liquid
Expand Down
2 changes: 1 addition & 1 deletion src/context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class Context {
this.strictVariables = renderOptions.strictVariables ?? this.opts.strictVariables
this.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly
this.memoryLimit = memoryLimit ?? new Limiter('memory alloc', renderOptions.memoryLimit ?? opts.memoryLimit)
this.renderLimit = renderLimit ?? new Limiter('template render', performance.now() + (renderOptions.templateLimit ?? opts.renderLimit))
this.renderLimit = renderLimit ?? new Limiter('template render', performance.now() + (renderOptions.renderLimit ?? opts.renderLimit))
}
public getRegister (key: string) {
return (this.registers[key] = this.registers[key] || {})
Expand Down
2 changes: 0 additions & 2 deletions src/filters/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@ export function * find<T extends object> (this: FilterImpl, arr: T[], property:
const value = yield evalToken(token, this.context.spawn(item))
if (equals(value, expected)) return item
}
return null
}

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

export function uniq<T> (this: FilterImpl, arr: T[]): T[] {
Expand Down
36 changes: 14 additions & 22 deletions src/filters/date.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { toValue, stringify, isString, isNumber, TimezoneDate, LiquidDate, strftime, isNil } from '../util'
import { toValue, stringify, isString, isNumber, LiquidDate, strftime, isNil } from '../util'
import { FilterImpl } from '../template'
import { LiquidOptions } from '../liquid-options'
import { NormalizedFullOptions } from '../liquid-options'

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

function parseDate (v: string | Date, opts: LiquidOptions, timezoneOffset?: number | string): LiquidDate | undefined {
let date: LiquidDate
function parseDate (v: string | Date, opts: NormalizedFullOptions, timezoneOffset?: number | string): LiquidDate | undefined {
let date: LiquidDate | undefined
const defaultTimezoneOffset = timezoneOffset ?? opts.timezoneOffset
const locale = opts.locale
v = toValue(v)
if (v === 'now' || v === 'today') {
date = new Date()
date = new LiquidDate(Date.now(), locale, defaultTimezoneOffset)
} else if (isNumber(v)) {
date = new Date(v * 1000)
date = new LiquidDate(v * 1000, locale, defaultTimezoneOffset)
} else if (isString(v)) {
if (/^\d+$/.test(v)) {
date = new Date(+v * 1000)
} else if (opts.preserveTimezones) {
date = TimezoneDate.createDateFixedToTimezone(v)
date = new LiquidDate(+v * 1000, locale, defaultTimezoneOffset)
} else if (opts.preserveTimezones && timezoneOffset === undefined) {
date = LiquidDate.createDateFixedToTimezone(v, locale)
} else {
date = new Date(v)
date = new LiquidDate(v, locale, defaultTimezoneOffset)
}
} else {
date = v
date = new LiquidDate(v, locale, defaultTimezoneOffset)
}
if (!isValidDate(date)) return
if (timezoneOffset !== undefined) {
date = new TimezoneDate(date, timezoneOffset)
} else if (!(date instanceof TimezoneDate) && opts.timezoneOffset !== undefined) {
date = new TimezoneDate(date, opts.timezoneOffset)
}
return date
}

function isValidDate (date: any): date is Date {
return (date instanceof Date || date instanceof TimezoneDate) && !isNaN(date.getTime())
return date.valid() ? date : undefined
}
2 changes: 1 addition & 1 deletion src/fs/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class Loader {
const rRelativePath = new RegExp(['.' + sep, '..' + sep, './', '../'].map(prefix => escapeRegex(prefix)).join('|'))
this.shouldLoadRelative = (referencedFile: string) => rRelativePath.test(referencedFile)
} else {
this.shouldLoadRelative = (referencedFile: string) => false
this.shouldLoadRelative = (_referencedFile: string) => false
}
this.contains = this.options.fs.contains || (() => true)
}
Expand Down
51 changes: 32 additions & 19 deletions src/fs/map-fs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,38 @@ import { MapFS } from './map-fs'

describe('MapFS', () => {
const fs = new MapFS({})
it('should resolve relative file paths', () => {
expect(fs.resolve('foo/bar', 'coo', '')).toEqual('foo/bar/coo')
describe('#resolve()', () => {
it('should resolve relative file paths', () => {
expect(fs.resolve('foo/bar', 'coo', '')).toEqual('foo/bar/coo')
})
it('should resolve to parent', () => {
expect(fs.resolve('foo/bar', '../coo', '')).toEqual('foo/coo')
})
it('should resolve to root', () => {
expect(fs.resolve('foo/bar', '../../coo', '')).toEqual('coo')
})
it('should resolve exceeding root', () => {
expect(fs.resolve('foo/bar', '../../../coo', '')).toEqual('coo')
})
it('should resolve from absolute path', () => {
expect(fs.resolve('/foo/bar', '../../coo', '')).toEqual('/coo')
})
it('should resolve exceeding root from absolute path', () => {
expect(fs.resolve('/foo/bar', '../../../coo', '')).toEqual('/coo')
})
it('should resolve from invalid path', () => {
expect(fs.resolve('foo//bar', '../coo', '')).toEqual('foo/coo')
})
it('should resolve current path', () => {
expect(fs.resolve('foo/bar', '.././coo', '')).toEqual('foo/coo')
})
it('should resolve invalid path', () => {
expect(fs.resolve('foo/bar', '..//coo', '')).toEqual('foo/coo')
})
})
it('should resolve to parent', () => {
expect(fs.resolve('foo/bar', '../coo', '')).toEqual('foo/coo')
})
it('should resolve to root', () => {
expect(fs.resolve('foo/bar', '../../coo', '')).toEqual('coo')
})
it('should resolve exceeding root', () => {
expect(fs.resolve('foo/bar', '../../../coo', '')).toEqual('coo')
})
it('should resolve from absolute path', () => {
expect(fs.resolve('/foo/bar', '../../coo', '')).toEqual('/coo')
})
it('should resolve exceeding root from absolute path', () => {
expect(fs.resolve('/foo/bar', '../../../coo', '')).toEqual('/coo')
})
it('should resolve from invalid path', () => {
expect(fs.resolve('foo//bar', '../coo', '')).toEqual('foo/coo')
describe('#.readFileSync()', () => {
it('should throw if not exist', () => {
expect(() => fs.readFileSync('foo/bar')).toThrow('NOENT: foo/bar')
})
})
})
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* istanbul ignore file */
export const version = '[VI]{version}[/VI]'
export * as TypeGuards from './util/type-guards'
export { toValue, TimezoneDate, createTrie, Trie, toPromise, toValueSync, assert, LiquidError, ParseError, RenderError, UndefinedVariableError, TokenizationError, AssertionError } from './util'
export { toValue, createTrie, Trie, toPromise, toValueSync, assert, LiquidError, ParseError, RenderError, UndefinedVariableError, TokenizationError, AssertionError } from './util'
export { Drop } from './drop'
export { Emitter } from './emitters'
export { defaultOperators, Operators, evalToken, evalQuotedToken, Expression, isFalsy, isTruthy } from './render'
Expand Down
11 changes: 8 additions & 3 deletions src/liquid-options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assert, isArray, isString, isFunction } from './util'
import { getDateTimeFormat } from './util/intl'
import { LRU, LiquidCache } from './cache'
import { FS, LookupType } from './fs'
import * as fs from './fs/fs-impl'
Expand Down Expand Up @@ -43,6 +44,8 @@ export interface LiquidOptions {
timezoneOffset?: number | string;
/** 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`. */
dateFormat?: string;
/** Default locale, will be used by date filter. Defaults to system locale. */
locale?: string;
/** Strip blank characters (including ` `, `\t`, and `\r`) from the right of tags (`{% %}`) until `\n` (inclusive). Defaults to `false`. */
trimTagRight?: boolean;
/** Similar to `trimTagRight`, whereas the `\n` is exclusive. Defaults to `false`. See Whitespace Control for details. */
Expand Down Expand Up @@ -138,6 +141,7 @@ export interface NormalizedFullOptions extends NormalizedOptions {
ownPropertyOnly: boolean;
lenientIf: boolean;
dateFormat: string;
locale: string;
trimTagRight: boolean;
trimTagLeft: boolean;
trimOutputRight: boolean;
Expand Down Expand Up @@ -168,6 +172,7 @@ export const defaultOptions: NormalizedFullOptions = {
dynamicPartials: true,
jsTruthy: false,
dateFormat: '%A, %B %-e, %Y at %-l:%M %P %z',
locale: '',
trimTagRight: false,
trimTagLeft: false,
trimOutputRight: false,
Expand Down Expand Up @@ -211,9 +216,9 @@ export function normalize (options: LiquidOptions): NormalizedFullOptions {
options.partials = normalizeDirectoryList(options.partials)
options.layouts = normalizeDirectoryList(options.layouts)
options.outputEscape = options.outputEscape && getOutputEscapeFunction(options.outputEscape)
options.parseLimit = options.parseLimit || Infinity
options.renderLimit = options.renderLimit || Infinity
options.memoryLimit = options.memoryLimit || Infinity
if (!options.locale) {
options.locale = getDateTimeFormat()?.().resolvedOptions().locale ?? 'en-US'
}
if (options.templates) {
options.fs = new MapFS(options.templates)
options.relativeReference = true
Expand Down
2 changes: 1 addition & 1 deletion src/parser/tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export class Tokenizer {
return new HTMLToken(this.input, begin, this.p, this.file)
}

readTagToken (options: NormalizedFullOptions = defaultOptions): TagToken {
readTagToken (options: NormalizedFullOptions): TagToken {
const { file, input } = this
const begin = this.p
if (this.readToDelimiter(options.tagDelimiterRight) === -1) {
Expand Down
10 changes: 0 additions & 10 deletions src/tokens/identifier-token.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Token } from './token'
import { NUMBER, TYPES, SIGN } from '../util'
import { TokenKind } from '../parser'

export class IdentifierToken extends Token {
Expand All @@ -13,13 +12,4 @@ export class IdentifierToken extends Token {
super(TokenKind.Word, input, begin, end, file)
this.content = this.getText()
}
isNumber (allowSign = false) {
const begin = allowSign && TYPES[this.input.charCodeAt(this.begin)] & SIGN
? this.begin + 1
: this.begin
for (let i = begin; i < this.end; i++) {
if (!(TYPES[this.input.charCodeAt(i)] & NUMBER)) return false
}
return true
}
}
40 changes: 40 additions & 0 deletions src/util/error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Template } from '../template'
import { NumberToken } from '../tokens'
import { LiquidErrors, LiquidError, ParseError, RenderError } from './error'

describe('LiquidError', () => {
describe('.is()', () => {
it('should return true for a LiquidError instance', () => {
const err = new Error('intended')
const token = new NumberToken('3', 0, 1)
expect(LiquidError.is(new ParseError(err, token))).toBeTruthy()
})
it('should return false for null', () => {
expect(LiquidError.is(null)).toBeFalsy()
})
})
})

describe('LiquidErrors', () => {
describe('.is()', () => {
it('should return true for a LiquidErrors instance', () => {
const err = new Error('intended')
const token = new NumberToken('3', 0, 1)
const error = new ParseError(err, token)
expect(LiquidErrors.is(new LiquidErrors([error]))).toBeTruthy()
})
})
})

describe('RenderError', () => {
describe('.is()', () => {
it('should return true for a RenderError instance', () => {
const err = new Error('intended')
const tpl = {
token: new NumberToken('3', 0, 1),
render: () => ''
} as any as Template
expect(RenderError.is(new RenderError(err, tpl))).toBeTruthy()
})
})
})
2 changes: 1 addition & 1 deletion src/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ export * from './type-guards'
export * from './async'
export * from './strftime'
export * from './liquid-date'
export * from './timezone-date'
export * from './limiter'
export * from './intl'
3 changes: 3 additions & 0 deletions src/util/intl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function getDateTimeFormat () {
return (typeof Intl !== 'undefined' ? Intl.DateTimeFormat : undefined)
}
62 changes: 62 additions & 0 deletions src/util/liquid-date.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { LiquidDate } from './liquid-date'
import { disableIntl } from '../../test/stub/no-intl'

describe('LiquidDate', () => {
describe('timezone', () => {
it('should respect timezone set to 00:00', () => {
const date = new LiquidDate('2021-10-06T14:26:00.000+08:00', 'en-US', 0)
expect(date.getTimezoneOffset()).toBe(0)
expect(date.getHours()).toBe(6)
expect(date.getMinutes()).toBe(26)
})
it('should respect timezone set to -06:00', () => {
const date = new LiquidDate('2021-10-06T14:26:00.000+08:00', 'en-US', -360)
expect(date.getTimezoneOffset()).toBe(-360)
expect(date.getMinutes()).toBe(26)
})
})
it('should support Date as argument', () => {
const date = new LiquidDate(new Date('2021-10-06T14:26:00.000+08:00'), 'en-US', 0)
expect(date.getHours()).toBe(6)
})
it('should support .getMilliseconds()', () => {
const date = new LiquidDate('2021-10-06T14:26:00.001+00:00', 'en-US', 0)
expect(date.getMilliseconds()).toBe(1)
})
it('should support .getDay()', () => {
const date = new LiquidDate('2021-12-07T00:00:00.001+08:00', 'en-US', -480)
expect(date.getDay()).toBe(2)
})
it('should support .toLocaleString()', () => {
const date = new LiquidDate('2021-10-06T00:00:00.001+00:00', 'en-US', -480)
expect(date.toLocaleString('en-US')).toMatch(/8:00:00\sAM$/)
expect(date.toLocaleString('en-US', { timeZone: 'America/New_York' })).toMatch(/8:00:00\sPM$/)
expect(() => date.toLocaleString()).not.toThrow()
})
it('should support .toLocaleTimeString()', () => {
const date = new LiquidDate('2021-10-06T00:00:00.001+00:00', 'en-US', -480)
expect(date.toLocaleTimeString('en-US')).toMatch(/^8:00:00\sAM$/)
expect(() => date.toLocaleDateString()).not.toThrow()
})
it('should support .toLocaleDateString()', () => {
const date = new LiquidDate('2021-10-06T22:00:00.001+00:00', 'en-US', -480)
expect(date.toLocaleDateString('en-US')).toBe('10/7/2021')
expect(() => date.toLocaleDateString()).not.toThrow()
})
describe('compatibility', () => {
disableIntl()
it('should use English months if Intl.DateTimeFormat not supported', () => {
expect(new LiquidDate('2021-10-06T22:00:00.001+00:00', 'en-US', -480).getLongMonthName()).toEqual('October')
expect(new LiquidDate('2021-10-06T22:00:00.001+00:00', 'zh-CN', -480).getLongMonthName()).toEqual('October')
expect(new LiquidDate('2021-10-06T22:00:00.001+00:00', 'zh-CN', -480).getShortMonthName()).toEqual('Oct')
})
it('should use English weekdays if Intl.DateTimeFormat not supported', () => {
expect(new LiquidDate('2024-07-21T22:00:00.001+00:00', 'en-US', 0).getLongWeekdayName()).toEqual('Sunday')
expect(new LiquidDate('2024-07-21T22:00:00.001+00:00', 'zh-CN', -480).getLongWeekdayName()).toEqual('Monday')
expect(new LiquidDate('2024-07-21T22:00:00.001+00:00', 'zh-CN', -480).getShortWeekdayName()).toEqual('Mon')
})
it('should return none for timezone if Intl.DateTimeFormat not supported', () => {
expect(new LiquidDate('2024-07-21T22:00:00.001', 'en-US').getTimeZoneName()).toEqual(undefined)
})
})
})
Loading
Loading