UNPKG

@burglekitt/gmt

Version:

Temporal-based date and time utilities with timezone support and polyfill integration

386 lines (385 loc) 16 kB
import { getLocaleEraNames } from "../plain/locale/getLocaleEraNames.js"; import { getLocaleMeridiems } from "../plain/locale/getLocaleMeridiems.js"; import { getLocaleMonthNames } from "../plain/locale/getLocaleMonthNames.js"; import { getLocaleWeekdayNames } from "../plain/locale/getLocaleWeekdayNames.js"; /** Fields accepted by `parseDateWithPattern` — date-shaped tokens only. */ export const DATE_PATTERN_FIELDS = new Set([ "year", "month", "day", "weekday", "era", ]); /** Fields accepted by `parseTimeWithPattern` — time-shaped tokens only. */ export const TIME_PATTERN_FIELDS = new Set([ "hour", "minute", "second", "millisecond", "meridiem", ]); /** Fields accepted by `parseDateTimeWithPattern` — the full combined set. */ export const DATE_TIME_PATTERN_FIELDS = new Set([ ...DATE_PATTERN_FIELDS, ...TIME_PATTERN_FIELDS, ]); // Recognized vocabulary letters (Decision 1's Luxon-derived token set, // restricted to what's unambiguous to parse — no offset/zone-name tokens // since this story is plain-only). Any other unquoted letter in a pattern // is malformed. Numeric ranges are baked into each regex so a shape // mismatch (e.g. "13" for a 12-hour token) fails at the regex stage, // before ever reaching Temporal. const TOKEN_TABLE = { y4: { field: "year", kind: "yearFull", regexSource: "\\d{4}" }, y2: { field: "year", kind: "yearShort", regexSource: "\\d{2}" }, M2: { field: "month", kind: "monthNumber", regexSource: "(?:0[1-9]|1[0-2])", }, M1: { field: "month", kind: "monthNumber", regexSource: "(?:0?[1-9]|1[0-2])", }, M3: { field: "month", kind: "monthNameShort" }, M4: { field: "month", kind: "monthNameLong" }, d2: { field: "day", kind: "day", regexSource: "(?:0[1-9]|[12]\\d|3[01])" }, d1: { field: "day", kind: "day", regexSource: "(?:0?[1-9]|[12]\\d|3[01])", }, H2: { field: "hour", kind: "hour24", regexSource: "(?:[01]\\d|2[0-3])" }, H1: { field: "hour", kind: "hour24", regexSource: "(?:[01]?\\d|2[0-3])", }, h2: { field: "hour", kind: "hour12", regexSource: "(?:0[1-9]|1[0-2])" }, h1: { field: "hour", kind: "hour12", regexSource: "(?:0?[1-9]|1[0-2])", }, m2: { field: "minute", kind: "minute", regexSource: "[0-5]\\d" }, m1: { field: "minute", kind: "minute", regexSource: "(?:[0-5]?\\d)" }, s2: { field: "second", kind: "second", regexSource: "[0-5]\\d" }, s1: { field: "second", kind: "second", regexSource: "(?:[0-5]?\\d)" }, S3: { field: "millisecond", kind: "millisecond", regexSource: "\\d{3}" }, E3: { field: "weekday", kind: "weekdayShort" }, E4: { field: "weekday", kind: "weekdayLong" }, a1: { field: "meridiem", kind: "meridiem" }, G2: { field: "era", kind: "eraShort" }, G4: { field: "era", kind: "eraLong" }, }; const VOCABULARY_LETTERS = "yMdHhmsSEaG"; /** * Split a pattern string into literal and letter-run parts. * * Quoted segments (`'...'`) are literals matched verbatim; a doubled `''` * *inside* an open quote is the escape for one literal `'` character * (mirrors Luxon's own escaping exactly — Decision 1 already establishes * Luxon's vocabulary as this story's starting point). Any character that * isn't part of a letter run and isn't inside a quote (separators, * literal digits, punctuation) is automatically a one-character literal — * no explicit quoting required for those. */ function tokenizePattern(pattern) { const parts = []; let i = 0; while (i < pattern.length) { const ch = pattern[i]; if (ch === "'") { let j = i + 1; let text = ""; let closed = false; while (j < pattern.length) { if (pattern[j] === "'") { if (pattern[j + 1] === "'") { text += "'"; j += 2; continue; } j += 1; closed = true; break; } text += pattern[j]; j += 1; } // An unterminated quote has no unambiguous literal meaning — treat // it as a malformed pattern rather than guessing where it "should" // have closed. if (!closed) return null; parts.push({ type: "literal", text }); i = j; continue; } if (/[a-zA-Z]/.test(ch)) { let j = i; while (j < pattern.length && pattern[j] === ch) j++; parts.push({ type: "token", letter: ch, length: j - i }); i = j; continue; } parts.push({ type: "literal", text: ch }); i++; } return parts; } function escapeRegExp(text) { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Build a case-insensitive alternation from a locale's name list. * * Names are sorted by descending length before joining with `|` so a * longer name is tried before a shorter one that happens to be its * prefix (e.g. a locale where a short-form weekday name is a strict * prefix of a longer name) — otherwise the regex engine's left-to-right * alternation would greedily commit to the shorter match and never * backtrack into the longer one. */ function buildNameAlternation(names) { return [...names] .sort((a, b) => b.length - a.length) .map((name) => escapeRegExp(name)) .join("|"); } /** * Compile a token pattern into an anchored `RegExp` plus the metadata * needed to resolve each capture back to a field value. * * Returns `null` for any malformed pattern: an unquoted letter outside * the recognized vocabulary, a recognized letter repeated to an * unsupported width, a field appearing more than once, a field outside * `allowedFields` (e.g. a time token handed to the date-only parser), an * unterminated quote, or (for a locale-aware token) a locale that * `Intl.Locale` cannot resolve. */ function compilePattern(pattern, allowedFields, locale) { const parts = tokenizePattern(pattern); if (parts === null) return null; const usedFields = new Set(); const tokens = []; const regexPieces = []; let groupCounter = 0; for (const part of parts) { if (part.type === "literal") { regexPieces.push(escapeRegExp(part.text ?? "")); continue; } const letter = part.letter; const length = part.length; // An unquoted letter outside the recognized vocabulary is always // malformed, regardless of run length. if (!VOCABULARY_LETTERS.includes(letter)) return null; const spec = TOKEN_TABLE[`${letter}${length}`]; // A recognized letter repeated to a width not in the token table // (e.g. "yyy", "hhh", "SS") is malformed. if (!spec) return null; // A time-only token in a date-only pattern (or vice versa) is // rejected here, before ever attempting to match `value` — this is // the pattern-parsing-stage rejection the spec requires. if (!allowedFields.has(spec.field)) return null; // The same field appearing twice (e.g. two independent yyyy runs, or // H combined with hh) is malformed — each field may resolve to only // one captured value. if (usedFields.has(spec.field)) return null; usedFields.add(spec.field); const groupName = `g${groupCounter++}`; if (spec.regexSource) { regexPieces.push(`(?<${groupName}>${spec.regexSource})`); tokens.push({ groupName, field: spec.field, kind: spec.kind }); continue; } if (spec.kind === "monthNameShort" || spec.kind === "monthNameLong") { const names = getLocaleMonthNames(locale, spec.kind === "monthNameLong" ? "long" : "short"); if (names.length !== 12) return null; // locale not resolvable regexPieces.push(`(?<${groupName}>${buildNameAlternation(names)})`); tokens.push({ groupName, field: spec.field, kind: spec.kind, monthNames: names, }); continue; } if (spec.kind === "weekdayShort" || spec.kind === "weekdayLong") { const names = getLocaleWeekdayNames(locale, spec.kind === "weekdayLong" ? "long" : "short"); if (names.length !== 7) return null; // Deliberate scope limit: the weekday name is consumed and matched // against the pattern (so the overall shape still has to line up) // but is NOT cross-validated against the constructed date — that // would require a second weekday-index-to-ISO-dayOfWeek mapping // layer this story does not build. No capturing group is created; // nothing about this token feeds `resolvePatternFields`. regexPieces.push(`(?:${buildNameAlternation(names)})`); continue; } if (spec.kind === "meridiem") { const meridiems = getLocaleMeridiems(locale); if (meridiems.length !== 2) return null; regexPieces.push(`(?<${groupName}>${buildNameAlternation(meridiems)})`); tokens.push({ groupName, field: spec.field, kind: spec.kind, meridiems, }); continue; } // era (eraShort | eraLong) const eras = getLocaleEraNames(locale, spec.kind === "eraLong" ? "long" : "short"); if (eras.length !== 2) return null; regexPieces.push(`(?<${groupName}>${buildNameAlternation(eras)})`); tokens.push({ groupName, field: spec.field, kind: spec.kind, eras }); } // Anchor the fully assembled pattern so only a whole-string match // counts — a `value` that merely starts with something pattern-shaped // (e.g. trailing garbage) must not silently succeed. The whole // alternation is case-insensitive so locale name matching (and any // literal separators) doesn't depend on the caller's casing. const regex = new RegExp(`^${regexPieces.join("")}$`, "i"); return { regex, tokens }; } /** * Resolve a successful regex match's captures back into date/time field * values, applying the two-digit-year pivot, 12-hour-to-24-hour * conversion (via a matched meridiem), and BCE/CE year adjustment. * * This step only ever produces a *candidate* fields object — it does not * itself prove the fields form a real date/time. The caller is * responsible for handing the result to `Temporal.*.from(fields, { * overflow: "reject" })`, which is what actually validates it. */ function resolvePatternFields(match, tokens) { const groups = (match.groups ?? {}); const fields = {}; let hourIsTwelveHour = false; let meridiemIsPM = false; let eraIsBCE = false; for (const token of tokens) { const raw = groups[token.groupName]; if (raw === undefined) continue; switch (token.kind) { case "yearFull": fields.year = Number.parseInt(raw, 10); break; case "yearShort": { // Two-digit year pivot: 00-68 -> 2000-2068, 69-99 -> 1969-1999. // Fixed rule (mirrors common strptime %y behavior) — not a // configurable/global setting. const numeric = Number.parseInt(raw, 10); fields.year = numeric <= 68 ? 2000 + numeric : 1900 + numeric; break; } case "monthNumber": fields.month = Number.parseInt(raw, 10); break; case "monthNameShort": case "monthNameLong": { const names = token.monthNames ?? []; const index = names.findIndex((name) => name.toLowerCase() === raw.toLowerCase()); fields.month = index + 1; // -1 (not found) would yield 0, which Temporal rejects break; } case "day": fields.day = Number.parseInt(raw, 10); break; case "hour24": fields.hour = Number.parseInt(raw, 10); break; case "hour12": hourIsTwelveHour = true; fields.hour = Number.parseInt(raw, 10); break; case "minute": fields.minute = Number.parseInt(raw, 10); break; case "second": fields.second = Number.parseInt(raw, 10); break; case "millisecond": fields.millisecond = Number.parseInt(raw, 10); break; case "meridiem": { const [amLabel, pmLabel] = token.meridiems ?? ["", ""]; // Guard against a locale whose AM/PM labels happen to coincide — // in that case there is nothing to distinguish, so don't treat // the match as PM by accident. meridiemIsPM = raw.toLowerCase() === (pmLabel ?? "").toLowerCase() && (pmLabel ?? "").toLowerCase() !== (amLabel ?? "").toLowerCase(); break; } case "eraShort": case "eraLong": { const [bceLabel, ceLabel] = token.eras ?? ["", ""]; eraIsBCE = raw.toLowerCase() === (bceLabel ?? "").toLowerCase() && (bceLabel ?? "").toLowerCase() !== (ceLabel ?? "").toLowerCase(); break; } default: break; } } if (hourIsTwelveHour && fields.hour !== undefined) { // Resolve h/hh to 24-hour. With no `a` token present in the pattern // at all, `meridiemIsPM` stays false and this falls through the same // branch as an explicit AM match — the documented "ambiguous AM // default" behavior. if (meridiemIsPM && fields.hour !== 12) fields.hour += 12; if (!meridiemIsPM && fields.hour === 12) fields.hour = 0; } if (eraIsBCE && fields.year !== undefined) { // Proleptic Gregorian: 1 BCE = year 0, 2 BCE = year -1, etc. fields.year = 1 - fields.year; } return fields; } /** * Parse `value` against `pattern` and return the resolved candidate * fields, or `null` on malformed pattern / no match / invalid input. * * `null` here always means "return the sentinel" to the caller — it * never distinguishes malformed-pattern from no-match from invalid-input, * matching the shared never-throw / sentinel-return contract. */ export function parseValueWithPattern(value, pattern, locale, allowedFields) { if (typeof value !== "string" || value.length === 0) return null; if (typeof pattern !== "string" || pattern.length === 0) return null; if (locale !== undefined && typeof locale !== "string") return null; // No `locale` is required to parse a pattern containing only numeric // tokens, but a name-based token (MMM/MMMM/EEE/EEEE/a/GG/GGGG) needs // *some* locale to resolve against — default to "en-US" rather than // silently returning "" for every caller who didn't have another // locale in mind. const resolvedLocale = typeof locale === "string" ? locale : "en-US"; const compiled = compilePattern(pattern, allowedFields, resolvedLocale); if (compiled === null) return null; const match = value.match(compiled.regex); if (match === null) return null; return resolvePatternFields(match, compiled.tokens); }