Skip to content

Commit fd5ef47

Browse files
committed
fix: support timezoneOffset for date from scope, #401
1 parent 4f6b88c commit fd5ef47

File tree

11 files changed

+132
-53
lines changed

11 files changed

+132
-53
lines changed

docs/themes/navy/layout/partial/all-contributors.swig

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
<td align="center"><a href="https://digitalinspiration.com/"><img src="https://avatars.githubusercontent.com/u/1344071?v=4?s=100" width="100px;" alt=""/></a></td>
4848
<td align="center"><a href="https://n1ru4l.cloud/"><img src="https://avatars.githubusercontent.com/u/14338007?v=4?s=100" width="100px;" alt=""/></a></td>
4949
<td align="center"><a href="https://github.com/mattvague"><img src="https://avatars.githubusercontent.com/u/64985?v=4?s=100" width="100px;" alt=""/></a></td>
50+
<td align="center"><a href="https://github.com/bglw"><img src="https://avatars.githubusercontent.com/u/40188355?v=4?s=100" width="100px;" alt=""/></a></td>
5051
</tr>
5152
</table>
5253

src/builtin/filters/date.ts

+10-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import strftime, { createDateFixedToTimezone } from '../../util/strftime'
1+
import strftime from '../../util/strftime'
22
import { isString, isNumber } from '../../util/underscore'
33
import { FilterImpl } from '../../template/filter/filter-impl'
4+
import { TimezoneDate } from '../../util/timezone-date'
45

