kotori 0.1.6 → 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 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.38kb gzipped
43
+ - 0.34kb gzipped
37
44
  - Modular and tree-shakeable
38
45
  - 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
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 { createTranslations, dict, setLanguage } = kotori({
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 { createTranslations, dict } from './utils'
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 } = useTranslations()
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 { createTranslations, dict } from './utils'
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 } = useTranslations()
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('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>
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
- ## How It Works
159
+ ## API
164
160
 
165
161
  ![how kotori works](image.webp)
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, createTranslations, setLanguage }`.
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
- ### `createTranslations(dicts)`
178
+ By default, variables are typed as `string | number`. Pass a generic to narrow them:
218
179
 
219
- Registers a set of dicts and returns `{ useTranslations }`. Call once per page or feature module.
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 `useTranslations` consumers across all pages. Available directly on the `kotori` instance — useful for calling outside of React (route guards, axios interceptors, etc.).
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
- ### `useTranslations()`
191
+ ### `useT()`
226
192
 
227
193
  React hook. Returns `{ t, language, setLanguage }`.
228
194
 
229
195
  | return | type | description |
230
196
  | --- | --- | --- |
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. |
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 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,73 +1,73 @@
1
- {
2
- "name": "kotori",
3
- "description": "Strongly-typed and composable internationalization library for React",
4
- "version": "0.1.6",
5
- "scripts": {
6
- "setup": "rm -rf node_modules && npm i && git init && husky",
7
- "prepublishOnly": "npm i && npx tsc && npm run build",
8
- "build": "tsdown",
9
- "size": "tsdown --minify",
10
- "test": "vitest",
11
- "lint": "npx @biomejs/biome check --write",
12
- "dev": "vite --host"
13
- },
14
- "files": [
15
- "dist"
16
- ],
17
- "lint-staged": {
18
- "*": [
19
- "npm run lint"
20
- ]
21
- },
22
- "type": "module",
23
- "main": "./dist/index.cjs",
24
- "types": "./dist/index.d.cts",
25
- "exports": {
26
- ".": {
27
- "require": {
28
- "types": "./dist/index.d.cts",
29
- "default": "./dist/index.cjs"
30
- },
31
- "import": {
32
- "types": "./dist/index.d.mts",
33
- "default": "./dist/index.mjs"
34
- }
35
- }
36
- },
37
- "keywords": [
38
- "i18n",
39
- "internationalization",
40
- "react",
41
- "typescript",
42
- "strongly-typed"
43
- ],
44
- "devDependencies": {
45
- "@biomejs/biome": "^2.4.12",
46
- "@types/node": "^25.6.0",
47
- "@types/react": "^19.2.14",
48
- "@types/react-dom": "^19.2.3",
49
- "@vitejs/plugin-react": "^6.0.1",
50
- "@vitest/coverage-v8": "^4.1.5",
51
- "bcp47-language-tags": "^1.1.0",
52
- "husky": "^9.1.7",
53
- "lint-staged": "^16.4.0",
54
- "react": "^19.2.5",
55
- "react-dom": "^19.2.5",
56
- "tsdown": "^0.21.10",
57
- "tsx": "^4.21.0",
58
- "typescript": "^6.0.3",
59
- "vitest": "^4.1.5"
60
- },
61
- "repository": {
62
- "type": "git",
63
- "url": "git+https://github.com/tylim88/kotori.git"
64
- },
65
- "bugs": {
66
- "url": "https://github.com/tylim88/kotori/issues"
67
- },
68
- "author": "tylim88",
69
- "license": "MIT",
70
- "peerDependencies": {
71
- "react": ">=19.2.5"
72
- }
73
- }
1
+ {
2
+ "name": "kotori",
3
+ "description": "Strongly-typed and composable internationalization library for React",
4
+ "version": "1.0.0",
5
+ "scripts": {
6
+ "setup": "rm -rf node_modules && npm i && git init && husky",
7
+ "prepublishOnly": "npm i && npx tsc && npm run build",
8
+ "build": "tsdown",
9
+ "size": "tsdown --minify",
10
+ "test": "vitest",
11
+ "lint": "npx @biomejs/biome check --write",
12
+ "dev": "vite --host"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "lint-staged": {
18
+ "*": [
19
+ "npm run lint"
20
+ ]
21
+ },
22
+ "type": "module",
23
+ "main": "./dist/index.cjs",
24
+ "types": "./dist/index.d.cts",
25
+ "exports": {
26
+ ".": {
27
+ "require": {
28
+ "types": "./dist/index.d.cts",
29
+ "default": "./dist/index.cjs"
30
+ },
31
+ "import": {
32
+ "types": "./dist/index.d.mts",
33
+ "default": "./dist/index.mjs"
34
+ }
35
+ }
36
+ },
37
+ "keywords": [
38
+ "i18n",
39
+ "internationalization",
40
+ "react",
41
+ "typescript",
42
+ "strongly-typed"
43
+ ],
44
+ "devDependencies": {
45
+ "@biomejs/biome": "^2.4.12",
46
+ "@types/node": "^25.6.0",
47
+ "@types/react": "^19.2.14",
48
+ "@types/react-dom": "^19.2.3",
49
+ "@vitejs/plugin-react": "^6.0.1",
50
+ "@vitest/coverage-v8": "^4.1.5",
51
+ "bcp47-language-tags": "^1.1.0",
52
+ "husky": "^9.1.7",
53
+ "lint-staged": "^16.4.0",
54
+ "react": "^19.2.5",
55
+ "react-dom": "^19.2.5",
56
+ "tsdown": "^0.21.10",
57
+ "tsx": "^4.21.0",
58
+ "typescript": "^6.0.3",
59
+ "vitest": "^4.1.5"
60
+ },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/tylim88/kotori.git"
64
+ },
65
+ "bugs": {
66
+ "url": "https://github.com/tylim88/kotori/issues"
67
+ },
68
+ "author": "tylim88",
69
+ "license": "MIT",
70
+ "peerDependencies": {
71
+ "react": ">=19.2.5"
72
+ }
73
+ }