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
8 changes: 8 additions & 0 deletions .storybook/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ declare module '@douglasneuroinformatics/libui/i18n' {
init({ translations: { common } });
```

To require that every language be provided, rather than falling back to the default language at runtime, add:

```ts
export interface Options {
requireCompleteTranslations: true;
}
```

**main.tsx**

```js
Expand Down
41 changes: 40 additions & 1 deletion TRANSLATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The `Language` type includes `en`, `es`, and `fr` out of the box. Every leaf nod
}
```

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`).
By default, 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`). Consumers who want every language to be mandatory can opt in — see [Requiring complete translations](#requiring-complete-translations).

## Architecture

Expand Down Expand Up @@ -99,6 +99,45 @@ The `LanguageToggle` component renders a dropdown from the `options` prop — on
<LanguageToggle options={{ en: 'English', fr: 'Français' }} />
```

## Requiring complete translations

By default a translation may omit languages and fall back at runtime. Set `requireCompleteTranslations` on `UserConfig.Options` to make every language in `LanguageOptions` mandatory:

```ts
declare module '@douglasneuroinformatics/libui/i18n' {
export namespace UserConfig {
export interface Options {
requireCompleteTranslations: true;
}
}
}
```

With the flag set, both registered JSON namespaces and inline objects are checked:

```ts
// error: Property 'fr' is missing in type '{ en: string; es: string; }'
t({ en: 'Save', es: 'Guardar' });
```

```ts
// common.json is missing "fr" for one or more keys
declare module '@douglasneuroinformatics/libui/i18n' {
export namespace UserConfig {
export interface Translations {
// error: Property 'common' ... is not assignable to 'string' index type
common: typeof common;
}
}
}
```

The JSON error is reported on the offending property in your own `declare module` block, so it is unaffected by `skipLibCheck`. Note that:

- Leaf **detection** stays permissive, so adding a language that `libui.json` does not yet translate never corrupts `TranslationKey`.
- libui's own `libui` namespace is exempt from the check — it is typed directly from `libui.json` rather than through the index signature.
- Per-language format arguments (`TranslateFormatArgs`) remain optional, since they are a formatting convenience rather than translated copy.

## Adding a new language

1. Add the language code to `LanguageOptions` in `src/i18n/types.ts`.
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
TranslateOptions,
TranslationKey,
Translations,
TranslationValue,
TranslatorType
} from './types.ts';

Expand Down Expand Up @@ -112,7 +113,7 @@ export class Translator implements TranslatorType<TranslationKey> {
}

@InitializedOnly
t(target: TranslationKey | { [L in Language]?: string }, { args }: TranslateOptions = {}): string {
t(target: TranslationKey | TranslationValue, { args }: TranslateOptions = {}): string {
let obj: { [key: string]: string };
if (typeof target === 'string') {
obj = get(this.#config.translations, target) ?? {};
Expand Down
43 changes: 40 additions & 3 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Merge, OmitIndexSignature, Primitive, Simplify } from 'type-fest';
import type libuiTranslations from './translations/libui.json';

interface TranslationsLike {
[key: string]: TranslationsLike | { [L in Language]?: string };
[key: string]: TranslationsLike | TranslationValue;
}

interface DefaultLanguageOptions {
Expand All @@ -16,17 +16,54 @@ interface DefaultLanguageOptions {
fr: true;
}

/**
* The shape used to *identify* a translation leaf, as opposed to a group of nested translations.
* This is always partial, so that key extraction is unaffected by {@link RequireCompleteTranslations}.
*/
type TranslationValueLike = { [L in Language]?: string };

export declare namespace UserConfig {
interface LanguageOptions {
[key: string]: boolean;
}
/**
* Opt-in flags, set by consumers through declaration merging. This interface is intentionally
* empty here: declaring a member with a default (e.g. `requireCompleteTranslations?: boolean`)
* would make a consumer's `requireCompleteTranslations: true` an illegal redeclaration, since
* merged interface members must have identical types.
*
* @example
* declare module '@douglasneuroinformatics/libui/i18n' {
* export namespace UserConfig {
* export interface Options {
* requireCompleteTranslations: true;
* }
* }
* }
*/
interface Options {}
interface Translations extends TranslationsLike {}
}

export type LanguageOptions = OmitIndexSignature<Merge<DefaultLanguageOptions, UserConfig.LanguageOptions>>;

export type Language = keyof { [L in keyof LanguageOptions as LanguageOptions[L] extends true ? L : never]: any };

/**
* Whether every language in {@link LanguageOptions} must be provided for each translation, as
* opposed to falling back to the default language at runtime. Set via `UserConfig.Options`.
*/
export type RequireCompleteTranslations = UserConfig.Options extends { requireCompleteTranslations: true }
? true
: false;

/**
* The shape a translation leaf must *satisfy*, whether it is defined inline or in a JSON file.
*/
export type TranslationValue = RequireCompleteTranslations extends true
? { [L in Language]: string }
: TranslationValueLike;

export type Translations = Simplify<
OmitIndexSignature<UserConfig.Translations> & {
libui: typeof libuiTranslations;
Expand All @@ -35,7 +72,7 @@ export type Translations = Simplify<

export type ExtractTranslationKey<T extends { [key: string]: any }, Key = keyof T> = Key extends string
? T[Key] extends { [key: string]: any }
? T[Key] extends { [K in Language]?: string }
? T[Key] extends TranslationValueLike
? Key
: `${Key}.${ExtractTranslationKey<T[Key]>}`
: `${Key}`
Expand All @@ -60,7 +97,7 @@ export type TranslateOptions = {

export interface TranslateFunction<TKey extends string> {
(key: TKey, options?: TranslateOptions): string;
(translations: { [L in Language]?: string }, options?: TranslateOptions): string;
(translations: TranslationValue, options?: TranslateOptions): string;
}

export type TranslatorType<TKey extends string> = {
Expand Down
Loading