56
export function date (this: FilterImpl, v: string | Date, arg: string) {
7+
const opts = this.context.opts
68
let date: Date
79
if (v === 'now' || v === 'today') {
810
date = new Date()
@@ -11,15 +13,19 @@ export function date (this: FilterImpl, v: string | Date, arg: string) {
1113
} else if (isString(v)) {
1214
if (/^\d+$/.test(v)) {
1315
date = new Date(+v * 1000)
14-
} else if (this.context.opts.preserveTimezones) {
15-
date = createDateFixedToTimezone(v)
16+
} else if (opts.preserveTimezones) {
17+
date = TimezoneDate.createDateFixedToTimezone(v)
1618
} else {
1719
date = new Date(v)
1820
}
1921
} else {
2022
date = v
2123
}
22-
return isValidDate(date) ? strftime(date, arg) : v
24+
if (!isValidDate(date)) return v
25+
if (opts.hasOwnProperty('timezoneOffset')) {
26+
date = new TimezoneDate(date, opts.timezoneOffset!)
27+
}
28+
return strftime(date, arg)
2329
}
2430

2531
function isValidDate (date: any): date is Date {

src/liquid-options.ts

-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { FS } from './fs/fs'
66
import * as fs from './fs/node'
77
import { defaultOperators, Operators } from './render/operator'
88
import { createTrie, Trie } from './util/operator-trie'
9-
import { timezoneOffset } from './util/strftime'
109

1110
export interface LiquidOptions {
1211
/** 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 = {
123122
lenientIf: false,
124123
globals: {},
125124
keepOutputType: false,
126-
timezoneOffset: timezoneOffset,
127125
operators: defaultOperators,
128126
operatorsTrie: createTrie(defaultOperators)
129127
}

src/util/strftime.ts

+2-32
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { changeCase, padStart, padEnd } from './underscore'
22

3-
export const timezoneOffset = new Date().getTimezoneOffset()
4-
const ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/
53
const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/
64
const monthNames = [
75
'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
@@ -127,10 +125,10 @@ const formatCodes = {
127125
y: (d: Date) => d.getFullYear().toString().substring(2, 4),
128126
Y: (d: Date) => d.getFullYear(),
129127
z: (d: Date, opts: FormatOptions) => {
130-
const nOffset = Math.abs(timezoneOffset)
128+
const nOffset = Math.abs(d.getTimezoneOffset())
131129
const h = Math.floor(nOffset / 60)
132130
const m = nOffset % 60
133-
return (timezoneOffset > 0 ? '-' : '+') +
131+
return (d.getTimezoneOffset() > 0 ? '-' : '+') +
134132
padStart(h, 2, '0') +
135133
(opts.flags[':'] ? ':' : '') +
136134
padStart(m, 2, '0')
@@ -169,31 +167,3 @@ function format (d: Date, match: RegExpExecArray) {
169167
if (flags['-']) padWidth = 0
170168
return padStart(ret, padWidth, padChar)
171169
}
172-
173-
/**
174-
* Create a Date object fixed to it's declared Timezone. Both
175-
* - 2021-08-06T02:29:00.000Z and
176-
* - 2021-08-06T02:29:00.000+08:00
177-
* will always be displayed as
178-
* - 2021-08-06 02:29:00
179-
* regardless timezoneOffset in JavaScript realm
180-
*
181-
* The implementation hack:
182-
* Instead of calling `.getMonth()`/`.getUTCMonth()` respect to `preserveTimezones`,
183-
* we create a different Date to trick strftime, it's both simpler and more performant.
184-
* Given that a template is expected to be parsed fewer times than rendered.
185-
*/
186-
export function createDateFixedToTimezone (dateString: string) {
187-
const m = dateString.match(ISO8601_TIMEZONE_PATTERN)
188-
// representing a UTC datetime
189-
if (m && m[1] === 'Z') {
190-
return new Date(+new Date(dateString) + timezoneOffset * 60000)
191-
}
192-
// has a timezone specified
193-
if (m && m[2] && m[3] && m[4]) {
194-
const [, , sign, hours, minutes] = m
195-
const delta = (sign === '+' ? 1 : -1) * (parseInt(hours, 10) * 60 + parseInt(minutes, 10))
196-
return new Date(+new Date(dateString) + (timezoneOffset + delta) * 60000)
197-
}
198-
return new Date(dateString)
199-
}

src/util/timezone-date.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// one minute in milliseconds
2+
const OneMinute = 60000
3+
const hostTimezoneOffset = new Date().getTimezoneOffset()
4+
const ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/
5+
6+
/**
7+
* A date implementation with timezone info, just like Ruby date
8+
*
9+
* Implementation:
10+
* - create a Date offset by it's timezone difference, avoiding overriding a bunch of methods
11+
* - rewrite getTimezoneOffset() to trick strftime
12+
*/
13+
export class TimezoneDate extends Date {
14+
private timezoneOffset?: number
15+
constructor (init: string | number | Date, timezoneOffset: number) {
16+
if (init instanceof TimezoneDate) return init
17+
const diff = (hostTimezoneOffset - timezoneOffset) * OneMinute
18+
const time = new Date(init).getTime() + diff
19+
super(time)
20+
this.timezoneOffset = timezoneOffset
21+
}
22+
getTimezoneOffset () {
23+
return this.timezoneOffset!
24+
}
25+
26+
/**
27+
* Create a Date object fixed to it's declared Timezone. Both
28+
* - 2021-08-06T02:29:00.000Z and
29+
* - 2021-08-06T02:29:00.000+08:00
30+
* will always be displayed as
31+
* - 2021-08-06 02:29:00
32+
* regardless timezoneOffset in JavaScript realm
33+
*
34+
* The implementation hack:
35+
* Instead of calling `.getMonth()`/`.getUTCMonth()` respect to `preserveTimezones`,
36+
* we create a different Date to trick strftime, it's both simpler and more performant.
37+
* Given that a template is expected to be parsed fewer times than rendered.
38+
*/
39+
static createDateFixedToTimezone (dateString: string) {
40+
const m = dateString.match(ISO8601_TIMEZONE_PATTERN)
41+
// representing a UTC timestamp
42+
if (m && m[1] === 'Z') {
43+
return new TimezoneDate(+new Date(dateString), 0)
44+
}
45+
// has a timezone specified
46+
if (m && m[2] && m[3] && m[4]) {
47+
const [, , sign, hours, minutes] = m
48+
const delta = (sign === '+' ? -1 : 1) * (parseInt(hours, 10) * 60 + parseInt(minutes, 10))
49+
return new TimezoneDate(+new Date(dateString), delta)
50+
}
51+
return new Date(dateString)
52+
}
53+
}

test/e2e/issues.ts

+6
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,10 @@ describe('Issues', function () {
108108
const html = await engine.parseAndRender(tpl)
109109
expect(html).to.equal('\r\ntrue\r\n')
110110
})
111+
it('#401 Timezone Offset Issue', async () => {
112+
const engine = new Liquid({ timezoneOffset: -600 })
113+
const tpl = engine.parse('{{ date | date: "%Y-%m-%d %H:%M %p %z" }}')
114+
const html = await engine.render(tpl, { date: '2021-10-06T15:31:00+08:00' })
115+
expect(html).to.equal('2021-10-06 17:31 PM +1000')
116+
})
111117
})

test/integration/builtin/filters/date.ts

+26
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,30 @@ describe('filters/date', function () {
5454
'2017-02-28T12:00:00'
5555
)
5656
})
57+
describe('timezoneOffset', function () {
58+
// -06:00
59+
const opts: LiquidOptions = { timezoneOffset: 360 }
60+
61+
it('should offset UTC date literal', function () {
62+
return test('{{ "1990-12-31T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T17:00:00', undefined, opts)
63+
})
64+
it('should offset date literal with timezone 00:00 specified', function () {
65+
return test('{{ "1990-12-31T23:00:00+00:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T17:00:00', undefined, opts)
66+
})
67+
it('should offset date literal with timezone -01:00 specified', function () {
68+
return test('{{ "1990-12-31T23:00:00-01:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T18:00:00', undefined, opts)
69+
})
70+
it('should offset date from scope', function () {
71+
const scope = { date: new Date('1990-12-31T23:00:00Z') }
72+
return test('{{ date | date: "%Y-%m-%dT%H:%M:%S"}}', scope, '1990-12-31T17:00:00', opts)
73+
})
74+
it('should reflect timezoneOffset', function () {
75+
const scope = { date: new Date('1990-12-31T23:00:00Z') }
76+
return test('{{ date | date: "%z"}}', scope, '-0600', opts)
77+
})
78+
it('should ignore this setting when `preserveTimezones` also specified', function () {
79+
const opts: LiquidOptions = { timezoneOffset: 600, preserveTimezones: true }
80+
return test('{{ "1990-12-31T23:00:00+02:30" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T23:00:00', undefined, opts)
81+
})
82+
})
5783
})

