Luxon Home Reference Source Repository

src/impl/locale.js

import { Util } from './util';
import { English } from './english';
import { Settings } from '../settings';
import { DateTime } from '../datetime';

const localeCache = {};

function intlConfigString(locale, numberingSystem, outputCalendar) {
  let loc = locale || new Intl.DateTimeFormat().resolvedOptions().locale;
  loc = Array.isArray(locale) ? locale : [locale];

  if (outputCalendar || numberingSystem) {
    loc = loc.map(l => {
      l += '-u';

      if (outputCalendar) {
        l += '-ca-' + outputCalendar;
      }

      if (numberingSystem) {
        l += '-nu-' + numberingSystem;
      }
      return l;
    });
  }
  return loc;
}

function mapMonths(f) {
  const ms = [];
  for (let i = 1; i <= 12; i++) {
    const dt = DateTime.utc(2016, 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, defaultOK, englishFn, intlFn) {
  const mode = loc.listingMode(defaultOK);

  if (mode === 'error') {
    return null;
  } else if (mode === 'en') {
    return englishFn(length);
  } else {
    return intlFn(length);
  }
}

/**
 * @private
 */

class PolyNumberFormatter {
  constructor(opts) {
    this.padTo = opts.padTo || 0;
    this.round = opts.round || false;
  }

  format(i) {
    const maybeRounded = this.round ? Math.round(i) : i;
    return maybeRounded.toString().padStart(this.padTo, '0');
  }
}

class PolyDateFormatter {
  format(d) {
    return d.toString();
  }

  resolvedOptions() {
    return {
      locale: 'en-US',
      numberingSystem: 'latn',
      outputCalendar: 'gregory'
    };
  }
}

/**
 * @private
 */

export class Locale {
  static fromOpts(opts) {
    return Locale.create(opts.locale, opts.numberingSystem, opts.outputCalendar);
  }

  static create(locale, numberingSystem, outputCalendar) {
    const localeR = locale || Settings.defaultLocale,
      numberingSystemR = numberingSystem || null,
      outputCalendarR = outputCalendar || null,
      cacheKey = `${localeR}|${numberingSystemR}|${outputCalendarR}`,
      cached = localeCache[cacheKey];

    if (cached) {
      return cached;
    } else {
      const fresh = new Locale(localeR, numberingSystemR, outputCalendarR);
      localeCache[cacheKey] = fresh;
      return fresh;
    }
  }

  static fromObject({ locale, numberingSystem, outputCalendar } = {}) {
    return Locale.create(locale, numberingSystem, outputCalendar);
  }

  constructor(locale, numbering, outputCalendar) {
    Object.defineProperty(this, 'locale', { value: locale, enumerable: true });
    Object.defineProperty(this, 'numberingSystem', {
      value: numbering || null,
      enumerable: true
    });
    Object.defineProperty(this, 'outputCalendar', {
      value: outputCalendar || null,
      enumerable: true
    });
    Object.defineProperty(this, 'intl', {
      value: intlConfigString(this.locale, this.numberingSystem, this.outputCalendar),
      enumerable: false
    });

    // cached usefulness
    Object.defineProperty(this, 'weekdaysCache', {
      value: { format: {}, standalone: {} },
      enumerable: false
    });
    Object.defineProperty(this, 'monthsCache', {
      value: { format: {}, standalone: {} },
      enumerable: false
    });
    Object.defineProperty(this, 'meridiemCache', {
      value: null,
      enumerable: false,
      writable: true
    });
    Object.defineProperty(this, 'eraCache', {
      value: {},
      enumerable: false,
      writable: true
    });
  }

  // todo: cache me
  listingMode(defaultOk = true) {
    const hasIntl = Intl && Intl.DateTimeFormat,
      hasFTP = hasIntl && Intl.DateTimeFormat.prototype.formatToParts,
      isActuallyEn =
        this.locale === 'en' ||
        this.locale.toLowerCase() === 'en-us' ||
        (hasIntl &&
          Intl.DateTimeFormat(this.intl)
            .resolvedOptions()
            .locale.startsWith('en-US')),
      hasNoWeirdness =
        (this.numberingSystem === null || this.numberingSystem === 'latn') &&
        (this.outputCalendar === null || this.outputCalendar === 'gregory');

    if (!hasFTP && !(isActuallyEn && hasNoWeirdness) && !defaultOk) {
      return 'error';
    } else if (!hasFTP || (isActuallyEn && hasNoWeirdness)) {
      return 'en';
    } else {
      return 'intl';
    }
  }

  clone(alts) {
    if (!alts || Object.getOwnPropertyNames(alts).length === 0) {
      return this;
    } else {
      return Locale.create(
        alts.locale || this.locale,
        alts.numberingSystem || this.numberingSystem,
        alts.outputCalendar || this.outputCalendar
      );
    }
  }

  months(length, format = false, defaultOK = true) {
    return listStuff(this, length, defaultOK, 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, defaultOK = true) {
    return listStuff(this, length, defaultOK, 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(defaultOK = true) {
    return listStuff(
      this,
      undefined,
      defaultOK,
      () => 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', hour12: true };
          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, defaultOK = true) {
    return listStuff(this, length, defaultOK, English.eras, () => {
      const intl = { era: length };

      // This is utter bullshit. 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, d] = this.dtFormatter(dt, intlOpts),
      results = df.formatToParts(d),
      matching = results.find(m => m.type.toLowerCase() === field);

    return matching ? matching.value : null;
  }

  numberFormatter(opts = {}, intlOpts = {}) {
    if (Intl && Intl.NumberFormat) {
      const realIntlOpts = Object.assign({ useGrouping: false }, intlOpts);

      if (opts.padTo > 0) {
        realIntlOpts.minimumIntegerDigits = opts.padTo;
      }

      if (opts.round) {
        realIntlOpts.maximumFractionDigits = 0;
      }

      return new Intl.NumberFormat(this.intl, realIntlOpts);
    } else {
      return new PolyNumberFormatter(opts);
    }
  }

  dtFormatter(dt, intlOpts = {}) {
    let d, z;

    if (dt.zone.universal) {
      // if we have a fixed-offset zone that isn't actually UTC,
      // (like UTC+8), we need to make do with just displaying
      // the time in UTC; the formatter doesn't know how to handle UTC+8
      d = Util.asIfUTC(dt);
      z = 'UTC';
    } else if (dt.zone.type === 'local') {
      d = dt.toJSDate();
    } else {
      d = dt.toJSDate();
      z = dt.zone.name;
    }

    if (Intl && Intl.DateTimeFormat) {
      const realIntlOpts = Object.assign({}, intlOpts);
      if (z) {
        realIntlOpts.timeZone = z;
      }
      return [new Intl.DateTimeFormat(this.intl, realIntlOpts), d];
    } else {
      return [new PolyDateFormatter(), d];
    }
  }

  equals(other) {
    return (
      this.locale === other.locale &&
      this.numberingSystem === other.numberingSystem &&
      this.outputCalendar === other.outputCalendar
    );
  }
}