From 6b9f872bccb4b0c636dc7be2088cafa9bc6c900a Mon Sep 17 00:00:00 2001 From: Harttle Date: Mon, 27 Sep 2021 01:33:04 +0800 Subject: [PATCH] feat: timezoneOffset option to specify output timezone, see #375 --- docs/source/filters/date.md | 8 ++++ docs/source/tutorials/options.md | 6 +++ docs/source/zh-cn/filters/date.md | 8 ++++ docs/source/zh-cn/tutorials/options.md | 7 ++++ src/builtin/filters/date.ts | 8 ++-- src/liquid-options.ts | 4 ++ src/util/strftime.ts | 56 +++++++++++++------------- test/unit/util/strftime.ts | 15 ++++--- 8 files changed, 76 insertions(+), 36 deletions(-) diff --git a/docs/source/filters/date.md b/docs/source/filters/date.md index 1ca16935f..91dfae69e 100644 --- a/docs/source/filters/date.md +++ b/docs/source/filters/date.md @@ -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" }} @@ -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 diff --git a/docs/source/tutorials/options.md b/docs/source/tutorials/options.md index d3555cf66..23ef1a0d0 100644 --- a/docs/source/tutorials/options.md +++ b/docs/source/tutorials/options.md @@ -71,6 +71,12 @@ Before 2.0.1, extname 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. diff --git a/docs/source/zh-cn/filters/date.md b/docs/source/zh-cn/filters/date.md index cb8b7558e..90505fafa 100644 --- a/docs/source/zh-cn/filters/date.md +++ b/docs/source/zh-cn/filters/date.md @@ -16,6 +16,10 @@ title: date Fri, Jul 17, 15 ``` +{% note info 时区 %} +日期在输出时会转换为当地时区,设置 `timezoneOffset` LiquidJS 参数可以指定一个不同的时区。或者设置 `preserveTimezones` 为 `true` 来保持字面量时间戳的时区,数据中的日期对象不受此参数的影响。 +{% endnote %} + 输入 ```liquid {{ article.published_at | date: "%Y" }} @@ -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"`)来获取当前时间: 输入 diff --git a/docs/source/zh-cn/tutorials/options.md b/docs/source/zh-cn/tutorials/options.md index 50663180d..f48402ffc 100644 --- a/docs/source/zh-cn/tutorials/options.md +++ b/docs/source/zh-cn/tutorials/options.md @@ -69,6 +69,13 @@ LiquidJS 把这个选项默认值设为 true 以兼容于 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]。 diff --git a/src/builtin/filters/date.ts b/src/builtin/filters/date.ts index cad21e9ef..b2b09231e 100644 --- a/src/builtin/filters/date.ts +++ b/src/builtin/filters/date.ts @@ -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)) { @@ -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 } diff --git a/src/liquid-options.ts b/src/liquid-options.ts index 831c97c3e..5a9eee705 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -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 `["."]` */ @@ -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. */ @@ -108,6 +111,7 @@ 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 76bbb4a81..e7031998e 100644 --- a/src/util/strftime.ts +++ b/src/util/strftime.ts @@ -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', @@ -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') @@ -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 @@ -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) } diff --git a/test/unit/util/strftime.ts b/test/unit/util/strftime.ts index b0628a80b..614efff07 100644 --- a/test/unit/util/strftime.ts +++ b/test/unit/util/strftime.ts @@ -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 () { @@ -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') }) })