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

This commit is contained in:
Jun Yang
2024-07-22 00:39:44 +08:00
committed by GitHub
parent 542a75fd44
commit e4aeb023fd
29 changed files with 511 additions and 318 deletions
+40
View File
@@ -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()
})
})
})
+1 -1
View File
@@ -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
View File
@@ -0,0 +1,3 @@
export function getDateTimeFormat () {
return (typeof Intl !== 'undefined' ? Intl.DateTimeFormat : undefined)
}
+62
View File
@@ -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)
})
})
})
+147 -17
View File
@@ -1,20 +1,150 @@
import { getDateTimeFormat } from './intl'
import { isString } from './underscore'
// one minute in milliseconds
const OneMinute = 60000
const ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
'September', 'October', 'November', 'December'
]
const monthNamesShort = monthNames.map(name => name.slice(0, 3))
const dayNames = [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
]
const dayNamesShort = dayNames.map(name => name.slice(0, 3))
/**
* The date interface LiquidJS uses.
* Basically a subset of JavaScript Date,
* it's defined abstractly here to allow different implementation
* A date implementation with timezone info, just like Ruby date
*
* Implementation:
* - create a Date offset by it's timezone difference, avoiding overriding a bunch of methods
* - rewrite getTimezoneOffset() to trick strftime
*/
export interface LiquidDate {
getTime(): number;
getMilliseconds(): number;
getSeconds(): number;
getMinutes(): number;
getHours(): number;
getDay(): number;
getDate(): number;
getMonth(): number;
getFullYear(): number;
getTimezoneOffset(): number;
getTimezoneName?(): string;
toLocaleTimeString(): string;
toLocaleDateString(): string;
export class LiquidDate {
private timezoneOffset: number
private timezoneName: string
private date: Date
private displayDate: Date
private DateTimeFormat = getDateTimeFormat()
public timezoneFixed: boolean
constructor (
init: string | number | Date,
private locale: string,
timezone?: number | string
) {
this.date = new Date(init)
this.timezoneFixed = timezone !== undefined
if (timezone === undefined) {
timezone = this.date.getTimezoneOffset()
}
this.timezoneOffset = isString(timezone) ? LiquidDate.getTimezoneOffset(timezone, this.date) : timezone
this.timezoneName = isString(timezone) ? timezone : ''
const diff = (this.date.getTimezoneOffset() - this.timezoneOffset) * OneMinute
const time = this.date.getTime() + diff
this.displayDate = new Date(time)
}
getTime () {
return this.displayDate.getTime()
}
getMilliseconds () {
return this.displayDate.getMilliseconds()
}
getSeconds () {
return this.displayDate.getSeconds()
}
getMinutes () {
return this.displayDate.getMinutes()
}
getHours () {
return this.displayDate.getHours()
}
getDay () {
return this.displayDate.getDay()
}
getDate () {
return this.displayDate.getDate()
}
getMonth () {
return this.displayDate.getMonth()
}
getFullYear () {
return this.displayDate.getFullYear()
}
toLocaleString (locale?: string, init?: any) {
if (init?.timeZone) {
return this.date.toLocaleString(locale, init)
}
return this.displayDate.toLocaleString(locale, init)
}
toLocaleTimeString (locale?: string) {
return this.displayDate.toLocaleTimeString(locale)
}
toLocaleDateString (locale?: string) {
return this.displayDate.toLocaleDateString(locale)
}
getTimezoneOffset () {
return this.timezoneOffset!
}
getTimeZoneName () {
if (this.timezoneFixed) return this.timezoneName
if (!this.DateTimeFormat) return
return this.DateTimeFormat().resolvedOptions().timeZone
}
getLongMonthName () {
return this.format({ month: 'long' }) ?? monthNames[this.getMonth()]
}
getShortMonthName () {
return this.format({ month: 'short' }) ?? monthNamesShort[this.getMonth()]
}
getLongWeekdayName () {
return this.format({ weekday: 'long' }) ?? dayNames[this.displayDate.getDay()]
}
getShortWeekdayName () {
return this.format({ weekday: 'short' }) ?? dayNamesShort[this.displayDate.getDay()]
}
valid () {
return !isNaN(this.getTime())
}
private format (options: Intl.DateTimeFormatOptions) {
return this.DateTimeFormat && this.DateTimeFormat(this.locale, options).format(this.displayDate)
}
/**
* Create a Date object fixed to it's declared Timezone. Both
* - 2021-08-06T02:29:00.000Z and
* - 2021-08-06T02:29:00.000+08:00
* will always be displayed as
* - 2021-08-06 02:29:00
* regardless timezoneOffset in JavaScript realm
*
* The implementation hack:
* Instead of calling `.getMonth()`/`.getUTCMonth()` respect to `preserveTimezones`,
* we create a different Date to trick strftime, it's both simpler and more performant.
* Given that a template is expected to be parsed fewer times than rendered.
*/
static createDateFixedToTimezone (dateString: string, locale: string): LiquidDate {
const m = dateString.match(ISO8601_TIMEZONE_PATTERN)
// representing a UTC timestamp
if (m && m[1] === 'Z') {
return new LiquidDate(+new Date(dateString), locale, 0)
}
// has a timezone specified
if (m && m[2] && m[3] && m[4]) {
const [, , sign, hours, minutes] = m
const offset = (sign === '+' ? -1 : 1) * (parseInt(hours, 10) * 60 + parseInt(minutes, 10))
return new LiquidDate(+new Date(dateString), locale, offset)
}
return new LiquidDate(dateString, locale)
}
private static getTimezoneOffset (timezoneName: string, date: Date) {
const localDateString = date.toLocaleString('en-US', { timeZone: timezoneName })
const utcDateString = date.toLocaleString('en-US', { timeZone: 'UTC' })
const localDate = new Date(localDateString)
const utcDate = new Date(utcDateString)
return (+utcDate - +localDate) / (60 * 1000)
}
}
+17 -17
View File
@@ -1,9 +1,9 @@
import { strftime as t } from './strftime'
import { DateWithTimezone } from '../../test/stub/date-with-timezone'
import { DateWithTimezone, TestDate } from '../../test/stub/date'
describe('util/strftime', function () {
const now = new Date('2016-01-04 13:15:23')
const then = new Date('2016-03-06 03:05:03')
const now = new TestDate('2016-01-04 13:15:23')
const then = new TestDate('2016-03-06 03:05:03')
describe('Date (Year, Month, Day)', () => {
it('should format %C as century', function () {
@@ -23,26 +23,26 @@ describe('util/strftime', function () {
expect(t(then, '%j')).toBe('066')
})
it('should take count of leap years', function () {
const date = new Date('2001 03 01')
const date = new TestDate('2001 03 01')
expect(t(date, '%j')).toBe('060')
})
it('should take count of leap years', function () {
const date = new Date('2000 03 01')
const date = new TestDate('2000 03 01')
expect(t(date, '%j')).toBe('061')
})
})
it('should format %q as date suffix', function () {
const first = new Date('2016-03-01 03:05:03')
const second = new Date('2016-03-02 03:05:03')
const third = new Date('2016-03-03 03:05:03')
const first = new TestDate('2016-03-01 03:05:03')
const second = new TestDate('2016-03-02 03:05:03')
const third = new TestDate('2016-03-03 03:05:03')
const eleventh = new Date('2016-03-11 03:05:03')
const twelfth = new Date('2016-03-12 03:05:03')
const thirteenth = new Date('2016-03-13 03:05:03')
const eleventh = new TestDate('2016-03-11 03:05:03')
const twelfth = new TestDate('2016-03-12 03:05:03')
const thirteenth = new TestDate('2016-03-13 03:05:03')
const twentyfirst = new Date('2016-03-21 03:05:03')
const twentysecond = new Date('2016-03-22 03:05:03')
const twentythird = new Date('2016-03-23 03:05:03')
const twentyfirst = new TestDate('2016-03-21 03:05:03')
const twentysecond = new TestDate('2016-03-22 03:05:03')
const twentythird = new TestDate('2016-03-23 03:05:03')
expect(t(first, '%q')).toBe('st')
expect(t(second, '%q')).toBe('nd')
@@ -64,7 +64,7 @@ describe('util/strftime', function () {
expect(t(now, '%I')).toBe('01')
})
it('should format %I as 12 for 00:00', function () {
const date = new Date('2016-01-01 00:00:00')
const date = new TestDate('2016-01-01 00:00:00')
expect(t(date, '%I')).toBe('12')
})
it('should format %k as space padded hour', function () {
@@ -74,14 +74,14 @@ describe('util/strftime', function () {
expect(t(now, '%l')).toBe(' 1')
})
it('should format %l as 12 for 00:00', function () {
const date = new Date('2016-01-01 00:00:00')
const date = new TestDate('2016-01-01 00:00:00')
expect(t(date, '%l')).toBe('12')
})
it('should format %L as 0 padded millisecond', function () {
expect(t(then, '%L')).toBe('000')
})
it('should format %N as fractional seconds digits', function () {
const time = new Date('2019-12-15 01:21:00.129')
const time = new TestDate('2019-12-15 01:21:00.129')
expect(t(time, '%N')).toBe('129000000')
expect(t(time, '%2N')).toBe('12')
expect(t(time, '%10N')).toBe('1290000000')
+8 -37
View File
@@ -2,25 +2,12 @@ import { changeCase, padStart, padEnd } from './underscore'
import { LiquidDate } from './liquid-date'
const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
'September', 'October', 'November', 'December'
]
const dayNames = [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
]
const monthNamesShort = monthNames.map(abbr)
const dayNamesShort = dayNames.map(abbr)
interface FormatOptions {
flags: object;
width?: string;
modifier?: string;
}
function abbr (str: string) {
return str.slice(0, 3)
}
// prototype extensions
function daysInMonth (d: LiquidDate) {
const feb = isLeapYear(d) ? 29 : 28
@@ -77,19 +64,8 @@ const padWidths = {
W: 2
}
// default to '0'
const padChars = {
a: ' ',
A: ' ',
b: ' ',
B: ' ',
c: ' ',
e: ' ',
k: ' ',
l: ' ',
p: ' ',
P: ' '
}
const padSpaceChars = new Set('aAbBceklpP')
function getTimezoneOffset (d: LiquidDate, opts: FormatOptions) {
const nOffset = Math.abs(d.getTimezoneOffset())
const h = Math.floor(nOffset / 60)
@@ -100,10 +76,10 @@ function getTimezoneOffset (d: LiquidDate, opts: FormatOptions) {
padStart(m, 2, '0')
}
const formatCodes = {
a: (d: LiquidDate) => dayNamesShort[d.getDay()],
A: (d: LiquidDate) => dayNames[d.getDay()],
b: (d: LiquidDate) => monthNamesShort[d.getMonth()],
B: (d: LiquidDate) => monthNames[d.getMonth()],
a: (d: LiquidDate) => d.getShortWeekdayName(),
A: (d: LiquidDate) => d.getLongWeekdayName(),
b: (d: LiquidDate) => d.getShortMonthName(),
B: (d: LiquidDate) => d.getLongMonthName(),
c: (d: LiquidDate) => d.toLocaleString(),
C: (d: LiquidDate) => century(d),
d: (d: LiquidDate) => d.getDate(),
@@ -135,12 +111,7 @@ const formatCodes = {
y: (d: LiquidDate) => d.getFullYear().toString().slice(2, 4),
Y: (d: LiquidDate) => d.getFullYear(),
z: getTimezoneOffset,
Z: (d: LiquidDate, opts: FormatOptions) => {
if (d.getTimezoneName) {
return d.getTimezoneName() || getTimezoneOffset(d, opts)
}
return (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : '')
},
Z: (d: LiquidDate, opts: FormatOptions) => d.getTimeZoneName() || getTimezoneOffset(d, opts),
't': () => '\t',
'n': () => '\n',
'%': () => '%'
@@ -166,7 +137,7 @@ function format (d: LiquidDate, match: RegExpExecArray) {
const flags = {}
for (const flag of flagStr) flags[flag] = true
let ret = String(convert(d, { flags, width, modifier }))
let padChar = padChars[conversion] || '0'
let padChar = padSpaceChars.has(conversion) ? ' ' : '0'
let padWidth = width || padWidths[conversion] || 0
if (flags['^']) ret = ret.toUpperCase()
else if (flags['#']) ret = changeCase(ret)
-43
View File
@@ -1,43 +0,0 @@
import { TimezoneDate } from './timezone-date'
describe('TimezoneDate', () => {
it('should respect timezone set to 00:00', () => {
const date = new TimezoneDate('2021-10-06T14:26:00.000+08:00', 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 TimezoneDate('2021-10-06T14:26:00.000+08:00', -360)
expect(date.getTimezoneOffset()).toBe(-360)
expect(date.getMinutes()).toBe(26)
})
it('should support Date as argument', () => {
const date = new TimezoneDate(new Date('2021-10-06T14:26:00.000+08:00'), 0)
expect(date.getHours()).toBe(6)
})
it('should support .getMilliseconds()', () => {
const date = new TimezoneDate('2021-10-06T14:26:00.001+00:00', 0)
expect(date.getMilliseconds()).toBe(1)
})
it('should support .getDay()', () => {
const date = new TimezoneDate('2021-12-07T00:00:00.001+08:00', -480)
expect(date.getDay()).toBe(2)
})
it('should support .toLocaleString()', () => {
const date = new TimezoneDate('2021-10-06T00:00:00.001+00:00', -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 TimezoneDate('2021-10-06T00:00:00.001+00:00', -480)
expect(date.toLocaleTimeString('en-US')).toMatch(/^8:00:00\sAM$/)
expect(() => date.toLocaleDateString()).not.toThrow()
})
it('should support .toLocaleDateString()', () => {
const date = new TimezoneDate('2021-10-06T22:00:00.001+00:00', -480)
expect(date.toLocaleDateString('en-US')).toBe('10/7/2021')
expect(() => date.toLocaleDateString()).not.toThrow()
})
})
-114
View File
@@ -1,114 +0,0 @@
import { LiquidDate } from './liquid-date'
import { isString } from './underscore'
// one minute in milliseconds
const OneMinute = 60000
const ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/
/**
* A date implementation with timezone info, just like Ruby date
*
* Implementation:
* - create a Date offset by it's timezone difference, avoiding overriding a bunch of methods
* - rewrite getTimezoneOffset() to trick strftime
*/
export class TimezoneDate implements LiquidDate {
private timezoneOffset: number
private timezoneName: string
private date: Date
private displayDate: Date
constructor (init: string | number | Date | TimezoneDate, timezone: number | string) {
this.date = init instanceof TimezoneDate
? init.date
: new Date(init)
this.timezoneOffset = isString(timezone) ? TimezoneDate.getTimezoneOffset(timezone, this.date) : timezone
this.timezoneName = isString(timezone) ? timezone : ''
const diff = (this.date.getTimezoneOffset() - this.timezoneOffset) * OneMinute
const time = this.date.getTime() + diff
this.displayDate = new Date(time)
}
getTime () {
return this.displayDate.getTime()
}
getMilliseconds () {
return this.displayDate.getMilliseconds()
}
getSeconds () {
return this.displayDate.getSeconds()
}
getMinutes () {
return this.displayDate.getMinutes()
}
getHours () {
return this.displayDate.getHours()
}
getDay () {
return this.displayDate.getDay()
}
getDate () {
return this.displayDate.getDate()
}
getMonth () {
return this.displayDate.getMonth()
}
getFullYear () {
return this.displayDate.getFullYear()
}
toLocaleString (locale?: string, init?: any) {
if (init?.timeZone) {
return this.date.toLocaleString(locale, init)
}
return this.displayDate.toLocaleString(locale, init)
}
toLocaleTimeString (locale?: string) {
return this.displayDate.toLocaleTimeString(locale)
}
toLocaleDateString (locale?: string) {
return this.displayDate.toLocaleDateString(locale)
}
getTimezoneOffset () {
return this.timezoneOffset!
}
getTimezoneName () {
return this.timezoneName
}
/**
* Create a Date object fixed to it's declared Timezone. Both
* - 2021-08-06T02:29:00.000Z and
* - 2021-08-06T02:29:00.000+08:00
* will always be displayed as
* - 2021-08-06 02:29:00
* regardless timezoneOffset in JavaScript realm
*
* The implementation hack:
* Instead of calling `.getMonth()`/`.getUTCMonth()` respect to `preserveTimezones`,
* we create a different Date to trick strftime, it's both simpler and more performant.
* Given that a template is expected to be parsed fewer times than rendered.
*/
static createDateFixedToTimezone (dateString: string): LiquidDate {
const m = dateString.match(ISO8601_TIMEZONE_PATTERN)
// representing a UTC timestamp
if (m && m[1] === 'Z') {
return new TimezoneDate(+new Date(dateString), 0)
}
// has a timezone specified
if (m && m[2] && m[3] && m[4]) {
const [, , sign, hours, minutes] = m
const offset = (sign === '+' ? -1 : 1) * (parseInt(hours, 10) * 60 + parseInt(minutes, 10))
return new TimezoneDate(+new Date(dateString), offset)
}
return new Date(dateString)
}
private static getTimezoneOffset (timezoneName: string, date = new Date()) {
const localDateString = date.toLocaleString('en-US', { timeZone: timezoneName })
const utcDateString = date.toLocaleString('en-US', { timeZone: 'UTC' })
const localDate = new Date(localDateString)
const utcDate = new Date(utcDateString)
return (+utcDate - +localDate) / (60 * 1000)
}
}
+21
View File
@@ -0,0 +1,21 @@
import { LiteralToken } from '../tokens'
import { isLiteralToken, isNumberToken, isWordToken } from './type-guards'
describe('isLiteralToken()', () => {
it('should return true for literal', () => {
expect(isLiteralToken(new LiteralToken('true', 0, 4))).toBeTruthy()
})
})
describe('isWordToken()', () => {
it('should return false for literal', () => {
expect(isWordToken(new LiteralToken('true', 0, 4))).toBeFalsy()
})
})
describe('isNumberToken()', () => {
it('should return false for literal', () => {
expect(isNumberToken(new LiteralToken('true', 0, 4))).toBeFalsy()
})
it('should return false for null', () => {
expect(isNumberToken(null)).toBeFalsy()
})
})