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 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.38kb gzipped
47
+ - 0.34kb gzipped
37
48
  - Modular and tree-shakeable
38
49
  - Language change in one page rerenders all pages
39
- - Translation keys are typed — no more string typos
40
- - Variables typed and inferred from string literals
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 { createTranslations, dict, setLanguage } = kotori({
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 { createTranslations, dict } from './utils'
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 } = useTranslations()
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 { createTranslations, dict } from './utils'
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 } = useTranslations()
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('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>
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
- ## How It Works
163
+ ## API
164
164
 
165
165
  ![how kotori works](image.webp)
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, createTranslations, setLanguage }`.
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
- ### `createTranslations(dicts)`
182
+ By default, variables are typed as `string | number`. Pass a generic to narrow them:
218
183
 
219
- Registers a set of dicts and returns `{ useTranslations }`. Call once per page or feature module.
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 `useTranslations` consumers across all pages. Available directly on the `kotori` instance — useful for calling outside of React (route guards, axios interceptors, etc.).
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
- ### `useTranslations()`
195
+ ### `useT()`
226
196
 
227
197
  React hook. Returns `{ t, language, setLanguage }`.
228
198
 
229
199
  | return | type | description |
230
200
  | --- | --- | --- |
231
- | `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. |
232
- | `language` | `WorkingTags` | The current language tag as a reactive value. Updates when `setLanguage` is called. |
233
- | `setLanguage(tag)` | `void` | Updates the language and rerenders all active `useTranslations` consumers. |
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 languageTag = props.primaryLanguageTag;
6
+ let language = props.primaryLanguageTag;
7
7
  const snapshots = /* @__PURE__ */ new Map();
8
8
  const setLanguage = (tag) => {
9
- languageTag = tag;
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
- createTranslations: (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 { 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
- createTranslations: <const DictCallbacks extends Record<string, () => Readonly<{
21
- translation: Record<PrimaryTag | SecondaryTags, string>;
22
- [_args]?: Record<string, string | number>;
23
- }>>>(dictCallbacks: DictCallbacks) => {
24
- useTranslations: () => {
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
- };
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
- createTranslations: <const DictCallbacks extends Record<string, () => Readonly<{
21
- translation: Record<PrimaryTag | SecondaryTags, string>;
22
- [_args]?: Record<string, string | number>;
23
- }>>>(dictCallbacks: DictCallbacks) => {
24
- useTranslations: () => {
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
- };
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 languageTag = props.primaryLanguageTag;
5
+ let language = props.primaryLanguageTag;
6
6
  const snapshots = /* @__PURE__ */ new Map();
7
7
  const setLanguage = (tag) => {
8
- languageTag = tag;
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
- createTranslations: (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 { 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.7",
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",