Skip to content
Merged
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
106 changes: 106 additions & 0 deletions TRANSLATIONS.md
Original file line number Diff line number Diff line change
@@ -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
<LanguageToggle options={{ en: 'English', fr: 'Français' }} />
```

## 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`.
7 changes: 7 additions & 0 deletions src/i18n/__tests__/translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down
57 changes: 49 additions & 8 deletions src/i18n/translations/libui.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,183 +2,224 @@
"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"
}
},
"notifications": {
"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"
}
}
},
"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"
}
}
1 change: 1 addition & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export declare namespace UserConfig {

export type LanguageOptions = UserConfig.LanguageOptions & {
en: true;
es: true;
fr: true;
};

Expand Down
Loading