From 2e30322aa48bbd333acdbae561101deb3787f5e2 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Wed, 5 Aug 2026 16:07:31 -0400 Subject: [PATCH] feat(i18n): add option to require complete translations Add `UserConfig.Options.requireCompleteTranslations`, an opt-in flag set through declaration merging that makes every language in `LanguageOptions` mandatory, for both inline translation objects passed to `t()` and JSON namespaces registered on `UserConfig.Translations`. When the flag is unset, behaviour is unchanged: language keys stay optional and the translator falls back to `defaultLanguage` at runtime. `UserConfig.Options` is intentionally empty in the library, since shipping `requireCompleteTranslations?: boolean` as a default would make a consumer's `requireCompleteTranslations: true` an illegal redeclaration. Leaf detection in `ExtractTranslationKey` stays permissive on purpose, so a language that `libui.json` does not yet translate cannot corrupt `TranslationKey`. Co-Authored-By: Claude Opus 5 (1M context) --- .storybook/index.mdx | 8 ++++++++ TRANSLATIONS.md | 41 +++++++++++++++++++++++++++++++++++++++- src/i18n/translator.ts | 3 ++- src/i18n/types.ts | 43 +++++++++++++++++++++++++++++++++++++++--- 4 files changed, 90 insertions(+), 5 deletions(-) diff --git a/.storybook/index.mdx b/.storybook/index.mdx index 38b141c6..934f0464 100644 --- a/.storybook/index.mdx +++ b/.storybook/index.mdx @@ -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 diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md index d7ba3411..79bc96da 100644 --- a/TRANSLATIONS.md +++ b/TRANSLATIONS.md @@ -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 @@ -99,6 +99,45 @@ The `LanguageToggle` component renders a dropdown from the `options` prop — on ``` +## 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`. diff --git a/src/i18n/translator.ts b/src/i18n/translator.ts index a719debb..61fe6167 100644 --- a/src/i18n/translator.ts +++ b/src/i18n/translator.ts @@ -10,6 +10,7 @@ import type { TranslateOptions, TranslationKey, Translations, + TranslationValue, TranslatorType } from './types.ts'; @@ -112,7 +113,7 @@ export class Translator implements TranslatorType { } @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) ?? {}; diff --git a/src/i18n/types.ts b/src/i18n/types.ts index d2848fff..1d1c39a2 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -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 { @@ -16,10 +16,32 @@ 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 {} } @@ -27,6 +49,21 @@ export type LanguageOptions = OmitIndexSignature & { libui: typeof libuiTranslations; @@ -35,7 +72,7 @@ export type Translations = Simplify< export type ExtractTranslationKey = Key extends string ? T[Key] extends { [key: string]: any } - ? T[Key] extends { [K in Language]?: string } + ? T[Key] extends TranslationValueLike ? Key : `${Key}.${ExtractTranslationKey}` : `${Key}` @@ -60,7 +97,7 @@ export type TranslateOptions = { export interface TranslateFunction { (key: TKey, options?: TranslateOptions): string; - (translations: { [L in Language]?: string }, options?: TranslateOptions): string; + (translations: TranslationValue, options?: TranslateOptions): string; } export type TranslatorType = {