Repository: harttle/liquidjs
Pull request #606 (2023-04-24 16:58:11)

fix: timezone issues #604 and #605


Patch:
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index bb5491188..f32693861 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -3,7 +3,7 @@ on: [push, pull_request]
 jobs:
   check:
     name: Check
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-latest
     steps:
       - name: Checkout
         uses: actions/checkout@v2
@@ -20,7 +20,9 @@ jobs:
       - name: Test
         run: |
           npm run lint
-          npm run test
+          TZ=Etc/GMT npm run test
+          TZ=Asia/Shanghai npm run test
+          TZ=America/New_York npm run test
       - name: Coverage
         uses: coverallsapp/github-action@v1.1.2
         with:
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 8d2b4a76e..511b81569 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -6,7 +6,7 @@ on:
 jobs:
   docs:
     name: Docs
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-latest
     steps:
       - name: Checkout
         uses: actions/checkout@v2
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fa255702a..67d4211de 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,7 +3,7 @@ on: workflow_dispatch
 jobs:
   release:
     name: Release
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-latest
     steps:
       - name: Checkout
         uses: actions/checkout@v2
diff --git a/src/filters/date.ts b/src/filters/date.ts
index 0f7360814..b6ed1b1cf 100644
--- a/src/filters/date.ts
+++ b/src/filters/date.ts
@@ -26,7 +26,7 @@ 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))
-  } else if (opts.timezoneOffset !== undefined) {
+  } else if (!(date instanceof TimezoneDate) && opts.timezoneOffset !== undefined) {
     date = new TimezoneDate(date, parseTimezoneOffset(date, opts.timezoneOffset))
   }
   return strftime(date, format)
diff --git a/src/util/timezone-date.spec.ts b/src/util/timezone-date.spec.ts
index c1aa76c66..5f4c3a62f 100644
--- a/src/util/timezone-date.spec.ts
+++ b/src/util/timezone-date.spec.ts
@@ -24,6 +24,12 @@ describe('TimezoneDate', () => {
     const date = new TimezoneDate('2021-12-07T00:00:00.001+08:00', -480)
     expect(date.getDay()).toBe(2)
   })
