UNPKG

42.5 kBPlain TextView Raw
1/* eslint-disable class-methods-use-this, no-underscore-dangle */
2
3import get from "lodash/get";
4import has from "lodash/has";
5import merge from "lodash/merge";
6
7import {
8 DateTime,
9 Dict,
10 FormatNumberOptions,
11 I18nOptions,
12 MissingPlaceholderHandler,
13 NullPlaceholderHandler,
14 NumberToCurrencyOptions,
15 NumberToDelimitedOptions,
16 NumberToHumanOptions,
17 NumberToHumanSizeOptions,
18 NumberToPercentageOptions,
19 NumberToRoundedOptions,
20 Numeric,
21 OnChangeHandler,
22 Scope,
23 StrftimeOptions,
24 TimeAgoInWordsOptions,
25 ToSentenceOptions,
26 TranslateOptions,
27} from "./typing";
28import { Locales } from "./Locales";
29import { Pluralization } from "./Pluralization";
30import { MissingTranslation } from "./MissingTranslation";
31import {
32 camelCaseKeys,
33 createTranslationOptions,
34 formatNumber,
35 getFullScope,
36 inferType,
37 interpolate,
38 isSet,
39 lookup,
40 numberToDelimited,
41 numberToHuman,
42 numberToHumanSize,
43 parseDate,
44 pluralize,
45 strftime,
46 timeAgoInWords,
47} from "./helpers";
48
49const DEFAULT_I18N_OPTIONS: I18nOptions = {
50 defaultLocale: "en",
51 locale: "en",
52 defaultSeparator: ".",
53 placeholder: /(?:\{\{|%\{)(.*?)(?:\}\}?)/gm,
54 enableFallback: false,
55 missingBehavior: "message",
56 missingTranslationPrefix: "",
57
58 missingPlaceholder: (_i18n: I18n, placeholder: string): string =>
59 `[missing "${placeholder}" value]`,
60
61 nullPlaceholder: (
62 i18n: I18n,
63 placeholder,
64 message: string,
65 options: Dict,
66 ): string => i18n.missingPlaceholder(i18n, placeholder, message, options),
67
68 transformKey: (key: string): string => key,
69};
70
71export class I18n {
72 private _locale: string = DEFAULT_I18N_OPTIONS.locale;
73 private _defaultLocale: string = DEFAULT_I18N_OPTIONS.defaultLocale;
74 private _version = 0;
75
76 /**
77 * List of all onChange handlers.
78 *
79 * @type {OnChangeHandler[]}
80 */
81 public onChangeHandlers: OnChangeHandler[] = [];
82
83 /**
84 * Set the default string separator. Defaults to `.`, as in
85 * `scope.translation`.
86 *
87 * @type {string}
88 */
89 public defaultSeparator: string;
90
91 /**
92 * Set if engine should fallback to the default locale when a translation is
93 * missing. Defaults to `false`.
94 *
95 * When enabled, missing translations will first be looked for in less
96 * specific versions of the requested locale and if that fails by taking them
97 * from your `I18n#defaultLocale`.
98 *
99 * @type {boolean}
100 */
101 public enableFallback: boolean;
102
103 /**
104 * The locale resolver registry.
105 *
106 * @see {@link Locales}
107 *
108 * @type {Locales}
109 */
110 public locales: Locales;
111
112 /**
113 * The pluralization behavior registry.
114 *
115 * @see {@link Pluralization}
116 *
117 * @type {Pluralization}
118 */
119 public pluralization: Pluralization;
120
121 /**
122 * Set missing translation behavior.
123 *
124 * - `message` will display a message that the translation is missing.
125 * - `guess` will try to guess the string.
126 * - `error` will raise an exception whenever a translation is not defined.
127 *
128 * See {@link MissingTranslation.register} for instructions on how to define
129 * your own behavior.
130 *
131 * @type {MissingBehavior}
132 */
133 public missingBehavior: string;
134
135 /**
136 * Return a missing placeholder message for given parameters.
137 *
138 * @type {MissingPlaceholderHandler}
139 */
140 public missingPlaceholder: MissingPlaceholderHandler;
141
142 /**
143 * If you use missingBehavior with 'message', but want to know that the string
144 * is actually missing for testing purposes, you can prefix the guessed string
145 * by setting the value here. By default, no prefix is used.
146 *
147 * @type {string}
148 */
149 public missingTranslationPrefix: string;
150
151 /**
152 * Return a placeholder message for null values. Defaults to the same behavior
153 * as `I18n.missingPlaceholder`.
154 *
155 * @type {NullPlaceholderHandler}
156 */
157 public nullPlaceholder: NullPlaceholderHandler;
158
159 /**
160 * The missing translation behavior registry.
161 *
162 * @see {@link MissingTranslation}
163 *
164 * @type {MissingTranslation}
165 */
166 public missingTranslation: MissingTranslation;
167
168 /**
169 * Set the placeholder format. Accepts `{{placeholder}}` and `%{placeholder}`.
170 *
171 * @type {RegExp}
172 */
173 public placeholder: RegExp;
174
175 /**
176 * Set the registered translations. The root key must always be the locale
177 * (and its variations with region).
178 *
179 * Remember that no events will be triggered if you change this object
180 * directly. To trigger `onchange` events, you must perform updates either
181 * using `I18n#store` or `I18n#update`.
182 *
183 * @type {Dict}
184 */
185 public translations: Dict = {};
186
187 /**
188 * Transform keys. By default, it returns the key as it is, but allows for
189 * overriding. For instance, you can set a function to receive the camelcase
190 * key, and convert it to snake case.
191 *
192 * @type {(key: string) => string}
193 */
194 public transformKey: (key: string) => string;
195
196 /**
197 * Override the interpolation function. For the default implementation, see
198 * <https://github.com/fnando/i18n/tree/main/src/helpers/interpolate.ts>
199 * @type {(i18n: I18n, message: string, options: TranslateOptions) => string}
200 */
201 public interpolate: typeof interpolate;
202
203 constructor(translations: Dict = {}, options: Partial<I18nOptions> = {}) {
204 const {
205 locale,
206 enableFallback,
207 missingBehavior,
208 missingTranslationPrefix,
209 missingPlaceholder,
210 nullPlaceholder,
211 defaultLocale,
212 defaultSeparator,
213 placeholder,
214 transformKey,
215 }: I18nOptions = {
216 ...DEFAULT_I18N_OPTIONS,
217 ...options,
218 };
219
220 this.locale = locale;
221 this.defaultLocale = defaultLocale;
222 this.defaultSeparator = defaultSeparator;
223 this.enableFallback = enableFallback;
224 this.locale = locale;
225 this.missingBehavior = missingBehavior;
226 this.missingTranslationPrefix = missingTranslationPrefix;
227 this.missingPlaceholder = missingPlaceholder;
228 this.nullPlaceholder = nullPlaceholder;
229 this.placeholder = placeholder;
230 this.pluralization = new Pluralization(this);
231 this.locales = new Locales(this);
232 this.missingTranslation = new MissingTranslation(this);
233 this.transformKey = transformKey;
234 this.interpolate = interpolate;
235
236 this.store(translations);
237 }
238
239 /**
240 * Update translations by merging them. Newest translations will override
241 * existing ones.
242 *
243 * @param {Dict} translations An object containing the translations that will
244 * be merged into existing translations.
245 *
246 * @returns {void}
247 */
248 public store(translations: Dict): void {
249 merge(this.translations, translations);
250 this.hasChanged();
251 }
252
253 /**
254 * Return the current locale, using a explicit locale set using
255 * `i18n.locale = newLocale`, the default locale set using
256 * `i18n.defaultLocale` or the fallback, which is `en`.
257 *
258 * @returns {string} The current locale.
259 */
260 public get locale(): string {
261 return this._locale || this.defaultLocale || "en";
262 }
263
264 /**
265 * Set the current locale explicitly.
266 *
267 * @param {string} newLocale The new locale.
268 */
269 public set locale(newLocale: string) {
270 if (typeof newLocale !== "string") {
271 throw new Error(
272 `Expected newLocale to be a string; got ${inferType(newLocale)}`,
273 );
274 }
275
276 const changed = this._locale !== newLocale;
277
278 this._locale = newLocale;
279
280 if (changed) {
281 this.hasChanged();
282 }
283 }
284
285 /**
286 * Return the default locale, using a explicit locale set using
287 * `i18n.defaultLocale = locale`, the default locale set using
288 * `i18n.defaultLocale` or the fallback, which is `en`.
289 *
290 * @returns {string} The current locale.
291 */
292 public get defaultLocale(): string {
293 return this._defaultLocale || "en";
294 }
295
296 /**
297 * Set the default locale explicitly.
298 *
299 * @param {string} newLocale The new locale.
300 */
301 public set defaultLocale(newLocale: string) {
302 if (typeof newLocale !== "string") {
303 throw new Error(
304 `Expected newLocale to be a string; got ${inferType(newLocale)}`,
305 );
306 }
307
308 const changed = this._defaultLocale !== newLocale;
309
310 this._defaultLocale = newLocale;
311
312 if (changed) {
313 this.hasChanged();
314 }
315 }
316
317 /**
318 * Translate the given scope with the provided options.
319 *
320 * @param {string|array} scope The scope that will be used.
321 *
322 * @param {TranslateOptions} options The options that will be used on the
323 * translation. Can include some special options like `defaultValue`, `count`,
324 * and `scope`. Everything else will be treated as replacement values.
325 *
326 * @param {number} options.count Enable pluralization. The returned
327 * translation will depend on the detected pluralizer.
328 *
329 * @param {any} options.defaultValue The default value that will used in case
330 * the translation defined by `scope` cannot be found. Can be a function that
331 * returns a string; the signature is
332 * `(i18n:I18n, options: TranslateOptions): string`.
333 *
334 * @param {MissingBehavior|string} options.missingBehavior The missing
335 * behavior that will be used instead of the default one.
336 *
337 * @param {Dict[]} options.defaults An array of hashs where the key is the
338 * type of translation desired, a `scope` or a `message`. The translation
339 * returned will be either the first scope recognized, or the first message
340 * defined.
341 *
342 * @returns {T | string} The translated string.
343 */
344 public translate<T = string>(
345 scope: Scope,
346 options?: TranslateOptions,
347 ): string | T {
348 options = { ...options };
349
350 const translationOptions: TranslateOptions[] = createTranslationOptions(
351 this,
352 scope,
353 options,
354 ) as TranslateOptions[];
355
356 let translation: string | Dict | undefined;
357
358 // Iterate through the translation options until a translation
359 // or message is found.
360 const hasFoundTranslation = translationOptions.some(
361 (translationOption: TranslateOptions) => {
362 if (isSet(translationOption.scope)) {
363 translation = lookup(this, translationOption.scope as Scope, options);
364 } else if (isSet(translationOption.message)) {
365 translation = translationOption.message;
366 }
367
368 return translation !== undefined && translation !== null;
369 },
370 );
371
372 if (!hasFoundTranslation) {
373 return this.missingTranslation.get(scope, options);
374 }
375
376 if (typeof translation === "string") {
377 translation = this.interpolate(this, translation, options);
378 } else if (
379 typeof translation === "object" &&
380 translation &&
381 isSet(options.count)
382 ) {
383 translation = pluralize({
384 i18n: this,
385 count: options.count || 0,
386 scope: translation as unknown as string,
387 options,
388 baseScope: getFullScope(this, scope, options),
389 });
390 }
391
392 if (options && translation instanceof Array) {
393 translation = translation.map((entry) =>
394 typeof entry === "string"
395 ? interpolate(this, entry, options as TranslateOptions)
396 : entry,
397 );
398 }
399
400 return translation as string | T;
401 }
402
403 /**
404 * @alias {@link translate}
405 */
406 public t = this.translate;
407
408 /**
409 * Pluralize the given scope using the `count` value. The pluralized
410 * translation may have other placeholders, which will be retrieved from
411 * `options`.
412 *
413 * @param {number} count The counting number.
414 *
415 * @param {Scope} scope The translation scope.
416 *
417 * @param {TranslateOptions} options The translation options.
418 *
419 * @returns {string} The translated string.
420 */
421 public pluralize(
422 count: number,
423 scope: Scope,
424 options?: TranslateOptions,
425 ): string {
426 return pluralize({
427 i18n: this,
428 count,
429 scope,
430 options: { ...options },
431 baseScope: getFullScope(this, scope, options ?? {}),
432 });
433 }
434
435 /**
436 * @alias {@link pluralize}
437 */
438 public p = this.pluralize;
439
440 /**
441 * Localize several values.
442 *
443 * You can provide the following scopes: `currency`, `number`, or
444 * `percentage`. If you provide a scope that matches the `/^(date|time)/`
445 * regular expression then the `value` will be converted by using the
446 * `I18n.toTime` function. It will default to the value's `toString` function.
447 *
448 * If value is either `null` or `undefined` then an empty string will be
449 * returned, regardless of what localization type has been used.
450 *
451 * @param {string} type The localization type.
452 *
453 * @param {string|number|Date} value The value that must be localized.
454 *
455 * @param {Dict} options The localization options.
456 *
457 * @returns {string} The localized string.
458 */
459 public localize(
460 type: string,
461 value: string | number | Date | null | undefined,
462 options?: Dict,
463 ): string {
464 options = { ...options };
465
466 if (value === undefined || value === null) {
467 return "";
468 }
469
470 switch (type) {
471 case "currency":
472 return this.numberToCurrency(value as number);
473
474 case "number":
475 return formatNumber(value as number, {
476 delimiter: ",",
477 precision: 3,
478 separator: ".",
479 significant: false,
480 stripInsignificantZeros: false,
481 ...lookup(this, "number.format"),
482 });
483
484 case "percentage":
485 return this.numberToPercentage(value as number);
486
487 default: {
488 let localizedValue: string;
489
490 if (type.match(/^(date|time)/)) {
491 localizedValue = this.toTime(type, value as DateTime);
492 } else {
493 localizedValue = (value as string | number | Date).toString();
494 }
495
496 return interpolate(this, localizedValue, options);
497 }
498 }
499 }
500
501 /**
502 * @alias {@link localize}
503 */
504 public l = this.localize;
505
506 /**
507 * Convert the given dateString into a formatted date.
508 *
509 * @param {scope} scope The formatting scope.
510 *
511 * @param {DateTime} input The string that must be parsed into a Date object.
512 *
513 * @returns {string} The formatted date.
514 */
515 public toTime(scope: Scope, input: DateTime): string {
516 const date = parseDate(input);
517 const format: string = lookup(this, scope);
518
519 if (date.toString().match(/invalid/i)) {
520 return date.toString();
521 }
522
523 if (!format) {
524 return date.toString();
525 }
526
527 return this.strftime(date, format);
528 }
529
530 /**
531 * Formats a `number` into a currency string (e.g., $13.65). You can customize
532 * the format in the using an `options` object.
533 *
534 * The currency unit and number formatting of the current locale will be used
535 * unless otherwise specified in the provided options. No currency conversion
536 * is performed. If the user is given a way to change their locale, they will
537 * also be able to change the relative value of the currency displayed with
538 * this helper.
539 *
540 * @example
541 * ```js
542 * i18n.numberToCurrency(1234567890.5);
543 * // => "$1,234,567,890.50"
544 *
545 * i18n.numberToCurrency(1234567890.506);
546 * // => "$1,234,567,890.51"
547 *
548 * i18n.numberToCurrency(1234567890.506, { precision: 3 });
549 * // => "$1,234,567,890.506"
550 *
551 * i18n.numberToCurrency("123a456");
552 * // => "$123a456"
553 *
554 * i18n.numberToCurrency("123a456", { raise: true });
555 * // => raises exception ("123a456" is not a valid numeric value)
556 *
557 * i18n.numberToCurrency(-0.456789, { precision: 0 });
558 * // => "$0"
559 *
560 * i18n.numberToCurrency(-1234567890.5, { negativeFormat: "(%u%n)" });
561 * // => "($1,234,567,890.50)"
562 *
563 * i18n.numberToCurrency(1234567890.5, {
564 * unit: "&pound;",
565 * separator: ",",
566 * delimiter: "",
567 * });
568 * // => "&pound;1234567890,50"
569 *
570 * i18n.numberToCurrency(1234567890.5, {
571 * unit: "&pound;",
572 * separator: ",",
573 * delimiter: "",
574 * format: "%n %u",
575 * });
576 * // => "1234567890,50 &pound;"
577 *
578 * i18n.numberToCurrency(1234567890.5, { stripInsignificantZeros: true });
579 * // => "$1,234,567,890.5"
580 *
581 * i18n.numberToCurrency(1234567890.5, { precision: 0, roundMode: "up" });
582 * // => "$1,234,567,891"
583 * ```
584 *
585 * @param {Numeric} input The number to be formatted.
586 *
587 * @param {NumberToCurrencyOptions} options The formatting options. When
588 * defined, supersedes the default options defined by `number.format` and
589 * `number.currency.*`.
590 *
591 * @param {number} options.precision Sets the level of precision (defaults to
592 * 2).
593 *
594 * @param {RoundingMode} options.roundMode Determine how rounding is performed
595 * (defaults to `default`.)
596 *
597 * @param {string} options.unit Sets the denomination of the currency
598 * (defaults to "$").
599 *
600 * @param {string} options.separator Sets the separator between the units
601 * (defaults to ".").
602 *
603 * @param {string} options.delimiter Sets the thousands delimiter
604 * (defaults to ",").
605 *
606 * @param {string} options.format Sets the format for non-negative numbers
607 * (defaults to "%u%n"). Fields are `%u` for the currency, and `%n` for the
608 * number.
609 *
610 * @param {string} options.negativeFormat Sets the format for negative numbers
611 * (defaults to prepending a hyphen to the formatted number given by
612 * `format`). Accepts the same fields than `format`, except `%n` is here the
613 * absolute value of the number.
614 *
615 * @param {boolean} options.stripInsignificantZeros If `true` removes
616 * insignificant zeros after the decimal separator (defaults to `false`).
617 *
618 * @param {boolean} options.raise If `true`, raises exception for non-numeric
619 * values like `NaN` and infinite values.
620 *
621 * @returns {string} The formatted number.
622 */
623 public numberToCurrency(
624 input: Numeric,
625 options: Partial<NumberToCurrencyOptions> = {},
626 ): string {
627 return formatNumber(input, {
628 delimiter: ",",
629 format: "%u%n",
630 precision: 2,
631 separator: ".",
632 significant: false,
633 stripInsignificantZeros: false,
634 unit: "$",
635 ...camelCaseKeys<Partial<FormatNumberOptions>>(this.get("number.format")),
636 ...camelCaseKeys<Partial<NumberToCurrencyOptions>>(
637 this.get("number.currency.format"),
638 ),
639 ...options,
640 } as FormatNumberOptions);
641 }
642
643 /**
644 * Convert a number into a formatted percentage value.
645 *
646 * @example
647 * ```js
648 * i18n.numberToPercentage(100);
649 * // => "100.000%"
650 *
651 * i18n.numberToPercentage("98");
652 * // => "98.000%"
653 *
654 * i18n.numberToPercentage(100, { precision: 0 });
655 * // => "100%"
656 *
657 * i18n.numberToPercentage(1000, { delimiter: ".", separator: "," });
658 * // => "1.000,000%"
659 *
660 * i18n.numberToPercentage(302.24398923423, { precision: 5 });
661 * // => "302.24399%"
662 *
663 * i18n.numberToPercentage(1000, { precision: null });
664 * // => "1000%"
665 *
666 * i18n.numberToPercentage("98a");
667 * // => "98a%"
668 *
669 * i18n.numberToPercentage(100, { format: "%n %" });
670 * // => "100.000 %"
671 *
672 * i18n.numberToPercentage(302.24398923423, { precision: 5, roundMode: "down" });
673 * // => "302.24398%"
674 * ```
675 *
676 * @param {Numeric} input The number to be formatted.
677 *
678 * @param {NumberToPercentageOptions} options The formatting options. When
679 * defined, supersedes the default options stored at `number.format` and
680 * `number.percentage.*`.
681 *
682 * @param {number} options.precision Sets the level of precision (defaults to
683 * 3).
684 *
685 * @param {RoundingMode} options.roundMode Determine how rounding is performed
686 * (defaults to `default`.)
687 *
688 * @param {string} options.separator Sets the separator between the units
689 * (defaults to ".").
690 *
691 * @param {string} options.delimiter Sets the thousands delimiter (defaults to
692 * "").
693 *
694 * @param {string} options.format Sets the format for non-negative numbers
695 * (defaults to "%n%"). The number field is represented by `%n`.
696 *
697 * @param {string} options.negativeFormat Sets the format for negative numbers
698 * (defaults to prepending a hyphen to the formatted number given by
699 * `format`). Accepts the same fields than `format`, except `%n` is here the
700 * absolute value of the number.
701 *
702 * @param {boolean} options.stripInsignificantZeros If `true` removes
703 * insignificant zeros after the decimal separator (defaults to `false`).
704 *
705 * @returns {string} The formatted number.
706 */
707 public numberToPercentage(
708 input: Numeric,
709 options: Partial<NumberToPercentageOptions> = {},
710 ): string {
711 return formatNumber(input, {
712 delimiter: "",
713 format: "%n%",
714 precision: 3,
715 stripInsignificantZeros: false,
716 separator: ".",
717 significant: false,
718 ...camelCaseKeys<Partial<FormatNumberOptions>>(this.get("number.format")),
719 ...camelCaseKeys<Partial<NumberToPercentageOptions>>(
720 this.get("number.percentage.format"),
721 ),
722 ...options,
723 } as FormatNumberOptions);
724 }
725
726 /**
727 * Convert a number into a readable size representation.
728 *
729 * @example
730 * ```js
731 * i18n.numberToHumanSize(123)
732 * // => "123 Bytes"
733 *
734 * i18n.numberToHumanSize(1234)
735 * // => "1.21 KB"
736 *
737 * i18n.numberToHumanSize(12345)
738 * // => "12.1 KB"
739 *
740 * i18n.numberToHumanSize(1234567)
741 * // => "1.18 MB"
742 *
743 * i18n.numberToHumanSize(1234567890)
744 * // => "1.15 GB"
745 *
746 * i18n.numberToHumanSize(1234567890123)
747 * // => "1.12 TB"
748 *
749 * i18n.numberToHumanSize(1234567890123456)
750 * // => "1.1 PB"
751 *
752 * i18n.numberToHumanSize(1234567890123456789)
753 * // => "1.07 EB"
754 *
755 * i18n.numberToHumanSize(1234567, {precision: 2})
756 * // => "1.2 MB"
757 *
758 * i18n.numberToHumanSize(483989, precision: 2)
759 * // => "470 KB"
760 *
761 * i18n.numberToHumanSize(483989, {precision: 2, roundMode: "up"})
762 * // => "480 KB"
763 *
764 * i18n.numberToHumanSize(1234567, {precision: 2, separator: ","})
765 * // => "1,2 MB"
766 *
767 * i18n.numberToHumanSize(1234567890123, {precision: 5})
768 * // => "1.1228 TB"
769 *
770 * i18n.numberToHumanSize(524288000, {precision: 5})
771 * // => "500 MB"
772 * ```
773 *
774 * @param {Numeric} input The number that will be formatted.
775 *
776 * @param {NumberToHumanSizeOptions} options The formatting options. When
777 * defined, supersedes the default options stored at
778 * `number.human.storage_units.*` and `number.human.format`.
779 *
780 * @param {number} options.precision Sets the precision of the number
781 * (defaults to 3).
782 *
783 * @param {RoundingMode} options.roundMode Determine how rounding is performed
784 * (defaults to `default`)
785 *
786 * @param {boolean} options.significant If `true`, precision will be the
787 * number of significant digits. If `false`, the number of fractional digits
788 * (defaults to `true`).
789 *
790 * @param {string} options.separator Sets the separator between the fractional
791 * and integer digits (defaults to ".").
792 *
793 * @param {string} options.delimiter Sets the thousands delimiter (defaults
794 * to "").
795 *
796 * @param {boolean} options.stripInsignificantZeros If `true` removes
797 * insignificant zeros after the decimal separator (defaults to `true`).
798 *
799 * @returns {string} The formatted number.
800 */
801 public numberToHumanSize(
802 input: Numeric,
803 options: Partial<NumberToHumanSizeOptions> = {},
804 ): string {
805 return numberToHumanSize(this, input, {
806 delimiter: "",
807 precision: 3,
808 significant: true,
809 stripInsignificantZeros: true,
810 units: {
811 billion: "Billion",
812 million: "Million",
813 quadrillion: "Quadrillion",
814 thousand: "Thousand",
815 trillion: "Trillion",
816 unit: "",
817 },
818 ...camelCaseKeys<Partial<NumberToHumanSizeOptions>>(
819 this.get("number.human.format"),
820 ),
821 ...camelCaseKeys<Partial<NumberToHumanSizeOptions>>(
822 this.get("number.human.storage_units"),
823 ),
824 ...options,
825 } as NumberToHumanSizeOptions);
826 }
827
828 /**
829 * Convert a number into a readable representation.
830 *
831 * @example
832 * ```js
833 * i18n.numberToHuman(123);
834 * // => "123"
835 *
836 * i18n.numberToHuman(1234);
837 * // => "1.23 Thousand"
838 *
839 * i18n.numberToHuman(12345);
840 * // => "12.3 Thousand"
841 *
842 * i18n.numberToHuman(1234567);
843 * // => "1.23 Million"
844 *
845 * i18n.numberToHuman(1234567890);
846 * // => "1.23 Billion"
847 *
848 * i18n.numberToHuman(1234567890123);
849 * // => "1.23 Trillion"
850 *
851 * i18n.numberToHuman(1234567890123456);
852 * // => "1.23 Quadrillion"
853 *
854 * i18n.numberToHuman(1234567890123456789);
855 * // => "1230 Quadrillion"
856 *
857 * i18n.numberToHuman(489939, { precision: 2 });
858 * // => "490 Thousand"
859 *
860 * i18n.numberToHuman(489939, { precision: 4 });
861 * // => "489.9 Thousand"
862 *
863 * i18n.numberToHuman(489939, { precision: 2, roundMode: "down" });
864 * // => "480 Thousand"
865 *
866 * i18n.numberToHuman(1234567, { precision: 4, significant: false });
867 * // => "1.2346 Million"
868 *
869 * i18n.numberToHuman(1234567, {
870 * precision: 1,
871 * separator: ",",
872 * significant: false,
873 * });
874 * // => "1,2 Million"
875 *
876 * i18n.numberToHuman(500000000, { precision: 5 });
877 * // => "500 Million"
878 *
879 * i18n.numberToHuman(12345012345, { significant: false });
880 * // => "12.345 Billion"
881 * ```
882 *
883 * Non-significant zeros after the decimal separator are stripped out by default
884 * (set `stripInsignificantZeros` to `false` to change that):
885 *
886 * ```js
887 * i18n.numberToHuman(12.00001);
888 * // => "12"
889 *
890 * i18n.numberToHuman(12.00001, { stripInsignificantZeros: false });
891 * // => "12.0"
892 * ```
893 *
894 * You can also use your own custom unit quantifiers:
895 *
896 * ```js
897 * i18n.numberToHuman(500000, units: { unit: "ml", thousand: "lt" });
898 * // => "500 lt"
899 * ```
900 *
901 * If in your I18n locale you have:
902 *
903 * ```yaml
904 * ---
905 * en:
906 * distance:
907 * centi:
908 * one: "centimeter"
909 * other: "centimeters"
910 * unit:
911 * one: "meter"
912 * other: "meters"
913 * thousand:
914 * one: "kilometer"
915 * other: "kilometers"
916 * billion: "gazillion-distance"
917 * ```
918 *
919 * Then you could do:
920 *
921 * ```js
922 * i18n.numberToHuman(543934, { units: "distance" });
923 * // => "544 kilometers"
924 *
925 * i18n.numberToHuman(54393498, { units: "distance" });
926 * // => "54400 kilometers"
927 *
928 * i18n.numberToHuman(54393498000, { units: "distance" });
929 * // => "54.4 gazillion-distance"
930 *
931 * i18n.numberToHuman(343, { units: "distance", precision: 1 });
932 * // => "300 meters"
933 *
934 * i18n.numberToHuman(1, { units: "distance" });
935 * // => "1 meter"
936 *
937 * i18n.numberToHuman(0.34, { units: "distance" });
938 * // => "34 centimeters"
939 * ```
940 *
941 * @param {Numeric} input The number that will be formatted.
942 *
943 * @param {NumberToHumanOptions} options The formatting options. When
944 * defined, supersedes the default options stored at `number.human.format.*`
945 * and `number.human.storage_units.*`.
946 *
947 * @param {number} options.precision Sets the precision of the number
948 * (defaults to 3).
949 *
950 * @param {RoundingMode} options.roundMode Determine how rounding is performed
951 * (defaults to `default`).
952 *
953 * @param {boolean} options.significant If `true`, precision will be the
954 * number of significant_digits. If `false`, the number of fractional digits
955 * (defaults to `true`)
956 *
957 * @param {string} options.separator Sets the separator between the fractional
958 * and integer digits (defaults to ".").
959 *
960 * @param {string} options.delimiter Sets the thousands delimiter
961 * (defaults to "").
962 *
963 * @param {boolean} options.stripInsignificantZeros If `true` removes
964 * insignificant zeros after the decimal separator (defaults to `true`).
965 *
966 * @param {Dict} options.units A Hash of unit quantifier names. Or a string
967 * containing an I18n scope where to find this object. It might have the
968 * following keys:
969 *
970 * - _integers_: `unit`, `ten`, `hundred`, `thousand`, `million`, `billion`,
971 * `trillion`, `quadrillion`
972 * - _fractionals_: `deci`, `centi`, `mili`, `micro`, `nano`, `pico`, `femto`
973 *
974 * @param {string} options.format Sets the format of the output string
975 * (defaults to "%n %u"). The field types are:
976 *
977 * - `%u` - The quantifier (ex.: 'thousand')
978 * - `%n` - The number
979 *
980 * @returns {string} The formatted number.
981 */
982 public numberToHuman(
983 input: Numeric,
984 options: Partial<NumberToHumanOptions> = {},
985 ): string {
986 return numberToHuman(this, input, {
987 delimiter: "",
988 separator: ".",
989 precision: 3,
990 significant: true,
991 stripInsignificantZeros: true,
992 format: "%n %u",
993 roundMode: "default",
994 units: {
995 billion: "Billion",
996 million: "Million",
997 quadrillion: "Quadrillion",
998 thousand: "Thousand",
999 trillion: "Trillion",
1000 unit: "",
1001 },
1002 ...camelCaseKeys<Partial<NumberToHumanOptions>>(
1003 this.get("number.human.format"),
1004 ),
1005 ...camelCaseKeys<Partial<NumberToHumanOptions>>(
1006 this.get("number.human.decimal_units"),
1007 ),
1008 ...options,
1009 } as NumberToHumanOptions);
1010 }
1011
1012 /**
1013 * Convert number to a formatted rounded value.
1014 *
1015 * @example
1016 * ```js
1017 * i18n.numberToRounded(111.2345);
1018 * // => "111.235"
1019 *
1020 * i18n.numberToRounded(111.2345, { precision: 2 });
1021 * // => "111.23"
1022 *
1023 * i18n.numberToRounded(13, { precision: 5 });
1024 * // => "13.00000"
1025 *
1026 * i18n.numberToRounded(389.32314, { precision: 0 });
1027 * // => "389"
1028 *
1029 * i18n.numberToRounded(111.2345, { significant: true });
1030 * // => "111"
1031 *
1032 * i18n.numberToRounded(111.2345, { precision: 1, significant: true });
1033 * // => "100"
1034 *
1035 * i18n.numberToRounded(13, { precision: 5, significant: true });
1036 * // => "13.000"
1037 *
1038 * i18n.numberToRounded(13, { precision: null });
1039 * // => "13"
1040 *
1041 * i18n.numberToRounded(389.32314, { precision: 0, roundMode: "up" });
1042 * // => "390"
1043 *
1044 * i18n.numberToRounded(13, {
1045 * precision: 5,
1046 * significant: true,
1047 * stripInsignificantZeros: true,
1048 * });
1049 * // => "13"
1050 *
1051 * i18n.numberToRounded(389.32314, { precision: 4, significant: true });
1052 * // => "389.3"
1053 *
1054 * i18n.numberToRounded(1111.2345, {
1055 * precision: 2,
1056 * separator: ",",
1057 * delimiter: ".",
1058 * });
1059 * // => "1.111,23"
1060 * ```
1061 *
1062 * @param {Numeric} input The number to be formatted.
1063 *
1064 * @param {NumberToRoundedOptions} options The formatting options.
1065 *
1066 * @param {number} options.precision Sets the precision of the number
1067 * (defaults to 3).
1068 *
1069 * @param {string} options.separator Sets the separator between the
1070 * fractional and integer digits (defaults to ".").
1071 *
1072 * @param {RoundingMode} options.roundMode Determine how rounding is
1073 * performed.
1074 *
1075 * @param {boolean} options.significant If `true`, precision will be the
1076 * number of significant_digits. If `false`, the number of fractional digits
1077 * (defaults to `false`).
1078 *
1079 * @param {boolean} options.stripInsignificantZeros If `true` removes
1080 * insignificant zeros after the decimal separator (defaults to `false`).
1081 *
1082 * @returns {string} The formatted number.
1083 */
1084 public numberToRounded(
1085 input: Numeric,
1086 options?: Partial<NumberToRoundedOptions>,
1087 ): string {
1088 return formatNumber(input, {
1089 unit: "",
1090 precision: 3,
1091 significant: false,
1092 separator: ".",
1093 delimiter: "",
1094 stripInsignificantZeros: false,
1095 ...options,
1096 } as FormatNumberOptions);
1097 }
1098
1099 /**
1100 * Formats a +number+ with grouped thousands using `delimiter` (e.g., 12,324).
1101 * You can customize the format in the `options` parameter.
1102 *
1103 * @example
1104 * ```js
1105 * i18n.numberToDelimited(12345678);
1106 * // => "12,345,678"
1107 *
1108 * i18n.numberToDelimited("123456");
1109 * // => "123,456"
1110 *
1111 * i18n.numberToDelimited(12345678.05);
1112 * // => "12,345,678.05"
1113 *
1114 * i18n.numberToDelimited(12345678, { delimiter: "." });
1115 * // => "12.345.678"
1116 *
1117 * i18n.numberToDelimited(12345678, { delimiter: "," });
1118 * // => "12,345,678"
1119 *
1120 * i18n.numberToDelimited(12345678.05, { separator: " " });
1121 * // => "12,345,678 05"
1122 *
1123 * i18n.numberToDelimited("112a");
1124 * // => "112a"
1125 *
1126 * i18n.numberToDelimited(98765432.98, { delimiter: " ", separator: "," });
1127 * // => "98 765 432,98"
1128 *
1129 * i18n.numberToDelimited("123456.78", {
1130 * delimiterPattern: /(\d+?)(?=(\d\d)+(\d)(?!\d))/g,
1131 * });
1132 * // => "1,23,456.78"
1133 * ```
1134 *
1135 * @param {Numeric} input The numeric value that will be formatted.
1136 *
1137 * @param {NumberToDelimitedOptions} options The formatting options.
1138 *
1139 * @param {string} options.delimiter Sets the thousands delimiter (defaults to
1140 * ",").
1141 *
1142 * @param {string} options.separator Sets the separator between the fractional
1143 * and integer digits (defaults to ".").
1144 *
1145 * @param {RegExp} options.delimiterPattern Sets a custom regular expression
1146 * used for deriving the placement of delimiter. Helpful when using currency
1147 * formats like INR.
1148 *
1149 * @return {string} The formatted number.
1150 */
1151 public numberToDelimited(
1152 input: Numeric,
1153 options: Partial<NumberToDelimitedOptions> = {},
1154 ): string {
1155 return numberToDelimited(input, {
1156 delimiterPattern: /(\d)(?=(\d\d\d)+(?!\d))/g,
1157 delimiter: ",",
1158 separator: ".",
1159 ...options,
1160 } as NumberToDelimitedOptions);
1161 }
1162
1163 /**
1164 * Executes function with given locale set. The locale will be changed only
1165 * during the `callback`'s execution, switching back to the previous value
1166 * once it finishes (with or without errors).
1167 *
1168 * This is an asynchronous call, which means you must use `await` or you may
1169 * end up with a race condition.
1170 *
1171 * @example
1172 * ```js
1173 * await i18n.withLocale("pt", () => {
1174 * console.log(i18n.t("hello"));
1175 * });
1176 * ```
1177 *
1178 * @param {string} locale The temporary locale that will be set during the
1179 * function's execution.
1180 *
1181 * @param {Function} callback The function that will be executed with a
1182 * temporary locale set.
1183 *
1184 * @returns {void}
1185 */
1186 public async withLocale(locale: string, callback: () => void): Promise<void> {
1187 const originalLocale = this.locale;
1188
1189 try {
1190 this.locale = locale;
1191 await callback();
1192 } finally {
1193 this.locale = originalLocale;
1194 }
1195 }
1196
1197 /**
1198 * Formats time according to the directives in the given format string.
1199 * The directives begins with a percent (`%`) character. Any text not listed
1200 * as a directive will be passed through to the output string.
1201 *
1202 * @see strftime
1203 *
1204 * @param {Date} date The date that will be formatted.
1205 *
1206 * @param {string} format The formatting string.
1207 *
1208 * @param {StrftimeOptions} options The formatting options.
1209 *
1210 * @returns {string} The formatted date.
1211 */
1212 public strftime(
1213 date: Date,
1214 format: string,
1215 options: Partial<StrftimeOptions> = {},
1216 ): string {
1217 return strftime(date, format, {
1218 ...camelCaseKeys(lookup(this, "date")),
1219 meridian: {
1220 am: lookup(this, "time.am") || "AM",
1221 pm: lookup(this, "time.pm") || "PM",
1222 },
1223 ...options,
1224 });
1225 }
1226
1227 /**
1228 * You may want to update a part of your translations. This is a public
1229 * interface for doing it so.
1230 *
1231 * If the provided path exists, it'll be replaced. Otherwise, a new node will
1232 * be created. When running in strict mode, paths that doesn't already exist
1233 * will raise an exception.
1234 *
1235 * Strict mode will also raise an exception if the override type differs from
1236 * previous node type.
1237 *
1238 * @example
1239 * ```js
1240 * i18n.update("en.number.format", {unit: "%n %u"});
1241 * i18n.update("en.number.format", {unit: "%n %u"}, true);
1242 * ```
1243 *
1244 * @param {string} path The path that's going to be updated. It must
1245 * include the language, as in `en.messages`.
1246 *
1247 * @param {Dict} override The new translation node.
1248 *
1249 * @param {boolean} options Set options.
1250 *
1251 * @param {boolean} options.strict Raise an exception if path doesn't already
1252 * exist, or if previous node's type differs from new node's type.
1253 *
1254 * @returns {void}
1255 */
1256 public update(
1257 path: string,
1258 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
1259 override: any,
1260 options: { strict: boolean } = { strict: false },
1261 ): void {
1262 if (options.strict && !has(this.translations, path)) {
1263 throw new Error(`The path "${path}" is not currently defined`);
1264 }
1265
1266 const currentNode = get(this.translations, path);
1267 const currentType = inferType(currentNode);
1268 const overrideType = inferType(override);
1269
1270 if (options.strict && currentType !== overrideType) {
1271 throw new Error(
1272 `The current type for "${path}" is "${currentType}", but you're trying to override it with "${overrideType}"`,
1273 );
1274 }
1275
1276 let newNode: unknown;
1277
1278 if (overrideType === "object") {
1279 newNode = { ...currentNode, ...override };
1280 } else {
1281 newNode = override;
1282 }
1283
1284 const components = path.split(this.defaultSeparator);
1285 const prop = components.pop();
1286 let buffer = this.translations;
1287
1288 for (const component of components) {
1289 if (!buffer[component]) {
1290 buffer[component] = {};
1291 }
1292
1293 buffer = buffer[component];
1294 }
1295
1296 buffer[prop as keyof typeof buffer] = newNode;
1297
1298 this.hasChanged();
1299 }
1300
1301 /**
1302 * Converts the array to a comma-separated sentence where the last element is
1303 * joined by the connector word.
1304 *
1305 * @example
1306 * ```js
1307 * i18n.toSentence(["apple", "banana", "pineapple"]);
1308 * //=> apple, banana, and pineapple.
1309 * ```
1310 *
1311 * @param {any[]} items The list of items that will be joined.
1312 *
1313 * @param {ToSentenceOptions} options The options.
1314 *
1315 * @param {string} options.wordsConnector The sign or word used to join the
1316 * elements in arrays with two or more elements (default: ", ").
1317 *
1318 * @param {string} options.twoWordsConnector The sign or word used to join the
1319 * elements in arrays with two elements (default: " and ").
1320 *
1321 * @param {string} options.lastWordConnector The sign or word used to join the
1322 * last element in arrays with three or more elements (default: ", and ").
1323 *
1324 * @returns {string} The joined string.
1325 */
1326 public toSentence(
1327 items: any[],
1328 options: Partial<ToSentenceOptions> = {},
1329 ): string {
1330 const { wordsConnector, twoWordsConnector, lastWordConnector } = {
1331 wordsConnector: ", ",
1332 twoWordsConnector: " and ",
1333 lastWordConnector: ", and ",
1334 ...camelCaseKeys<Partial<ToSentenceOptions>>(
1335 lookup(this, "support.array"),
1336 ),
1337 ...options,
1338 } as ToSentenceOptions;
1339
1340 const size = items.length;
1341
1342 switch (size) {
1343 case 0:
1344 return "";
1345
1346 case 1:
1347 return `${items[0]}`;
1348
1349 case 2:
1350 return items.join(twoWordsConnector);
1351
1352 default:
1353 return [
1354 items.slice(0, size - 1).join(wordsConnector),
1355 lastWordConnector,
1356 items[size - 1],
1357 ].join("");
1358 }
1359 }
1360
1361 /**
1362 * Reports the approximate distance in time between two time representations.
1363 *
1364 * @param {DateTime} fromTime The initial time.
1365 *
1366 * @param {DateTime} toTime The ending time. Defaults to `Date.now()`.
1367 *
1368 * @param {TimeAgoInWordsOptions} options The options.
1369 *
1370 * @param {boolean} options.includeSeconds Pass `{includeSeconds: true}` if
1371 * you want more detailed approximations when distance < 1 min, 29 secs.
1372 *
1373 * @param {Scope} options.scope With the scope option, you can define a custom
1374 * scope to look up the translation.
1375 *
1376 * @returns {string} The distance in time representation.
1377 */
1378 public timeAgoInWords(
1379 fromTime: DateTime,
1380 toTime: DateTime,
1381 options: TimeAgoInWordsOptions = {},
1382 ): string {
1383 return timeAgoInWords(this, fromTime, toTime, options);
1384 }
1385
1386 /**
1387 * @alias {@link timeAgoInWords}
1388 */
1389 public distanceOfTimeInWords = this.timeAgoInWords;
1390
1391 /**
1392 * Add a callback that will be executed whenever locale/defaultLocale changes,
1393 * or `I18n#store` / `I18n#update` is called.
1394 *
1395 * @param {OnChangeHandler} callback The callback that will be executed.
1396 *
1397 * @returns {Function} Return a function that can be used to unsubscribe the
1398 * event handler.
1399 *
1400 */
1401 public onChange(callback: OnChangeHandler): () => void {
1402 this.onChangeHandlers.push(callback);
1403
1404 return () => {
1405 this.onChangeHandlers.splice(this.onChangeHandlers.indexOf(callback), 1);
1406 };
1407 }
1408
1409 /**
1410 * Return the change version. This value is incremented whenever `I18n#store`
1411 * or `I18n#update` is called, or when `I18n#locale`/`I18n#defaultLocale` is
1412 * set.
1413 */
1414 public get version(): number {
1415 return this._version;
1416 }
1417
1418 /**
1419 * Formats a number.
1420 *
1421 * @param {Numeric} input The numeric value that will be
1422 * formatted.
1423 * @param {FormatNumberOptions} options The formatting options. Defaults to:
1424 * `{
1425 * delimiter: ",",
1426 * precision: 3,
1427 * separator: ".",
1428 * unit: "",
1429 * format: "%u%n",
1430 * significant: false,
1431 * stripInsignificantZeros: false,
1432 * }`
1433 * @return {string} The formatted number.
1434 */
1435 public formatNumber(
1436 input: Numeric,
1437 options: Partial<FormatNumberOptions> = {},
1438 ): string {
1439 options = {
1440 delimiter: ",",
1441 precision: 3,
1442 separator: ".",
1443 unit: "",
1444 format: "%u%n",
1445 significant: false,
1446 stripInsignificantZeros: false,
1447 ...camelCaseKeys<Partial<FormatNumberOptions>>(this.get("number.format")),
1448 ...options,
1449 };
1450
1451 return formatNumber(input, options as FormatNumberOptions);
1452 }
1453
1454 /**
1455 * @param {Scope} scope The scope lookup path.
1456 *
1457 * @returns {any} The found scope.
1458 */
1459 public get(scope: Scope): any {
1460 return lookup(this, scope);
1461 }
1462
1463 /**
1464 * @private
1465 *
1466 * @returns {void}
1467 */
1468 private runCallbacks(): void {
1469 this.onChangeHandlers.forEach((callback) => callback(this));
1470 }
1471
1472 /**
1473 * @private
1474 *
1475 * @returns {void}
1476 */
1477 private hasChanged(): void {
1478 this._version += 1;
1479
1480 this.runCallbacks();
1481 }
1482}