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

|
|
166
166
|
|
|
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
167
|
### `kotori(options)`
|
|
203
168
|
|
|
204
169
|
Creates a scoped i18n instance.
|
|
@@ -208,36 +173,50 @@ Creates a scoped i18n instance.
|
|
|
208
173
|
| `primaryLanguageTag` | `AllTags` | The source language. Drives variable inference. |
|
|
209
174
|
| `secondaryLanguageTags` | `AllTags[]` | Additional supported languages. |
|
|
210
175
|
|
|
211
|
-
Returns `{ dict,
|
|
176
|
+
Returns `{ dict, useT, setLanguage }`.
|
|
212
177
|
|
|
213
178
|
### `dict(translations)<argsType?>`
|
|
214
179
|
|
|
215
180
|
Defines a translation unit. Takes one string per language. Optionally takes a generic to narrow the interpolated variable types.
|
|
216
181
|
|
|
217
|
-
|
|
182
|
+
By default, variables are typed as `string | number`. Pass a generic to narrow them:
|
|
218
183
|
|
|
219
|
-
|
|
184
|
+
```ts
|
|
185
|
+
const time = dict({ en: '{{hour}}:{{minute}}' })<{
|
|
186
|
+
hour: number
|
|
187
|
+
minute: number
|
|
188
|
+
}>
|
|
189
|
+
```
|
|
220
190
|
|
|
221
191
|
### `setLanguage(tag)`
|
|
222
192
|
|
|
223
|
-
Updates the current language and rerenders all active `
|
|
193
|
+
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
194
|
|
|
225
|
-
### `
|
|
195
|
+
### `useT()`
|
|
226
196
|
|
|
227
197
|
React hook. Returns `{ t, language, setLanguage }`.
|
|
228
198
|
|
|
229
199
|
| return | type | description |
|
|
230
200
|
| --- | --- | --- |
|
|
231
|
-
| `t(
|
|
232
|
-
| `language` | `
|
|
233
|
-
| `setLanguage(tag)` | `void` | Updates the language and rerenders all active `
|
|
201
|
+
| `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. |
|
|
202
|
+
| `language` | `primaryLanguageTag` \| `secondaryLanguageTags` | The current language tag as a reactive value. Updates when `setLanguage` is called. |
|
|
203
|
+
| `setLanguage(tag)` | `void` | Updates the language and rerenders all active `useT` consumers. |
|
|
234
204
|
|
|
235
205
|
## Language Tags
|
|
236
206
|
|
|
237
207
|
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
208
|
|
|
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
|
+
|
|
239
217
|
## Trivial
|
|
240
218
|
|
|
241
219
|
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
220
|
|
|
243
221
|
*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 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.1
|
|
4
|
+
"version": "1.0.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",
|