Skip to content

Commit

Permalink
feat: locale support for date filter, #567
Browse files Browse the repository at this point in the history
  • Loading branch information
harttle committed Jul 21, 2024
1 parent 542a75f commit fa338ea
Show file tree
Hide file tree
Showing 29 changed files with 511 additions and 318 deletions.
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

0 comments on commit fa338ea

Please sign in to comment.