diff --git a/.drone.yml b/.drone.yml index 6f896b5526f..9f6ba848ad2 100644 --- a/.drone.yml +++ b/.drone.yml @@ -243,11 +243,11 @@ steps: - commands: - apk add --update git - |- - yarn run i18n:extract || (echo " + make i18n-extract || (echo " Extraction failed. Make sure that you have no dynamic translation phrases, such as 't(\`preferences.theme.\$${themeID}\`, themeName)' and that no translation key is used twice. Search the output for '[warning]' to find the offending file." && false) - "\n file_diff=$(git diff --dirstat public/locales)\n if [ -n \"$file_diff\" ]; then\n echo $file_diff\n echo - \"\nTranslation extraction has not been committed. Please run 'yarn i18n:extract', + \"\nTranslation extraction has not been committed. Please run 'make i18n-extract', commit the changes and push again.\"\n exit 1\n fi\n \ " depends_on: @@ -1627,11 +1627,11 @@ steps: - commands: - apk add --update git - |- - yarn run i18n:extract || (echo " + make i18n-extract || (echo " Extraction failed. Make sure that you have no dynamic translation phrases, such as 't(\`preferences.theme.\$${themeID}\`, themeName)' and that no translation key is used twice. Search the output for '[warning]' to find the offending file." && false) - "\n file_diff=$(git diff --dirstat public/locales)\n if [ -n \"$file_diff\" ]; then\n echo $file_diff\n echo - \"\nTranslation extraction has not been committed. Please run 'yarn i18n:extract', + \"\nTranslation extraction has not been committed. Please run 'make i18n-extract', commit the changes and push again.\"\n exit 1\n fi\n \ " depends_on: @@ -4925,6 +4925,6 @@ kind: secret name: gcr_credentials --- kind: signature -hmac: fbd59890dac44eb6fb34562f2b2b2db0fc0a8a50d451f9887f815ee35757e0f6 +hmac: 958ed40ca0620498c01fa867b05a1d6c3bcd908067fbb34be691923e95cfc77b ... diff --git a/.prettierignore b/.prettierignore index 160926fd718..76bf2adf696 100644 --- a/.prettierignore +++ b/.prettierignore @@ -19,10 +19,6 @@ vendor # TS generate from cue by cuetsy **/*.gen.ts -# Auto-generated internationalization files -public/locales/_build/ -public/locales/**/*.js - # Auto-generated theme files theme.light.generated.json theme.dark.generated.json diff --git a/Makefile b/Makefile index bd6bc808bb4..0d47a3d0215 100644 --- a/Makefile +++ b/Makefile @@ -110,6 +110,25 @@ OAPI_SPEC_TARGET = public/openapi3.json openapi3-gen: swagger-gen ## Generates OpenApi 3 specs from the Swagger 2 already generated $(GO) run scripts/openapi3/openapi3conv.go $(MERGED_SPEC_TARGET) $(OAPI_SPEC_TARGET) +##@ Internationalisation +.PHONY: i18n-extract-enterprise +ENTERPRISE_FE_EXT_FILE = public/app/extensions/index.ts +ifeq ("$(wildcard $(ENTERPRISE_FE_EXT_FILE))","") ## if enterprise is not enabled +i18n-extract-enterprise: + @echo "Skipping i18n extract for Enterprise: not enabled" +else +i18n-extract-enterprise: $(SWAGGER) ## Generate API Swagger specification + @echo "Extracting i18n strings for Enterprise" + yarn run i18next --config public/locales/i18next-parser-enterprise.config.cjs + node ./public/locales/pseudo.mjs --mode enterprise +endif + +.PHONY: i18n-extract +i18n-extract: i18n-extract-enterprise + @echo "Extracting i18n strings for OSS" + yarn run i18next --config public/locales/i18next-parser.config.cjs + node ./public/locales/pseudo.mjs --mode oss + ##@ Building .PHONY: gen-cue gen-cue: ## Do all CUE/Thema code generation diff --git a/contribute/internationalization.md b/contribute/internationalization.md index 7a0ac2a249f..577d0188eb6 100644 --- a/contribute/internationalization.md +++ b/contribute/internationalization.md @@ -9,7 +9,7 @@ Grafana uses the [i18next](https://www.i18next.com/) framework for managing tran - Use `Go to {{ pageTitle }}` in code to add a translatable phrase - Translations are stored in JSON files in `public/locales/{locale}/grafana.json` - If a particular phrase is not available in the a language then it will fall back to English -- To update phrases in English, edit the default phrase in the component's source and then run `yarn i18n:extract`. +- To update phrases in English, edit the default phrase in the component's source and then run `make i18n-extract`. - The single source of truth for en-US (fallback language) is in grafana/grafana, the single source of truth for any translated language is Crowdin - To update phrases in any translated language, edit the phrase in Crowdin. Do not edit the `{locale}/grafana.json` @@ -41,7 +41,7 @@ const ErrorMessage = ({ id, message }) => There 2. Upon reload, the default English phrase will appear on the page. -3. Before submitting your PR, run the `yarn i18n:extract` command to extract the messages you added into the `public/locales/en-US/grafana.json` file and make them available for translation. +3. Before submitting your PR, run the `make i18n-extract` command to extract the messages you added into the `public/locales/en-US/grafana.json` file and make them available for translation. **Note:** All other languages will receive their translations when they are ready to be downloaded from Crowdin. ### Plain JS usage @@ -79,6 +79,7 @@ While the `t` function can technically be used outside of React functions (e.g, 1. Add a new constant for the new language 2. Add the new constant to the `LOCALES` array 3. Create a PR with the changes and merge when you are ready to release the new language (probably wait until we have translations for it) +4. In the Enterprise repo, update `src/public/locales/localeExtensions.ts` ## How translations work in Grafana @@ -186,7 +187,7 @@ import { t } from 'app/core/internationalization'; const translatedString = t('inbox.heading', 'You got {{count}} message', { count: messages.length }); ``` -Once extracted with `yarn i18n:extract` you will need to manually edit the [English grafana.json message catalogue](../public/locales/en-US/grafana.json) to correct the plural forms. See the [react-i18next docs](https://react.i18next.com/latest/trans-component#plural) for more details. +Once extracted with `make i18n-extract` you will need to manually edit the [English grafana.json message catalogue](../public/locales/en-US/grafana.json) to correct the plural forms. See the [react-i18next docs](https://react.i18next.com/latest/trans-component#plural) for more details. ```json { diff --git a/package.json b/package.json index 5a182619e3a..4c9bc730654 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,6 @@ "plugins:build-bundled": "find plugins-bundled -name package.json -not -path '*/node_modules/*' -execdir yarn build \\;", "watch": "yarn start -d watch,start core:start --watchTheme", "ci:test-frontend": "yarn run test:ci", - "i18n:clean": "rimraf public/locales/en-US/grafana.json", - "i18n:extract": "yarn run i18next -c public/locales/i18next-parser.config.cjs 'public/**/*.{tsx,ts}' 'packages/grafana-ui/**/*.{tsx,ts}' && yarn i18n:pseudo", - "i18n:pseudo": "node ./public/locales/pseudo.js", "i18n:stats": "node ./scripts/cli/reportI18nStats.mjs", "betterer": "betterer", "betterer:json": "ts-node --transpile-only --project ./scripts/cli/tsconfig.json ./scripts/cli/bettererResultsToJson.ts", diff --git a/public/app/core/internationalization/constants.ts b/public/app/core/internationalization/constants.ts index b367bb57a44..a241519fed4 100644 --- a/public/app/core/internationalization/constants.ts +++ b/public/app/core/internationalization/constants.ts @@ -1,4 +1,5 @@ import { ResourceKey } from 'i18next'; +import { uniq } from 'lodash'; export const ENGLISH_US = 'en-US'; export const FRENCH_FRANCE = 'fr-FR'; @@ -10,7 +11,9 @@ export const PSEUDO_LOCALE = 'pseudo-LOCALE'; export const DEFAULT_LANGUAGE = ENGLISH_US; -interface LanguageDefinitions { +export type LocaleFileLoader = () => Promise; + +export interface LanguageDefinition { /** IETF language tag for the language e.g. en-US */ code: string; @@ -18,53 +21,90 @@ interface LanguageDefinitions { name: string; /** Function to load translations */ - loader: () => Promise; + loader: Record; } -export const LANGUAGES: LanguageDefinitions[] = [ +export const LANGUAGES: LanguageDefinition[] = [ { code: ENGLISH_US, name: 'English', - loader: () => import('../../../locales/en-US/grafana.json'), + loader: { + grafana: () => import('../../../locales/en-US/grafana.json'), + }, }, { code: FRENCH_FRANCE, name: 'Français', - loader: () => import('../../../locales/fr-FR/grafana.json'), + loader: { + grafana: () => import('../../../locales/fr-FR/grafana.json'), + }, }, { code: SPANISH_SPAIN, name: 'Español', - loader: () => import('../../../locales/es-ES/grafana.json'), + loader: { + grafana: () => import('../../../locales/es-ES/grafana.json'), + }, }, { code: GERMAN_GERMANY, name: 'Deutsch', - loader: () => import('../../../locales/de-DE/grafana.json'), + loader: { + grafana: () => import('../../../locales/de-DE/grafana.json'), + }, }, { code: CHINESE_SIMPLIFIED, name: '中文(简体)', - loader: () => import('../../../locales/zh-Hans/grafana.json'), + loader: { + grafana: () => import('../../../locales/zh-Hans/grafana.json'), + }, }, { code: BRAZILIAN_PORTUGUESE, name: 'Português Brasileiro', - loader: () => import('../../../locales/pt-BR/grafana.json'), + loader: { + grafana: () => import('../../../locales/pt-BR/grafana.json'), + }, }, -]; +] satisfies Array>; if (process.env.NODE_ENV === 'development') { LANGUAGES.push({ code: PSEUDO_LOCALE, name: 'Pseudo-locale', - loader: () => import('../../../locales/pseudo-LOCALE/grafana.json'), + loader: { + grafana: () => import('../../../locales/pseudo-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 +// +// require.context doesn't work in jest, so we don't even attempt to load enterprise translations... +if (process.env.NODE_ENV !== 'test') { + const extensionRequireContext = require.context('../../', true, /app\/extensions\/locales\/localeExtensions/); + if (extensionRequireContext.keys().includes('app/extensions/locales/localeExtensions')) { + const { LOCALE_EXTENSIONS, ENTERPRISE_I18N_NAMESPACE } = extensionRequireContext( + 'app/extensions/locales/localeExtensions' + ); + + for (const language of LANGUAGES) { + const localeLoader = LOCALE_EXTENSIONS[language.code]; + + if (localeLoader) { + language.loader[ENTERPRISE_I18N_NAMESPACE] = localeLoader; + } + } + } +} + export const VALID_LANGUAGES = LANGUAGES.map((v) => v.code); + +export const NAMESPACES = uniq(LANGUAGES.flatMap((v) => Object.keys(v.loader))); diff --git a/public/app/core/internationalization/index.tsx b/public/app/core/internationalization/index.tsx index 4d3e4bf047d..812fc4f83b7 100644 --- a/public/app/core/internationalization/index.tsx +++ b/public/app/core/internationalization/index.tsx @@ -3,12 +3,12 @@ import LanguageDetector, { DetectorOptions } from 'i18next-browser-languagedetec import React from 'react'; import { Trans as I18NextTrans, initReactI18next } from 'react-i18next'; // eslint-disable-line no-restricted-imports -import { DEFAULT_LANGUAGE, VALID_LANGUAGES } from './constants'; +import { DEFAULT_LANGUAGE, NAMESPACES, VALID_LANGUAGES } from './constants'; import { loadTranslations } from './loadTranslations'; let tFunc: TFunction | undefined; -export function initializeI18n(language: string): Promise<{ language: string | undefined }> { +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.'); @@ -24,7 +24,10 @@ export function initializeI18n(language: string): Promise<{ language: string | u // 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, }; + let i18nInstance = i18n; if (language === 'detect') { i18nInstance = i18nInstance.use(LanguageDetector); @@ -39,13 +42,13 @@ export function initializeI18n(language: string): Promise<{ language: string | u .use(initReactI18next) // passes i18n down to react-i18next .init(options); - tFunc = i18n.t; + await loadPromise; - return loadPromise.then(() => { - return { - language: i18nInstance.resolvedLanguage, - }; - }); + tFunc = i18n.getFixedT(null, NAMESPACES); + + return { + language: i18nInstance.resolvedLanguage, + }; } export function changeLanguage(locale: string) { @@ -54,7 +57,7 @@ export function changeLanguage(locale: string) { } export const Trans: typeof I18NextTrans = (props) => { - return ; + return ; }; // Wrap t() to provide default namespaces and enforce a consistent API diff --git a/public/app/core/internationalization/loadTranslations.ts b/public/app/core/internationalization/loadTranslations.ts index 3e618027ee4..301261fca6d 100644 --- a/public/app/core/internationalization/loadTranslations.ts +++ b/public/app/core/internationalization/loadTranslations.ts @@ -12,10 +12,17 @@ export const loadTranslations: BackendModule = { if (!localeDef) { localeDef = LANGUAGES.find((v) => getLanguagePartFromCode(v.code) === getLanguagePartFromCode(language)); } + if (!localeDef) { - return callback(new Error('No message loader available for ' + language), null); + return callback(new Error(`No message loader available for ${language}`), null); } - const messages = await localeDef.loader(); + + const namespaceLoader = localeDef.loader[namespace]; + if (!namespaceLoader) { + return callback(new Error(`No message loader available for ${language} with namespace ${namespace}`), null); + } + + const messages = await namespaceLoader(); callback(null, messages); }, }; diff --git a/public/locales/i18next-parser-enterprise.config.cjs b/public/locales/i18next-parser-enterprise.config.cjs new file mode 100644 index 00000000000..66e8cd9cca2 --- /dev/null +++ b/public/locales/i18next-parser-enterprise.config.cjs @@ -0,0 +1,8 @@ +const baseConfig = require('./i18next-parser.config.cjs'); + +module.exports = { + ...baseConfig, + defaultNamespace: 'grafana-enterprise', + input: ['../../public/app/extensions/**/*.{tsx,ts}'], + output: './public/app/extensions/locales/$LOCALE/$NAMESPACE.json', +}; diff --git a/public/locales/i18next-parser.config.cjs b/public/locales/i18next-parser.config.cjs index 2aeca9d3745..f6c47aa7cc1 100644 --- a/public/locales/i18next-parser.config.cjs +++ b/public/locales/i18next-parser.config.cjs @@ -1,6 +1,6 @@ module.exports = { - // Base config - locales: ['en-US'], // Only en-US is updated - Crowdin will PR with other languages + // Base config - same for both OSS and Enterprise + locales: ['en-US'], // Only en-US is updated - Crowdin will PR with other languages sort: true, createOldCatalogs: false, failOnWarnings: true, @@ -9,6 +9,10 @@ module.exports = { // OSS-specific config defaultNamespace: 'grafana', - input: ['../../public/**/*.{tsx,ts}', '!../../public/app/extensions/**/*', '../../packages/grafana-ui/**/*.{tsx,ts}'], + input: [ + '../../public/**/*.{tsx,ts}', + '!../../public/app/extensions/**/*', // Don't extract from Enterprise + '../../packages/grafana-ui/**/*.{tsx,ts}', + ], output: './public/locales/$LOCALE/$NAMESPACE.json', }; diff --git a/public/locales/pseudo.js b/public/locales/pseudo.js deleted file mode 100644 index 4bcfef5df05..00000000000 --- a/public/locales/pseudo.js +++ /dev/null @@ -1,24 +0,0 @@ -const fs = require('fs/promises'); -const pseudoizer = require('pseudoizer'); -const prettier = require('prettier'); - -function pseudoizeJsonReplacer(key, value) { - if (typeof value === 'string') { - // Split string on brace-enclosed segments. Odd indices will be {{variables}} - const phraseParts = value.split(/(\{\{[^}]+}\})/g); - const translatedParts = phraseParts.map((str, index) => index % 2 ? str : pseudoizer.pseudoize(str)) - return translatedParts.join("") - } - - return value; -} - -fs.readFile('./public/locales/en-US/grafana.json').then(async (enJson) => { - const enMessages = JSON.parse(enJson); - // Add newline to make prettier happy - const pseudoJson = await prettier.format(JSON.stringify(enMessages, pseudoizeJsonReplacer, 2), { - parser: 'json', - }); - - return fs.writeFile('./public/locales/pseudo-LOCALE/grafana.json', pseudoJson); -}); diff --git a/public/locales/pseudo.mjs b/public/locales/pseudo.mjs new file mode 100644 index 00000000000..05f3f89626d --- /dev/null +++ b/public/locales/pseudo.mjs @@ -0,0 +1,63 @@ +// @ts-check +import { readFile, writeFile } from 'fs/promises'; +import { format } from 'prettier'; +import { pseudoize } from 'pseudoizer'; +import { hideBin } from 'yargs/helpers'; +import yargs from 'yargs/yargs'; + +const argv = await yargs(hideBin(process.argv)) + .option('mode', { + demandOption: true, + describe: 'Path to a template to use for each issue. See source bettererIssueTemplate.md for an example', + type: 'string', + choices: ['oss', 'enterprise', 'both'], + }) + .version(false).argv; + +const extractOSS = ['oss', 'both'].includes(argv.mode); +const extractEnterprise = ['enterprise', 'both'].includes(argv.mode); + +/** + * @param {string} key + * @param {unknown} value + */ +function pseudoizeJsonReplacer(key, value) { + if (typeof value === 'string') { + // Split string on brace-enclosed segments. Odd indices will be {{variables}} + const phraseParts = value.split(/(\{\{[^}]+}\})/g); + const translatedParts = phraseParts.map((str, index) => (index % 2 ? str : pseudoize(str))); + return translatedParts.join(''); + } + + return value; +} +/** + * @param {string} inputPath + * @param {string} outputPath + */ +async function pseudoizeJson(inputPath, outputPath) { + const baseJson = await readFile(inputPath, 'utf-8'); + const enMessages = JSON.parse(baseJson); + const pseudoJson = JSON.stringify(enMessages, pseudoizeJsonReplacer, 2); + const prettyPseudoJson = await format(pseudoJson, { + parser: 'json', + }); + + await writeFile(outputPath, prettyPseudoJson); + console.log('Wrote', outputPath); +} + +// +// OSS translations +if (extractOSS) { + await pseudoizeJson('./public/locales/en-US/grafana.json', './public/locales/pseudo-LOCALE/grafana.json'); +} + +// +// Enterprise translations +if (extractEnterprise) { + await pseudoizeJson( + './public/app/extensions/locales/en-US/grafana-enterprise.json', + './public/app/extensions/locales/pseudo-LOCALE/grafana-enterprise.json' + ); +} diff --git a/scripts/drone/steps/lib.star b/scripts/drone/steps/lib.star index 85d435f7f7c..26070962065 100644 --- a/scripts/drone/steps/lib.star +++ b/scripts/drone/steps/lib.star @@ -641,7 +641,7 @@ def lint_frontend_step(): def verify_i18n_step(): extract_error_message = "\nExtraction failed. Make sure that you have no dynamic translation phrases, such as 't(\\`preferences.theme.\\$${themeID}\\`, themeName)' and that no translation key is used twice. Search the output for '[warning]' to find the offending file." - uncommited_error_message = "\nTranslation extraction has not been committed. Please run 'yarn i18n:extract', commit the changes and push again." + uncommited_error_message = "\nTranslation extraction has not been committed. Please run 'make i18n-extract', commit the changes and push again." return { "name": "verify-i18n", "image": images["node"], @@ -651,7 +651,7 @@ def verify_i18n_step(): "failure": "ignore", "commands": [ "apk add --update git", - "yarn run i18n:extract || (echo \"{}\" && false)".format(extract_error_message), + "make i18n-extract || (echo \"{}\" && false)".format(extract_error_message), # Verify that translation extraction has been committed ''' file_diff=$(git diff --dirstat public/locales) diff --git a/yarn.lock b/yarn.lock index 700b3a5c9bb..39e99715e8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18365,8 +18365,8 @@ __metadata: linkType: hard "glob-stream@npm:^8.0.0": - version: 8.0.0 - resolution: "glob-stream@npm:8.0.0" + version: 8.0.2 + resolution: "glob-stream@npm:8.0.2" dependencies: "@gulpjs/to-absolute-glob": "npm:^4.0.0" anymatch: "npm:^3.1.3" @@ -18376,7 +18376,7 @@ __metadata: is-negated-glob: "npm:^1.0.0" normalize-path: "npm:^3.0.0" streamx: "npm:^2.12.5" - checksum: 10/b1d18b6fd49086ff02e031f03e3debac747047d304b349a6dced3b7944c665344ef63496363f483acc7c6afcd6ebfb11af1652824f2c370d83c0f3905d5c67e0 + checksum: 10/cda46c02b6313d4a5cd0a3e67c7a2bd477d5f708904dc761c0d6364611f188a303051ec4e0cd405597522c7f7ffbba530f147754b4bf5af9f18e970c024734d8 languageName: node linkType: hard