kotori 0.1.7 → 1.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 +41 -66
- package/dist/index.cjs +12 -16
- package/dist/index.d.cts +7 -9
- package/dist/index.d.mts +7 -9
- package/dist/index.mjs +12 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,19 +12,26 @@ const { dict } = kotori({
|
|
|
12
12
|
const intro = dict({
|
|
13
13
|
// ⭐ base string drives the type contract
|
|
14
14
|
en: 'Hello {{name}}, is it {{time}} now?',
|
|
15
|
+
|
|
15
16
|
// ❌ TypeScript error: missing key 'name'
|
|
16
17
|
zh: '你好,现在是 {{time}} 吗?',
|
|
18
|
+
|
|
17
19
|
// ❌ TypeScript error: unknown key 'nam'
|
|
18
20
|
ms: 'Hai {{nam}}, adakah pukul {{time}} sekarang?'
|
|
21
|
+
|
|
19
22
|
// optional: type your arguments, by default it's `Record<'name'|'time', string | number>` in this example
|
|
20
23
|
})<{name: string; time: `${number}:${number}`}>
|
|
21
24
|
|
|
25
|
+
|
|
22
26
|
// ✅ Works
|
|
23
27
|
t('intro', { name: 'John', time: '12:25' })
|
|
28
|
+
|
|
24
29
|
// ❌ TypeScript error: missing { name }
|
|
25
30
|
t('intro', { time: '12:25' })
|
|
31
|
+
|
|
26
32
|
// ❌ TypeScript error: unknown key 'nama'
|
|
27
33
|
t('intro', { nama: 'John', time: '12:25' })
|
|
34
|
+
|
|
28
35
|
// ❌ TypeScript error: invalid format for 'time'
|
|
29
36
|
t('intro', { name: 'John', time: '12-00' })
|
|
30
37
|
```
|
|
@@ -33,11 +40,11 @@ t('intro', { name: 'John', time: '12-00' })
|
|
|
33
40
|
- No JSON
|
|
34
41
|
- No dependencies
|
|
35
42
|
- No build step
|
|
36
|
-
- 0.
|
|
43
|
+
- 0.34kb gzipped
|
|
37
44
|
- Modular and tree-shakeable
|
|
38
45
|
- Language change in one page rerenders all pages
|
|
39
|
-
-
|
|
40
|
-
-
|
|
46
|
+
- Variables typed and inferred from string literals — no more string typos
|
|
47
|
+
- maximum type safety with minimum types
|
|
41
48
|
|
|
42
49
|
Demo: <https://stackblitz.com/edit/vitejs-vite-nyxwmhre?file=src%2FApp.tsx>
|
|
43
50
|
|
|
@@ -54,7 +61,7 @@ npm i kotori
|
|
|
54
61
|
```ts
|
|
55
62
|
import { kotori } from './kotori'
|
|
56
63
|
|
|
57
|
-
export const {
|
|
64
|
+
export const { useT, dict, setLanguage } = kotori({
|
|
58
65
|
primaryLanguageTag: 'en',
|
|
59
66
|
secondaryLanguageTags: ['zh', 'ja', 'ms'],
|
|
60
67
|
})
|
|
@@ -63,7 +70,7 @@ export const { createTranslations, dict, setLanguage } = kotori({
|
|
|
63
70
|
**page1.tsx**
|
|
64
71
|
|
|
65
72
|
```tsx
|
|
66
|
-
import {
|
|
73
|
+
import { useT, dict } from './utils'
|
|
67
74
|
|
|
68
75
|
const intro = dict({
|
|
69
76
|
en: 'my name is {{name}}, I am {{age}} years old.',
|
|
@@ -80,13 +87,8 @@ const time = dict({
|
|
|
80
87
|
// optional: type your arguments, by default it's `Record<'time', string | number>` in this example
|
|
81
88
|
})<{ time: `${number}:${number}:${number}` }>
|
|
82
89
|
|
|
83
|
-
const { useTranslations } = createTranslations({
|
|
84
|
-
intro,
|
|
85
|
-
time,
|
|
86
|
-
})
|
|
87
|
-
|
|
88
90
|
export const Page1 = () => {
|
|
89
|
-
const { t, language, setLanguage } =
|
|
91
|
+
const { t, language, setLanguage } = useT()
|
|
90
92
|
return (
|
|
91
93
|
<>
|
|
92
94
|
<select
|
|
@@ -109,7 +111,7 @@ export const Page1 = () => {
|
|
|
109
111
|
**page2.tsx**
|
|
110
112
|
|
|
111
113
|
```tsx
|
|
112
|
-
import {
|
|
114
|
+
import { useT, dict } from './utils'
|
|
113
115
|
|
|
114
116
|
const weather = dict({
|
|
115
117
|
en: 'The weather in {{city}} has {{humidity}}% humidity.',
|
|
@@ -132,14 +134,8 @@ const lastLogin = dict({
|
|
|
132
134
|
ms: 'Log masuk terakhir: {{date}} pada {{time}}',
|
|
133
135
|
})<{ date: `${number}-${number}-${number}`; time: `${number}:${number}` }>
|
|
134
136
|
|
|
135
|
-
const { useTranslations } = createTranslations({
|
|
136
|
-
weather,
|
|
137
|
-
score,
|
|
138
|
-
lastLogin,
|
|
139
|
-
})
|
|
140
|
-
|
|
141
137
|
export const Page2 = () => {
|
|
142
|
-
const { t, language, setLanguage } =
|
|
138
|
+
const { t, language, setLanguage } = useT()
|
|
143
139
|
return (
|
|
144
140
|
<>
|
|
145
141
|
<select
|
|
@@ -152,53 +148,18 @@ export const Page2 = () => {
|
|
|
152
148
|
<option value="ja">Japanese</option>
|
|
153
149
|
<option value="ms">Malay</option>
|
|
154
150
|
</select>
|
|
155
|
-
<p>{t(
|
|
156
|
-
<p>{t(
|
|
157
|
-
<p>{t(
|
|
151
|
+
<p>{t(weather, { city: 'Kuala Lumpur', humidity: 80 })}</p>
|
|
152
|
+
<p>{t(score, { score: 87, total: 100 })}</p>
|
|
153
|
+
<p>{t(lastLogin, { date: '2024-04-24', time: '09:30' })}</p>
|
|
158
154
|
</>
|
|
159
155
|
)
|
|
160
156
|
}
|
|
161
157
|
```
|
|
162
158
|
|
|
163
|
-
##
|
|
159
|
+
## API
|
|
164
160
|
|
|
165
161
|

|
|
166
162
|
|
|
167
|
-
### One `kotori` instance per app
|
|
168
|
-
|
|
169
|
-
`kotori` holds the language state. All `createTranslations` calls share that state — changing the language anywhere rerenders everywhere.
|
|
170
|
-
|
|
171
|
-
### One `createTranslations` per page/component/feature
|
|
172
|
-
|
|
173
|
-
Translations are colocated with the component that uses them. Bundlers naturally code-split them, so each page only loads what it needs.
|
|
174
|
-
|
|
175
|
-
### Variables are inferred from string literals
|
|
176
|
-
|
|
177
|
-
kotori parses `{{variable}}` at the type level. No separate type definitions needed — the string *is* the schema.
|
|
178
|
-
|
|
179
|
-
```ts
|
|
180
|
-
// primary string drives the contract
|
|
181
|
-
const greeting = dict({ en: 'Hi {{name}}', zh: '你好 {{name}}' })
|
|
182
|
-
// ^^^^^^ — inferred as required arg
|
|
183
|
-
|
|
184
|
-
// secondary strings are validated against it
|
|
185
|
-
const mismatch = dict({ en: 'Hi {{name}}', zh: '你好 {{other}}' })
|
|
186
|
-
// ^^^^^^^ — compile error
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
### Custom argument types
|
|
190
|
-
|
|
191
|
-
By default, variables are typed as `string | number`. Pass a generic to narrow them:
|
|
192
|
-
|
|
193
|
-
```ts
|
|
194
|
-
const time = dict({ en: '{{hour}}:{{minute}}' })<{
|
|
195
|
-
hour: number
|
|
196
|
-
minute: number
|
|
197
|
-
}>
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
## API
|
|
201
|
-
|
|
202
163
|
### `kotori(options)`
|
|
203
164
|
|
|
204
165
|
Creates a scoped i18n instance.
|
|
@@ -208,36 +169,50 @@ Creates a scoped i18n instance.
|
|
|
208
169
|
| `primaryLanguageTag` | `AllTags` | The source language. Drives variable inference. |
|
|
209
170
|
| `secondaryLanguageTags` | `AllTags[]` | Additional supported languages. |
|
|
210
171
|
|
|
211
|
-
Returns `{ dict,
|
|
172
|
+
Returns `{ dict, useT, setLanguage }`.
|
|
212
173
|
|
|
213
174
|
### `dict(translations)<argsType?>`
|
|
214
175
|
|
|
215
176
|
Defines a translation unit. Takes one string per language. Optionally takes a generic to narrow the interpolated variable types.
|
|
216
177
|
|
|
217
|
-
|
|
178
|
+
By default, variables are typed as `string | number`. Pass a generic to narrow them:
|
|
218
179
|
|
|
219
|
-
|
|
180
|
+
```ts
|
|
181
|
+
const time = dict({ en: '{{hour}}:{{minute}}' })<{
|
|
182
|
+
hour: number
|
|
183
|
+
minute: number
|
|
184
|
+
}>
|
|
185
|
+
```
|
|
220
186
|
|
|
221
187
|
### `setLanguage(tag)`
|
|
222
188
|
|
|
223
|
-
Updates the current language and rerenders all active `
|
|
189
|
+
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.).
|
|
224
190
|
|
|
225
|
-
### `
|
|
191
|
+
### `useT()`
|
|
226
192
|
|
|
227
193
|
React hook. Returns `{ t, language, setLanguage }`.
|
|
228
194
|
|
|
229
195
|
| return | type | description |
|
|
230
196
|
| --- | --- | --- |
|
|
231
|
-
| `t(
|
|
232
|
-
| `language` | `
|
|
233
|
-
| `setLanguage(tag)` | `void` | Updates the language and rerenders all active `
|
|
197
|
+
| `t(dict, args?)` | `string` | Returns the translated string for the current language. `args` is required if the string has variables, omitted if it doesn't. |
|
|
198
|
+
| `language` | `primaryLanguageTag` \| `secondaryLanguageTags` | The current language tag as a reactive value. Updates when `setLanguage` is called. |
|
|
199
|
+
| `setLanguage(tag)` | `void` | Updates the language and rerenders all active `useT` consumers. |
|
|
234
200
|
|
|
235
201
|
## Language Tags
|
|
236
202
|
|
|
237
203
|
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.
|
|
238
204
|
|
|
205
|
+
## Roadmap
|
|
206
|
+
|
|
207
|
+
- Auto detect locale from browser settings
|
|
208
|
+
- Auto persist language selection to localStorage
|
|
209
|
+
- Pluralization support
|
|
210
|
+
- Gender support
|
|
211
|
+
- Value formatting (date, number, currency)
|
|
212
|
+
|
|
239
213
|
## Trivial
|
|
240
214
|
|
|
241
215
|
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.
|
|
242
216
|
|
|
243
217
|
*Kotori* (小鳥) means "small bird" in Japanese. No deeper relevance to the library — it just sounds nice.
|
|
218
|
+
|
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 language = props.primaryLanguageTag;
|
|
7
7
|
const snapshots = /* @__PURE__ */ new Map();
|
|
8
8
|
const setLanguage = (tag) => {
|
|
9
|
-
|
|
9
|
+
language = tag;
|
|
10
10
|
snapshots.forEach((snapshot, key) => {
|
|
11
11
|
snapshots.set(key, {
|
|
12
12
|
...snapshot,
|
|
@@ -23,23 +23,19 @@ 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
|
+
};
|
|
26
35
|
return {
|
|
27
36
|
setLanguage,
|
|
28
37
|
dict: (translation) => () => ({ translation }),
|
|
29
|
-
|
|
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 { useTranslations: () => (0, react.useSyncExternalStore)(subscribe, () => snapshots.get(s), () => snapshot) };
|
|
42
|
-
}
|
|
38
|
+
useT: () => (0, react.useSyncExternalStore)(subscribe, () => snapshot, () => snapshot)
|
|
43
39
|
};
|
|
44
40
|
};
|
|
45
41
|
//#endregion
|
package/dist/index.d.cts
CHANGED
|
@@ -17,15 +17,13 @@ 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
|
-
|
|
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
|
-
};
|
|
20
|
+
useT: () => {
|
|
21
|
+
language: PrimaryTag | SecondaryTags;
|
|
22
|
+
setLanguage: (tag: PrimaryTag | SecondaryTags) => void;
|
|
23
|
+
t: <DictCallback extends () => Readonly<{
|
|
24
|
+
translation: Record<PrimaryTag | SecondaryTags, string>;
|
|
25
|
+
[_args]?: Record<string, string | number>;
|
|
26
|
+
}>>(dict: DictCallback, ...args: keyof NonNullable<ReturnType<DictCallback>[typeof _args]> extends never ? [] : [NonNullable<ReturnType<DictCallback>[typeof _args]>]) => string;
|
|
29
27
|
};
|
|
30
28
|
};
|
|
31
29
|
//#endregion
|
package/dist/index.d.mts
CHANGED
|
@@ -17,15 +17,13 @@ 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
|
-
|
|
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
|
-
};
|
|
20
|
+
useT: () => {
|
|
21
|
+
language: PrimaryTag | SecondaryTags;
|
|
22
|
+
setLanguage: (tag: PrimaryTag | SecondaryTags) => void;
|
|
23
|
+
t: <DictCallback extends () => Readonly<{
|
|
24
|
+
translation: Record<PrimaryTag | SecondaryTags, string>;
|
|
25
|
+
[_args]?: Record<string, string | number>;
|
|
26
|
+
}>>(dict: DictCallback, ...args: keyof NonNullable<ReturnType<DictCallback>[typeof _args]> extends never ? [] : [NonNullable<ReturnType<DictCallback>[typeof _args]>]) => string;
|
|
29
27
|
};
|
|
30
28
|
};
|
|
31
29
|
//#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 language = props.primaryLanguageTag;
|
|
6
6
|
const snapshots = /* @__PURE__ */ new Map();
|
|
7
7
|
const setLanguage = (tag) => {
|
|
8
|
-
|
|
8
|
+
language = tag;
|
|
9
9
|
snapshots.forEach((snapshot, key) => {
|
|
10
10
|
snapshots.set(key, {
|
|
11
11
|
...snapshot,
|
|
@@ -22,23 +22,19 @@ 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
|
+
};
|
|
25
34
|
return {
|
|
26
35
|
setLanguage,
|
|
27
36
|
dict: (translation) => () => ({ translation }),
|
|
28
|
-
|
|
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 { useTranslations: () => useSyncExternalStore(subscribe, () => snapshots.get(s), () => snapshot) };
|
|
41
|
-
}
|
|
37
|
+
useT: () => useSyncExternalStore(subscribe, () => snapshot, () => snapshot)
|
|
42
38
|
};
|
|
43
39
|
};
|
|
44
40
|
//#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": "0.
|
|
4
|
+
"version": "1.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",
|