diff --git a/docs/themes/navy/layout/partial/all-contributors.swig b/docs/themes/navy/layout/partial/all-contributors.swig
index 5520166ed..9d0180180 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 b2b09231e..c4272ff66 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 7a6d70b94..cbe2bd59d 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 e7031998e..ddf88b2fa 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 000000000..68c9fb495
--- /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 2e3c6e0d4..90a7472a9 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 bc50815b5..e80ace1ca 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 000000000..aca88cc93
--- /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 dbbb05b0b..a212a6197 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 45d5e7905..959b5e925 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 000000000..3ce54631b
--- /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)
+ })
+})