From e09657c52b5e9920256d73f99455e2e81cadf065 Mon Sep 17 00:00:00 2001 From: Yang Jun Date: Sun, 21 Apr 2024 15:23:32 +0800 Subject: [PATCH] fix: allow %Z for TimezoneDate, update docs accordingly #684 --- bin/build-contributors.sh | 3 ++- docs/source/filters/date.md | 31 ++++++++++------------ docs/source/tutorials/differences.md | 4 +-- docs/source/zh-cn/filters/date.md | 8 ++++++ docs/source/zh-cn/tutorials/differences.md | 1 + src/filters/date.ts | 14 ++-------- src/util/liquid-date.ts | 1 + src/util/strftime.ts | 23 ++++++++++------ src/util/timezone-date.ts | 18 +++++++++++-- test/integration/filters/date.spec.ts | 18 +++++++++++++ 10 files changed, 79 insertions(+), 42 deletions(-) diff --git a/bin/build-contributors.sh b/bin/build-contributors.sh index 93c49164bd..e624e30199 100755 --- a/bin/build-contributors.sh +++ b/bin/build-contributors.sh @@ -3,7 +3,7 @@ # Run `sed` in a way that's compatible with both macOS (BSD) and Linux (GNU) sedi() { if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "$@" + /usr/bin/sed -i '' "$@" else sed -i "$@" fi @@ -16,6 +16,7 @@ sedi \ -e 's/"contributorsPerLine": 7/"contributorsPerLine": 65535/g' \ docs/.all-contributorsrc +touch docs/themes/navy/layout/partial/all-contributors.swig all-contributors --config docs/.all-contributorsrc generate sedi 's/
.*<\/td>/<\/a><\/td>/g' docs/themes/navy/layout/partial/all-contributors.swig diff --git a/docs/source/filters/date.md b/docs/source/filters/date.md index 070496d5f7..6e1a0516f8 100644 --- a/docs/source/filters/date.md +++ b/docs/source/filters/date.md @@ -3,15 +3,15 @@ title: date --- {% since %}v1.9.1{% endsince %} -# Format -* Converts a timestamp into another date format -* LiquidJS tries to be conformant with Shopify/Liquid which is using Ruby's core [Time#strftime(string)](http://www.ruby-doc.org/core/Time.html#method-i-strftime) - * Refer [format flags](https://ruby-doc.org/core/strftime_formatting_rdoc.html) - * Not all options are supported though - refer [differences here](/tutorials/differences.html#Differences) -* The input is firstly converted to `Date` object via [new Date()][jsDate] -* Date format can be provided individually as a filter option - * If not provided, then `%A, %B %-e, %Y at %-l:%M %P %z` format will be used as default format - * Override this using [`dateFormat`](/api/interfaces/LiquidOptions.html#dateFormat) LiquidJS option, to set your preferred default format for all date filters +Date filter is used to convert a timestamp into the specified format. + +* LiquidJS tries to conform to Shopify/Liquid, which uses Ruby's core [Time#strftime(string)](http://www.ruby-doc.org/core/Time.html#method-i-strftime). There're differences with [Ruby's format flags](https://ruby-doc.org/core/strftime_formatting_rdoc.html): + * `%Z` (since v10.11.1) works when there's a passed-in timezone name from `LiquidOption` or in-place value (see TimeZone below). If passed-in timezone is an offset number instead of string, it'll behave like `%z`. If there's none passed-in timezone, it returns [the runtime's default time zone](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/resolvedOptions#timezone). + * LiquidJS provides an additional `%q` flag for date ordinals. e.g. `{{ '2023/02/02' | date: '%d%q of %b'}}` => `02nd of Feb` +* Date literals are firstly converted to `Date` object via [new Date()][jsDate], that means literal values are considered in runtime's time zone by default. +* The format filter argument is optional: + * If not provided, it defaults to `%A, %B %-e, %Y at %-l:%M %P %z`. + * The above default can be overridden by [`dateFormat`](/api/interfaces/LiquidOptions.html#dateFormat) LiquidJS option. ### Examples ```liquid @@ -23,16 +23,14 @@ title: date ``` # TimeZone -* By default, dates will be converted to local timezone before output -* You can override that by, - * setting a timezone for each individual `date` filter via the second parameter - * using the [`timezoneOffset`](/api/interfaces/LiquidOptions.html#timezoneOffset) LiquidJS option - * Its default value is your local timezone offset which can be obtained by `new Date().getTimezoneOffset()` +* During output, LiquidJS uses local timezone which can override by: + * setting a timezone in-place when calling `date` filter, or + * setting the [`timezoneOffset`](/api/interfaces/LiquidOptions.html#timezoneOffset) LiquidJS option + * It defaults to runtime's time one. * Offset can be set as, * minutes: `-360` means `'+06:00'` and `360` means `'-06:00'` * timeZone ID: `Asia/Colombo` or `America/New_York` - * Use minutes for better performance with repeated processing of templates with many dates like, converting template for each email recipient - * Refer [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for TZ database values + * See [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for TZ database values ### Examples ```liquid @@ -41,7 +39,6 @@ title: date {{ "1990-12-31T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S", "Asia/Colombo" }} => 1991-01-01T04:30:00 ``` - # Input * `date` works on strings if they contain well-formatted dates * Note that LiquidJS is using [JavaScript Date][jsDate] 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. diff --git a/docs/source/tutorials/differences.md b/docs/source/tutorials/differences.md index 036ea54169..91a6b43dd4 100644 --- a/docs/source/tutorials/differences.md +++ b/docs/source/tutorials/differences.md @@ -33,8 +33,8 @@ Though we're trying to be compatible with the Ruby version, there are still some * LiquidJS-defined tags: [layout][layout], [render][render] and corresponding `block` tag. * LiquidJS-defined filters: [json][json]. * Tags/filters that don't depend on Shopify platform are borrowed from [Shopify][shopify-tags]. - * Tags/filters that don't depend on Jekyll framework are borrowed from [Jekyll][jekyll-filters] -* LiquidJS [date][date] filter supports `%q` for date ordinals like `{{ '2023/02/02' | date: '%d%q of %b'}}` => `02nd of Feb` + * Tags/filters that don't depend on Jekyll framework are borrowed from [Jekyll][jekyll-filters]. +* Some tags/filters behave differently: [date][date] filter. [date]: https://liquidjs.com/filters/date.html [layout]: ../tags/layout.html diff --git a/docs/source/zh-cn/filters/date.md b/docs/source/zh-cn/filters/date.md index 3c02c22e38..be922a6650 100644 --- a/docs/source/zh-cn/filters/date.md +++ b/docs/source/zh-cn/filters/date.md @@ -6,6 +6,14 @@ title: date 把时间戳转换为字符串。LiquidJS 尝试跟 Shopify/Liquid 保持一致,它用的是 Ruby 核心的 [Time#strftime(string)](http://www.ruby-doc.org/core/Time.html#method-i-strftime)。此外 LiquidJS 会先通过 [new Date()][newDate] 尝试把输入转换为 Date 对象。 +但 LiquidJS 支持的格式与 [Ruby 的 flag](https://ruby-doc.org/core/strftime_formatting_rdoc.html) 有些不同: + * `%Z`(自 v10.11.1 起支持)只有在传入了时区时才起作用(可以通过 `LiquidOption` 传入,也可以在创建日期时单独传入,见下文)。如果传入的时区是个数字,那么它的表现将会与 `%z` 相同。如果没有传入时区,将会返回 [运行时默认时区](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/resolvedOptions#timezone)。 + * LiquidJS 提供额外的 `%q` 用来处理序数:`{{ '2023/02/02' | date: '%d%q of %b'}}` => `02nd of Feb` +* 日期字面量会通过 [new Date()][jsDate] 转化为 `Date` 对象,这意味着字面量默认使用运行时默认时区。 +* 格式字参数是可选的: + * 如果不传,默认为 `%A, %B %-e, %Y at %-l:%M %P %z`。 + * 上述默认值可以通过 [`dateFormat`](/api/interfaces/LiquidOptions.html#dateFormat) 参数覆盖。 + 输入 ```liquid {{ article.published_at | date: "%a, %b %d, %y" }} diff --git a/docs/source/zh-cn/tutorials/differences.md b/docs/source/zh-cn/tutorials/differences.md index 3dff7367a5..9c34dafa9c 100644 --- a/docs/source/zh-cn/tutorials/differences.md +++ b/docs/source/zh-cn/tutorials/differences.md @@ -34,6 +34,7 @@ LiquidJS 一直很重视兼容于 Ruby 版本的 Liquid。Liquid 模板语言最 * LiquidJS 自己定义的过滤器:[json][json]。 * 从 [Shopify][shopify-tags] 借来的不依赖 Shopify 平台的标签/过滤器。 * 从 [Jekyll][jekyll-filters] 借来的不依赖 Jekyll 框架的标签/过滤器。 +* 有些过滤器和标签表现不同:比如 [date][date]。 [layout]: ../tags/layout.html [render]: ../tags/render.html diff --git a/src/filters/date.ts b/src/filters/date.ts index b6ed1b1cf6..31ce2c890e 100644 --- a/src/filters/date.ts +++ b/src/filters/date.ts @@ -25,9 +25,9 @@ export function date (this: FilterImpl, v: string | Date, format?: string, timez } if (!isValidDate(date)) return v if (timezoneOffset !== undefined) { - date = new TimezoneDate(date, parseTimezoneOffset(date, timezoneOffset)) + date = new TimezoneDate(date, timezoneOffset) } else if (!(date instanceof TimezoneDate) && opts.timezoneOffset !== undefined) { - date = new TimezoneDate(date, parseTimezoneOffset(date, opts.timezoneOffset)) + date = new TimezoneDate(date, opts.timezoneOffset) } return strftime(date, format) } @@ -35,13 +35,3 @@ export function date (this: FilterImpl, v: string | Date, format?: string, timez function isValidDate (date: any): date is Date { return (date instanceof Date || date instanceof TimezoneDate) && !isNaN(date.getTime()) } - -/** - * need pass in a `date` because offset is dependent on whether DST is active - */ -function parseTimezoneOffset (date: Date, timeZone: string | number) { - if (isNumber(timeZone)) return timeZone - const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' })) - const tzDate = new Date(date.toLocaleString('en-US', { timeZone })) - return (utcDate.getTime() - tzDate.getTime()) / 6e4 -} diff --git a/src/util/liquid-date.ts b/src/util/liquid-date.ts index fb2c348f6f..8ce2d0ff5d 100644 --- a/src/util/liquid-date.ts +++ b/src/util/liquid-date.ts @@ -14,6 +14,7 @@ export interface LiquidDate { getMonth(): number; getFullYear(): number; getTimezoneOffset(): number; + getTimezoneName?(): string; toLocaleTimeString(): string; toLocaleDateString(): string; } diff --git a/src/util/strftime.ts b/src/util/strftime.ts index ab9bc9f026..335379d67a 100644 --- a/src/util/strftime.ts +++ b/src/util/strftime.ts @@ -105,6 +105,15 @@ const padChars = { p: ' ', P: ' ' } +function getTimezoneOffset (d: LiquidDate, opts: FormatOptions) { + const nOffset = Math.abs(d.getTimezoneOffset()) + const h = Math.floor(nOffset / 60) + const m = nOffset % 60 + return (d.getTimezoneOffset() > 0 ? '-' : '+') + + padStart(h, 2, '0') + + (opts.flags[':'] ? ':' : '') + + padStart(m, 2, '0') +} const formatCodes = { a: (d: LiquidDate) => dayNamesShort[d.getDay()], A: (d: LiquidDate) => dayNames[d.getDay()], @@ -140,14 +149,12 @@ const formatCodes = { X: (d: LiquidDate) => d.toLocaleTimeString(), y: (d: LiquidDate) => d.getFullYear().toString().slice(2, 4), Y: (d: LiquidDate) => d.getFullYear(), - z: (d: LiquidDate, opts: FormatOptions) => { - const nOffset = Math.abs(d.getTimezoneOffset()) - const h = Math.floor(nOffset / 60) - const m = nOffset % 60 - return (d.getTimezoneOffset() > 0 ? '-' : '+') + - padStart(h, 2, '0') + - (opts.flags[':'] ? ':' : '') + - padStart(m, 2, '0') + z: getTimezoneOffset, + Z: (d: LiquidDate, opts: FormatOptions) => { + if (d.getTimezoneName) { + return d.getTimezoneName() || getTimezoneOffset(d, opts) + } + return (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : '') }, 't': () => '\t', 'n': () => '\n', diff --git a/src/util/timezone-date.ts b/src/util/timezone-date.ts index 6e65c80f34..c23ff611c2 100644 --- a/src/util/timezone-date.ts +++ b/src/util/timezone-date.ts @@ -1,4 +1,5 @@ import { LiquidDate } from './liquid-date' +import { isString } from './underscore' // one minute in milliseconds const OneMinute = 60000 @@ -13,13 +14,15 @@ const ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/ */ export class TimezoneDate implements LiquidDate { private timezoneOffset: number + private timezoneName: string private date: Date private displayDate: Date - constructor (init: string | number | Date | TimezoneDate, timezoneOffset: number) { + constructor (init: string | number | Date | TimezoneDate, timezone: number | string) { this.date = init instanceof TimezoneDate ? init.date : new Date(init) - this.timezoneOffset = timezoneOffset + this.timezoneOffset = isString(timezone) ? TimezoneDate.getTimezoneOffset(timezone, this.date) : timezone + this.timezoneName = isString(timezone) ? timezone : '' const diff = (this.date.getTimezoneOffset() - this.timezoneOffset) * OneMinute const time = this.date.getTime() + diff @@ -69,6 +72,9 @@ export class TimezoneDate implements LiquidDate { getTimezoneOffset () { return this.timezoneOffset! } + getTimezoneName () { + return this.timezoneName + } /** * Create a Date object fixed to it's declared Timezone. Both @@ -97,4 +103,12 @@ export class TimezoneDate implements LiquidDate { } return new Date(dateString) } + private static getTimezoneOffset (timezoneName: string, date = new Date()) { + const localDateString = date.toLocaleString('en-US', { timeZone: timezoneName }) + const utcDateString = date.toLocaleString('en-US', { timeZone: 'UTC' }) + + const localDate = new Date(localDateString) + const utcDate = new Date(utcDateString) + return (+utcDate - +localDate) / (60 * 1000) + } } diff --git a/test/integration/filters/date.spec.ts b/test/integration/filters/date.spec.ts index 3416197367..d944285e16 100644 --- a/test/integration/filters/date.spec.ts +++ b/test/integration/filters/date.spec.ts @@ -108,6 +108,24 @@ describe('filters/date', function () { const html = liquid.parseAndRenderSync('{{ "1990-12-31T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S", "Asia/Colombo" }}') expect(html).toEqual('1991-01-01T04:30:00') }) + it('should use runtime default timezone when not specified', async () => { + const liquid = new Liquid() + const html = liquid.parseAndRenderSync('{{ "1990-12-31T23:00:00Z" | date: "%Z" }}') + expect(html).toEqual(Intl.DateTimeFormat().resolvedOptions().timeZone) + }) + it('should use in-place timezoneOffset as timezone name', async () => { + const liquid = new Liquid({ preserveTimezones: true }) + const html = liquid.parseAndRenderSync('{{ "1990-12-31T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S %Z", "Asia/Colombo" }}') + expect(html).toEqual('1991-01-01T04:30:00 Asia/Colombo') + }) + it('should use options.timezoneOffset as default timezone name', function () { + const opts: LiquidOptions = { timezoneOffset: 'Australia/Brisbane' } + return test('{{ "1990-12-31T23:00:00.000Z" | date: "%Y-%m-%dT%H:%M:%S %Z"}}', '1991-01-01T10:00:00 Australia/Brisbane', undefined, opts) + }) + it('should use given timezone offset number as timezone name', function () { + const opts: LiquidOptions = { preserveTimezones: true } + return test('{{ "1990-12-31T23:00:00+02:30" | date: "%Y-%m-%dT%H:%M:%S %:Z"}}', '1990-12-31T23:00:00 +02:30', undefined, opts) + }) }) describe('dateFormat', function () { const optsWithoutDateFormat: LiquidOptions = { timezoneOffset: 360 } // -06:00