@burglekitt/gmt
Version:
Temporal-based date and time utilities with timezone support and polyfill integration
121 lines (120 loc) • 8.03 kB
JavaScript
import { Temporal } from "@js-temporal/polyfill";
import { isLeapSecond } from "../plain/validate/isLeapSecond.js";
import { calendarZonedDateTime } from "../regex/index.js";
import { parseCalendarDateValue } from "./calendarDateString.js";
import { temporalCalendarIds } from "./calendarSystemIds.js";
import { isEthiopicFamilyCalendar } from "./ethiopicFamilyCalendar.js";
import { calendarDateStringParts } from "./formatDateInCalendar.js";
import { hasCalendarAnnotation } from "./hasCalendarAnnotation.js";
/**
* Parse a bare ISO ZonedDateTime string or a GMT calendar-annotated ZonedDateTime string
* (`"5784-06-15T14:30:00-05:00[u-ca=hebrew][America/New_York]"`, calendar-native fields, not
* Temporal's own ISO-digit `[u-ca=...]` annotation convention) into a Temporal.ZonedDateTime.
* Throws on invalid input — callers wrap this in try-catch per GMT's sentinel-return contract.
*
* The regex only proves shape; the real construction and validation happen in Temporal —
* `parseCalendarDateValue` (`Temporal.PlainDate.from(fields, { overflow: "reject" })`) for the
* calendar-native date half, and `Temporal.ZonedDateTime.from` for the recomposed ISO string
* (which rejects unknown zones, stale offsets, and out-of-range times). See
* `context/coding-standards.md`'s scoped manual-string-parsing exception, rule 2.
*
* ---------------------------------------------------------------------------------------
* WHY THE DATE HALF IS FIELD-DECOMPOSED AND HANDED BACK TO TEMPORAL AS ISO DIGITS
* ---------------------------------------------------------------------------------------
* The `;era=` suffix can NEVER round-trip through Temporal, at any segment ordering, because it
* is not valid RFC 9557 at all — `Temporal.PlainDate.from("0006-10-03[u-ca=japanese;era=reiwa]")`
* throws `RangeError: invalid RFC 9557 string`, and so does every `Temporal.*.from` entry point
* given the same suffix. Era-bearing calendars ("japanese", "ethiopic") therefore have to be
* decomposed into `{ era, eraYear, month, day }` and constructed field-wise, which is exactly
* what `parseCalendarDateValue` already does for the plain case — this function delegates the
* whole date half to it rather than reimplementing era handling, the Ethiopic-family "ethioaa"
* carrier routing, or the GMT-to-Temporal calendar-id mapping.
*
* The date half then travels back through Temporal as plain ISO digits
* (`.withCalendar("iso8601").toString()`), so `Temporal.ZonedDateTime.from` resolves the zone,
* offset and DST against a string it can actually parse. The calendar is re-attached to the
* RESULT via `.withCalendar(...)`, which keeps downstream `.add`/`.until`/`.round` calendar-aware
* (verified: `withTimeZone`/`round`/`with`/`startOfDay`/`toPlainDate` all preserve the tag).
*
* `isLeapSecond` runs before anything else, exactly as `isValidZonedDateTime` does: verified that
* `Temporal.ZonedDateTime.from("2024-06-30T23:59:60+00:00[UTC]")` silently CLAMPS to `:59` rather
* than throwing, so this guard is load-bearing rather than redundant.
*
* @param value bare ISO zoned datetime string, or GMT calendar-annotated zoned datetime string
* @param options optional Temporal `disambiguation`/`offset`, passed through to
* `Temporal.ZonedDateTime.from` so a caller's own DST/offset policy still applies
* @returns Temporal.ZonedDateTime carrying the annotated calendar (or `iso8601` when bare)
*/
export function parseCalendarZonedValue(value, options) {
if (isLeapSecond(value)) {
throw new RangeError(`Leap seconds are not supported: ${value}`);
}
const match = calendarZonedDateTime.exec(value);
if (!match) {
// Either Temporal's own `[timeZone][u-ca=...]` ordering or a wrong-ordered GMT string. Both
// are rejected rather than re-parsed: the former is not GMT's contract (E5 decision D1), and
// the latter is the ~3760-year silent-misparse hazard documented in
// `regex/calendar-zoned-date-time.ts`. Only a genuinely un-annotated string falls through.
if (hasCalendarAnnotation(value)) {
throw new RangeError(`Not a valid GMT ZonedDateTime string: ${value}`);
}
const bare = Temporal.ZonedDateTime.from(value, options);
if (bare.timeZoneId.length === 0) {
throw new RangeError(`Missing time zone: ${value}`);
}
return bare;
}
const [, year, month, day, time, offset, calendarId, era, timeZone] = match;
const eraSuffix = era ? `;era=${era}` : "";
const date = parseCalendarDateValue(`${year}-${month}-${day}[u-ca=${calendarId}${eraSuffix}]`);
const isoDate = date.withCalendar("iso8601").toString();
const zoned = Temporal.ZonedDateTime.from(`${isoDate}T${time}${offset ?? ""}[${timeZone}]`, options);
// Re-attach the calendar the date half resolved to — `date.calendarId` rather than the string's
// own tag, so the Ethiopic family lands on its "ethioaa" carrier and GMT ids that differ from
// Temporal's ("islamic-tabular" -> "islamic-tbla") map through exactly once, in one place.
return zoned.withCalendar(date.calendarId);
}
/**
* Format a Temporal.ZonedDateTime as GMT's calendar-annotated (or bare ISO) zoned string in a
* known target CalendarSystem — the zoned companion to `formatDateInCalendar`, and the ONLY
* writer of the calendar-annotated zoned grammar anywhere in GMT.
*
* Never call `zdt.toString()` on a calendared value and never compose
* `` `${zdt.toPlainDateTime()}[${zdt.timeZoneId}]` `` on one either: both emit Temporal's
* `[timeZone][u-ca=...]` RFC 9557 ordering (verified: a Hebrew-calendared
* `.toPlainDateTime().toString()` yields `"2024-03-25T14:30:00[u-ca=hebrew]"`), which is exactly
* the ordering GMT rejects because reading it back misparses the digits as ISO. Route every
* calendar-annotated zoned output through this function instead.
*
* Every field is RE-DERIVED from the actual `zdt` result and nothing but the IANA zone id is
* copied (E7's D7-zoned, extending E5's D7): a single calendar-unit add can move the era AND the
* UTC offset at once — verified, Japanese Heisei 31-04-05 in `Africa/Casablanca` `+1 month` lands
* on Reiwa 1-05-05 inside a DST fold — so a tag or offset copied from the input would describe a
* moment that no longer exists.
*
* Unlike `formatDateInCalendar`, this does NOT require `zdt` to already carry `calendar`'s own
* Temporal calendar id — it re-calendars the value itself before reading the date fields. That is
* deliberate: several `zoned/interval/*` functions synthesize boundary points via
* `Temporal.Instant.prototype.toZonedDateTimeISO`, which always returns an `iso8601`-calendared
* value. Re-calendaring here means a synthesized boundary can never silently emit a bare ISO
* string into an otherwise calendar-tagged result set — one class of bug removed by construction
* rather than by remembering to re-attach the tag at every synthesis site. The instant, wall time
* and zone are untouched; only which calendar the date fields resolve through changes.
*
* @param zdt Temporal.ZonedDateTime to format
* @param calendar the CalendarSystem to express `zdt`'s date fields in
* @returns GMT's calendar-annotated (or bare ISO, for "gregorian") zoned datetime string
*/
export function formatZonedInCalendar(zdt, calendar) {
if (calendar === "gregorian") {
return zdt.withCalendar("iso8601").toString();
}
// The whole Ethiopic family computes through "ethioaa" rather than Temporal's own
// "ethiopic"/"coptic" ids, which throw under ICU >= 78 — see internal/ethiopicFamilyCalendar.ts.
const temporalCalendarId = isEthiopicFamilyCalendar(calendar)
? "ethioaa"
: temporalCalendarIds[calendar];
const { date, annotation } = calendarDateStringParts(zdt.withCalendar(temporalCalendarId).toPlainDate(), calendar);
const time = zdt.toPlainTime().toString();
return `${date}T${time}${zdt.offset}${annotation}[${zdt.timeZoneId}]`;
}