UNPKG

@js-temporal/polyfill

Version:

Polyfill for Tc39 Stage 3 proposal Temporal (https://github.com/tc39/proposal-temporal)

1,156 lines (1,121 loc) 107 kB
import { DEBUG } from './debug'; import * as ES from './ecmascript'; import { GetIntrinsic, MakeIntrinsicClass, DefineIntrinsic } from './intrinsicclass'; import { CALENDAR_ID, ISO_YEAR, ISO_MONTH, ISO_DAY, YEARS, MONTHS, WEEKS, DAYS, HOURS, MINUTES, SECONDS, MILLISECONDS, MICROSECONDS, NANOSECONDS, CreateSlots, GetSlot, HasSlot, SetSlot } from './slots'; import type { Temporal } from '..'; import type { BuiltinCalendarId, CalendarParams as Params, CalendarReturn as Return } from './internaltypes'; const ArrayIncludes = Array.prototype.includes; const ArrayPrototypePush = Array.prototype.push; const IntlDateTimeFormat = globalThis.Intl.DateTimeFormat; const ArraySort = Array.prototype.sort; const MathAbs = Math.abs; const MathFloor = Math.floor; const ObjectEntries = Object.entries; const ObjectKeys = Object.keys; /** * Shape of internal implementation of each built-in calendar. Note that * parameter types are simpler than CalendarProtocol because the `Calendar` * class performs validation and parameter normalization before handing control * over to CalendarImpl. * * There are two instances of this interface: one for the ISO calendar and * another that handles logic that's the same across all non-ISO calendars. The * latter is cloned for each non-ISO calendar at the end of this file. */ interface CalendarImpl { year(date: Temporal.PlainDate | Temporal.PlainYearMonth): number; month(date: Temporal.PlainDate | Temporal.PlainYearMonth | Temporal.PlainMonthDay): number; monthCode(date: Temporal.PlainDate | Temporal.PlainYearMonth | Temporal.PlainMonthDay): string; day(date: Temporal.PlainDate | Temporal.PlainMonthDay): number; era(date: Temporal.PlainDate | Temporal.PlainYearMonth): string | undefined; eraYear(date: Temporal.PlainDate | Temporal.PlainYearMonth): number | undefined; dayOfWeek(date: Temporal.PlainDate): number; dayOfYear(date: Temporal.PlainDate): number; weekOfYear(date: Temporal.PlainDate): number; daysInWeek(date: Temporal.PlainDate): number; daysInMonth(date: Temporal.PlainDate | Temporal.PlainYearMonth): number; daysInYear(date: Temporal.PlainDate | Temporal.PlainYearMonth): number; monthsInYear(date: Temporal.PlainDate | Temporal.PlainYearMonth): number; inLeapYear(date: Temporal.PlainDate | Temporal.PlainYearMonth): boolean; dateFromFields( fields: Params['dateFromFields'][0], options: NonNullable<Params['dateFromFields'][1]>, calendar: Temporal.Calendar ): Temporal.PlainDate; yearMonthFromFields( fields: Params['yearMonthFromFields'][0], options: NonNullable<Params['yearMonthFromFields'][1]>, calendar: Temporal.Calendar ): Temporal.PlainYearMonth; monthDayFromFields( fields: Params['monthDayFromFields'][0], options: NonNullable<Params['monthDayFromFields'][1]>, calendar: Temporal.Calendar ): Temporal.PlainMonthDay; dateAdd( date: Temporal.PlainDate, years: number, months: number, weeks: number, days: number, overflow: Overflow, calendar: Temporal.Calendar ): Temporal.PlainDate; dateUntil( one: Temporal.PlainDate, two: Temporal.PlainDate, largestUnit: 'year' | 'month' | 'week' | 'day' ): { years: number; months: number; weeks: number; days: number }; fields(fields: string[]): string[]; mergeFields(fields: Record<string, unknown>, additionalFields: Record<string, unknown>): Record<string, unknown>; } /** * Implementations for each calendar. Non-ISO calendars have an extra `helper` * property that provides additional per-calendar logic. */ const impl = {} as { iso8601: CalendarImpl; } & { [id in Exclude<BuiltinCalendarId, 'iso8601'>]: NonIsoImpl; }; /** * Thin wrapper around the implementation of each built-in calendar. This * class's methods follow a similar pattern: * 1. Validate parameters * 2. Fill in default options (for methods where options are present) * 3. Simplify and/or normalize parameters. For example, some methods accept * PlainDate, PlainDateTime, ZonedDateTime, etc. and these are normalized to * PlainDate. * 4. Look up the ID of the built-in calendar * 5. Fetch the implementation object for that ID. * 6. Call the corresponding method in the implementation object. */ export class Calendar implements Temporal.Calendar { constructor(idParam: Params['constructor'][0]) { // Note: if the argument is not passed, IsBuiltinCalendar("undefined") will fail. This check // exists only to improve the error message. if (arguments.length < 1) { throw new RangeError('missing argument: id is required'); } const id = ES.ToString(idParam); if (!ES.IsBuiltinCalendar(id)) throw new RangeError(`invalid calendar identifier ${id}`); CreateSlots(this); SetSlot(this, CALENDAR_ID, id); if (DEBUG) { Object.defineProperty(this, '_repr_', { value: `${this[Symbol.toStringTag]} <${id}>`, writable: false, enumerable: false, configurable: false }); } } get id(): Return['id'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); return ES.ToString(this); } dateFromFields( fields: Params['dateFromFields'][0], optionsParam: Params['dateFromFields'][1] = undefined ): Return['dateFromFields'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsObject(fields)) throw new TypeError('invalid fields'); const options = ES.GetOptionsObject(optionsParam); return impl[GetSlot(this, CALENDAR_ID)].dateFromFields(fields, options, this); } yearMonthFromFields( fields: Params['yearMonthFromFields'][0], optionsParam: Params['yearMonthFromFields'][1] = undefined ): Return['yearMonthFromFields'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsObject(fields)) throw new TypeError('invalid fields'); const options = ES.GetOptionsObject(optionsParam); return impl[GetSlot(this, CALENDAR_ID)].yearMonthFromFields(fields, options, this); } monthDayFromFields( fields: Params['monthDayFromFields'][0], optionsParam: Params['monthDayFromFields'][1] = undefined ): Return['monthDayFromFields'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsObject(fields)) throw new TypeError('invalid fields'); const options = ES.GetOptionsObject(optionsParam); return impl[GetSlot(this, CALENDAR_ID)].monthDayFromFields(fields, options, this); } fields(fields: Params['fields'][0]): Return['fields'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const fieldsArray = [] as string[]; const allowed = new Set([ 'year', 'month', 'monthCode', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond' ]); for (const name of fields) { if (typeof name !== 'string') throw new TypeError('invalid fields'); if (!allowed.has(name)) throw new RangeError(`invalid field name ${name}`); allowed.delete(name); ArrayPrototypePush.call(fieldsArray, name); } return impl[GetSlot(this, CALENDAR_ID)].fields(fieldsArray); } mergeFields(fields: Params['mergeFields'][0], additionalFields: Params['mergeFields'][1]): Return['mergeFields'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); return impl[GetSlot(this, CALENDAR_ID)].mergeFields(fields, additionalFields); } dateAdd( dateParam: Params['dateAdd'][0], durationParam: Params['dateAdd'][1], optionsParam: Params['dateAdd'][2] = undefined ): Return['dateAdd'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const date = ES.ToTemporalDate(dateParam); const duration = ES.ToTemporalDuration(durationParam); const options = ES.GetOptionsObject(optionsParam); const overflow = ES.ToTemporalOverflow(options); const { days } = ES.BalanceDuration( GetSlot(duration, DAYS), GetSlot(duration, HOURS), GetSlot(duration, MINUTES), GetSlot(duration, SECONDS), GetSlot(duration, MILLISECONDS), GetSlot(duration, MICROSECONDS), GetSlot(duration, NANOSECONDS), 'day' ); return impl[GetSlot(this, CALENDAR_ID)].dateAdd( date, GetSlot(duration, YEARS), GetSlot(duration, MONTHS), GetSlot(duration, WEEKS), days, overflow, this ); } dateUntil( oneParam: Params['dateUntil'][0], twoParam: Params['dateUntil'][1], optionsParam: Params['dateUntil'][2] = undefined ): Return['dateUntil'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const one = ES.ToTemporalDate(oneParam); const two = ES.ToTemporalDate(twoParam); const options = ES.GetOptionsObject(optionsParam); let largestUnit = ES.GetTemporalUnit(options, 'largestUnit', 'date', 'auto'); if (largestUnit === 'auto') largestUnit = 'day'; const { years, months, weeks, days } = impl[GetSlot(this, CALENDAR_ID)].dateUntil(one, two, largestUnit); const Duration = GetIntrinsic('%Temporal.Duration%'); return new Duration(years, months, weeks, days, 0, 0, 0, 0, 0, 0); } year(dateParam: Params['year'][0]): Return['year'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].year(date as Temporal.PlainDate | Temporal.PlainYearMonth); } month(dateParam: Params['month'][0]): Return['month'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (ES.IsTemporalMonthDay(date)) throw new TypeError('use monthCode on PlainMonthDay instead'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].month(date as Temporal.PlainDate | Temporal.PlainYearMonth); } monthCode(dateParam: Params['monthCode'][0]): Return['monthCode'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date) && !ES.IsTemporalMonthDay(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].monthCode( date as Temporal.PlainDate | Temporal.PlainMonthDay | Temporal.PlainYearMonth ); } day(dateParam: Params['day'][0]): Return['day'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalMonthDay(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].day(date as Temporal.PlainDate | Temporal.PlainMonthDay); } era(dateParam: Params['era'][0]): Return['era'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].era(date as Temporal.PlainDate | Temporal.PlainYearMonth); } eraYear(dateParam: Params['eraYear'][0]): Return['eraYear'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].eraYear(date as Temporal.PlainDate | Temporal.PlainYearMonth); } dayOfWeek(dateParam: Params['dayOfWeek'][0]): Return['dayOfWeek'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const date = ES.ToTemporalDate(dateParam); return impl[GetSlot(this, CALENDAR_ID)].dayOfWeek(date); } dayOfYear(dateParam: Params['dayOfYear'][0]): Return['dayOfYear'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const date = ES.ToTemporalDate(dateParam); return impl[GetSlot(this, CALENDAR_ID)].dayOfYear(date); } weekOfYear(dateParam: Params['weekOfYear'][0]): Return['weekOfYear'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const date = ES.ToTemporalDate(dateParam); return impl[GetSlot(this, CALENDAR_ID)].weekOfYear(date); } daysInWeek(dateParam: Params['daysInWeek'][0]): Return['daysInWeek'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); const date = ES.ToTemporalDate(dateParam); return impl[GetSlot(this, CALENDAR_ID)].daysInWeek(date); } daysInMonth(dateParam: Params['daysInMonth'][0]): Return['daysInMonth'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].daysInMonth(date as Temporal.PlainDate | Temporal.PlainYearMonth); } daysInYear(dateParam: Params['daysInYear'][0]): Return['daysInYear'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].daysInYear(date as Temporal.PlainDate | Temporal.PlainYearMonth); } monthsInYear(dateParam: Params['monthsInYear'][0]): Return['monthsInYear'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].monthsInYear(date as Temporal.PlainDate | Temporal.PlainYearMonth); } inLeapYear(dateParam: Params['inLeapYear'][0]): Return['inLeapYear'] { let date = dateParam; if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); if (!ES.IsTemporalYearMonth(date)) date = ES.ToTemporalDate(date); return impl[GetSlot(this, CALENDAR_ID)].inLeapYear(date as Temporal.PlainDate | Temporal.PlainYearMonth); } toString(): string { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); return GetSlot(this, CALENDAR_ID); } toJSON(): Return['toJSON'] { if (!ES.IsTemporalCalendar(this)) throw new TypeError('invalid receiver'); return ES.ToString(this); } static from(item: Params['from'][0]): Return['from'] { return ES.ToTemporalCalendar(item); } [Symbol.toStringTag]!: 'Temporal.Calendar'; } MakeIntrinsicClass(Calendar, 'Temporal.Calendar'); DefineIntrinsic('Temporal.Calendar.from', Calendar.from); /** * Implementation for the ISO 8601 calendar. This is the only calendar that's * guaranteed to be supported by all ECMAScript implementations, including those * without Intl (ECMA-402) support. */ impl['iso8601'] = { dateFromFields(fieldsParam, options, calendar) { const overflow = ES.ToTemporalOverflow(options); let fields = ES.PrepareTemporalFields(fieldsParam, ['day', 'month', 'monthCode', 'year'], ['year', 'day']); fields = resolveNonLunisolarMonth(fields); let { year, month, day } = fields; ({ year, month, day } = ES.RegulateISODate(year, month, day, overflow)); return ES.CreateTemporalDate(year, month, day, calendar); }, yearMonthFromFields(fieldsParam, options, calendar) { const overflow = ES.ToTemporalOverflow(options); let fields = ES.PrepareTemporalFields(fieldsParam, ['month', 'monthCode', 'year'], ['year']); fields = resolveNonLunisolarMonth(fields); let { year, month } = fields; ({ year, month } = ES.RegulateISOYearMonth(year, month, overflow)); return ES.CreateTemporalYearMonth(year, month, calendar, /* referenceISODay = */ 1); }, monthDayFromFields(fieldsParam, options, calendar) { const overflow = ES.ToTemporalOverflow(options); let fields = ES.PrepareTemporalFields(fieldsParam, ['day', 'month', 'monthCode', 'year'], ['day']); if (fields.month !== undefined && fields.year === undefined && fields.monthCode === undefined) { throw new TypeError('either year or monthCode required with month'); } const useYear = fields.monthCode === undefined; const referenceISOYear = 1972; fields = resolveNonLunisolarMonth(fields); let { month, day, year } = fields; ({ month, day } = ES.RegulateISODate(useYear ? year : referenceISOYear, month, day, overflow)); return ES.CreateTemporalMonthDay(month, day, calendar, referenceISOYear); }, fields(fields) { return fields; }, mergeFields(fields, additionalFields) { const merged: typeof fields = {}; for (const nextKey of ObjectKeys(fields)) { if (nextKey === 'month' || nextKey === 'monthCode') continue; merged[nextKey] = fields[nextKey]; } const newKeys = ObjectKeys(additionalFields); for (const nextKey of newKeys) { merged[nextKey] = additionalFields[nextKey]; } if (!ArrayIncludes.call(newKeys, 'month') && !ArrayIncludes.call(newKeys, 'monthCode')) { const { month, monthCode } = fields; if (month !== undefined) merged.month = month; if (monthCode !== undefined) merged.monthCode = monthCode; } return merged; }, dateAdd(date, years, months, weeks, days, overflow, calendar) { let year = GetSlot(date, ISO_YEAR); let month = GetSlot(date, ISO_MONTH); let day = GetSlot(date, ISO_DAY); ({ year, month, day } = ES.AddISODate(year, month, day, years, months, weeks, days, overflow)); return ES.CreateTemporalDate(year, month, day, calendar); }, dateUntil(one, two, largestUnit) { return ES.DifferenceISODate( GetSlot(one, ISO_YEAR), GetSlot(one, ISO_MONTH), GetSlot(one, ISO_DAY), GetSlot(two, ISO_YEAR), GetSlot(two, ISO_MONTH), GetSlot(two, ISO_DAY), largestUnit ); }, year(date) { return GetSlot(date, ISO_YEAR); }, era() { return undefined; }, eraYear() { return undefined; }, month(date) { return GetSlot(date, ISO_MONTH); }, monthCode(date) { return buildMonthCode(GetSlot(date, ISO_MONTH)); }, day(date) { return GetSlot(date, ISO_DAY); }, dayOfWeek(date) { return ES.DayOfWeek(GetSlot(date, ISO_YEAR), GetSlot(date, ISO_MONTH), GetSlot(date, ISO_DAY)); }, dayOfYear(date) { return ES.DayOfYear(GetSlot(date, ISO_YEAR), GetSlot(date, ISO_MONTH), GetSlot(date, ISO_DAY)); }, weekOfYear(date) { return ES.WeekOfYear(GetSlot(date, ISO_YEAR), GetSlot(date, ISO_MONTH), GetSlot(date, ISO_DAY)); }, daysInWeek() { return 7; }, daysInMonth(date) { return ES.ISODaysInMonth(GetSlot(date, ISO_YEAR), GetSlot(date, ISO_MONTH)); }, daysInYear(dateParam) { let date = dateParam; if (!HasSlot(date, ISO_YEAR)) date = ES.ToTemporalDate(date); return ES.LeapYear(GetSlot(date, ISO_YEAR)) ? 366 : 365; }, monthsInYear() { return 12; }, inLeapYear(dateParam) { let date = dateParam; if (!HasSlot(date, ISO_YEAR)) date = ES.ToTemporalDate(date); return ES.LeapYear(GetSlot(date, ISO_YEAR)); } }; // Note: Built-in calendars other than iso8601 are not part of the Temporal // proposal for ECMA-262. These calendars will be standardized as part of // ECMA-402. Code below here includes an implementation of these calendars to // validate the Temporal API and to get feedback. However, native non-ISO // calendar behavior is at least somewhat implementation-defined, so may not // match this polyfill's output exactly. // // Some ES implementations don't include ECMA-402. For this reason, it's helpful // to ensure a clean separation between the ISO calendar implementation which is // a part of ECMA-262 and the non-ISO calendar implementation which requires // ECMA-402. // // To ensure this separation, the implementation is split. A `CalendarImpl` // interface powers both ISO and non-ISO calendars. That interface is extended // (as `NonIsoImpl`) with a `helper` property that implements logic that varies // between each non-ISO calendar. /** * Interface for non-ISO calendar implementations. The `helper` is an abstract * base class that's extended for each non-ISO calendar, e.g. `HebrewHelper`. */ interface NonIsoImpl extends CalendarImpl { helper: HelperBase; } /** * This type is passed through from Calendar#dateFromFields(). * `monthExtra` is additional information used internally to identify lunisolar leap months. */ type CalendarDateFields = Params['dateFromFields'][0] & { monthExtra?: string }; /** * This is a "fully populated" calendar date record. It's only lacking * `era`/`eraYear` (which may not be present in all calendars) and `monthExtra` * which is only used in some cases. */ type FullCalendarDate = { era?: string; eraYear?: number; year: number; month: number; monthCode: string; day: number; monthExtra?: string; }; // The types below are various subsets of calendar dates type CalendarYMD = { year: number; month: number; day: number }; type CalendarYM = { year: number; month: number }; type CalendarYearOnly = { year: number }; type EraAndEraYear = { era: string; eraYear: number }; /** Record representing YMD of an ISO calendar date */ type IsoYMD = { year: number; month: number; day: number }; type Overflow = NonNullable<Temporal.AssignmentOptions['overflow']>; function monthCodeNumberPart(monthCode: string) { if (!monthCode.startsWith('M')) { throw new RangeError(`Invalid month code: ${monthCode}. Month codes must start with M.`); } const month = +monthCode.slice(1); if (isNaN(month)) throw new RangeError(`Invalid month code: ${monthCode}`); return month; } function buildMonthCode(month: number | string, leap = false) { return `M${month.toString().padStart(2, '0')}${leap ? 'L' : ''}`; } /** * Safely merge a month, monthCode pair into an integer month. * If both are present, make sure they match. * This logic doesn't work for lunisolar calendars! * */ function resolveNonLunisolarMonth<T extends { monthCode?: string; month?: number }>( calendarDate: T, overflow: Overflow | undefined = undefined, monthsPerYear = 12 ) { let { month, monthCode } = calendarDate; if (monthCode === undefined) { if (month === undefined) throw new TypeError('Either month or monthCode are required'); // The ISO calendar uses the default (undefined) value because it does // constrain/reject after this method returns. Non-ISO calendars, however, // rely on this function to constrain/reject out-of-range `month` values. if (overflow === 'reject') ES.RejectToRange(month, 1, monthsPerYear); if (overflow === 'constrain') month = ES.ConstrainToRange(month, 1, monthsPerYear); monthCode = buildMonthCode(month); } else { const numberPart = monthCodeNumberPart(monthCode); if (month !== undefined && month !== numberPart) { throw new RangeError(`monthCode ${monthCode} and month ${month} must match if both are present`); } if (monthCode !== buildMonthCode(numberPart)) { throw new RangeError(`Invalid month code: ${monthCode}`); } month = numberPart; if (month < 1 || month > monthsPerYear) throw new RangeError(`Invalid monthCode: ${monthCode}`); } return { ...calendarDate, month, monthCode }; } type CachedTypes = Temporal.PlainYearMonth | Temporal.PlainDate | Temporal.PlainMonthDay; /** * This prototype implementation of non-ISO calendars makes many repeated calls * to Intl APIs which may be slow (e.g. >0.2ms). This trivial cache will speed * up these repeat accesses. Each cache instance is associated (via a WeakMap) * to a specific Temporal object, which speeds up multiple calendar calls on the * same Temporal object instance. No invalidation or pruning is necessary * because each object's cache is thrown away when the object is GC-ed. */ class OneObjectCache { map = new Map(); calls = 0; now: number; hits = 0; misses = 0; constructor(cacheToClone?: OneObjectCache) { this.now = globalThis.performance ? globalThis.performance.now() : Date.now(); if (cacheToClone !== undefined) { let i = 0; for (const entry of cacheToClone.map.entries()) { if (++i > OneObjectCache.MAX_CACHE_ENTRIES) break; this.map.set(...entry); } } } get(key: string) { const result = this.map.get(key); if (result) { this.hits++; this.report(); } this.calls++; return result; } set(key: string, value: unknown) { this.map.set(key, value); this.misses++; this.report(); } report() { /* if (this.calls === 0) return; const ms = (globalThis.performance ? globalThis.performance.now() : Date.now()) - this.now; const hitRate = ((100 * this.hits) / this.calls).toFixed(0); console.log(`${this.calls} calls in ${ms.toFixed(2)}ms. Hits: ${this.hits} (${hitRate}%). Misses: ${this.misses}.`); */ } setObject(obj: CachedTypes) { if (OneObjectCache.objectMap.get(obj)) throw new RangeError('object already cached'); OneObjectCache.objectMap.set(obj, this); this.report(); } static objectMap = new WeakMap(); static MAX_CACHE_ENTRIES = 1000; /** * Returns a WeakMap-backed cache that's used to store expensive results * that are associated with a particular Temporal object instance. * * @param obj - object to associate with the cache */ static getCacheForObject(obj: CachedTypes) { let cache = OneObjectCache.objectMap.get(obj); if (!cache) { cache = new OneObjectCache(); OneObjectCache.objectMap.set(obj, cache); } return cache; } } function toUtcIsoDateString({ isoYear, isoMonth, isoDay }: { isoYear: number; isoMonth: number; isoDay: number }) { const yearString = ES.ISOYearString(isoYear); const monthString = ES.ISODateTimePartString(isoMonth); const dayString = ES.ISODateTimePartString(isoDay); return `${yearString}-${monthString}-${dayString}T00:00Z`; } function simpleDateDiff(one: CalendarYMD, two: CalendarYMD) { return { years: one.year - two.year, months: one.month - two.month, days: one.day - two.day }; } /** * Implementation helper that's common to all non-ISO calendars */ abstract class HelperBase { abstract id: BuiltinCalendarId; abstract monthsInYear(calendarDate: CalendarYearOnly, cache?: OneObjectCache): number; abstract maximumMonthLength(calendarDate?: CalendarYM): number; abstract minimumMonthLength(calendarDate?: CalendarYM): number; abstract estimateIsoDate(calendarDate: CalendarYMD): IsoYMD; abstract inLeapYear(calendarDate: CalendarYearOnly, cache?: OneObjectCache): boolean; abstract calendarType: 'solar' | 'lunar' | 'lunisolar'; reviseIntlEra?<T extends Partial<EraAndEraYear>>(calendarDate: T, isoDate: IsoYMD): T; constantEra?: string; checkIcuBugs?(isoDate: IsoYMD): void; private formatter?: globalThis.Intl.DateTimeFormat; getFormatter() { // `new Intl.DateTimeFormat()` is amazingly slow and chews up RAM. Per // https://bugs.chromium.org/p/v8/issues/detail?id=6528#c4, we cache one // DateTimeFormat instance per calendar. Caching is lazy so we only pay for // calendars that are used. Note that the nonIsoHelperBase object is spread // into each each calendar's implementation before any cache is created, so // each calendar gets its own separate cached formatter. if (typeof this.formatter === 'undefined') { this.formatter = new IntlDateTimeFormat(`en-US-u-ca-${this.id}`, { day: 'numeric', month: 'numeric', year: 'numeric', era: this.eraLength, timeZone: 'UTC' }); } return this.formatter; } isoToCalendarDate(isoDate: IsoYMD, cache: OneObjectCache): FullCalendarDate { const { year: isoYear, month: isoMonth, day: isoDay } = isoDate; const key = JSON.stringify({ func: 'isoToCalendarDate', isoYear, isoMonth, isoDay, id: this.id }); const cached = cache.get(key); if (cached) return cached; const dateTimeFormat = this.getFormatter(); let parts, isoString; try { isoString = toUtcIsoDateString({ isoYear, isoMonth, isoDay }); parts = dateTimeFormat.formatToParts(new Date(isoString)); } catch (e: unknown) { throw new RangeError(`Invalid ISO date: ${JSON.stringify({ isoYear, isoMonth, isoDay })}`); } const result: Partial<FullCalendarDate> = {}; for (let { type, value } of parts) { if (type === 'year') result.eraYear = +value; // TODO: remove this type annotation when `relatedYear` gets into TS lib types if (type === ('relatedYear' as Intl.DateTimeFormatPartTypes)) result.eraYear = +value; if (type === 'month') { const matches = /^([0-9]*)(.*?)$/.exec(value); if (!matches || matches.length != 3 || (!matches[1] && !matches[2])) { throw new RangeError(`Unexpected month: ${value}`); } // If the month has no numeric part (should only see this for the Hebrew // calendar with newer FF / Chromium versions; see // https://bugzilla.mozilla.org/show_bug.cgi?id=1751833) then set a // placeholder month index of `1` and rely on the derived class to // calculate the correct month index from the month name stored in // `monthExtra`. result.month = matches[1] ? +matches[1] : 1; if (result.month < 1) { throw new RangeError( `Invalid month ${value} from ${isoString}[u-ca-${this.id}]` + ' (probably due to https://bugs.chromium.org/p/v8/issues/detail?id=10527)' ); } if (result.month > 13) { throw new RangeError( `Invalid month ${value} from ${isoString}[u-ca-${this.id}]` + ' (probably due to https://bugs.chromium.org/p/v8/issues/detail?id=10529)' ); } // The ICU formats for the Hebrew calendar no longer support a numeric // month format. So we'll rely on the derived class to interpret it. // `monthExtra` is also used on the Chinese calendar to handle a suffix // "bis" indicating a leap month. if (matches[2]) result.monthExtra = matches[2]; } if (type === 'day') result.day = +value; if (this.hasEra && type === 'era' && value != null && value !== '') { // The convention for Temporal era values is lowercase, so following // that convention in this prototype. Punctuation is removed, accented // letters are normalized, and spaces are replaced with dashes. // E.g.: "ERA0" => "era0", "Before R.O.C." => "before-roc", "En’ō" => "eno" // The call to normalize() and the replacement regex deals with era // names that contain non-ASCII characters like Japanese eras. Also // ignore extra content in parentheses like JPN era date ranges. value = value.split(' (')[0]; result.era = value .normalize('NFD') .replace(/[^-0-9 \p{L}]/gu, '') .replace(' ', '-') .toLowerCase(); } } if (result.eraYear === undefined) { // Node 12 has outdated ICU data that lacks the `relatedYear` field in the // output of Intl.DateTimeFormat.formatToParts. throw new RangeError( `Intl.DateTimeFormat.formatToParts lacks relatedYear in ${this.id} calendar. Try Node 14+ or modern browsers.` ); } // Translate eras that may be handled differently by Temporal vs. by Intl // (e.g. Japanese pre-Meiji eras). See https://github.com/tc39/proposal-temporal/issues/526. if (this.reviseIntlEra) { const { era, eraYear } = this.reviseIntlEra(result, isoDate); result.era = era; result.eraYear = eraYear; } if (this.checkIcuBugs) this.checkIcuBugs(isoDate); const calendarDate = this.adjustCalendarDate(result, cache, 'constrain', true); if (calendarDate.year === undefined) throw new RangeError(`Missing year converting ${JSON.stringify(isoDate)}`); if (calendarDate.month === undefined) throw new RangeError(`Missing month converting ${JSON.stringify(isoDate)}`); if (calendarDate.day === undefined) throw new RangeError(`Missing day converting ${JSON.stringify(isoDate)}`); cache.set(key, calendarDate); // Also cache the reverse mapping ['constrain', 'reject'].forEach((overflow) => { const keyReverse = JSON.stringify({ func: 'calendarToIsoDate', year: calendarDate.year, month: calendarDate.month, day: calendarDate.day, overflow, id: this.id }); cache.set(keyReverse, isoDate); }); return calendarDate; } validateCalendarDate(calendarDate: Partial<FullCalendarDate>): asserts calendarDate is FullCalendarDate { const { era, month, year, day, eraYear, monthCode, monthExtra } = calendarDate as Partial<FullCalendarDate>; // When there's a suffix (e.g. "5bis" for a leap month in Chinese calendar) // the derived class must deal with it. if (monthExtra !== undefined) throw new RangeError('Unexpected `monthExtra` value'); if (year === undefined && eraYear === undefined) throw new TypeError('year or eraYear is required'); if (month === undefined && monthCode === undefined) throw new TypeError('month or monthCode is required'); if (day === undefined) throw new RangeError('Missing day'); if (monthCode !== undefined) { if (typeof monthCode !== 'string') { throw new RangeError(`monthCode must be a string, not ${typeof monthCode}`); } if (!/^M([01]?\d)(L?)$/.test(monthCode)) throw new RangeError(`Invalid monthCode: ${monthCode}`); } if (this.constantEra) { if (era !== undefined && era !== this.constantEra) { throw new RangeError(`era must be ${this.constantEra}, not ${era}`); } if (eraYear !== undefined && year !== undefined && eraYear !== year) { throw new RangeError(`eraYear ${eraYear} does not match year ${year}`); } } } /** * Allows derived calendars to add additional fields and/or to make * adjustments e.g. to set the era based on the date or to revise the month * number in lunisolar calendars per * https://github.com/tc39/proposal-temporal/issues/1203. * * The base implementation fills in missing values by assuming the simplest * possible calendar: * - no eras or a constant era defined in `.constantEra` * - non-lunisolar calendar (no leap months) * */ adjustCalendarDate( calendarDateParam: Partial<FullCalendarDate>, cache: OneObjectCache | undefined = undefined, overflow: Overflow = 'constrain', // This param is only used by derived classes // eslint-disable-next-line @typescript-eslint/no-unused-vars fromLegacyDate = false ): FullCalendarDate { if (this.calendarType === 'lunisolar') throw new RangeError('Override required for lunisolar calendars'); let calendarDate = calendarDateParam; this.validateCalendarDate(calendarDate); // For calendars that always use the same era, set it here so that derived // calendars won't need to implement this method simply to set the era. if (this.constantEra) { // year and eraYear always match when there's only one possible era const { year, eraYear } = calendarDate; calendarDate = { ...calendarDate, era: this.constantEra, year: year !== undefined ? year : eraYear, eraYear: eraYear !== undefined ? eraYear : year }; } const largestMonth = this.monthsInYear(calendarDate as CalendarYearOnly, cache); let { month, monthCode } = calendarDate; ({ month, monthCode } = resolveNonLunisolarMonth(calendarDate, overflow, largestMonth)); return { ...(calendarDate as typeof calendarDate & CalendarYMD), month, monthCode }; } regulateMonthDayNaive(calendarDate: FullCalendarDate, overflow: Overflow, cache: OneObjectCache): FullCalendarDate { const largestMonth = this.monthsInYear(calendarDate, cache); let { month, day } = calendarDate; if (overflow === 'reject') { ES.RejectToRange(month, 1, largestMonth); ES.RejectToRange(day, 1, this.maximumMonthLength(calendarDate)); } else { month = ES.ConstrainToRange(month, 1, largestMonth); day = ES.ConstrainToRange(day, 1, this.maximumMonthLength({ ...calendarDate, month })); } return { ...calendarDate, month, day }; } calendarToIsoDate(dateParam: CalendarDateFields, overflow: Overflow = 'constrain', cache: OneObjectCache): IsoYMD { const originalDate = dateParam as Partial<FullCalendarDate>; // First, normalize the calendar date to ensure that (year, month, day) // are all present, converting monthCode and eraYear if needed. let date = this.adjustCalendarDate(dateParam, cache, overflow, false); // Fix obviously out-of-bounds values. Values that are valid generally, but // not in this particular year, may not be caught here for some calendars. // If so, these will be handled lower below. date = this.regulateMonthDayNaive(date, overflow, cache); const { year, month, day } = date; const key = JSON.stringify({ func: 'calendarToIsoDate', year, month, day, overflow, id: this.id }); let cached = cache.get(key); if (cached) return cached; // If YMD are present in the input but the input has been constrained // already, then cache both the original value and the constrained value. let keyOriginal; if ( originalDate.year !== undefined && originalDate.month !== undefined && originalDate.day !== undefined && (originalDate.year !== date.year || originalDate.month !== date.month || originalDate.day !== date.day) ) { keyOriginal = JSON.stringify({ func: 'calendarToIsoDate', year: originalDate.year, month: originalDate.month, day: originalDate.day, overflow, id: this.id }); cached = cache.get(keyOriginal); if (cached) return cached; } // First, try to roughly guess the result let isoEstimate = this.estimateIsoDate({ year, month, day }); const calculateSameMonthResult = (diffDays: number) => { // If the estimate is in the same year & month as the target, then we can // calculate the result exactly and short-circuit any additional logic. // This optimization assumes that months are continuous. It would break if // a calendar skipped days, like the Julian->Gregorian switchover. But the // only ICU calendars that currently skip days (japanese/roc/buddhist) is // a bug (https://bugs.chromium.org/p/chromium/issues/detail?id=1173158) // that's currently detected by `checkIcuBugs()` which will throw. So // this optimization should be safe for all ICU calendars. let testIsoEstimate = this.addDaysIso(isoEstimate, diffDays); if (date.day > this.minimumMonthLength(date)) { // There's a chance that the calendar date is out of range. Throw or // constrain if so. let testCalendarDate = this.isoToCalendarDate(testIsoEstimate, cache); while (testCalendarDate.month !== month || testCalendarDate.year !== year) { if (overflow === 'reject') { throw new RangeError(`day ${day} does not exist in month ${month} of year ${year}`); } // Back up a day at a time until we're not hanging over the month end testIsoEstimate = this.addDaysIso(testIsoEstimate, -1); testCalendarDate = this.isoToCalendarDate(testIsoEstimate, cache); } } return testIsoEstimate; }; let sign = 0; let roundtripEstimate = this.isoToCalendarDate(isoEstimate, cache); let diff = simpleDateDiff(date, roundtripEstimate); if (diff.years !== 0 || diff.months !== 0 || diff.days !== 0) { const diffTotalDaysEstimate = diff.years * 365 + diff.months * 30 + diff.days; isoEstimate = this.addDaysIso(isoEstimate, diffTotalDaysEstimate); roundtripEstimate = this.isoToCalendarDate(isoEstimate, cache); diff = simpleDateDiff(date, roundtripEstimate); if (diff.years === 0 && diff.months === 0) { isoEstimate = calculateSameMonthResult(diff.days); } else { sign = this.compareCalendarDates(date, roundtripEstimate); } } // If the initial guess is not in the same month, then then bisect the // distance to the target, starting with 8 days per step. let increment = 8; let maybeConstrained = false; while (sign) { isoEstimate = this.addDaysIso(isoEstimate, sign * increment); const oldRoundtripEstimate = roundtripEstimate; roundtripEstimate = this.isoToCalendarDate(isoEstimate, cache); const oldSign = sign; sign = this.compareCalendarDates(date, roundtripEstimate); if (sign) { diff = simpleDateDiff(date, roundtripEstimate); if (diff.years === 0 && diff.months === 0) { isoEstimate = calculateSameMonthResult(diff.days); // Signal the loop condition that there's a match. sign = 0; // If the calendar day is larger than the minimal length for this // month, then it might be larger than the actual length of the month. // So we won't cache it as the correct calendar date for this ISO // date. maybeConstrained = date.day > this.minimumMonthLength(date); } else if (oldSign && sign !== oldSign) { if (increment > 1) { // If the estimate overshot the target, try again with a smaller increment // in the reverse direction. increment /= 2; } else { // Increment is 1, and neither the previous estimate nor the new // estimate is correct. The only way that can happen is if the // original date was an invalid value that will be constrained or // rejected here. if (overflow === 'reject') { throw new RangeError(`Can't find ISO date from calendar date: ${JSON.stringify({ ...originalDate })}`); } else { // To constrain, pick the earliest value const order = this.compareCalendarDates(roundtripEstimate, oldRoundtripEstimate); // If current value is larger, then back up to the previous value. if (order > 0) isoEstimate = this.addDaysIso(isoEstimate, -1); maybeConstrained = true; sign = 0; } } } } } cache.set(key, isoEstimate); if (keyOriginal) cache.set(keyOriginal, isoEstimate); if ( date.year === undefined || date.month === undefined || date.day === undefined || date.monthCode === undefined || (this.hasEra && (date.era === undefined || date.eraYear === undefined)) ) { throw new RangeError('Unexpected missing property'); } if (!maybeConstrained) { // Also cache the reverse mapping const keyReverse = JSON.stringify({ func: 'isoToCalendarDate', isoYear: isoEstimate.year, isoMonth: isoEstimate.month, isoDay: isoEstimate.day, id: this.id }); cache.set(keyReverse, date); } return isoEstimate; } temporalToCalendarDate( date: Temporal.PlainDate | Temporal.PlainMonthDay | Temporal.PlainYearMonth, cache: OneObjectCache ): FullCalendarDate { const isoDate = { year: GetSlot(date, ISO_YEAR), month: GetSlot(date, ISO_MONTH), day: GetSlot(date, ISO_DAY) }; const result = this.isoToCalendarDate(isoDate, cache); return result; } compareCalendarDates(date1Param: Partial<CalendarYMD>, date2Param: Partial<CalendarYMD>): 0 | 1 | -1 { // `date1` and `date2` are already records. The calls below simply validate // that all three required fields are present. const date1 = ES.PrepareTemporalFields(date1Param, ['day', 'month', 'year'], ['day', 'month', 'year']); const date2 = ES.PrepareTemporalFields(date2Param, ['day', 'month', 'year'], ['day', 'month', 'year']); if (date1.year !== date2.year) return ES.ComparisonResult(date1.year - date2.year); if (date1.month !== date2.month) return ES.ComparisonResult(date1.month - date2.month); if (date1.day !== date2.day) return ES.ComparisonResult(date1.day - date2.day); return 0; } /** Ensure that a calendar date actually exists. If not, return the closest earlier date. */ regulateDate(calendarDate: CalendarYMD, overflow: Overflow = 'constrain', cache: OneObjectCache): FullCalendarDate { const isoDate = this.calendarToIsoDate(calendarDate, overflow, cache); return this.isoToCalendarDate(isoDate, cache); } addDaysIso(isoDate: IsoYMD, days: number): IsoYMD { const added = ES.AddISODate(isoDate.year, isoDate.month, isoDate.day, 0, 0, 0, days, 'constrain'); return added; } addDaysCalendar(calendarDate: CalendarYMD, days: number, cache: OneObjectCache): FullCalendarDate { const isoDate = this.calendarToIsoDate(calendarDate, 'constrain', cache); const addedIso = this.addDaysIso(isoDate, days); const addedCalendar = this.isoToCalendarDate(addedIso, cache); return addedCalendar; } addMonthsCalendar( calendarDateParam: CalendarYMD, months: number, overflow: Overflow, cache: OneObjectCache ): CalendarYMD { let calendarDate = calendarDateParam; const { day } = calendarDate; for (let i = 0, absMonths = MathAbs(months); i < absMonths; i++) { const { month } = calendarDate; const oldCalendarDate = calendarDate; const days = months < 0 ? -Math.max(day, this.daysInPreviousMonth(calendarDate, cache)) : this.daysInMonth(calendarDate, cache); const isoDate = this.calendarToIsoDate(calendarDate, 'constrain', cache); let addedIso = this.addDaysIso(isoDate, days); calendarDate = this.isoToCalendarDate(addedIso, cache); // Normally, we can advance one month by adding the number of days in the // current month. However, if we're at the end of the current month and // the next month has fewer days, then we rolled over to the after-next // month. Below we detect this condition and back up until we're back in // the desired month. if (months > 0) { const monthsInOldYear = this.monthsInYear(oldCalendarDate, cache); while (calendarDate.month - 1 !== month % monthsInOldYear) { addedIso = this.addDaysIso(addedIso, -1); calendarDate = this.isoToCalendarDate(addedIso, cache); } } if (calendarDate.day !== day) { // try to retain the original day-of-month, if possible calendarDate = this.regulateDate({ ...calendarDate, day }, 'constrain', cache); } } if (overflow === 'reject' && calendarDate.day !== day) { throw new RangeError(`Day ${day} does not exist in resulting calendar month`); } return calendarDate; } addCalendar( calendarDate: CalendarYMD & { monthCode: string }, { years = 0, months = 0, weeks = 0, days = 0 }, overflow: Overflow, cache: OneObjectCache ): FullCalendarDate { const { year, day, monthCode } = calendarDate; const addedYears = this.adjustCalendarDate({ year: year + years, monthCode, day }, cache); const addedMonths = this.addMonthsCalendar(addedYears, months, overflow, cache); const initialDays = days + weeks * 7; const addedDays = this.addDaysCalendar(addedMonths, initialDays, cache); return addedDays; } untilCalendar( calendarOne: FullCalendarDate, calendarTwo: FullCalendarDate, largestUnit: Temporal.DateUnit, cache: OneObjectCache ): { years: number; months: number; weeks: number; days: number } { let days = 0; let weeks = 0; let months = 0; let years = 0; switch (largestUnit) { case 'day': days = this.calendarDaysUntil(calendarOne, calendarTwo, cache); break; case 'week': { const totalDays = this.calendarDaysUntil(calendarOne, calendarTwo, cache); days = totalDays % 7; weeks = (totalDays - days) / 7; break; } case 'month': case 'year': { const diffYears = calendarTwo.year - calendarOne.year; const diffMonths = calendarTwo.month - calendarOne.month; const diffDays = calendarTwo.day - calendarOne.day; const sign = this.compareCalendarDates(calendarTwo, calendarOne); if (!sign) { return { years: 0, months: 0, weeks: 0, days: 0 }; } if (largestUnit === 'year' && diffYears) { const isOneFurtherInYear = diffMonths * sign < 0 || (diffMonths === 0 && diffDays * sign < 0); years = isOneFurtherInYear ? diffYears - sign : diffYears; } const yearsAdded = years ? this.addCalendar(calendarOne, { years }, 'constrain', cache) : calendarOne; // Now we have less than one year remaining. Add one month at a time // until we go over the target, then back up one month and calculate // remaining days and weeks. let current; let next: CalendarYMD = yearsAdded; do { months += sign; current = next; next = this.addMonthsCalendar(current, sign, 'constrain', cache); if (next.day !== calendarOne.day) { // In case the day was constrained down, try to un-constrain it next = this.regulateDate({ ...next, day: calendarOne.day }, 'constrain', cache); } } while (this.compareCalendarDates(calendarTwo, next) * sign >= 0); months -= sign; // correct for loop above which overshoots by 1 const remainingDays = this.calendarDaysUntil(current, calendarTwo, cache); days = remainingDays; break; } } return { years, months, weeks, days }; } daysInMonth(calendarDate: CalendarYMD, cache: OneObjectCache): number { // Add enough days to roll over to the next month. One we're in the next // month, we can calculate th