From da95cce985f68c0995da1bb1941701b99fcfca50 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 2 Oct 2026 11:25:17 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=90(i18n)=20key=20crowdin=20frontend?= =?UTF-8?q?=20exports=20by=20locale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crowdin exported the frontend translations to a folder named after %two_letters_code%, so every regional variant of a language landed in the same folder: zh-CN and zh-TW both gave "zh", and the last one downloaded overwrote the other. Mapping the Chinese locales apart would have fixed only that pair, and each new variant (pt-BR, es-MX, en-GB...) would have needed its own entry. Use %locale% instead, which is unique per locale, like the backend already does with %locale_with_underscore%. format-deploy keeps the keys of translations.json as they are today (fr, de...) and only keeps the region (zh-cn, zh-tw) when several locales of the same language are translated. The language detection and the language picker already match a locale exactly before falling back to its language, so they need no change. The export pattern is stored by Crowdin with the source file: the sources must be uploaded again before the next download, and the zh-CN data that was polluted with Traditional Chinese still needs to be re-uploaded. Fixes #2749 --- CHANGELOG.md | 1 + crowdin/config.yml | 2 +- .../packages/i18n/__tests__/i18n.test.ts | 30 +++++++++++++++++++ src/frontend/packages/i18n/format-deploy.mjs | 30 +++++++++++++++---- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b830350..884281b8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,7 @@ and this project adheres to - 🐛(frontend) fix redirect after deleting a document #2706 - 🐛(frontend) keep documents draggable with a mouse when zoomed #2727 - 📝(docs) fix markdown typo in installation README #2766 +- 🐛(i18n) export locale correctly #2750 ### Removed diff --git a/crowdin/config.yml b/crowdin/config.yml index 724e321b0..19909fc6c 100644 --- a/crowdin/config.yml +++ b/crowdin/config.yml @@ -23,7 +23,7 @@ files: [ { source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json", dest: "/frontend-impress.json", - translation: "/frontend/packages/i18n/locales/impress/%two_letters_code%/translations.json", + translation: "/frontend/packages/i18n/locales/impress/%locale%/translations.json", skip_untranslated_strings: true, }, ] diff --git a/src/frontend/packages/i18n/__tests__/i18n.test.ts b/src/frontend/packages/i18n/__tests__/i18n.test.ts index 98e45e250..2041004ad 100644 --- a/src/frontend/packages/i18n/__tests__/i18n.test.ts +++ b/src/frontend/packages/i18n/__tests__/i18n.test.ts @@ -60,6 +60,36 @@ describe('integration testing on i18n package', () => { }); }); + test('cmd format-deploy keeps the region only when locales collide', () => { + // To be sure the tests folder is not here + fs.rmSync('./locales/tests', { recursive: true, force: true }); + + const locales = { + en: 'My test', + 'fr-FR': 'Mon test', + 'zh-CN': 'įŽ€', + 'zh-TW': 'įš', + }; + Object.entries(locales).forEach(([locale, message]) => { + fs.mkdirSync(`./locales/tests/${locale}/`, { recursive: true }); + fs.writeFileSync( + `./locales/tests/${locale}/translations.json`, + JSON.stringify({ test: { message } }), + 'utf8', + ); + }); + + const output = './locales/tests/translations.json'; + execSync(`node ./format-deploy.mjs --app=tests --output=${output}`); + const json = JSON.parse(fs.readFileSync(output, 'utf8')); + expect(json).toEqual({ + en: { translation: { test: 'My test' } }, + fr: { translation: { test: 'Mon test' } }, + 'zh-cn': { translation: { test: 'įŽ€' } }, + 'zh-tw': { translation: { test: 'įš' } }, + }); + }); + test('cmd format-deploy throws an error when translation file is not found', () => { // To be sure the tests folder is not here fs.rmSync('./locales/tests', { recursive: true, force: true }); diff --git a/src/frontend/packages/i18n/format-deploy.mjs b/src/frontend/packages/i18n/format-deploy.mjs index ccd3ee382..08c618a55 100644 --- a/src/frontend/packages/i18n/format-deploy.mjs +++ b/src/frontend/packages/i18n/format-deploy.mjs @@ -11,13 +11,31 @@ const folderPath = './locales/' + app; const namefile = 'translations.json'; const jsonI18n = {}; -// Fetch the files in the locales folder -fs.readdirSync(folderPath).map((language) => { - const languagePath = path.join(folderPath, path.sep, language); +// Crowdin outputs one folder per locale (fr-FR, zh-CN, zh-TW, en...) +const locales = fs.readdirSync(folderPath).filter((entry) => { // Crowdin output file in folder, we want to treat only these ones - if (!fs.lstatSync(languagePath).isDirectory()) { - return; - } + return fs.lstatSync(path.join(folderPath, entry)).isDirectory(); +}); + +const getLanguage = (locale) => locale.split(/[-_]/)[0].toLowerCase(); + +const localesPerLanguage = {}; +locales.forEach((locale) => { + const language = getLanguage(locale); + localesPerLanguage[language] = (localesPerLanguage[language] ?? 0) + 1; +}); + +// The language alone (fr) is enough when only one locale of it is translated, +// the region is kept (zh-cn, zh-tw) when the locales would otherwise collide. +const getKey = (locale) => + localesPerLanguage[getLanguage(locale)] > 1 + ? locale.replace('_', '-').toLowerCase() + : getLanguage(locale); + +// Fetch the files in the locales folder +locales.forEach((locale) => { + const language = getKey(locale); + const languagePath = path.join(folderPath, path.sep, locale); jsonI18n[language] = { translation: {},