kotori 6.0.0 → 6.1.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/README.md +61 -14
- package/dist/index.cjs +254 -3
- package/dist/index.d.cts +218 -4
- package/dist/index.d.mts +218 -4
- package/dist/index.mjs +254 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
13
|
<p align="center">
|
|
14
|
-
🕊️ Kotori is a zero-config, fully type-safe, and modular internationalization library for React that compiles down to just 0.
|
|
14
|
+
🕊️ Kotori is a zero-config, fully type-safe, and modular internationalization library for React that compiles down to just 0.29kB. No JSON, no external CLI tools, no codegen—just live type inference from your strings.
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
17
|
```ts
|
|
@@ -55,13 +55,13 @@ const Component = () => {
|
|
|
55
55
|
- No JSON
|
|
56
56
|
- No dependencies
|
|
57
57
|
- No build step
|
|
58
|
-
- 0.
|
|
58
|
+
- 0.29kB minified and gzipped
|
|
59
59
|
- Modular and tree-shakeable
|
|
60
60
|
- Language change in one page rerenders all pages
|
|
61
61
|
- Variables typed and inferred from string literals — no more string typos
|
|
62
|
-
-
|
|
62
|
+
- Maximum type safety with minimum types
|
|
63
63
|
|
|
64
|
-
Demo: <https://stackblitz.com/edit/
|
|
64
|
+
Demo: <https://stackblitz.com/edit/kotori?file=src%2FApp.tsx>
|
|
65
65
|
|
|
66
66
|
## Installation
|
|
67
67
|
|
|
@@ -163,7 +163,7 @@ export const Page2 = () => {
|
|
|
163
163
|
|
|
164
164
|

|
|
165
165
|
|
|
166
|
-
### `kotori(options)`
|
|
166
|
+
### `kotori(options)` (0.29kB)
|
|
167
167
|
|
|
168
168
|
Creates a scoped i18n instance.
|
|
169
169
|
|
|
@@ -178,14 +178,14 @@ export const { useT, d, setLanguage } = kotori({
|
|
|
178
178
|
|
|
179
179
|
| option | type | description |
|
|
180
180
|
| --- | --- | --- |
|
|
181
|
-
| `primary` | `
|
|
182
|
-
| `secondaries` | `
|
|
181
|
+
| `primary` | `BCP47LanguageTagWithSubtag` | The source language. Drives variable inference. |
|
|
182
|
+
| `secondaries` | `Exclude<BCP47LanguageTagWithSubtag, primary>[]` | Additional supported languages. Cannot include the primary language. |
|
|
183
183
|
|
|
184
184
|
Returns `{ d, useT, setLanguage, r }`.
|
|
185
185
|
|
|
186
186
|
### `d(translations)<argsType?>`
|
|
187
187
|
|
|
188
|
-
Defines a translation unit. Takes one string per language.
|
|
188
|
+
Defines a translation unit. Takes one string per language. Returns a `dictionary` object.
|
|
189
189
|
|
|
190
190
|
```ts
|
|
191
191
|
const time = d({ en: '{{hour}}:{{minute}}' })
|
|
@@ -215,7 +215,7 @@ Returns the translated string for the current language. `args` is required if th
|
|
|
215
215
|
⚠️ Do not call this inside React components (it will break React Compiler optimization rules). Use this exclusively in raw JS/TS environments like router guards, API interceptors, or state utilities.
|
|
216
216
|
|
|
217
217
|
```tsx
|
|
218
|
-
r(intro, { name: 'John', age: 30 })
|
|
218
|
+
r(intro, { name: 'John', age: 30 })
|
|
219
219
|
```
|
|
220
220
|
|
|
221
221
|
### `useT()`
|
|
@@ -233,22 +233,69 @@ Returns the translated string for the current language. `args` is required if th
|
|
|
233
233
|
React version of `r(dictionary, args?)`, works with React Compiler.
|
|
234
234
|
|
|
235
235
|
```tsx
|
|
236
|
-
|
|
236
|
+
const Intro = () => {
|
|
237
|
+
const { t } = useT()
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<p>{t(intro, { name: 'John', age: 30 })}</p>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### `detectLanguage(instance, options?)` (0.12kB with Kotori or 0.2kB standalone)
|
|
246
|
+
|
|
247
|
+
Detects the user's preferred language from browser settings and sets it on the kotori instance. Iterates through the user's full language preference list in order, stopping at the first match.
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import { detectLanguage } from 'kotori'
|
|
251
|
+
import { i18n } from './locales'
|
|
252
|
+
|
|
253
|
+
detectLanguage(i18n)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
| option | type | default | description |
|
|
257
|
+
| --- | --- | --- | --- |
|
|
258
|
+
| `fallbackToSubtag` | `boolean` | `true` | If no exact match is found (e.g. `'zh-CN'`), fall back to the subtag (e.g. `'zh'`). |
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// browser reports: ['zh-CN', 'en-US']
|
|
262
|
+
// declared languages: ['en', 'zh', 'ja', 'ms']
|
|
263
|
+
|
|
264
|
+
detectLanguage(i18n)
|
|
265
|
+
// 'zh-CN' → no exact match → subtag 'zh' → match → setLanguage('zh') ✅
|
|
266
|
+
|
|
267
|
+
detectLanguage(i18n, { fallbackToSubtag: false })
|
|
268
|
+
// 'zh-CN' → no exact match → 'en-US' → no exact match → no-op, stays 'en'
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`detectLanguage` also works with non-kotori instances. Any object with a `setLanguage` callback and a `config` object satisfies the interface:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
detectLanguage({
|
|
275
|
+
setLanguage: (lang: 'en' | 'zh') => mySetLang(lang),
|
|
276
|
+
config: { primary: 'en', secondaries: ['zh'] },
|
|
277
|
+
})
|
|
237
278
|
```
|
|
238
279
|
|
|
239
280
|
## Language Tags
|
|
240
281
|
|
|
241
|
-
kotori uses [BCP 47](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry) language tags.
|
|
282
|
+
kotori uses [BCP 47](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry) language tags. The type `BCP47LanguageTagWithSubtag` accepts both subtags (`en`, `zh`) and full tags (`en-US`, `zh-CN`), and is validated at the type level.
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
kotori({ primary: 'en', secondaries: ['zh', 'ms-MY'] }) // ✅
|
|
286
|
+
kotori({ primary: 'klingon', secondaries: ['zh'] }) // ❌ compile error
|
|
287
|
+
```
|
|
242
288
|
|
|
243
289
|
## Tips
|
|
244
290
|
|
|
245
291
|
- If you plan to add new languages frequently, consider colocating all your dicts in a single file or multiple files in one folder. It is easier to copy the entire file and hand it to an AI to translate.
|
|
246
|
-
- If your supported languages are fixed, consider splitting dicts by page or component. Translations stay close to the code that uses them and are easier to maintain.
|
|
292
|
+
- If your supported languages are fixed, consider splitting dicts by page or component. Translations stay close to the code that uses them and are easier to maintain. This approach also pairs well with TypeScript — every time you add a new language, type errors will guide you to every dict that needs updating.
|
|
247
293
|
- Both approaches are tree-shakeable — only the dicts imported by the current page are included in its bundle.
|
|
294
|
+
- The `primary` language is the source of truth for variable inference and validation. Write your primary language strings carefully — a variable rename in the primary string becomes a compile error across every secondary language, which is intentional.
|
|
248
295
|
|
|
249
296
|
## Roadmap
|
|
250
297
|
|
|
251
|
-
- Auto detect locale from browser settings
|
|
298
|
+
- ✅ Auto detect locale from browser settings
|
|
252
299
|
- Auto persist language selection to localStorage
|
|
253
300
|
- Pluralization support
|
|
254
301
|
- Gender support
|
|
@@ -258,4 +305,4 @@ kotori uses [BCP 47](https://www.iana.org/assignments/language-subtag-registry/l
|
|
|
258
305
|
|
|
259
306
|
There are already many i18n libraries, and the good names are mostly taken. The original plan was *kotoba* (言葉), the Japanese word for "words" — also taken. Claude suggested *kotori* as an alternative, and it stuck.
|
|
260
307
|
|
|
261
|
-
*Kotori* (小鳥) means "small bird" in Japanese. No deeper relevance to the library — it just sounds nice.
|
|
308
|
+
*Kotori* (小鳥) means "small bird" in Japanese. No deeper relevance to the library — it just sounds nice.
|
package/dist/index.cjs
CHANGED
|
@@ -1,13 +1,145 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
let react = require("react");
|
|
3
|
-
//#region src/
|
|
4
|
-
|
|
3
|
+
//#region src/detectLanguage.ts
|
|
4
|
+
/**
|
|
5
|
+
* Detects the user's preferred language from browser settings and sets it
|
|
6
|
+
* on the kotori instance. Iterates through the user's full language preference
|
|
7
|
+
* list in order, stopping at the first match.
|
|
8
|
+
*
|
|
9
|
+
* @param instance - An object with `setLanguage` and `config`. Compatible with
|
|
10
|
+
* the kotori instance returned by `kotori()`, or any custom object that satisfies
|
|
11
|
+
* the same shape — allowing use with non-kotori setups.
|
|
12
|
+
* @param instance.setLanguage - `(language: BCP47LanguageTagNameWithSubTag) => void` — Called with the
|
|
13
|
+
* first matched language. Can be any callback — a kotori `setLanguage`, a React
|
|
14
|
+
* state setter, a Zustand action, etc.
|
|
15
|
+
* @param instance.config - Language configuration.
|
|
16
|
+
* @param instance.config.primary - `BCP47LanguageTagNameWithSubTag` — The primary/fallback language tag.
|
|
17
|
+
* @param instance.config.secondaries - `Exclude<AllTags, primary>[]` — Additional supported language tags. Cannot include the primary language.
|
|
18
|
+
* @param options - Detection options.
|
|
19
|
+
* @param options.fallbackToSubtag - `boolean` — If no exact match is found (e.g. `'zh-CN'`),
|
|
20
|
+
* fall back to the subtag (e.g. `'zh'`). Defaults to `true`.
|
|
21
|
+
* @returns `void` — Calls `setLanguage` on the first match. No-op if no match is found.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* // with kotori instance
|
|
26
|
+
* import { kotori, detectLanguage } from 'kotori'
|
|
27
|
+
*
|
|
28
|
+
* const i18n = kotori({
|
|
29
|
+
* primary: 'en',
|
|
30
|
+
* secondaries: ['zh', 'ja', 'ms'],
|
|
31
|
+
* })
|
|
32
|
+
*
|
|
33
|
+
* detectLanguage(i18n)
|
|
34
|
+
* // browser: ['zh-CN', 'en-US'] → subtag 'zh' → setLanguage('zh') ✅
|
|
35
|
+
*
|
|
36
|
+
* detectLanguage(i18n, { fallbackToSubtag: false })
|
|
37
|
+
* // browser: ['zh-CN'] → no exact match → no-op, stays 'en'
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* // ✅ setLanguage type exactly matches primary + secondaries union
|
|
43
|
+
* detectLanguage({
|
|
44
|
+
* setLanguage: (lang: 'en' | 'zh' | 'ja') => setLang(lang),
|
|
45
|
+
* config: {
|
|
46
|
+
* primary: 'en',
|
|
47
|
+
* secondaries: ['zh', 'ja'],
|
|
48
|
+
* },
|
|
49
|
+
* })
|
|
50
|
+
*
|
|
51
|
+
* // ❌ compile error — setLanguage accepts wider type than declared
|
|
52
|
+
* detectLanguage({
|
|
53
|
+
* setLanguage: (lang: string) => setLang(lang),
|
|
54
|
+
* config: { primary: 'en', secondaries: ['zh'] },
|
|
55
|
+
* })
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
const detectLanguage = (instance, options = { fallbackToSubtag: true }) => {
|
|
59
|
+
const languages = [instance.config.primary, ...instance.config.secondaries];
|
|
60
|
+
for (const browserLang of navigator.languages) {
|
|
61
|
+
if (languages.includes(browserLang)) return instance.setLanguage(browserLang);
|
|
62
|
+
if (options.fallbackToSubtag) {
|
|
63
|
+
const subtag = browserLang.split("-")[0];
|
|
64
|
+
if (languages.includes(subtag)) return instance.setLanguage(subtag);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/kotori.ts
|
|
70
|
+
/**
|
|
71
|
+
* Creates a scoped i18n instance for your app.
|
|
72
|
+
* Call once and export the returned utilities.
|
|
73
|
+
*
|
|
74
|
+
* @param config - Configuration options.
|
|
75
|
+
* @param config.primary - `BCP47LanguageTag` — The source language. Variable inference and
|
|
76
|
+
* secondary string validation are both driven by this language's strings.
|
|
77
|
+
* @param config.secondaries - `Exclude<BCP47LanguageTag, primary>[]` — Additional supported languages.
|
|
78
|
+
* Cannot include the primary language.
|
|
79
|
+
* @returns `{ d, r, useT, setLanguage, config }`
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* // locales.ts
|
|
84
|
+
* import { kotori } from 'kotori'
|
|
85
|
+
*
|
|
86
|
+
* export const { d, r, useT, setLanguage } = kotori({
|
|
87
|
+
* primary: 'en',
|
|
88
|
+
* secondaries: ['zh', 'ja', 'ms'],
|
|
89
|
+
* })
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
const kotori = (config) => {
|
|
5
93
|
const listeners = /* @__PURE__ */ new Set();
|
|
94
|
+
/**
|
|
95
|
+
* Translates a dict to the current language and interpolates variables.
|
|
96
|
+
*
|
|
97
|
+
* @param dictionary - A dict created by `d()`.
|
|
98
|
+
* @param args - `Record<string, string | number>` — Variable values to interpolate. Required if the dict has variables, omitted if it doesn't.
|
|
99
|
+
* @returns `string` — The translated and interpolated string.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* const Intro = () => {
|
|
103
|
+
* const { t } = useT()
|
|
104
|
+
*
|
|
105
|
+
* return (
|
|
106
|
+
* <p>{t(greeting)}</p> // ✅ no args needed
|
|
107
|
+
* <p>{t(intro, { name: 'John', age: 30 })}</p> // ✅ typed args
|
|
108
|
+
* <p>{t(intro)}</p> // ❌ compile error: missing args
|
|
109
|
+
* <p>{t(intro, { name: 'John', x: 30 })}</p> // ❌ compile error: unknown key 'x'
|
|
110
|
+
* )
|
|
111
|
+
* }
|
|
112
|
+
*/
|
|
6
113
|
const t = (dictionary, ...args) => (dictionary().d[snapshot.language] || "").replace(/\{\{\s*([\w-]+)\s*\}\}/g, (_, key) => String(args[0]?.[key]));
|
|
7
114
|
let snapshot = {
|
|
8
|
-
language:
|
|
115
|
+
language: config.primary,
|
|
9
116
|
t
|
|
10
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* Updates the current language and triggers a rerender in all active
|
|
120
|
+
* `useT()` consumers across all pages. Safe to call outside React.
|
|
121
|
+
*
|
|
122
|
+
* @param language - `Language` — Must be one of the tags declared in
|
|
123
|
+
* `primary` or `secondaries`. Any other value is a compile error.
|
|
124
|
+
* @returns `void`
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* import { setLanguage } from './locales'
|
|
129
|
+
*
|
|
130
|
+
* setLanguage('zh') // ✅
|
|
131
|
+
* setLanguage('ms') // ✅
|
|
132
|
+
* setLanguage('de') // ❌ compile error: 'de' was not declared
|
|
133
|
+
*
|
|
134
|
+
* // persist and restore language selection
|
|
135
|
+
* setLanguage('zh')
|
|
136
|
+
* localStorage.setItem('lang', 'zh')
|
|
137
|
+
*
|
|
138
|
+
* // restore on app startup
|
|
139
|
+
* const saved = localStorage.getItem('lang')
|
|
140
|
+
* if (saved) setLanguage(saved as 'en')
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
11
143
|
const setLanguage = (language) => {
|
|
12
144
|
snapshot = {
|
|
13
145
|
language,
|
|
@@ -24,11 +156,130 @@ const kotori = (props) => {
|
|
|
24
156
|
};
|
|
25
157
|
};
|
|
26
158
|
return {
|
|
159
|
+
config,
|
|
27
160
|
setLanguage,
|
|
161
|
+
/**
|
|
162
|
+
* Translates a dict to the current language and interpolates variables.
|
|
163
|
+
* For use **outside** React components — route guards, axios interceptors,
|
|
164
|
+
* utility functions, etc.
|
|
165
|
+
*
|
|
166
|
+
* ⚠️ Do not use inside React components. The React Compiler will memoize
|
|
167
|
+
* the result permanently since `r` is a stable module-level reference,
|
|
168
|
+
* causing stale translations after a language change. Use `t` from `useT()`
|
|
169
|
+
* inside components instead.
|
|
170
|
+
*
|
|
171
|
+
* @param dictionary - A dict created by `d()`.
|
|
172
|
+
* @param args - `Record<string, string | number>` — Variable values to interpolate.
|
|
173
|
+
* Required if the dict has variables, omitted if it doesn't.
|
|
174
|
+
* @returns `string` — The translated and interpolated string in the current language.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* import { r, greeting, intro } from './locales'
|
|
179
|
+
*
|
|
180
|
+
* r(greeting) // ✅ no args needed
|
|
181
|
+
* r(intro, { name: 'John', age: 30 }) // ✅ typed args
|
|
182
|
+
* r(intro) // ❌ compile error: missing args
|
|
183
|
+
* r(intro, { name: 'John', x: 1 }) // ❌ compile error: unknown key 'x'
|
|
184
|
+
*
|
|
185
|
+
* // route guard
|
|
186
|
+
* router.beforeEach(() => {
|
|
187
|
+
* document.title = r(pageTitle)
|
|
188
|
+
* })
|
|
189
|
+
*
|
|
190
|
+
* // axios interceptor
|
|
191
|
+
* axios.interceptors.response.use(null, () => {
|
|
192
|
+
* toast.error(r(errorMessage))
|
|
193
|
+
* })
|
|
194
|
+
* ```
|
|
195
|
+
*/
|
|
28
196
|
r: t,
|
|
197
|
+
/**
|
|
198
|
+
* Defines a translation unit. The primary language string drives variable
|
|
199
|
+
* inference — all secondary strings are validated against it at compile time.
|
|
200
|
+
*
|
|
201
|
+
* Call with an optional generic to narrow variable types beyond the default
|
|
202
|
+
* `string | number`. Supports TypeScript template literal types.
|
|
203
|
+
*
|
|
204
|
+
* @param dictionary - `Record<Language, string>` — One string per language.
|
|
205
|
+
* Variables are declared with `{{variableName}}` syntax. Spaces inside braces
|
|
206
|
+
* are trimmed — `{{ name }}` and `{{name}}` are equivalent.
|
|
207
|
+
* Secondary strings must use exactly the same variable names as the primary.
|
|
208
|
+
* @returns A function optionally accepting a generic to narrow variable types,
|
|
209
|
+
* which returns the translation unit.
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```ts
|
|
213
|
+
* // no variables
|
|
214
|
+
* const greeting = d({ en: 'Hello', zh: '你好', ja: 'こんにちは', ms: 'Helo' })()
|
|
215
|
+
*
|
|
216
|
+
* // variables — inferred as Record<'name' | 'age', string | number> by default
|
|
217
|
+
* const intro = d({
|
|
218
|
+
* en: 'My name is {{name}}, I am {{age}} years old.',
|
|
219
|
+
* zh: '我叫{{name}},我今年{{age}}岁了。',
|
|
220
|
+
* ja: '私の名前は{{name}}で、{{age}}歳です。',
|
|
221
|
+
* ms: 'Nama saya {{name}}, saya berumur {{age}} tahun.',
|
|
222
|
+
* })()
|
|
223
|
+
*
|
|
224
|
+
* // narrowed variable types via generic
|
|
225
|
+
* const clock = d({
|
|
226
|
+
* en: 'Current time: {{hour}}:{{minute}}',
|
|
227
|
+
* zh: '现在时间:{{hour}}:{{minute}}',
|
|
228
|
+
* ja: '現在時刻:{{hour}}:{{minute}}',
|
|
229
|
+
* ms: 'Masa semasa: {{hour}}:{{minute}}',
|
|
230
|
+
* })<{ hour: number; minute: number }>()
|
|
231
|
+
*
|
|
232
|
+
* // TypeScript template literal types work too
|
|
233
|
+
* const lastLogin = d({
|
|
234
|
+
* en: 'Last login: {{date}}',
|
|
235
|
+
* zh: '上次登录:{{date}}',
|
|
236
|
+
* ja: '最終ログイン:{{date}}',
|
|
237
|
+
* ms: 'Log masuk terakhir: {{date}}',
|
|
238
|
+
* })<{ date: `${number}-${number}-${number}` }>()
|
|
239
|
+
*
|
|
240
|
+
* // ❌ compile error — 'ja' translation missing
|
|
241
|
+
* const bad1 = d({ en: 'Hello', zh: '你好', ms: 'Helo' })()
|
|
242
|
+
*
|
|
243
|
+
* // ❌ compile error — secondary string has wrong variable name
|
|
244
|
+
* const bad2 = d({ en: 'Hello {{name}}', zh: '你好 {{naam}}', ja: 'こんにちは {{name}}', ms: 'Helo {{name}}' })()
|
|
245
|
+
*
|
|
246
|
+
* // ❌ compile error — secondary string missing a variable
|
|
247
|
+
* const bad3 = d({ en: '{{x}} {{y}}', zh: '{{x}}', ja: '{{x}} {{y}}', ms: '{{x}} {{y}}' })()
|
|
248
|
+
* ```
|
|
249
|
+
*/
|
|
29
250
|
d: (dictionary) => () => ({ d: dictionary }),
|
|
251
|
+
/**
|
|
252
|
+
* React hook. Subscribes the component to language changes.
|
|
253
|
+
* Returns a snapshot containing the current `language` and a
|
|
254
|
+
* React Compiler-safe `t` function.
|
|
255
|
+
*
|
|
256
|
+
* Call in every component that renders translated strings.
|
|
257
|
+
* When `setLanguage` is called anywhere, all `useT()` consumers rerender.
|
|
258
|
+
*
|
|
259
|
+
* @returns `{ language: Language, t: TranslateFunction }`
|
|
260
|
+
* @returns `language` - `Language` — The current active language tag as a reactive value.
|
|
261
|
+
* @returns `t` - `TranslateFunction` — React Compiler-safe translate function. Use this inside components instead of the instance-level `t`.
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* ```tsx
|
|
265
|
+
* import { useT, intro, time } from './locales'
|
|
266
|
+
*
|
|
267
|
+
* const Page = () => {
|
|
268
|
+
* const { t, language } = useT()
|
|
269
|
+
*
|
|
270
|
+
* return (
|
|
271
|
+
* <>
|
|
272
|
+
* <p>{t(intro, { name: 'John', age: 30 })}</p>
|
|
273
|
+
* <p>{t(time, { hour: 12, minute: 0 })}</p>
|
|
274
|
+
* <p>Current language: {language}</p>
|
|
275
|
+
* </>
|
|
276
|
+
* )
|
|
277
|
+
* }
|
|
278
|
+
* ```
|
|
279
|
+
*/
|
|
30
280
|
useT: () => (0, react.useSyncExternalStore)(subscribe, () => snapshot, () => snapshot)
|
|
31
281
|
};
|
|
32
282
|
};
|
|
33
283
|
//#endregion
|
|
284
|
+
exports.detectLanguage = detectLanguage;
|
|
34
285
|
exports.kotori = kotori;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,26 +1,169 @@
|
|
|
1
1
|
//#region node_modules/bcp47-language-tags/dist/zh.d.ts
|
|
2
2
|
type BCP47LanguageTagName = "zh-CN" | "zh-TW" | "zh-HK" | "zh-MO" | "zh-SG" | "zh-CHS" | "zh-CHT" | "en-US" | "en-GB" | "en-CA" | "en-AU" | "en-IN" | "en-ZA" | "en-NZ" | "en-IE" | "en-PH" | "en-ZW" | "en-BZ" | "en-CB" | "en-JM" | "en-TT" | "hi-IN" | "es-ES" | "es-MX" | "es-AR" | "es-CO" | "es-PE" | "es-VE" | "es-CL" | "es-EC" | "es-GT" | "es-CU" | "es-BO" | "es-DO" | "es-HN" | "es-PY" | "es-SV" | "es-NI" | "es-PR" | "es-UY" | "es-PA" | "es-CR" | "ar-EG" | "ar-SA" | "ar-DZ" | "ar-MA" | "ar-IQ" | "ar-SD" | "ar-YE" | "ar-SY" | "ar-TN" | "ar-LY" | "ar-JO" | "ar-LB" | "ar-KW" | "ar-AE" | "ar-BH" | "ar-QA" | "ar-OM" | "pt-BR" | "pt-PT" | "ru-RU" | "ja-JP" | "de-DE" | "de-AT" | "de-CH" | "fr-FR" | "fr-CA" | "fr-BE" | "fr-CH" | "fr-LU" | "fr-MC" | "ko-KR" | "it-IT" | "it-CH" | "tr-TR" | "th-TH" | "el-GR" | "cs-CZ" | "sv-SE" | "sv-FI" | "hu-HU" | "fi-FI" | "da-DK" | "nb-NO" | "nn-NO" | "he-IL" | "id-ID" | "ms-MY" | "ms-BN" | "ro-RO" | "bg-BG" | "uk-UA" | "sk-SK" | "sl-SI" | "hr-HR" | "ca-ES" | "lt-LT" | "lv-LV" | "et-EE" | "sq-AL" | "mk-MK" | "be-BY" | "is-IS" | "gl-ES" | "eu-ES" | "af-ZA" | "sw-KE" | "ta-IN" | "te-IN" | "kn-IN" | "mr-IN" | "gu-IN" | "pa-IN" | "kok-IN" | "sa-IN" | "ur-PK" | "fa-IR" | "syr-SY" | "div-MV" | "ka-GE";
|
|
3
3
|
//#endregion
|
|
4
|
-
//#region src/
|
|
4
|
+
//#region src/kotori.d.ts
|
|
5
5
|
type Tags = BCP47LanguageTagName;
|
|
6
6
|
type SubTags = BCP47LanguageTagName extends `${infer SubTag}-${string}` ? SubTag : never;
|
|
7
|
-
type
|
|
7
|
+
type BCP47LanguageTagNameWithSubTag = Tags | SubTags;
|
|
8
8
|
type Trim<T extends string> = T extends ` ${infer R}` ? Trim<R> : T extends `${infer L} ` ? Trim<L> : T;
|
|
9
9
|
type ExtractVariables<T extends string> = T extends `${string}{{${infer P}}}${infer Q}` ? Trim<P> | ExtractVariables<Q> : never;
|
|
10
10
|
declare const _args: unique symbol;
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Creates a scoped i18n instance for your app.
|
|
13
|
+
* Call once and export the returned utilities.
|
|
14
|
+
*
|
|
15
|
+
* @param config - Configuration options.
|
|
16
|
+
* @param config.primary - `BCP47LanguageTag` — The source language. Variable inference and
|
|
17
|
+
* secondary string validation are both driven by this language's strings.
|
|
18
|
+
* @param config.secondaries - `Exclude<BCP47LanguageTag, primary>[]` — Additional supported languages.
|
|
19
|
+
* Cannot include the primary language.
|
|
20
|
+
* @returns `{ d, r, useT, setLanguage, config }`
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* // locales.ts
|
|
25
|
+
* import { kotori } from 'kotori'
|
|
26
|
+
*
|
|
27
|
+
* export const { d, r, useT, setLanguage } = kotori({
|
|
28
|
+
* primary: 'en',
|
|
29
|
+
* secondaries: ['zh', 'ja', 'ms'],
|
|
30
|
+
* })
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
declare const kotori: <const Primary extends BCP47LanguageTagNameWithSubTag, const Secondary extends Exclude<BCP47LanguageTagNameWithSubTag, Primary>>(config: {
|
|
12
34
|
primary: Primary;
|
|
13
35
|
secondaries: Secondary[];
|
|
14
36
|
}) => {
|
|
37
|
+
config: {
|
|
38
|
+
primary: Primary;
|
|
39
|
+
secondaries: Secondary[];
|
|
40
|
+
};
|
|
15
41
|
setLanguage: (language: Primary | Secondary) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Translates a dict to the current language and interpolates variables.
|
|
44
|
+
* For use **outside** React components — route guards, axios interceptors,
|
|
45
|
+
* utility functions, etc.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ Do not use inside React components. The React Compiler will memoize
|
|
48
|
+
* the result permanently since `r` is a stable module-level reference,
|
|
49
|
+
* causing stale translations after a language change. Use `t` from `useT()`
|
|
50
|
+
* inside components instead.
|
|
51
|
+
*
|
|
52
|
+
* @param dictionary - A dict created by `d()`.
|
|
53
|
+
* @param args - `Record<string, string | number>` — Variable values to interpolate.
|
|
54
|
+
* Required if the dict has variables, omitted if it doesn't.
|
|
55
|
+
* @returns `string` — The translated and interpolated string in the current language.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { r, greeting, intro } from './locales'
|
|
60
|
+
*
|
|
61
|
+
* r(greeting) // ✅ no args needed
|
|
62
|
+
* r(intro, { name: 'John', age: 30 }) // ✅ typed args
|
|
63
|
+
* r(intro) // ❌ compile error: missing args
|
|
64
|
+
* r(intro, { name: 'John', x: 1 }) // ❌ compile error: unknown key 'x'
|
|
65
|
+
*
|
|
66
|
+
* // route guard
|
|
67
|
+
* router.beforeEach(() => {
|
|
68
|
+
* document.title = r(pageTitle)
|
|
69
|
+
* })
|
|
70
|
+
*
|
|
71
|
+
* // axios interceptor
|
|
72
|
+
* axios.interceptors.response.use(null, () => {
|
|
73
|
+
* toast.error(r(errorMessage))
|
|
74
|
+
* })
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
16
77
|
r: <Dictionary extends () => Readonly<{
|
|
17
78
|
d: Record<Primary | Secondary, string>;
|
|
18
79
|
[_args]?: Record<string, string | number>;
|
|
19
80
|
}>>(dictionary: Dictionary, ...args: keyof NonNullable<ReturnType<Dictionary>[typeof _args]> extends never ? [] : [NonNullable<ReturnType<Dictionary>[typeof _args]>]) => string;
|
|
81
|
+
/**
|
|
82
|
+
* Defines a translation unit. The primary language string drives variable
|
|
83
|
+
* inference — all secondary strings are validated against it at compile time.
|
|
84
|
+
*
|
|
85
|
+
* Call with an optional generic to narrow variable types beyond the default
|
|
86
|
+
* `string | number`. Supports TypeScript template literal types.
|
|
87
|
+
*
|
|
88
|
+
* @param dictionary - `Record<Language, string>` — One string per language.
|
|
89
|
+
* Variables are declared with `{{variableName}}` syntax. Spaces inside braces
|
|
90
|
+
* are trimmed — `{{ name }}` and `{{name}}` are equivalent.
|
|
91
|
+
* Secondary strings must use exactly the same variable names as the primary.
|
|
92
|
+
* @returns A function optionally accepting a generic to narrow variable types,
|
|
93
|
+
* which returns the translation unit.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* // no variables
|
|
98
|
+
* const greeting = d({ en: 'Hello', zh: '你好', ja: 'こんにちは', ms: 'Helo' })()
|
|
99
|
+
*
|
|
100
|
+
* // variables — inferred as Record<'name' | 'age', string | number> by default
|
|
101
|
+
* const intro = d({
|
|
102
|
+
* en: 'My name is {{name}}, I am {{age}} years old.',
|
|
103
|
+
* zh: '我叫{{name}},我今年{{age}}岁了。',
|
|
104
|
+
* ja: '私の名前は{{name}}で、{{age}}歳です。',
|
|
105
|
+
* ms: 'Nama saya {{name}}, saya berumur {{age}} tahun.',
|
|
106
|
+
* })()
|
|
107
|
+
*
|
|
108
|
+
* // narrowed variable types via generic
|
|
109
|
+
* const clock = d({
|
|
110
|
+
* en: 'Current time: {{hour}}:{{minute}}',
|
|
111
|
+
* zh: '现在时间:{{hour}}:{{minute}}',
|
|
112
|
+
* ja: '現在時刻:{{hour}}:{{minute}}',
|
|
113
|
+
* ms: 'Masa semasa: {{hour}}:{{minute}}',
|
|
114
|
+
* })<{ hour: number; minute: number }>()
|
|
115
|
+
*
|
|
116
|
+
* // TypeScript template literal types work too
|
|
117
|
+
* const lastLogin = d({
|
|
118
|
+
* en: 'Last login: {{date}}',
|
|
119
|
+
* zh: '上次登录:{{date}}',
|
|
120
|
+
* ja: '最終ログイン:{{date}}',
|
|
121
|
+
* ms: 'Log masuk terakhir: {{date}}',
|
|
122
|
+
* })<{ date: `${number}-${number}-${number}` }>()
|
|
123
|
+
*
|
|
124
|
+
* // ❌ compile error — 'ja' translation missing
|
|
125
|
+
* const bad1 = d({ en: 'Hello', zh: '你好', ms: 'Helo' })()
|
|
126
|
+
*
|
|
127
|
+
* // ❌ compile error — secondary string has wrong variable name
|
|
128
|
+
* const bad2 = d({ en: 'Hello {{name}}', zh: '你好 {{naam}}', ja: 'こんにちは {{name}}', ms: 'Helo {{name}}' })()
|
|
129
|
+
*
|
|
130
|
+
* // ❌ compile error — secondary string missing a variable
|
|
131
|
+
* const bad3 = d({ en: '{{x}} {{y}}', zh: '{{x}}', ja: '{{x}} {{y}}', ms: '{{x}} {{y}}' })()
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
20
134
|
d: <const PrimaryString extends string, const SecondaryObject extends { [Key in Secondary]: ExtractVariables<PrimaryString> extends infer PrimaryVariables ? ExtractVariables<SecondaryObject[Key] & string> extends infer SecondaryVariables ? PrimaryVariables[] extends SecondaryVariables[] ? SecondaryVariables[] extends PrimaryVariables[] ? SecondaryObject[Key] : "variables not match!" : "variables not match!!" : never : never }>(dictionary: { [Key in Primary]: PrimaryString } & SecondaryObject) => <const ArgsType extends Record<ExtractVariables<PrimaryString>, string | number> = Record<ExtractVariables<PrimaryString>, string | number>>() => Readonly<{
|
|
21
135
|
d: typeof dictionary;
|
|
22
136
|
[_args]?: ArgsType;
|
|
23
137
|
}>;
|
|
138
|
+
/**
|
|
139
|
+
* React hook. Subscribes the component to language changes.
|
|
140
|
+
* Returns a snapshot containing the current `language` and a
|
|
141
|
+
* React Compiler-safe `t` function.
|
|
142
|
+
*
|
|
143
|
+
* Call in every component that renders translated strings.
|
|
144
|
+
* When `setLanguage` is called anywhere, all `useT()` consumers rerender.
|
|
145
|
+
*
|
|
146
|
+
* @returns `{ language: Language, t: TranslateFunction }`
|
|
147
|
+
* @returns `language` - `Language` — The current active language tag as a reactive value.
|
|
148
|
+
* @returns `t` - `TranslateFunction` — React Compiler-safe translate function. Use this inside components instead of the instance-level `t`.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```tsx
|
|
152
|
+
* import { useT, intro, time } from './locales'
|
|
153
|
+
*
|
|
154
|
+
* const Page = () => {
|
|
155
|
+
* const { t, language } = useT()
|
|
156
|
+
*
|
|
157
|
+
* return (
|
|
158
|
+
* <>
|
|
159
|
+
* <p>{t(intro, { name: 'John', age: 30 })}</p>
|
|
160
|
+
* <p>{t(time, { hour: 12, minute: 0 })}</p>
|
|
161
|
+
* <p>Current language: {language}</p>
|
|
162
|
+
* </>
|
|
163
|
+
* )
|
|
164
|
+
* }
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
24
167
|
useT: () => {
|
|
25
168
|
language: Primary | Secondary;
|
|
26
169
|
t: <Dictionary extends () => Readonly<{
|
|
@@ -30,4 +173,75 @@ declare const kotori: <const Primary extends AllTags, const Secondary extends Ex
|
|
|
30
173
|
};
|
|
31
174
|
};
|
|
32
175
|
//#endregion
|
|
33
|
-
|
|
176
|
+
//#region src/detectLanguage.d.ts
|
|
177
|
+
/**
|
|
178
|
+
* Detects the user's preferred language from browser settings and sets it
|
|
179
|
+
* on the kotori instance. Iterates through the user's full language preference
|
|
180
|
+
* list in order, stopping at the first match.
|
|
181
|
+
*
|
|
182
|
+
* @param instance - An object with `setLanguage` and `config`. Compatible with
|
|
183
|
+
* the kotori instance returned by `kotori()`, or any custom object that satisfies
|
|
184
|
+
* the same shape — allowing use with non-kotori setups.
|
|
185
|
+
* @param instance.setLanguage - `(language: BCP47LanguageTagNameWithSubTag) => void` — Called with the
|
|
186
|
+
* first matched language. Can be any callback — a kotori `setLanguage`, a React
|
|
187
|
+
* state setter, a Zustand action, etc.
|
|
188
|
+
* @param instance.config - Language configuration.
|
|
189
|
+
* @param instance.config.primary - `BCP47LanguageTagNameWithSubTag` — The primary/fallback language tag.
|
|
190
|
+
* @param instance.config.secondaries - `Exclude<AllTags, primary>[]` — Additional supported language tags. Cannot include the primary language.
|
|
191
|
+
* @param options - Detection options.
|
|
192
|
+
* @param options.fallbackToSubtag - `boolean` — If no exact match is found (e.g. `'zh-CN'`),
|
|
193
|
+
* fall back to the subtag (e.g. `'zh'`). Defaults to `true`.
|
|
194
|
+
* @returns `void` — Calls `setLanguage` on the first match. No-op if no match is found.
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* // with kotori instance
|
|
199
|
+
* import { kotori, detectLanguage } from 'kotori'
|
|
200
|
+
*
|
|
201
|
+
* const i18n = kotori({
|
|
202
|
+
* primary: 'en',
|
|
203
|
+
* secondaries: ['zh', 'ja', 'ms'],
|
|
204
|
+
* })
|
|
205
|
+
*
|
|
206
|
+
* detectLanguage(i18n)
|
|
207
|
+
* // browser: ['zh-CN', 'en-US'] → subtag 'zh' → setLanguage('zh') ✅
|
|
208
|
+
*
|
|
209
|
+
* detectLanguage(i18n, { fallbackToSubtag: false })
|
|
210
|
+
* // browser: ['zh-CN'] → no exact match → no-op, stays 'en'
|
|
211
|
+
* ```
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```ts
|
|
215
|
+
* // ✅ setLanguage type exactly matches primary + secondaries union
|
|
216
|
+
* detectLanguage({
|
|
217
|
+
* setLanguage: (lang: 'en' | 'zh' | 'ja') => setLang(lang),
|
|
218
|
+
* config: {
|
|
219
|
+
* primary: 'en',
|
|
220
|
+
* secondaries: ['zh', 'ja'],
|
|
221
|
+
* },
|
|
222
|
+
* })
|
|
223
|
+
*
|
|
224
|
+
* // ❌ compile error — setLanguage accepts wider type than declared
|
|
225
|
+
* detectLanguage({
|
|
226
|
+
* setLanguage: (lang: string) => setLang(lang),
|
|
227
|
+
* config: { primary: 'en', secondaries: ['zh'] },
|
|
228
|
+
* })
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
declare const detectLanguage: <const T extends {
|
|
232
|
+
setLanguage: (language: Parameters<T["setLanguage"]>[0]) => void;
|
|
233
|
+
config: {
|
|
234
|
+
primary: BCP47LanguageTagNameWithSubTag;
|
|
235
|
+
secondaries: BCP47LanguageTagNameWithSubTag[];
|
|
236
|
+
};
|
|
237
|
+
}>(instance: T["config"]["primary"] | T["config"]["secondaries"][number] extends infer A ? {
|
|
238
|
+
setLanguage: Parameters<T["setLanguage"]>[0] extends infer L ? L[] extends BCP47LanguageTagNameWithSubTag[] ? A[] extends L[] ? (language: L) => void : "language param does not match primary or secondaries language types" : "language param does not match BCP47LanguageTagNameWithSubTag types" : never;
|
|
239
|
+
config: {
|
|
240
|
+
primary: T["config"]["primary"];
|
|
241
|
+
secondaries: Exclude<BCP47LanguageTagNameWithSubTag, T["config"]["primary"]>[];
|
|
242
|
+
};
|
|
243
|
+
} : T, options?: {
|
|
244
|
+
fallbackToSubtag?: boolean;
|
|
245
|
+
}) => any;
|
|
246
|
+
//#endregion
|
|
247
|
+
export { BCP47LanguageTagNameWithSubTag, SubTags, Tags, detectLanguage, kotori };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,26 +1,169 @@
|
|
|
1
1
|
//#region node_modules/bcp47-language-tags/dist/zh.d.ts
|
|
2
2
|
type BCP47LanguageTagName = "zh-CN" | "zh-TW" | "zh-HK" | "zh-MO" | "zh-SG" | "zh-CHS" | "zh-CHT" | "en-US" | "en-GB" | "en-CA" | "en-AU" | "en-IN" | "en-ZA" | "en-NZ" | "en-IE" | "en-PH" | "en-ZW" | "en-BZ" | "en-CB" | "en-JM" | "en-TT" | "hi-IN" | "es-ES" | "es-MX" | "es-AR" | "es-CO" | "es-PE" | "es-VE" | "es-CL" | "es-EC" | "es-GT" | "es-CU" | "es-BO" | "es-DO" | "es-HN" | "es-PY" | "es-SV" | "es-NI" | "es-PR" | "es-UY" | "es-PA" | "es-CR" | "ar-EG" | "ar-SA" | "ar-DZ" | "ar-MA" | "ar-IQ" | "ar-SD" | "ar-YE" | "ar-SY" | "ar-TN" | "ar-LY" | "ar-JO" | "ar-LB" | "ar-KW" | "ar-AE" | "ar-BH" | "ar-QA" | "ar-OM" | "pt-BR" | "pt-PT" | "ru-RU" | "ja-JP" | "de-DE" | "de-AT" | "de-CH" | "fr-FR" | "fr-CA" | "fr-BE" | "fr-CH" | "fr-LU" | "fr-MC" | "ko-KR" | "it-IT" | "it-CH" | "tr-TR" | "th-TH" | "el-GR" | "cs-CZ" | "sv-SE" | "sv-FI" | "hu-HU" | "fi-FI" | "da-DK" | "nb-NO" | "nn-NO" | "he-IL" | "id-ID" | "ms-MY" | "ms-BN" | "ro-RO" | "bg-BG" | "uk-UA" | "sk-SK" | "sl-SI" | "hr-HR" | "ca-ES" | "lt-LT" | "lv-LV" | "et-EE" | "sq-AL" | "mk-MK" | "be-BY" | "is-IS" | "gl-ES" | "eu-ES" | "af-ZA" | "sw-KE" | "ta-IN" | "te-IN" | "kn-IN" | "mr-IN" | "gu-IN" | "pa-IN" | "kok-IN" | "sa-IN" | "ur-PK" | "fa-IR" | "syr-SY" | "div-MV" | "ka-GE";
|
|
3
3
|
//#endregion
|
|
4
|
-
//#region src/
|
|
4
|
+
//#region src/kotori.d.ts
|
|
5
5
|
type Tags = BCP47LanguageTagName;
|
|
6
6
|
type SubTags = BCP47LanguageTagName extends `${infer SubTag}-${string}` ? SubTag : never;
|
|
7
|
-
type
|
|
7
|
+
type BCP47LanguageTagNameWithSubTag = Tags | SubTags;
|
|
8
8
|
type Trim<T extends string> = T extends ` ${infer R}` ? Trim<R> : T extends `${infer L} ` ? Trim<L> : T;
|
|
9
9
|
type ExtractVariables<T extends string> = T extends `${string}{{${infer P}}}${infer Q}` ? Trim<P> | ExtractVariables<Q> : never;
|
|
10
10
|
declare const _args: unique symbol;
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Creates a scoped i18n instance for your app.
|
|
13
|
+
* Call once and export the returned utilities.
|
|
14
|
+
*
|
|
15
|
+
* @param config - Configuration options.
|
|
16
|
+
* @param config.primary - `BCP47LanguageTag` — The source language. Variable inference and
|
|
17
|
+
* secondary string validation are both driven by this language's strings.
|
|
18
|
+
* @param config.secondaries - `Exclude<BCP47LanguageTag, primary>[]` — Additional supported languages.
|
|
19
|
+
* Cannot include the primary language.
|
|
20
|
+
* @returns `{ d, r, useT, setLanguage, config }`
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* // locales.ts
|
|
25
|
+
* import { kotori } from 'kotori'
|
|
26
|
+
*
|
|
27
|
+
* export const { d, r, useT, setLanguage } = kotori({
|
|
28
|
+
* primary: 'en',
|
|
29
|
+
* secondaries: ['zh', 'ja', 'ms'],
|
|
30
|
+
* })
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
declare const kotori: <const Primary extends BCP47LanguageTagNameWithSubTag, const Secondary extends Exclude<BCP47LanguageTagNameWithSubTag, Primary>>(config: {
|
|
12
34
|
primary: Primary;
|
|
13
35
|
secondaries: Secondary[];
|
|
14
36
|
}) => {
|
|
37
|
+
config: {
|
|
38
|
+
primary: Primary;
|
|
39
|
+
secondaries: Secondary[];
|
|
40
|
+
};
|
|
15
41
|
setLanguage: (language: Primary | Secondary) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Translates a dict to the current language and interpolates variables.
|
|
44
|
+
* For use **outside** React components — route guards, axios interceptors,
|
|
45
|
+
* utility functions, etc.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ Do not use inside React components. The React Compiler will memoize
|
|
48
|
+
* the result permanently since `r` is a stable module-level reference,
|
|
49
|
+
* causing stale translations after a language change. Use `t` from `useT()`
|
|
50
|
+
* inside components instead.
|
|
51
|
+
*
|
|
52
|
+
* @param dictionary - A dict created by `d()`.
|
|
53
|
+
* @param args - `Record<string, string | number>` — Variable values to interpolate.
|
|
54
|
+
* Required if the dict has variables, omitted if it doesn't.
|
|
55
|
+
* @returns `string` — The translated and interpolated string in the current language.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* import { r, greeting, intro } from './locales'
|
|
60
|
+
*
|
|
61
|
+
* r(greeting) // ✅ no args needed
|
|
62
|
+
* r(intro, { name: 'John', age: 30 }) // ✅ typed args
|
|
63
|
+
* r(intro) // ❌ compile error: missing args
|
|
64
|
+
* r(intro, { name: 'John', x: 1 }) // ❌ compile error: unknown key 'x'
|
|
65
|
+
*
|
|
66
|
+
* // route guard
|
|
67
|
+
* router.beforeEach(() => {
|
|
68
|
+
* document.title = r(pageTitle)
|
|
69
|
+
* })
|
|
70
|
+
*
|
|
71
|
+
* // axios interceptor
|
|
72
|
+
* axios.interceptors.response.use(null, () => {
|
|
73
|
+
* toast.error(r(errorMessage))
|
|
74
|
+
* })
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
16
77
|
r: <Dictionary extends () => Readonly<{
|
|
17
78
|
d: Record<Primary | Secondary, string>;
|
|
18
79
|
[_args]?: Record<string, string | number>;
|
|
19
80
|
}>>(dictionary: Dictionary, ...args: keyof NonNullable<ReturnType<Dictionary>[typeof _args]> extends never ? [] : [NonNullable<ReturnType<Dictionary>[typeof _args]>]) => string;
|
|
81
|
+
/**
|
|
82
|
+
* Defines a translation unit. The primary language string drives variable
|
|
83
|
+
* inference — all secondary strings are validated against it at compile time.
|
|
84
|
+
*
|
|
85
|
+
* Call with an optional generic to narrow variable types beyond the default
|
|
86
|
+
* `string | number`. Supports TypeScript template literal types.
|
|
87
|
+
*
|
|
88
|
+
* @param dictionary - `Record<Language, string>` — One string per language.
|
|
89
|
+
* Variables are declared with `{{variableName}}` syntax. Spaces inside braces
|
|
90
|
+
* are trimmed — `{{ name }}` and `{{name}}` are equivalent.
|
|
91
|
+
* Secondary strings must use exactly the same variable names as the primary.
|
|
92
|
+
* @returns A function optionally accepting a generic to narrow variable types,
|
|
93
|
+
* which returns the translation unit.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* // no variables
|
|
98
|
+
* const greeting = d({ en: 'Hello', zh: '你好', ja: 'こんにちは', ms: 'Helo' })()
|
|
99
|
+
*
|
|
100
|
+
* // variables — inferred as Record<'name' | 'age', string | number> by default
|
|
101
|
+
* const intro = d({
|
|
102
|
+
* en: 'My name is {{name}}, I am {{age}} years old.',
|
|
103
|
+
* zh: '我叫{{name}},我今年{{age}}岁了。',
|
|
104
|
+
* ja: '私の名前は{{name}}で、{{age}}歳です。',
|
|
105
|
+
* ms: 'Nama saya {{name}}, saya berumur {{age}} tahun.',
|
|
106
|
+
* })()
|
|
107
|
+
*
|
|
108
|
+
* // narrowed variable types via generic
|
|
109
|
+
* const clock = d({
|
|
110
|
+
* en: 'Current time: {{hour}}:{{minute}}',
|
|
111
|
+
* zh: '现在时间:{{hour}}:{{minute}}',
|
|
112
|
+
* ja: '現在時刻:{{hour}}:{{minute}}',
|
|
113
|
+
* ms: 'Masa semasa: {{hour}}:{{minute}}',
|
|
114
|
+
* })<{ hour: number; minute: number }>()
|
|
115
|
+
*
|
|
116
|
+
* // TypeScript template literal types work too
|
|
117
|
+
* const lastLogin = d({
|
|
118
|
+
* en: 'Last login: {{date}}',
|
|
119
|
+
* zh: '上次登录:{{date}}',
|
|
120
|
+
* ja: '最終ログイン:{{date}}',
|
|
121
|
+
* ms: 'Log masuk terakhir: {{date}}',
|
|
122
|
+
* })<{ date: `${number}-${number}-${number}` }>()
|
|
123
|
+
*
|
|
124
|
+
* // ❌ compile error — 'ja' translation missing
|
|
125
|
+
* const bad1 = d({ en: 'Hello', zh: '你好', ms: 'Helo' })()
|
|
126
|
+
*
|
|
127
|
+
* // ❌ compile error — secondary string has wrong variable name
|
|
128
|
+
* const bad2 = d({ en: 'Hello {{name}}', zh: '你好 {{naam}}', ja: 'こんにちは {{name}}', ms: 'Helo {{name}}' })()
|
|
129
|
+
*
|
|
130
|
+
* // ❌ compile error — secondary string missing a variable
|
|
131
|
+
* const bad3 = d({ en: '{{x}} {{y}}', zh: '{{x}}', ja: '{{x}} {{y}}', ms: '{{x}} {{y}}' })()
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
20
134
|
d: <const PrimaryString extends string, const SecondaryObject extends { [Key in Secondary]: ExtractVariables<PrimaryString> extends infer PrimaryVariables ? ExtractVariables<SecondaryObject[Key] & string> extends infer SecondaryVariables ? PrimaryVariables[] extends SecondaryVariables[] ? SecondaryVariables[] extends PrimaryVariables[] ? SecondaryObject[Key] : "variables not match!" : "variables not match!!" : never : never }>(dictionary: { [Key in Primary]: PrimaryString } & SecondaryObject) => <const ArgsType extends Record<ExtractVariables<PrimaryString>, string | number> = Record<ExtractVariables<PrimaryString>, string | number>>() => Readonly<{
|
|
21
135
|
d: typeof dictionary;
|
|
22
136
|
[_args]?: ArgsType;
|
|
23
137
|
}>;
|
|
138
|
+
/**
|
|
139
|
+
* React hook. Subscribes the component to language changes.
|
|
140
|
+
* Returns a snapshot containing the current `language` and a
|
|
141
|
+
* React Compiler-safe `t` function.
|
|
142
|
+
*
|
|
143
|
+
* Call in every component that renders translated strings.
|
|
144
|
+
* When `setLanguage` is called anywhere, all `useT()` consumers rerender.
|
|
145
|
+
*
|
|
146
|
+
* @returns `{ language: Language, t: TranslateFunction }`
|
|
147
|
+
* @returns `language` - `Language` — The current active language tag as a reactive value.
|
|
148
|
+
* @returns `t` - `TranslateFunction` — React Compiler-safe translate function. Use this inside components instead of the instance-level `t`.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```tsx
|
|
152
|
+
* import { useT, intro, time } from './locales'
|
|
153
|
+
*
|
|
154
|
+
* const Page = () => {
|
|
155
|
+
* const { t, language } = useT()
|
|
156
|
+
*
|
|
157
|
+
* return (
|
|
158
|
+
* <>
|
|
159
|
+
* <p>{t(intro, { name: 'John', age: 30 })}</p>
|
|
160
|
+
* <p>{t(time, { hour: 12, minute: 0 })}</p>
|
|
161
|
+
* <p>Current language: {language}</p>
|
|
162
|
+
* </>
|
|
163
|
+
* )
|
|
164
|
+
* }
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
24
167
|
useT: () => {
|
|
25
168
|
language: Primary | Secondary;
|
|
26
169
|
t: <Dictionary extends () => Readonly<{
|
|
@@ -30,4 +173,75 @@ declare const kotori: <const Primary extends AllTags, const Secondary extends Ex
|
|
|
30
173
|
};
|
|
31
174
|
};
|
|
32
175
|
//#endregion
|
|
33
|
-
|
|
176
|
+
//#region src/detectLanguage.d.ts
|
|
177
|
+
/**
|
|
178
|
+
* Detects the user's preferred language from browser settings and sets it
|
|
179
|
+
* on the kotori instance. Iterates through the user's full language preference
|
|
180
|
+
* list in order, stopping at the first match.
|
|
181
|
+
*
|
|
182
|
+
* @param instance - An object with `setLanguage` and `config`. Compatible with
|
|
183
|
+
* the kotori instance returned by `kotori()`, or any custom object that satisfies
|
|
184
|
+
* the same shape — allowing use with non-kotori setups.
|
|
185
|
+
* @param instance.setLanguage - `(language: BCP47LanguageTagNameWithSubTag) => void` — Called with the
|
|
186
|
+
* first matched language. Can be any callback — a kotori `setLanguage`, a React
|
|
187
|
+
* state setter, a Zustand action, etc.
|
|
188
|
+
* @param instance.config - Language configuration.
|
|
189
|
+
* @param instance.config.primary - `BCP47LanguageTagNameWithSubTag` — The primary/fallback language tag.
|
|
190
|
+
* @param instance.config.secondaries - `Exclude<AllTags, primary>[]` — Additional supported language tags. Cannot include the primary language.
|
|
191
|
+
* @param options - Detection options.
|
|
192
|
+
* @param options.fallbackToSubtag - `boolean` — If no exact match is found (e.g. `'zh-CN'`),
|
|
193
|
+
* fall back to the subtag (e.g. `'zh'`). Defaults to `true`.
|
|
194
|
+
* @returns `void` — Calls `setLanguage` on the first match. No-op if no match is found.
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* // with kotori instance
|
|
199
|
+
* import { kotori, detectLanguage } from 'kotori'
|
|
200
|
+
*
|
|
201
|
+
* const i18n = kotori({
|
|
202
|
+
* primary: 'en',
|
|
203
|
+
* secondaries: ['zh', 'ja', 'ms'],
|
|
204
|
+
* })
|
|
205
|
+
*
|
|
206
|
+
* detectLanguage(i18n)
|
|
207
|
+
* // browser: ['zh-CN', 'en-US'] → subtag 'zh' → setLanguage('zh') ✅
|
|
208
|
+
*
|
|
209
|
+
* detectLanguage(i18n, { fallbackToSubtag: false })
|
|
210
|
+
* // browser: ['zh-CN'] → no exact match → no-op, stays 'en'
|
|
211
|
+
* ```
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* ```ts
|
|
215
|
+
* // ✅ setLanguage type exactly matches primary + secondaries union
|
|
216
|
+
* detectLanguage({
|
|
217
|
+
* setLanguage: (lang: 'en' | 'zh' | 'ja') => setLang(lang),
|
|
218
|
+
* config: {
|
|
219
|
+
* primary: 'en',
|
|
220
|
+
* secondaries: ['zh', 'ja'],
|
|
221
|
+
* },
|
|
222
|
+
* })
|
|
223
|
+
*
|
|
224
|
+
* // ❌ compile error — setLanguage accepts wider type than declared
|
|
225
|
+
* detectLanguage({
|
|
226
|
+
* setLanguage: (lang: string) => setLang(lang),
|
|
227
|
+
* config: { primary: 'en', secondaries: ['zh'] },
|
|
228
|
+
* })
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
declare const detectLanguage: <const T extends {
|
|
232
|
+
setLanguage: (language: Parameters<T["setLanguage"]>[0]) => void;
|
|
233
|
+
config: {
|
|
234
|
+
primary: BCP47LanguageTagNameWithSubTag;
|
|
235
|
+
secondaries: BCP47LanguageTagNameWithSubTag[];
|
|
236
|
+
};
|
|
237
|
+
}>(instance: T["config"]["primary"] | T["config"]["secondaries"][number] extends infer A ? {
|
|
238
|
+
setLanguage: Parameters<T["setLanguage"]>[0] extends infer L ? L[] extends BCP47LanguageTagNameWithSubTag[] ? A[] extends L[] ? (language: L) => void : "language param does not match primary or secondaries language types" : "language param does not match BCP47LanguageTagNameWithSubTag types" : never;
|
|
239
|
+
config: {
|
|
240
|
+
primary: T["config"]["primary"];
|
|
241
|
+
secondaries: Exclude<BCP47LanguageTagNameWithSubTag, T["config"]["primary"]>[];
|
|
242
|
+
};
|
|
243
|
+
} : T, options?: {
|
|
244
|
+
fallbackToSubtag?: boolean;
|
|
245
|
+
}) => any;
|
|
246
|
+
//#endregion
|
|
247
|
+
export { BCP47LanguageTagNameWithSubTag, SubTags, Tags, detectLanguage, kotori };
|
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,144 @@
|
|
|
1
1
|
import { useSyncExternalStore } from "react";
|
|
2
|
-
//#region src/
|
|
3
|
-
|
|
2
|
+
//#region src/detectLanguage.ts
|
|
3
|
+
/**
|
|
4
|
+
* Detects the user's preferred language from browser settings and sets it
|
|
5
|
+
* on the kotori instance. Iterates through the user's full language preference
|
|
6
|
+
* list in order, stopping at the first match.
|
|
7
|
+
*
|
|
8
|
+
* @param instance - An object with `setLanguage` and `config`. Compatible with
|
|
9
|
+
* the kotori instance returned by `kotori()`, or any custom object that satisfies
|
|
10
|
+
* the same shape — allowing use with non-kotori setups.
|
|
11
|
+
* @param instance.setLanguage - `(language: BCP47LanguageTagNameWithSubTag) => void` — Called with the
|
|
12
|
+
* first matched language. Can be any callback — a kotori `setLanguage`, a React
|
|
13
|
+
* state setter, a Zustand action, etc.
|
|
14
|
+
* @param instance.config - Language configuration.
|
|
15
|
+
* @param instance.config.primary - `BCP47LanguageTagNameWithSubTag` — The primary/fallback language tag.
|
|
16
|
+
* @param instance.config.secondaries - `Exclude<AllTags, primary>[]` — Additional supported language tags. Cannot include the primary language.
|
|
17
|
+
* @param options - Detection options.
|
|
18
|
+
* @param options.fallbackToSubtag - `boolean` — If no exact match is found (e.g. `'zh-CN'`),
|
|
19
|
+
* fall back to the subtag (e.g. `'zh'`). Defaults to `true`.
|
|
20
|
+
* @returns `void` — Calls `setLanguage` on the first match. No-op if no match is found.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* // with kotori instance
|
|
25
|
+
* import { kotori, detectLanguage } from 'kotori'
|
|
26
|
+
*
|
|
27
|
+
* const i18n = kotori({
|
|
28
|
+
* primary: 'en',
|
|
29
|
+
* secondaries: ['zh', 'ja', 'ms'],
|
|
30
|
+
* })
|
|
31
|
+
*
|
|
32
|
+
* detectLanguage(i18n)
|
|
33
|
+
* // browser: ['zh-CN', 'en-US'] → subtag 'zh' → setLanguage('zh') ✅
|
|
34
|
+
*
|
|
35
|
+
* detectLanguage(i18n, { fallbackToSubtag: false })
|
|
36
|
+
* // browser: ['zh-CN'] → no exact match → no-op, stays 'en'
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* // ✅ setLanguage type exactly matches primary + secondaries union
|
|
42
|
+
* detectLanguage({
|
|
43
|
+
* setLanguage: (lang: 'en' | 'zh' | 'ja') => setLang(lang),
|
|
44
|
+
* config: {
|
|
45
|
+
* primary: 'en',
|
|
46
|
+
* secondaries: ['zh', 'ja'],
|
|
47
|
+
* },
|
|
48
|
+
* })
|
|
49
|
+
*
|
|
50
|
+
* // ❌ compile error — setLanguage accepts wider type than declared
|
|
51
|
+
* detectLanguage({
|
|
52
|
+
* setLanguage: (lang: string) => setLang(lang),
|
|
53
|
+
* config: { primary: 'en', secondaries: ['zh'] },
|
|
54
|
+
* })
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
const detectLanguage = (instance, options = { fallbackToSubtag: true }) => {
|
|
58
|
+
const languages = [instance.config.primary, ...instance.config.secondaries];
|
|
59
|
+
for (const browserLang of navigator.languages) {
|
|
60
|
+
if (languages.includes(browserLang)) return instance.setLanguage(browserLang);
|
|
61
|
+
if (options.fallbackToSubtag) {
|
|
62
|
+
const subtag = browserLang.split("-")[0];
|
|
63
|
+
if (languages.includes(subtag)) return instance.setLanguage(subtag);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/kotori.ts
|
|
69
|
+
/**
|
|
70
|
+
* Creates a scoped i18n instance for your app.
|
|
71
|
+
* Call once and export the returned utilities.
|
|
72
|
+
*
|
|
73
|
+
* @param config - Configuration options.
|
|
74
|
+
* @param config.primary - `BCP47LanguageTag` — The source language. Variable inference and
|
|
75
|
+
* secondary string validation are both driven by this language's strings.
|
|
76
|
+
* @param config.secondaries - `Exclude<BCP47LanguageTag, primary>[]` — Additional supported languages.
|
|
77
|
+
* Cannot include the primary language.
|
|
78
|
+
* @returns `{ d, r, useT, setLanguage, config }`
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* // locales.ts
|
|
83
|
+
* import { kotori } from 'kotori'
|
|
84
|
+
*
|
|
85
|
+
* export const { d, r, useT, setLanguage } = kotori({
|
|
86
|
+
* primary: 'en',
|
|
87
|
+
* secondaries: ['zh', 'ja', 'ms'],
|
|
88
|
+
* })
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
const kotori = (config) => {
|
|
4
92
|
const listeners = /* @__PURE__ */ new Set();
|
|
93
|
+
/**
|
|
94
|
+
* Translates a dict to the current language and interpolates variables.
|
|
95
|
+
*
|
|
96
|
+
* @param dictionary - A dict created by `d()`.
|
|
97
|
+
* @param args - `Record<string, string | number>` — Variable values to interpolate. Required if the dict has variables, omitted if it doesn't.
|
|
98
|
+
* @returns `string` — The translated and interpolated string.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* const Intro = () => {
|
|
102
|
+
* const { t } = useT()
|
|
103
|
+
*
|
|
104
|
+
* return (
|
|
105
|
+
* <p>{t(greeting)}</p> // ✅ no args needed
|
|
106
|
+
* <p>{t(intro, { name: 'John', age: 30 })}</p> // ✅ typed args
|
|
107
|
+
* <p>{t(intro)}</p> // ❌ compile error: missing args
|
|
108
|
+
* <p>{t(intro, { name: 'John', x: 30 })}</p> // ❌ compile error: unknown key 'x'
|
|
109
|
+
* )
|
|
110
|
+
* }
|
|
111
|
+
*/
|
|
5
112
|
const t = (dictionary, ...args) => (dictionary().d[snapshot.language] || "").replace(/\{\{\s*([\w-]+)\s*\}\}/g, (_, key) => String(args[0]?.[key]));
|
|
6
113
|
let snapshot = {
|
|
7
|
-
language:
|
|
114
|
+
language: config.primary,
|
|
8
115
|
t
|
|
9
116
|
};
|
|
117
|
+
/**
|
|
118
|
+
* Updates the current language and triggers a rerender in all active
|
|
119
|
+
* `useT()` consumers across all pages. Safe to call outside React.
|
|
120
|
+
*
|
|
121
|
+
* @param language - `Language` — Must be one of the tags declared in
|
|
122
|
+
* `primary` or `secondaries`. Any other value is a compile error.
|
|
123
|
+
* @returns `void`
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* import { setLanguage } from './locales'
|
|
128
|
+
*
|
|
129
|
+
* setLanguage('zh') // ✅
|
|
130
|
+
* setLanguage('ms') // ✅
|
|
131
|
+
* setLanguage('de') // ❌ compile error: 'de' was not declared
|
|
132
|
+
*
|
|
133
|
+
* // persist and restore language selection
|
|
134
|
+
* setLanguage('zh')
|
|
135
|
+
* localStorage.setItem('lang', 'zh')
|
|
136
|
+
*
|
|
137
|
+
* // restore on app startup
|
|
138
|
+
* const saved = localStorage.getItem('lang')
|
|
139
|
+
* if (saved) setLanguage(saved as 'en')
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
10
142
|
const setLanguage = (language) => {
|
|
11
143
|
snapshot = {
|
|
12
144
|
language,
|
|
@@ -23,11 +155,129 @@ const kotori = (props) => {
|
|
|
23
155
|
};
|
|
24
156
|
};
|
|
25
157
|
return {
|
|
158
|
+
config,
|
|
26
159
|
setLanguage,
|
|
160
|
+
/**
|
|
161
|
+
* Translates a dict to the current language and interpolates variables.
|
|
162
|
+
* For use **outside** React components — route guards, axios interceptors,
|
|
163
|
+
* utility functions, etc.
|
|
164
|
+
*
|
|
165
|
+
* ⚠️ Do not use inside React components. The React Compiler will memoize
|
|
166
|
+
* the result permanently since `r` is a stable module-level reference,
|
|
167
|
+
* causing stale translations after a language change. Use `t` from `useT()`
|
|
168
|
+
* inside components instead.
|
|
169
|
+
*
|
|
170
|
+
* @param dictionary - A dict created by `d()`.
|
|
171
|
+
* @param args - `Record<string, string | number>` — Variable values to interpolate.
|
|
172
|
+
* Required if the dict has variables, omitted if it doesn't.
|
|
173
|
+
* @returns `string` — The translated and interpolated string in the current language.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```ts
|
|
177
|
+
* import { r, greeting, intro } from './locales'
|
|
178
|
+
*
|
|
179
|
+
* r(greeting) // ✅ no args needed
|
|
180
|
+
* r(intro, { name: 'John', age: 30 }) // ✅ typed args
|
|
181
|
+
* r(intro) // ❌ compile error: missing args
|
|
182
|
+
* r(intro, { name: 'John', x: 1 }) // ❌ compile error: unknown key 'x'
|
|
183
|
+
*
|
|
184
|
+
* // route guard
|
|
185
|
+
* router.beforeEach(() => {
|
|
186
|
+
* document.title = r(pageTitle)
|
|
187
|
+
* })
|
|
188
|
+
*
|
|
189
|
+
* // axios interceptor
|
|
190
|
+
* axios.interceptors.response.use(null, () => {
|
|
191
|
+
* toast.error(r(errorMessage))
|
|
192
|
+
* })
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
27
195
|
r: t,
|
|
196
|
+
/**
|
|
197
|
+
* Defines a translation unit. The primary language string drives variable
|
|
198
|
+
* inference — all secondary strings are validated against it at compile time.
|
|
199
|
+
*
|
|
200
|
+
* Call with an optional generic to narrow variable types beyond the default
|
|
201
|
+
* `string | number`. Supports TypeScript template literal types.
|
|
202
|
+
*
|
|
203
|
+
* @param dictionary - `Record<Language, string>` — One string per language.
|
|
204
|
+
* Variables are declared with `{{variableName}}` syntax. Spaces inside braces
|
|
205
|
+
* are trimmed — `{{ name }}` and `{{name}}` are equivalent.
|
|
206
|
+
* Secondary strings must use exactly the same variable names as the primary.
|
|
207
|
+
* @returns A function optionally accepting a generic to narrow variable types,
|
|
208
|
+
* which returns the translation unit.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```ts
|
|
212
|
+
* // no variables
|
|
213
|
+
* const greeting = d({ en: 'Hello', zh: '你好', ja: 'こんにちは', ms: 'Helo' })()
|
|
214
|
+
*
|
|
215
|
+
* // variables — inferred as Record<'name' | 'age', string | number> by default
|
|
216
|
+
* const intro = d({
|
|
217
|
+
* en: 'My name is {{name}}, I am {{age}} years old.',
|
|
218
|
+
* zh: '我叫{{name}},我今年{{age}}岁了。',
|
|
219
|
+
* ja: '私の名前は{{name}}で、{{age}}歳です。',
|
|
220
|
+
* ms: 'Nama saya {{name}}, saya berumur {{age}} tahun.',
|
|
221
|
+
* })()
|
|
222
|
+
*
|
|
223
|
+
* // narrowed variable types via generic
|
|
224
|
+
* const clock = d({
|
|
225
|
+
* en: 'Current time: {{hour}}:{{minute}}',
|
|
226
|
+
* zh: '现在时间:{{hour}}:{{minute}}',
|
|
227
|
+
* ja: '現在時刻:{{hour}}:{{minute}}',
|
|
228
|
+
* ms: 'Masa semasa: {{hour}}:{{minute}}',
|
|
229
|
+
* })<{ hour: number; minute: number }>()
|
|
230
|
+
*
|
|
231
|
+
* // TypeScript template literal types work too
|
|
232
|
+
* const lastLogin = d({
|
|
233
|
+
* en: 'Last login: {{date}}',
|
|
234
|
+
* zh: '上次登录:{{date}}',
|
|
235
|
+
* ja: '最終ログイン:{{date}}',
|
|
236
|
+
* ms: 'Log masuk terakhir: {{date}}',
|
|
237
|
+
* })<{ date: `${number}-${number}-${number}` }>()
|
|
238
|
+
*
|
|
239
|
+
* // ❌ compile error — 'ja' translation missing
|
|
240
|
+
* const bad1 = d({ en: 'Hello', zh: '你好', ms: 'Helo' })()
|
|
241
|
+
*
|
|
242
|
+
* // ❌ compile error — secondary string has wrong variable name
|
|
243
|
+
* const bad2 = d({ en: 'Hello {{name}}', zh: '你好 {{naam}}', ja: 'こんにちは {{name}}', ms: 'Helo {{name}}' })()
|
|
244
|
+
*
|
|
245
|
+
* // ❌ compile error — secondary string missing a variable
|
|
246
|
+
* const bad3 = d({ en: '{{x}} {{y}}', zh: '{{x}}', ja: '{{x}} {{y}}', ms: '{{x}} {{y}}' })()
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
28
249
|
d: (dictionary) => () => ({ d: dictionary }),
|
|
250
|
+
/**
|
|
251
|
+
* React hook. Subscribes the component to language changes.
|
|
252
|
+
* Returns a snapshot containing the current `language` and a
|
|
253
|
+
* React Compiler-safe `t` function.
|
|
254
|
+
*
|
|
255
|
+
* Call in every component that renders translated strings.
|
|
256
|
+
* When `setLanguage` is called anywhere, all `useT()` consumers rerender.
|
|
257
|
+
*
|
|
258
|
+
* @returns `{ language: Language, t: TranslateFunction }`
|
|
259
|
+
* @returns `language` - `Language` — The current active language tag as a reactive value.
|
|
260
|
+
* @returns `t` - `TranslateFunction` — React Compiler-safe translate function. Use this inside components instead of the instance-level `t`.
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* ```tsx
|
|
264
|
+
* import { useT, intro, time } from './locales'
|
|
265
|
+
*
|
|
266
|
+
* const Page = () => {
|
|
267
|
+
* const { t, language } = useT()
|
|
268
|
+
*
|
|
269
|
+
* return (
|
|
270
|
+
* <>
|
|
271
|
+
* <p>{t(intro, { name: 'John', age: 30 })}</p>
|
|
272
|
+
* <p>{t(time, { hour: 12, minute: 0 })}</p>
|
|
273
|
+
* <p>Current language: {language}</p>
|
|
274
|
+
* </>
|
|
275
|
+
* )
|
|
276
|
+
* }
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
29
279
|
useT: () => useSyncExternalStore(subscribe, () => snapshot, () => snapshot)
|
|
30
280
|
};
|
|
31
281
|
};
|
|
32
282
|
//#endregion
|
|
33
|
-
export { kotori };
|
|
283
|
+
export { detectLanguage, kotori };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kotori",
|
|
3
3
|
"description": "0.28kB Strongly-typed and tree-shakeable internationalization library for React",
|
|
4
|
-
"version": "6.
|
|
4
|
+
"version": "6.1.1",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"setup": "rm -rf node_modules && npm i && git init && husky",
|
|
7
7
|
"prepublishOnly": "npm i && npx tsc && npm run build",
|