Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: allow %Z for TimezoneDate, update docs accordingly #684 #685

Merged
merged 1 commit into from
Apr 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion bin/build-contributors.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
31 changes: 14 additions & 17 deletions docs/source/filters/date.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/source/tutorials/differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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" }}
Expand Down
1 change: 1 addition & 0 deletions docs/source/zh-cn/tutorials/differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 2 additions & 12 deletions src/filters/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,13 @@ 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)
}

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
}
1 change: 1 addition & 0 deletions src/util/liquid-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface LiquidDate {
getMonth(): number;
getFullYear(): number;
getTimezoneOffset(): number;
getTimezoneName?(): string;
toLocaleTimeString(): string;
toLocaleDateString(): string;
}
23 changes: 15 additions & 8 deletions src/util/strftime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand Down Expand Up @@ -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',
Expand Down
18 changes: 16 additions & 2 deletions src/util/timezone-date.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LiquidDate } from './liquid-date'
import { isString } from './underscore'

// one minute in milliseconds
const OneMinute = 60000
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
18 changes: 18 additions & 0 deletions test/integration/filters/date.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down