Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"main": "polyfill/lib/index.mjs",
"devDependencies": {
"@tc39/ecma262-biblio": "=2.1.2576",
"@tc39/ecma262-biblio": "=2.1.2577",
"@typescript-eslint/eslint-plugin": "^5.59.9",
"@typescript-eslint/parser": "^5.59.9",
"ecmarkup": "^17.0.0",
Expand Down
95 changes: 85 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,87 @@ 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) {
// The most common case is when the identifier is a canonical time zone ID.
// Fast-path that case by caching all canonical IDs. For old ECMAScript
// implementations lacking this API, set the cache to `null` to avoid retries.
if (canonicalTimeZoneIdsCache === undefined) {
const canonicalTimeZoneIds = IntlSupportedValuesOf?.('timeZone');
canonicalTimeZoneIdsCache = canonicalTimeZoneIds
? new Map(canonicalTimeZoneIds.map((id) => [ASCIILowercase(id), id]))
: null;
}

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) {
// If a single-segment legacy ID is 2-3 chars or contains a number or dash, then
// (except for the "GB-Eire" special case) the case-normalized form is uppercase.
// These are: GMT+0, GMT-0, GB, NZ, PRC, ROC, ROK, UCT, GMT, GMT0, CET, CST6CDT,
// EET, EST, HST, MET, MST, MST7MDT, PST8PDT, WET, NZ-CHAT, and W-SU.
// Otherwise it's 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 uppercase except three 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 are 2 segments and use standard form.
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