diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md new file mode 100644 index 00000000..d7ba3411 --- /dev/null +++ b/TRANSLATIONS.md @@ -0,0 +1,106 @@ +# Translation System + +libui ships a type-safe, JSON-driven translation system. Consumers can extend it via TypeScript declaration merging without forking the library. + +## Built-in languages + +The `Language` type includes `en`, `es`, and `fr` out of the box. Every leaf node in a translation JSON file is an object keyed by language code with an optional string value: + +```json +{ + "greeting": { + "en": "Hello", + "es": "Hola", + "fr": "Bonjour" + } +} +``` + +All language keys are optional (`{ [L in Language]?: string }`). When the active language has no translation, the translator falls back to `defaultLanguage` (defaults to `en`). + +## Architecture + +| File | Role | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `src/i18n/types.ts` | Defines `Language`, `Translations`, `TranslationKey`, and the extensible `UserConfig` namespace | +| `src/i18n/translator.ts` | `Translator` class — singleton that resolves translations at runtime | +| `src/i18n/translations/libui.json` | libui's own UI string translations | +| `src/hooks/useTranslation/useTranslation.ts` | React hook wrapping the translator with re-render on language change | + +## How it works + +### 1. Language resolution + +`LanguageOptions` is a merged interface: + +```ts +export type LanguageOptions = UserConfig.LanguageOptions & { + en: true; + es: true; + fr: true; +}; +export type Language = keyof { [L in keyof LanguageOptions as LanguageOptions[L] extends true ? L : never]: any }; +``` + +Consumers can add languages via declaration merging on `UserConfig.LanguageOptions`. + +### 2. Translation namespaces + +Translation JSON files are registered as namespaces. libui registers `libui` automatically. Consumers register their own via `UserConfig.Translations`: + +```ts +declare module '@douglasneuroinformatics/libui/i18n' { + export namespace UserConfig { + export interface Translations { + myNamespace: typeof import('./translations/my-namespace.json'); + } + } +} +``` + +Translation keys are then dot-paths: `myNamespace.greeting`. + +### 3. Initialization + +Call `i18n.init()` once at app startup: + +```ts +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import myTranslations from './translations/my-namespace.json'; + +i18n.init({ + defaultLanguage: 'en', + translations: { myNamespace: myTranslations } +}); +``` + +### 4. Using translations + +The `useTranslation` hook provides `t()`, `resolvedLanguage`, and `changeLanguage`: + +```tsx +const { t } = useTranslation('myNamespace'); + +// Key-based (typed, autocompleted) +t('greeting'); + +// Inline object (for one-off strings) +t({ en: 'Save', es: 'Guardar', fr: 'Enregistrer' }); + +// With format arguments +t('welcome', { args: ['World'] }); // "Hello, World" +``` + +### 5. Controlling which languages appear in the UI + +The `LanguageToggle` component renders a dropdown from the `options` prop — only languages you pass are shown. This lets the consuming app control which languages are user-selectable independently of which languages exist in the type system. + +```tsx + +``` + +## Adding a new language + +1. Add the language code to `LanguageOptions` in `src/i18n/types.ts`. +2. Add translations for every leaf in `src/i18n/translations/libui.json`. +3. Consumers add translations in their own JSON files and pass the language to `LanguageToggle.options`. diff --git a/src/i18n/__tests__/translator.test.ts b/src/i18n/__tests__/translator.test.ts index f05db87f..ce97c7d4 100644 --- a/src/i18n/__tests__/translator.test.ts +++ b/src/i18n/__tests__/translator.test.ts @@ -54,6 +54,13 @@ describe('Translator', () => { expect(translator.t({ en: 'Yes' })).toBe('Yes'); }); + it('should resolve Spanish translations', () => { + translator.changeLanguage('es'); + expect(translator.resolvedLanguage).toBe('es'); + expect(translator.t('libui.days.monday')).toBe('Lunes'); + expect(translator.t({ en: 'Yes', es: 'Sí', fr: 'Oui' })).toBe('Sí'); + }); + it('should return an empty string if no translation is available', () => { vi.spyOn(console, 'error').mockImplementationOnce(() => undefined); expect(translator.t({})).toBe(''); diff --git a/src/i18n/translations/libui.json b/src/i18n/translations/libui.json index 4ab9f8d6..450935a0 100644 --- a/src/i18n/translations/libui.json +++ b/src/i18n/translations/libui.json @@ -2,112 +2,138 @@ "days": { "friday": { "en": "Friday", + "es": "Viernes", "fr": "Vendredi" }, "monday": { "en": "Monday", + "es": "Lunes", "fr": "Lundi" }, "saturday": { "en": "Saturday", + "es": "Sábado", "fr": "Samedi" }, "sunday": { "en": "Sunday", + "es": "Domingo", "fr": "Dimanche" }, "thursday": { "en": "Thursday", + "es": "Jueves", "fr": "Jeudi" }, "tuesday": { "en": "Tuesday", + "es": "Martes", "fr": "Mardi" }, "wednesday": { "en": "Wednesday", + "es": "Miércoles", "fr": "Mercredi" } }, "form": { "append": { "en": "Append", + "es": "Agregar", "fr": "Ajouter" }, "radioLabels": { "false": { "en": "False", + "es": "Falso", "fr": "Faux" }, "true": { "en": "True", + "es": "Verdadero", "fr": "Vrai" } }, "remove": { "en": "Remove", + "es": "Eliminar", "fr": "Supprimer" }, "required": { "en": "This field is required", + "es": "Este campo es obligatorio", "fr": "Ce champ est obligatoire" }, "reset": { "en": "Reset", + "es": "Restablecer", "fr": "Réinitialiser" }, "submit": { "en": "Submit", + "es": "Enviar", "fr": "Soumettre" } }, "months": { "april": { "en": "April", + "es": "Abril", "fr": "Avril" }, "august": { "en": "August", + "es": "Agosto", "fr": "Août" }, "december": { "en": "December", + "es": "Diciembre", "fr": "Décembre" }, "february": { "en": "February", + "es": "Febrero", "fr": "Février" }, "january": { "en": "January", + "es": "Enero", "fr": "Janvier" }, "july": { "en": "July", + "es": "Julio", "fr": "Juillet" }, "june": { "en": "June", + "es": "Junio", "fr": "Juin" }, "march": { "en": "March", + "es": "Marzo", "fr": "Mars" }, "may": { "en": "May", + "es": "Mayo", "fr": "Mai" }, "november": { "en": "November", + "es": "Noviembre", "fr": "Novembre" }, "october": { "en": "October", + "es": "Octubre", "fr": "Octobre" }, "september": { "en": "September", + "es": "Septiembre", "fr": "Septembre" } }, @@ -115,18 +141,22 @@ "types": { "error": { "en": "Error", + "es": "Error", "fr": "Erreur" }, "info": { "en": "Info", + "es": "Información", "fr": "Attention" }, "success": { "en": "Success", + "es": "Éxito", "fr": "Succès" }, "warning": { "en": "Warning", + "es": "Advertencia", "fr": "Avertissement" } } @@ -134,51 +164,62 @@ "oneTimePasswordInput": { "invalidCodeFormat": { "en": "Invalid code format", + "es": "Formato de código no válido", "fr": "Format de code invalide" } }, "pagination": { - "firstPage": { - "en": "<< First", - "fr": "<< Première" - }, "first": { "en": "First", + "es": "Primera", "fr": "Première" }, + "firstPage": { + "en": "<< First", + "es": "<< Primera", + "fr": "<< Première" + }, "info": { "en": "Showing {{first}} to {{last}} of {{total}} results", + "es": "Mostrando {{first}} a {{last}} de {{total}} resultados", "fr": "Affichage de {{first}} à {{last}} sur {{total}} résultats" }, - "lastPage": { - "en": "Last >>", - "fr": "Dernière >>" - }, "last": { "en": "Last", + "es": "Última", "fr": "Dernière" }, + "lastPage": { + "en": "Last >>", + "es": "Última >>", + "fr": "Dernière >>" + }, "next": { "en": "Next", + "es": "Siguiente", "fr": "Suivant" }, "previous": { "en": "Previous", + "es": "Anterior", "fr": "Précédent" } }, "searchBar": { "placeholder": { "en": "Search...", + "es": "Buscar...", "fr": "Rechercher..." } }, "yes": { "en": "Yes", + "es": "Sí", "fr": "Oui" }, "no": { "en": "No", + "es": "No", "fr": "Non" } } diff --git a/src/i18n/types.ts b/src/i18n/types.ts index aa183a38..dd357fcd 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -19,6 +19,7 @@ export declare namespace UserConfig { export type LanguageOptions = UserConfig.LanguageOptions & { en: true; + es: true; fr: true; };