UNPKG

luxon

Version:
547 lines (476 loc) 15.7 kB
import { hasLocaleWeekInfo, hasRelative, padStart, roundTo, validateWeekSettings } from "./util.js"; import * as English from "./english.js"; import Settings from "../settings.js"; import DateTime from "../datetime.js"; import IANAZone from "../zones/IANAZone.js"; // todo - remap caching let intlLFCache = {}; function getCachedLF(locString, opts = {}) { const key = JSON.stringify([locString, opts]); let dtf = intlLFCache[key]; if (!dtf) { dtf = new Intl.ListFormat(locString, opts); intlLFCache[key] = dtf; } return dtf; } let intlDTCache = {}; function getCachedDTF(locString, opts = {}) { const key = JSON.stringify([locString, opts]); let dtf = intlDTCache[key]; if (!dtf) { dtf = new Intl.DateTimeFormat(locString, opts); intlDTCache[key] = dtf; } return dtf; } let intlNumCache = {}; function getCachedINF(locString, opts = {}) { const key = JSON.stringify([locString, opts]); let inf = intlNumCache[key]; if (!inf) { inf = new Intl.NumberFormat(locString, opts); intlNumCache[key] = inf; } return inf; } let intlRelCache = {}; function getCachedRTF(locString, opts = {}) { const { base, ...cacheKeyOpts } = opts; // exclude `base` from the options const key = JSON.stringify([locString, cacheKeyOpts]); let inf = intlRelCache[key]; if (!inf) { inf = new Intl.RelativeTimeFormat(locString, opts); intlRelCache[key] = inf; } return inf; } let sysLocaleCache = null; function systemLocale() { if (sysLocaleCache) { return sysLocaleCache; } else { sysLocaleCache = new Intl.DateTimeFormat().resolvedOptions().locale; return sysLocaleCache; } } let weekInfoCache = {}; function getCachedWeekInfo(locString) { let data = weekInfoCache[locString]; if (!data) { const locale = new Intl.Locale(locString); // browsers currently implement this as a property, but spec says it should be a getter function data = "getWeekInfo" in locale ? locale.getWeekInfo() : locale.weekInfo; weekInfoCache[locString] = data; } return data; } function parseLocaleString(localeStr) { // I really want to avoid writing a BCP 47 parser // see, e.g. https://github.com/wooorm/bcp-47 // Instead, we'll do this: // a) if the string has no -u extensions, just leave it alone // b) if it does, use Intl to resolve everything // c) if Intl fails, try again without the -u // private subtags and unicode subtags have ordering requirements, // and we're not properly parsing this, so just strip out the // private ones if they exist. const xIndex = localeStr.indexOf("-x-"); if (xIndex !== -1) { localeStr = localeStr.substring(0, xIndex); } const uIndex = localeStr.indexOf("-u-"); if (uIndex === -1) { return [localeStr]; } else { let options; let selectedStr; try { options = getCachedDTF(localeStr).resolvedOptions(); selectedStr = localeStr; } catch (e) { const smaller = localeStr.substring(0, uIndex); options = getCachedDTF(smaller).resolvedOptions(); selectedStr = smaller; } const { numberingSystem, calendar } = options; return [selectedStr, numberingSystem, calendar]; } } function intlConfigString(localeStr, numberingSystem, outputCalendar) { if (outputCalendar || numberingSystem) { if (!localeStr.includes("-u-")) { localeStr += "-u"; } if (outputCalendar) { localeStr += `-ca-${outputCalendar}`; } if (numberingSystem) { localeStr += `-nu-${numberingSystem}`; } return localeStr; } else { return localeStr; } } function mapMonths(f) { const ms = []; for (let i = 1; i <= 12; i++) { const dt = DateTime.utc(2009, i, 1); ms.push(f(dt)); } return ms; } function mapWeekdays(f) { const ms = []; for (let i = 1; i <= 7; i++) { const dt = DateTime.utc(2016, 11, 13 + i); ms.push(f(dt)); } return ms; } function listStuff(loc, length, englishFn, intlFn) { const mode = loc.listingMode(); if (mode === "error") { return null; } else if (mode === "en") { return englishFn(length); } else { return intlFn(length); } } function supportsFastNumbers(loc) { if (loc.numberingSystem && loc.numberingSystem !== "latn") { return false; } else { return ( loc.numberingSystem === "latn" || !loc.locale || loc.locale.startsWith("en") || new Intl.DateTimeFormat(loc.intl).resolvedOptions().numberingSystem === "latn" ); } } /** * @private */ class PolyNumberFormatter { constructor(intl, forceSimple, opts) { this.padTo = opts.padTo || 0; this.floor = opts.floor || false; const { padTo, floor, ...otherOpts } = opts; if (!forceSimple || Object.keys(otherOpts).length > 0) { const intlOpts = { useGrouping: false, ...opts }; if (opts.padTo > 0) intlOpts.minimumIntegerDigits = opts.padTo; this.inf = getCachedINF(intl, intlOpts); } } format(i) { if (this.inf) { const fixed = this.floor ? Math.floor(i) : i; return this.inf.format(fixed); } else { // to match the browser's numberformatter defaults const fixed = this.floor ? Math.floor(i) : roundTo(i, 3); return padStart(fixed, this.padTo); } } } /** * @private */ class PolyDateFormatter { constructor(dt, intl, opts) { this.opts = opts; this.originalZone = undefined; let z = undefined; if (this.opts.timeZone) { // Don't apply any workarounds if a timeZone is explicitly provided in opts this.dt = dt; } else if (dt.zone.type === "fixed") { // UTC-8 or Etc/UTC-8 are not part of tzdata, only Etc/GMT+8 and the like. // That is why fixed-offset TZ is set to that unless it is: // 1. Representing offset 0 when UTC is used to maintain previous behavior and does not become GMT. // 2. Unsupported by the browser: // - some do not support Etc/ // - < Etc/GMT-14, > Etc/GMT+12, and 30-minute or 45-minute offsets are not part of tzdata const gmtOffset = -1 * (dt.offset / 60); const offsetZ = gmtOffset >= 0 ? `Etc/GMT+${gmtOffset}` : `Etc/GMT${gmtOffset}`; if (dt.offset !== 0 && IANAZone.create(offsetZ).valid) { z = offsetZ; this.dt = dt; } else { // Not all fixed-offset zones like Etc/+4:30 are present in tzdata so // we manually apply the offset and substitute the zone as needed. z = "UTC"; this.dt = dt.offset === 0 ? dt : dt.setZone("UTC").plus({ minutes: dt.offset }); this.originalZone = dt.zone; } } else if (dt.zone.type === "system") { this.dt = dt; } else if (dt.zone.type === "iana") { this.dt = dt; z = dt.zone.name; } else { // Custom zones can have any offset / offsetName so we just manually // apply the offset and substitute the zone as needed. z = "UTC"; this.dt = dt.setZone("UTC").plus({ minutes: dt.offset }); this.originalZone = dt.zone; } const intlOpts = { ...this.opts }; intlOpts.timeZone = intlOpts.timeZone || z; this.dtf = getCachedDTF(intl, intlOpts); } format() { if (this.originalZone) { // If we have to substitute in the actual zone name, we have to use // formatToParts so that the timezone can be replaced. return this.formatToParts() .map(({ value }) => value) .join(""); } return this.dtf.format(this.dt.toJSDate()); } formatToParts() { const parts = this.dtf.formatToParts(this.dt.toJSDate()); if (this.originalZone) { return parts.map((part) => { if (part.type === "timeZoneName") { const offsetName = this.originalZone.offsetName(this.dt.ts, { locale: this.dt.locale, format: this.opts.timeZoneName, }); return { ...part, value: offsetName, }; } else { return part; } }); } return parts; } resolvedOptions() { return this.dtf.resolvedOptions(); } } /** * @private */ class PolyRelFormatter { constructor(intl, isEnglish, opts) { this.opts = { style: "long", ...opts }; if (!isEnglish && hasRelative()) { this.rtf = getCachedRTF(intl, opts); } } format(count, unit) { if (this.rtf) { return this.rtf.format(count, unit); } else { return English.formatRelativeTime(unit, count, this.opts.numeric, this.opts.style !== "long"); } } formatToParts(count, unit) { if (this.rtf) { return this.rtf.formatToParts(count, unit); } else { return []; } } } const fallbackWeekSettings = { firstDay: 1, minimalDays: 4, weekend: [6, 7], }; /** * @private */ export default class Locale { static fromOpts(opts) { return Locale.create( opts.locale, opts.numberingSystem, opts.outputCalendar, opts.weekSettings, opts.defaultToEN ); } static create(locale, numberingSystem, outputCalendar, weekSettings, defaultToEN = false) { const specifiedLocale = locale || Settings.defaultLocale; // the system locale is useful for human-readable strings but annoying for parsing/formatting known formats const localeR = specifiedLocale || (defaultToEN ? "en-US" : systemLocale()); const numberingSystemR = numberingSystem || Settings.defaultNumberingSystem; const outputCalendarR = outputCalendar || Settings.defaultOutputCalendar; const weekSettingsR = validateWeekSettings(weekSettings) || Settings.defaultWeekSettings; return new Locale(localeR, numberingSystemR, outputCalendarR, weekSettingsR, specifiedLocale); } static resetCache() { sysLocaleCache = null; intlDTCache = {}; intlNumCache = {}; intlRelCache = {}; } static fromObject({ locale, numberingSystem, outputCalendar, weekSettings } = {}) { return Locale.create(locale, numberingSystem, outputCalendar, weekSettings); } constructor(locale, numbering, outputCalendar, weekSettings, specifiedLocale) { const [parsedLocale, parsedNumberingSystem, parsedOutputCalendar] = parseLocaleString(locale); this.locale = parsedLocale; this.numberingSystem = numbering || parsedNumberingSystem || null; this.outputCalendar = outputCalendar || parsedOutputCalendar || null; this.weekSettings = weekSettings; this.intl = intlConfigString(this.locale, this.numberingSystem, this.outputCalendar); this.weekdaysCache = { format: {}, standalone: {} }; this.monthsCache = { format: {}, standalone: {} }; this.meridiemCache = null; this.eraCache = {}; this.specifiedLocale = specifiedLocale; this.fastNumbersCached = null; } get fastNumbers() { if (this.fastNumbersCached == null) { this.fastNumbersCached = supportsFastNumbers(this); } return this.fastNumbersCached; } listingMode() { const isActuallyEn = this.isEnglish(); const hasNoWeirdness = (this.numberingSystem === null || this.numberingSystem === "latn") && (this.outputCalendar === null || this.outputCalendar === "gregory"); return isActuallyEn && hasNoWeirdness ? "en" : "intl"; } clone(alts) { if (!alts || Object.getOwnPropertyNames(alts).length === 0) { return this; } else { return Locale.create( alts.locale || this.specifiedLocale, alts.numberingSystem || this.numberingSystem, alts.outputCalendar || this.outputCalendar, validateWeekSettings(alts.weekSettings) || this.weekSettings, alts.defaultToEN || false ); } } redefaultToEN(alts = {}) { return this.clone({ ...alts, defaultToEN: true }); } redefaultToSystem(alts = {}) { return this.clone({ ...alts, defaultToEN: false }); } months(length, format = false) { return listStuff(this, length, English.months, () => { const intl = format ? { month: length, day: "numeric" } : { month: length }, formatStr = format ? "format" : "standalone"; if (!this.monthsCache[formatStr][length]) { this.monthsCache[formatStr][length] = mapMonths((dt) => this.extract(dt, intl, "month")); } return this.monthsCache[formatStr][length]; }); } weekdays(length, format = false) { return listStuff(this, length, English.weekdays, () => { const intl = format ? { weekday: length, year: "numeric", month: "long", day: "numeric" } : { weekday: length }, formatStr = format ? "format" : "standalone"; if (!this.weekdaysCache[formatStr][length]) { this.weekdaysCache[formatStr][length] = mapWeekdays((dt) => this.extract(dt, intl, "weekday") ); } return this.weekdaysCache[formatStr][length]; }); } meridiems() { return listStuff( this, undefined, () => English.meridiems, () => { // In theory there could be aribitrary day periods. We're gonna assume there are exactly two // for AM and PM. This is probably wrong, but it's makes parsing way easier. if (!this.meridiemCache) { const intl = { hour: "numeric", hourCycle: "h12" }; this.meridiemCache = [DateTime.utc(2016, 11, 13, 9), DateTime.utc(2016, 11, 13, 19)].map( (dt) => this.extract(dt, intl, "dayperiod") ); } return this.meridiemCache; } ); } eras(length) { return listStuff(this, length, English.eras, () => { const intl = { era: length }; // This is problematic. Different calendars are going to define eras totally differently. What I need is the minimum set of dates // to definitely enumerate them. if (!this.eraCache[length]) { this.eraCache[length] = [DateTime.utc(-40, 1, 1), DateTime.utc(2017, 1, 1)].map((dt) => this.extract(dt, intl, "era") ); } return this.eraCache[length]; }); } extract(dt, intlOpts, field) { const df = this.dtFormatter(dt, intlOpts), results = df.formatToParts(), matching = results.find((m) => m.type.toLowerCase() === field); return matching ? matching.value : null; } numberFormatter(opts = {}) { // this forcesimple option is never used (the only caller short-circuits on it, but it seems safer to leave) // (in contrast, the rest of the condition is used heavily) return new PolyNumberFormatter(this.intl, opts.forceSimple || this.fastNumbers, opts); } dtFormatter(dt, intlOpts = {}) { return new PolyDateFormatter(dt, this.intl, intlOpts); } relFormatter(opts = {}) { return new PolyRelFormatter(this.intl, this.isEnglish(), opts); } listFormatter(opts = {}) { return getCachedLF(this.intl, opts); } isEnglish() { return ( this.locale === "en" || this.locale.toLowerCase() === "en-us" || new Intl.DateTimeFormat(this.intl).resolvedOptions().locale.startsWith("en-us") ); } getWeekSettings() { if (this.weekSettings) { return this.weekSettings; } else if (!hasLocaleWeekInfo()) { return fallbackWeekSettings; } else { return getCachedWeekInfo(this.locale); } } getStartOfWeek() { return this.getWeekSettings().firstDay; } getMinDaysInFirstWeek() { return this.getWeekSettings().minimalDays; } getWeekendDays() { return this.getWeekSettings().weekend; } equals(other) { return ( this.locale === other.locale && this.numberingSystem === other.numberingSystem && this.outputCalendar === other.outputCalendar ); } toString() { return `Locale(${this.locale}, ${this.numberingSystem}, ${this.outputCalendar})`; } }