From fd5ef474c36212e6a2446012dcd26bca93f84c7b Mon Sep 17 00:00:00 2001 From: Harttle Date: Wed, 6 Oct 2021 15:01:07 +0800 Subject: [PATCH] fix: support timezoneOffset for date from scope, #401 --- .../navy/layout/partial/all-contributors.swig | 1 + src/builtin/filters/date.ts | 14 +++-- src/liquid-options.ts | 2 - src/util/strftime.ts | 34 +----------- src/util/timezone-date.ts | 53 +++++++++++++++++++ test/e2e/issues.ts | 6 +++ test/integration/builtin/filters/date.ts | 26 +++++++++ test/stub/date-with-timezone.ts | 6 +++ test/stub/render.ts | 8 +-- test/unit/util/strftime.ts | 19 +++---- test/unit/util/timezon-date.ts | 16 ++++++ 11 files changed, 132 insertions(+), 53 deletions(-) create mode 100644 src/util/timezone-date.ts create mode 100644 test/stub/date-with-timezone.ts create mode 100644 test/unit/util/timezon-date.ts diff --git a/docs/themes/navy/layout/partial/all-contributors.swig b/docs/themes/navy/layout/partial/all-contributors.swig index 5520166ed8..9d0180180a 100644 --- a/docs/themes/navy/layout/partial/all-contributors.swig +++ b/docs/themes/navy/layout/partial/all-contributors.swig @@ -47,6 +47,7 @@ + diff --git a/src/builtin/filters/date.ts b/src/builtin/filters/date.ts index b2b09231e1..c4272ff66e 100644 --- a/src/builtin/filters/date.ts +++ b/src/builtin/filters/date.ts @@ -1,8 +1,10 @@ -import strftime, { createDateFixedToTimezone } from '../../util/strftime' +import strftime from '../../util/strftime' import { isString, isNumber } from '../../util/underscore' import { FilterImpl } from '../../template/filter/filter-impl' +import { TimezoneDate } from '../../util/timezone-date' export function date (this: FilterImpl, v: string | Date, arg: string) { + const opts = this.context.opts let date: Date if (v === 'now' || v === 'today') { date = new Date() @@ -11,15 +13,19 @@ export function date (this: FilterImpl, v: string | Date, arg: string) { } else if (isString(v)) { if (/^\d+$/.test(v)) { date = new Date(+v * 1000) - } else if (this.context.opts.preserveTimezones) { - date = createDateFixedToTimezone(v) + } else if (opts.preserveTimezones) { + date = TimezoneDate.createDateFixedToTimezone(v) } else { date = new Date(v) } } else { date = v } - return isValidDate(date) ? strftime(date, arg) : v + if (!isValidDate(date)) return v + if (opts.hasOwnProperty('timezoneOffset')) { + date = new TimezoneDate(date, opts.timezoneOffset!) + } + return strftime(date, arg) } function isValidDate (date: any): date is Date { diff --git a/src/liquid-options.ts b/src/liquid-options.ts index 7a6d70b944..cbe2bd59db 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -6,7 +6,6 @@ import { FS } from './fs/fs' import * as fs from './fs/node' import { defaultOperators, Operators } from './render/operator' import { createTrie, Trie } from './util/operator-trie' -import { timezoneOffset } from './util/strftime' export interface LiquidOptions { /** A directory or an array of directories from where to resolve layout and include templates, and the filename passed to `.renderFile()`. If it's an array, the files are looked up in the order they occur in the array. Defaults to `["."]` */ @@ -123,7 +122,6 @@ export const defaultOptions: NormalizedFullOptions = { lenientIf: false, globals: {}, keepOutputType: false, - timezoneOffset: timezoneOffset, operators: defaultOperators, operatorsTrie: createTrie(defaultOperators) } diff --git a/src/util/strftime.ts b/src/util/strftime.ts index e7031998e0..ddf88b2faa 100644 --- a/src/util/strftime.ts +++ b/src/util/strftime.ts @@ -1,7 +1,5 @@ import { changeCase, padStart, padEnd } from './underscore' -export const timezoneOffset = new Date().getTimezoneOffset() -const ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/ const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/ const monthNames = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', @@ -127,10 +125,10 @@ const formatCodes = { y: (d: Date) => d.getFullYear().toString().substring(2, 4), Y: (d: Date) => d.getFullYear(), z: (d: Date, opts: FormatOptions) => { - const nOffset = Math.abs(timezoneOffset) + const nOffset = Math.abs(d.getTimezoneOffset()) const h = Math.floor(nOffset / 60) const m = nOffset % 60 - return (timezoneOffset > 0 ? '-' : '+') + + return (d.getTimezoneOffset() > 0 ? '-' : '+') + padStart(h, 2, '0') + (opts.flags[':'] ? ':' : '') + padStart(m, 2, '0') @@ -169,31 +167,3 @@ function format (d: Date, match: RegExpExecArray) { if (flags['-']) padWidth = 0 return padStart(ret, padWidth, padChar) } - -/** - * 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. - */ -export function createDateFixedToTimezone (dateString: string) { - const m = dateString.match(ISO8601_TIMEZONE_PATTERN) - // representing a UTC datetime - if (m && m[1] === 'Z') { - return new Date(+new Date(dateString) + timezoneOffset * 60000) - } - // has a timezone specified - if (m && m[2] && m[3] && m[4]) { - const [, , sign, hours, minutes] = m - const delta = (sign === '+' ? 1 : -1) * (parseInt(hours, 10) * 60 + parseInt(minutes, 10)) - return new Date(+new Date(dateString) + (timezoneOffset + delta) * 60000) - } - return new Date(dateString) -} diff --git a/src/util/timezone-date.ts b/src/util/timezone-date.ts new file mode 100644 index 0000000000..68c9fb495c --- /dev/null +++ b/src/util/timezone-date.ts @@ -0,0 +1,53 @@ +// one minute in milliseconds +const OneMinute = 60000 +const hostTimezoneOffset = new Date().getTimezoneOffset() +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 extends Date { + private timezoneOffset?: number + constructor (init: string | number | Date, timezoneOffset: number) { + if (init instanceof TimezoneDate) return init + const diff = (hostTimezoneOffset - timezoneOffset) * OneMinute + const time = new Date(init).getTime() + diff + super(time) + this.timezoneOffset = timezoneOffset + } + getTimezoneOffset () { + return this.timezoneOffset! + } + + /** + * 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) { + 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 delta = (sign === '+' ? -1 : 1) * (parseInt(hours, 10) * 60 + parseInt(minutes, 10)) + return new TimezoneDate(+new Date(dateString), delta) + } + return new Date(dateString) + } +} diff --git a/test/e2e/issues.ts b/test/e2e/issues.ts index 2e3c6e0d4e..90a7472a95 100644 --- a/test/e2e/issues.ts +++ b/test/e2e/issues.ts @@ -108,4 +108,10 @@ describe('Issues', function () { const html = await engine.parseAndRender(tpl) expect(html).to.equal('\r\ntrue\r\n') }) + it('#401 Timezone Offset Issue', async () => { + const engine = new Liquid({ timezoneOffset: -600 }) + const tpl = engine.parse('{{ date | date: "%Y-%m-%d %H:%M %p %z" }}') + const html = await engine.render(tpl, { date: '2021-10-06T15:31:00+08:00' }) + expect(html).to.equal('2021-10-06 17:31 PM +1000') + }) }) diff --git a/test/integration/builtin/filters/date.ts b/test/integration/builtin/filters/date.ts index bc50815b51..e80ace1ca1 100644 --- a/test/integration/builtin/filters/date.ts +++ b/test/integration/builtin/filters/date.ts @@ -54,4 +54,30 @@ describe('filters/date', function () { '2017-02-28T12:00:00' ) }) + describe('timezoneOffset', function () { + // -06:00 + const opts: LiquidOptions = { timezoneOffset: 360 } + + it('should offset UTC date literal', function () { + return test('{{ "1990-12-31T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T17:00:00', undefined, opts) + }) + it('should offset date literal with timezone 00:00 specified', function () { + return test('{{ "1990-12-31T23:00:00+00:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T17:00:00', undefined, opts) + }) + it('should offset date literal with timezone -01:00 specified', function () { + return test('{{ "1990-12-31T23:00:00-01:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T18:00:00', undefined, opts) + }) + it('should offset date from scope', function () { + const scope = { date: new Date('1990-12-31T23:00:00Z') } + return test('{{ date | date: "%Y-%m-%dT%H:%M:%S"}}', scope, '1990-12-31T17:00:00', opts) + }) + it('should reflect timezoneOffset', function () { + const scope = { date: new Date('1990-12-31T23:00:00Z') } + return test('{{ date | date: "%z"}}', scope, '-0600', opts) + }) + it('should ignore this setting when `preserveTimezones` also specified', function () { + const opts: LiquidOptions = { timezoneOffset: 600, preserveTimezones: true } + return test('{{ "1990-12-31T23:00:00+02:30" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T23:00:00', undefined, opts) + }) + }) }) diff --git a/test/stub/date-with-timezone.ts b/test/stub/date-with-timezone.ts new file mode 100644 index 0000000000..aca88cc93e --- /dev/null +++ b/test/stub/date-with-timezone.ts @@ -0,0 +1,6 @@ +export class DateWithTimezone extends Date { + constructor (init: string, timezone: number) { + super(init) + this.getTimezoneOffset = () => timezone + } +} diff --git a/test/stub/render.ts b/test/stub/render.ts index dbbb05b0b6..a212a61971 100644 --- a/test/stub/render.ts +++ b/test/stub/render.ts @@ -8,11 +8,11 @@ export function render (src: string, ctx?: object) { return liquid.parseAndRender(src, ctx) } -export async function test (src: string, ctx: object | string, dst?: string, opts?: LiquidOptions) { - if (dst === undefined) { - dst = ctx as string +export async function test (src: string, ctx: object | string, expected?: string, opts?: LiquidOptions) { + if (expected === undefined) { + expected = ctx as string ctx = {} } const engine = opts ? new Liquid(opts) : liquid - return expect(await engine.parseAndRender(src, ctx as object)).to.equal(dst) + return expect(await engine.parseAndRender(src, ctx as object)).to.equal(expected) } diff --git a/test/unit/util/strftime.ts b/test/unit/util/strftime.ts index 45d5e7905f..959b5e9253 100644 --- a/test/unit/util/strftime.ts +++ b/test/unit/util/strftime.ts @@ -1,5 +1,6 @@ import * as chai from 'chai' -import t, { timezoneOffset } from '../../../src/util/strftime' +import t from '../../../src/util/strftime' +import { DateWithTimezone } from '../../stub/date-with-timezone' const expect = chai.expect describe('util/strftime', function () { @@ -118,18 +119,14 @@ describe('util/strftime', function () { }) describe('Time zone', () => { - afterEach(() => { - (timezoneOffset as any) = (new Date()).getTimezoneOffset() - }) it('should format %z as time zone', function () { - const now = new Date('2016-01-04 13:15:23'); - - (timezoneOffset as any) = -480 // suppose we're in +8:00 + // suppose we're in +8:00 + const now = new DateWithTimezone('2016-01-04 13:15:23', -480) expect(t(now, '%z')).to.equal('+0800') }) it('should format %z as negative time zone', function () { - const date = new Date('2016-01-04T13:15:23.000Z'); - (timezoneOffset as any) = 480 // suppose we're in -8:00 + // suppose we're in -8:00 + const date = new DateWithTimezone('2016-01-04T13:15:23.000Z', 480) expect(t(date, '%z')).to.equal('-0800') }) }) @@ -210,8 +207,8 @@ describe('util/strftime', function () { expect(t(now, '%#P')).to.equal('PM') }) it('should support : flag', () => { - const date = new Date('2016-01-04T13:15:23.000Z'); - (timezoneOffset as any) = -480 // suppose we're in +8:00 + // suppose we're in +8:00 + const date = new DateWithTimezone('2016-01-04T13:15:23.000Z', -480) expect(t(date, '%:z')).to.equal('+08:00') expect(t(date, '%z')).to.equal('+0800') }) diff --git a/test/unit/util/timezon-date.ts b/test/unit/util/timezon-date.ts new file mode 100644 index 0000000000..3ce54631b7 --- /dev/null +++ b/test/unit/util/timezon-date.ts @@ -0,0 +1,16 @@ +import { TimezoneDate } from '../../../src/util/timezone-date' +import { expect } from 'chai' + +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()).to.equal(0) + expect(date.getHours()).to.equal(6) + expect(date.getMinutes()).to.equal(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()).to.equal(-360) + expect(date.getMinutes()).to.equal(26) + }) +})