feat: add Brazilian Portuguese locale (#1653)
* Add Brazilian Portuguese locale * Add missing pt-BR translations after main merge * Test zh-CN translation fallback coverage
This commit is contained in:
+5
-2
@@ -8,10 +8,12 @@ Paseo client UI translations live in `packages/app/src/i18n`.
|
||||
- `ar`
|
||||
- `es`
|
||||
- `fr`
|
||||
- `ja`
|
||||
- `pt-BR`
|
||||
- `ru`
|
||||
- `zh-CN`
|
||||
|
||||
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English.
|
||||
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
|
||||
|
||||
## Translation Scope
|
||||
|
||||
@@ -33,7 +35,7 @@ Run:
|
||||
npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
|
||||
```
|
||||
|
||||
The parity test catches missing keys between English and Simplified Chinese resources.
|
||||
The parity test catches missing keys across English and every supported locale resource.
|
||||
|
||||
## Migration Order
|
||||
|
||||
@@ -77,3 +79,4 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t
|
||||
- Batch 4X migrated descriptor and command chrome: Pair-device modal header, workspace setup sheet title, terminal panel fallback labels, command-center action titles, and file-pane host-disconnected fallback. Provider/catalog names, command-center search keywords, terminal runtime titles, file paths, and raw read errors remain runtime values.
|
||||
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
|
||||
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
|
||||
- Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { en } from "./resources/en";
|
||||
import { es } from "./resources/es";
|
||||
import { fr } from "./resources/fr";
|
||||
import { ja } from "./resources/ja";
|
||||
import { ptBR } from "./resources/pt-BR";
|
||||
import { ru } from "./resources/ru";
|
||||
import { zhCN } from "./resources/zh-CN";
|
||||
|
||||
@@ -22,6 +23,7 @@ observeI18nInit(
|
||||
es: { translation: es },
|
||||
fr: { translation: fr },
|
||||
ja: { translation: ja },
|
||||
"pt-BR": { translation: ptBR },
|
||||
ru: { translation: ru },
|
||||
"zh-CN": { translation: zhCN },
|
||||
},
|
||||
|
||||
@@ -8,16 +8,9 @@ import {
|
||||
|
||||
describe("parseAppLanguage", () => {
|
||||
it("accepts system and all supported language locales", () => {
|
||||
expect(["system", "ar", "en", "es", "fr", "ja", "ru", "zh-CN"].map(parseAppLanguage)).toEqual([
|
||||
"system",
|
||||
"ar",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"ja",
|
||||
"ru",
|
||||
"zh-CN",
|
||||
]);
|
||||
expect(
|
||||
["system", "ar", "en", "es", "fr", "ja", "pt-BR", "ru", "zh-CN"].map(parseAppLanguage),
|
||||
).toEqual(["system", "ar", "en", "es", "fr", "ja", "pt-BR", "ru", "zh-CN"]);
|
||||
});
|
||||
|
||||
it("returns null for unknown values", () => {
|
||||
@@ -33,6 +26,7 @@ describe("parseAppLanguage", () => {
|
||||
"es",
|
||||
"fr",
|
||||
"ja",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
"zh-CN",
|
||||
]);
|
||||
@@ -42,14 +36,24 @@ describe("parseAppLanguage", () => {
|
||||
describe("formatLanguageOptionLabel", () => {
|
||||
it("shows the native language name and English name in English UI", () => {
|
||||
const arabic = LANGUAGE_OPTIONS.find((option) => option.value === "ar");
|
||||
const japanese = LANGUAGE_OPTIONS.find((option) => option.value === "ja");
|
||||
const portuguese = LANGUAGE_OPTIONS.find((option) => option.value === "pt-BR");
|
||||
const spanish = LANGUAGE_OPTIONS.find((option) => option.value === "es");
|
||||
const chinese = LANGUAGE_OPTIONS.find((option) => option.value === "zh-CN");
|
||||
|
||||
expect([
|
||||
formatLanguageOptionLabel(arabic!, "en", "System"),
|
||||
formatLanguageOptionLabel(japanese!, "en", "System"),
|
||||
formatLanguageOptionLabel(portuguese!, "en", "System"),
|
||||
formatLanguageOptionLabel(spanish!, "en", "System"),
|
||||
formatLanguageOptionLabel(chinese!, "en", "System"),
|
||||
]).toEqual(["العربية - Arabic", "Español - Spanish", "简体中文 - Simplified Chinese"]);
|
||||
]).toEqual([
|
||||
"العربية - Arabic",
|
||||
"日本語 - Japanese",
|
||||
"Português brasileiro - Brazilian Portuguese",
|
||||
"Español - Spanish",
|
||||
"简体中文 - Simplified Chinese",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the native language name and Chinese name in Chinese UI", () => {
|
||||
@@ -66,8 +70,12 @@ describe("formatLanguageOptionLabel", () => {
|
||||
|
||||
it("uses a single label when both language names match", () => {
|
||||
const english = LANGUAGE_OPTIONS.find((option) => option.value === "en");
|
||||
const japanese = LANGUAGE_OPTIONS.find((option) => option.value === "ja");
|
||||
const portuguese = LANGUAGE_OPTIONS.find((option) => option.value === "pt-BR");
|
||||
|
||||
expect(formatLanguageOptionLabel(english!, "en", "System")).toBe("English");
|
||||
expect(formatLanguageOptionLabel(japanese!, "ja", "システム")).toBe("日本語");
|
||||
expect(formatLanguageOptionLabel(portuguese!, "pt-BR", "Sistema")).toBe("Português brasileiro");
|
||||
});
|
||||
|
||||
it("uses the active-language name for System", () => {
|
||||
@@ -84,6 +92,7 @@ describe("resolveSupportedLocale", () => {
|
||||
expect(resolveSupportedLocale("es", ["en-US"])).toBe("es");
|
||||
expect(resolveSupportedLocale("fr", ["en-US"])).toBe("fr");
|
||||
expect(resolveSupportedLocale("ja", ["en-US"])).toBe("ja");
|
||||
expect(resolveSupportedLocale("pt-BR", ["en-US"])).toBe("pt-BR");
|
||||
expect(resolveSupportedLocale("ru", ["en-US"])).toBe("ru");
|
||||
expect(resolveSupportedLocale("zh-CN", ["en-US"])).toBe("zh-CN");
|
||||
});
|
||||
@@ -94,9 +103,16 @@ describe("resolveSupportedLocale", () => {
|
||||
expect(resolveSupportedLocale("system", ["es-MX"])).toBe("es");
|
||||
expect(resolveSupportedLocale("system", ["fr-CA"])).toBe("fr");
|
||||
expect(resolveSupportedLocale("system", ["ja-JP"])).toBe("ja");
|
||||
expect(resolveSupportedLocale("system", ["pt-BR"])).toBe("pt-BR");
|
||||
expect(resolveSupportedLocale("system", ["pt"])).toBe("pt-BR");
|
||||
expect(resolveSupportedLocale("system", ["ru-RU"])).toBe("ru");
|
||||
});
|
||||
|
||||
it("does not map non-Brazilian Portuguese system locales to Brazilian Portuguese", () => {
|
||||
expect(resolveSupportedLocale("system", ["pt-PT"])).toBe("en");
|
||||
expect(resolveSupportedLocale("system", ["pt-AO"])).toBe("en");
|
||||
});
|
||||
|
||||
it("keeps English when Spanish is a secondary system language", () => {
|
||||
expect(resolveSupportedLocale("system", ["en-US", "es-GB"])).toBe("en");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SupportedLocale = "ar" | "en" | "es" | "fr" | "ja" | "ru" | "zh-CN";
|
||||
export type SupportedLocale = "ar" | "en" | "es" | "fr" | "ja" | "pt-BR" | "ru" | "zh-CN";
|
||||
export type AppLanguage = "system" | SupportedLocale;
|
||||
|
||||
export interface LanguageOption {
|
||||
@@ -15,6 +15,7 @@ export const LANGUAGE_OPTIONS: LanguageOption[] = [
|
||||
{ value: "es", labelKey: "settings.general.language.options.es" },
|
||||
{ value: "fr", labelKey: "settings.general.language.options.fr" },
|
||||
{ value: "ja", labelKey: "settings.general.language.options.ja" },
|
||||
{ value: "pt-BR", labelKey: "settings.general.language.options.ptBR" },
|
||||
{ value: "ru", labelKey: "settings.general.language.options.ru" },
|
||||
{ value: "zh-CN", labelKey: "settings.general.language.options.zhCN" },
|
||||
];
|
||||
@@ -26,6 +27,7 @@ const SUPPORTED_LANGUAGES = new Set<AppLanguage>([
|
||||
"es",
|
||||
"fr",
|
||||
"ja",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
"zh-CN",
|
||||
]);
|
||||
@@ -35,6 +37,7 @@ const LANGUAGE_NATIVE_NAMES: Record<SupportedLocale, string> = {
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
ja: "日本語",
|
||||
"pt-BR": "Português brasileiro",
|
||||
ru: "Русский",
|
||||
"zh-CN": "简体中文",
|
||||
};
|
||||
@@ -45,6 +48,7 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
|
||||
es: "الإسبانية",
|
||||
fr: "الفرنسية",
|
||||
ja: "اليابانية",
|
||||
"pt-BR": "البرتغالية البرازيلية",
|
||||
ru: "الروسية",
|
||||
"zh-CN": "الصينية المبسطة",
|
||||
},
|
||||
@@ -54,6 +58,7 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
|
||||
es: "Spanish",
|
||||
fr: "French",
|
||||
ja: "Japanese",
|
||||
"pt-BR": "Brazilian Portuguese",
|
||||
ru: "Russian",
|
||||
"zh-CN": "Simplified Chinese",
|
||||
},
|
||||
@@ -63,6 +68,7 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
|
||||
es: "español",
|
||||
fr: "francés",
|
||||
ja: "japonés",
|
||||
"pt-BR": "portugués brasileño",
|
||||
ru: "ruso",
|
||||
"zh-CN": "chino simplificado",
|
||||
},
|
||||
@@ -72,6 +78,7 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
|
||||
es: "espagnol",
|
||||
fr: "français",
|
||||
ja: "japonais",
|
||||
"pt-BR": "portugais brésilien",
|
||||
ru: "russe",
|
||||
"zh-CN": "chinois simplifié",
|
||||
},
|
||||
@@ -81,15 +88,27 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
|
||||
es: "スペイン語",
|
||||
fr: "フランス語",
|
||||
ja: "日本語",
|
||||
"pt-BR": "ブラジルポルトガル語",
|
||||
ru: "ロシア語",
|
||||
"zh-CN": "簡体字中国語",
|
||||
},
|
||||
"pt-BR": {
|
||||
ar: "árabe",
|
||||
en: "inglês",
|
||||
es: "espanhol",
|
||||
fr: "francês",
|
||||
ja: "japonês",
|
||||
"pt-BR": "Português brasileiro",
|
||||
ru: "russo",
|
||||
"zh-CN": "chinês simplificado",
|
||||
},
|
||||
ru: {
|
||||
ar: "арабский",
|
||||
en: "английский",
|
||||
es: "испанский",
|
||||
fr: "французский",
|
||||
ja: "японский",
|
||||
"pt-BR": "бразильский португальский",
|
||||
ru: "русский",
|
||||
"zh-CN": "упрощенный китайский",
|
||||
},
|
||||
@@ -99,6 +118,7 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
|
||||
es: "西班牙语",
|
||||
fr: "法语",
|
||||
ja: "日语",
|
||||
"pt-BR": "巴西葡萄牙语",
|
||||
ru: "俄语",
|
||||
"zh-CN": "简体中文",
|
||||
},
|
||||
@@ -153,6 +173,9 @@ export function resolveSupportedLocale(
|
||||
if (normalized === "ja" || normalized.startsWith("ja-")) {
|
||||
return "ja";
|
||||
}
|
||||
if (normalized === "pt" || normalized === "pt-br") {
|
||||
return "pt-BR";
|
||||
}
|
||||
if (normalized === "ru" || normalized.startsWith("ru-")) {
|
||||
return "ru";
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { en } from "./resources/en";
|
||||
import { es } from "./resources/es";
|
||||
import { fr } from "./resources/fr";
|
||||
import { ja } from "./resources/ja";
|
||||
import { ptBR } from "./resources/pt-BR";
|
||||
import { ru } from "./resources/ru";
|
||||
import { zhCN } from "./resources/zh-CN";
|
||||
|
||||
@@ -108,6 +109,7 @@ describe("translation resources", () => {
|
||||
expect(flattenKeys(es).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(fr).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(ja).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(ptBR).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(ru).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(zhCN).sort()).toEqual(englishKeys);
|
||||
});
|
||||
@@ -119,7 +121,9 @@ describe("translation resources", () => {
|
||||
expect(countMatchingEnglishStrings(es)).toBeLessThan(maxFallbackStrings);
|
||||
expect(countMatchingEnglishStrings(fr)).toBeLessThan(maxFallbackStrings);
|
||||
expect(countMatchingEnglishStrings(ja)).toBeLessThan(maxFallbackStrings);
|
||||
expect(countMatchingEnglishStrings(ptBR)).toBeLessThan(maxFallbackStrings);
|
||||
expect(countMatchingEnglishStrings(ru)).toBeLessThan(maxFallbackStrings);
|
||||
expect(countMatchingEnglishStrings(zhCN)).toBeLessThan(maxFallbackStrings);
|
||||
});
|
||||
|
||||
it("preserves interpolation placeholders in every language", () => {
|
||||
@@ -127,6 +131,7 @@ describe("translation resources", () => {
|
||||
expect(findInterpolationMismatches(es)).toEqual([]);
|
||||
expect(findInterpolationMismatches(fr)).toEqual([]);
|
||||
expect(findInterpolationMismatches(ja)).toEqual([]);
|
||||
expect(findInterpolationMismatches(ptBR)).toEqual([]);
|
||||
expect(findInterpolationMismatches(ru)).toEqual([]);
|
||||
expect(findInterpolationMismatches(zhCN)).toEqual([]);
|
||||
});
|
||||
@@ -142,12 +147,14 @@ describe("translation resources", () => {
|
||||
expect(es.modelSelector.modelCountPlural).toBe("{{count}} modelos");
|
||||
expect(fr.modelSelector.modelCountPlural).toBe("{{count}} modèles");
|
||||
expect(ja.modelSelector.modelCountPlural).toBe("{{count}}つのモデル");
|
||||
expect(ptBR.modelSelector.modelCountPlural).toBe("{{count}} modelos");
|
||||
expect(ru.modelSelector.modelCountPlural).toBe("{{count}} моделей");
|
||||
expect(zhCN.modelSelector.modelCountPlural).toBe("{{count}} 个模型");
|
||||
expect(ar.settings.providers.models.many).toBe("{{count}} نماذج");
|
||||
expect(es.settings.providers.models.many).toBe("{{count}} modelos");
|
||||
expect(fr.settings.providers.models.many).toBe("{{count}} modèles");
|
||||
expect(ja.settings.providers.models.many).toBe("{{count}}つのモデル");
|
||||
expect(ptBR.settings.providers.models.many).toBe("{{count}} modelos");
|
||||
expect(ru.settings.providers.models.many).toBe("{{count}} моделей");
|
||||
expect(zhCN.settings.providers.models.many).toBe("{{count}} 个 Model");
|
||||
});
|
||||
|
||||
@@ -1439,6 +1439,7 @@ export const ar: TranslationResources = {
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
ja: "日本語",
|
||||
ptBR: "Português brasileiro",
|
||||
ru: "Русский",
|
||||
zhCN: "中文",
|
||||
},
|
||||
|
||||
@@ -1445,6 +1445,7 @@ export const en = {
|
||||
es: "Spanish",
|
||||
fr: "French",
|
||||
ja: "Japanese",
|
||||
ptBR: "Brazilian Portuguese",
|
||||
ru: "Russian",
|
||||
zhCN: "Simplified Chinese",
|
||||
},
|
||||
|
||||
@@ -1476,6 +1476,7 @@ export const es: TranslationResources = {
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
ja: "日本語",
|
||||
ptBR: "Português brasileiro",
|
||||
ru: "Русский",
|
||||
zhCN: "中文",
|
||||
},
|
||||
|
||||
@@ -1479,6 +1479,7 @@ export const fr: TranslationResources = {
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
ja: "日本語",
|
||||
ptBR: "Português brasileiro",
|
||||
ru: "Русский",
|
||||
zhCN: "中文",
|
||||
},
|
||||
|
||||
@@ -1454,6 +1454,7 @@ export const ja: TranslationResources = {
|
||||
es: "スペイン語",
|
||||
fr: "フランス語",
|
||||
ja: "日本語",
|
||||
ptBR: "ブラジルポルトガル語",
|
||||
ru: "ロシア語",
|
||||
zhCN: "簡体字中国語",
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1466,6 +1466,7 @@ export const ru: TranslationResources = {
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
ja: "日本語",
|
||||
ptBR: "Português brasileiro",
|
||||
ru: "Русский",
|
||||
zhCN: "中文",
|
||||
},
|
||||
|
||||
@@ -1421,6 +1421,7 @@ export const zhCN: TranslationResources = {
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
ja: "日本語",
|
||||
ptBR: "Português brasileiro",
|
||||
ru: "Русский",
|
||||
zhCN: "简体中文",
|
||||
},
|
||||
|
||||
@@ -383,7 +383,7 @@ function GeneralSection({
|
||||
>
|
||||
<Text style={styles.themeTriggerText}>{selectedLanguageLabel}</Text>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={220}>
|
||||
<DropdownMenuContent side="bottom" align="end" width={300}>
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
<LanguageMenuItem
|
||||
key={option.value}
|
||||
|
||||
Reference in New Issue
Block a user