kotori 1.0.1 → 2.0.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 +61 -30
- package/dist/index.cjs +16 -12
- package/dist/index.d.cts +9 -7
- package/dist/index.d.mts +9 -7
- package/dist/index.mjs +16 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
<img src="logo.webp" alt="kotori i18n logo">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
-
# Kotori
|
|
6
|
-
|
|
7
5
|
Strongly-typed, modular i18n for React. Variables are inferred directly from your strings — no codegen, no JSON, no schema files.
|
|
8
6
|
|
|
9
7
|
```ts
|
|
@@ -16,26 +14,19 @@ const { dict } = kotori({
|
|
|
16
14
|
const intro = dict({
|
|
17
15
|
// ⭐ base string drives the type contract
|
|
18
16
|
en: 'Hello {{name}}, is it {{time}} now?',
|
|
19
|
-
|
|
20
17
|
// ❌ TypeScript error: missing key 'name'
|
|
21
18
|
zh: '你好,现在是 {{time}} 吗?',
|
|
22
|
-
|
|
23
19
|
// ❌ TypeScript error: unknown key 'nam'
|
|
24
20
|
ms: 'Hai {{nam}}, adakah pukul {{time}} sekarang?'
|
|
25
|
-
|
|
26
21
|
// optional: type your arguments, by default it's `Record<'name'|'time', string | number>` in this example
|
|
27
22
|
})<{name: string; time: `${number}:${number}`}>
|
|
28
23
|
|
|
29
|
-
|
|
30
24
|
// ✅ Works
|
|
31
25
|
t('intro', { name: 'John', time: '12:25' })
|
|
32
|
-
|
|
33
26
|
// ❌ TypeScript error: missing { name }
|
|
34
27
|
t('intro', { time: '12:25' })
|
|
35
|
-
|
|
36
28
|
// ❌ TypeScript error: unknown key 'nama'
|
|
37
29
|
t('intro', { nama: 'John', time: '12:25' })
|
|
38
|
-
|
|
39
30
|
// ❌ TypeScript error: invalid format for 'time'
|
|
40
31
|
t('intro', { name: 'John', time: '12-00' })
|
|
41
32
|
```
|
|
@@ -44,11 +35,11 @@ t('intro', { name: 'John', time: '12-00' })
|
|
|
44
35
|
- No JSON
|
|
45
36
|
- No dependencies
|
|
46
37
|
- No build step
|
|
47
|
-
- 0.
|
|
38
|
+
- 0.38kb gzipped
|
|
48
39
|
- Modular and tree-shakeable
|
|
49
40
|
- Language change in one page rerenders all pages
|
|
50
|
-
-
|
|
51
|
-
-
|
|
41
|
+
- Translation keys are typed — no more string typos
|
|
42
|
+
- Variables typed and inferred from string literals
|
|
52
43
|
|
|
53
44
|
Demo: <https://stackblitz.com/edit/vitejs-vite-nyxwmhre?file=src%2FApp.tsx>
|
|
54
45
|
|
|
@@ -65,7 +56,7 @@ npm i kotori
|
|
|
65
56
|
```ts
|
|
66
57
|
import { kotori } from './kotori'
|
|
67
58
|
|
|
68
|
-
export const {
|
|
59
|
+
export const { createT, dict, setLanguage } = kotori({
|
|
69
60
|
primaryLanguageTag: 'en',
|
|
70
61
|
secondaryLanguageTags: ['zh', 'ja', 'ms'],
|
|
71
62
|
})
|
|
@@ -74,7 +65,7 @@ export const { useT, dict, setLanguage } = kotori({
|
|
|
74
65
|
**page1.tsx**
|
|
75
66
|
|
|
76
67
|
```tsx
|
|
77
|
-
import {
|
|
68
|
+
import { createT, dict } from './utils'
|
|
78
69
|
|
|
79
70
|
const intro = dict({
|
|
80
71
|
en: 'my name is {{name}}, I am {{age}} years old.',
|
|
@@ -91,6 +82,11 @@ const time = dict({
|
|
|
91
82
|
// optional: type your arguments, by default it's `Record<'time', string | number>` in this example
|
|
92
83
|
})<{ time: `${number}:${number}:${number}` }>
|
|
93
84
|
|
|
85
|
+
const { useT } = createT({
|
|
86
|
+
intro,
|
|
87
|
+
time,
|
|
88
|
+
})
|
|
89
|
+
|
|
94
90
|
export const Page1 = () => {
|
|
95
91
|
const { t, language, setLanguage } = useT()
|
|
96
92
|
return (
|
|
@@ -115,7 +111,7 @@ export const Page1 = () => {
|
|
|
115
111
|
**page2.tsx**
|
|
116
112
|
|
|
117
113
|
```tsx
|
|
118
|
-
import {
|
|
114
|
+
import { createT, dict } from './utils'
|
|
119
115
|
|
|
120
116
|
const weather = dict({
|
|
121
117
|
en: 'The weather in {{city}} has {{humidity}}% humidity.',
|
|
@@ -138,6 +134,12 @@ const lastLogin = dict({
|
|
|
138
134
|
ms: 'Log masuk terakhir: {{date}} pada {{time}}',
|
|
139
135
|
})<{ date: `${number}-${number}-${number}`; time: `${number}:${number}` }>
|
|
140
136
|
|
|
137
|
+
const { useT } = createT({
|
|
138
|
+
weather,
|
|
139
|
+
score,
|
|
140
|
+
lastLogin,
|
|
141
|
+
})
|
|
142
|
+
|
|
141
143
|
export const Page2 = () => {
|
|
142
144
|
const { t, language, setLanguage } = useT()
|
|
143
145
|
return (
|
|
@@ -152,14 +154,44 @@ export const Page2 = () => {
|
|
|
152
154
|
<option value="ja">Japanese</option>
|
|
153
155
|
<option value="ms">Malay</option>
|
|
154
156
|
</select>
|
|
155
|
-
<p>{t(weather, { city: 'Kuala Lumpur', humidity: 80 })}</p>
|
|
156
|
-
<p>{t(score, { score: 87, total: 100 })}</p>
|
|
157
|
-
<p>{t(lastLogin, { date: '2024-04-24', time: '09:30' })}</p>
|
|
157
|
+
<p>{t('weather', { city: 'Kuala Lumpur', humidity: 80 })}</p>
|
|
158
|
+
<p>{t('score', { score: 87, total: 100 })}</p>
|
|
159
|
+
<p>{t('lastLogin', { date: '2024-04-24', time: '09:30' })}</p>
|
|
158
160
|
</>
|
|
159
161
|
)
|
|
160
162
|
}
|
|
161
163
|
```
|
|
162
164
|
|
|
165
|
+
## How It Works
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
### One `kotori` instance per app
|
|
170
|
+
|
|
171
|
+
`kotori` holds the language state. All `createT` calls share that state — changing the language anywhere rerenders everywhere.
|
|
172
|
+
|
|
173
|
+
### One `createT` per page/component/feature
|
|
174
|
+
|
|
175
|
+
Translations are colocated with the component that uses them. Bundlers naturally code-split them, so each page only loads what it needs.
|
|
176
|
+
|
|
177
|
+
### Variables are inferred from string literals
|
|
178
|
+
|
|
179
|
+
kotori parses `{{variable}}` at the type level. No separate type definitions needed — the string *is* the schema.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
// primary string drives the contract
|
|
183
|
+
const greeting = dict({ en: 'Hi {{name}}', zh: '你好 {{name}}' })
|
|
184
|
+
// ^^^^^^ — inferred as required arg
|
|
185
|
+
|
|
186
|
+
// secondary strings are validated against it
|
|
187
|
+
const mismatch = dict({ en: 'Hi {{name}}', zh: '你好 {{other}}' })
|
|
188
|
+
// ^^^^^^^ — compile error
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Custom argument types
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
163
195
|
## API
|
|
164
196
|
|
|
165
197
|

|
|
@@ -173,11 +205,15 @@ Creates a scoped i18n instance.
|
|
|
173
205
|
| `primaryLanguageTag` | `AllTags` | The source language. Drives variable inference. |
|
|
174
206
|
| `secondaryLanguageTags` | `AllTags[]` | Additional supported languages. |
|
|
175
207
|
|
|
176
|
-
Returns `{ dict,
|
|
208
|
+
Returns `{ dict, createT, setLanguage }`.
|
|
177
209
|
|
|
178
210
|
### `dict(translations)<argsType?>`
|
|
179
211
|
|
|
180
|
-
Defines a translation unit. Takes one string per language
|
|
212
|
+
Defines a translation unit. Takes one string per language
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const time = dict({ en: '{{hour}}:{{minute}}' })
|
|
216
|
+
```
|
|
181
217
|
|
|
182
218
|
By default, variables are typed as `string | number`. Pass a generic to narrow them:
|
|
183
219
|
|
|
@@ -188,6 +224,10 @@ const time = dict({ en: '{{hour}}:{{minute}}' })<{
|
|
|
188
224
|
}>
|
|
189
225
|
```
|
|
190
226
|
|
|
227
|
+
### `createT(dicts)`
|
|
228
|
+
|
|
229
|
+
Registers a set of dicts and returns `{ useT }`. Call once per page or feature module.
|
|
230
|
+
|
|
191
231
|
### `setLanguage(tag)`
|
|
192
232
|
|
|
193
233
|
Updates the current language and rerenders all active `useT` consumers across all pages. Available directly on the `kotori` instance — useful for calling outside of React (route guards, axios interceptors, etc.).
|
|
@@ -198,7 +238,7 @@ React hook. Returns `{ t, language, setLanguage }`.
|
|
|
198
238
|
|
|
199
239
|
| return | type | description |
|
|
200
240
|
| --- | --- | --- |
|
|
201
|
-
| `t(
|
|
241
|
+
| `t(key, args?)` | `string` | Returns the translated string for the current language. `args` is required if the string has variables, omitted if it doesn't. |
|
|
202
242
|
| `language` | `primaryLanguageTag` \| `secondaryLanguageTags` | The current language tag as a reactive value. Updates when `setLanguage` is called. |
|
|
203
243
|
| `setLanguage(tag)` | `void` | Updates the language and rerenders all active `useT` consumers. |
|
|
204
244
|
|
|
@@ -206,17 +246,8 @@ React hook. Returns `{ t, language, setLanguage }`.
|
|
|
206
246
|
|
|
207
247
|
kotori uses [BCP 47](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry) language tags. Both subtags (`en`, `zh`) and full tags (`en-US`, `zh-Hans`) are accepted and validated at the type level.
|
|
208
248
|
|
|
209
|
-
## Roadmap
|
|
210
|
-
|
|
211
|
-
- Auto detect locale from browser settings
|
|
212
|
-
- Auto persist language selection to localStorage
|
|
213
|
-
- Pluralization support
|
|
214
|
-
- Gender support
|
|
215
|
-
- Value formatting (date, number, currency)
|
|
216
|
-
|
|
217
249
|
## Trivial
|
|
218
250
|
|
|
219
251
|
There are already a lot of 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.
|
|
220
252
|
|
|
221
253
|
*Kotori* (小鳥) means "small bird" in Japanese. No deeper relevance to the library — it just sounds nice.
|
|
222
|
-
|
package/dist/index.cjs
CHANGED
|
@@ -3,10 +3,10 @@ let react = require("react");
|
|
|
3
3
|
//#region src/index.ts
|
|
4
4
|
const kotori = (props) => {
|
|
5
5
|
const listeners = /* @__PURE__ */ new Set();
|
|
6
|
-
let
|
|
6
|
+
let languageTag = props.primaryLanguageTag;
|
|
7
7
|
const snapshots = /* @__PURE__ */ new Map();
|
|
8
8
|
const setLanguage = (tag) => {
|
|
9
|
-
|
|
9
|
+
languageTag = tag;
|
|
10
10
|
snapshots.forEach((snapshot, key) => {
|
|
11
11
|
snapshots.set(key, {
|
|
12
12
|
...snapshot,
|
|
@@ -23,19 +23,23 @@ const kotori = (props) => {
|
|
|
23
23
|
listeners.delete(listener);
|
|
24
24
|
};
|
|
25
25
|
};
|
|
26
|
-
const snapshot = {
|
|
27
|
-
language,
|
|
28
|
-
setLanguage,
|
|
29
|
-
t: (dict, ...args) => {
|
|
30
|
-
let locale = dict().translation[language] || "unable_to_load_translations";
|
|
31
|
-
for (const objKey in args[0]) locale = locale.replace(new RegExp(`\\{\\{\\s*${objKey}\\s*\\}\\}`, "g"), () => String(args[0]?.[objKey]));
|
|
32
|
-
return locale;
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
26
|
return {
|
|
36
27
|
setLanguage,
|
|
37
28
|
dict: (translation) => () => ({ translation }),
|
|
38
|
-
|
|
29
|
+
createT: (dictCallbacks) => {
|
|
30
|
+
const s = Symbol();
|
|
31
|
+
const snapshot = {
|
|
32
|
+
language: languageTag,
|
|
33
|
+
setLanguage,
|
|
34
|
+
t: (key, ...args) => {
|
|
35
|
+
let locale = dictCallbacks[key]?.().translation[languageTag] || "unable_to_load_translations";
|
|
36
|
+
for (const objKey in args[0]) locale = locale.replace(new RegExp(`\\{\\{\\s*${objKey}\\s*\\}\\}`, "g"), () => String(args[0]?.[objKey]));
|
|
37
|
+
return locale;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
snapshots.set(s, snapshot);
|
|
41
|
+
return { useT: () => (0, react.useSyncExternalStore)(subscribe, () => snapshots.get(s), () => snapshot) };
|
|
42
|
+
}
|
|
39
43
|
};
|
|
40
44
|
};
|
|
41
45
|
//#endregion
|
package/dist/index.d.cts
CHANGED
|
@@ -17,13 +17,15 @@ declare const kotori: <const PrimaryTag extends AllTags, const SecondaryTags ext
|
|
|
17
17
|
translation: typeof translation;
|
|
18
18
|
[_args]?: ArgsType;
|
|
19
19
|
}>;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
createT: <const DictCallbacks extends Record<string, () => Readonly<{
|
|
21
|
+
translation: Record<PrimaryTag | SecondaryTags, string>;
|
|
22
|
+
[_args]?: Record<string, string | number>;
|
|
23
|
+
}>>>(dictCallbacks: DictCallbacks) => {
|
|
24
|
+
useT: () => {
|
|
25
|
+
language: PrimaryTag | SecondaryTags;
|
|
26
|
+
setLanguage: (tag: PrimaryTag | SecondaryTags) => void;
|
|
27
|
+
t: <Key extends keyof DictCallbacks>(key: Key, ...args: keyof NonNullable<ReturnType<DictCallbacks[Key]>[typeof _args]> extends never ? [] : [NonNullable<ReturnType<DictCallbacks[Key]>[typeof _args]>]) => string;
|
|
28
|
+
};
|
|
27
29
|
};
|
|
28
30
|
};
|
|
29
31
|
//#endregion
|
package/dist/index.d.mts
CHANGED
|
@@ -17,13 +17,15 @@ declare const kotori: <const PrimaryTag extends AllTags, const SecondaryTags ext
|
|
|
17
17
|
translation: typeof translation;
|
|
18
18
|
[_args]?: ArgsType;
|
|
19
19
|
}>;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
createT: <const DictCallbacks extends Record<string, () => Readonly<{
|
|
21
|
+
translation: Record<PrimaryTag | SecondaryTags, string>;
|
|
22
|
+
[_args]?: Record<string, string | number>;
|
|
23
|
+
}>>>(dictCallbacks: DictCallbacks) => {
|
|
24
|
+
useT: () => {
|
|
25
|
+
language: PrimaryTag | SecondaryTags;
|
|
26
|
+
setLanguage: (tag: PrimaryTag | SecondaryTags) => void;
|
|
27
|
+
t: <Key extends keyof DictCallbacks>(key: Key, ...args: keyof NonNullable<ReturnType<DictCallbacks[Key]>[typeof _args]> extends never ? [] : [NonNullable<ReturnType<DictCallbacks[Key]>[typeof _args]>]) => string;
|
|
28
|
+
};
|
|
27
29
|
};
|
|
28
30
|
};
|
|
29
31
|
//#endregion
|
package/dist/index.mjs
CHANGED
|
@@ -2,10 +2,10 @@ import { useSyncExternalStore } from "react";
|
|
|
2
2
|
//#region src/index.ts
|
|
3
3
|
const kotori = (props) => {
|
|
4
4
|
const listeners = /* @__PURE__ */ new Set();
|
|
5
|
-
let
|
|
5
|
+
let languageTag = props.primaryLanguageTag;
|
|
6
6
|
const snapshots = /* @__PURE__ */ new Map();
|
|
7
7
|
const setLanguage = (tag) => {
|
|
8
|
-
|
|
8
|
+
languageTag = tag;
|
|
9
9
|
snapshots.forEach((snapshot, key) => {
|
|
10
10
|
snapshots.set(key, {
|
|
11
11
|
...snapshot,
|
|
@@ -22,19 +22,23 @@ const kotori = (props) => {
|
|
|
22
22
|
listeners.delete(listener);
|
|
23
23
|
};
|
|
24
24
|
};
|
|
25
|
-
const snapshot = {
|
|
26
|
-
language,
|
|
27
|
-
setLanguage,
|
|
28
|
-
t: (dict, ...args) => {
|
|
29
|
-
let locale = dict().translation[language] || "unable_to_load_translations";
|
|
30
|
-
for (const objKey in args[0]) locale = locale.replace(new RegExp(`\\{\\{\\s*${objKey}\\s*\\}\\}`, "g"), () => String(args[0]?.[objKey]));
|
|
31
|
-
return locale;
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
25
|
return {
|
|
35
26
|
setLanguage,
|
|
36
27
|
dict: (translation) => () => ({ translation }),
|
|
37
|
-
|
|
28
|
+
createT: (dictCallbacks) => {
|
|
29
|
+
const s = Symbol();
|
|
30
|
+
const snapshot = {
|
|
31
|
+
language: languageTag,
|
|
32
|
+
setLanguage,
|
|
33
|
+
t: (key, ...args) => {
|
|
34
|
+
let locale = dictCallbacks[key]?.().translation[languageTag] || "unable_to_load_translations";
|
|
35
|
+
for (const objKey in args[0]) locale = locale.replace(new RegExp(`\\{\\{\\s*${objKey}\\s*\\}\\}`, "g"), () => String(args[0]?.[objKey]));
|
|
36
|
+
return locale;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
snapshots.set(s, snapshot);
|
|
40
|
+
return { useT: () => useSyncExternalStore(subscribe, () => snapshots.get(s), () => snapshot) };
|
|
41
|
+
}
|
|
38
42
|
};
|
|
39
43
|
};
|
|
40
44
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kotori",
|
|
3
3
|
"description": "Strongly-typed and composable internationalization library for React",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.0.0",
|
|
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",
|