Skip to content

Commit de702fb

Browse files
committed
Polyfill: Refactor time zone identifier handling
1 parent 990d0bd commit de702fb

File tree

6 files changed

+763
-25
lines changed

6 files changed

+763
-25
lines changed

polyfill/lib/ecmascript.mjs

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -357,13 +357,22 @@ export function RejectTemporalLikeObject(item) {
357357
}
358358
}
359359

360+
export function CanonicalizeTimeZoneOffsetString(identifier) {
361+
const offsetNs = ParseTimeZoneOffsetString(identifier);
362+
return FormatTimeZoneOffsetString(offsetNs);
363+
}
364+
360365
export function ParseTemporalTimeZone(stringIdent) {
361366
const { ianaName, offset, z } = ParseTemporalTimeZoneString(stringIdent);
362-
if (ianaName) return GetCanonicalTimeZoneIdentifier(ianaName);
367+
if (ianaName) {
368+
if (IsTimeZoneOffsetString(ianaName)) return CanonicalizeTimeZoneOffsetString(ianaName);
369+
const record = GetAvailableNamedTimeZoneIdentifier(ianaName);
370+
if (!record) throw new RangeError(`Unrecognized time zone ${ianaName}`);
371+
return record.primaryIdentifier;
372+
}
363373
if (z) return 'UTC';
364374
// if !ianaName && !z then offset must be present
365-
const offsetNs = ParseTimeZoneOffsetString(offset);
366-
return FormatTimeZoneOffsetString(offsetNs);
375+
return CanonicalizeTimeZoneOffsetString(offset);
367376
}
368377

