@kitr/i18n 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Platon Podkopov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @kitr/i18n
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Translations.** Translate your app into several languages with typed keys.
4
+
5
+ ```sh
6
+ pnpm kitr add i18n
7
+ ```
8
+
9
+ `kitr add` installs the package and wires it into your app: the provider in `kitrrc.ts`, its config file and its `AGENTS.md` section. Import it from `@kitr/i18n`.
10
+
11
+ Built on react-i18next.
12
+
13
+ Read the guide at https://kitr.dev/packages/i18n.
14
+
15
+ Part of [kitr](https://kitr.dev), the TypeScript framework where everything is an add-on. MIT licensed.
@@ -0,0 +1,43 @@
1
+ import i18next, { InitOptions, i18n } from "i18next";
2
+ export * from "react-i18next";
3
+ /**
4
+ * The options in `config/i18n.ts`: your strings per locale, the fallback locale and detection.
5
+ * Any other i18next init option passes straight through.
6
+ */
7
+ export interface I18nConfig extends Omit<InitOptions, 'resources' | 'lng'> {
8
+ /** Every locale's strings, keyed by locale then namespace: `{ en: { common: { hello: 'Hello' } } }`. */
9
+ resources: Record<string, Record<string, Record<string, unknown>>>;
10
+ /** The locale used when nothing better matches, and the fallback for missing keys. */
11
+ defaultLocale: string;
12
+ /** The locales the app supports. Default: the keys of `resources`. */
13
+ locales?: string[];
14
+ /** Detect from `navigator.language` (client) or `Accept-Language` (server). Default true. */
15
+ detect?: boolean;
16
+ /** Query/cookie/storage key for an explicit choice. Default `locale`. */
17
+ storageKey?: string;
18
+ }
19
+ declare module '@kitr/core' {
20
+ interface ConfigRegistry {
21
+ i18n: I18nConfig;
22
+ }
23
+ interface ContainerBindings {
24
+ i18n: i18n;
25
+ }
26
+ }
27
+ /** Types `config/i18n.ts`. Returns the object you pass, unchanged. */
28
+ export declare const defineConfig: <const C extends I18nConfig>(config: C) => C;
29
+ /** Keys of a resource bundle as `namespace:key.path` strings, for typed `t()`. */
30
+ export type TranslationKeys<R, Prefix extends string = ''> = { [K in keyof R & string]: R[K] extends string ? `${Prefix}${K}` : TranslationKeys<R[K], `${Prefix}${K}.`>; }[keyof R & string];
31
+ /**
32
+ * Creates an isolated i18next instance from your config, already initialised with react-i18next.
33
+ * The locale is `initialLocale`, else the detected one when `detect` is on, else `defaultLocale`.
34
+ */
35
+ export declare function createI18n(config: I18nConfig, initialLocale?: string): i18n;
36
+ /**
37
+ * In the browser, the saved choice under `storageKey` in localStorage, else the best match for
38
+ * `navigator.languages`. Returns `undefined` on the server or when nothing matches.
39
+ */
40
+ export declare function detectLocale(supported: string[], storageKey?: string): string | undefined;
41
+ /** Pick the best supported locale from an `Accept-Language` header (server) or a language list. */
42
+ export declare function matchLocale(candidates: string | readonly string[], supported: string[]): string | undefined;
43
+ export { i18next };
package/dist/index.js ADDED
@@ -0,0 +1,56 @@
1
+ import { createDefineConfig } from "@kitr/core";
2
+ import i18next from "i18next";
3
+ import { initReactI18next } from "react-i18next";
4
+ export * from "react-i18next";
5
+ // src/index.tsx
6
+ /** Types `config/i18n.ts`. Returns the object you pass, unchanged. */
7
+ const defineConfig = createDefineConfig();
8
+ /**
9
+ * Creates an isolated i18next instance from your config, already initialised with react-i18next.
10
+ * The locale is `initialLocale`, else the detected one when `detect` is on, else `defaultLocale`.
11
+ */
12
+ function createI18n(config, initialLocale) {
13
+ const { resources, defaultLocale, locales, detect = true, storageKey = "locale", ...rest } = config;
14
+ const supported = locales ?? Object.keys(resources);
15
+ const namespaces = [...new Set(Object.values(resources).flatMap((bundle) => Object.keys(bundle)))];
16
+ const defaultNS = namespaces.includes("common") ? "common" : namespaces[0];
17
+ const instance = i18next.createInstance();
18
+ const locale = initialLocale ?? (detect ? detectLocale(supported, storageKey) : void 0) ?? defaultLocale;
19
+ instance.use(initReactI18next).init({
20
+ resources,
21
+ lng: locale,
22
+ fallbackLng: defaultLocale,
23
+ supportedLngs: supported,
24
+ ...defaultNS ? {
25
+ ns: namespaces,
26
+ defaultNS
27
+ } : {},
28
+ interpolation: { escapeValue: false },
29
+ react: { useSuspense: false },
30
+ ...rest
31
+ });
32
+ return instance;
33
+ }
34
+ /**
35
+ * In the browser, the saved choice under `storageKey` in localStorage, else the best match for
36
+ * `navigator.languages`. Returns `undefined` on the server or when nothing matches.
37
+ */
38
+ function detectLocale(supported, storageKey = "locale") {
39
+ if (typeof window === "undefined") return void 0;
40
+ try {
41
+ const stored = window.localStorage.getItem(storageKey);
42
+ if (stored && supported.includes(stored)) return stored;
43
+ } catch {}
44
+ return matchLocale(navigator.languages ?? [navigator.language], supported);
45
+ }
46
+ /** Pick the best supported locale from an `Accept-Language` header (server) or a language list. */
47
+ function matchLocale(candidates, supported) {
48
+ const list = typeof candidates === "string" ? candidates.split(",").map((p) => p.trim().split(";")[0]).filter(Boolean) : candidates;
49
+ for (const lang of list) {
50
+ if (supported.includes(lang)) return lang;
51
+ const short = lang.split("-")[0];
52
+ const hit = supported.find((s) => s === short || s.startsWith(`${short}-`));
53
+ if (hit) return hit;
54
+ }
55
+ }
56
+ export { createI18n, defineConfig, detectLocale, i18next, matchLocale };
package/dist/kitr.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** `kitr add i18n`: react-i18next with typed keys, locale detection and a provider. */
2
+ declare const _default: {
3
+ readonly name: "i18n";
4
+ readonly client: true;
5
+ readonly add: (ctx: import("@kitr/core/package").AddContext) => void;
6
+ readonly remove: (ctx: import("@kitr/core/package").AddContext) => void;
7
+ readonly wraps: {
8
+ readonly 'react-i18next': "@kitr/i18n";
9
+ };
10
+ readonly eject: {
11
+ readonly '@kitr/i18n': "react-i18next";
12
+ };
13
+ readonly vite: {
14
+ readonly optimizeDeps: ["i18next", "react-i18next"];
15
+ };
16
+ };
17
+ export { _default as default };
package/dist/kitr.js ADDED
@@ -0,0 +1,23 @@
1
+ import { definePackage } from "@kitr/core/package";
2
+ // src/kitr.ts
3
+ /** `kitr add i18n`: react-i18next with typed keys, locale detection and a provider. */
4
+ var kitr_default = definePackage({
5
+ name: "i18n",
6
+ client: true,
7
+ add(ctx) {
8
+ ctx.deps.add("i18next", "react-i18next");
9
+ ctx.provider.add("@kitr/i18n/provider");
10
+ ctx.write("app/locales/en.ts", `export default {\n common: {\n hello: 'Hello, {{name}}',\n save: 'Save',\n },\n} as const\n`);
11
+ ctx.config("i18n", `import { defineConfig } from '@kitr/i18n'\nimport en from '#app/locales/en'\n\nexport default defineConfig({\n resources: { en },\n defaultLocale: 'en',\n defaultNS: 'common',\n})\n`);
12
+ ctx.agents.add("i18n", `- Strings live in \`app/locales/<lang>.ts\`. Components: \`const { t } = useTranslation()\` from \`@kitr/i18n\`; keys are typed from the English bundle.`);
13
+ },
14
+ remove(ctx) {
15
+ ctx.provider.remove("@kitr/i18n/provider");
16
+ ctx.deps.remove("i18next", "react-i18next");
17
+ ctx.agents.remove("i18n");
18
+ },
19
+ wraps: { "react-i18next": "@kitr/i18n" },
20
+ eject: { "@kitr/i18n": "react-i18next" },
21
+ vite: { optimizeDeps: ["i18next", "react-i18next"] }
22
+ });
23
+ export { kitr_default as default };
@@ -0,0 +1,4 @@
1
+ import { KitrProvider } from "@kitr/core";
2
+ /** Binds `i18n` from `config/i18n.ts` and wraps the tree in I18nextProvider. */
3
+ export declare const i18nProvider: KitrProvider;
4
+ export { i18nProvider as default };
@@ -0,0 +1,19 @@
1
+ import { createI18n } from "./index.js";
2
+ import { defineProvider } from "@kitr/core";
3
+ import { I18nextProvider } from "react-i18next";
4
+ import { jsx } from "react/jsx-runtime";
5
+ // src/provider.tsx
6
+ /** Binds `i18n` from `config/i18n.ts` and wraps the tree in I18nextProvider. */
7
+ const i18nProvider = defineProvider({
8
+ name: "i18n",
9
+ register(app) {
10
+ app.container.scoped("i18n", () => createI18n(app.config.get("i18n"), app.env.locale));
11
+ },
12
+ wrap(children, app) {
13
+ return /* @__PURE__ */ jsx(I18nextProvider, {
14
+ i18n: app.container.use("i18n"),
15
+ children
16
+ });
17
+ }
18
+ });
19
+ export { i18nProvider as default, i18nProvider };
package/docs/api.md ADDED
@@ -0,0 +1,89 @@
1
+ ---
2
+ title: API reference
3
+ description: Every export of @kitr/i18n, with its signature.
4
+ order: 2
5
+ ---
6
+
7
+ # API reference
8
+
9
+ Every export of `@kitr/i18n`, `@kitr/i18n/provider`, read from the published type definitions. The [guide](/packages/i18n/guide) shows them in use.
10
+
11
+ ## @kitr/i18n
12
+
13
+ Re-exports `i18next`, `react-i18next` unchanged, plus the following.
14
+
15
+ ### createI18n
16
+
17
+ ```ts
18
+ function createI18n(config: I18nConfig, initialLocale?: string): i18n;
19
+ ```
20
+
21
+ Creates an isolated i18next instance from your config, already initialised with react-i18next.
22
+ The locale is `initialLocale`, else the detected one when `detect` is on, else `defaultLocale`.
23
+
24
+ ### detectLocale
25
+
26
+ ```ts
27
+ function detectLocale(supported: string[], storageKey?: string): string | undefined;
28
+ ```
29
+
30
+ In the browser, the saved choice under `storageKey` in localStorage, else the best match for
31
+ `navigator.languages`. Returns `undefined` on the server or when nothing matches.
32
+
33
+ ### matchLocale
34
+
35
+ ```ts
36
+ function matchLocale(candidates: string | readonly string[], supported: string[]): string | undefined;
37
+ ```
38
+
39
+ Pick the best supported locale from an `Accept-Language` header (server) or a language list.
40
+
41
+ ### defineConfig
42
+
43
+ ```ts
44
+ const defineConfig: <const C extends I18nConfig>(config: C) => C;
45
+ ```
46
+
47
+ Types `config/i18n.ts`. Returns the object you pass, unchanged.
48
+
49
+ ### I18nConfig
50
+
51
+ ```ts
52
+ interface I18nConfig extends Omit<InitOptions, 'resources' | 'lng'> {
53
+ /** Every locale's strings, keyed by locale then namespace: `{ en: { common: { hello: 'Hello' } } }`. */
54
+ resources: Record<string, Record<string, Record<string, unknown>>>;
55
+ /** The locale used when nothing better matches, and the fallback for missing keys. */
56
+ defaultLocale: string;
57
+ /** The locales the app supports. Default: the keys of `resources`. */
58
+ locales?: string[];
59
+ /** Detect from `navigator.language` (client) or `Accept-Language` (server). Default true. */
60
+ detect?: boolean;
61
+ /** Query/cookie/storage key for an explicit choice. Default `locale`. */
62
+ storageKey?: string;
63
+ }
64
+ ```
65
+
66
+ The options in `config/i18n.ts`: your strings per locale, the fallback locale and detection.
67
+ Any other i18next init option passes straight through.
68
+
69
+ ### TranslationKeys
70
+
71
+ ```ts
72
+ type TranslationKeys<R, Prefix extends string = ''> = {
73
+ [K in keyof R & string]: R[K] extends string ? `${Prefix}${K}` : TranslationKeys<R[K], `${Prefix}${K}.`>;
74
+ }[keyof R & string];
75
+ ```
76
+
77
+ Keys of a resource bundle as `namespace:key.path` strings, for typed `t()`.
78
+
79
+ ## @kitr/i18n/provider
80
+
81
+ ### i18nProvider
82
+
83
+ ```ts
84
+ const i18nProvider: KitrProvider;
85
+ ```
86
+
87
+ Binds `i18n` from `config/i18n.ts` and wraps the tree in I18nextProvider.
88
+
89
+ <!-- GENERATED by scripts/api-docs.mjs from the type definitions. Do not edit. -->
package/docs/index.md ADDED
@@ -0,0 +1,605 @@
1
+ ---
2
+ title: Translations
3
+ description: Translate your app into several languages with typed keys.
4
+ order: 1
5
+ ---
6
+
7
+ # Translations
8
+
9
+ Acme's first customers all read English. Then a team in Berlin signs up, and every button, empty state and order total needs to read naturally in German too. `@kitr/i18n` moves your interface text into one file per language, picks the visitor's language automatically, and makes a misspelled key a type error.
10
+
11
+ > [!SUMMARY]
12
+ > Translations with typed keys and automatic language detection, built on react-i18next. Add it with `kitr add i18n`; the overview is at [/packages/i18n](/packages/i18n).
13
+
14
+ It is [react-i18next](https://react.i18next.com) and [i18next](https://www.i18next.com), re-exported unchanged, with a provider and sensible defaults: the fallback language is your default locale, there is no HTML escaping (React already escapes) and no Suspense. It adds `matchLocale()` for `Accept-Language` headers and a `TranslationKeys` type for typed key props.
15
+
16
+ The provider runs in the browser, inside React. On the server you can still call `createI18n()` or `matchLocale()` directly, to translate an email or pick a page's language.
17
+
18
+ The examples translate Acme's order screens at `app.acme.com` into English and German.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ kitr add i18n
24
+ ```
25
+
26
+ <details>
27
+ <summary>Everything kitr add changes</summary>
28
+
29
+ What `kitr add` does:
30
+
31
+ - Adds `@kitr/i18n`, `i18next` and `react-i18next` to `dependencies` and installs them. Pass `--no-install` to skip the install.
32
+ - Adds `() => import('@kitr/i18n/provider')` to `providers` and `'@kitr/i18n'` to `packages` in `kitrrc.ts`. The provider binds `i18n` in the container and wraps your app in react-i18next's `I18nextProvider`.
33
+ - Writes `app/locales/en.ts` with a `common` namespace holding `hello` and `save`, unless the file exists.
34
+ - Writes `config/i18n.ts`, unless it exists. See [Configure](#configure).
35
+ - Adds a `## kitr: i18n` section to `AGENTS.md`: strings live in `app/locales/<lang>.ts`, components call `const { t } = useTranslation()` from `@kitr/i18n`, and keys are typed from the English bundle.
36
+
37
+ The `site` kit includes i18n with English and German bundles, a language switch, and the visitor's choice in a cookie so server-rendered pages arrive in the right language.
38
+
39
+ `kitr remove i18n` takes the provider and the package out of `kitrrc.ts`, deletes `config/i18n.ts`, removes the three dependencies and the `AGENTS.md` section, then lists files that still import `@kitr/i18n`. It leaves `app/locales/` in place.
40
+
41
+ </details>
42
+
43
+ > [!WARNING] Client packages only
44
+ > `kitr add i18n` skips a server-only project with a warning.
45
+
46
+ ## Configure
47
+
48
+ `kitr add i18n` writes this file:
49
+
50
+ ```ts
51
+ // config/i18n.ts
52
+ import { defineConfig } from '@kitr/i18n'
53
+ import en from '#app/locales/en'
54
+
55
+ export default defineConfig({
56
+ resources: { en },
57
+ defaultLocale: 'en',
58
+ defaultNS: 'common',
59
+ })
60
+ ```
61
+
62
+ | Option | Default | Description |
63
+ | --- | --- | --- |
64
+ | `resources` | required | Every locale's strings, keyed by locale then namespace: `{ en: { common: { ... } } }`. |
65
+ | `defaultLocale` | required | The language used when nothing better matches, and the fallback for any key missing in another language. |
66
+ | `locales` | the keys of `resources` | The languages the app supports. A language outside this list is never chosen, even when you call `changeLanguage()` with it. |
67
+ | `detect` | `true` | In the browser, choose the language from the saved choice under `storageKey`, then from the browser's languages. `false` always starts in `defaultLocale`. |
68
+ | `storageKey` | `'locale'` | The `localStorage` key detection reads for a saved choice. |
69
+ | `defaultNS` | `'common'` when a bundle has it, else the first namespace in `resources` | The namespace `t('save')` looks in when the key names none. |
70
+
71
+ Any other [i18next option](https://www.i18next.com/overview/configuration-options) passes straight through, and wins over kitr's defaults:
72
+
73
+ | i18next option | kitr's default | Why |
74
+ | --- | --- | --- |
75
+ | `fallbackLng` | `defaultLocale` | A German page with a missing key shows the English string, not the key. |
76
+ | `supportedLngs` | `locales` | Only languages you ship are chosen. |
77
+ | `interpolation.escapeValue` | `false` | React escapes text already; escaping twice shows `&lt;`. |
78
+ | `react.useSuspense` | `false` | Bundles are in memory, so there is nothing to wait for. |
79
+
80
+ > [!WARNING] interpolation replaces kitr's object
81
+ > Include `escapeValue: false` in it when you add other interpolation options.
82
+
83
+ ## Translate your first string
84
+
85
+ The generated `app/locales/en.ts` holds one namespace, `common`:
86
+
87
+ ```ts
88
+ // app/locales/en.ts
89
+ export default {
90
+ common: {
91
+ hello: 'Hello, {{name}}',
92
+ save: 'Save',
93
+ },
94
+ } as const
95
+ ```
96
+
97
+ In a component, `useTranslation()` from `@kitr/i18n` returns `t`, which looks a key up in the current language:
98
+
99
+ ```tsx
100
+ // app/components/orders/welcome.tsx
101
+ import { useTranslation } from '@kitr/i18n'
102
+
103
+ export function Welcome({ name }: { name: string }) {
104
+ const { t } = useTranslation()
105
+ return (
106
+ <section>
107
+ <h2>{t('hello', { name })}</h2>
108
+ <button type="submit">{t('save')}</button>
109
+ </section>
110
+ )
111
+ }
112
+ ```
113
+
114
+ `<Welcome name="Ada" />` renders "Hello, Ada" and a "Save" button. `as const` keeps the exact strings, which [typed keys](#type-your-keys) need.
115
+
116
+ ## Organize strings into namespaces
117
+
118
+ Each top-level key in a bundle is a namespace. Keep `common` for words used everywhere and give each feature its own:
119
+
120
+ ```ts
121
+ // app/locales/en.ts
122
+ export default {
123
+ common: {
124
+ hello: 'Hello, {{name}}',
125
+ save: 'Save',
126
+ cancel: 'Cancel',
127
+ },
128
+ orders: {
129
+ title: 'Orders',
130
+ empty: 'No orders yet',
131
+ status: { open: 'Open', paid: 'Paid', refunded: 'Refunded' },
132
+ },
133
+ } as const
134
+ ```
135
+
136
+ Pass a namespace to `useTranslation` to make it the default for that component. Reach another namespace with a `namespace:` prefix, and nested keys with dots:
137
+
138
+ ```tsx
139
+ // app/components/orders/orders-header.tsx
140
+ import { useTranslation } from '@kitr/i18n'
141
+
142
+ export function OrdersHeader() {
143
+ const { t } = useTranslation('orders')
144
+ return (
145
+ <header>
146
+ <h1>{t('title')}</h1>
147
+ <span>{t('status.open')}</span>
148
+ <button type="button">{t('common:cancel')}</button>
149
+ </header>
150
+ )
151
+ }
152
+ ```
153
+
154
+ For one branch, `useTranslation('orders', { keyPrefix: 'status' })` lets you write `t('paid')` for `orders.status.paid`.
155
+
156
+ ## Insert values
157
+
158
+ Put a placeholder in double braces and pass the value under the same name:
159
+
160
+ ```tsx
161
+ // app/components/orders/assigned-to.tsx
162
+ import { useTranslation } from '@kitr/i18n'
163
+
164
+ export function AssignedTo({ order }: { order: { id: string; owner: string } }) {
165
+ const { t } = useTranslation('orders')
166
+ // orders.assigned: 'Order {{id}} is assigned to {{owner}}'
167
+ return <p>{t('assigned', { id: order.id, owner: order.owner })}</p>
168
+ // => Order 1001 is assigned to Grace
169
+ }
170
+ ```
171
+
172
+ > [!WARNING] Values are not HTML-escaped
173
+ > Rendered as React text that is safe. Never pass a translated string to `dangerouslySetInnerHTML`; use [Trans](#put-markup-inside-a-string) for markup.
174
+
175
+ ## Count things
176
+
177
+ Give a key one variant per plural form and pass `count`. i18next picks the form for the current language:
178
+
179
+ ```ts
180
+ // app/locales/en.ts
181
+ export default {
182
+ orders: {
183
+ count_zero: 'No orders',
184
+ count_one: '{{count}} order',
185
+ count_other: '{{count}} orders',
186
+ },
187
+ } as const
188
+ ```
189
+
190
+ ```tsx
191
+ // app/components/orders/order-count.tsx
192
+ import { useTranslation } from '@kitr/i18n'
193
+
194
+ export function OrderCount({ count }: { count: number }) {
195
+ const { t } = useTranslation('orders')
196
+ return <p>{t('count', { count })}</p>
197
+ }
198
+ // count 0 => No orders
199
+ // count 1 => 1 order
200
+ // count 3 => 3 orders
201
+ ```
202
+
203
+ Ask for `count`, never `count_one`. `_zero` is optional. Other languages need other forms, such as Polish `_few` and `_many`; see the [i18next plural guide](https://www.i18next.com/translation-function/plurals).
204
+
205
+ ## Format numbers, money and dates
206
+
207
+ Name a formatter after the value inside the braces. It uses `Intl` with the current language, so one key prints `€1,234.50` in English and `1.234,50 €` in German:
208
+
209
+ ```ts
210
+ // app/locales/en.ts
211
+ export default {
212
+ orders: {
213
+ total: 'Total: {{amount, currency(EUR)}}',
214
+ placed: 'Placed on {{date, datetime}}',
215
+ items: '{{count, number}} items',
216
+ },
217
+ } as const
218
+ ```
219
+
220
+ ```tsx
221
+ // app/components/orders/order-summary.tsx
222
+ import { useTranslation } from '@kitr/i18n'
223
+
224
+ export function OrderSummary({ order }: { order: { total: number; placedAt: Date } }) {
225
+ const { t } = useTranslation('orders')
226
+ return (
227
+ <dl>
228
+ <dd>{t('total', { amount: order.total })}</dd>
229
+ <dd>{t('placed', { date: order.placedAt })}</dd>
230
+ </dl>
231
+ )
232
+ }
233
+ // en, total 1234.5 => Total: €1,234.50
234
+ // de, total 1234.5 => Summe: 1.234,50 €
235
+ // de, placedAt 2026-03-01 => Bestellt am 1.3.2026
236
+ ```
237
+
238
+ The formatters are `number`, `currency`, `datetime`, `relativetime` and `list`, each taking `Intl` options in parentheses; see [i18next formatting](https://www.i18next.com/translation-function/formatting). Pass raw numbers and `Date` objects to `t`; don't format them first.
239
+
240
+ ## Put markup inside a string
241
+
242
+ A sentence with a link or bold words can't be split without breaking word order in other languages. Keep it as one string with named tags and render it with `Trans`:
243
+
244
+ ```ts
245
+ // app/locales/en.ts
246
+ export default {
247
+ common: {
248
+ welcome: 'Welcome back, <bold>{{name}}</bold>. <link>See your orders</link>.',
249
+ },
250
+ } as const
251
+ ```
252
+
253
+ ```tsx
254
+ // app/components/orders/welcome-back.tsx
255
+ import { Trans } from '@kitr/i18n'
256
+
257
+ export function WelcomeBack({ name }: { name: string }) {
258
+ return (
259
+ <p>
260
+ <Trans
261
+ i18nKey="common:welcome"
262
+ values={{ name }}
263
+ components={{ bold: <strong />, link: <a href="https://app.acme.com/orders" /> }}
264
+ />
265
+ </p>
266
+ )
267
+ }
268
+ ```
269
+
270
+ The German bundle can move the tags anywhere in its sentence. [Trans in react-i18next's docs](https://react.i18next.com/latest/trans-component) covers the other forms.
271
+
272
+ ## Add a language
273
+
274
+ Copy the English bundle, translate it, and list it in the config. Typing the copy against the English one makes a missing key fail the type check:
275
+
276
+ ```ts
277
+ // app/locales/en.ts
278
+ const en = {
279
+ common: { hello: 'Hello, {{name}}', save: 'Save' },
280
+ orders: { title: 'Orders', empty: 'No orders yet' },
281
+ } as const
282
+
283
+ export default en
284
+
285
+ /** The shape every other locale must match: same keys, any strings. */
286
+ export type Bundle<T> = { [K in keyof T]: T[K] extends string ? string : Bundle<T[K]> }
287
+ ```
288
+
289
+ ```ts
290
+ // app/locales/de.ts
291
+ import type { Bundle } from './en'
292
+ import type en from './en'
293
+
294
+ const de = {
295
+ common: { hello: 'Hallo, {{name}}', save: 'Speichern' },
296
+ orders: { title: 'Bestellungen', empty: 'Noch keine Bestellungen' },
297
+ } as const satisfies Bundle<typeof en>
298
+
299
+ export default de
300
+ ```
301
+
302
+ ```ts
303
+ // config/i18n.ts
304
+ import { defineConfig } from '@kitr/i18n'
305
+ import de from '#app/locales/de'
306
+ import en from '#app/locales/en'
307
+
308
+ export default defineConfig({
309
+ resources: { en, de },
310
+ defaultLocale: 'en',
311
+ defaultNS: 'common',
312
+ })
313
+ ```
314
+
315
+ A key missing from `de.ts` is a type error, and at run time a German page shows the English string. Plural keys are the exception: a language may need forms English lacks, so loosen `Bundle` for those namespaces.
316
+
317
+ ## Type your keys
318
+
319
+ Tell i18next the shape of the English bundle once, and `t()` autocompletes every key and rejects typos at compile time:
320
+
321
+ ```ts
322
+ // app/i18n.d.ts
323
+ import type en from '#app/locales/en'
324
+
325
+ declare module 'i18next' {
326
+ interface CustomTypeOptions {
327
+ defaultNS: 'common'
328
+ resources: typeof en
329
+ }
330
+ }
331
+ ```
332
+
333
+ ```tsx
334
+ // app/components/orders/orders-header.tsx
335
+ import { useTranslation } from '@kitr/i18n'
336
+
337
+ export function OrdersHeader() {
338
+ const { t } = useTranslation('orders')
339
+ return <h1>{t('titel')}</h1>
340
+ // Type error: 'titel' is not a key of the orders namespace
341
+ }
342
+ ```
343
+
344
+ When a component takes a key as a prop, type it with `TranslationKeys`, which flattens a namespace into dotted key paths:
345
+
346
+ ```tsx
347
+ // app/components/orders/status-badge.tsx
348
+ import { type TranslationKeys, useTranslation } from '@kitr/i18n'
349
+ import type en from '#app/locales/en'
350
+
351
+ type OrderKey = TranslationKeys<(typeof en)['orders']>
352
+ // => 'title' | 'empty' | 'status.open' | 'status.paid' | 'status.refunded'
353
+
354
+ export function StatusBadge({ label }: { label: OrderKey }) {
355
+ const { t } = useTranslation('orders')
356
+ return <span className="badge">{t(label)}</span>
357
+ }
358
+ ```
359
+
360
+ > [!WARNING] Pass one namespace
361
+ > `TranslationKeys<typeof en>` over the whole bundle gives `'common.hello'`, not the `common:hello` form `t()` expects.
362
+
363
+ ## Switch the language
364
+
365
+ `i18n.changeLanguage()` switches every mounted component. Save the choice under `storageKey` so detection finds it next visit:
366
+
367
+ ```tsx
368
+ // app/components/language-switch.tsx
369
+ import { useTranslation } from '@kitr/i18n'
370
+
371
+ const languages = { en: 'English', de: 'Deutsch' } as const
372
+
373
+ export function LanguageSwitch() {
374
+ const { i18n } = useTranslation()
375
+ return (
376
+ <select
377
+ value={i18n.resolvedLanguage}
378
+ onChange={(event) => {
379
+ const next = event.target.value
380
+ localStorage.setItem('locale', next)
381
+ void i18n.changeLanguage(next)
382
+ }}
383
+ >
384
+ {Object.entries(languages).map(([code, label]) => (
385
+ <option key={code} value={code}>
386
+ {label}
387
+ </option>
388
+ ))}
389
+ </select>
390
+ )
391
+ }
392
+ ```
393
+
394
+ `changeLanguage` writes neither `localStorage` nor `<html lang>`. Keep `<html lang>` in sync for screen readers by listening once at startup:
395
+
396
+ ```ts
397
+ // app/client.tsx
398
+ import { app } from './app'
399
+
400
+ app.boot().then(() => {
401
+ const i18n = app.container.use('i18n')
402
+ document.documentElement.lang = i18n.language
403
+ i18n.on('languageChanged', (language) => {
404
+ document.documentElement.lang = language
405
+ })
406
+ // ... then hydrate or render as before
407
+ })
408
+ ```
409
+
410
+ A language outside `locales` is never chosen: `changeLanguage('fr')` without a French bundle stays on `en`.
411
+
412
+ ## Detect the visitor's language
413
+
414
+ With `detect: true`, the provider picks the first language in this order:
415
+
416
+ 1. `app.env.locale`, when the app was created with one.
417
+ 2. The saved choice in `localStorage` under `storageKey`, when it is a supported language.
418
+ 3. The browser's `navigator.languages`, matched against your languages.
419
+ 4. `defaultLocale`.
420
+
421
+ Matching tries each browser language in order: an exact match wins, then the base language (`en-GB` matches `en`), and a base matches a regional bundle (`pt` matches `pt-BR`). `detectLocale(supported, storageKey)` runs steps 2 and 3 alone:
422
+
423
+ ```ts
424
+ // app/lib/settings/language.ts
425
+ import { detectLocale } from '@kitr/i18n'
426
+
427
+ detectLocale(['en', 'de'])
428
+ // navigator.languages ['de-AT', 'en'] and nothing saved => 'de'
429
+ // localStorage locale = 'en' => 'en'
430
+ // on the server => undefined
431
+ ```
432
+
433
+ Detection runs once at startup. Use `detect: false` when the server decides the language.
434
+
435
+ ## Choose the language on the server
436
+
437
+ On the server the `Accept-Language` header carries the visitor's preference. `matchLocale()` parses it and returns the best supported language:
438
+
439
+ ```ts
440
+ // server/controllers/locale-controller.ts
441
+ import type { HttpContext } from '@kitr/api'
442
+ import { matchLocale } from '@kitr/i18n'
443
+
444
+ export default class LocaleController {
445
+ async show({ request }: HttpContext) {
446
+ const header = request.header('accept-language') ?? ''
447
+ return { locale: matchLocale(header, ['en', 'de']) ?? 'en' }
448
+ }
449
+ }
450
+ ```
451
+
452
+ With `['en', 'de']` supported:
453
+
454
+ | `Accept-Language` | Result |
455
+ | --- | --- |
456
+ | `de-CH,de;q=0.9,en;q=0.8` | `'de'` |
457
+ | `en-GB` | `'en'` |
458
+ | `fr-FR,fr;q=0.9` | `undefined` |
459
+ | `''` | `undefined` |
460
+
461
+ It reads languages in header order and does not sort by `q`; browsers already send them in preference order. It also takes an array such as `navigator.languages`. A server-rendered app usually stores the choice in a cookie so server and browser agree; the `site` kit does this with [storage](/packages/storage)'s `defineCookie`.
462
+
463
+ ## Translate outside components
464
+
465
+ The provider binds the instance as `i18n` in the container. Read it where there are no hooks, such as a toast after a mutation:
466
+
467
+ ```ts
468
+ // app/lib/orders/notify.ts
469
+ import type { KitrApp } from 'kitr/app'
470
+
471
+ export function orderSavedMessage(app: KitrApp, orderId: string) {
472
+ const i18n = app.container.use('i18n')
473
+ return i18n.t('orders:saved', { id: orderId })
474
+ // en => Order 1001 saved
475
+ }
476
+ ```
477
+
478
+ For an email in the customer's language, create an instance from the same config and take a fixed `t` per language:
479
+
480
+ ```ts
481
+ // server/mail/order-confirmation.ts
482
+ import { createI18n } from '@kitr/i18n'
483
+ import i18nConfig from '#config/i18n'
484
+
485
+ const i18n = createI18n({ ...i18nConfig, detect: false })
486
+
487
+ export function confirmationSubject(locale: string, count: number) {
488
+ const t = i18n.getFixedT(locale, 'orders')
489
+ return t('count', { count })
490
+ }
491
+ // confirmationSubject('de', 2) => 2 Bestellungen
492
+ ```
493
+
494
+ > [!WARNING] Don't change language on a shared instance
495
+ > An instance you create at module level is shared by every request, so `changeLanguage()` would switch the language under requests still rendering. Use `getFixedT`, or `i18n.cloneInstance({ lng })` for a per-request copy.
496
+
497
+ ## Load a language on demand
498
+
499
+ Every bundle in `resources` ships in the main JavaScript bundle. Load rarely used languages when chosen, and list them in `locales` up front:
500
+
501
+ ```ts
502
+ // app/lib/settings/load-language.ts
503
+ import type { KitrApp } from 'kitr/app'
504
+
505
+ export async function useFrench(app: KitrApp) {
506
+ const i18n = app.container.use('i18n')
507
+ const { default: fr } = await import('#app/locales/fr')
508
+ for (const [namespace, strings] of Object.entries(fr)) {
509
+ i18n.addResourceBundle('fr', namespace, strings)
510
+ }
511
+ await i18n.changeLanguage('fr')
512
+ }
513
+ ```
514
+
515
+ ```ts
516
+ // config/i18n.ts
517
+ import { defineConfig } from '@kitr/i18n'
518
+ import de from '#app/locales/de'
519
+ import en from '#app/locales/en'
520
+
521
+ export default defineConfig({
522
+ resources: { en, de },
523
+ locales: ['en', 'de', 'fr'],
524
+ defaultLocale: 'en',
525
+ defaultNS: 'common',
526
+ })
527
+ ```
528
+
529
+ Without `fr` in `locales`, `changeLanguage('fr')` stays on English. To load every language over HTTP, see i18next [backend plugins](https://www.i18next.com/overview/plugins-and-utils#backends).
530
+
531
+ ## Handle missing keys
532
+
533
+ A key missing in German shows the English string. A key that exists nowhere shows the key itself, without its namespace:
534
+
535
+ ```ts
536
+ // app/lib/orders/missing.ts
537
+ import type { KitrApp } from 'kitr/app'
538
+
539
+ export function demo(app: KitrApp) {
540
+ const i18n = app.container.use('i18n')
541
+ i18n.t('orders:refundNote')
542
+ // => 'refundNote' when no bundle has it
543
+ }
544
+ ```
545
+
546
+ Typed keys catch this at compile time. To report missing keys at run time, pass i18next's `saveMissing` and `missingKeyHandler` in `config/i18n.ts`.
547
+
548
+ ## Testing
549
+
550
+ Render translated components with [testing](/packages/testing)'s `createTestApp` and `renderWithKitr`. Use `detect: false` so the runner's browser language doesn't decide the result, and `env: { locale }` to render another language:
551
+
552
+ ```tsx
553
+ // app/components/orders/orders-header.test.tsx
554
+ import { screen } from '@testing-library/react'
555
+ import { useTranslation } from '@kitr/i18n'
556
+ import i18nProvider from '@kitr/i18n/provider'
557
+ import { createTestApp } from '@kitr/testing'
558
+ import { renderWithKitr } from '@kitr/testing/react'
559
+ import { describe, expect, it } from 'vitest'
560
+ import i18nConfig from '#config/i18n'
561
+
562
+ function OrdersHeader({ count, total }: { count: number; total: number }) {
563
+ const { t } = useTranslation('orders')
564
+ return (
565
+ <header>
566
+ <h1>{t('title')}</h1>
567
+ <p>{t('count', { count })}</p>
568
+ <p>{t('total', { amount: total })}</p>
569
+ </header>
570
+ )
571
+ }
572
+
573
+ const boot = (locale?: string) =>
574
+ createTestApp({
575
+ providers: [i18nProvider],
576
+ config: { i18n: { ...i18nConfig, detect: false } },
577
+ env: locale ? { locale } : {},
578
+ })
579
+
580
+ describe('OrdersHeader', () => {
581
+ it('renders in English by default', async () => {
582
+ renderWithKitr(<OrdersHeader count={3} total={1234.5} />, { app: await boot() })
583
+
584
+ expect(screen.getByRole('heading', { name: 'Orders' })).toBeTruthy()
585
+ expect(screen.getByText('3 orders')).toBeTruthy()
586
+ expect(screen.getByText('Total: €1,234.50')).toBeTruthy()
587
+ })
588
+
589
+ it('renders in German', async () => {
590
+ renderWithKitr(<OrdersHeader count={1} total={1234.5} />, { app: await boot('de') })
591
+
592
+ expect(screen.getByRole('heading', { name: 'Bestellungen' })).toBeTruthy()
593
+ expect(screen.getByText('1 Bestellung')).toBeTruthy()
594
+ expect(screen.getByText('Summe: 1.234,50 €')).toBeTruthy()
595
+ })
596
+ })
597
+ ```
598
+
599
+ To test plain functions that call `t`, skip React and use `createI18n(i18nConfig, 'de').t(...)`.
600
+
601
+ ## Next steps
602
+
603
+ - [API reference](/packages/i18n/api): every export of `@kitr/i18n` and `@kitr/i18n/provider`.
604
+ - [Storage](/packages/storage): keep the visitor's language in a cookie the server can read.
605
+ - [i18next docs](https://www.i18next.com): plurals, formatting, context and options.
@@ -0,0 +1,74 @@
1
+ ---
2
+ title: Overview
3
+ description: Translate your app into several languages with typed keys.
4
+ order: 0
5
+ ---
6
+
7
+ # Translations
8
+
9
+ ## What it is
10
+
11
+ `@kitr/i18n` is react-i18next with typed keys. Strings live in one file per language under `app/locales/`, components call `t('key')`, and a misspelt key is a type error. The provider picks the visitor's language from a saved choice or the browser.
12
+
13
+ Runs in the browser. On the server, `matchLocale()` picks a language from `Accept-Language`.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ kitr add i18n
19
+ ```
20
+
21
+ Adds `@kitr/i18n` with i18next and react-i18next, its provider, `config/i18n.ts` and `app/locales/en.ts`. [Every change](/packages/i18n/guide#install).
22
+
23
+ ## Quick start
24
+
25
+ The generated English bundle holds one namespace:
26
+
27
+ ```ts
28
+ // app/locales/en.ts
29
+ export default {
30
+ common: {
31
+ hello: 'Hello, {{name}}',
32
+ save: 'Save',
33
+ },
34
+ } as const
35
+ ```
36
+
37
+ In a component, `useTranslation()` returns `t`, which looks a key up in the current language:
38
+
39
+ ```tsx
40
+ // app/features/orders/welcome.tsx
41
+ import { useTranslation } from '@kitr/i18n'
42
+
43
+ export function Welcome({ name }: { name: string }) {
44
+ const { t } = useTranslation()
45
+ return (
46
+ <section>
47
+ <h2>{t('hello', { name })}</h2>
48
+ <button type="submit">{t('save')}</button>
49
+ </section>
50
+ )
51
+ }
52
+ ```
53
+
54
+ `<Welcome name="Ada" />` renders "Hello, Ada" and a "Save" button. `t('helo')` is a type error. Next in the guide: [Translate your first string](/packages/i18n/guide#translate-your-first-string).
55
+
56
+ ## Built on
57
+
58
+ kitr adds the provider with sensible defaults, `matchLocale()` for the server, `detectLocale()` for the client and the `TranslationKeys` type.
59
+
60
+ ## Problems it solves
61
+
62
+ - **Strings scattered through components.** [Organize strings into namespaces](/packages/i18n/guide#organize-strings-into-namespaces).
63
+ - **Plurals and formatting done by hand.** [Count things](/packages/i18n/guide#count-things).
64
+ - **Misspelt keys that render blank.** [Type your keys](/packages/i18n/guide#type-your-keys).
65
+ - **The wrong language on first render.** [Choose the language on the server](/packages/i18n/guide#choose-the-language-on-the-server).
66
+ - **Every language shipped to every visitor.** [Load a language on demand](/packages/i18n/guide#load-a-language-on-demand).
67
+
68
+ ## Further reading
69
+
70
+ - [Guide](/packages/i18n/guide).
71
+ - [API reference](/packages/i18n/api).
72
+ - [react-i18next docs](https://react.i18next.com).
73
+ - [i18next docs](https://www.i18next.com).
74
+ - [Storage](/packages/storage).
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "@kitr/i18n",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.1",
4
+ "description": "@kitr/i18n: react-i18next re-exported, plus typed keys and locale helpers.",
5
+ "kitr": {
6
+ "name": "i18n",
7
+ "title": "Translations",
8
+ "summary": "Translate your app into several languages with typed keys.",
9
+ "category": "interface",
10
+ "runsOn": "client",
11
+ "builtOn": [
12
+ "react-i18next"
13
+ ],
14
+ "client": true,
15
+ "docs": "docs",
16
+ "setup": "./kitr"
17
+ },
18
+ "type": "module",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/kitrjs/kitr.git",
23
+ "directory": "packages/i18n"
24
+ },
25
+ "homepage": "https://kitr.dev/packages/i18n",
26
+ "bugs": "https://github.com/kitrjs/kitr/issues",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "sideEffects": false,
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./provider": {
37
+ "types": "./dist/provider.d.ts",
38
+ "default": "./dist/provider.js"
39
+ },
40
+ "./kitr": {
41
+ "types": "./dist/kitr.d.ts",
42
+ "default": "./dist/kitr.js"
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "files": [
47
+ "dist",
48
+ "docs"
49
+ ],
50
+ "dependencies": {
51
+ "@kitr/core": "0.2.1"
52
+ },
53
+ "peerDependencies": {
54
+ "react": ">=18.0.0",
55
+ "i18next": ">=23.0.0",
56
+ "react-i18next": ">=15.0.0"
57
+ },
58
+ "devDependencies": {
59
+ "tsdown": "^0.23.7",
60
+ "typescript": "^5.9.3",
61
+ "react": "^19.3.1",
62
+ "@types/react": "^19.3.0",
63
+ "i18next": "^27.0.0",
64
+ "react-i18next": "^18.0.0",
65
+ "@kitr/react": "0.2.1"
66
+ },
67
+ "keywords": [
68
+ "kitr",
69
+ "kitr-package"
70
+ ],
71
+ "scripts": {
72
+ "build": "tsdown",
73
+ "typecheck": "tsc -p tsconfig.json"
74
+ }
6
75
  }