Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-chinese-locales-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rocket.chat/meteor': patch
'@rocket.chat/i18n': patch
'@rocket.chat/tools': patch
---

Fix regression where Traditional Chinese (zh-TW, zh-HK) locales resolve to Simplified Chinese (zh) resources and browser language detection collapses Traditional Chinese variants into Simplified Chinese.
4 changes: 3 additions & 1 deletion apps/meteor/client/providers/TranslationProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
availableTranslationNamespaces,
defaultTranslationNamespace,
extractTranslationNamespaces,
defaultFallbackLng,
} from '@rocket.chat/i18n';
import languages from '@rocket.chat/i18n/dist/languages';
import en from '@rocket.chat/i18n/dist/resources/en.i18n.json';
Expand Down Expand Up @@ -72,7 +73,8 @@ const useI18next = (lng: string): typeof i18next => {
isI18nInitialized = true;
i18n.init({
lng,
fallbackLng: 'en',
fallbackLng: defaultFallbackLng,
load: 'currentOnly',
ns: availableTranslationNamespaces,
defaultNS: defaultTranslationNamespace,
nsSeparator: '.',
Expand Down
10 changes: 8 additions & 2 deletions apps/meteor/server/lib/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { availableTranslationNamespaces, defaultTranslationNamespace, extractTranslationNamespaces } from '@rocket.chat/i18n';
import {
availableTranslationNamespaces,
defaultFallbackLng,
defaultTranslationNamespace,
extractTranslationNamespaces,
} from '@rocket.chat/i18n';
import languages from '@rocket.chat/i18n/dist/languages';

import { i18n } from '../../app/utils/lib/i18n';
Expand Down Expand Up @@ -79,7 +84,8 @@ if (false) {

void i18n.init({
lng: 'en',
fallbackLng: 'en',
fallbackLng: defaultFallbackLng,
load: 'currentOnly',
defaultNS: defaultTranslationNamespace,
ns: availableTranslationNamespaces,
nsSeparator: '.',
Expand Down
111 changes: 111 additions & 0 deletions packages/i18n/src/fallbackLng.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import i18next from 'i18next';

import { defaultFallbackLng } from './index';

describe('defaultFallbackLng', () => {
it('should resolve zh-TW to [zh-TW, zh-HK, en] without zh / Simplified Chinese', () => {
const i18n = i18next.createInstance();
i18n.init({
lng: 'zh-TW',
fallbackLng: defaultFallbackLng,
load: 'currentOnly',
initImmediate: false,
});

expect(i18n.languages).toEqual(['zh-TW', 'zh-HK', 'en']);
expect(i18n.languages).not.toContain('zh');
expect(i18n.languages).not.toContain('zh-CN');
});

it('should resolve zh-HK to [zh-HK, zh-TW, en] without zh / Simplified Chinese', () => {
const i18n = i18next.createInstance();
i18n.init({
lng: 'zh-HK',
fallbackLng: defaultFallbackLng,
load: 'currentOnly',
initImmediate: false,
});

expect(i18n.languages).toEqual(['zh-HK', 'zh-TW', 'en']);
expect(i18n.languages).not.toContain('zh');
expect(i18n.languages).not.toContain('zh-CN');
});

it('should resolve zh (Simplified Chinese) to [zh, en]', () => {
const i18n = i18next.createInstance();
i18n.init({
lng: 'zh',
fallbackLng: defaultFallbackLng,
load: 'currentOnly',
initImmediate: false,
});

expect(i18n.languages).toEqual(['zh', 'en']);
});

it('should resolve zh-CN to [zh-CN, zh, en]', () => {
const i18n = i18next.createInstance();
i18n.init({
lng: 'zh-CN',
fallbackLng: defaultFallbackLng,
load: 'currentOnly',
initImmediate: false,
});

expect(i18n.languages).toEqual(['zh-CN', 'zh', 'en']);
});

it('should resolve regional variants like pt-BR and de-AT appropriately', () => {
const i18nPt = i18next.createInstance();
i18nPt.init({
lng: 'pt-BR',
fallbackLng: defaultFallbackLng,
load: 'currentOnly',
initImmediate: false,
});
expect(i18nPt.languages).toEqual(['pt-BR', 'pt', 'en']);

const i18nDe = i18next.createInstance();
i18nDe.init({
lng: 'de-AT',
fallbackLng: defaultFallbackLng,
load: 'currentOnly',
initImmediate: false,
});
expect(i18nDe.languages).toEqual(['de-AT', 'de', 'en']);
});

it('should resolve translation keys from zh-TW and fall back to en when missing in zh-TW', () => {
const i18n = i18next.createInstance();
i18n.init({
lng: 'zh-TW',
fallbackLng: defaultFallbackLng,
load: 'currentOnly',
initImmediate: false,
resources: {
'zh-TW': {
translation: {
Users: '使用者',
},
},
'zh': {
translation: {
Users: '用户',
private: '私有',
},
},
'en': {
translation: {
Users: 'Users',
private: 'private',
},
},
},
});

// Present in zh-TW
expect(i18n.t('Users')).toBe('使用者');
// Missing in zh-TW -> should fall back to en, NOT zh (Simplified Chinese)
expect(i18n.t('private')).toBe('private');
});
});
17 changes: 17 additions & 0 deletions packages/i18n/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,20 @@ export const applyCustomTranslations = (
}
}
};

export const defaultFallbackLng = {
'zh-TW': ['zh-HK', 'en'],
'zh-HK': ['zh-TW', 'en'],
'zh-Hant': ['zh-TW', 'zh-HK', 'en'],
'zh-Hans': ['zh', 'en'],
'zh-CN': ['zh', 'en'],
'zh-SG': ['zh', 'en'],
'zh-MO': ['zh-HK', 'zh-TW', 'en'],
'pt-BR': ['pt', 'en'],
'de-AT': ['de', 'en'],
'de-IN': ['de', 'en'],
'bn-BD': ['bn-IN', 'en'],
'bn-IN': ['bn-BD', 'en'],
'hi-IN': ['hi', 'en'],
'default': ['en'],
};
74 changes: 74 additions & 0 deletions packages/tools/src/normalizeLanguage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { normalizeLanguage } from './normalizeLanguage';

describe('normalizeLanguage', () => {
it('should return empty/falsy language as is', () => {
expect(normalizeLanguage('')).toBe('');
});

it('should normalize lowercase region codes to uppercase', () => {
expect(normalizeLanguage('pt-br')).toBe('pt-BR');
expect(normalizeLanguage('en-us')).toBe('en-US');
expect(normalizeLanguage('de-at')).toBe('de-AT');
});

it('should support 3-digit UN M49 region codes', () => {
expect(normalizeLanguage('es-419')).toBe('es-419');
});

it('should normalize script subtags with title casing rather than treating them as region codes', () => {
expect(normalizeLanguage('sr-latn')).toBe('sr-Latn');
expect(normalizeLanguage('sr-Latn')).toBe('sr-Latn');
expect(normalizeLanguage('sr-LATN')).toBe('sr-Latn');
expect(normalizeLanguage('az-latn')).toBe('az-Latn');
expect(normalizeLanguage('uz-cyrl')).toBe('uz-Cyrl');
});

it('should handle underscore delimiter', () => {
expect(normalizeLanguage('pt_br')).toBe('pt-BR');
expect(normalizeLanguage('zh_tw')).toBe('zh-TW');
expect(normalizeLanguage('zh_hk')).toBe('zh-HK');
expect(normalizeLanguage('zh_cn')).toBe('zh');
expect(normalizeLanguage('sr_latn')).toBe('sr-Latn');
});

it('should resolve Traditional Chinese variants to zh-TW', () => {
expect(normalizeLanguage('zh-TW')).toBe('zh-TW');
expect(normalizeLanguage('zh-tw')).toBe('zh-TW');
expect(normalizeLanguage('zh-Hant')).toBe('zh-TW');
expect(normalizeLanguage('zh-hant')).toBe('zh-TW');
expect(normalizeLanguage('zh-Hant-TW')).toBe('zh-TW');
expect(normalizeLanguage('zh-hant-tw')).toBe('zh-TW');
expect(normalizeLanguage('zh-CHT')).toBe('zh-TW');
expect(normalizeLanguage('zh-cht')).toBe('zh-TW');
});

it('should resolve Hong Kong / Macau Traditional Chinese variants to zh-HK', () => {
expect(normalizeLanguage('zh-HK')).toBe('zh-HK');
expect(normalizeLanguage('zh-hk')).toBe('zh-HK');
expect(normalizeLanguage('zh-Hant-HK')).toBe('zh-HK');
expect(normalizeLanguage('zh-hant-hk')).toBe('zh-HK');
expect(normalizeLanguage('zh-MO')).toBe('zh-HK');
expect(normalizeLanguage('zh-mo')).toBe('zh-HK');
expect(normalizeLanguage('zh-Hant-MO')).toBe('zh-HK');
expect(normalizeLanguage('zh-hant-mo')).toBe('zh-HK');
});

it('should resolve Simplified Chinese variants to zh', () => {
expect(normalizeLanguage('zh')).toBe('zh');
expect(normalizeLanguage('zh-CN')).toBe('zh');
expect(normalizeLanguage('zh-cn')).toBe('zh');
expect(normalizeLanguage('zh-Hans')).toBe('zh');
expect(normalizeLanguage('zh-hans')).toBe('zh');
expect(normalizeLanguage('zh-Hans-CN')).toBe('zh');
expect(normalizeLanguage('zh-hans-cn')).toBe('zh');
expect(normalizeLanguage('zh-SG')).toBe('zh');
expect(normalizeLanguage('zh-CHS')).toBe('zh');
});

it('should keep other languages unchanged', () => {
expect(normalizeLanguage('en')).toBe('en');
expect(normalizeLanguage('fr')).toBe('fr');
expect(normalizeLanguage('de')).toBe('de');
expect(normalizeLanguage('ja')).toBe('ja');
});
});
28 changes: 25 additions & 3 deletions packages/tools/src/normalizeLanguage.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
export const normalizeLanguage = (language: string): string => {
if (!language) {
return language;
}

const lang = language.replace(/_/g, '-');

if (/^zh-(hant-hk|hk|hant-mo|mo)/i.test(lang)) {
return 'zh-HK';
}
if (/^zh-(hant(-tw)?|cht|tw)/i.test(lang)) {
return 'zh-TW';
}
if (/^zh-(hans|cn|sg|chs)/i.test(lang) || lang.toLowerCase() === 'zh') {
return 'zh';
}

// Fix browsers having all-lowercase language settings eg. pt-br, en-us
const regex = /([a-z]{2,3})-([a-z]{2,4})/;
const matches = regex.exec(language);
const regex = /^([a-z]{2,3})-([a-z]{2}|[0-9]{3})$/i;
const matches = regex.exec(lang);
if (matches) {
return `${matches[1]}-${matches[2].toUpperCase()}`;
return `${matches[1].toLowerCase()}-${matches[2].toUpperCase()}`;
}

const scriptRegex = /^([a-z]{2,3})-([a-z]{4})$/i;
const scriptMatches = scriptRegex.exec(lang);
if (scriptMatches) {
return `${scriptMatches[1].toLowerCase()}-${scriptMatches[2][0].toUpperCase()}${scriptMatches[2].slice(1).toLowerCase()}`;
}

return language;
Expand Down