369378
export function MaybeFormatCalendarAnnotation(calendar, showCalendar) {
@@ -2585,13 +2594,76 @@ export function ParseTimeZoneOffsetString(string) {
25852594
return sign * (((hours * 60 + minutes) * 60 + seconds) * 1e9 + nanoseconds);
25862595
}
25872596

2588-
export function GetCanonicalTimeZoneIdentifier(timeZoneIdentifier) {
2589-
if (IsTimeZoneOffsetString(timeZoneIdentifier)) {
2590-
const offsetNs = ParseTimeZoneOffsetString(timeZoneIdentifier);
2591-
return FormatTimeZoneOffsetString(offsetNs);
2597+
export function GetAvailableNamedTimeZoneIdentifier(identifier) {
2598+
// TODO: should there be an assertion here that IsTimeZoneOffsetString returns false?
2599+
if (IsTimeZoneOffsetString(identifier)) return undefined;
2600+
2601+
let primaryIdentifier;
2602+
try {
2603+
const formatter = getIntlDateTimeFormatEnUsForTimeZone(String(identifier));
2604+
primaryIdentifier = formatter.resolvedOptions().timeZone;
2605+
} catch {
2606+
return undefined;
2607+
}
2608+
2609+
const lower = ASCIILowercase(identifier);
2610+
if (ASCIILowercase(primaryIdentifier) === lower) return { identifier: primaryIdentifier, primaryIdentifier };
2611+
2612+
// The identifier is an alias (a deprecated identifier that's a synonym for
2613+
// a primary identifier), so we need to case-normalize the identifier to
2614+
// match the IANA TZDB, e.g. america/new_york => America/New_York. There's
2615+
// no built-in way to do this using Intl.DateTimeFormat, but the we can
2616+
// normalize almost all aliases (modulo a few special cases) using the
2617+
// TZDB's basic capitalization pattern:
2618+
// 1. capitalize the first letter of the identifier
2619+
// 2. capitalize the letter after every slash, dash, or underscore delimiter
2620+
const standardCase = [...lower]
2621+
.map((c, i) => (i === 0 || '/-_'.includes(lower[i - 1]) ? c.toUpperCase() : c))
2622+
.join('');
2623+
const segments = standardCase.split('/');
2624+
2625+
if (segments.length === 1) {
2626+
// For single-segment legacy IDs, if it's 2-3 chars or contains a number of dash, then
2627+
// (except for the "GB-Eire" special case) the case-normalized form is all uppercase.
2628+
// GMT+0, GMT-0, ACT, LHI, NSW, GB, NZ, PRC, ROC, ROK, UCT, GMT, GMT0,
2629+
// CET, CST6CDT, EET, EST, HST, MET, MST, MST7MDT, PST8PDT, WET, NZ-CHAT, W-SU
2630+
// Otherwise it's the standard form: first letter capitalized, e.g. Iran, Egypt, Hongkong
2631+
if (lower === 'gb-eire') return { identifier: 'GB-Eire', primaryIdentifier };
2632+
return {
2633+
identifier: lower.length <= 3 || /[-0-9]/.test(lower) ? lower.toUpperCase() : segments[0],
2634+
primaryIdentifier
2635+
};
25922636
}
2593-
const formatter = getIntlDateTimeFormatEnUsForTimeZone(String(timeZoneIdentifier));
2594-
return formatter.resolvedOptions().timeZone;
2637+
2638+
// All Etc zone names are upper case except a few exceptions.
2639+
if (segments[0] === 'Etc') {
2640+
const etcName = ['Zulu', 'Greenwich', 'Universal'].includes(segments[1]) ? segments[1] : segments[1].toUpperCase();
2641+
return { identifier: `Etc/${etcName}`, primaryIdentifier };
2642+
}
2643+
2644+
// Legacy US identifiers like US/Alaska or US/Indiana-Starke. They're always 2 segments and use standard case.
2645+
if (segments[0] === 'Us') return { identifier: `US/${segments[1]}`, primaryIdentifier };
2646+
2647+
// For multi-segment IDs, there's a few special cases in the second/third segments
2648+
const specialCases = {
2649+
Act: 'ACT',
2650+
Lhi: 'LHI',
2651+
Nsw: 'NSW',
2652+
Dar_Es_Salaam: 'Dar_es_Salaam',
2653+
Port_Of_Spain: 'Port_of_Spain',
2654+
Isle_Of_Man: 'Isle_of_Man',
2655+
Comodrivadavia: 'ComodRivadavia',
2656+
Knox_In: 'Knox_IN',
2657+
Dumontdurville: 'DumontDUrville',
2658+
Mcmurdo: 'McMurdo',
2659+
Denoronha: 'DeNoronha',
2660+
Easterisland: 'EasterIsland',
2661+
Bajanorte: 'BajaNorte',
2662+
Bajasur: 'BajaSur'
2663+
};
2664+
segments[1] = specialCases[segments[1]] ?? segments[1];
2665+
if (segments.length > 2) segments[2] = specialCases[segments[2]] ?? segments[2];
2666+
return { identifier: segments.join('/'), primaryIdentifier };
25952667
}
25962668

25972669
export function GetNamedTimeZoneOffsetNanoseconds(id, epochNanoseconds) {

polyfill/lib/intl.mjs

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ const TIME = Symbol('time');
2121
const DATETIME = Symbol('datetime');
2222
const INST = Symbol('instant');
2323
const ORIGINAL = Symbol('original');
24-
const TZ_RESOLVED = Symbol('timezone');
24+
const TZ_CANONICAL = Symbol('timezone-canonical');
25+
const TZ_ORIGINAL = Symbol('timezone-original');
2526
const CAL_ID = Symbol('calendar-id');
2627
const LOCALE = Symbol('locale');
2728
const OPTIONS = Symbol('options');
@@ -81,14 +82,33 @@ export function DateTimeFormat(locale = undefined, options = undefined) {
8182

8283
this[LOCALE] = ro.locale;
8384
this[ORIGINAL] = original;
84-
this[TZ_RESOLVED] = ro.timeZone;
85+
this[TZ_CANONICAL] = ro.timeZone;
8586
this[CAL_ID] = ro.calendar;
8687
this[DATE] = dateAmend;
8788
this[YM] = yearMonthAmend;
8889
this[MD] = monthDayAmend;
8990
this[TIME] = timeAmend;
9091
this[DATETIME] = datetimeAmend;
9192
this[INST] = instantAmend;
93+
94+
// Save the original time zone, for a few reasons:
95+
// - Clearer error messages
96+
// - More clearly follows the spec for InitializeDateTimeFormat
97+
// - Because it follows the spec more closely, will make it easier to integrate
98+
// support of offset strings and other potential changes like proposal-canonical-tz.
99+
const timeZoneOption = hasOptions ? options.timeZone : undefined;
100+
if (timeZoneOption === undefined) {
101+
this[TZ_ORIGINAL] = ro.timeZone;
102+
} else {
103+
const id = ES.ToString(timeZoneOption);
104+
if (ES.IsTimeZoneOffsetString(id)) {
105+
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
106+
throw new RangeError('Intl.DateTimeFormat does not currently support offset time zones');
107+
}
108+
const record = ES.GetAvailableNamedTimeZoneIdentifier(id);
109+
if (!record) throw new RangeError(`Intl.DateTimeFormat formats built-in time zones, not ${id}`);
110+
this[TZ_ORIGINAL] = record.primaryIdentifier;
111+
}
92112
}
93113

94114
DateTimeFormat.supportedLocalesOf = function (...args) {
@@ -118,7 +138,9 @@ Object.defineProperty(DateTimeFormat, 'prototype', {
118138
});
119139

120140
function resolvedOptions() {
121-
return this[ORIGINAL].resolvedOptions();
141+
const resolved = this[ORIGINAL].resolvedOptions();
142+
resolved.timeZone = this[TZ_ORIGINAL];
143+
return resolved;
122144
}
123145

124146
function format(datetime, ...rest) {
@@ -335,7 +357,7 @@ function extractOverrides(temporalObj, main) {
335357
const nanosecond = GetSlot(temporalObj, ISO_NANOSECOND);
336358
const datetime = new DateTime(1970, 1, 1, hour, minute, second, millisecond, microsecond, nanosecond, main[CAL_ID]);
337359
return {
338-
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
360+
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
339361
formatter: getPropLazy(main, TIME)
340362
};
341363
}
@@ -352,7 +374,7 @@ function extractOverrides(temporalObj, main) {
352374
}
353375
const datetime = new DateTime(isoYear, isoMonth, referenceISODay, 12, 0, 0, 0, 0, 0, calendar);
354376
return {
355-
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
377+
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
356378
formatter: getPropLazy(main, YM)
357379
};
358380
}
@@ -369,7 +391,7 @@ function extractOverrides(temporalObj, main) {
369391
}
370392
const datetime = new DateTime(referenceISOYear, isoMonth, isoDay, 12, 0, 0, 0, 0, 0, calendar);
371393
return {
372-
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
394+
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
373395
formatter: getPropLazy(main, MD)
374396
};
375397
}
@@ -384,7 +406,7 @@ function extractOverrides(temporalObj, main) {
384406
}
385407
const datetime = new DateTime(isoYear, isoMonth, isoDay, 12, 0, 0, 0, 0, 0, main[CAL_ID]);
386408
return {
387-
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
409+
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
388410
formatter: getPropLazy(main, DATE)
389411
};
390412
}
@@ -421,7 +443,7 @@ function extractOverrides(temporalObj, main) {
421443
);
422444
}
423445
return {
424-
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
446+
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
425447
formatter: getPropLazy(main, DATETIME)
426448
};
427449
}

polyfill/lib/timezone.mjs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,14 @@ export class TimeZone {
2626
if (arguments.length < 1) {
2727
throw new RangeError('missing argument: identifier is required');
2828
}
29-
30-
timeZoneIdentifier = ES.GetCanonicalTimeZoneIdentifier(timeZoneIdentifier);
29+
timeZoneIdentifier = ES.ToString(timeZoneIdentifier);
30+
if (ES.IsTimeZoneOffsetString(timeZoneIdentifier)) {
31+
timeZoneIdentifier = ES.CanonicalizeTimeZoneOffsetString(timeZoneIdentifier);
32+
} else {
33+
const record = ES.GetAvailableNamedTimeZoneIdentifier(timeZoneIdentifier);
34+
if (!record) throw new RangeError(`Invalid time zone identifier: ${timeZoneIdentifier}`);
35+
timeZoneIdentifier = record.primaryIdentifier;
36+
}
3137
CreateSlots(this);
3238
SetSlot(this, TIMEZONE_ID, timeZoneIdentifier);
3339

polyfill/lib/zoneddatetime.mjs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -472,13 +472,15 @@ export class ZonedDateTime {
472472
// The rest of the defaults will be filled in by formatting the Instant
473473
}
474474

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

483485
const formatter = new DateTimeFormat(locales, optionsCopy);
484486

0 commit comments

Comments
 (0)