diff --git a/package.json b/package.json index 267eb45b688..d18601eb07f 100644 --- a/package.json +++ b/package.json @@ -272,6 +272,7 @@ "@grafana/faro-web-tracing": "^1.13.2", "@grafana/flamegraph": "workspace:*", "@grafana/google-sdk": "0.1.2", + "@grafana/i18n": "workspace:*", "@grafana/lezer-logql": "0.2.7", "@grafana/llm": "0.13.2", "@grafana/monaco-logql": "^0.0.8", diff --git a/packages/grafana-data/src/unstable.ts b/packages/grafana-data/src/unstable.ts index 53e92431b55..8a42447206f 100644 --- a/packages/grafana-data/src/unstable.ts +++ b/packages/grafana-data/src/unstable.ts @@ -9,26 +9,5 @@ * and be subject to the standard policies */ -export { - DEFAULT_LANGUAGE, - LANGUAGES, - BRAZILIAN_PORTUGUESE, - CHINESE_SIMPLIFIED, - CHINESE_TRADITIONAL, - CZECH_CZECHIA, - DUTCH_NETHERLANDS, - ENGLISH_US, - FRENCH_FRANCE, - GERMAN_GERMANY, - HUNGARIAN_HUNGARY, - INDONESIAN_INDONESIA, - ITALIAN_ITALY, - JAPANESE_JAPAN, - KOREAN_KOREA, - POLISH_POLAND, - PORTUGUESE_PORTUGAL, - RUSSIAN_RUSSIA, - SPANISH_SPAIN, - SWEDISH_SWEDEN, - TURKISH_TURKEY, -} from './utils/i18n'; +// This is a dummy export so typescript doesn't error importing an "empty module" +export const unstable = {}; diff --git a/packages/grafana-i18n/CHANGELOG.md b/packages/grafana-i18n/CHANGELOG.md new file mode 100644 index 00000000000..8f36f74766d --- /dev/null +++ b/packages/grafana-i18n/CHANGELOG.md @@ -0,0 +1,3 @@ +# (2025-04-11) + +First public release diff --git a/packages/grafana-i18n/LICENSE_APACHE2 b/packages/grafana-i18n/LICENSE_APACHE2 new file mode 100644 index 00000000000..373dde574a0 --- /dev/null +++ b/packages/grafana-i18n/LICENSE_APACHE2 @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2015 Grafana Labs + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/grafana-i18n/README.md b/packages/grafana-i18n/README.md new file mode 100644 index 00000000000..a20e93907f1 --- /dev/null +++ b/packages/grafana-i18n/README.md @@ -0,0 +1,5 @@ +# Grafana Internationalization Library + +> **@grafana/i18n is currently in ALPHA**. + +This package holds internationalization type functions and types diff --git a/packages/grafana-i18n/package.json b/packages/grafana-i18n/package.json new file mode 100644 index 00000000000..f1f13199427 --- /dev/null +++ b/packages/grafana-i18n/package.json @@ -0,0 +1,66 @@ +{ + "author": "Grafana Labs", + "license": "Apache-2.0", + "name": "@grafana/i18n", + "version": "12.1.0-pre", + "description": "Grafana Internationalization Library", + "keywords": [ + "grafana", + "typescript" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git", + "directory": "packages/grafana-i18n" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "module": "src/index.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "import": "./src/index.ts", + "require": "./src/index.ts" + }, + "./internal": { + "import": "./src/internal/index.ts", + "require": "./src/internal/index.ts" + } + }, + "publishConfig": { + "main": "./dist/cjs/index.cjs", + "module": "./dist/esm/index.mjs", + "types": "./dist/cjs/index.d.cts", + "access": "public" + }, + "files": [ + "dist", + "./README.md", + "./CHANGELOG.md", + "LICENSE_APACHE2" + ], + "scripts": { + "build": "tsc -p ./tsconfig.build.json && rollup -c rollup.config.ts --configPlugin esbuild", + "bundle": "rollup -c rollup.config.ts --configPlugin esbuild", + "clean": "rimraf ./dist ./compiled ./package.tgz", + "typecheck": "tsc --emitDeclarationOnly false --noEmit", + "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", + "postpack": "mv package.json.bak package.json" + }, + "dependencies": { + "i18next": "^24.0.0", + "i18next-browser-languagedetector": "^8.0.0", + "i18next-pseudo": "^2.2.1", + "react-i18next": "^15.0.0" + }, + "devDependencies": { + "@grafana/tsconfig": "^2.0.0", + "@types/react": "18.3.18", + "rollup": "^4.22.4", + "typescript": "5.7.3" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/grafana-i18n/project.json b/packages/grafana-i18n/project.json new file mode 100644 index 00000000000..9d13669dada --- /dev/null +++ b/packages/grafana-i18n/project.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "tags": ["scope:package", "type:ui"], + "targets": { + "build": {} + } +} diff --git a/packages/grafana-i18n/rollup.config.ts b/packages/grafana-i18n/rollup.config.ts new file mode 100644 index 00000000000..acdb390d129 --- /dev/null +++ b/packages/grafana-i18n/rollup.config.ts @@ -0,0 +1,15 @@ +import { createRequire } from 'node:module'; + +import { entryPoint, plugins, esmOutput, cjsOutput, tsDeclarationOutput } from '../rollup.config.parts'; + +const rq = createRequire(import.meta.url); +const pkg = rq('./package.json'); + +export default [ + { + input: entryPoint, + plugins, + output: [cjsOutput(pkg), esmOutput(pkg, 'grafana-i18n')], + }, + tsDeclarationOutput(pkg), +]; diff --git a/packages/grafana-i18n/src/constants.ts b/packages/grafana-i18n/src/constants.ts new file mode 100644 index 00000000000..295712f20d7 --- /dev/null +++ b/packages/grafana-i18n/src/constants.ts @@ -0,0 +1,22 @@ +export const ENGLISH_US = 'en-US'; +export const FRENCH_FRANCE = 'fr-FR'; +export const SPANISH_SPAIN = 'es-ES'; +export const GERMAN_GERMANY = 'de-DE'; +export const BRAZILIAN_PORTUGUESE = 'pt-BR'; +export const CHINESE_SIMPLIFIED = 'zh-Hans'; +export const ITALIAN_ITALY = 'it-IT'; +export const JAPANESE_JAPAN = 'ja-JP'; +export const INDONESIAN_INDONESIA = 'id-ID'; +export const KOREAN_KOREA = 'ko-KR'; +export const RUSSIAN_RUSSIA = 'ru-RU'; +export const CZECH_CZECHIA = 'cs-CZ'; +export const DUTCH_NETHERLANDS = 'nl-NL'; +export const HUNGARIAN_HUNGARY = 'hu-HU'; +export const PORTUGUESE_PORTUGAL = 'pt-PT'; +export const POLISH_POLAND = 'pl-PL'; +export const SWEDISH_SWEDEN = 'sv-SE'; +export const TURKISH_TURKEY = 'tr-TR'; +export const CHINESE_TRADITIONAL = 'zh-Hant'; + +export const DEFAULT_LANGUAGE = ENGLISH_US; +export const PSEUDO_LOCALE = 'pseudo'; diff --git a/packages/grafana-i18n/src/i18n.tsx b/packages/grafana-i18n/src/i18n.tsx new file mode 100644 index 00000000000..a0459c73e26 --- /dev/null +++ b/packages/grafana-i18n/src/i18n.tsx @@ -0,0 +1,158 @@ +import i18n, { InitOptions, ReactOptions, TFunction } from 'i18next'; +import LanguageDetector, { DetectorOptions } from 'i18next-browser-languagedetector'; +// eslint-disable-next-line no-restricted-imports +import { initReactI18next, setDefaults, setI18n, Trans as I18NextTrans, getI18n } from 'react-i18next'; + +import { DEFAULT_LANGUAGE, PSEUDO_LOCALE } from './constants'; +import { LANGUAGES } from './languages'; +import { TransProps, TransType } from './types'; + +let tFunc: TFunction | undefined; +let transComponent: TransType; + +export async function initPluginTranslations(id: string) { + // If the resources are not an object, we need to initialize the plugin translations + if (!getI18nInstance().options?.resources || typeof getI18nInstance().options.resources !== 'object') { + await getI18nInstance().use(initReactI18next).init({ + resources: {}, + returnEmptyString: false, + lng: DEFAULT_LANGUAGE, // this should be the locale of the phrases in our source JSX + }); + } + + // If the initReactI18next is not set, we need to set them + if (!getI18n()?.options?.react) { + const options: ReactOptions = {}; + setDefaults(options); + setI18n(getI18nInstance()); + } + + tFunc = getI18nInstance().getFixedT(null, id); + transComponent = (props: TransProps) => ; + + return { language: getI18nInstance().resolvedLanguage }; +} + +export function getI18nInstance() { + return i18n; +} + +interface Module { + type: 'backend'; +} + +interface InitializeI18nOptions { + ns?: string[]; + language?: string; + module?: Module; +} + +export async function initTranslations({ + ns, + language = DEFAULT_LANGUAGE, + module, +}: InitializeI18nOptions): Promise<{ language: string | undefined }> { + const options: InitOptions = { + // We don't bundle any translations, we load them async + partialBundledLanguages: true, + resources: {}, + + // If translations are empty strings (no translation), fall back to the default value in source code + returnEmptyString: false, + + // Required to ensure that `resolvedLanguage` is set property when an invalid language is passed (such as through 'detect') + supportedLngs: LANGUAGES.map((language) => language.code), + fallbackLng: DEFAULT_LANGUAGE, + + ns, + postProcess: [ + // Add pseudo processing even if we aren't necessarily going to use it + PSEUDO_LOCALE, + ], + }; + + if (language === 'detect') { + getI18nInstance().use(LanguageDetector); + const detection: DetectorOptions = { order: ['navigator'], caches: [] }; + options.detection = detection; + } else { + options.lng = LANGUAGES.find((lang) => lang.code === language)?.code ?? undefined; + } + + if (module) { + getI18nInstance().use(module).use(initReactI18next); // passes i18n down to react-i18next + } else { + getI18nInstance().use(initReactI18next); // passes i18n down to react-i18next + } + + if (process.env.NODE_ENV === 'development') { + const { default: Pseudo } = await import('i18next-pseudo'); + getI18nInstance().use( + new Pseudo({ + languageToPseudo: PSEUDO_LOCALE, + enabled: true, + wrapped: true, + }) + ); + } + + await getI18nInstance().init(options); + + tFunc = getI18nInstance().t; + transComponent = (props: TransProps) => ; + + return { + language: getI18nInstance().resolvedLanguage, + }; +} + +export function getLanguage() { + return getI18nInstance()?.language || DEFAULT_LANGUAGE; +} + +export function getResolvedLanguage() { + return getI18nInstance()?.resolvedLanguage || DEFAULT_LANGUAGE; +} + +export function getNamespaces() { + return getI18nInstance()?.options.ns; +} + +export async function changeLanguage(language?: string) { + await getI18nInstance().changeLanguage(language ?? DEFAULT_LANGUAGE); +} + +type ResourceKey = string; +type ResourceLanguage = Record; +type ResourceType = Record; + +export function addResourceBundle(language: string, namespace: string, resource: ResourceType) { + getI18nInstance().addResourceBundle(language, namespace, resource, undefined, true); +} + +export function t(id: string, defaultMessage: string, values?: Record) { + if (!tFunc) { + if (process.env.NODE_ENV !== 'test') { + console.warn( + 't() was called before i18n was initialized. This is probably caused by calling t() in the root module scope, instead of lazily on render' + ); + } + + if (process.env.NODE_ENV === 'development') { + throw new Error('t() was called before i18n was initialized'); + } + + tFunc = getI18nInstance().t; + } + + return tFunc(id, defaultMessage, values); +} + +export function useTranslate() { + return { t }; +} + +export function Trans(props: TransProps) { + const Component = transComponent ?? I18NextTrans; + return ; +} diff --git a/packages/grafana-i18n/src/index.ts b/packages/grafana-i18n/src/index.ts new file mode 100644 index 00000000000..e2e7e3c3d60 --- /dev/null +++ b/packages/grafana-i18n/src/index.ts @@ -0,0 +1,26 @@ +export { LANGUAGES } from './languages'; +export { + ENGLISH_US, + FRENCH_FRANCE, + SPANISH_SPAIN, + GERMAN_GERMANY, + BRAZILIAN_PORTUGUESE, + CHINESE_SIMPLIFIED, + ITALIAN_ITALY, + JAPANESE_JAPAN, + INDONESIAN_INDONESIA, + KOREAN_KOREA, + RUSSIAN_RUSSIA, + CZECH_CZECHIA, + DUTCH_NETHERLANDS, + HUNGARIAN_HUNGARY, + PORTUGUESE_PORTUGAL, + POLISH_POLAND, + SWEDISH_SWEDEN, + TURKISH_TURKEY, + CHINESE_TRADITIONAL, + PSEUDO_LOCALE, + DEFAULT_LANGUAGE, +} from './constants'; +export { initPluginTranslations, Trans, useTranslate } from './i18n'; +export type { TransProps } from './types'; diff --git a/packages/grafana-i18n/src/internal/index.ts b/packages/grafana-i18n/src/internal/index.ts new file mode 100644 index 00000000000..5e1174e6437 --- /dev/null +++ b/packages/grafana-i18n/src/internal/index.ts @@ -0,0 +1,20 @@ +/** + * This file is used to share internal grafana/i18n code with Grafana core. + * Note that these exports are also used within Enterprise. + * + * Through the exports declared in package.json we can import this code in core Grafana and the grafana/i18n + * package will continue to be able to access all code when it's published to npm as it's private to the package. + * + * During the yarn pack lifecycle the exports[./internal] property is deleted from the package.json + * preventing the code from being importable by plugins or other npm packages making it truly "internal". + * + */ +export { + addResourceBundle, + changeLanguage, + getI18nInstance, + getLanguage, + getResolvedLanguage, + initTranslations, + t, +} from '../i18n'; diff --git a/packages/grafana-i18n/src/languages.test.ts b/packages/grafana-i18n/src/languages.test.ts new file mode 100644 index 00000000000..4c2804b5bb2 --- /dev/null +++ b/packages/grafana-i18n/src/languages.test.ts @@ -0,0 +1,45 @@ +import { LANGUAGES } from './languages'; + +const expectedLanguages = [ + { code: 'en-US', name: 'English' }, + { code: 'fr-FR', name: 'Français' }, + { code: 'es-ES', name: 'Español' }, + { code: 'de-DE', name: 'Deutsch' }, + { code: 'zh-Hans', name: '中文(简体)' }, + { code: 'pt-BR', name: 'Português Brasileiro' }, + { code: 'zh-Hant', name: '中文(繁體)' }, + { code: 'it-IT', name: 'Italiano' }, + { code: 'ja-JP', name: '日本語' }, + { code: 'id-ID', name: 'Bahasa Indonesia' }, + { code: 'ko-KR', name: '한국어' }, + { code: 'ru-RU', name: 'Русский' }, + { code: 'cs-CZ', name: 'Čeština' }, + { code: 'nl-NL', name: 'Nederlands' }, + { code: 'hu-HU', name: 'Magyar' }, + { code: 'pt-PT', name: 'Português' }, + { code: 'pl-PL', name: 'Polski' }, + { code: 'sv-SE', name: 'Svenska' }, + { code: 'tr-TR', name: 'Türkçe' }, +]; + +describe('LANGUAGES', () => { + it('should contain all supported languages with correct codes and names', () => { + expect(LANGUAGES).toEqual(expectedLanguages); + }); + + it('should include the pseudo-locale in development mode', async () => { + process.env.NODE_ENV = 'development'; + jest.resetModules(); + const { LANGUAGES: languages } = await import('./languages'); + + expect(languages).toEqual([...expectedLanguages, { code: 'pseudo', name: 'Pseudo-locale' }]); + }); + + it('should not include the pseudo-locale in production mode', async () => { + process.env.NODE_ENV = 'production'; + jest.resetModules(); + const { LANGUAGES: languages } = await import('./languages'); + + expect(languages).toEqual(expectedLanguages); + }); +}); diff --git a/packages/grafana-data/src/utils/i18n.ts b/packages/grafana-i18n/src/languages.ts similarity index 57% rename from packages/grafana-data/src/utils/i18n.ts rename to packages/grafana-i18n/src/languages.ts index bcb51e06df3..697dbf99481 100644 --- a/packages/grafana-data/src/utils/i18n.ts +++ b/packages/grafana-i18n/src/languages.ts @@ -1,27 +1,25 @@ -export const ENGLISH_US = 'en-US'; -export const FRENCH_FRANCE = 'fr-FR'; -export const SPANISH_SPAIN = 'es-ES'; -export const GERMAN_GERMANY = 'de-DE'; -export const BRAZILIAN_PORTUGUESE = 'pt-BR'; -export const CHINESE_SIMPLIFIED = 'zh-Hans'; -export const ITALIAN_ITALY = 'it-IT'; -export const JAPANESE_JAPAN = 'ja-JP'; -export const INDONESIAN_INDONESIA = 'id-ID'; -export const KOREAN_KOREA = 'ko-KR'; -export const RUSSIAN_RUSSIA = 'ru-RU'; -export const CZECH_CZECHIA = 'cs-CZ'; -export const DUTCH_NETHERLANDS = 'nl-NL'; -export const HUNGARIAN_HUNGARY = 'hu-HU'; -export const PORTUGUESE_PORTUGAL = 'pt-PT'; -export const POLISH_POLAND = 'pl-PL'; -export const SWEDISH_SWEDEN = 'sv-SE'; -export const TURKISH_TURKEY = 'tr-TR'; -export const CHINESE_TRADITIONAL = 'zh-Hant'; - -/** - * Default language. - */ -export const DEFAULT_LANGUAGE = ENGLISH_US; +import { + ENGLISH_US, + FRENCH_FRANCE, + SPANISH_SPAIN, + GERMAN_GERMANY, + CHINESE_SIMPLIFIED, + BRAZILIAN_PORTUGUESE, + CHINESE_TRADITIONAL, + ITALIAN_ITALY, + JAPANESE_JAPAN, + INDONESIAN_INDONESIA, + KOREAN_KOREA, + RUSSIAN_RUSSIA, + CZECH_CZECHIA, + DUTCH_NETHERLANDS, + HUNGARIAN_HUNGARY, + PORTUGUESE_PORTUGAL, + POLISH_POLAND, + SWEDISH_SWEDEN, + TURKISH_TURKEY, + PSEUDO_LOCALE, +} from './constants'; interface TranslationDefinition { code: string; @@ -52,3 +50,7 @@ export const LANGUAGES: TranslationDefinition[] = [ { code: SWEDISH_SWEDEN, name: 'Svenska' }, { code: TURKISH_TURKEY, name: 'Türkçe' }, ]; + +if (process.env.NODE_ENV === 'development') { + LANGUAGES.push({ code: PSEUDO_LOCALE, name: 'Pseudo-locale' }); +} diff --git a/packages/grafana-runtime/src/types/i18n.ts b/packages/grafana-i18n/src/types.ts similarity index 100% rename from packages/grafana-runtime/src/types/i18n.ts rename to packages/grafana-i18n/src/types.ts diff --git a/packages/grafana-i18n/tsconfig.build.json b/packages/grafana-i18n/tsconfig.build.json new file mode 100644 index 00000000000..54309163ebc --- /dev/null +++ b/packages/grafana-i18n/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "exclude": ["dist", "node_modules", "test", "**/*.test.ts*"], + "extends": "./tsconfig.json" +} diff --git a/packages/grafana-i18n/tsconfig.json b/packages/grafana-i18n/tsconfig.json new file mode 100644 index 00000000000..998a2dffff5 --- /dev/null +++ b/packages/grafana-i18n/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "declarationDir": "./compiled", + "emitDeclarationOnly": true, + "isolatedModules": true, + "rootDirs": ["."] + }, + "exclude": ["dist/**/*"], + "extends": "@grafana/tsconfig", + "include": ["src/**/*.ts*", "../../public/app/types/*.d.ts"] +} diff --git a/packages/grafana-runtime/src/unstable.ts b/packages/grafana-runtime/src/unstable.ts index 07bb817ac5b..8a42447206f 100644 --- a/packages/grafana-runtime/src/unstable.ts +++ b/packages/grafana-runtime/src/unstable.ts @@ -9,5 +9,5 @@ * and be subject to the standard policies */ -export { useTranslate, setUseTranslateHook, setTransComponent, Trans } from './utils/i18n'; -export type { TransProps } from './types/i18n'; +// This is a dummy export so typescript doesn't error importing an "empty module" +export const unstable = {}; diff --git a/packages/grafana-runtime/src/utils/i18n.tsx b/packages/grafana-runtime/src/utils/i18n.tsx deleted file mode 100644 index 30c06263dfc..00000000000 --- a/packages/grafana-runtime/src/utils/i18n.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { type TransProps, type TransType, type UseTranslateHook } from '../types/i18n'; - -/** - * Provides a i18next-compatible translation function. - */ -export let useTranslate: UseTranslateHook = useTranslateDefault; - -function useTranslateDefault() { - // Fallback implementation that should be overridden by setUseT - const errorMessage = 'useTranslate is not set. useTranslate must not be called before Grafana is initialized.'; - if (process.env.NODE_ENV === 'development') { - throw new Error(errorMessage); - } - - console.error(errorMessage); - return (id: string, defaultMessage: string) => { - return defaultMessage; - }; -} - -export function setUseTranslateHook(hook: UseTranslateHook) { - useTranslate = hook; -} - -let TransComponent: TransType | undefined; - -/** - * Sets the Trans component that will be used for translations throughout the application. - * This function should only be called once during application initialization. - * - * @param transComponent - The Trans component function to use for translations - * @throws {Error} If called multiple times outside of test environment - */ -export function setTransComponent(transComponent: TransType) { - // We allow overriding the trans component in tests - if (TransComponent && process.env.NODE_ENV !== 'test') { - throw new Error('setTransComponent() function should only be called once, when Grafana is starting.'); - } - - TransComponent = transComponent; -} - -/** - * A React component for handling translations with support for interpolation and pluralization. - * This component must be initialized using setTransComponent before use. - * - * @param props - The translation props including the i18nKey and any interpolation values - * @returns A React element containing the translated content - * @throws {Error} If the Trans component hasn't been initialized - */ -export function Trans(props: TransProps): React.ReactElement { - if (!TransComponent) { - throw new Error('Trans component not set. Use setTransComponent to set the Trans component.'); - } - - return ; -} diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index 2cd7ca041e9..f51a4d09a0c 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { FeatureState, ThemeRegistryItem } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { PSEUDO_LOCALE } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen'; import { @@ -23,7 +24,7 @@ import { } from '@grafana/ui'; import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; import { t, Trans } from 'app/core/internationalization'; -import { LANGUAGES, PSEUDO_LOCALE } from 'app/core/internationalization/constants'; +import { LANGUAGES } from 'app/core/internationalization/constants'; import { LOCALES } from 'app/core/internationalization/locales'; import { PreferencesService } from 'app/core/services/PreferencesService'; import { changeTheme } from 'app/core/services/theme'; diff --git a/public/app/core/internationalization/constants.ts b/public/app/core/internationalization/constants.ts index 4a16b08e66c..05cb9a6d40e 100644 --- a/public/app/core/internationalization/constants.ts +++ b/public/app/core/internationalization/constants.ts @@ -1,12 +1,9 @@ import { ResourceKey } from 'i18next'; import { uniq } from 'lodash'; -import { LANGUAGES as SUPPORTED_LANGUAGES } from '@grafana/data/unstable'; +import { DEFAULT_LANGUAGE, PSEUDO_LOCALE, LANGUAGES as SUPPORTED_LANGUAGES } from '@grafana/i18n'; export type LocaleFileLoader = () => Promise; - -export const PSEUDO_LOCALE = 'pseudo'; - export interface LanguageDefinition { /** IETF language tag for the language e.g. en-US */ code: string; @@ -18,22 +15,14 @@ export interface LanguageDefinition { loader: Record; } -export const LANGUAGES: LanguageDefinition[] = SUPPORTED_LANGUAGES.map((def) => ({ - ...def, - loader: { grafana: () => import(`../../../locales/${def.code}/grafana.json`) }, -})); - -if (process.env.NODE_ENV === 'development') { - LANGUAGES.push({ - code: PSEUDO_LOCALE, - name: 'Pseudo-locale', - loader: { - // Load the English locale as the pseudo-locale, - // as it will be post-processed by i18next-pseudo library - grafana: () => import('../../../locales/en-US/grafana.json'), - }, - }); -} +export const LANGUAGES: LanguageDefinition[] = SUPPORTED_LANGUAGES.map((def) => { + // Load the Default language (en-US) as the pseudo-locale, as it will be post-processed by i18next-pseudo library + const locale = def.code === PSEUDO_LOCALE ? DEFAULT_LANGUAGE : def.code; + return { + ...def, + loader: { grafana: () => import(`../../../locales/${locale}/grafana.json`) }, + }; +}); // Optionally load enterprise locale extensions, if they are present. // It is important that this happens before NAMESPACES is defined so it has the correct value diff --git a/public/app/core/internationalization/dates.ts b/public/app/core/internationalization/dates.ts index ca7590bd74f..f60f9a3604c 100644 --- a/public/app/core/internationalization/dates.ts +++ b/public/app/core/internationalization/dates.ts @@ -2,10 +2,9 @@ import '@formatjs/intl-durationformat/polyfill'; import deepEqual from 'fast-deep-equal'; import memoize from 'micro-memoize'; +import { getLanguage } from '@grafana/i18n/internal'; import { config } from 'app/core/config'; -import { getI18next } from './index'; - const deepMemoize: typeof memoize = (fn) => memoize(fn, { isEqual: deepEqual }); const isLocaleEnabled = config.featureToggles.localeFormatPreference; @@ -24,8 +23,7 @@ export const formatDate = deepMemoize( return formatDate(new Date(value), format); } - const i18n = getI18next(); - const currentLocale = isLocaleEnabled ? config.locale : i18n.language; + const currentLocale = isLocaleEnabled ? config.locale : getLanguage(); const dateFormatter = createDateTimeFormatter(currentLocale, format); return dateFormatter.format(value); @@ -34,8 +32,7 @@ export const formatDate = deepMemoize( export const formatDuration = deepMemoize( (duration: Intl.DurationInput, options: Intl.DurationFormatOptions = {}): string => { - const i18n = getI18next(); - const currentLocale = isLocaleEnabled ? config.locale : i18n.language; + const currentLocale = isLocaleEnabled ? config.locale : getLanguage(); const dateFormatter = createDurationFormatter(currentLocale, options); return dateFormatter.format(duration); diff --git a/public/app/core/internationalization/index.test.tsx b/public/app/core/internationalization/index.test.tsx deleted file mode 100644 index cb0b3a37148..00000000000 --- a/public/app/core/internationalization/index.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { render } from '@testing-library/react'; -import { I18nextProvider } from 'react-i18next'; - -import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data'; -import { Trans as PluginTrans, setTransComponent, setUseTranslateHook, useTranslate } from '@grafana/runtime/unstable'; - -import { getI18next, Trans, useTranslateInternal } from './index'; - -const id = 'frontend-test-languages-plugin'; -const mockedMeta: PluginMeta = { - id, - name: 'Frontend Test Languages Plugin', - type: PluginType.panel, - info: { - author: { name: 'Test Author' }, - description: 'Test Description', - links: [], - logos: { - large: 'test-plugin-large-logo', - small: 'test-plugin-small-logo', - }, - screenshots: [], - version: '1.0.0', - updated: '2021-01-01', - }, - module: 'test-plugin', - baseUrl: 'test-plugin', -}; - -const DummyUseTranslateComponent = () => { - const t = useTranslate(); - return
{t('frontendtests.test-key', 'test-key not found')}
; -}; - -describe('internationalization', () => { - describe('Trans component', () => { - it('should interpolate strings without escaping dangerous characters', () => { - const name = ''; - const { getByText } = render(Table - {{ name }}); - - expect(getByText('Table - ')).toBeInTheDocument(); - }); - - it('should escape dangerous characters when shouldUnescape is false', () => { - const name = ''; - const { getByText } = render( - - Table - {{ name }} - - ); - - expect(getByText('Table - <script></script>')).toBeInTheDocument(); - }); - }); - describe('for plugins', () => { - beforeEach(() => { - getI18next().addResourceBundle('en', id, { 'frontendtests.test-key': 'test-value' }, undefined, true); - setTransComponent(Trans); - setUseTranslateHook(useTranslateInternal); - }); - - it('should return the correct value when using Trans component within a plugin context', async () => { - const { getByText, queryByText } = render( - - - - - - ); - - expect(getByText('test-value')).toBeInTheDocument(); - expect(queryByText('test-key not found')).not.toBeInTheDocument(); - }); - - it('should return the correct value when using Trans component without a plugin context', async () => { - const { getByText, queryByText } = render( - - - - ); - - expect(getByText('test-key not found')).toBeInTheDocument(); - expect(queryByText('test-value')).not.toBeInTheDocument(); - }); - - it('should return the correct value when using useTranslate hook within a plugin context', async () => { - const { getByText, queryByText } = render( - - - - - - ); - - expect(getByText('test-value')).toBeInTheDocument(); - expect(queryByText('test-key not found')).not.toBeInTheDocument(); - }); - - it('should return the correct value when using useTranslate hook without a plugin context', async () => { - const { getByText, queryByText } = render( - - - - ); - - expect(getByText('test-key not found')).toBeInTheDocument(); - expect(queryByText('test-value')).not.toBeInTheDocument(); - }); - }); -}); diff --git a/public/app/core/internationalization/index.tsx b/public/app/core/internationalization/index.tsx index c449dd84bb5..8e7e946500e 100644 --- a/public/app/core/internationalization/index.tsx +++ b/public/app/core/internationalization/index.tsx @@ -1,91 +1,25 @@ -import i18n, { InitOptions, TFunction } from 'i18next'; -import LanguageDetector, { DetectorOptions } from 'i18next-browser-languagedetector'; -import { ReactElement, useMemo } from 'react'; -import { Trans as I18NextTrans, initReactI18next } from 'react-i18next'; // eslint-disable-line no-restricted-imports +import { ReactElement } from 'react'; -import { usePluginContext } from '@grafana/data'; -import { DEFAULT_LANGUAGE } from '@grafana/data/unstable'; -import { setTransComponent, setUseTranslateHook, TransProps } from '@grafana/runtime/unstable'; +import { Trans as TransCore, TransProps } from '@grafana/i18n'; +import { changeLanguage as changeLanguageCore, initTranslations, t as tCore } from '@grafana/i18n/internal'; import { NAMESPACES, VALID_LANGUAGES } from './constants'; import { loadTranslations } from './loadTranslations'; -let tFunc: TFunction | undefined; -let i18nInstance: typeof i18n; +// This is a placeholder so we can put a 'comment' in the message json files. +// Starts with an underscore so it's sorted to the top of the file. Even though it is in a comment the following line is still extracted +// t('_comment', 'The code is the source of truth for English phrases. They should be updated in the components directly, and additional plurals specified in this file.'); export async function initializeI18n(language: string): Promise<{ language: string | undefined }> { - // This is a placeholder so we can put a 'comment' in the message json files. - // Starts with an underscore so it's sorted to the top of the file. Even though it is in a comment the following line is still extracted - // t('_comment', 'The code is the source of truth for English phrases. They should be updated in the components directly, and additional plurals specified in this file.'); - - const options: InitOptions = { - // We don't bundle any translations, we load them async - partialBundledLanguages: true, - resources: {}, - - // If translations are empty strings (no translation), fall back to the default value in source code - returnEmptyString: false, - - // Required to ensure that `resolvedLanguage` is set property when an invalid language is passed (such as through 'detect') - supportedLngs: VALID_LANGUAGES, - fallbackLng: DEFAULT_LANGUAGE, - - ns: NAMESPACES, - postProcess: [ - // Add pseudo processing even if we aren't necessarily going to use it - 'pseudo', - ], - }; - - i18nInstance = i18n; - if (language === 'detect') { - i18nInstance = i18nInstance.use(LanguageDetector); - const detection: DetectorOptions = { order: ['navigator'], caches: [] }; - options.detection = detection; - } else { - options.lng = VALID_LANGUAGES.includes(language) ? language : undefined; - } - - i18nInstance.use(loadTranslations).use(initReactI18next); // passes i18n down to react-i18next - - if (process.env.NODE_ENV === 'development') { - const { default: Pseudo } = await import('i18next-pseudo'); - i18nInstance.use( - new Pseudo({ - languageToPseudo: 'pseudo', - enabled: true, - wrapped: true, - }) - ); - } - - await i18nInstance.init(options); - - tFunc = i18n.getFixedT(null, NAMESPACES); - - setUseTranslateHook(useTranslateInternal); - setTransComponent(Trans); - - return { - language: i18nInstance.resolvedLanguage, - }; + return initTranslations({ language, ns: NAMESPACES, module: loadTranslations }); } export function changeLanguage(locale: string) { const validLocale = VALID_LANGUAGES.includes(locale) ? locale : undefined; - return i18n.changeLanguage(validLocale); + return changeLanguageCore(validLocale); } -export const Trans = (props: TransProps): ReactElement => { - const context = usePluginContext(); - - // If we are in a plugin context, use the plugin's id as the namespace - if (context?.meta?.id) { - return ; - } - - return ; -}; +export const Trans = (props: TransProps): ReactElement => ; /** * This is a simple wrapper over i18n.t() to provide default namespaces and enforce a consistent API. @@ -97,53 +31,5 @@ export const Trans = (props: TransProps): ReactElement => { * @param defaultMessage Default message to use if the translation is missing * @param values Values to be interpolated into the string */ -export const t = (id: string, defaultMessage: string, values?: Record) => { - if (!tFunc) { - if (process.env.NODE_ENV !== 'test') { - console.warn( - 't() was called before i18n was initialized. This is probably caused by calling t() in the root module scope, instead of lazily on render' - ); - } - - if (process.env.NODE_ENV === 'development') { - throw new Error('t() was called before i18n was initialized'); - } - - tFunc = i18n.t; - } - - return tFunc(id, defaultMessage, values); -}; - -export function getI18next() { - if (!tFunc) { - if (process.env.NODE_ENV !== 'test') { - console.warn( - 'An attempt to internationalize was made before it was initialized. This was probably caused by calling a locale-aware function in the root module scope, instead of in render' - ); - } - - if (process.env.NODE_ENV === 'development') { - throw new Error('getI18next was called before i18n was initialized'); - } - - return i18n; - } - - return i18nInstance || i18n; -} - -// We want to move to a react-only API for translations. -// This hook doesn't do much now, but we want it to define the API for plugins. -// Perhaps in the future this will use useTranslation from react-i18next or something else -// from context -export function useTranslateInternal() { - const context = usePluginContext(); - if (!context) { - return t; - } - - const { meta } = context; - const pluginT = useMemo(() => getI18next().getFixedT(null, meta.id), [meta.id]); - return pluginT; -} +export const t = (id: string, defaultMessage: string, values?: Record) => + tCore(id, defaultMessage, values); diff --git a/public/app/features/bookmarks/BookmarksPage.tsx b/public/app/features/bookmarks/BookmarksPage.tsx index 2e4bc821e95..f2f3860dff7 100644 --- a/public/app/features/bookmarks/BookmarksPage.tsx +++ b/public/app/features/bookmarks/BookmarksPage.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { useTranslate } from '@grafana/runtime/unstable'; +import { useTranslate } from '@grafana/i18n'; import { EmptyState, useStyles2 } from '@grafana/ui'; import { usePinnedItems } from 'app/core/components/AppChrome/MegaMenu/hooks'; import { findByUrl } from 'app/core/components/AppChrome/MegaMenu/utils'; @@ -11,7 +11,7 @@ import { Trans } from 'app/core/internationalization'; import { useSelector } from 'app/types'; export function BookmarksPage() { - const t = useTranslate(); + const { t } = useTranslate(); const styles = useStyles2(getStyles); const pinnedItems = usePinnedItems(); const navTree = useSelector((state) => state.navBarTree); diff --git a/public/app/features/plugins/loader/sharedDependencies.ts b/public/app/features/plugins/loader/sharedDependencies.ts index 1bb042f6530..572224fdd44 100644 --- a/public/app/features/plugins/loader/sharedDependencies.ts +++ b/public/app/features/plugins/loader/sharedDependencies.ts @@ -88,7 +88,7 @@ export const sharedDependenciesMap = { d3: () => import('d3'), emotion: () => import('@emotion/css'), // bundling grafana-ui in plugins requires sharing i18next state - i18next: () => import('i18next'), + i18next: () => import('@grafana/i18n/internal').then((module) => module.getI18nInstance()), jquery: { default: jquery, __useDefault: true, diff --git a/public/app/features/plugins/plugin_loader.test.ts b/public/app/features/plugins/plugin_loader.test.ts index 27f4006545a..8fa6b6901e2 100644 --- a/public/app/features/plugins/plugin_loader.test.ts +++ b/public/app/features/plugins/plugin_loader.test.ts @@ -1,6 +1,6 @@ import { i18n } from 'i18next'; -import { getI18next } from 'app/core/internationalization'; +import * as i18nModule from '@grafana/i18n/internal'; import { server } from './loader/pluginLoader.mock'; import { SystemJS } from './loader/systemjs'; @@ -25,7 +25,7 @@ describe('plugin_loader', () => { beforeEach(() => { addResourceBundleSpy = jest - .spyOn(getI18next(), 'addResourceBundle') + .spyOn(i18nModule, 'addResourceBundle') .mockImplementation(() => ({}) as unknown as i18n); }); @@ -54,14 +54,7 @@ describe('plugin_loader', () => { }); expect(addResourceBundleSpy).toHaveBeenCalledTimes(1); - expect(addResourceBundleSpy).toHaveBeenNthCalledWith( - 1, - 'pt-BR', - 'test-panel', - { testKey: 'valorDeTeste' }, - undefined, - true - ); + expect(addResourceBundleSpy).toHaveBeenNthCalledWith(1, 'pt-BR', 'test-panel', { testKey: 'valorDeTeste' }); }); it('should add translations that match the fallback language if the resolved language is not in the translations', async () => { @@ -78,14 +71,7 @@ describe('plugin_loader', () => { }); expect(addResourceBundleSpy).toHaveBeenCalledTimes(1); - expect(addResourceBundleSpy).toHaveBeenNthCalledWith( - 1, - 'en-US', - 'test-panel', - { testKey: 'testValue' }, - undefined, - true - ); + expect(addResourceBundleSpy).toHaveBeenNthCalledWith(1, 'en-US', 'test-panel', { testKey: 'testValue' }); }); it('should warn if no translations are found', async () => { diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index a27ec275b56..6a415316caf 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -8,10 +8,10 @@ import { PluginMeta, throwIfAngular, } from '@grafana/data'; -import { DEFAULT_LANGUAGE } from '@grafana/data/unstable'; +import { DEFAULT_LANGUAGE } from '@grafana/i18n'; +import { addResourceBundle, getResolvedLanguage } from '@grafana/i18n/internal'; import { config } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; -import { getI18next } from 'app/core/internationalization'; import { GenericDataSourcePlugin } from '../datasources/types'; @@ -102,7 +102,7 @@ export async function importPluginModule({ // Add locales to i18n for a plugin if the feature toggle is enabled and the plugin has locales if (config.featureToggles.localizationForPlugins && translations) { await addTranslationsToI18n({ - resolvedLanguage: getI18next().resolvedLanguage ?? DEFAULT_LANGUAGE, + resolvedLanguage: getResolvedLanguage(), fallbackLanguage: DEFAULT_LANGUAGE, pluginId, translations, @@ -270,7 +270,7 @@ export async function addTranslationsToI18n({ } const language = resolvedPath ? resolvedLanguage : fallbackLanguage; - getI18next().addResourceBundle(language, pluginId, module.default, undefined, true); + addResourceBundle(language, pluginId, module.default); } catch (error) { console.warn(`Could not load translation for plugin ${pluginId}`, { resolvedLanguage, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index bd428b4369f..7553c3caed5 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5363,9 +5363,6 @@ "discard-button": "Discard unsaved changes", "title": "Leave page?" }, - "frontendtests": { - "test-key": "test-key not found" - }, "gen-ai": { "apply-suggestion": "Apply", "incomplete-request-error": "Sorry, I was unable to complete your request. Please try again.", diff --git a/yarn.lock b/yarn.lock index 0c7571f1a24..809987a82ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3169,6 +3169,23 @@ __metadata: languageName: node linkType: hard +"@grafana/i18n@workspace:*, @grafana/i18n@workspace:packages/grafana-i18n": + version: 0.0.0-use.local + resolution: "@grafana/i18n@workspace:packages/grafana-i18n" + dependencies: + "@grafana/tsconfig": "npm:^2.0.0" + "@types/react": "npm:18.3.18" + i18next: "npm:^24.0.0" + i18next-browser-languagedetector: "npm:^8.0.0" + i18next-pseudo: "npm:^2.2.1" + react-i18next: "npm:^15.0.0" + rollup: "npm:^4.22.4" + typescript: "npm:5.7.3" + peerDependencies: + react: ">=18" + languageName: unknown + linkType: soft + "@grafana/lezer-logql@npm:0.2.7": version: 0.2.7 resolution: "@grafana/lezer-logql@npm:0.2.7" @@ -17660,6 +17677,7 @@ __metadata: "@grafana/faro-web-tracing": "npm:^1.13.2" "@grafana/flamegraph": "workspace:*" "@grafana/google-sdk": "npm:0.1.2" + "@grafana/i18n": "workspace:*" "@grafana/lezer-logql": "npm:0.2.7" "@grafana/llm": "npm:0.13.2" "@grafana/monaco-logql": "npm:^0.0.8"