@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 +21 -0
- package/README.md +14 -2
- package/dist/index.d.ts +43 -0
- package/dist/index.js +56 -0
- package/dist/kitr.d.ts +17 -0
- package/dist/kitr.js +23 -0
- package/dist/provider.d.ts +4 -0
- package/dist/provider.js +19 -0
- package/docs/api.md +89 -0
- package/docs/index.md +605 -0
- package/docs/overview.md +74 -0
- package/package.json +72 -3
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
|
-
#
|
|
1
|
+
# @kitr/i18n
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|
package/dist/provider.js
ADDED
|
@@ -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 `<`. |
|
|
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.
|
package/docs/overview.md
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|