🌐(i18n) key crowdin frontend exports by locale

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
This commit is contained in:
Anthony LC
2026-10-02 12:12:52 +02:00
parent 8a7416cb3e
commit da95cce985
4 changed files with 56 additions and 7 deletions
+1
View File
@@ -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
+1 -1
View File
@@ -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,
},
]
@@ -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 });
+24 -6
View File
@@ -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: {},