Skip to content

Commit

Permalink
feat: timezoneOffset option to specify output timezone, see #375
Browse files Browse the repository at this point in the history
  • Loading branch information
Harttle authored and harttle committed Sep 26, 2021
1 parent 91c721e commit 6b9f872
Show file tree
Hide file tree
Showing 8 changed files with 76 additions and 36 deletions.
8 changes: 8 additions & 0 deletions docs/source/filters/date.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ Output
Fri, Jul 17, 15
```

{% note info TimeZone %}
Date will be converted to local time before output. To avoid that, you can set `timezoneOffset` LiquidJS option to `0`, its default value is your local timezone offset which can be obtained by `new Date().getTimezoneOffset()`.
{% endnote %}

Input
```liquid
{{ article.published_at | date: "%Y" }}
Expand All @@ -38,6 +42,10 @@ Output
Mar 14, 16
```

{% note info Timestamp Strings %}
Note that LiquidJS is using JavaScript [Date][newDate] to parse the input string, that means [IETF-compliant RFC 2822 timestamps](https://datatracker.ietf.org/doc/html/rfc2822#page-14) and strings in [a version of ISO8601](https://www.ecma-international.org/ecma-262/11.0/#sec-date.parse) are supported.
{% endnote %}

To get the current time, pass the special word `"now"` (or `"today"`) to `date`:

Input
Expand Down
6 changes: 6 additions & 0 deletions docs/source/tutorials/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ Before 2.0.1, <code>extname</code> is set to `.liquid` by default. To change tha

it defaults to false. For example, when set to true, a blank string would evaluate to false with jsTruthy. With Shopify's truthiness, a blank string is true.

## Date

**timezoneOffset** is used to specify a different timezone to output dates, your local timezone will be used if not specified. For example, set `timezoneOffset: 0` to output all dates in UTC/GMT 00:00.

**preserveTimezones** is a boolean effects only literal timestamps. When set to `true`, all literal timestamps will remain the same when output. This is a parser option, so Date objects passed to LiquidJS as data will not be affected.

## Trimming

**greedy**, **trimOutputLeft**, **trimOutputRight**, **trimTagLeft**, **trimTagRight** options are used to eliminate extra newlines and indents in templates arround Liquid Constructs. See [Whitespace Control][wc] for details.
Expand Down
8 changes: 8 additions & 0 deletions docs/source/zh-cn/filters/date.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ title: date
Fri, Jul 17, 15
```

{% note info 时区 %}
日期在输出时会转换为当地时区,设置 `timezoneOffset` LiquidJS 参数可以指定一个不同的时区。或者设置 `preserveTimezones``true` 来保持字面量时间戳的时区,数据中的日期对象不受此参数的影响。
{% endnote %}

输入
```liquid
{{ article.published_at | date: "%Y" }}
Expand All @@ -38,6 +42,10 @@ Fri, Jul 17, 15
Mar 14, 16
```

