UNPKG

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