Skip to content

Commit

Permalink
Polyfill: Refactor time zone identifier handling
Browse files Browse the repository at this point in the history
  • Loading branch information
justingrant committed Jun 12, 2023
1 parent f487252 commit 1267751
Show file tree
Hide file tree
Showing 6 changed files with 2,113 additions and 25 deletions.
100 changes: 90 additions & 10 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const ArrayIncludes = Array.prototype.includes;
const ArrayPrototypePush = Array.prototype.push;
const ArrayPrototypeSort = Array.prototype.sort;
const IntlDateTimeFormat = globalThis.Intl.DateTimeFormat;
const IntlSupportedValuesOf = globalThis.Intl.supportedValuesOf;
const MathAbs = Math.abs;
const MathFloor = Math.floor;
const MathMax = Math.max;
Expand Down Expand Up @@ -368,7 +369,9 @@ export function ParseTemporalTimeZone(stringIdent) {
const { tzName, offset, z } = ParseTemporalTimeZoneString(stringIdent);
if (tzName) {
if (IsTimeZoneOffsetString(tzName)) return CanonicalizeTimeZoneOffsetString(tzName);
return GetCanonicalTimeZoneIdentifier(tzName);
const record = GetAvailableNamedTimeZoneIdentifier(tzName);
if (!record) throw new RangeError(`Unrecognized time zone ${tzName}`);
return record.primaryIdentifier;
}
if (z) return 'UTC';
// if !tzName && !z then offset must be present
Expand Down Expand Up @@ -2589,15 +2592,92 @@ export function ParseTimeZoneOffsetString(string) {
return sign * (((hours * 60 + minutes) * 60 + seconds) * 1e9 + nanoseconds);
}

// In the spec, GetCanonicalTimeZoneIdentifier is infallible and is always
// preceded by a call to IsAvailableTimeZoneName. However in the polyfill,
// we don't (yet) have a way to check if a time zone ID is valid without
// also canonicalizing it. So we combine both operations into one function,
// which will return the canonical ID if the ID is valid, and will throw
// if it's not.
export function GetCanonicalTimeZoneIdentifier(timeZoneIdentifier) {
const formatter = getIntlDateTimeFormatEnUsForTimeZone(timeZoneIdentifier);
return formatter.resolvedOptions().timeZone;
let canonicalTimeZoneIdsCache = undefined;

export function GetAvailableNamedTimeZoneIdentifier(identifier) {
// TODO: should there be an assertion here that IsTimeZoneOffsetString returns false?
if (IsTimeZoneOffsetString(identifier)) return undefined;

// The most common case is when the identifier is a canonical time zone ID.
// Fast-path that case by caching a list of canonical IDs. If this is an old
// ECMAScript implementation that doesn't have this API, we'll set the cache
// to `null` so we won't bother trying again.
if (canonicalTimeZoneIdsCache === undefined) {
const canonicalTimeZoneIds = IntlSupportedValuesOf?.('timeZone') ?? null;
if (canonicalTimeZoneIds) {
const entries = canonicalTimeZoneIds.map((id) => [ASCIILowercase(id), id]);
canonicalTimeZoneIdsCache = new Map(entries);
}
}

const lower = ASCIILowercase(identifier);
let primaryIdentifier = canonicalTimeZoneIdsCache?.get(lower);
if (primaryIdentifier) return { identifier: primaryIdentifier, primaryIdentifier };

// It's not already a primary identifier, so get its primary identifier (or
// return if it's not an available named time zone ID).
try {
const formatter = getIntlDateTimeFormatEnUsForTimeZone(identifier);
primaryIdentifier = formatter.resolvedOptions().timeZone;
} catch {
return undefined;
}

// The identifier is an alias (a deprecated identifier that's a synonym for
// a primary identifier), so we need to case-normalize the identifier to
// match the IANA TZDB, e.g. america/new_york => America/New_York. There's
// no built-in way to do this using Intl.DateTimeFormat, but the we can
// normalize almost all aliases (modulo a few special cases) using the
// TZDB's basic capitalization pattern:
// 1. capitalize the first letter of the identifier
// 2. capitalize the letter after every slash, dash, or underscore delimiter
const standardCase = [...lower]
.map((c, i) => (i === 0 || '/-_'.includes(lower[i - 1]) ? c.toUpperCase() : c))
.join('');
const segments = standardCase.split('/');

if (segments.length === 1) {
// For single-segment legacy IDs, if it's 2-3 chars or contains a number or dash, then
// (except for the "GB-Eire" special case) the case-normalized form is all uppercase.
// GMT+0, GMT-0, ACT, LHI, NSW, GB, NZ, PRC, ROC, ROK, UCT, GMT, GMT0,
// CET, CST6CDT, EET, EST, HST, MET, MST, MST7MDT, PST8PDT, WET, NZ-CHAT, W-SU
// Otherwise it's the standard form: first letter capitalized, e.g. Iran, Egypt, Hongkong
if (lower === 'gb-eire') return { identifier: 'GB-Eire', primaryIdentifier };
return {
identifier: lower.length <= 3 || /[-0-9]/.test(lower) ? lower.toUpperCase() : segments[0],
primaryIdentifier
};
}

// All Etc zone names are upper case except a few exceptions.
if (segments[0] === 'Etc') {
const etcName = ['Zulu', 'Greenwich', 'Universal'].includes(segments[1]) ? segments[1] : segments[1].toUpperCase();
return { identifier: `Etc/${etcName}`, primaryIdentifier };
}

// Legacy US identifiers like US/Alaska or US/Indiana-Starke. They're always 2 segments and use standard case.
if (segments[0] === 'Us') return { identifier: `US/${segments[1]}`, primaryIdentifier };

// For multi-segment IDs, there's a few special cases in the second/third segments
const specialCases = {
Act: 'ACT',
Lhi: 'LHI',
Nsw: 'NSW',
Dar_Es_Salaam: 'Dar_es_Salaam',
Port_Of_Spain: 'Port_of_Spain',
Isle_Of_Man: 'Isle_of_Man',
Comodrivadavia: 'ComodRivadavia',
Knox_In: 'Knox_IN',
Dumontdurville: 'DumontDUrville',
Mcmurdo: 'McMurdo',
Denoronha: 'DeNoronha',
Easterisland: 'EasterIsland',
Bajanorte: 'BajaNorte',
Bajasur: 'BajaSur'
};
segments[1] = specialCases[segments[1]] ?? segments[1];
if (segments.length > 2) segments[2] = specialCases[segments[2]] ?? segments[2];
return { identifier: segments.join('/'), primaryIdentifier };
}

export function GetNamedTimeZoneOffsetNanoseconds(id, epochNanoseconds) {
Expand Down
38 changes: 30 additions & 8 deletions polyfill/lib/intl.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ const TIME = Symbol('time');
const DATETIME = Symbol('datetime');
const INST = Symbol('instant');
const ORIGINAL = Symbol('original');
const TZ_RESOLVED = Symbol('timezone');
const TZ_CANONICAL = Symbol('timezone-canonical');
const TZ_ORIGINAL = Symbol('timezone-original');
const CAL_ID = Symbol('calendar-id');
const LOCALE = Symbol('locale');
const OPTIONS = Symbol('options');
Expand Down Expand Up @@ -81,14 +82,33 @@ export function DateTimeFormat(locale = undefined, options = undefined) {

this[LOCALE] = ro.locale;
this[ORIGINAL] = original;
this[TZ_RESOLVED] = ro.timeZone;
this[TZ_CANONICAL] = ro.timeZone;
this[CAL_ID] = ro.calendar;
this[DATE] = dateAmend;
this[YM] = yearMonthAmend;
this[MD] = monthDayAmend;
this[TIME] = timeAmend;
this[DATETIME] = datetimeAmend;
this[INST] = instantAmend;

// Save the original time zone, for a few reasons:
// - Clearer error messages
// - More clearly follows the spec for InitializeDateTimeFormat
// - Because it follows the spec more closely, will make it easier to integrate
// support of offset strings and other potential changes like proposal-canonical-tz.
const timeZoneOption = hasOptions ? options.timeZone : undefined;
if (timeZoneOption === undefined) {
this[TZ_ORIGINAL] = ro.timeZone;
} else {
const id = ES.ToString(timeZoneOption);
if (ES.IsTimeZoneOffsetString(id)) {
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
throw new RangeError('Intl.DateTimeFormat does not currently support offset time zones');
}
const record = ES.GetAvailableNamedTimeZoneIdentifier(id);
if (!record) throw new RangeError(`Intl.DateTimeFormat formats built-in time zones, not ${id}`);
this[TZ_ORIGINAL] = record.identifier;
}
}

DateTimeFormat.supportedLocalesOf = function (...args) {
Expand Down Expand Up @@ -118,7 +138,9 @@ Object.defineProperty(DateTimeFormat, 'prototype', {
});

function resolvedOptions() {
return this[ORIGINAL].resolvedOptions();
const resolved = this[ORIGINAL].resolvedOptions();
resolved.timeZone = this[TZ_CANONICAL];
return resolved;
}

function format(datetime, ...rest) {
Expand Down Expand Up @@ -335,7 +357,7 @@ function extractOverrides(temporalObj, main) {
const nanosecond = GetSlot(temporalObj, ISO_NANOSECOND);
const datetime = new DateTime(1970, 1, 1, hour, minute, second, millisecond, microsecond, nanosecond, main[CAL_ID]);
return {
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
formatter: getPropLazy(main, TIME)
};
}
Expand All @@ -352,7 +374,7 @@ function extractOverrides(temporalObj, main) {
}
const datetime = new DateTime(isoYear, isoMonth, referenceISODay, 12, 0, 0, 0, 0, 0, calendar);
return {
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
formatter: getPropLazy(main, YM)
};
}
Expand All @@ -369,7 +391,7 @@ function extractOverrides(temporalObj, main) {
}
const datetime = new DateTime(referenceISOYear, isoMonth, isoDay, 12, 0, 0, 0, 0, 0, calendar);
return {
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
formatter: getPropLazy(main, MD)
};
}
Expand All @@ -384,7 +406,7 @@ function extractOverrides(temporalObj, main) {
}
const datetime = new DateTime(isoYear, isoMonth, isoDay, 12, 0, 0, 0, 0, 0, main[CAL_ID]);
return {
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
formatter: getPropLazy(main, DATE)
};
}
Expand Down Expand Up @@ -421,7 +443,7 @@ function extractOverrides(temporalObj, main) {
);
}
return {
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
formatter: getPropLazy(main, DATETIME)
};
}
Expand Down
5 changes: 3 additions & 2 deletions polyfill/lib/timezone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ export class TimeZone {
if (arguments.length < 1) {
throw new RangeError('missing argument: identifier is required');
}

let stringIdentifier = ES.ToString(identifier);
if (ES.IsTimeZoneOffsetString(stringIdentifier)) {
stringIdentifier = ES.CanonicalizeTimeZoneOffsetString(stringIdentifier);
} else {
stringIdentifier = ES.GetCanonicalTimeZoneIdentifier(stringIdentifier);
const record = ES.GetAvailableNamedTimeZoneIdentifier(stringIdentifier);
if (!record) throw new RangeError(`Invalid time zone identifier: ${stringIdentifier}`);
stringIdentifier = record.primaryIdentifier;
}
CreateSlots(this);
SetSlot(this, TIMEZONE_ID, stringIdentifier);
Expand Down
12 changes: 7 additions & 5 deletions polyfill/lib/zoneddatetime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -471,13 +471,15 @@ export class ZonedDateTime {
// The rest of the defaults will be filled in by formatting the Instant
}

let timeZone = ES.ToTemporalTimeZoneIdentifier(GetSlot(this, TIME_ZONE));
if (ES.IsTimeZoneOffsetString(timeZone)) {
const timeZoneIdentifier = ES.ToTemporalTimeZoneIdentifier(GetSlot(this, TIME_ZONE));
if (ES.IsTimeZoneOffsetString(timeZoneIdentifier)) {
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
throw new RangeError('toLocaleString does not support offset string time zones');
throw new RangeError('toLocaleString does not currently support offset time zones');
} else {
const record = ES.GetAvailableNamedTimeZoneIdentifier(timeZoneIdentifier);
if (!record) throw new RangeError(`toLocaleString formats built-in time zones, not ${timeZoneIdentifier}`);
optionsCopy.timeZone = record.primaryIdentifier;
}
timeZone = ES.GetCanonicalTimeZoneIdentifier(timeZone);
optionsCopy.timeZone = timeZone;

const formatter = new DateTimeFormat(locales, optionsCopy);

Expand Down
Loading

0 comments on commit 1267751

Please sign in to comment.