{% note info 时间戳字符串 %}
LiquidJS 使用 JavaScript [Date][newDate] 来解析输入字符串,意味着支持 [IETF-compliant RFC 2822 时间戳](https://datatracker.ietf.org/doc/html/rfc2822#page-14)[特定版本的 ISO8601](https://www.ecma-international.org/ecma-262/11.0/#sec-date.parse)
{% endnote %}

可以用特殊值 `"now"`(或`"today"`)来获取当前时间:

输入
Expand Down
7 changes: 7 additions & 0 deletions docs/source/zh-cn/tutorials/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ LiquidJS 把这个选项默认值设为 <code>true</code> 以兼容于 shopify/l

例如,空字符串在 JavaScript 中为假(`jsTruthy``true` 时),在 Shopify 真值表中为真。

## 时间日期和时区

**timezoneOffset** 用来指定一个和你当地时区不同的时区,所有日期和时间输出时都转换到这个指定的时区。例如设置 `timezoneOffset: 0` 将会把所有日期按照 UTC/GMT 00:00 来输出。

**preserveTimezones** 是一个布尔值,只影响时间戳字面量。当设置为 `true` 时,所有字面量的时间戳字符串会在输出时保持原状,即不论输入时采取怎样的时区,输出时仍然采用那一时区(和 Shopify Liquid 的行为一致)。注意这是一个解析器参数,渲染时传入的数据中的日期的输出不会受此参数影响。


## 换行和缩进

**greedy**, **trimOutputLeft**, **trimOutputRight**, **trimTagLeft**, **trimTagRight** 选项用来移除 Liquid 语法周围的换行和缩进,详情请参考 [Whitespace Control][wc]
Expand Down
8 changes: 5 additions & 3 deletions src/builtin/filters/date.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import strftime, { TimezoneDate } from '../../util/strftime'
import strftime, { createDateFixedToTimezone } from '../../util/strftime'
import { isString, isNumber } from '../../util/underscore'
import { FilterImpl } from '../../template/filter/filter-impl'

export function date (this: FilterImpl, v: string | Date, arg: string) {
let date = v
let date: Date
if (v === 'now' || v === 'today') {
date = new Date()
} else if (isNumber(v)) {
Expand All @@ -12,10 +12,12 @@ export function date (this: FilterImpl, v: string | Date, arg: string) {
if (/^\d+$/.test(v)) {
date = new Date(+v * 1000)
} else if (this.context.opts.preserveTimezones) {
date = new TimezoneDate(v)
date = createDateFixedToTimezone(v)
} else {
date = new Date(v)
}
} else {
date = v
}
return isValidDate(date) ? strftime(date, arg) : v
}
Expand Down
4 changes: 4 additions & 0 deletions src/liquid-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 `["."]` */
Expand All @@ -24,6 +25,8 @@ export interface LiquidOptions {
strictVariables?: boolean;
/** Modifies the behavior of `strictVariables`. If set, a single undefined variable will *not* cause an exception in the context of the `if`/`elsif`/`unless` tag and the `default` filter. Instead, it will evaluate to `false` and `null`, respectively. Irrelevant if `strictVariables` is not set. Defaults to `false`. **/
lenientIf?: boolean;
/** JavaScript timezoneOffset for `date` filter, default to local time. That means if you're in Australia (UTC+10), it'll default to -600 */
timezoneOffset?: number;
/** Strip blank characters (including ` `, `\t`, and `\r`) from the right of tags (`{% %}`) until `\n` (inclusive). Defaults to `false`. */
trimTagRight?: boolean;
/** Similar to `trimTagRight`, whereas the `\n` is exclusive. Defaults to `false`. See Whitespace Control for details. */
Expand Down Expand Up @@ -108,6 +111,7 @@ export const defaultOptions: NormalizedFullOptions = {
lenientIf: false,
globals: {},
keepOutputType: false,
timezoneOffset: timezoneOffset,
operators: defaultOperators,
operatorsTrie: createTrie(defaultOperators)
}
Expand Down
56 changes: 29 additions & 27 deletions src/util/strftime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
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',
Expand Down Expand Up @@ -125,11 +127,10 @@ const formatCodes = {
y: (d: Date) => d.getFullYear().toString().substring(2, 4),
Y: (d: Date) => d.getFullYear(),
z: (d: Date, opts: FormatOptions) => {
const offset = d.getTimezoneOffset()
const nOffset = Math.abs(offset)
const nOffset = Math.abs(timezoneOffset)
const h = Math.floor(nOffset / 60)
const m = nOffset % 60
return (offset > 0 ? '-' : '+') +
return (timezoneOffset > 0 ? '-' : '+') +
padStart(h, 2, '0') +
(opts.flags[':'] ? ':' : '') +
padStart(m, 2, '0')
Expand All @@ -140,12 +141,7 @@ const formatCodes = {
};
(formatCodes as any).h = formatCodes.b

export default function (inputDate: Date, formatStr: string) {
let d = inputDate
if (d instanceof TimezoneDate) {
d = d.getDisplayDate()
}

export default function (d: Date, formatStr: string) {
let output = ''
let remaining = formatStr
let match
Expand Down Expand Up @@ -174,24 +170,30 @@ function format (d: Date, match: RegExpExecArray) {
return padStart(ret, padWidth, padChar)
}

export class TimezoneDate extends Date {
ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/;

inputTimezoneOffset = 0;

constructor (public dateString: string) {
super(dateString)
const m = dateString.match(this.ISO8601_TIMEZONE_PATTERN)
if (m && m[1] === 'Z') {
this.inputTimezoneOffset = this.getTimezoneOffset()
} else 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))
this.inputTimezoneOffset = this.getTimezoneOffset() + delta
}
/**
* 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)
}

getDisplayDate (): Date {
return new Date((+this) + this.inputTimezoneOffset * 60 * 1000)
// 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)
}
15 changes: 9 additions & 6 deletions test/unit/util/strftime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as chai from 'chai'
import t from '../../../src/util/strftime'

import t, { timezoneOffset } from '../../../src/util/strftime'
const expect = chai.expect

describe('util/strftime', function () {
Expand Down Expand Up @@ -119,14 +118,18 @@ 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')
now.getTimezoneOffset = () => -480 // suppose we're in +8:00
const now = new Date('2016-01-04 13:15:23');

(timezoneOffset as any) = -480 // suppose we're in +8:00
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')
date.getTimezoneOffset = () => 480 // suppose we're in -8:00
const date = new Date('2016-01-04T13:15:23.000Z');
(timezoneOffset as any) = 480 // suppose we're in -8:00
expect(t(date, '%z')).to.equal('-0800')
})
})
Expand Down

0 comments on commit 6b9f872

Please sign in to comment.