i18n-js
Version:
A small library to provide I18n on JavaScript.
1,491 lines (1,393 loc) • 42.6 kB
text/typescript
/* eslint-disable class-methods-use-this, no-underscore-dangle */
import get from "lodash/get";
import has from "lodash/has";
import merge from "lodash/merge";
import {
DateTime,
Dict,
FormatNumberOptions,
I18nOptions,
MissingPlaceholderHandler,
NullPlaceholderHandler,
NumberToCurrencyOptions,
NumberToDelimitedOptions,
NumberToHumanOptions,
NumberToHumanSizeOptions,
NumberToPercentageOptions,
NumberToRoundedOptions,
Numeric,
OnChangeHandler,
Scope,
StrftimeOptions,
TimeAgoInWordsOptions,
ToSentenceOptions,
TranslateOptions,
} from "./typing";
import { Locales } from "./Locales";
import { Pluralization } from "./Pluralization";
import { MissingTranslation } from "./MissingTranslation";
import {
camelCaseKeys,
createTranslationOptions,
formatNumber,
getFullScope,
inferType,
interpolate,
isSet,
lookup,
numberToDelimited,
numberToHuman,
numberToHumanSize,
parseDate,
pluralize,
strftime,
timeAgoInWords,
} from "./helpers";
const DEFAULT_I18N_OPTIONS: I18nOptions = {
defaultLocale: "en",
availableLocales: ["en"],
locale: "en",
defaultSeparator: ".",
placeholder: /(?:\{\{|%\{)(.*?)(?:\}\}?)/gm,
enableFallback: false,
missingBehavior: "message",
missingTranslationPrefix: "",
missingPlaceholder: (_i18n: I18n, placeholder: string): string =>
`[missing "${placeholder}" value]`,
nullPlaceholder: (
i18n: I18n,
placeholder,
message: string,
options: Dict,
): string => i18n.missingPlaceholder(i18n, placeholder, message, options),
transformKey: (key: string): string => key,
};
export class I18n {
private _locale: string = DEFAULT_I18N_OPTIONS.locale;
private _defaultLocale: string = DEFAULT_I18N_OPTIONS.defaultLocale;
private _version = 0;
/**
* List of all onChange handlers.
*
* @type {OnChangeHandler[]}
*/
public onChangeHandlers: OnChangeHandler[] = [];
/**
* Set the default string separator. Defaults to `.`, as in
* `scope.translation`.
*
* @type {string}
*/
public defaultSeparator: string;
/**
* Set if engine should fallback to the default locale when a translation is
* missing. Defaults to `false`.
*
* When enabled, missing translations will first be looked for in less
* specific versions of the requested locale and if that fails by taking them
* from your `I18n#defaultLocale`.
*
* @type {boolean}
*/
public enableFallback: boolean;
/**
* The locale resolver registry.
*
* @see {@link Locales}
*
* @type {Locales}
*/
public locales: Locales;
/**
* The pluralization behavior registry.
*
* @see {@link Pluralization}
*
* @type {Pluralization}
*/
public pluralization: Pluralization;
/**
* Set missing translation behavior.
*
* - `message` will display a message that the translation is missing.
* - `guess` will try to guess the string.
* - `error` will raise an exception whenever a translation is not defined.
*
* See {@link MissingTranslation.register} for instructions on how to define
* your own behavior.
*
* @type {MissingBehavior}
*/
public missingBehavior: string;
/**
* Return a missing placeholder message for given parameters.
*
* @type {MissingPlaceholderHandler}
*/
public missingPlaceholder: MissingPlaceholderHandler;
/**
* If you use missingBehavior with 'message', but want to know that the string
* is actually missing for testing purposes, you can prefix the guessed string
* by setting the value here. By default, no prefix is used.
*
* @type {string}
*/
public missingTranslationPrefix: string;
/**
* Return a placeholder message for null values. Defaults to the same behavior
* as `I18n.missingPlaceholder`.
*
* @type {NullPlaceholderHandler}
*/
public nullPlaceholder: NullPlaceholderHandler;
/**
* The missing translation behavior registry.
*
* @see {@link MissingTranslation}
*
* @type {MissingTranslation}
*/
public missingTranslation: MissingTranslation;
/**
* Set the placeholder format. Accepts `{{placeholder}}` and `%{placeholder}`.
*
* @type {RegExp}
*/
public placeholder: RegExp;
/**
* Set the registered translations. The root key must always be the locale
* (and its variations with region).
*
* Remember that no events will be triggered if you change this object
* directly. To trigger `onchange` events, you must perform updates either
* using `I18n#store` or `I18n#update`.
*
* @type {Dict}
*/
public translations: Dict = {};
/**
* Transform keys. By default, it returns the key as it is, but allows for
* overriding. For instance, you can set a function to receive the camelcase
* key, and convert it to snake case.
*
* @type {(key: string) => string}
*/
public transformKey: (key: string) => string;
/**
* Override the interpolation function. For the default implementation, see
* <https://github.com/fnando/i18n/tree/main/src/helpers/interpolate.ts>
* @type {(i18n: I18n, message: string, options: TranslateOptions) => string}
*/
public interpolate: typeof interpolate;
/**
* Set the available locales.
*
* @type {string[]}
*/
public availableLocales: string[] = [];
constructor(translations: Dict = {}, options: Partial<I18nOptions> = {}) {
const {
locale,
enableFallback,
missingBehavior,
missingTranslationPrefix,
missingPlaceholder,
nullPlaceholder,
defaultLocale,
defaultSeparator,
placeholder,
transformKey,
}: I18nOptions = {
...DEFAULT_I18N_OPTIONS,
...options,
};
this.locale = locale;
this.defaultLocale = defaultLocale;
this.defaultSeparator = defaultSeparator;
this.enableFallback = enableFallback;
this.locale = locale;
this.missingBehavior = missingBehavior;
this.missingTranslationPrefix = missingTranslationPrefix;
this.missingPlaceholder = missingPlaceholder;
this.nullPlaceholder = nullPlaceholder;
this.placeholder = placeholder;
this.pluralization = new Pluralization(this);
this.locales = new Locales(this);
this.missingTranslation = new MissingTranslation(this);
this.transformKey = transformKey;
this.interpolate = interpolate;
this.store(translations);
}
/**
* Update translations by merging them. Newest translations will override
* existing ones.
*
* @param {Dict} translations An object containing the translations that will
* be merged into existing translations.
*
* @returns {void}
*/
public store(translations: Dict): void {
merge(this.translations, translations);
this.hasChanged();
}
/**
* Return the current locale, using a explicit locale set using
* `i18n.locale = newLocale`, the default locale set using
* `i18n.defaultLocale` or the fallback, which is `en`.
*
* @returns {string} The current locale.
*/
public get locale(): string {
return this._locale || this.defaultLocale || "en";
}
/**
* Set the current locale explicitly.
*
* @param {string} newLocale The new locale.
*/
public set locale(newLocale: string) {
if (typeof newLocale !== "string") {
throw new Error(
`Expected newLocale to be a string; got ${inferType(newLocale)}`,
);
}
const changed = this._locale !== newLocale;
this._locale = newLocale;
if (changed) {
this.hasChanged();
}
}
/**
* Return the default locale, using a explicit locale set using
* `i18n.defaultLocale = locale`, the default locale set using
* `i18n.defaultLocale` or the fallback, which is `en`.
*
* @returns {string} The current locale.
*/
public get defaultLocale(): string {
return this._defaultLocale || "en";
}
/**
* Set the default locale explicitly.
*
* @param {string} newLocale The new locale.
*/
public set defaultLocale(newLocale: string) {
if (typeof newLocale !== "string") {
throw new Error(
`Expected newLocale to be a string; got ${inferType(newLocale)}`,
);
}
const changed = this._defaultLocale !== newLocale;
this._defaultLocale = newLocale;
if (changed) {
this.hasChanged();
}
}
/**
* Translate the given scope with the provided options.
*
* @param {string|array} scope The scope that will be used.
*
* @param {TranslateOptions} options The options that will be used on the
* translation. Can include some special options like `defaultValue`, `count`,
* and `scope`. Everything else will be treated as replacement values.
*
* @param {number} options.count Enable pluralization. The returned
* translation will depend on the detected pluralizer.
*
* @param {any} options.defaultValue The default value that will used in case
* the translation defined by `scope` cannot be found. Can be a function that
* returns a string; the signature is
* `(i18n:I18n, options: TranslateOptions): string`.
*
* @param {MissingBehavior|string} options.missingBehavior The missing
* behavior that will be used instead of the default one.
*
* @param {Dict[]} options.defaults An array of hashs where the key is the
* type of translation desired, a `scope` or a `message`. The translation
* returned will be either the first scope recognized, or the first message
* defined.
*
* @returns {T | string} The translated string.
*/
public translate<T = string>(
scope: Scope,
options?: TranslateOptions,
): string | T {
options = { ...options };
const translationOptions: TranslateOptions[] = createTranslationOptions(
this,
scope,
options,
) as TranslateOptions[];
let translation: string | Dict | undefined;
// Iterate through the translation options until a translation
// or message is found.
const hasFoundTranslation = translationOptions.some(
(translationOption: TranslateOptions) => {
if (isSet(translationOption.scope)) {
translation = lookup(this, translationOption.scope as Scope, options);
} else if (isSet(translationOption.message)) {
translation = translationOption.message;
}
return translation !== undefined && translation !== null;
},
);
if (!hasFoundTranslation) {
return this.missingTranslation.get(scope, options);
}
if (typeof translation === "string") {
translation = this.interpolate(this, translation, options);
} else if (
typeof translation === "object" &&
translation &&
isSet(options.count)
) {
translation = pluralize({
i18n: this,
count: options.count || 0,
scope: translation as unknown as string,
options,
baseScope: getFullScope(this, scope, options),
});
}
if (options && translation instanceof Array) {
translation = translation.map((entry) =>
typeof entry === "string"
? interpolate(this, entry, options as TranslateOptions)
: entry,
);
}
return translation as string | T;
}
/**
* @alias {@link translate}
*/
public t = this.translate;
/**
* Pluralize the given scope using the `count` value. The pluralized
* translation may have other placeholders, which will be retrieved from
* `options`.
*
* @param {number} count The counting number.
*
* @param {Scope} scope The translation scope.
*
* @param {TranslateOptions} options The translation options.
*
* @returns {string} The translated string.
*/
public pluralize(
count: number,
scope: Scope,
options?: TranslateOptions,
): string {
return pluralize({
i18n: this,
count,
scope,
options: { ...options },
baseScope: getFullScope(this, scope, options ?? {}),
});
}
/**
* @alias {@link pluralize}
*/
public p = this.pluralize;
/**
* Localize several values.
*
* You can provide the following scopes: `currency`, `number`, or
* `percentage`. If you provide a scope that matches the `/^(date|time)/`
* regular expression then the `value` will be converted by using the
* `I18n.toTime` function. It will default to the value's `toString` function.
*
* If value is either `null` or `undefined` then an empty string will be
* returned, regardless of what localization type has been used.
*
* @param {string} type The localization type.
*
* @param {string|number|Date} value The value that must be localized.
*
* @param {Dict} options The localization options.
*
* @returns {string} The localized string.
*/
public localize(
type: string,
value: string | number | Date | null | undefined,
options?: Dict,
): string {
options = { ...options };
if (value === undefined || value === null) {
return "";
}
switch (type) {
case "currency":
return this.numberToCurrency(value as number);
case "number":
return formatNumber(value as number, {
delimiter: ",",
precision: 3,
separator: ".",
significant: false,
stripInsignificantZeros: false,
...lookup(this, "number.format"),
});
case "percentage":
return this.numberToPercentage(value as number);
default: {
let localizedValue: string;
if (type.match(/^(date|time)/)) {
localizedValue = this.toTime(type, value as DateTime);
} else {
localizedValue = (value as string | number | Date).toString();
}
return interpolate(this, localizedValue, options);
}
}
}
/**
* @alias {@link localize}
*/
public l = this.localize;
/**
* Convert the given dateString into a formatted date.
*
* @param {scope} scope The formatting scope.
*
* @param {DateTime} input The string that must be parsed into a Date object.
*
* @returns {string} The formatted date.
*/
public toTime(scope: Scope, input: DateTime): string {
const date = parseDate(input);
const format: string = lookup(this, scope);
if (date.toString().match(/invalid/i)) {
return date.toString();
}
if (!format) {
return date.toString();
}
return this.strftime(date, format);
}
/**
* Formats a `number` into a currency string (e.g., $13.65). You can customize
* the format in the using an `options` object.
*
* The currency unit and number formatting of the current locale will be used
* unless otherwise specified in the provided options. No currency conversion
* is performed. If the user is given a way to change their locale, they will
* also be able to change the relative value of the currency displayed with
* this helper.
*
* @example
* ```js
* i18n.numberToCurrency(1234567890.5);
* // => "$1,234,567,890.50"
*
* i18n.numberToCurrency(1234567890.506);
* // => "$1,234,567,890.51"
*
* i18n.numberToCurrency(1234567890.506, { precision: 3 });
* // => "$1,234,567,890.506"
*
* i18n.numberToCurrency("123a456");
* // => "$123a456"
*
* i18n.numberToCurrency("123a456", { raise: true });
* // => raises exception ("123a456" is not a valid numeric value)
*
* i18n.numberToCurrency(-0.456789, { precision: 0 });
* // => "$0"
*
* i18n.numberToCurrency(-1234567890.5, { negativeFormat: "(%u%n)" });
* // => "($1,234,567,890.50)"
*
* i18n.numberToCurrency(1234567890.5, {
* unit: "£",
* separator: ",",
* delimiter: "",
* });
* // => "£1234567890,50"
*
* i18n.numberToCurrency(1234567890.5, {
* unit: "£",
* separator: ",",
* delimiter: "",
* format: "%n %u",
* });
* // => "1234567890,50 £"
*
* i18n.numberToCurrency(1234567890.5, { stripInsignificantZeros: true });
* // => "$1,234,567,890.5"
*
* i18n.numberToCurrency(1234567890.5, { precision: 0, roundMode: "up" });
* // => "$1,234,567,891"
* ```
*
* @param {Numeric} input The number to be formatted.
*
* @param {NumberToCurrencyOptions} options The formatting options. When
* defined, supersedes the default options defined by `number.format` and
* `number.currency.*`.
*
* @param {number} options.precision Sets the level of precision (defaults to
* 2).
*
* @param {RoundingMode} options.roundMode Determine how rounding is performed
* (defaults to `default`.)
*
* @param {string} options.unit Sets the denomination of the currency
* (defaults to "$").
*
* @param {string} options.separator Sets the separator between the units
* (defaults to ".").
*
* @param {string} options.delimiter Sets the thousands delimiter
* (defaults to ",").
*
* @param {string} options.format Sets the format for non-negative numbers
* (defaults to "%u%n"). Fields are `%u` for the currency, and `%n` for the
* number.
*
* @param {string} options.negativeFormat Sets the format for negative numbers
* (defaults to prepending a hyphen to the formatted number given by
* `format`). Accepts the same fields than `format`, except `%n` is here the
* absolute value of the number.
*
* @param {boolean} options.stripInsignificantZeros If `true` removes
* insignificant zeros after the decimal separator (defaults to `false`).
*
* @param {boolean} options.raise If `true`, raises exception for non-numeric
* values like `NaN` and infinite values.
*
* @returns {string} The formatted number.
*/
public numberToCurrency(
input: Numeric,
options: Partial<NumberToCurrencyOptions> = {},
): string {
return formatNumber(input, {
delimiter: ",",
format: "%u%n",
precision: 2,
separator: ".",
significant: false,
stripInsignificantZeros: false,
unit: "$",
...camelCaseKeys<Partial<FormatNumberOptions>>(this.get("number.format")),
...camelCaseKeys<Partial<NumberToCurrencyOptions>>(
this.get("number.currency.format"),
),
...options,
} as FormatNumberOptions);
}
/**
* Convert a number into a formatted percentage value.
*
* @example
* ```js
* i18n.numberToPercentage(100);
* // => "100.000%"
*
* i18n.numberToPercentage("98");
* // => "98.000%"
*
* i18n.numberToPercentage(100, { precision: 0 });
* // => "100%"
*
* i18n.numberToPercentage(1000, { delimiter: ".", separator: "," });
* // => "1.000,000%"
*
* i18n.numberToPercentage(302.24398923423, { precision: 5 });
* // => "302.24399%"
*
* i18n.numberToPercentage(1000, { precision: null });
* // => "1000%"
*
* i18n.numberToPercentage("98a");
* // => "98a%"
*
* i18n.numberToPercentage(100, { format: "%n %" });
* // => "100.000 %"
*
* i18n.numberToPercentage(302.24398923423, { precision: 5, roundMode: "down" });
* // => "302.24398%"
* ```
*
* @param {Numeric} input The number to be formatted.
*
* @param {NumberToPercentageOptions} options The formatting options. When
* defined, supersedes the default options stored at `number.format` and
* `number.percentage.*`.
*
* @param {number} options.precision Sets the level of precision (defaults to
* 3).
*
* @param {RoundingMode} options.roundMode Determine how rounding is performed
* (defaults to `default`.)
*
* @param {string} options.separator Sets the separator between the units
* (defaults to ".").
*
* @param {string} options.delimiter Sets the thousands delimiter (defaults to
* "").
*
* @param {string} options.format Sets the format for non-negative numbers
* (defaults to "%n%"). The number field is represented by `%n`.
*
* @param {string} options.negativeFormat Sets the format for negative numbers
* (defaults to prepending a hyphen to the formatted number given by
* `format`). Accepts the same fields than `format`, except `%n` is here the
* absolute value of the number.
*
* @param {boolean} options.stripInsignificantZeros If `true` removes
* insignificant zeros after the decimal separator (defaults to `false`).
*
* @returns {string} The formatted number.
*/
public numberToPercentage(
input: Numeric,
options: Partial<NumberToPercentageOptions> = {},
): string {
return formatNumber(input, {
delimiter: "",
format: "%n%",
precision: 3,
stripInsignificantZeros: false,
separator: ".",
significant: false,
...camelCaseKeys<Partial<FormatNumberOptions>>(this.get("number.format")),
...camelCaseKeys<Partial<NumberToPercentageOptions>>(
this.get("number.percentage.format"),
),
...options,
} as FormatNumberOptions);
}
/**
* Convert a number into a readable size representation.
*
* @example
* ```js
* i18n.numberToHumanSize(123)
* // => "123 Bytes"
*
* i18n.numberToHumanSize(1234)
* // => "1.21 KB"
*
* i18n.numberToHumanSize(12345)
* // => "12.1 KB"
*
* i18n.numberToHumanSize(1234567)
* // => "1.18 MB"
*
* i18n.numberToHumanSize(1234567890)
* // => "1.15 GB"
*
* i18n.numberToHumanSize(1234567890123)
* // => "1.12 TB"
*
* i18n.numberToHumanSize(1234567890123456)
* // => "1.1 PB"
*
* i18n.numberToHumanSize(1234567890123456789)
* // => "1.07 EB"
*
* i18n.numberToHumanSize(1234567, {precision: 2})
* // => "1.2 MB"
*
* i18n.numberToHumanSize(483989, precision: 2)
* // => "470 KB"
*
* i18n.numberToHumanSize(483989, {precision: 2, roundMode: "up"})
* // => "480 KB"
*
* i18n.numberToHumanSize(1234567, {precision: 2, separator: ","})
* // => "1,2 MB"
*
* i18n.numberToHumanSize(1234567890123, {precision: 5})
* // => "1.1228 TB"
*
* i18n.numberToHumanSize(524288000, {precision: 5})
* // => "500 MB"
* ```
*
* @param {Numeric} input The number that will be formatted.
*
* @param {NumberToHumanSizeOptions} options The formatting options. When
* defined, supersedes the default options stored at
* `number.human.storage_units.*` and `number.human.format`.
*
* @param {number} options.precision Sets the precision of the number
* (defaults to 3).
*
* @param {RoundingMode} options.roundMode Determine how rounding is performed
* (defaults to `default`)
*
* @param {boolean} options.significant If `true`, precision will be the
* number of significant digits. If `false`, the number of fractional digits
* (defaults to `true`).
*
* @param {string} options.separator Sets the separator between the fractional
* and integer digits (defaults to ".").
*
* @param {string} options.delimiter Sets the thousands delimiter (defaults
* to "").
*
* @param {boolean} options.stripInsignificantZeros If `true` removes
* insignificant zeros after the decimal separator (defaults to `true`).
*
* @returns {string} The formatted number.
*/
public numberToHumanSize(
input: Numeric,
options: Partial<NumberToHumanSizeOptions> = {},
): string {
return numberToHumanSize(this, input, {
delimiter: "",
precision: 3,
significant: true,
stripInsignificantZeros: true,
units: {
billion: "Billion",
million: "Million",
quadrillion: "Quadrillion",
thousand: "Thousand",
trillion: "Trillion",
unit: "",
},
...camelCaseKeys<Partial<NumberToHumanSizeOptions>>(
this.get("number.human.format"),
),
...camelCaseKeys<Partial<NumberToHumanSizeOptions>>(
this.get("number.human.storage_units"),
),
...options,
} as NumberToHumanSizeOptions);
}
/**
* Convert a number into a readable representation.
*
* @example
* ```js
* i18n.numberToHuman(123);
* // => "123"
*
* i18n.numberToHuman(1234);
* // => "1.23 Thousand"
*
* i18n.numberToHuman(12345);
* // => "12.3 Thousand"
*
* i18n.numberToHuman(1234567);
* // => "1.23 Million"
*
* i18n.numberToHuman(1234567890);
* // => "1.23 Billion"
*
* i18n.numberToHuman(1234567890123);
* // => "1.23 Trillion"
*
* i18n.numberToHuman(1234567890123456);
* // => "1.23 Quadrillion"
*
* i18n.numberToHuman(1234567890123456789);
* // => "1230 Quadrillion"
*
* i18n.numberToHuman(489939, { precision: 2 });
* // => "490 Thousand"
*
* i18n.numberToHuman(489939, { precision: 4 });
* // => "489.9 Thousand"
*
* i18n.numberToHuman(489939, { precision: 2, roundMode: "down" });
* // => "480 Thousand"
*
* i18n.numberToHuman(1234567, { precision: 4, significant: false });
* // => "1.2346 Million"
*
* i18n.numberToHuman(1234567, {
* precision: 1,
* separator: ",",
* significant: false,
* });
* // => "1,2 Million"
*
* i18n.numberToHuman(500000000, { precision: 5 });
* // => "500 Million"
*
* i18n.numberToHuman(12345012345, { significant: false });
* // => "12.345 Billion"
* ```
*
* Non-significant zeros after the decimal separator are stripped out by default
* (set `stripInsignificantZeros` to `false` to change that):
*
* ```js
* i18n.numberToHuman(12.00001);
* // => "12"
*
* i18n.numberToHuman(12.00001, { stripInsignificantZeros: false });
* // => "12.0"
* ```
*
* You can also use your own custom unit quantifiers:
*
* ```js
* i18n.numberToHuman(500000, units: { unit: "ml", thousand: "lt" });
* // => "500 lt"
* ```
*
* If in your I18n locale you have:
*
* ```yaml
* ---
* en:
* distance:
* centi:
* one: "centimeter"
* other: "centimeters"
* unit:
* one: "meter"
* other: "meters"
* thousand:
* one: "kilometer"
* other: "kilometers"
* billion: "gazillion-distance"
* ```
*
* Then you could do:
*
* ```js
* i18n.numberToHuman(543934, { units: "distance" });
* // => "544 kilometers"
*
* i18n.numberToHuman(54393498, { units: "distance" });
* // => "54400 kilometers"
*
* i18n.numberToHuman(54393498000, { units: "distance" });
* // => "54.4 gazillion-distance"
*
* i18n.numberToHuman(343, { units: "distance", precision: 1 });
* // => "300 meters"
*
* i18n.numberToHuman(1, { units: "distance" });
* // => "1 meter"
*
* i18n.numberToHuman(0.34, { units: "distance" });
* // => "34 centimeters"
* ```
*
* @param {Numeric} input The number that will be formatted.
*
* @param {NumberToHumanOptions} options The formatting options. When
* defined, supersedes the default options stored at `number.human.format.*`
* and `number.human.storage_units.*`.
*
* @param {number} options.precision Sets the precision of the number
* (defaults to 3).
*
* @param {RoundingMode} options.roundMode Determine how rounding is performed
* (defaults to `default`).
*
* @param {boolean} options.significant If `true`, precision will be the
* number of significant_digits. If `false`, the number of fractional digits
* (defaults to `true`)
*
* @param {string} options.separator Sets the separator between the fractional
* and integer digits (defaults to ".").
*
* @param {string} options.delimiter Sets the thousands delimiter
* (defaults to "").
*
* @param {boolean} options.stripInsignificantZeros If `true` removes
* insignificant zeros after the decimal separator (defaults to `true`).
*
* @param {Dict} options.units A Hash of unit quantifier names. Or a string
* containing an I18n scope where to find this object. It might have the
* following keys:
*
* - _integers_: `unit`, `ten`, `hundred`, `thousand`, `million`, `billion`,
* `trillion`, `quadrillion`
* - _fractionals_: `deci`, `centi`, `mili`, `micro`, `nano`, `pico`, `femto`
*
* @param {string} options.format Sets the format of the output string
* (defaults to "%n %u"). The field types are:
*
* - `%u` - The quantifier (ex.: 'thousand')
* - `%n` - The number
*
* @returns {string} The formatted number.
*/
public numberToHuman(
input: Numeric,
options: Partial<NumberToHumanOptions> = {},
): string {
return numberToHuman(this, input, {
delimiter: "",
separator: ".",
precision: 3,
significant: true,
stripInsignificantZeros: true,
format: "%n %u",
roundMode: "default",
units: {
billion: "Billion",
million: "Million",
quadrillion: "Quadrillion",
thousand: "Thousand",
trillion: "Trillion",
unit: "",
},
...camelCaseKeys<Partial<NumberToHumanOptions>>(
this.get("number.human.format"),
),
...camelCaseKeys<Partial<NumberToHumanOptions>>(
this.get("number.human.decimal_units"),
),
...options,
} as NumberToHumanOptions);
}
/**
* Convert number to a formatted rounded value.
*
* @example
* ```js
* i18n.numberToRounded(111.2345);
* // => "111.235"
*
* i18n.numberToRounded(111.2345, { precision: 2 });
* // => "111.23"
*
* i18n.numberToRounded(13, { precision: 5 });
* // => "13.00000"
*
* i18n.numberToRounded(389.32314, { precision: 0 });
* // => "389"
*
* i18n.numberToRounded(111.2345, { significant: true });
* // => "111"
*
* i18n.numberToRounded(111.2345, { precision: 1, significant: true });
* // => "100"
*
* i18n.numberToRounded(13, { precision: 5, significant: true });
* // => "13.000"
*
* i18n.numberToRounded(13, { precision: null });
* // => "13"
*
* i18n.numberToRounded(389.32314, { precision: 0, roundMode: "up" });
* // => "390"
*
* i18n.numberToRounded(13, {
* precision: 5,
* significant: true,
* stripInsignificantZeros: true,
* });
* // => "13"
*
* i18n.numberToRounded(389.32314, { precision: 4, significant: true });
* // => "389.3"
*
* i18n.numberToRounded(1111.2345, {
* precision: 2,
* separator: ",",
* delimiter: ".",
* });
* // => "1.111,23"
* ```
*
* @param {Numeric} input The number to be formatted.
*
* @param {NumberToRoundedOptions} options The formatting options.
*
* @param {number} options.precision Sets the precision of the number
* (defaults to 3).
*
* @param {string} options.separator Sets the separator between the
* fractional and integer digits (defaults to ".").
*
* @param {RoundingMode} options.roundMode Determine how rounding is
* performed.
*
* @param {boolean} options.significant If `true`, precision will be the
* number of significant_digits. If `false`, the number of fractional digits
* (defaults to `false`).
*
* @param {boolean} options.stripInsignificantZeros If `true` removes
* insignificant zeros after the decimal separator (defaults to `false`).
*
* @returns {string} The formatted number.
*/
public numberToRounded(
input: Numeric,
options?: Partial<NumberToRoundedOptions>,
): string {
return formatNumber(input, {
unit: "",
precision: 3,
significant: false,
separator: ".",
delimiter: "",
stripInsignificantZeros: false,
...options,
} as FormatNumberOptions);
}
/**
* Formats a +number+ with grouped thousands using `delimiter` (e.g., 12,324).
* You can customize the format in the `options` parameter.
*
* @example
* ```js
* i18n.numberToDelimited(12345678);
* // => "12,345,678"
*
* i18n.numberToDelimited("123456");
* // => "123,456"
*
* i18n.numberToDelimited(12345678.05);
* // => "12,345,678.05"
*
* i18n.numberToDelimited(12345678, { delimiter: "." });
* // => "12.345.678"
*
* i18n.numberToDelimited(12345678, { delimiter: "," });
* // => "12,345,678"
*
* i18n.numberToDelimited(12345678.05, { separator: " " });
* // => "12,345,678 05"
*
* i18n.numberToDelimited("112a");
* // => "112a"
*
* i18n.numberToDelimited(98765432.98, { delimiter: " ", separator: "," });
* // => "98 765 432,98"
*
* i18n.numberToDelimited("123456.78", {
* delimiterPattern: /(\d+?)(?=(\d\d)+(\d)(?!\d))/g,
* });
* // => "1,23,456.78"
* ```
*
* @param {Numeric} input The numeric value that will be formatted.
*
* @param {NumberToDelimitedOptions} options The formatting options.
*
* @param {string} options.delimiter Sets the thousands delimiter (defaults to
* ",").
*
* @param {string} options.separator Sets the separator between the fractional
* and integer digits (defaults to ".").
*
* @param {RegExp} options.delimiterPattern Sets a custom regular expression
* used for deriving the placement of delimiter. Helpful when using currency
* formats like INR.
*
* @return {string} The formatted number.
*/
public numberToDelimited(
input: Numeric,
options: Partial<NumberToDelimitedOptions> = {},
): string {
return numberToDelimited(input, {
delimiterPattern: /(\d)(?=(\d\d\d)+(?!\d))/g,
delimiter: ",",
separator: ".",
...options,
} as NumberToDelimitedOptions);
}
/**
* Executes function with given locale set. The locale will be changed only
* during the `callback`'s execution, switching back to the previous value
* once it finishes (with or without errors).
*
* This is an asynchronous call, which means you must use `await` or you may
* end up with a race condition.
*
* @example
* ```js
* await i18n.withLocale("pt", () => {
* console.log(i18n.t("hello"));
* });
* ```
*
* @param {string} locale The temporary locale that will be set during the
* function's execution.
*
* @param {Function} callback The function that will be executed with a
* temporary locale set.
*
* @returns {void}
*/
public async withLocale(locale: string, callback: () => void): Promise<void> {
const originalLocale = this.locale;
try {
this.locale = locale;
await callback();
} finally {
this.locale = originalLocale;
}
}
/**
* Formats time according to the directives in the given format string.
* The directives begins with a percent (`%`) character. Any text not listed
* as a directive will be passed through to the output string.
*
* @see strftime
*
* @param {Date} date The date that will be formatted.
*
* @param {string} format The formatting string.
*
* @param {StrftimeOptions} options The formatting options.
*
* @returns {string} The formatted date.
*/
public strftime(
date: Date,
format: string,
options: Partial<StrftimeOptions> = {},
): string {
return strftime(date, format, {
...camelCaseKeys(lookup(this, "date")),
meridian: {
am: lookup(this, "time.am") || "AM",
pm: lookup(this, "time.pm") || "PM",
},
...options,
});
}
/**
* You may want to update a part of your translations. This is a public
* interface for doing it so.
*
* If the provided path exists, it'll be replaced. Otherwise, a new node will
* be created. When running in strict mode, paths that doesn't already exist
* will raise an exception.
*
* Strict mode will also raise an exception if the override type differs from
* previous node type.
*
* @example
* ```js
* i18n.update("en.number.format", {unit: "%n %u"});
* i18n.update("en.number.format", {unit: "%n %u"}, true);
* ```
*
* @param {string} path The path that's going to be updated. It must
* include the language, as in `en.messages`.
*
* @param {Dict} override The new translation node.
*
* @param {boolean} options Set options.
*
* @param {boolean} options.strict Raise an exception if path doesn't already
* exist, or if previous node's type differs from new node's type.
*
* @returns {void}
*/
public update(
path: string,
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
override: any,
options: { strict: boolean } = { strict: false },
): void {
if (options.strict && !has(this.translations, path)) {
throw new Error(`The path "${path}" is not currently defined`);
}
const currentNode = get(this.translations, path);
const currentType = inferType(currentNode);
const overrideType = inferType(override);
if (options.strict && currentType !== overrideType) {
throw new Error(
`The current type for "${path}" is "${currentType}", but you're trying to override it with "${overrideType}"`,
);
}
let newNode: unknown;
if (overrideType === "object") {
newNode = { ...currentNode, ...override };
} else {
newNode = override;
}
const components = path.split(this.defaultSeparator);
const prop = components.pop();
let buffer = this.translations;
for (const component of components) {
if (!buffer[component]) {
buffer[component] = {};
}
buffer = buffer[component];
}
buffer[prop as keyof typeof buffer] = newNode;
this.hasChanged();
}
/**
* Converts the array to a comma-separated sentence where the last element is
* joined by the connector word.
*
* @example
* ```js
* i18n.toSentence(["apple", "banana", "pineapple"]);
* //=> apple, banana, and pineapple.
* ```
*
* @param {any[]} items The list of items that will be joined.
*
* @param {ToSentenceOptions} options The options.
*
* @param {string} options.wordsConnector The sign or word used to join the
* elements in arrays with two or more elements (default: ", ").
*
* @param {string} options.twoWordsConnector The sign or word used to join the
* elements in arrays with two elements (default: " and ").
*
* @param {string} options.lastWordConnector The sign or word used to join the
* last element in arrays with three or more elements (default: ", and ").
*
* @returns {string} The joined string.
*/
public toSentence(
items: any[],
options: Partial<ToSentenceOptions> = {},
): string {
const { wordsConnector, twoWordsConnector, lastWordConnector } = {
wordsConnector: ", ",
twoWordsConnector: " and ",
lastWordConnector: ", and ",
...camelCaseKeys<Partial<ToSentenceOptions>>(
lookup(this, "support.array"),
),
...options,
} as ToSentenceOptions;
const size = items.length;
switch (size) {
case 0:
return "";
case 1:
return `${items[0]}`;
case 2:
return items.join(twoWordsConnector);
default:
return [
items.slice(0, size - 1).join(wordsConnector),
lastWordConnector,
items[size - 1],
].join("");
}
}
/**
* Reports the approximate distance in time between two time representations.
*
* @param {DateTime} fromTime The initial time.
*
* @param {DateTime} toTime The ending time. Defaults to `Date.now()`.
*
* @param {TimeAgoInWordsOptions} options The options.
*
* @param {boolean} options.includeSeconds Pass `{includeSeconds: true}` if
* you want more detailed approximations when distance < 1 min, 29 secs.
*
* @param {Scope} options.scope With the scope option, you can define a custom
* scope to look up the translation.
*
* @returns {string} The distance in time representation.
*/
public timeAgoInWords(
fromTime: DateTime,
toTime: DateTime,
options: TimeAgoInWordsOptions = {},
): string {
return timeAgoInWords(this, fromTime, toTime, options);
}
/**
* @alias {@link timeAgoInWords}
*/
public distanceOfTimeInWords = this.timeAgoInWords;
/**
* Add a callback that will be executed whenever locale/defaultLocale changes,
* or `I18n#store` / `I18n#update` is called.
*
* @param {OnChangeHandler} callback The callback that will be executed.
*
* @returns {Function} Return a function that can be used to unsubscribe the
* event handler.
*
*/
public onChange(callback: OnChangeHandler): () => void {
this.onChangeHandlers.push(callback);
return () => {
this.onChangeHandlers.splice(this.onChangeHandlers.indexOf(callback), 1);
};
}
/**
* Return the change version. This value is incremented whenever `I18n#store`
* or `I18n#update` is called, or when `I18n#locale`/`I18n#defaultLocale` is
* set.
*/
public get version(): number {
return this._version;
}
/**
* Formats a number.
*
* @param {Numeric} input The numeric value that will be
* formatted.
* @param {FormatNumberOptions} options The formatting options. Defaults to:
* `{
* delimiter: ",",
* precision: 3,
* separator: ".",
* unit: "",
* format: "%u%n",
* significant: false,
* stripInsignificantZeros: false,
* }`
* @return {string} The formatted number.
*/
public formatNumber(
input: Numeric,
options: Partial<FormatNumberOptions> = {},
): string {
options = {
delimiter: ",",
precision: 3,
separator: ".",
unit: "",
format: "%u%n",
significant: false,
stripInsignificantZeros: false,
...camelCaseKeys<Partial<FormatNumberOptions>>(this.get("number.format")),
...options,
};
return formatNumber(input, options as FormatNumberOptions);
}
/**
* @param {Scope} scope The scope lookup path.
*
* @returns {any} The found scope.
*/
public get(scope: Scope): any {
return lookup(this, scope);
}
/**
* @private
*
* @returns {void}
*/
private runCallbacks(): void {
this.onChangeHandlers.forEach((callback) => callback(this));
}
/**
* @private
*
* @returns {void}
*/
private hasChanged(): void {
this._version += 1;
this.runCallbacks();
}
}