Files
liquidjs/src/util/liquid-date.ts
T
amit777andGitHub 39c87437c5 fix(date): %s returns Unix epoch unaffected by display timezone (#932)
The %s handler read LiquidDate.getTime(), which returns the
displayDate deliberately shifted by the display timezone offset for
wall-clock getters. With a timezone argument or timezoneOffset
option set, %s produced an epoch shifted by (server offset - display
offset) instead of the true Unix timestamp.

Expose the unshifted time as LiquidDate.dateValue() and use it for
%s. Also switch Math.round to Math.floor so fractional seconds
truncate toward the epoch like Ruby strftime.

Fixes #931
2026-07-10 23:13:07 +08:00

164 lines
5.4 KiB
TypeScript

import { getDateTimeFormat } from './intl'
import { isString } from './underscore'
// one minute in milliseconds
const OneMinute = 60000
/**
* Need support both ISO8601 and RFC2822 as in major browsers & NodeJS
* RFC2822: https://datatracker.ietf.org/doc/html/rfc2822#section-3.3
*/
const 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))
/**
* 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 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()
}
/**
* 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()
}
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(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)
}
}