kotori 5.0.12 → 6.1.0
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 +110 -53
- package/dist/index.cjs +266 -9
- package/dist/index.d.cts +233 -13
- package/dist/index.d.mts +233 -13
- package/dist/index.mjs +266 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,21 +11,17 @@
|
|
|
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
|
-
## Note
|
|
18
|
-
|
|
19
|
-
⚠️ Doesn't work with React Compiler!!
|
|
20
|
-
|
|
21
17
|
```ts
|
|
22
|
-
const {
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
const { d, useT } = kotori({
|
|
19
|
+
primary: 'en',
|
|
20
|
+
secondaries: ['zh', 'ja', 'ms'],
|
|
25
21
|
})
|
|
26
22
|
|
|
27
23
|
// ❌ TypeScript error: missing japanese translation
|
|
28
|
-
const intro =
|
|
24
|
+
const intro = d({
|
|
29
25
|
// ⭐ base string drives the type contract
|
|
30
26
|
en: 'Hello {{name}}, is it {{time}} now?',
|
|
31
27
|
|
|
@@ -38,31 +34,34 @@ const intro = dict({
|
|
|
38
34
|
// optional: type your arguments, by default it's `Record<'name'|'time', string | number>` in this example
|
|
39
35
|
})<{name: string; time: `${number}:${number}`}>
|
|
40
36
|
|
|
37
|
+
const Component = () => {
|
|
38
|
+
const { t } = useT()
|
|
41
39
|
|
|
42
|
-
// ✅ Works
|
|
43
|
-
t(intro, { name: 'John', time: '12:25' })
|
|
40
|
+
// ✅ Works
|
|
41
|
+
t(intro, { name: 'John', time: '12:25' })
|
|
44
42
|
|
|
45
|
-
// ❌ TypeScript error: missing { name }
|
|
46
|
-
t(intro, { time: '12:25' })
|
|
43
|
+
// ❌ TypeScript error: missing { name }
|
|
44
|
+
t(intro, { time: '12:25' })
|
|
47
45
|
|
|
48
|
-
// ❌ TypeScript error: unknown key 'nama'
|
|
49
|
-
t(intro, { nama: 'John', time: '12:25' })
|
|
46
|
+
// ❌ TypeScript error: unknown key 'nama'
|
|
47
|
+
t(intro, { nama: 'John', time: '12:25' })
|
|
50
48
|
|
|
51
|
-
// ❌ TypeScript error: invalid format for 'time'
|
|
52
|
-
t(intro, { name: 'John', time: '12-00' })
|
|
49
|
+
// ❌ TypeScript error: invalid format for 'time'
|
|
50
|
+
t(intro, { name: 'John', time: '12-00' })
|
|
51
|
+
}
|
|
53
52
|
```
|
|
54
53
|
|
|
55
54
|
- No codegen
|
|
56
55
|
- No JSON
|
|
57
56
|
- No dependencies
|
|
58
57
|
- No build step
|
|
59
|
-
- 0.
|
|
58
|
+
- 0.29kB minified and gzipped
|
|
60
59
|
- Modular and tree-shakeable
|
|
61
60
|
- Language change in one page rerenders all pages
|
|
62
61
|
- Variables typed and inferred from string literals — no more string typos
|
|
63
|
-
-
|
|
62
|
+
- Maximum type safety with minimum types
|
|
64
63
|
|
|
65
|
-
Demo: <https://stackblitz.com/edit/
|
|
64
|
+
Demo: <https://stackblitz.com/edit/kotori?file=src%2FApp.tsx>
|
|
66
65
|
|
|
67
66
|
## Installation
|
|
68
67
|
|
|
@@ -77,20 +76,20 @@ npm i kotori
|
|
|
77
76
|
```ts
|
|
78
77
|
import { kotori } from 'kotori'
|
|
79
78
|
|
|
80
|
-
export const { useT,
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
export const { useT, d, setLanguage } = kotori({
|
|
80
|
+
primary: 'en',
|
|
81
|
+
secondaries: ['zh', 'ja', 'ms'],
|
|
83
82
|
})
|
|
84
83
|
|
|
85
84
|
// you can define your dicts in the same file or separate them by component, it's up to you
|
|
86
|
-
export const intro =
|
|
85
|
+
export const intro = d({
|
|
87
86
|
en: 'my name is {{name}}, I am {{age}} years old.',
|
|
88
87
|
zh: '我叫{{name}},我今年{{age}}岁了。',
|
|
89
88
|
ja: '私の名前は{{name}}で、{{age}}歳です。',
|
|
90
89
|
ms: 'nama saya {{name}}, saya berumur {{age}} tahun.',
|
|
91
90
|
})
|
|
92
91
|
|
|
93
|
-
export const time =
|
|
92
|
+
export const time = d({
|
|
94
93
|
en: 'time {{time}}',
|
|
95
94
|
zh: '时间 {{time}}',
|
|
96
95
|
ja: '時間 {{time}}',
|
|
@@ -102,11 +101,11 @@ export const time = dict({
|
|
|
102
101
|
### page1.tsx
|
|
103
102
|
|
|
104
103
|
```tsx
|
|
105
|
-
import { useT,
|
|
104
|
+
import { useT, setLanguage, intro, time } from './locales'
|
|
106
105
|
|
|
107
106
|
export const Page1 = () => {
|
|
108
107
|
|
|
109
|
-
const language
|
|
108
|
+
const { language, t } = useT()
|
|
110
109
|
|
|
111
110
|
return (
|
|
112
111
|
<>
|
|
@@ -130,17 +129,17 @@ export const Page1 = () => {
|
|
|
130
129
|
### page2.tsx
|
|
131
130
|
|
|
132
131
|
```tsx
|
|
133
|
-
import { useT,
|
|
132
|
+
import { useT, d } from './locales'
|
|
134
133
|
|
|
135
134
|
// you can also define dicts in the same file as your components, it's up to you
|
|
136
|
-
const weather =
|
|
135
|
+
const weather = d({
|
|
137
136
|
en: 'The weather in {{city}} has {{humidity}}% humidity.',
|
|
138
137
|
zh: '{{city}}的天气湿度为{{humidity}}%。',
|
|
139
138
|
ja: '{{city}}の湿度は{{humidity}}%です。',
|
|
140
139
|
ms: 'Cuaca di {{city}} mempunyai kelembapan {{humidity}}%.',
|
|
141
140
|
})<{ city: string; humidity: number }>
|
|
142
141
|
|
|
143
|
-
const lastLogin =
|
|
142
|
+
const lastLogin = d({
|
|
144
143
|
en: 'Last login: {{date}} at {{time}}',
|
|
145
144
|
zh: '上次登录:{{date}} {{time}}',
|
|
146
145
|
ja: '最終ログイン:{{date}} {{time}}',
|
|
@@ -149,7 +148,7 @@ const lastLogin = dict({
|
|
|
149
148
|
|
|
150
149
|
export const Page2 = () => {
|
|
151
150
|
|
|
152
|
-
useT()
|
|
151
|
+
const { t } = useT()
|
|
153
152
|
|
|
154
153
|
return (
|
|
155
154
|
<>
|
|
@@ -164,38 +163,38 @@ export const Page2 = () => {
|
|
|
164
163
|
|
|
165
164
|

|
|
166
165
|
|
|
167
|
-
### `kotori(options)`
|
|
166
|
+
### `kotori(options)` (0.29kB)
|
|
168
167
|
|
|
169
168
|
Creates a scoped i18n instance.
|
|
170
169
|
|
|
171
170
|
```ts
|
|
172
171
|
import { kotori } from 'kotori'
|
|
173
172
|
|
|
174
|
-
export const { useT,
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
export const { useT, d, setLanguage } = kotori({
|
|
174
|
+
primary: 'en',
|
|
175
|
+
secondaries: ['zh', 'ja', 'ms'],
|
|
177
176
|
})
|
|
178
177
|
```
|
|
179
178
|
|
|
180
179
|
| option | type | description |
|
|
181
180
|
| --- | --- | --- |
|
|
182
|
-
| `
|
|
183
|
-
| `
|
|
181
|
+
| `primary` | `BCP47LanguageTagWithSubtag` | The source language. Drives variable inference. |
|
|
182
|
+
| `secondaries` | `Exclude<BCP47LanguageTagWithSubtag, primary>[]` | Additional supported languages. Cannot include the primary language. |
|
|
184
183
|
|
|
185
|
-
Returns `{
|
|
184
|
+
Returns `{ d, useT, setLanguage, r }`.
|
|
186
185
|
|
|
187
|
-
### `
|
|
186
|
+
### `d(translations)<argsType?>`
|
|
188
187
|
|
|
189
|
-
Defines a translation unit. Takes one string per language.
|
|
188
|
+
Defines a translation unit. Takes one string per language. Returns a `dictionary` object.
|
|
190
189
|
|
|
191
190
|
```ts
|
|
192
|
-
const time =
|
|
191
|
+
const time = d({ en: '{{hour}}:{{minute}}' })
|
|
193
192
|
```
|
|
194
193
|
|
|
195
194
|
By default, variables are typed as `string | number`. Pass a generic to narrow them:
|
|
196
195
|
|
|
197
196
|
```ts
|
|
198
|
-
const time =
|
|
197
|
+
const time = d({ en: '{{hour}}:{{minute}}' })<{
|
|
199
198
|
hour: number
|
|
200
199
|
minute: number
|
|
201
200
|
}>
|
|
@@ -209,43 +208,101 @@ Updates the current language and rerenders all active `useT` consumers across al
|
|
|
209
208
|
setLanguage('zh')
|
|
210
209
|
```
|
|
211
210
|
|
|
212
|
-
### `
|
|
211
|
+
### `r(dictionary, args?)`
|
|
213
212
|
|
|
214
|
-
Returns the translated string for the current language. `args` is required if the string has variables, omitted if it doesn't.
|
|
213
|
+
Returns the translated string for the current language. `args` is required if the string has variables, omitted if it doesn't.
|
|
214
|
+
|
|
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.
|
|
215
216
|
|
|
216
217
|
```tsx
|
|
217
|
-
|
|
218
|
+
r(intro, { name: 'John', age: 30 })
|
|
218
219
|
```
|
|
219
220
|
|
|
220
221
|
### `useT()`
|
|
221
222
|
|
|
222
|
-
React hook. Returns the current language tag as a reactive value. Updates when `setLanguage` is called.
|
|
223
|
+
React hook. Returns the current language tag as a reactive value. Updates when `setLanguage` is called. Returns `{ t, language }`.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const { t, language } = useT()
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### `t(dictionary, args?)`
|
|
230
|
+
|
|
231
|
+
Returns the translated string for the current language. `args` is required if the string has variables, omitted if it doesn't.
|
|
232
|
+
|
|
233
|
+
React version of `r(dictionary, args?)`, works with React Compiler.
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
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.
|
|
223
248
|
|
|
224
249
|
```ts
|
|
225
|
-
|
|
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
|
+
})
|
|
226
278
|
```
|
|
227
279
|
|
|
228
280
|
## Language Tags
|
|
229
281
|
|
|
230
|
-
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
|
+
```
|
|
231
288
|
|
|
232
289
|
## Tips
|
|
233
290
|
|
|
234
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.
|
|
235
|
-
- 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.
|
|
236
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.
|
|
237
295
|
|
|
238
296
|
## Roadmap
|
|
239
297
|
|
|
240
|
-
- Auto detect locale from browser settings
|
|
298
|
+
- ✅ Auto detect locale from browser settings
|
|
241
299
|
- Auto persist language selection to localStorage
|
|
242
300
|
- Pluralization support
|
|
243
301
|
- Gender support
|
|
244
302
|
- Value formatting (date, number, currency)
|
|
245
|
-
- Support for non-React frameworks (Vue, Svelte, Angular, etc.)
|
|
246
303
|
|
|
247
304
|
## Trivial
|
|
248
305
|
|
|
249
|
-
There are already
|
|
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.
|
|
250
307
|
|
|
251
|
-
*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,11 +1,150 @@
|
|
|
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();
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
*/
|
|
113
|
+
const t = (dictionary, ...args) => (dictionary().d[snapshot.language] || "").replace(/\{\{\s*([\w-]+)\s*\}\}/g, (_, key) => String(args[0]?.[key]));
|
|
114
|
+
let snapshot = {
|
|
115
|
+
language: config.primary,
|
|
116
|
+
t
|
|
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
|
+
*/
|
|
143
|
+
const setLanguage = (language) => {
|
|
144
|
+
snapshot = {
|
|
145
|
+
language,
|
|
146
|
+
t: (...args) => t(...args)
|
|
147
|
+
};
|
|
9
148
|
listeners.forEach((listener) => {
|
|
10
149
|
listener();
|
|
11
150
|
});
|
|
@@ -16,13 +155,131 @@ const kotori = (props) => {
|
|
|
16
155
|
listeners.delete(listener);
|
|
17
156
|
};
|
|
18
157
|
};
|
|
19
|
-
const t = (dict, ...args) => (dict().translation[language] || "").replace(/\{\{\s*([\w-]+)\s*\}\}/g, (_, key) => String(args[0]?.[key]));
|
|
20
158
|
return {
|
|
159
|
+
config,
|
|
21
160
|
setLanguage,
|
|
22
|
-
|
|
23
|
-
dict
|
|
24
|
-
|
|
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
|
+
*/
|
|
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
|
+
*/
|
|
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
|
+
*/
|
|
280
|
+
useT: () => (0, react.useSyncExternalStore)(subscribe, () => snapshot, () => snapshot)
|
|
25
281
|
};
|
|
26
282
|
};
|
|
27
283
|
//#endregion
|
|
284
|
+
exports.detectLanguage = detectLanguage;
|
|
28
285
|
exports.kotori = kotori;
|