Repository: harttle/liquidjs
Pull request #685 (2024-04-21 07:26:41)

fix: allow %Z for TimezoneDate, update docs accordingly #684
- fix: added `%Z` flag for `date` filter
- docs: updated docs for date filter


Patch:
diff --git a/bin/build-contributors.sh b/bin/build-contributors.sh
index 93c49164b..e624e3019 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/<br \/>.*<\/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 070496d5f..6e1a0516f 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 036ea5416..91a6b43dd 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 3c02c22e3..be922a665 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 3dff7367a..9c34dafa9 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 b6ed1b1cf..31ce2c890 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 fb2c348f6..8ce2d0ff5 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 ab9bc9f02..335379d67 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 6e65c80f3..c23ff611c 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)
+  }
 }


Test patch:
diff --git a/test/integration/filters/date.spec.ts b/test/integration/filters/date.spec.ts
index 341619736..d944285e1 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