From 73a029641ae0997258a7ec7f4d23fe05c193418c Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Thu, 24 Oct 2024 16:48:33 +0100 Subject: [PATCH] chore: switch formatter to `format-message` (#4088) Co-authored-by: Robert Korulczyk --- framework/core/js/package.json | 3 +- .../js/src/@types/translator-icu-rich.d.ts | 26 ---- .../core/js/src/@types/translator-icu.d.ts | 17 --- framework/core/js/src/common/Translator.tsx | 127 ++++++++++++++---- .../js/src/common/helpers/fireDebugWarning.ts | 2 +- .../js/src/common/utils/abbreviateNumber.ts | 1 - .../src/forum/components/AccessTokensList.tsx | 3 +- .../unit/common/utils/Translator.test.ts | 69 ++++++++++ .../core/tests/unit/Locale/TranslatorTest.php | 91 +++++++++++++ .../jest-config/src/boostrap/common.js | 1 + yarn.lock | 63 +++++---- 11 files changed, 297 insertions(+), 106 deletions(-) delete mode 100644 framework/core/js/src/@types/translator-icu-rich.d.ts delete mode 100644 framework/core/js/src/@types/translator-icu.d.ts create mode 100644 framework/core/js/tests/unit/common/utils/Translator.test.ts create mode 100644 framework/core/tests/unit/Locale/TranslatorTest.php diff --git a/framework/core/js/package.json b/framework/core/js/package.json index b8d3ac0da7..a279f0d4d2 100644 --- a/framework/core/js/package.json +++ b/framework/core/js/package.json @@ -5,14 +5,13 @@ "type": "module", "prettier": "@flarum/prettier-config", "dependencies": { - "@askvortsov/rich-icu-message-formatter": "^0.2.4", - "@ultraq/icu-message-formatter": "^0.12.0", "body-scroll-lock": "^4.0.0-beta.0", "bootstrap": "^3.4.1", "clsx": "^1.1.1", "color-thief-browser": "^2.0.2", "dayjs": "^1.10.7", "focus-trap": "^6.7.1", + "format-message": "^6.2.4", "jquery": "^3.6.0", "jquery.hotkeys": "^0.1.0", "mithril": "^2.2", diff --git a/framework/core/js/src/@types/translator-icu-rich.d.ts b/framework/core/js/src/@types/translator-icu-rich.d.ts deleted file mode 100644 index dbecd881ca..0000000000 --- a/framework/core/js/src/@types/translator-icu-rich.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -declare module '@askvortsov/rich-icu-message-formatter' { - type IValues = Record; - - type ITypeHandler = ( - value: string, - matches: string, - locale: string, - values: IValues, - format: (message: string, values: IValues) => string - ) => string; - type IRichHandler = (tag: any, values: IValues, contents: string) => any; - - type ValueOrArray = T | ValueOrArray[]; - export type NestedStringArray = ValueOrArray; - - export class RichMessageFormatter { - locale: string | null; - constructor(locale: string | null, typeHandlers: Record, richHandler: IRichHandler); - - format(message: string, values: IValues): string; - process(message: string, values: IValues): NestedStringArray; - rich(message: string, values: IValues): NestedStringArray; - } - - export function mithrilRichHandler(tag: any, values: IValues, contents: string): any; -} diff --git a/framework/core/js/src/@types/translator-icu.d.ts b/framework/core/js/src/@types/translator-icu.d.ts deleted file mode 100644 index d1ed498f5b..0000000000 --- a/framework/core/js/src/@types/translator-icu.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -declare module '@ultraq/icu-message-formatter' { - export function pluralTypeHandler( - value: string, - matches: string, - locale: string, - values: Record, - format: (text: string, values: Record) => string - ): string; - - export function selectTypeHandler( - value: string, - matches: string, - locale: string, - values: Record, - format: (text: string, values: Record) => string - ): string; -} diff --git a/framework/core/js/src/common/Translator.tsx b/framework/core/js/src/common/Translator.tsx index b272a4fad7..60cea4a59e 100644 --- a/framework/core/js/src/common/Translator.tsx +++ b/framework/core/js/src/common/Translator.tsx @@ -1,13 +1,13 @@ -import type { Dayjs } from 'dayjs'; -import { RichMessageFormatter, mithrilRichHandler, NestedStringArray } from '@askvortsov/rich-icu-message-formatter'; -import { pluralTypeHandler, selectTypeHandler } from '@ultraq/icu-message-formatter'; import username from './helpers/username'; +import type { Dayjs } from 'dayjs'; import User from './models/User'; import extract from './utils/extract'; +import formatMessage, { Translation } from 'format-message'; +import fireDebugWarning from './helpers/fireDebugWarning'; import extractText from './utils/extractText'; import ItemList from './utils/ItemList'; -type Translations = Record; +type Translations = { [key: string]: string | Translation }; type TranslatorParameters = Record; type DateTimeFormatCallback = (id?: string) => string | void; @@ -15,7 +15,9 @@ export default class Translator { /** * A map of translation keys to their translated values. */ - translations: Translations = {}; + get translations(): Translations { + return this.formatter.setup().translations[this.getLocale()] ?? {}; + } /** * A item list of date time format callbacks. @@ -25,44 +27,44 @@ export default class Translator { /** * The underlying ICU MessageFormatter util. */ - protected formatter = new RichMessageFormatter(null, this.formatterTypeHandlers(), mithrilRichHandler); + protected formatter = formatMessage; /** * Sets the formatter's locale to the provided value. */ setLocale(locale: string) { - this.formatter.locale = locale; + this.formatter.setup({ + locale, + translations: { + [locale]: this.formatter.setup().translations[locale] ?? {}, + }, + }); } /** * Returns the formatter's current locale. */ - getLocale() { - return this.formatter.locale; + getLocale(): string { + return (Array.isArray(this.formatter.setup().locale) ? this.formatter.setup().locale[0] : this.formatter.setup().locale) as string; } addTranslations(translations: Translations) { - Object.assign(this.translations, translations); - } + const locale = this.getLocale(); - /** - * An extensible entrypoint for extenders to register type handlers for translations. - */ - protected formatterTypeHandlers() { - return { - plural: pluralTypeHandler, - select: selectTypeHandler, - }; + this.formatter.setup({ + translations: { + [locale]: Object.assign(this.translations, translations), + }, + }); } /** * A temporary system to preprocess parameters. * Should not be used by extensions. - * TODO: An extender will be added in v1.x. * * @internal */ - protected preprocessParameters(parameters: TranslatorParameters) { + protected preprocessParameters(parameters: TranslatorParameters, translation: string | Translation) { // If we've been given a user model as one of the input parameters, then // we'll extract the username and use that for the translation. In the // future there should be a hook here to inspect the user and change the @@ -75,23 +77,66 @@ export default class Translator { if (!parameters.username) parameters.username = username(user); } + // To maintain backwards compatibility, we will catch HTML elements and + // push the tags as mithril children to the parameters keyed by the tag name. + // Will be removed in v2.0 + translation = typeof translation === 'string' ? translation : translation.message; + const elements = translation.match(/<(\w+)[^>]*>.*?<\/\1>/g); + const tags = elements?.map((element) => element.match(/^<(\w+)/)![1]) || []; + + for (const tag of tags) { + if (!parameters[tag]) { + fireDebugWarning( + `Any HTML tags used within translations must have corresponding mithril component parameters.\nCaught in translation: \n\n"""\n${translation}\n"""`, + '', + 'v2.0', + 'flarum/framework' + ); + + parameters[tag] = ({ children }: any) => m(tag, children); + } + } + + // The old formatter allowed rich parameters as such: + // { link: } + // The new formatter dictates that the rich parameter must be a function, + // like so: { link: ({ children }) => {children} } + // This layer allows the old format to be used, and converts it to the new format. + for (const key in parameters) { + const value: any = parameters[key]; + + if (tags.includes(key) && typeof value === 'object' && value.attrs && value.tag) { + parameters[key] = ({ children }: any) => { + return m(value.tag, value.attrs, children); + }; + } + } + return parameters; } - trans(id: string, parameters: TranslatorParameters): NestedStringArray; - trans(id: string, parameters: TranslatorParameters, extract: false): NestedStringArray; + trans(id: string, parameters: TranslatorParameters): any[]; + trans(id: string, parameters: TranslatorParameters, extract: false): any[]; trans(id: string, parameters: TranslatorParameters, extract: true): string; - trans(id: string): NestedStringArray | string; + trans(id: string): any[] | string; trans(id: string, parameters: TranslatorParameters = {}, extract = false) { - const translation = this.translations[id]; + const translation = this.preprocessTranslation(this.translations[id]); if (translation) { - parameters = this.preprocessParameters(parameters); - const locale = this.formatter.rich(translation, parameters); + parameters = this.preprocessParameters(parameters, translation); + + this.translations[id] = translation; + + let locale = this.formatter.rich({ id, default: id }, parameters); + + // convert undefined args to {undefined}. + locale = locale instanceof Array ? locale.map((arg) => (arg === undefined ? '{undefined}' : arg)) : locale; if (extract) return extractText(locale); return locale; + } else { + fireDebugWarning(`Missing translation for key: "${id}"`); } return id; @@ -113,6 +158,32 @@ export default class Translator { if (result) return result; } - return time.format(this.translations[id]); + return time.format(this.preprocessTranslation(this.translations[id])); + } + + /** + * Backwards compatibility for translations such as ``, the old + * formatter supported that, but the new one doesn't, so attributes are auto dropped + * to avoid errors. + * + * @private + */ + private preprocessTranslation(translation: string | Translation | undefined): string | undefined { + if (!translation) return; + + translation = typeof translation === 'string' ? translation : translation.message; + + // If the translation contains a tag, then we'll need to + // remove the attributes for backwards compatibility. Will be removed in v2.0. + // And if it did have attributes, then we'll fire a warning + if (translation.match(/<\w+ [^>]+>/g)) { + fireDebugWarning( + `Any HTML tags used within translations must be simple tags, without attributes.\nCaught in translation: \n\n"""\n${translation}\n"""` + ); + + return translation.replace(/<(\w+)([^>]*)>/g, '<$1>'); + } + + return translation; } } diff --git a/framework/core/js/src/common/helpers/fireDebugWarning.ts b/framework/core/js/src/common/helpers/fireDebugWarning.ts index e29f6a0ec8..7f972a8eca 100644 --- a/framework/core/js/src/common/helpers/fireDebugWarning.ts +++ b/framework/core/js/src/common/helpers/fireDebugWarning.ts @@ -12,7 +12,7 @@ import app from '../app'; * can fix. */ export default function fireDebugWarning(...args: Parameters): void { - if (!app.forum.attribute('debug')) return; + if (!app.data.resources.find((r) => r.type === 'forums')?.attributes?.debug) return; console.warn(...args); } diff --git a/framework/core/js/src/common/utils/abbreviateNumber.ts b/framework/core/js/src/common/utils/abbreviateNumber.ts index 43586876a6..bc4d340743 100644 --- a/framework/core/js/src/common/utils/abbreviateNumber.ts +++ b/framework/core/js/src/common/utils/abbreviateNumber.ts @@ -9,7 +9,6 @@ import extractText from './extractText'; * // "1.2K" */ export default function abbreviateNumber(number: number): string { - // TODO: translation if (number >= 1000000) { return Math.floor(number / 1000000) + extractText(app.translator.trans('core.lib.number_suffix.mega_text')); } else if (number >= 1000) { diff --git a/framework/core/js/src/forum/components/AccessTokensList.tsx b/framework/core/js/src/forum/components/AccessTokensList.tsx index 5d58250817..e93e557fd3 100644 --- a/framework/core/js/src/forum/components/AccessTokensList.tsx +++ b/framework/core/js/src/forum/components/AccessTokensList.tsx @@ -9,7 +9,6 @@ import classList from '../../common/utils/classList'; import Tooltip from '../../common/components/Tooltip'; import type Mithril from 'mithril'; import type AccessToken from '../../common/models/AccessToken'; -import { NestedStringArray } from '@askvortsov/rich-icu-message-formatter'; import Icon from '../../common/components/Icon'; export interface IAccessTokensListAttrs extends ComponentAttrs { @@ -187,7 +186,7 @@ export default class AccessTokensList { + const translator = new Translator(); + translator.addTranslations({ + test1: 'test1 {placeholder} test1', + test2: 'test2 {placeholder} test2', + }); + + expect(extractText(translator.trans('test1', { placeholder: "'" }))).toBe("test1 ' test1"); + expect(extractText(translator.trans('test1', { placeholder: translator.trans('test2', { placeholder: "'" }) }))).toBe("test1 test2 ' test2 test1"); +}); + +// This is how the backend translator behaves. The only discrepancy with the frontend translator. +// test('missing placeholders', () => { +// const translator = new Translator(); +// translator.addTranslations({ +// test1: 'test1 {placeholder} test1', +// }); +// +// expect(extractText(translator.trans('test1', {}))).toBe('test1 {placeholder} test1'); +// }); + +test('missing placeholders', () => { + const translator = new Translator(); + translator.addTranslations({ + test1: 'test1 {placeholder} test1', + }); + + expect(extractText(translator.trans('test1', {}))).toBe('test1 {undefined} test1'); +}); + +test('escaped placeholders', () => { + const translator = new Translator(); + translator.addTranslations({ + test3: "test1 {placeholder} '{placeholder}' test1", + }); + + expect(extractText(translator.trans('test3', { placeholder: "'" }))).toBe("test1 ' {placeholder} test1"); +}); + +test('plural rules', () => { + const translator = new Translator(); + translator.addTranslations({ + test4: '{pageNumber, plural, =1 {{forumName}} other {Page # - {forumName}}}', + }); + + expect(extractText(translator.trans('test4', { forumName: 'A & B', pageNumber: 1 }))).toBe('A & B'); + expect(extractText(translator.trans('test4', { forumName: 'A & B', pageNumber: 2 }))).toBe('Page 2 - A & B'); +}); + +test('plural rules 2', () => { + const translator = new Translator(); + translator.setLocale('pl'); + translator.addTranslations({ + test5: '{count, plural, one {# post} few {# posty} many {# postów} other {# posta}}', + }); + + expect(extractText(translator.trans('test5', { count: 1 }))).toBe('1 post'); + expect(extractText(translator.trans('test5', { count: 2 }))).toBe('2 posty'); + expect(extractText(translator.trans('test5', { count: 5 }))).toBe('5 postów'); + expect(extractText(translator.trans('test5', { count: 1.5 }))).toBe('1,5 posta'); +}); diff --git a/framework/core/tests/unit/Locale/TranslatorTest.php b/framework/core/tests/unit/Locale/TranslatorTest.php new file mode 100644 index 0000000000..8fb63bb7b6 --- /dev/null +++ b/framework/core/tests/unit/Locale/TranslatorTest.php @@ -0,0 +1,91 @@ +addLoader('array', new ArrayLoader()); + $translator->addResource('array', [ + 'test1' => 'test1 {placeholder} test1', + 'test2' => 'test2 {placeholder} test2', + ], 'en', self::DOMAIN); + + $this->assertSame("test1 ' test1", $translator->trans('test1', ['placeholder' => "'"])); + $this->assertSame("test1 test2 ' test2 test1", $translator->trans('test1', ['placeholder' => $translator->trans('test2', ['placeholder' => "'"])])); + } + + /** @test */ + public function missing_placeholders() + { + $translator = new Translator('en'); + $translator->addLoader('array', new ArrayLoader()); + $translator->addResource('array', [ + 'test1' => 'test1 {placeholder} test1', + ], 'en', self::DOMAIN); + + $this->assertSame('test1 {placeholder} test1', $translator->trans('test1', [])); + } + + /** @test */ + public function escaped_placeholders() + { + $translator = new Translator('en'); + $translator->addLoader('array', new ArrayLoader()); + $translator->addResource('array', [ + 'test3' => "test1 {placeholder} '{placeholder}' test1", + ], 'en', self::DOMAIN); + + $this->assertSame("test1 ' {placeholder} test1", $translator->trans('test3', ['placeholder' => "'"])); + } + + /** @test */ + public function plural_rules() + { + $translator = new Translator('en'); + $translator->addLoader('array', new ArrayLoader()); + $translator->addResource('array', [ + 'test4' => '{pageNumber, plural, =1 {{forumName}} other {Page # - {forumName}}}', + ], 'en', self::DOMAIN); + + $this->assertSame('A & B', $translator->trans('test4', ['forumName' => 'A & B', 'pageNumber' => 1])); + $this->assertSame('Page 2 - A & B', $translator->trans('test4', ['forumName' => 'A & B', 'pageNumber' => 2])); + } + + /** @test */ + public function plural_rules_2() + { + $translator = new Translator('pl'); + $translator->addLoader('array', new ArrayLoader()); + $translator->addResource('array', [ + 'test4' => '{count, plural, one {# post} few {# posty} many {# postów} other {# posta}}', + ], 'pl', self::DOMAIN); + + $this->assertSame('1 post', $translator->trans('test4', ['count' => 1])); + $this->assertSame('2 posty', $translator->trans('test4', ['count' => 2])); + $this->assertSame('5 postów', $translator->trans('test4', ['count' => 5])); + $this->assertSame('1,5 posta', $translator->trans('test4', ['count' => 1.5])); + } +} diff --git a/js-packages/jest-config/src/boostrap/common.js b/js-packages/jest-config/src/boostrap/common.js index ea51a2244c..25080062e8 100644 --- a/js-packages/jest-config/src/boostrap/common.js +++ b/js-packages/jest-config/src/boostrap/common.js @@ -36,6 +36,7 @@ export default function bootstrap(Application, app, payload = {}) { ...payload, }); + app.translator.setLocale('en'); app.translator.addTranslations(flatten(jsYaml.load(fs.readFileSync('../locale/core.yml', 'utf8')))); app.drawer = new Drawer(); } diff --git a/yarn.lock b/yarn.lock index cfb2ed6998..d98bbd9aac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,15 +10,6 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@askvortsov/rich-icu-message-formatter@^0.2.4": - version "0.2.4" - resolved "https://registry.yarnpkg.com/@askvortsov/rich-icu-message-formatter/-/rich-icu-message-formatter-0.2.4.tgz#5810886d6d6751e9b800640748355a87ea985556" - integrity sha512-JOdZ7iw7qF3uxC3cfY8dighM3rgrV0WufgwVeFD9VEkxB7IwA7DX2kHs24zk4CYPR6HQXUEnM6fwOy+VKUrc8w== - dependencies: - "@babel/runtime" "^7.11.2" - "@ultraq/array-utils" "^2.1.0" - "@ultraq/icu-message-formatter" "^0.12.0" - "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.25.7": version "7.25.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.25.7.tgz#438f2c524071531d643c6f0188e1e28f130cebc7" @@ -987,7 +978,7 @@ "@babel/plugin-transform-modules-commonjs" "^7.25.7" "@babel/plugin-transform-typescript" "^7.25.7" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.20.1", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.20.1", "@babel/runtime@^7.8.4": version "7.25.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.7.tgz#7ffb53c37a8f247c8c4d335e89cdf16a2e0d0fb6" integrity sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w== @@ -1545,25 +1536,6 @@ dependencies: "@types/yargs-parser" "*" -"@ultraq/array-utils@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@ultraq/array-utils/-/array-utils-2.1.0.tgz#56f16a1ea3ef46c5d5f04638b47c4fca4d71a8c1" - integrity sha512-TKO1zE6foqs5HG3+QH32yKwJ0zhZrm6J3UmltscveQmxCdbgIPXhNf3A8C9HakjyZDHVRK5pYZOU0tTl28YGFg== - -"@ultraq/function-utils@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@ultraq/function-utils/-/function-utils-0.3.0.tgz#63eb7dceff18fdca212fae11a59b3ee01f556917" - integrity sha512-AwFCYorRn0GE34hfgxaCmfnReHqcwWE6QwWPQf/1Zj7k3Zi0FATSJhbtDA+6ayV8p6AnhEntntXaMWMkK17tEQ== - -"@ultraq/icu-message-formatter@^0.12.0": - version "0.12.0" - resolved "https://registry.yarnpkg.com/@ultraq/icu-message-formatter/-/icu-message-formatter-0.12.0.tgz#15a812a323395d7e5b5e3c6c2cc92df3989b26ce" - integrity sha512-ebd/ZyC1lCVPPrX3AQ9h77NDK4d1nor0Grmv43e97+omWvJB29lbuT+9yM3sq4Ri1QKwTvKG1BUhXBz0oAAR2w== - dependencies: - "@babel/runtime" "^7.11.2" - "@ultraq/array-utils" "^2.1.0" - "@ultraq/function-utils" "^0.3.0" - "@webassemblyjs/ast@1.12.1", "@webassemblyjs/ast@^1.12.1": version "1.12.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.12.1.tgz#bb16a0e8b1914f979f45864c23819cc3e3f0d4bb" @@ -2723,6 +2695,34 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +format-message-formats@^6.2.4: + version "6.2.4" + resolved "https://registry.yarnpkg.com/format-message-formats/-/format-message-formats-6.2.4.tgz#68b782e70c3c15f017377848c3225731e52ac4ea" + integrity sha512-smT/fAqBLqusWfWCKRAx6QBDAAbmYznWsIyTyk66COmvwt2Byiqd7SJe2ma9a5oV0kwRaOJpN/F4lr4YK/n6qQ== + +format-message-interpret@^6.2.4: + version "6.2.4" + resolved "https://registry.yarnpkg.com/format-message-interpret/-/format-message-interpret-6.2.4.tgz#28f579b9cd4b57f3de2ec2a4d9623f9870e9ed03" + integrity sha512-dRvz9mXhITApyOtfuFEb/XqvCe1u6RMkQW49UJHXS8w2S8cAHCqq5LNDFK+QK6XVzcofROycLb/k1uybTAKt2w== + dependencies: + format-message-formats "^6.2.4" + lookup-closest-locale "^6.2.0" + +format-message-parse@^6.2.4: + version "6.2.4" + resolved "https://registry.yarnpkg.com/format-message-parse/-/format-message-parse-6.2.4.tgz#2c9b39a32665bd247cb1c31ba2723932d9edf3f9" + integrity sha512-k7WqXkEzgXkW4wkHdS6Cv2Ou0rIFtiDelZjgoe1saW4p7FT7zS8OeAUpAekhormqzpeecR97e4vBft1zMsfFOQ== + +format-message@^6.2.4: + version "6.2.4" + resolved "https://registry.yarnpkg.com/format-message/-/format-message-6.2.4.tgz#0bd4b6161b036e3fbcf3207dce14a62e318b4c48" + integrity sha512-/24zYeSRy2ZlEO2OIctm7jOHvMpoWf+uhqFCaqqyZKi1C229zAAy2E5vF4lSSaMH0a2kewPrOzq6xN4Yy7cQrw== + dependencies: + format-message-formats "^6.2.4" + format-message-interpret "^6.2.4" + format-message-parse "^6.2.4" + lookup-closest-locale "^6.2.0" + frappe-charts@^1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-1.6.2.tgz#4671a943a8606e5020180fa65c8ea1835c510baf" @@ -3748,6 +3748,11 @@ lodash@^4.17.15: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +lookup-closest-locale@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/lookup-closest-locale/-/lookup-closest-locale-6.2.0.tgz#57f665e604fd26f77142d48152015402b607bcf3" + integrity sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ== + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"