From 99d5886e32a71c821b64dc6c33b1148c51d7d520 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 14 Nov 2024 11:53:43 +0100 Subject: [PATCH] Handles locales as Vite assets --- .github/workflows/translations-download.yaml | 4 +- README.md | 4 +- i18next-parser.config.ts | 2 +- localazy.json | 8 +-- {public/locales => locales}/bg/app.json | 0 {public/locales => locales}/cs/app.json | 0 {public/locales => locales}/de/app.json | 0 {public/locales => locales}/el/app.json | 0 {public/locales => locales}/en-GB/app.json | 0 {public/locales => locales}/es/app.json | 0 {public/locales => locales}/et/app.json | 0 {public/locales => locales}/fa/app.json | 0 {public/locales => locales}/fr/app.json | 0 {public/locales => locales}/id/app.json | 0 {public/locales => locales}/it/app.json | 0 {public/locales => locales}/ja/app.json | 0 {public/locales => locales}/lv/app.json | 0 {public/locales => locales}/pl/app.json | 0 {public/locales => locales}/ru/app.json | 0 {public/locales => locales}/sk/app.json | 0 {public/locales => locales}/sv/app.json | 0 {public/locales => locales}/tr/app.json | 0 {public/locales => locales}/uk/app.json | 0 {public/locales => locales}/vi/app.json | 0 {public/locales => locales}/zh-Hans/app.json | 0 {public/locales => locales}/zh-Hant/app.json | 0 package.json | 1 - src/@types/i18next.d.ts | 2 +- src/initializer.tsx | 70 +++++++++++++++++++- src/vitest.setup.ts | 2 +- vite.config.js | 19 ++++++ yarn.lock | 16 +---- 32 files changed, 99 insertions(+), 29 deletions(-) rename {public/locales => locales}/bg/app.json (100%) rename {public/locales => locales}/cs/app.json (100%) rename {public/locales => locales}/de/app.json (100%) rename {public/locales => locales}/el/app.json (100%) rename {public/locales => locales}/en-GB/app.json (100%) rename {public/locales => locales}/es/app.json (100%) rename {public/locales => locales}/et/app.json (100%) rename {public/locales => locales}/fa/app.json (100%) rename {public/locales => locales}/fr/app.json (100%) rename {public/locales => locales}/id/app.json (100%) rename {public/locales => locales}/it/app.json (100%) rename {public/locales => locales}/ja/app.json (100%) rename {public/locales => locales}/lv/app.json (100%) rename {public/locales => locales}/pl/app.json (100%) rename {public/locales => locales}/ru/app.json (100%) rename {public/locales => locales}/sk/app.json (100%) rename {public/locales => locales}/sv/app.json (100%) rename {public/locales => locales}/tr/app.json (100%) rename {public/locales => locales}/uk/app.json (100%) rename {public/locales => locales}/vi/app.json (100%) rename {public/locales => locales}/zh-Hans/app.json (100%) rename {public/locales => locales}/zh-Hant/app.json (100%) diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index d03040e4..7359f781 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -24,7 +24,7 @@ jobs: run: "yarn install --frozen-lockfile" - name: Prune i18n - run: "rm -R public/locales" + run: "rm -R locales" - name: Download translation files uses: localazy/download@0a79880fb66150601e3b43606fab69c88123c087 # v1.1.0 @@ -32,7 +32,7 @@ jobs: groups: "-p includeSourceLang:true" - name: Fix the owner of the downloaded files - run: "sudo chown runner:docker -R public/locales" + run: "sudo chown runner:docker -R locales" - name: Prettier run: yarn prettier:format diff --git a/README.md b/README.md index 43a2dce0..a0af77fc 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ To add a new translation key you can do these steps: 1. Add the new key entry to the code where the new key is used: `t("some_new_key")` 1. Run `yarn i18n` to extract the new key and update the translation files. This - will add a skeleton entry to the `public/locales/en-GB/app.json` file: + will add a skeleton entry to the `locales/en-GB/app.json` file: ```jsonc { ... @@ -221,7 +221,7 @@ To add a new translation key you can do these steps: ... } ``` -1. Update the skeleton entry in the `public/locales/en-GB/app.json` file with +1. Update the skeleton entry in the `locales/en-GB/app.json` file with the English translation: ```jsonc diff --git a/i18next-parser.config.ts b/i18next-parser.config.ts index f603c37e..7d71d727 100644 --- a/i18next-parser.config.ts +++ b/i18next-parser.config.ts @@ -22,7 +22,7 @@ export default { ], }, locales: ["en-GB"], - output: "public/locales/$LOCALE/$NAMESPACE.json", + output: "locales/$LOCALE/$NAMESPACE.json", input: ["src/**/*.{ts,tsx}"], sort: true, }; diff --git a/localazy.json b/localazy.json index 41cb7656..2b9f713c 100644 --- a/localazy.json +++ b/localazy.json @@ -7,13 +7,13 @@ "features": ["plural_postfix_us", "filter_untranslated"], "files": [ { - "pattern": "public/locales/en-GB/*.json", + "pattern": "locales/en-GB/*.json", "lang": "inherited" }, { "group": "existing", - "pattern": "public/locales/*/*.json", - "excludes": ["public/locales/en-GB/*.json"], + "pattern": "locales/*/*.json", + "excludes": ["locales/en-GB/*.json"], "lang": "${autodetectLang}" } ] @@ -22,7 +22,7 @@ "download": { "files": [ { - "output": "public/locales/${langLsrDash}/${file}" + "output": "locales/${langLsrDash}/${file}" } ], "includeSourceLang": "${includeSourceLang|false}", diff --git a/public/locales/bg/app.json b/locales/bg/app.json similarity index 100% rename from public/locales/bg/app.json rename to locales/bg/app.json diff --git a/public/locales/cs/app.json b/locales/cs/app.json similarity index 100% rename from public/locales/cs/app.json rename to locales/cs/app.json diff --git a/public/locales/de/app.json b/locales/de/app.json similarity index 100% rename from public/locales/de/app.json rename to locales/de/app.json diff --git a/public/locales/el/app.json b/locales/el/app.json similarity index 100% rename from public/locales/el/app.json rename to locales/el/app.json diff --git a/public/locales/en-GB/app.json b/locales/en-GB/app.json similarity index 100% rename from public/locales/en-GB/app.json rename to locales/en-GB/app.json diff --git a/public/locales/es/app.json b/locales/es/app.json similarity index 100% rename from public/locales/es/app.json rename to locales/es/app.json diff --git a/public/locales/et/app.json b/locales/et/app.json similarity index 100% rename from public/locales/et/app.json rename to locales/et/app.json diff --git a/public/locales/fa/app.json b/locales/fa/app.json similarity index 100% rename from public/locales/fa/app.json rename to locales/fa/app.json diff --git a/public/locales/fr/app.json b/locales/fr/app.json similarity index 100% rename from public/locales/fr/app.json rename to locales/fr/app.json diff --git a/public/locales/id/app.json b/locales/id/app.json similarity index 100% rename from public/locales/id/app.json rename to locales/id/app.json diff --git a/public/locales/it/app.json b/locales/it/app.json similarity index 100% rename from public/locales/it/app.json rename to locales/it/app.json diff --git a/public/locales/ja/app.json b/locales/ja/app.json similarity index 100% rename from public/locales/ja/app.json rename to locales/ja/app.json diff --git a/public/locales/lv/app.json b/locales/lv/app.json similarity index 100% rename from public/locales/lv/app.json rename to locales/lv/app.json diff --git a/public/locales/pl/app.json b/locales/pl/app.json similarity index 100% rename from public/locales/pl/app.json rename to locales/pl/app.json diff --git a/public/locales/ru/app.json b/locales/ru/app.json similarity index 100% rename from public/locales/ru/app.json rename to locales/ru/app.json diff --git a/public/locales/sk/app.json b/locales/sk/app.json similarity index 100% rename from public/locales/sk/app.json rename to locales/sk/app.json diff --git a/public/locales/sv/app.json b/locales/sv/app.json similarity index 100% rename from public/locales/sv/app.json rename to locales/sv/app.json diff --git a/public/locales/tr/app.json b/locales/tr/app.json similarity index 100% rename from public/locales/tr/app.json rename to locales/tr/app.json diff --git a/public/locales/uk/app.json b/locales/uk/app.json similarity index 100% rename from public/locales/uk/app.json rename to locales/uk/app.json diff --git a/public/locales/vi/app.json b/locales/vi/app.json similarity index 100% rename from public/locales/vi/app.json rename to locales/vi/app.json diff --git a/public/locales/zh-Hans/app.json b/locales/zh-Hans/app.json similarity index 100% rename from public/locales/zh-Hans/app.json rename to locales/zh-Hans/app.json diff --git a/public/locales/zh-Hant/app.json b/locales/zh-Hant/app.json similarity index 100% rename from public/locales/zh-Hant/app.json rename to locales/zh-Hant/app.json diff --git a/package.json b/package.json index b1015050..0515578e 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "history": "^4.0.0", "i18next": "^23.0.0", "i18next-browser-languagedetector": "^8.0.0", - "i18next-http-backend": "^2.0.0", "i18next-parser": "^9.0.0", "jsdom": "^25.0.0", "knip": "^5.27.2", diff --git a/src/@types/i18next.d.ts b/src/@types/i18next.d.ts index ad35f456..4a8830da 100644 --- a/src/@types/i18next.d.ts +++ b/src/@types/i18next.d.ts @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details. import "i18next"; // import all namespaces (for the default language, only) -import app from "../../public/locales/en-GB/app.json"; +import app from "../../locales/en-GB/app.json"; declare module "i18next" { interface CustomTypeOptions { diff --git a/src/initializer.tsx b/src/initializer.tsx index 0ac8a88a..47634078 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -5,10 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import i18n from "i18next"; +import i18n, { + type BackendModule, + type ReadCallback, + type ResourceKey, +} from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; -import Backend from "i18next-http-backend"; import * as Sentry from "@sentry/react"; import { logger } from "matrix-js-sdk/src/logger"; import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill"; @@ -19,6 +22,68 @@ import { Config } from "./config/Config"; import { ElementCallOpenTelemetry } from "./otel/otel"; import { platform } from "./Platform"; +// This generates a map of locale names to their URL (based on import.meta.url), which looks like this: +// { +// "../locales/en-GB/app.json": "/whatever/assets/root/locales/en-aabbcc.json", +// ... +// } +const locales = import.meta.glob("../locales/*/*.json", { + query: "?url", + import: "default", + eager: true, +}); + +const getLocaleUrl = ( + language: string, + namespace: string, +): string | undefined => locales[`../locales/${language}/${namespace}.json`]; + +const supportedLngs = [ + ...new Set( + Object.keys(locales).map((url) => { + // The URLs are of the form ../locales/en-GB/app.json + // This extracts the language code from the URL + const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1]; + if (!lang) { + throw new Error(`Could not parse locale URL ${url}`); + } + return lang; + }), + ), +]; + +// A backend that fetches the locale files from the URLs generated by the glob above +const Backend = { + type: "backend", + init(): void {}, + read(language: string, namespace: string, callback: ReadCallback): void { + (async (): Promise => { + const url = getLocaleUrl(language, namespace); + if (!url) { + throw new Error( + `Namespace ${namespace} for locale ${language} not found`, + ); + } + + const response = await fetch(url, { + credentials: "omit", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw Error(`Failed to fetch ${url}`); + } + + return await response.json(); + })().then( + (data) => callback(null, data), + (error) => callback(error, null), + ); + }, +} satisfies BackendModule; + enum LoadState { None, Loading, @@ -74,6 +139,7 @@ export class Initializer { nsSeparator: false, pluralSeparator: "_", contextSeparator: "|", + supportedLngs, interpolation: { escapeValue: false, // React has built-in XSS protections }, diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index 776a13b0..421ec663 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -16,7 +16,7 @@ import { cleanup } from "@testing-library/react"; import "vitest-axe/extend-expect"; import { logger } from "matrix-js-sdk/src/logger"; -import EN_GB from "../public/locales/en-GB/app.json"; +import EN_GB from "../locales/en-GB/app.json"; import { Config } from "./config/Config"; // Bare-minimum i18n config diff --git a/vite.config.js b/vite.config.js index b92f717b..6c714bcb 100644 --- a/vite.config.js +++ b/vite.config.js @@ -60,6 +60,25 @@ export default defineConfig(({ mode }) => { }, build: { sourcemap: true, + rollupOptions: { + output: { + assetFileNames: ({ originalFileNames }) => { + if (originalFileNames) { + for (const name of originalFileNames) { + // Custom asset name for locales to include the locale code in the filename + const match = name.match(/locales\/([^/]+)\/(.+)\.json$/); + if (match) { + const [, locale, filename] = match; + return `assets/${locale}-${filename}-[hash].json`; + } + } + } + + // Default naming fallback + return "assets/[name]-[hash][extname]"; + }, + }, + }, }, plugins, resolve: { diff --git a/yarn.lock b/yarn.lock index 5057090b..42c6e6b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4138,13 +4138,6 @@ cosmiconfig@^8.1.3: parse-json "^5.2.0" path-type "^4.0.0" -cross-fetch@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983" - integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g== - dependencies: - node-fetch "^2.6.12" - cross-spawn@^7.0.0, cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -5469,13 +5462,6 @@ i18next-browser-languagedetector@^8.0.0: dependencies: "@babel/runtime" "^7.23.2" -i18next-http-backend@^2.0.0: - version "2.6.2" - resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-2.6.2.tgz#b25516446ae6f251ce8231e70e6ffbca833d46a5" - integrity sha512-Hp/kd8/VuoxIHmxsknJXjkTYYHzivAyAF15pzliKzk2TiXC25rZCEerb1pUFoxz4IVrG3fCvQSY51/Lu4ECV4A== - dependencies: - cross-fetch "4.0.0" - i18next-parser@^9.0.0: version "9.0.2" resolved "https://registry.yarnpkg.com/i18next-parser/-/i18next-parser-9.0.2.tgz#f9d627422d33c352967556c8724975d58f1f5a95" @@ -6331,7 +6317,7 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== -node-fetch@^2.6.12, node-fetch@^2.6.7: +node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==