diff --git a/docs/source/filters/date.md b/docs/source/filters/date.md index 24e66e17ae..c38de7ddca 100644 --- a/docs/source/filters/date.md +++ b/docs/source/filters/date.md @@ -6,12 +6,15 @@ title: date 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)](https://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). + * `%Z` (since v10.11.1) is replaced by the 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. +* LiquidJS `date` supports locale specific weekdays and month names, which will fallback to English where `Intl` is not supported. + * Ordinals (`%q`) and Jekyll specific date filters are English-only. + * [`locale`](/api/interfaces/LiquidOptions.html#locale) can be set when creating Liquid instance. Defaults to `Intl.DateTimeFormat().resolvedOptions.locale`). ### Examples ```liquid diff --git a/src/context/context.ts b/src/context/context.ts index 2c331340c6..50bb168475 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -43,7 +43,7 @@ export class Context { this.strictVariables = renderOptions.strictVariables ?? this.opts.strictVariables this.ownPropertyOnly = renderOptions.ownPropertyOnly ?? opts.ownPropertyOnly this.memoryLimit = memoryLimit ?? new Limiter('memory alloc', renderOptions.memoryLimit ?? opts.memoryLimit) - this.renderLimit = renderLimit ?? new Limiter('template render', performance.now() + (renderOptions.templateLimit ?? opts.renderLimit)) + this.renderLimit = renderLimit ?? new Limiter('template render', performance.now() + (renderOptions.renderLimit ?? opts.renderLimit)) } public getRegister (key: string) { return (this.registers[key] = this.registers[key] || {}) diff --git a/src/filters/array.ts b/src/filters/array.ts index d9a392733d..8d716a88b0 100644 --- a/src/filters/array.ts +++ b/src/filters/array.ts @@ -175,7 +175,6 @@ export function * find (this: FilterImpl, arr: T[], property: const value = yield evalToken(token, this.context.spawn(item)) if (equals(value, expected)) return item } - return null } export function * find_exp (this: FilterImpl, arr: T[], itemName: string, exp: string): IterableIterator { @@ -185,7 +184,6 @@ export function * find_exp (this: FilterImpl, arr: T[], itemNa const value = yield predicate.value(this.context.spawn({ [itemName]: item })) if (value) return item } - return null } export function uniq (this: FilterImpl, arr: T[]): T[] { diff --git a/src/filters/date.ts b/src/filters/date.ts index 9a346f86d2..5f86178cef 100644 --- a/src/filters/date.ts +++ b/src/filters/date.ts @@ -1,6 +1,6 @@ -import { toValue, stringify, isString, isNumber, TimezoneDate, LiquidDate, strftime, isNil } from '../util' +import { toValue, stringify, isString, isNumber, LiquidDate, strftime, isNil } from '../util' import { FilterImpl } from '../template' -import { LiquidOptions } from '../liquid-options' +import { NormalizedFullOptions } from '../liquid-options' export function date (this: FilterImpl, v: string | Date, format?: string, timezoneOffset?: number | string) { const size = ((v as string)?.length ?? 0) + (format?.length ?? 0) + ((timezoneOffset as string)?.length ?? 0) @@ -40,33 +40,25 @@ function stringify_date (this: FilterImpl, v: string | Date, month_type: string, return strftime(date, `%d ${month_type} %Y`) } -function parseDate (v: string | Date, opts: LiquidOptions, timezoneOffset?: number | string): LiquidDate | undefined { - let date: LiquidDate +function parseDate (v: string | Date, opts: NormalizedFullOptions, timezoneOffset?: number | string): LiquidDate | undefined { + let date: LiquidDate | undefined + const defaultTimezoneOffset = timezoneOffset ?? opts.timezoneOffset + const locale = opts.locale v = toValue(v) if (v === 'now' || v === 'today') { - date = new Date() + date = new LiquidDate(Date.now(), locale, defaultTimezoneOffset) } else if (isNumber(v)) { - date = new Date(v * 1000) + date = new LiquidDate(v * 1000, locale, defaultTimezoneOffset) } else if (isString(v)) { if (/^\d+$/.test(v)) { - date = new Date(+v * 1000) - } else if (opts.preserveTimezones) { - date = TimezoneDate.createDateFixedToTimezone(v) + date = new LiquidDate(+v * 1000, locale, defaultTimezoneOffset) + } else if (opts.preserveTimezones && timezoneOffset === undefined) { + date = LiquidDate.createDateFixedToTimezone(v, locale) } else { - date = new Date(v) + date = new LiquidDate(v, locale, defaultTimezoneOffset) } } else { - date = v + date = new LiquidDate(v, locale, defaultTimezoneOffset) } - if (!isValidDate(date)) return - if (timezoneOffset !== undefined) { - date = new TimezoneDate(date, timezoneOffset) - } else if (!(date instanceof TimezoneDate) && opts.timezoneOffset !== undefined) { - date = new TimezoneDate(date, opts.timezoneOffset) - } - return date -} - -function isValidDate (date: any): date is Date { - return (date instanceof Date || date instanceof TimezoneDate) && !isNaN(date.getTime()) + return date.valid() ? date : undefined } diff --git a/src/fs/loader.ts b/src/fs/loader.ts index 86decd36b9..cb59041b1e 100644 --- a/src/fs/loader.ts +++ b/src/fs/loader.ts @@ -27,7 +27,7 @@ export class Loader { const rRelativePath = new RegExp(['.' + sep, '..' + sep, './', '../'].map(prefix => escapeRegex(prefix)).join('|')) this.shouldLoadRelative = (referencedFile: string) => rRelativePath.test(referencedFile) } else { - this.shouldLoadRelative = (referencedFile: string) => false + this.shouldLoadRelative = (_referencedFile: string) => false } this.contains = this.options.fs.contains || (() => true) } diff --git a/src/fs/map-fs.spec.ts b/src/fs/map-fs.spec.ts index c0c4ea3545..587ab08c40 100644 --- a/src/fs/map-fs.spec.ts +++ b/src/fs/map-fs.spec.ts @@ -2,25 +2,38 @@ import { MapFS } from './map-fs' describe('MapFS', () => { const fs = new MapFS({}) - it('should resolve relative file paths', () => { - expect(fs.resolve('foo/bar', 'coo', '')).toEqual('foo/bar/coo') + describe('#resolve()', () => { + it('should resolve relative file paths', () => { + expect(fs.resolve('foo/bar', 'coo', '')).toEqual('foo/bar/coo') + }) + it('should resolve to parent', () => { + expect(fs.resolve('foo/bar', '../coo', '')).toEqual('foo/coo') + }) + it('should resolve to root', () => { + expect(fs.resolve('foo/bar', '../../coo', '')).toEqual('coo') + }) + it('should resolve exceeding root', () => { + expect(fs.resolve('foo/bar', '../../../coo', '')).toEqual('coo') + }) + it('should resolve from absolute path', () => { + expect(fs.resolve('/foo/bar', '../../coo', '')).toEqual('/coo') + }) + it('should resolve exceeding root from absolute path', () => { + expect(fs.resolve('/foo/bar', '../../../coo', '')).toEqual('/coo') + }) + it('should resolve from invalid path', () => { + expect(fs.resolve('foo//bar', '../coo', '')).toEqual('foo/coo') + }) + it('should resolve current path', () => { + expect(fs.resolve('foo/bar', '.././coo', '')).toEqual('foo/coo') + }) + it('should resolve invalid path', () => { + expect(fs.resolve('foo/bar', '..//coo', '')).toEqual('foo/coo') + }) }) - it('should resolve to parent', () => { - expect(fs.resolve('foo/bar', '../coo', '')).toEqual('foo/coo') - }) - it('should resolve to root', () => { - expect(fs.resolve('foo/bar', '../../coo', '')).toEqual('coo') - }) - it('should resolve exceeding root', () => { - expect(fs.resolve('foo/bar', '../../../coo', '')).toEqual('coo') - }) - it('should resolve from absolute path', () => { - expect(fs.resolve('/foo/bar', '../../coo', '')).toEqual('/coo') - }) - it('should resolve exceeding root from absolute path', () => { - expect(fs.resolve('/foo/bar', '../../../coo', '')).toEqual('/coo') - }) - it('should resolve from invalid path', () => { - expect(fs.resolve('foo//bar', '../coo', '')).toEqual('foo/coo') + describe('#.readFileSync()', () => { + it('should throw if not exist', () => { + expect(() => fs.readFileSync('foo/bar')).toThrow('NOENT: foo/bar') + }) }) }) diff --git a/src/index.ts b/src/index.ts index 9e10063066..984d02641d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ /* istanbul ignore file */ export const version = '[VI]{version}[/VI]' export * as TypeGuards from './util/type-guards' -export { toValue, TimezoneDate, createTrie, Trie, toPromise, toValueSync, assert, LiquidError, ParseError, RenderError, UndefinedVariableError, TokenizationError, AssertionError } from './util' +export { toValue, createTrie, Trie, toPromise, toValueSync, assert, LiquidError, ParseError, RenderError, UndefinedVariableError, TokenizationError, AssertionError } from './util' export { Drop } from './drop' export { Emitter } from './emitters' export { defaultOperators, Operators, evalToken, evalQuotedToken, Expression, isFalsy, isTruthy } from './render' diff --git a/src/liquid-options.ts b/src/liquid-options.ts index e76e2b3253..7cea8b4465 100644 --- a/src/liquid-options.ts +++ b/src/liquid-options.ts @@ -1,4 +1,5 @@ import { assert, isArray, isString, isFunction } from './util' +import { getDateTimeFormat } from './util/intl' import { LRU, LiquidCache } from './cache' import { FS, LookupType } from './fs' import * as fs from './fs/fs-impl' @@ -43,6 +44,8 @@ export interface LiquidOptions { timezoneOffset?: number | string; /** Default date format to use if the date filter doesn't include a format. Defaults to `%A, %B %-e, %Y at %-l:%M %P %z`. */ dateFormat?: string; + /** Default locale, will be used by date filter. Defaults to system locale. */ + locale?: string; /** 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. */ @@ -138,6 +141,7 @@ export interface NormalizedFullOptions extends NormalizedOptions { ownPropertyOnly: boolean; lenientIf: boolean; dateFormat: string; + locale: string; trimTagRight: boolean; trimTagLeft: boolean; trimOutputRight: boolean; @@ -168,6 +172,7 @@ export const defaultOptions: NormalizedFullOptions = { dynamicPartials: true, jsTruthy: false, dateFormat: '%A, %B %-e, %Y at %-l:%M %P %z', + locale: '', trimTagRight: false, trimTagLeft: false, trimOutputRight: false, @@ -211,9 +216,9 @@ export function normalize (options: LiquidOptions): NormalizedFullOptions { options.partials = normalizeDirectoryList(options.partials) options.layouts = normalizeDirectoryList(options.layouts) options.outputEscape = options.outputEscape && getOutputEscapeFunction(options.outputEscape) - options.parseLimit = options.parseLimit || Infinity - options.renderLimit = options.renderLimit || Infinity - options.memoryLimit = options.memoryLimit || Infinity + if (!options.locale) { + options.locale = getDateTimeFormat()?.().resolvedOptions().locale ?? 'en-US' + } if (options.templates) { options.fs = new MapFS(options.templates) options.relativeReference = true diff --git a/src/parser/tokenizer.ts b/src/parser/tokenizer.ts index cea86d12a3..775a79888a 100644 --- a/src/parser/tokenizer.ts +++ b/src/parser/tokenizer.ts @@ -143,7 +143,7 @@ export class Tokenizer { return new HTMLToken(this.input, begin, this.p, this.file) } - readTagToken (options: NormalizedFullOptions = defaultOptions): TagToken { + readTagToken (options: NormalizedFullOptions): TagToken { const { file, input } = this const begin = this.p if (this.readToDelimiter(options.tagDelimiterRight) === -1) { diff --git a/src/tokens/identifier-token.ts b/src/tokens/identifier-token.ts index 0c44d3b460..a5826888cd 100644 --- a/src/tokens/identifier-token.ts +++ b/src/tokens/identifier-token.ts @@ -1,5 +1,4 @@ import { Token } from './token' -import { NUMBER, TYPES, SIGN } from '../util' import { TokenKind } from '../parser' export class IdentifierToken extends Token { @@ -13,13 +12,4 @@ export class IdentifierToken extends Token { super(TokenKind.Word, input, begin, end, file) this.content = this.getText() } - isNumber (allowSign = false) { - const begin = allowSign && TYPES[this.input.charCodeAt(this.begin)] & SIGN - ? this.begin + 1 - : this.begin - for (let i = begin; i < this.end; i++) { - if (!(TYPES[this.input.charCodeAt(i)] & NUMBER)) return false - } - return true - } } diff --git a/src/util/error.spec.ts b/src/util/error.spec.ts new file mode 100644 index 0000000000..37248d7073 --- /dev/null +++ b/src/util/error.spec.ts @@ -0,0 +1,40 @@ +import { Template } from '../template' +import { NumberToken } from '../tokens' +import { LiquidErrors, LiquidError, ParseError, RenderError } from './error' + +describe('LiquidError', () => { + describe('.is()', () => { + it('should return true for a LiquidError instance', () => { + const err = new Error('intended') + const token = new NumberToken('3', 0, 1) + expect(LiquidError.is(new ParseError(err, token))).toBeTruthy() + }) + it('should return false for null', () => { + expect(LiquidError.is(null)).toBeFalsy() + }) + }) +}) + +describe('LiquidErrors', () => { + describe('.is()', () => { + it('should return true for a LiquidErrors instance', () => { + const err = new Error('intended') + const token = new NumberToken('3', 0, 1) + const error = new ParseError(err, token) + expect(LiquidErrors.is(new LiquidErrors([error]))).toBeTruthy() + }) + }) +}) + +describe('RenderError', () => { + describe('.is()', () => { + it('should return true for a RenderError instance', () => { + const err = new Error('intended') + const tpl = { + token: new NumberToken('3', 0, 1), + render: () => '' + } as any as Template + expect(RenderError.is(new RenderError(err, tpl))).toBeTruthy() + }) + }) +}) diff --git a/src/util/index.ts b/src/util/index.ts index fc5c2c9b13..42f7c09f4b 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -8,5 +8,5 @@ export * from './type-guards' export * from './async' export * from './strftime' export * from './liquid-date' -export * from './timezone-date' export * from './limiter' +export * from './intl' diff --git a/src/util/intl.ts b/src/util/intl.ts new file mode 100644 index 0000000000..36fecee498 --- /dev/null +++ b/src/util/intl.ts @@ -0,0 +1,3 @@ +export function getDateTimeFormat () { + return (typeof Intl !== 'undefined' ? Intl.DateTimeFormat : undefined) +} diff --git a/src/util/liquid-date.spec.ts b/src/util/liquid-date.spec.ts new file mode 100644 index 0000000000..6fda0c23fb --- /dev/null +++ b/src/util/liquid-date.spec.ts @@ -0,0 +1,62 @@ +import { LiquidDate } from './liquid-date' +import { disableIntl } from '../../test/stub/no-intl' + +describe('LiquidDate', () => { + describe('timezone', () => { + it('should respect timezone set to 00:00', () => { + const date = new LiquidDate('2021-10-06T14:26:00.000+08:00', 'en-US', 0) + expect(date.getTimezoneOffset()).toBe(0) + expect(date.getHours()).toBe(6) + expect(date.getMinutes()).toBe(26) + }) + it('should respect timezone set to -06:00', () => { + const date = new LiquidDate('2021-10-06T14:26:00.000+08:00', 'en-US', -360) + expect(date.getTimezoneOffset()).toBe(-360) + expect(date.getMinutes()).toBe(26) + }) + }) + it('should support Date as argument', () => { + const date = new LiquidDate(new Date('2021-10-06T14:26:00.000+08:00'), 'en-US', 0) + expect(date.getHours()).toBe(6) + }) + it('should support .getMilliseconds()', () => { + const date = new LiquidDate('2021-10-06T14:26:00.001+00:00', 'en-US', 0) + expect(date.getMilliseconds()).toBe(1) + }) + it('should support .getDay()', () => { + const date = new LiquidDate('2021-12-07T00:00:00.001+08:00', 'en-US', -480) + expect(date.getDay()).toBe(2) + }) + it('should support .toLocaleString()', () => { + const date = new LiquidDate('2021-10-06T00:00:00.001+00:00', 'en-US', -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 LiquidDate('2021-10-06T00:00:00.001+00:00', 'en-US', -480) + expect(date.toLocaleTimeString('en-US')).toMatch(/^8:00:00\sAM$/) + expect(() => date.toLocaleDateString()).not.toThrow() + }) + it('should support .toLocaleDateString()', () => { + const date = new LiquidDate('2021-10-06T22:00:00.001+00:00', 'en-US', -480) + expect(date.toLocaleDateString('en-US')).toBe('10/7/2021') + expect(() => date.toLocaleDateString()).not.toThrow() + }) + describe('compatibility', () => { + disableIntl() + it('should use English months if Intl.DateTimeFormat not supported', () => { + expect(new LiquidDate('2021-10-06T22:00:00.001+00:00', 'en-US', -480).getLongMonthName()).toEqual('October') + expect(new LiquidDate('2021-10-06T22:00:00.001+00:00', 'zh-CN', -480).getLongMonthName()).toEqual('October') + expect(new LiquidDate('2021-10-06T22:00:00.001+00:00', 'zh-CN', -480).getShortMonthName()).toEqual('Oct') + }) + it('should use English weekdays if Intl.DateTimeFormat not supported', () => { + expect(new LiquidDate('2024-07-21T22:00:00.001+00:00', 'en-US', 0).getLongWeekdayName()).toEqual('Sunday') + expect(new LiquidDate('2024-07-21T22:00:00.001+00:00', 'zh-CN', -480).getLongWeekdayName()).toEqual('Monday') + expect(new LiquidDate('2024-07-21T22:00:00.001+00:00', 'zh-CN', -480).getShortWeekdayName()).toEqual('Mon') + }) + it('should return none for timezone if Intl.DateTimeFormat not supported', () => { + expect(new LiquidDate('2024-07-21T22:00:00.001', 'en-US').getTimeZoneName()).toEqual(undefined) + }) + }) +}) diff --git a/src/util/liquid-date.ts b/src/util/liquid-date.ts index 8ce2d0ff5d..655b2bfadf 100644 --- a/src/util/liquid-date.ts +++ b/src/util/liquid-date.ts @@ -1,20 +1,150 @@ +import { getDateTimeFormat } from './intl' +import { isString } from './underscore' + +// one minute in milliseconds +const OneMinute = 60000 +const ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/ +const monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', + 'September', 'October', 'November', 'December' +] +const monthNamesShort = monthNames.map(name => name.slice(0, 3)) +const dayNames = [ + 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' +] +const dayNamesShort = dayNames.map(name => name.slice(0, 3)) + /** - * The date interface LiquidJS uses. - * Basically a subset of JavaScript Date, - * it's defined abstractly here to allow different implementation + * A date implementation with timezone info, just like Ruby date + * + * Implementation: + * - create a Date offset by it's timezone difference, avoiding overriding a bunch of methods + * - rewrite getTimezoneOffset() to trick strftime */ -export interface LiquidDate { - getTime(): number; - getMilliseconds(): number; - getSeconds(): number; - getMinutes(): number; - getHours(): number; - getDay(): number; - getDate(): number; - getMonth(): number; - getFullYear(): number; - getTimezoneOffset(): number; - getTimezoneName?(): string; - toLocaleTimeString(): string; - toLocaleDateString(): string; +export class LiquidDate { + private timezoneOffset: number + private timezoneName: string + private date: Date + private displayDate: Date + private DateTimeFormat = getDateTimeFormat() + public timezoneFixed: boolean + constructor ( + init: string | number | Date, + private locale: string, + timezone?: number | string + ) { + this.date = new Date(init) + this.timezoneFixed = timezone !== undefined + if (timezone === undefined) { + timezone = this.date.getTimezoneOffset() + } + this.timezoneOffset = isString(timezone) ? LiquidDate.getTimezoneOffset(timezone, this.date) : timezone + this.timezoneName = isString(timezone) ? timezone : '' + + const diff = (this.date.getTimezoneOffset() - this.timezoneOffset) * OneMinute + const time = this.date.getTime() + diff + this.displayDate = new Date(time) + } + + getTime () { + return this.displayDate.getTime() + } + getMilliseconds () { + return this.displayDate.getMilliseconds() + } + getSeconds () { + return this.displayDate.getSeconds() + } + getMinutes () { + return this.displayDate.getMinutes() + } + getHours () { + return this.displayDate.getHours() + } + getDay () { + return this.displayDate.getDay() + } + getDate () { + return this.displayDate.getDate() + } + getMonth () { + return this.displayDate.getMonth() + } + 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.displayDate.toLocaleTimeString(locale) + } + toLocaleDateString (locale?: string) { + return this.displayDate.toLocaleDateString(locale) + } + getTimezoneOffset () { + return this.timezoneOffset! + } + getTimeZoneName () { + if (this.timezoneFixed) return this.timezoneName + if (!this.DateTimeFormat) return + return this.DateTimeFormat().resolvedOptions().timeZone + } + getLongMonthName () { + return this.format({ month: 'long' }) ?? monthNames[this.getMonth()] + } + getShortMonthName () { + return this.format({ month: 'short' }) ?? monthNamesShort[this.getMonth()] + } + getLongWeekdayName () { + return this.format({ weekday: 'long' }) ?? dayNames[this.displayDate.getDay()] + } + getShortWeekdayName () { + return this.format({ weekday: 'short' }) ?? dayNamesShort[this.displayDate.getDay()] + } + valid () { + return !isNaN(this.getTime()) + } + private format (options: Intl.DateTimeFormatOptions) { + return this.DateTimeFormat && this.DateTimeFormat(this.locale, options).format(this.displayDate) + } + + /** + * 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. + */ + static createDateFixedToTimezone (dateString: string, locale: string): LiquidDate { + const m = dateString.match(ISO8601_TIMEZONE_PATTERN) + // representing a UTC timestamp + if (m && m[1] === 'Z') { + return new LiquidDate(+new Date(dateString), locale, 0) + } + // has a timezone specified + if (m && m[2] && m[3] && m[4]) { + const [, , sign, hours, minutes] = m + const offset = (sign === '+' ? -1 : 1) * (parseInt(hours, 10) * 60 + parseInt(minutes, 10)) + return new LiquidDate(+new Date(dateString), locale, offset) + } + return new LiquidDate(dateString, locale) + } + private static getTimezoneOffset (timezoneName: string, date: 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/src/util/strftime.spec.ts b/src/util/strftime.spec.ts index 8edf30b1a9..b629b6d985 100644 --- a/src/util/strftime.spec.ts +++ b/src/util/strftime.spec.ts @@ -1,9 +1,9 @@ import { strftime as t } from './strftime' -import { DateWithTimezone } from '../../test/stub/date-with-timezone' +import { DateWithTimezone, TestDate } from '../../test/stub/date' describe('util/strftime', function () { - const now = new Date('2016-01-04 13:15:23') - const then = new Date('2016-03-06 03:05:03') + const now = new TestDate('2016-01-04 13:15:23') + const then = new TestDate('2016-03-06 03:05:03') describe('Date (Year, Month, Day)', () => { it('should format %C as century', function () { @@ -23,26 +23,26 @@ describe('util/strftime', function () { expect(t(then, '%j')).toBe('066') }) it('should take count of leap years', function () { - const date = new Date('2001 03 01') + const date = new TestDate('2001 03 01') expect(t(date, '%j')).toBe('060') }) it('should take count of leap years', function () { - const date = new Date('2000 03 01') + const date = new TestDate('2000 03 01') expect(t(date, '%j')).toBe('061') }) }) it('should format %q as date suffix', function () { - const first = new Date('2016-03-01 03:05:03') - const second = new Date('2016-03-02 03:05:03') - const third = new Date('2016-03-03 03:05:03') + const first = new TestDate('2016-03-01 03:05:03') + const second = new TestDate('2016-03-02 03:05:03') + const third = new TestDate('2016-03-03 03:05:03') - const eleventh = new Date('2016-03-11 03:05:03') - const twelfth = new Date('2016-03-12 03:05:03') - const thirteenth = new Date('2016-03-13 03:05:03') + const eleventh = new TestDate('2016-03-11 03:05:03') + const twelfth = new TestDate('2016-03-12 03:05:03') + const thirteenth = new TestDate('2016-03-13 03:05:03') - const twentyfirst = new Date('2016-03-21 03:05:03') - const twentysecond = new Date('2016-03-22 03:05:03') - const twentythird = new Date('2016-03-23 03:05:03') + const twentyfirst = new TestDate('2016-03-21 03:05:03') + const twentysecond = new TestDate('2016-03-22 03:05:03') + const twentythird = new TestDate('2016-03-23 03:05:03') expect(t(first, '%q')).toBe('st') expect(t(second, '%q')).toBe('nd') @@ -64,7 +64,7 @@ describe('util/strftime', function () { expect(t(now, '%I')).toBe('01') }) it('should format %I as 12 for 00:00', function () { - const date = new Date('2016-01-01 00:00:00') + const date = new TestDate('2016-01-01 00:00:00') expect(t(date, '%I')).toBe('12') }) it('should format %k as space padded hour', function () { @@ -74,14 +74,14 @@ describe('util/strftime', function () { expect(t(now, '%l')).toBe(' 1') }) it('should format %l as 12 for 00:00', function () { - const date = new Date('2016-01-01 00:00:00') + const date = new TestDate('2016-01-01 00:00:00') expect(t(date, '%l')).toBe('12') }) it('should format %L as 0 padded millisecond', function () { expect(t(then, '%L')).toBe('000') }) it('should format %N as fractional seconds digits', function () { - const time = new Date('2019-12-15 01:21:00.129') + const time = new TestDate('2019-12-15 01:21:00.129') expect(t(time, '%N')).toBe('129000000') expect(t(time, '%2N')).toBe('12') expect(t(time, '%10N')).toBe('1290000000') diff --git a/src/util/strftime.ts b/src/util/strftime.ts index b6bf2652f8..535152fd4e 100644 --- a/src/util/strftime.ts +++ b/src/util/strftime.ts @@ -2,25 +2,12 @@ import { changeCase, padStart, padEnd } from './underscore' import { LiquidDate } from './liquid-date' const rFormat = /%([-_0^#:]+)?(\d+)?([EO])?(.)/ -const monthNames = [ - 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', - 'September', 'October', 'November', 'December' -] -const dayNames = [ - 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' -] -const monthNamesShort = monthNames.map(abbr) -const dayNamesShort = dayNames.map(abbr) interface FormatOptions { flags: object; width?: string; modifier?: string; } -function abbr (str: string) { - return str.slice(0, 3) -} - // prototype extensions function daysInMonth (d: LiquidDate) { const feb = isLeapYear(d) ? 29 : 28 @@ -77,19 +64,8 @@ const padWidths = { W: 2 } -// default to '0' -const padChars = { - a: ' ', - A: ' ', - b: ' ', - B: ' ', - c: ' ', - e: ' ', - k: ' ', - l: ' ', - p: ' ', - P: ' ' -} +const padSpaceChars = new Set('aAbBceklpP') + function getTimezoneOffset (d: LiquidDate, opts: FormatOptions) { const nOffset = Math.abs(d.getTimezoneOffset()) const h = Math.floor(nOffset / 60) @@ -100,10 +76,10 @@ function getTimezoneOffset (d: LiquidDate, opts: FormatOptions) { padStart(m, 2, '0') } const formatCodes = { - a: (d: LiquidDate) => dayNamesShort[d.getDay()], - A: (d: LiquidDate) => dayNames[d.getDay()], - b: (d: LiquidDate) => monthNamesShort[d.getMonth()], - B: (d: LiquidDate) => monthNames[d.getMonth()], + a: (d: LiquidDate) => d.getShortWeekdayName(), + A: (d: LiquidDate) => d.getLongWeekdayName(), + b: (d: LiquidDate) => d.getShortMonthName(), + B: (d: LiquidDate) => d.getLongMonthName(), c: (d: LiquidDate) => d.toLocaleString(), C: (d: LiquidDate) => century(d), d: (d: LiquidDate) => d.getDate(), @@ -135,12 +111,7 @@ const formatCodes = { y: (d: LiquidDate) => d.getFullYear().toString().slice(2, 4), Y: (d: LiquidDate) => d.getFullYear(), z: getTimezoneOffset, - Z: (d: LiquidDate, opts: FormatOptions) => { - if (d.getTimezoneName) { - return d.getTimezoneName() || getTimezoneOffset(d, opts) - } - return (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : '') - }, + Z: (d: LiquidDate, opts: FormatOptions) => d.getTimeZoneName() || getTimezoneOffset(d, opts), 't': () => '\t', 'n': () => '\n', '%': () => '%' @@ -166,7 +137,7 @@ function format (d: LiquidDate, match: RegExpExecArray) { const flags = {} for (const flag of flagStr) flags[flag] = true let ret = String(convert(d, { flags, width, modifier })) - let padChar = padChars[conversion] || '0' + let padChar = padSpaceChars.has(conversion) ? ' ' : '0' let padWidth = width || padWidths[conversion] || 0 if (flags['^']) ret = ret.toUpperCase() else if (flags['#']) ret = changeCase(ret) diff --git a/src/util/timezone-date.spec.ts b/src/util/timezone-date.spec.ts deleted file mode 100644 index 5f4c3a62f1..0000000000 --- a/src/util/timezone-date.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { TimezoneDate } from './timezone-date' - -describe('TimezoneDate', () => { - it('should respect timezone set to 00:00', () => { - const date = new TimezoneDate('2021-10-06T14:26:00.000+08:00', 0) - expect(date.getTimezoneOffset()).toBe(0) - expect(date.getHours()).toBe(6) - expect(date.getMinutes()).toBe(26) - }) - it('should respect timezone set to -06:00', () => { - const date = new TimezoneDate('2021-10-06T14:26:00.000+08:00', -360) - expect(date.getTimezoneOffset()).toBe(-360) - expect(date.getMinutes()).toBe(26) - }) - it('should support Date as argument', () => { - const date = new TimezoneDate(new Date('2021-10-06T14:26:00.000+08:00'), 0) - expect(date.getHours()).toBe(6) - }) - it('should support .getMilliseconds()', () => { - const date = new TimezoneDate('2021-10-06T14:26:00.001+00:00', 0) - expect(date.getMilliseconds()).toBe(1) - }) - it('should support .getDay()', () => { - 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$/) - expect(() => date.toLocaleDateString()).not.toThrow() - }) - it('should support .toLocaleDateString()', () => { - const date = new TimezoneDate('2021-10-06T22:00:00.001+00:00', -480) - expect(date.toLocaleDateString('en-US')).toBe('10/7/2021') - expect(() => date.toLocaleDateString()).not.toThrow() - }) -}) diff --git a/src/util/timezone-date.ts b/src/util/timezone-date.ts deleted file mode 100644 index c23ff611c2..0000000000 --- a/src/util/timezone-date.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { LiquidDate } from './liquid-date' -import { isString } from './underscore' - -// one minute in milliseconds -const OneMinute = 60000 -const ISO8601_TIMEZONE_PATTERN = /([zZ]|([+-])(\d{2}):(\d{2}))$/ - -/** - * A date implementation with timezone info, just like Ruby date - * - * Implementation: - * - create a Date offset by it's timezone difference, avoiding overriding a bunch of methods - * - rewrite getTimezoneOffset() to trick strftime - */ -export class TimezoneDate implements LiquidDate { - private timezoneOffset: number - private timezoneName: string - private date: Date - private displayDate: Date - constructor (init: string | number | Date | TimezoneDate, timezone: number | string) { - this.date = init instanceof TimezoneDate - ? init.date - : new Date(init) - 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 - this.displayDate = new Date(time) - } - - getTime () { - return this.displayDate.getTime() - } - - getMilliseconds () { - return this.displayDate.getMilliseconds() - } - getSeconds () { - return this.displayDate.getSeconds() - } - getMinutes () { - return this.displayDate.getMinutes() - } - getHours () { - return this.displayDate.getHours() - } - getDay () { - return this.displayDate.getDay() - } - getDate () { - return this.displayDate.getDate() - } - getMonth () { - return this.displayDate.getMonth() - } - 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.displayDate.toLocaleTimeString(locale) - } - toLocaleDateString (locale?: string) { - return this.displayDate.toLocaleDateString(locale) - } - getTimezoneOffset () { - return this.timezoneOffset! - } - getTimezoneName () { - return this.timezoneName - } - - /** - * 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. - */ - static createDateFixedToTimezone (dateString: string): LiquidDate { - const m = dateString.match(ISO8601_TIMEZONE_PATTERN) - // representing a UTC timestamp - if (m && m[1] === 'Z') { - return new TimezoneDate(+new Date(dateString), 0) - } - // has a timezone specified - if (m && m[2] && m[3] && m[4]) { - const [, , sign, hours, minutes] = m - const offset = (sign === '+' ? -1 : 1) * (parseInt(hours, 10) * 60 + parseInt(minutes, 10)) - return new TimezoneDate(+new Date(dateString), offset) - } - 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/src/util/type-guards.spec.ts b/src/util/type-guards.spec.ts new file mode 100644 index 0000000000..0efce1c451 --- /dev/null +++ b/src/util/type-guards.spec.ts @@ -0,0 +1,21 @@ +import { LiteralToken } from '../tokens' +import { isLiteralToken, isNumberToken, isWordToken } from './type-guards' + +describe('isLiteralToken()', () => { + it('should return true for literal', () => { + expect(isLiteralToken(new LiteralToken('true', 0, 4))).toBeTruthy() + }) +}) +describe('isWordToken()', () => { + it('should return false for literal', () => { + expect(isWordToken(new LiteralToken('true', 0, 4))).toBeFalsy() + }) +}) +describe('isNumberToken()', () => { + it('should return false for literal', () => { + expect(isNumberToken(new LiteralToken('true', 0, 4))).toBeFalsy() + }) + it('should return false for null', () => { + expect(isNumberToken(null)).toBeFalsy() + }) +}) diff --git a/test/integration/filters/array.spec.ts b/test/integration/filters/array.spec.ts index 0742411593..092ef449cd 100644 --- a/test/integration/filters/array.spec.ts +++ b/test/integration/filters/array.spec.ts @@ -475,6 +475,15 @@ describe('filters/array', function () { - 2 `) }) + it('should render none if args not specified', function () { + return test(`{% assign kitchen_products = products | where %} + Kitchen products: + {% for product in kitchen_products -%} + - {{ product.title }} + {% endfor %}`, { products }, ` + Kitchen products: + `) + }) }) describe('where_exp', function () { const products = [ @@ -560,6 +569,12 @@ describe('filters/array', function () { { members }, `{"graduation_year":2014,"name":"John"}`) }) + it('should render none if not found', function () { + return test( + `{{ members | find: "graduation_year", 2018 | json }}`, + { members }, + ``) + }) }) describe('find_exp', function () { const members = [ @@ -573,5 +588,11 @@ describe('filters/array', function () { { members }, `{"graduation_year":2014,"name":"John"}`) }) + it('should render none if not found', function () { + return test( + `{{ members | find_exp: "item", "item.graduation_year == 2018" | json }}`, + { members }, + ``) + }) }) }) diff --git a/test/integration/filters/date.spec.ts b/test/integration/filters/date.spec.ts index f2772402f7..8c1d6ee371 100644 --- a/test/integration/filters/date.spec.ts +++ b/test/integration/filters/date.spec.ts @@ -1,22 +1,81 @@ import { LiquidOptions } from '../../../src/liquid-options' import { Liquid } from '.././../../src/liquid' import { test } from '../../stub/render' +import { disableIntl } from '../../stub/no-intl' describe('filters/date', function () { + const liquid = new Liquid({ locale: 'en-US' }) + + describe('constructor', () => { + it('should create a new Date when given "now"', function () { + return test('{{ "now" | date: "%Y"}}', (new Date()).getFullYear().toString()) + }) + it('should create a new Date when given "today"', function () { + return test('{{ "today" | date: "%Y"}}', (new Date()).getFullYear().toString()) + }) + it('should create from number', async function () { + const time = new Date('2017-03-07T12:00:00').getTime() / 1000 + return test('{{ time | date: "%Y-%m-%dT%H:%M:%S" }}', { time }, '2017-03-07T12:00:00') + }) + it('should create from number-like string', async function () { + const time = String(new Date('2017-03-07T12:00:00').getTime() / 1000) + return test('{{ time | date: "%Y-%m-%dT%H:%M:%S" }}', { time }, '2017-03-07T12:00:00') + }) + it('should treat nil as 0', () => { + expect(liquid.parseAndRenderSync('{{ nil | date: "%Y-%m-%dT%H:%M:%S", "Asia/Shanghai" }}')).toEqual('1970-01-01T08:00:00') + }) + it('should treat undefined as invalid', () => { + expect(liquid.parseAndRenderSync('{{ num | date: "%Y-%m-%dT%H:%M:%S", "Asia/Shanghai" }}', { num: undefined })).toEqual('') + }) + }) + it('should support date: %a %b %d %Y', function () { const date = new Date() return test('{{ date | date:"%a %b %d %Y"}}', { date }, date.toDateString()) }) + describe('%a', () => { + it('should support short week day', () => { + const tpl = '{{ "2024-07-21T20:24:00.000Z" | date: "%a", "Asia/Shanghai" }}' + expect(liquid.parseAndRenderSync(tpl)).toEqual('Mon') + }) + it('should support short week day with timezone', () => { + const tpl = '{{ "2024-07-21T20:24:00.000Z" | date: "%a", "America/New_York" }}' + expect(liquid.parseAndRenderSync(tpl)).toEqual('Sun') + }) + it('should support short week day with locale', () => { + const liquid = new Liquid({ locale: 'zh-CN' }) + const tpl = '{{ "2024-07-21T20:24:00.000Z" | date: "%a", "America/New_York" }}' + expect(liquid.parseAndRenderSync(tpl)).toEqual('周日') + }) + }) + describe('%b', () => { + it('should support short month', () => { + const tpl = '{{ "2024-07-31T20:24:00.000Z" | date: "%b", "Asia/Shanghai" }}' + expect(liquid.parseAndRenderSync(tpl)).toEqual('Aug') + }) + it('should support short week day with locale', () => { + const liquid = new Liquid({ locale: 'zh-CN' }) + const tpl = '{{ "2024-07-31T20:24:00.000Z" | date: "%b", "Asia/Shanghai" }}' + expect(liquid.parseAndRenderSync(tpl)).toEqual('8月') + }) + }) + describe('Intl compatibility', () => { + disableIntl() + it('should use English if Intl not supported', () => { + const liquid = new Liquid() + const tpl = '{{ "2024-07-31T20:24:00.000Z" | date: "%b", "Asia/Shanghai" }}' + expect(liquid.parseAndRenderSync(tpl)).toEqual('Aug') + }) + it('should use English if Intl not supported even for other locales', () => { + const liquid = new Liquid({ locale: 'zh-CN' }) + const tpl = '{{ "2024-07-31T20:24:00.000Z" | date: "%b", "Asia/Shanghai" }}' + expect(liquid.parseAndRenderSync(tpl)).toEqual('Aug') + }) + }) it('should support "now"', function () { // sample: Thursday, February 2, 2023 at 6:25 pm +0000 return test('{{ "now" | date }}', /\w+, \w+ \d+, \d\d\d\d at \d+:\d\d [ap]m [-+]\d\d\d\d/) }) - it('should create a new Date when given "now"', function () { - return test('{{ "now" | date: "%Y"}}', (new Date()).getFullYear().toString()) - }) - it('should create a new Date when given "today"', function () { - return test('{{ "today" | date: "%Y"}}', (new Date()).getFullYear().toString()) - }) it('should parse as Date when given a timezoneless string', function () { return test('{{ "1991-02-22T00:00:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1991-02-22T00:00:00') }) @@ -45,14 +104,6 @@ describe('filters/date', function () { it('should render object as string', function () { return test('{{ obj | date: "%Y"}}', { obj: {} }, '[object Object]') }) - it('should create from number', async function () { - const time = new Date('2017-03-07T12:00:00').getTime() / 1000 - return test('{{ time | date: "%Y-%m-%dT%H:%M:%S" }}', { time }, '2017-03-07T12:00:00') - }) - it('should create from number-like string', async function () { - const time = String(new Date('2017-03-07T12:00:00').getTime() / 1000) - return test('{{ time | date: "%Y-%m-%dT%H:%M:%S" }}', { time }, '2017-03-07T12:00:00') - }) it('should support manipulation', async function () { return test('{{ date | date: "%s" | minus : 604800 | date: "%Y-%m-%dT%H:%M:%S"}}', { date: new Date('2017-03-07T12:00:00') }, @@ -180,6 +231,10 @@ describe('filters/date_to_string', function () { const output = liquid.parseAndRenderSync('{{ "2008-11-07T13:07:54-08:00" | date_to_string: "ordinal", "US" }}') expect(output).toEqual('Nov 7th, 2008') }) + it('should render none if not valid', function () { + const output = liquid.parseAndRenderSync('{{ "hello" | date_to_string: "ordinal", "US" }}') + expect(output).toEqual('hello') + }) }) describe('filters/date_to_long_string', function () { diff --git a/test/integration/filters/misc.spec.ts b/test/integration/filters/misc.spec.ts index f60b260156..ef9c0f085b 100644 --- a/test/integration/filters/misc.spec.ts +++ b/test/integration/filters/misc.spec.ts @@ -43,9 +43,9 @@ describe('filters/object', function () { }) it('should inspect cyclic object', () => { const text = '{{foo | inspect}}' - const foo: any = { bar: 'bar' } + const foo: any = { bar: { coo: 'coo' } } foo.foo = foo - const expected = '{"bar":"bar","foo":"[Circular]"}' + const expected = '{"bar":{"coo":"coo"},"foo":"[Circular]"}' return expect(liquid.parseAndRenderSync(text, { foo })).toBe(expected) }) it('should support space argument', () => { diff --git a/test/integration/filters/string.spec.ts b/test/integration/filters/string.spec.ts index 108e46989f..9ea5bd0888 100644 --- a/test/integration/filters/string.spec.ts +++ b/test/integration/filters/string.spec.ts @@ -243,26 +243,39 @@ describe('filters/string', function () { const html = await liquid.parseAndRender('{{ "I\'m not hungry" | number_of_words: "auto"}}') expect(html).toEqual('3') }) - + it('should count words of empty sentence', async () => { + const html = await liquid.parseAndRender('{{ "" | number_of_words }}') + expect(html).toEqual('0') + }) it('should count words of mixed sentence', async () => { const html = await liquid.parseAndRender('{{ "Hello world!" | number_of_words }}') expect(html).toEqual('2') }) - it('should count words of CJK sentence', async () => { const html = await liquid.parseAndRender('{{ "你好hello世界world" | number_of_words }}') expect(html).toEqual('1') }) - + it('should count words of Latin sentence in CJK mode', async () => { + const html = await liquid.parseAndRender('{{ "I\'m not hungry" | number_of_words: "cjk"}}') + expect(html).toEqual('3') + }) + it('should count words of empty sentence in CJK mode', async () => { + const html = await liquid.parseAndRender('{{ "" | number_of_words: "cjk"}}') + expect(html).toEqual('0') + }) it('should count words of CJK sentence with mode "cjk"', async () => { const html = await liquid.parseAndRender('{{ "你好hello世界world" | number_of_words: "cjk" }}') expect(html).toEqual('6') }) - it('should count words of CJK sentence with mode "auto"', async () => { + it('should count words of mixed sentence with mode "auto"', async () => { const html = await liquid.parseAndRender('{{ "你好hello世界world" | number_of_words: "auto" }}') expect(html).toEqual('6') }) + it('should count words of CJK sentence with mode "auto"', async () => { + const html = await liquid.parseAndRender('{{ "你好世界" | number_of_words: "auto" }}') + expect(html).toEqual('4') + }) it('should handle empty input', async () => { const html = await liquid.parseAndRender('{{ "" | number_of_words }}') expect(html).toEqual('0') diff --git a/test/integration/liquid/dos.spec.ts b/test/integration/liquid/dos.spec.ts index a8a4e67e59..246e76c10b 100644 --- a/test/integration/liquid/dos.spec.ts +++ b/test/integration/liquid/dos.spec.ts @@ -33,6 +33,12 @@ describe('DoS related', function () { await expect(limit10.parseAndRender(src)).rejects.toThrow('template render limit exceeded') await expect(limit20.parseAndRender(src)).resolves.toBe('1,2,3,4,5,') }) + it('should support reset when calling render', async () => { + const src = '{% for i in (1..5) %}{{i}},{% endfor %}' + const liquid = new Liquid({ renderLimit: 0.01 }) + await expect(liquid.parseAndRender(src)).rejects.toThrow('template render limit exceeded') + await expect(liquid.parseAndRender(src, {}, { renderLimit: 1e6 })).resolves.toBe('1,2,3,4,5,') + }) it('should take partials into account', async () => { mock({ '/small': '{% for i in (1..5) %}{{i}}{% endfor %}', @@ -50,6 +56,12 @@ describe('DoS related', function () { await expect(liquid.parseAndRender('{{ array | slice: 0, 3 | join }}', { array })).resolves.toBe('0 0 0') await expect(liquid.parseAndRender('{{ array | slice: 0, 300 | join }}', { array })).rejects.toThrow('memory alloc limit exceeded, line:1, col:1') }) + it('should support reset when calling render', async () => { + const array = Array(1e3).fill(0) + const liquid = new Liquid({ memoryLimit: 100 }) + await expect(liquid.parseAndRender('{{ array | slice: 0, 300 | join }}', { array })).rejects.toThrow('memory alloc limit exceeded, line:1, col:1') + await expect(liquid.parseAndRender('{{ array | slice: 0, 300 | join }}', { array }, { memoryLimit: 1e3 })).resolves.toBe(Array(300).fill(0).join(' ')) + }) it('should throw for too many array iteration in tags', async () => { const array = ['a'] const liquid = new Liquid({ memoryLimit: 100 }) diff --git a/test/integration/liquid/fs-option.spec.ts b/test/integration/liquid/fs-option.spec.ts index 99fd2be92f..7e2183ed6d 100644 --- a/test/integration/liquid/fs-option.spec.ts +++ b/test/integration/liquid/fs-option.spec.ts @@ -9,7 +9,7 @@ describe('LiquidOptions#fs', function () { existsSync: (x: string) => !x.match(/not-exist/), readFile: (x: string) => Promise.resolve(`content for ${x}`), readFileSync: (x: string) => `content for ${x}`, - fallback: (x: string) => '/root/files/fallback', + fallback: (_x: string) => '/root/files/fallback', resolve: (base: string, path: string) => base + '/' + path } beforeEach(function () { @@ -49,7 +49,7 @@ describe('LiquidOptions#fs', function () { } as any) expect(engine.options.relativeReference).toBe(false) }) - it('should render from in-memory templates', () => { + it('should render from in-memory templates', async () => { const engine = new Liquid({ templates: { entry: '{% layout "main" %}entry', @@ -57,6 +57,7 @@ describe('LiquidOptions#fs', function () { } }) expect(engine.renderFileSync('entry')).toEqual('header entry footer') + await expect(engine.renderFile('entry')).resolves.toEqual('header entry footer') }) it('should render relative in-memory templates', () => { const engine = new Liquid({ diff --git a/test/stub/date-with-timezone.ts b/test/stub/date-with-timezone.ts deleted file mode 100644 index aca88cc93e..0000000000 --- a/test/stub/date-with-timezone.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class DateWithTimezone extends Date { - constructor (init: string, timezone: number) { - super(init) - this.getTimezoneOffset = () => timezone - } -} diff --git a/test/stub/date.ts b/test/stub/date.ts new file mode 100644 index 0000000000..61f94db83a --- /dev/null +++ b/test/stub/date.ts @@ -0,0 +1,16 @@ +import { LiquidDate } from '../../src/util' + +const locale = Intl.DateTimeFormat().resolvedOptions().locale + +export class DateWithTimezone extends LiquidDate { + constructor (init: string, timezone: number) { + super(init, locale) + this.getTimezoneOffset = () => timezone + } +} + +export class TestDate extends LiquidDate { + constructor (v: any) { + super(v, locale) + } +} diff --git a/test/stub/no-intl.ts b/test/stub/no-intl.ts new file mode 100644 index 0000000000..938fc4c0f7 --- /dev/null +++ b/test/stub/no-intl.ts @@ -0,0 +1,10 @@ +export function disableIntl () { + let intl: typeof Intl + beforeEach(() => { + intl = Intl + delete (global as any).Intl + }) + afterEach(() => { + (global as any).Intl = intl + }) +}