+  it('should support .toLocaleString()', () => {
+    const date = new TimezoneDate('2021-10-06T00:00:00.001+00:00', -480)
+    expect(date.toLocaleString('en-US')).toMatch(/8:00:00\sAM$/)
+    expect(date.toLocaleString('en-US', { timeZone: 'America/New_York' })).toMatch(/8:00:00\sPM$/)
+    expect(() => date.toLocaleString()).not.toThrow()
+  })
   it('should support .toLocaleTimeString()', () => {
     const date = new TimezoneDate('2021-10-06T00:00:00.001+00:00', -480)
     expect(date.toLocaleTimeString('en-US')).toMatch(/^8:00:00\sAM$/)
diff --git a/src/util/timezone-date.ts b/src/util/timezone-date.ts
index 283cbc077..6e65c80f3 100644
--- a/src/util/timezone-date.ts
+++ b/src/util/timezone-date.ts
@@ -2,7 +2,6 @@ import { LiquidDate } from './liquid-date'
 
 // one minute in milliseconds
 const OneMinute = 60000
-const hostTimezoneOffset = new Date().getTimezoneOffset()
 const ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/
 
 /**
@@ -15,51 +14,57 @@ const ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/
 export class TimezoneDate implements LiquidDate {
   private timezoneOffset: number
   private date: Date
+  private displayDate: Date
   constructor (init: string | number | Date | TimezoneDate, timezoneOffset: number) {
-    if (init instanceof TimezoneDate) {
-      this.date = init.date
-      timezoneOffset = init.timezoneOffset
-    } else {
-      const diff = (hostTimezoneOffset - timezoneOffset) * OneMinute
-      const time = new Date(init).getTime() + diff
-      this.date = new Date(time)
-    }
+    this.date = init instanceof TimezoneDate
+      ? init.date
+      : new Date(init)
     this.timezoneOffset = timezoneOffset
+
+    const diff = (this.date.getTimezoneOffset() - this.timezoneOffset) * OneMinute
+    const time = this.date.getTime() + diff
+    this.displayDate = new Date(time)
   }
 
   getTime () {
-    return this.date.getTime()
+    return this.displayDate.getTime()
   }
 
   getMilliseconds () {
-    return this.date.getMilliseconds()
+    return this.displayDate.getMilliseconds()
   }
   getSeconds () {
-    return this.date.getSeconds()
+    return this.displayDate.getSeconds()
   }
   getMinutes () {
-    return this.date.getMinutes()
+    return this.displayDate.getMinutes()
   }
   getHours () {
-    return this.date.getHours()
+    return this.displayDate.getHours()
   }
   getDay () {
-    return this.date.getDay()
+    return this.displayDate.getDay()
   }
   getDate () {
-    return this.date.getDate()
+    return this.displayDate.getDate()
   }
   getMonth () {
-    return this.date.getMonth()
+    return this.displayDate.getMonth()
   }
   getFullYear () {
-    return this.date.getFullYear()
+    return this.displayDate.getFullYear()
+  }
+  toLocaleString (locale?: string, init?: any) {
+    if (init?.timeZone) {
+      return this.date.toLocaleString(locale, init)
+    }
+    return this.displayDate.toLocaleString(locale, init)
   }
   toLocaleTimeString (locale?: string) {
-    return this.date.toLocaleTimeString(locale)
+    return this.displayDate.toLocaleTimeString(locale)
   }
   toLocaleDateString (locale?: string) {
-    return this.date.toLocaleDateString(locale)
+    return this.displayDate.toLocaleDateString(locale)
   }
   getTimezoneOffset () {
     return this.timezoneOffset!
@@ -87,8 +92,8 @@ export class TimezoneDate implements LiquidDate {
     // 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 TimezoneDate(+new Date(dateString), delta)
+      const offset = (sign === '+' ? -1 : 1) * (parseInt(hours, 10) * 60 + parseInt(minutes, 10))
+      return new TimezoneDate(+new Date(dateString), offset)
     }
     return new Date(dateString)
   }


Test patch:
diff --git a/test/e2e/issues.spec.ts b/test/e2e/issues.spec.ts
index f26bdae1a..09c026482 100644
--- a/test/e2e/issues.spec.ts
+++ b/test/e2e/issues.spec.ts
@@ -406,4 +406,24 @@ describe('Issues', function () {
     const html = await engine.parseAndRender(template)
     expect(html).toContain('true')
   })
+  it('#604 date filter appears to add DST correction to UTC dates', () => {
+    const engine = new Liquid({
+      timezoneOffset: 'Etc/GMT'
+    })
+
+    const html = engine.parseAndRenderSync(
+      '{{ "2023-04-05T12:00:00Z" | date: "%Y-%m-%dT%H:%M:%S%z", "Etc/GMT" }}' +
+      '{{ "2023-01-05T12:00:00Z" | date: "%Y-%m-%dT%H:%M:%S%z", 0 }}' +
+      '{{ "2023-01-05T12:00:00Z" | date: "%Y-%m-%dT%H:%M:%S%z", "Etc/GMT" }}' +
+      '{{ "2023-01-05T12:00:00Z" | date: "%Y-%m-%dT%H:%M:%S%z" }}' +
+      '{{ "2023-01-05T12:00:00+0000" | date: "%Y-%m-%dT%H:%M:%S%z", 0 }}'
+    )
+    const expected =
+      '2023-04-05T12:00:00+0000' +
+      '2023-01-05T12:00:00+0000' +
+      '2023-01-05T12:00:00+0000' +
+      '2023-01-05T12:00:00+0000' +
+      '2023-01-05T12:00:00+0000'
+    expect(html).toEqual(expected)
+  })
 })
diff --git a/test/integration/filters/date.spec.ts b/test/integration/filters/date.spec.ts
index bc4216255..341619736 100644
--- a/test/integration/filters/date.spec.ts
+++ b/test/integration/filters/date.spec.ts
@@ -1,4 +1,5 @@
 import { LiquidOptions } from '../../../src/liquid-options'
+import { Liquid } from '.././../../src/liquid'
 import { test } from '../../stub/render'
 
 describe('filters/date', function () {
@@ -98,10 +99,15 @@ describe('filters/date', function () {
       const scope = { date: new Date('1990-12-31T23:00:00Z') }
       return test('{{ date | date: "%z"}}', scope, '-0600', opts)
     })
-    it('should work with `preserveTimezones`', function () {
+    it('opts.timezoneOffset should work with `preserveTimezones`', function () {
       const opts: LiquidOptions = { timezoneOffset: 600, preserveTimezones: true }
       return test('{{ "1990-12-31T23:00:00+02:30" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T23:00:00', undefined, opts)
     })
+    it('timezoneOffset should work with `preserveTimezones`', async () => {
+      const liquid = new Liquid({ preserveTimezones: true })
+      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')
+    })
   })
   describe('dateFormat', function () {
     const optsWithoutDateFormat: LiquidOptions = { timezoneOffset: 360 } // -06:00