i18n: officer-web catalog goes regional (en-GB, pt-PT), bridge deep-merges app/panel/ui and sets dir
This commit is contained in:
@@ -1,11 +1,30 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { TranslationMap } from 'i18n';
|
import type { TranslationMap } from 'i18n';
|
||||||
import { I18nProvider } from 'i18n';
|
import { I18nProvider, localeDirection } from 'i18n';
|
||||||
|
import { panelCatalogs } from 'officerdev';
|
||||||
|
import { uiCatalogs } from '@/components/locales';
|
||||||
import { useSettings } from 'state/useSettings';
|
import { useSettings } from 'state/useSettings';
|
||||||
import en from '../locales/en.json';
|
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
|
||||||
import pt from '../locales/pt.json';
|
import appEnGB from '../locales/en-GB.json';
|
||||||
|
import appPtPT from '../locales/pt-PT.json';
|
||||||
|
|
||||||
const translations: TranslationMap = { en, pt };
|
const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from
|
||||||
|
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
|
||||||
|
*/
|
||||||
|
const translations: TranslationMap = Object.fromEntries(
|
||||||
|
SHIPPED_LOCALES.map(({ code }) => [
|
||||||
|
code,
|
||||||
|
{
|
||||||
|
app: appCatalogs[code] ?? {},
|
||||||
|
panel: panelCatalogs[code] ?? {},
|
||||||
|
ui: uiCatalogs[code] ?? {},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
type I18nBridgeProps = {
|
type I18nBridgeProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -15,8 +34,14 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
|
|||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const locale = settings.languages.default;
|
const locale = settings.languages.default;
|
||||||
|
|
||||||
|
// Prepared for RTL (D3) — every shipped locale is `ltr` today, but the wiring exists so a future
|
||||||
|
// RTL catalog needs no second pass over the tree.
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dir = localeDirection(locale);
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nProvider translations={translations} locale={locale}>
|
<I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
|
||||||
{children}
|
{children}
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { TranslationKeys } from 'i18n';
|
import type { TranslationKeys } from 'i18n';
|
||||||
import type { InterpolationParams } from 'i18n';
|
import type { InterpolationParams } from 'i18n';
|
||||||
import { useTranslation as useTranslationBase } from 'i18n';
|
import { useTranslation as useTranslationBase } from 'i18n';
|
||||||
import type en from '../locales/en.json';
|
import type en from '../locales/en-GB.json';
|
||||||
|
|
||||||
type AppTranslationKey = TranslationKeys<typeof en>;
|
type AppTranslationKey = TranslationKeys<typeof en>;
|
||||||
|
|
||||||
|
// The `app.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||||
|
// pass the same bare key the JSON file uses.
|
||||||
export const useTranslation = () => {
|
export const useTranslation = () => {
|
||||||
const { t: baseT, locale } = useTranslationBase();
|
const { t: baseT, locale } = useTranslationBase();
|
||||||
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(key, params);
|
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params);
|
||||||
return { t, locale };
|
return { t, locale };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* The UI locales that actually ship — regional BCP 47 tags, per docs/localization.md D2. This is the
|
||||||
|
* one list `I18nBridge` merges catalogs for and `Languages.tsx` offers for UI localization; it is
|
||||||
|
* deliberately separate from the broader "languages you speak" / "translate to" lists, which are a
|
||||||
|
* different feature (see the doc's open question 4) and are not limited to locales with a catalog.
|
||||||
|
*/
|
||||||
|
export type ShippedLocale = { code: string; label: string };
|
||||||
|
|
||||||
|
export const SHIPPED_LOCALES: ShippedLocale[] = [
|
||||||
|
{ code: 'en-GB', label: 'English (UK)' },
|
||||||
|
{ code: 'pt-PT', label: 'Português (Portugal)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FALLBACK_LOCALE = 'en-GB';
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"profile": {
|
||||||
|
"searchPlaceholder": "Search settings...",
|
||||||
|
"sections": {
|
||||||
|
"profileTitle": "Profile",
|
||||||
|
"profileDescription": "Update your name and avatar.",
|
||||||
|
"changePasswordTitle": "Change Password",
|
||||||
|
"changePasswordDescription": "Update your account password.",
|
||||||
|
"tasksTitle": "Tasks",
|
||||||
|
"tasksDescription": "Default model for file browser tasks.",
|
||||||
|
"languagesTitle": "Languages",
|
||||||
|
"languagesDescription": "Set your spoken languages and translation preferences."
|
||||||
|
},
|
||||||
|
"userData": {
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"nameLabel": "Name",
|
||||||
|
"namePlaceholder": "Your name",
|
||||||
|
"profileUpdated": "Profile updated",
|
||||||
|
"profileUpdateFailed": "Failed to update profile",
|
||||||
|
"selectImageFile": "Please select an image file",
|
||||||
|
"imageTooLarge": "Image must be smaller than 384KB"
|
||||||
|
},
|
||||||
|
"changePassword": {
|
||||||
|
"currentPasswordLabel": "Current Password",
|
||||||
|
"currentPasswordPlaceholder": "Current password",
|
||||||
|
"newPasswordLabel": "New Password",
|
||||||
|
"newPasswordPlaceholder": "New password",
|
||||||
|
"confirmPasswordLabel": "Confirm New Password",
|
||||||
|
"confirmPasswordPlaceholder": "Confirm new password",
|
||||||
|
"changeButton": "Change Password",
|
||||||
|
"changingButton": "Changing...",
|
||||||
|
"passwordChanged": "Password changed",
|
||||||
|
"passwordChangeFailed": "Failed to change password"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"spokenLabel": "Languages you speak",
|
||||||
|
"addLanguage": "Add a language...",
|
||||||
|
"defaultLabel": "Default language",
|
||||||
|
"defaultHint": "Used for UI localization.",
|
||||||
|
"translateToLabel": "Translate to",
|
||||||
|
"translateToHint": "Target language when translating content you don't speak."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"userMenu": {
|
||||||
|
"profile": "Profile",
|
||||||
|
"systemSettings": "System Settings",
|
||||||
|
"signOut": "Sign Out"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dock": {
|
||||||
|
"chat": "Chat",
|
||||||
|
"files": "Files",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"plans": "Plans",
|
||||||
|
"skills": "Skills",
|
||||||
|
"tasks": "Tasks",
|
||||||
|
"processes": "Processes",
|
||||||
|
"logs": "Logs"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { findMissingKeys } from 'i18n';
|
||||||
|
import en from './en-GB.json';
|
||||||
|
import ptPT from './pt-PT.json';
|
||||||
|
|
||||||
|
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and pt-PT lacks is a red build,
|
||||||
|
// not a warning — there is no regional fallback to paper over the gap.
|
||||||
|
describe('officer-web (app.*) locale catalogs', () => {
|
||||||
|
test('pt-PT has every key en-GB has', () => {
|
||||||
|
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Guardar",
|
||||||
|
"saving": "A guardar...",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"profile": {
|
||||||
|
"searchPlaceholder": "Pesquisar definições...",
|
||||||
|
"sections": {
|
||||||
|
"profileTitle": "Perfil",
|
||||||
|
"profileDescription": "Atualiza o teu nome e avatar.",
|
||||||
|
"changePasswordTitle": "Alterar Palavra-passe",
|
||||||
|
"changePasswordDescription": "Atualiza a palavra-passe da tua conta.",
|
||||||
|
"tasksTitle": "Tarefas",
|
||||||
|
"tasksDescription": "Modelo predefinido para tarefas do explorador de ficheiros.",
|
||||||
|
"languagesTitle": "Idiomas",
|
||||||
|
"languagesDescription": "Define os teus idiomas e preferências de tradução."
|
||||||
|
},
|
||||||
|
"userData": {
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"nameLabel": "Nome",
|
||||||
|
"namePlaceholder": "O teu nome",
|
||||||
|
"profileUpdated": "Perfil atualizado",
|
||||||
|
"profileUpdateFailed": "Falha ao atualizar perfil",
|
||||||
|
"selectImageFile": "Por favor seleciona um ficheiro de imagem",
|
||||||
|
"imageTooLarge": "A imagem deve ter menos de 384KB"
|
||||||
|
},
|
||||||
|
"changePassword": {
|
||||||
|
"currentPasswordLabel": "Palavra-passe Atual",
|
||||||
|
"currentPasswordPlaceholder": "Palavra-passe atual",
|
||||||
|
"newPasswordLabel": "Nova Palavra-passe",
|
||||||
|
"newPasswordPlaceholder": "Nova palavra-passe",
|
||||||
|
"confirmPasswordLabel": "Confirmar Nova Palavra-passe",
|
||||||
|
"confirmPasswordPlaceholder": "Confirmar nova palavra-passe",
|
||||||
|
"changeButton": "Alterar Palavra-passe",
|
||||||
|
"changingButton": "A alterar...",
|
||||||
|
"passwordChanged": "Palavra-passe alterada",
|
||||||
|
"passwordChangeFailed": "Falha ao alterar palavra-passe"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"spokenLabel": "Idiomas que falas",
|
||||||
|
"addLanguage": "Adicionar idioma...",
|
||||||
|
"defaultLabel": "Idioma predefinido",
|
||||||
|
"defaultHint": "Usado para localização da interface.",
|
||||||
|
"translateToLabel": "Traduzir para",
|
||||||
|
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"userMenu": {
|
||||||
|
"profile": "Perfil",
|
||||||
|
"systemSettings": "Definições do Sistema",
|
||||||
|
"signOut": "Terminar Sessão"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dock": {
|
||||||
|
"chat": "Chat",
|
||||||
|
"files": "Ficheiros",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"plans": "Planos",
|
||||||
|
"skills": "Skills",
|
||||||
|
"tasks": "Tarefas",
|
||||||
|
"processes": "Processos",
|
||||||
|
"logs": "Registos"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user