test/stub/date-with-timezone.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class DateWithTimezone extends Date {
2+
constructor (init: string, timezone: number) {
3+
super(init)
4+
this.getTimezoneOffset = () => timezone
5+
}
6+
}

test/stub/render.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ export function render (src: string, ctx?: object) {
88
return liquid.parseAndRender(src, ctx)
99
}
1010

11-
export async function test (src: string, ctx: object | string, dst?: string, opts?: LiquidOptions) {
12-
if (dst === undefined) {
13-
dst = ctx as string
11+
export async function test (src: string, ctx: object | string, expected?: string, opts?: LiquidOptions) {
12+
if (expected === undefined) {
13+
expected = ctx as string
1414
ctx = {}
1515
}
1616
const engine = opts ? new Liquid(opts) : liquid
17-
return expect(await engine.parseAndRender(src, ctx as object)).to.equal(dst)
17+
return expect(await engine.parseAndRender(src, ctx as object)).to.equal(expected)
1818
}

test/unit/util/strftime.ts

+8-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as chai from 'chai'
2-
import t, { timezoneOffset } from '../../../src/util/strftime'
2+
import t from '../../../src/util/strftime'
3+
import { DateWithTimezone } from '../../stub/date-with-timezone'
34
const expect = chai.expect
45

56
describe('util/strftime', function () {
@@ -118,18 +119,14 @@ describe('util/strftime', function () {
118119
})
119120

120121
describe('Time zone', () => {
121-
afterEach(() => {
122-
(timezoneOffset as any) = (new Date()).getTimezoneOffset()
123-
})
124122
it('should format %z as time zone', function () {
125-
const now = new Date('2016-01-04 13:15:23');
126-
127-
(timezoneOffset as any) = -480 // suppose we're in +8:00
123+
// suppose we're in +8:00
124+
const now = new DateWithTimezone('2016-01-04 13:15:23', -480)
128125
expect(t(now, '%z')).to.equal('+0800')
129126
})
130127
it('should format %z as negative time zone', function () {
131-
const date = new Date('2016-01-04T13:15:23.000Z');
132-
(timezoneOffset as any) = 480 // suppose we're in -8:00
128+
// suppose we're in -8:00
129+
const date = new DateWithTimezone('2016-01-04T13:15:23.000Z', 480)
133130
expect(t(date, '%z')).to.equal('-0800')
134131
})
135132
})
@@ -210,8 +207,8 @@ describe('util/strftime', function () {
210207
expect(t(now, '%#P')).to.equal('PM')
211208
})
212209
it('should support : flag', () => {
213-
const date = new Date('2016-01-04T13:15:23.000Z');
214-
(timezoneOffset as any) = -480 // suppose we're in +8:00
210+
// suppose we're in +8:00
211+
const date = new DateWithTimezone('2016-01-04T13:15:23.000Z', -480)
215212
expect(t(date, '%:z')).to.equal('+08:00')
216213
expect(t(date, '%z')).to.equal('+0800')
217214
})

test/unit/util/timezon-date.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { TimezoneDate } from '../../../src/util/timezone-date'
2+
import { expect } from 'chai'
3+
4+
describe('TimezoneDate', () => {
5+
it('should respect timezone set to 00:00', () => {
6+
const date = new TimezoneDate('2021-10-06T14:26:00.000+08:00', 0)
7+
expect(date.getTimezoneOffset()).to.equal(0)
8+
expect(date.getHours()).to.equal(6)
9+
expect(date.getMinutes()).to.equal(26)
10+
})
11+
it('should respect timezone set to -06:00', () => {
12+
const date = new TimezoneDate('2021-10-06T14:26:00.000+08:00', -360)
13+
expect(date.getTimezoneOffset()).to.equal(-360)
14+
expect(date.getMinutes()).to.equal(26)
15+
})
16+
})

0 commit comments

Comments
 (0)