kotori 1.0.2 → 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 CHANGED
@@ -14,26 +14,19 @@ const { dict } = kotori({
14
14
  const intro = dict({
15
15
  // ⭐ base string drives the type contract
16
16
  en: 'Hello {{name}}, is it {{time}} now?',
17
-
18
17
  // ❌ TypeScript error: missing key 'name'
19
18
  zh: '你好,现在是 {{time}} 吗?',
20
-
21
19
  // ❌ TypeScript error: unknown key 'nam'
22
20
  ms: 'Hai {{nam}}, adakah pukul {{time}} sekarang?'
23
-
24
21
  // optional: type your arguments, by default it's `Record<'name'|'time', string | number>` in this example
25
22
  })<{name: string; time: `${number}:${number}`}>
26
23
 
27
-
28
24
  // ✅ Works
29
25
  t('intro', { name: 'John', time: '12:25' })
30
-
31
26
  // ❌ TypeScript error: missing { name }
32
27
  t('intro', { time: '12:25' })
33
-
34
28
  // ❌ TypeScript error: unknown key 'nama'
35
29
  t('intro', { nama: 'John', time: '12:25' })
36
-
37
30
  // ❌ TypeScript error: invalid format for 'time'
38
31
  t('intro', { name: 'John', time: '12-00' })
39
32
  ```
@@ -42,11 +35,11 @@ t('intro', { name: 'John', time: '12-00' })
42
35
  - No JSON
43
36
  - No dependencies
44
37
  - No build step
45
- - 0.34kb gzipped
38
+ - 0.38kb gzipped
46
39
  - Modular and tree-shakeable
47
40
  - Language change in one page rerenders all pages
48
- - Variables typed and inferred from string literals — no more string typos
49
- - maximum type safety with minimum types
41
+ - Translation keys are typed — no more string typos
42
+ - Variables typed and inferred from string literals
50
43
 
51
44
  Demo: <https://stackblitz.com/edit/vitejs-vite-nyxwmhre?file=src%2FApp.tsx>
52
45
 
@@ -63,7 +56,7 @@ npm i kotori
63
56
  ```ts
64
57
  import { kotori } from './kotori'
65
58
 
66
- export const { useT, dict, setLanguage } = kotori({
59
+ export const { createT, dict, setLanguage } = kotori({
67
60
  primaryLanguageTag: 'en',
68
61
  secondaryLanguageTags: ['zh', 'ja', 'ms'],
69
62
  })
@@ -72,7 +65,7 @@ export const { useT, dict, setLanguage } = kotori({
72
65
  **page1.tsx**
73
66
 
74
67
  ```tsx
75
- import { useT, dict } from './utils'
68
+ import { createT, dict } from './utils'
76
69
 
77
70
  const intro = dict({
78
71
  en: 'my name is {{name}}, I am {{age}} years old.',
@@ -89,6 +82,11 @@ const time = dict({
89
82
  // optional: type your arguments, by default it's `Record<'time', string | number>` in this example
90
83
  })<{ time: `${number}:${number}:${number}` }>
91
84
 
85
+ const { useT } = createT({
86
+ intro,
87
+ time,
88
+ })
89
+
92
90
  export const Page1 = () => {
93
91
  const { t, language, setLanguage } = useT()
94
92
  return (
@@ -113,7 +111,7 @@ export const Page1 = () => {
113
111
  **page2.tsx**
114
112
 
115
113
  ```tsx
116
- import { useT, dict } from './utils'
114
+ import { createT, dict } from './utils'
117
115
 
118
116
  const weather = dict({
119
117
  en: 'The weather in {{city}} has {{humidity}}% humidity.',
@@ -136,6 +134,12 @@ const lastLogin = dict({
136
134
  ms: 'Log masuk terakhir: {{date}} pada {{time}}',
137
135
  })<{ date: `${number}-${number}-${number}`; time: `${number}:${number}` }>
138
136
 
137
+ const { useT } = createT({
138
+ weather,
139
+ score,
140
+ lastLogin,
141
+ })
142
+
139
143
  export const Page2 = () => {
140
144
  const { t, language, setLanguage } = useT()
141
145
  return (
@@ -150,14 +154,44 @@ export const Page2 = () => {
150
154
  <option value="ja">Japanese</option>
151
155
  <option value="ms">Malay</option>
152
156
  </select>
153
- <p>{t(weather, { city: 'Kuala Lumpur', humidity: 80 })}</p>
154
- <p>{t(score, { score: 87, total: 100 })}</p>
155
- <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>
156
160
  </>
157
161
  )
158
162
  }
159
163
  ```
160
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
+
161
195
  ## API
162
196
 
163
197
  ![how kotori works](image.webp)
@@ -171,11 +205,15 @@ Creates a scoped i18n instance.
171
205
  | `primaryLanguageTag` | `AllTags` | The source language. Drives variable inference. |
172
206
  | `secondaryLanguageTags` | `AllTags[]` | Additional supported languages. |
173
207
 
174
- Returns `{ dict, useT, setLanguage }`.
208
+ Returns `{ dict, createT, setLanguage }`.
175
209
 
176
210
  ### `dict(translations)<argsType?>`
177
211
 
178
- Defines a translation unit. Takes one string per language. Optionally takes a generic to narrow the interpolated variable types.
212
+ Defines a translation unit. Takes one string per language
213
+
214
+ ```ts
215
+ const time = dict({ en: '{{hour}}:{{minute}}' })
216
+ ```
179
217
 
180
218
  By default, variables are typed as `string | number`. Pass a generic to narrow them:
181
219
 
@@ -186,6 +224,10 @@ const time = dict({ en: '{{hour}}:{{minute}}' })<{
186
224
  }>
187
225
  ```
188
226
 
227
+ ### `createT(dicts)`
228
+
229
+ Registers a set of dicts and returns `{ useT }`. Call once per page or feature module.
230
+
189
231
  ### `setLanguage(tag)`
190
232
 
191
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.).
@@ -196,7 +238,7 @@ React hook. Returns `{ t, language, setLanguage }`.
196
238
 
197
239
  | return | type | description |
198
240
  | --- | --- | --- |
199
- | `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. |
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. |
200
242
  | `language` | `primaryLanguageTag` \| `secondaryLanguageTags` | The current language tag as a reactive value. Updates when `setLanguage` is called. |
201
243
  | `setLanguage(tag)` | `void` | Updates the language and rerenders all active `useT` consumers. |
202
244
 
@@ -204,17 +246,8 @@ React hook. Returns `{ t, language, setLanguage }`.
204
246
 
205
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.
206
248
 
207
- ## Roadmap
208
-
209
- - Auto detect locale from browser settings
210
- - Auto persist language selection to localStorage
211
- - Pluralization support
212
- - Gender support
213
- - Value formatting (date, number, currency)
214
-
215
249
  ## Trivial
216
250
 
217
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.
218
252
 
219
253
  *Kotori* (小鳥) means "small bird" in Japanese. No deeper relevance to the library — it just sounds nice.
220
-
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 language = props.primaryLanguageTag;
6
+ let languageTag = props.primaryLanguageTag;
7
7
  const snapshots = /* @__PURE__ */ new Map();
8
8
  const setLanguage = (tag) => {
9
- language = tag;
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
- useT: () => (0, react.useSyncExternalStore)(subscribe, () => snapshot, () => snapshot)
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
- 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;
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
- 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;
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 language = props.primaryLanguageTag;
5
+ let languageTag = props.primaryLanguageTag;
6
6
  const snapshots = /* @__PURE__ */ new Map();
7
7
  const setLanguage = (tag) => {
8
- language = tag;
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
- useT: () => useSyncExternalStore(subscribe, () => snapshot, () => snapshot)
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": "1.0.2",
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",