kotori 6.0.0 → 6.1.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
@@ -11,7 +11,7 @@
11
11
  </p>
12
12
 
13
13
  <p align="center">
14
- 🕊️ Kotori is a zero-config, fully type-safe, and modular internationalization library for React that compiles down to just 0.28kB. No JSON, no external CLI tools, no codegen—just live type inference from your strings.
14
+ 🕊️ Kotori is a zero-config, fully type-safe, and modular internationalization library for React that compiles down to just 0.29kB. No JSON, no external CLI tools, no codegen—just live type inference from your strings.
15
15
  </p>
16
16
 
17
17
  ```ts
@@ -55,13 +55,13 @@ const Component = () => {
55
55
  - No JSON
56
56
  - No dependencies
57
57
  - No build step
58
- - 0.28kB minified and gzipped
58
+ - 0.29kB minified and gzipped
59
59
  - Modular and tree-shakeable
60
60
  - Language change in one page rerenders all pages
61
61
  - Variables typed and inferred from string literals — no more string typos
62
- - maximum type safety with minimum types
62
+ - Maximum type safety with minimum types
63
63
 
64
- Demo: <https://stackblitz.com/edit/vitejs-vite-nyxwmhre?file=src%2FApp.tsx>
64
+ Demo: <https://stackblitz.com/edit/kotori?file=src%2FApp.tsx>
65
65
 
66
66
  ## Installation
67
67
 
@@ -163,7 +163,7 @@ export const Page2 = () => {
163
163
 
164
164
  ![how kotori works](image.webp)
165
165
 
166
- ### `kotori(options)`
166
+ ### `kotori(options)` (0.29kB)
167
167
 
168
168
  Creates a scoped i18n instance.
169
169
 
@@ -178,14 +178,14 @@ export const { useT, d, setLanguage } = kotori({
178
178
 
179
179
  | option | type | description |
180
180
  | --- | --- | --- |
181
- | `primary` | `BCP47LanguageTag` | The source language. Drives variable inference. |
182
- | `secondaries` | `BCP47LanguageTag[]` | Additional supported languages. |
181
+ | `primary` | `BCP47LanguageTagWithSubtag` | The source language. Drives variable inference. |
182
+ | `secondaries` | `Exclude<BCP47LanguageTagWithSubtag, primary>[]` | Additional supported languages. Cannot include the primary language. |
183
183
 
184
184
  Returns `{ d, useT, setLanguage, r }`.
185
185
 
186
186
  ### `d(translations)<argsType?>`
187
187
 
188
- Defines a translation unit. Takes one string per language. Return `dictionary` object.
188
+ Defines a translation unit. Takes one string per language. Returns a `dictionary` object.
189
189
 
190
190
  ```ts
191
191
  const time = d({ en: '{{hour}}:{{minute}}' })
@@ -215,7 +215,7 @@ Returns the translated string for the current language. `args` is required if th
215
215
  ⚠️ Do not call this inside React components (it will break React Compiler optimization rules). Use this exclusively in raw JS/TS environments like router guards, API interceptors, or state utilities.
216
216
 
217
217
  ```tsx
218
- r(intro, { name: 'John', age: 30 })}
218
+ r(intro, { name: 'John', age: 30 })
219
219
  ```
220
220
 
221
221
  ### `useT()`
@@ -233,22 +233,69 @@ Returns the translated string for the current language. `args` is required if th
233
233
  React version of `r(dictionary, args?)`, works with React Compiler.
234
234
 
235
235
  ```tsx
236
- <p>{t(intro, { name: 'John', age: 30 })}</p>
236
+ const Intro = () => {
237
+ const { t } = useT()
238
+
239
+ return (
240
+ <p>{t(intro, { name: 'John', age: 30 })}</p>
241
+ )
242
+ }
243
+ ```
244
+
245
+ ### `detectLanguage(instance, options?)` (0.12kB with Kotori or 0.2kB standalone)
246
+
247
+ Detects the user's preferred language from browser settings and sets it on the kotori instance. Iterates through the user's full language preference list in order, stopping at the first match.
248
+
249
+ ```ts
250
+ import { detectLanguage } from 'kotori'
251
+ import { i18n } from './locales'
252
+
253
+ detectLanguage(i18n)
254
+ ```
255
+
256
+ | option | type | default | description |
257
+ | --- | --- | --- | --- |
258
+ | `fallbackToSubtag` | `boolean` | `true` | If no exact match is found (e.g. `'zh-CN'`), fall back to the subtag (e.g. `'zh'`). |
259
+
260
+ ```ts
261
+ // browser reports: ['zh-CN', 'en-US']
262
+ // declared languages: ['en', 'zh', 'ja', 'ms']
263
+
264
+ detectLanguage(i18n)
265
+ // 'zh-CN' → no exact match → subtag 'zh' → match → setLanguage('zh') ✅
266
+
267
+ detectLanguage(i18n, { fallbackToSubtag: false })
268
+ // 'zh-CN' → no exact match → 'en-US' → no exact match → no-op, stays 'en'
269
+ ```
270
+
271
+ `detectLanguage` also works with non-kotori instances. Any object with a `setLanguage` callback and a `config` object satisfies the interface:
272
+
273
+ ```ts
274
+ detectLanguage({
275
+ setLanguage: (lang: 'en' | 'zh') => mySetLang(lang),
276
+ config: { primary: 'en', secondaries: ['zh'] },
277
+ })
237
278
  ```
238
279
 
239
280
  ## Language Tags
240
281
 
241
- 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-CN`) are accepted and validated at the type level.
282
+ kotori uses [BCP 47](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry) language tags. The type `BCP47LanguageTagWithSubtag` accepts both subtags (`en`, `zh`) and full tags (`en-US`, `zh-CN`), and is validated at the type level.
283
+
284
+ ```ts
285
+ kotori({ primary: 'en', secondaries: ['zh', 'ms-MY'] }) // ✅
286
+ kotori({ primary: 'klingon', secondaries: ['zh'] }) // ❌ compile error
287
+ ```
242
288
 
243
289
  ## Tips
244
290
 
245
291
  - If you plan to add new languages frequently, consider colocating all your dicts in a single file or multiple files in one folder. It is easier to copy the entire file and hand it to an AI to translate.
246
- - If your supported languages are fixed, consider splitting dicts by page or component. Translations stay close to the code that uses them and are easier to maintain.
292
+ - If your supported languages are fixed, consider splitting dicts by page or component. Translations stay close to the code that uses them and are easier to maintain. This approach also pairs well with TypeScript — every time you add a new language, type errors will guide you to every dict that needs updating.
247
293
  - Both approaches are tree-shakeable — only the dicts imported by the current page are included in its bundle.
294
+ - The `primary` language is the source of truth for variable inference and validation. Write your primary language strings carefully — a variable rename in the primary string becomes a compile error across every secondary language, which is intentional.
248
295
 
249
296
  ## Roadmap
250
297
 
251
- - Auto detect locale from browser settings
298
+ - Auto detect locale from browser settings
252
299
  - Auto persist language selection to localStorage
253
300
  - Pluralization support
254
301
  - Gender support
@@ -258,4 +305,4 @@ kotori uses [BCP 47](https://www.iana.org/assignments/language-subtag-registry/l
258
305
 
259
306
  There are already many 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.
260
307
 
261
- *Kotori* (小鳥) means "small bird" in Japanese. No deeper relevance to the library — it just sounds nice.
308
+ *Kotori* (小鳥) means "small bird" in Japanese. No deeper relevance to the library — it just sounds nice.
package/dist/index.cjs CHANGED
@@ -1,13 +1,145 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let react = require("react");
3
- //#region src/index.ts
4
- const kotori = (props) => {
3
+ //#region src/detectLanguage.ts
4
+ /**
5
+ * Detects the user's preferred language from browser settings and sets it
6
+ * on the kotori instance. Iterates through the user's full language preference
7
+ * list in order, stopping at the first match.
8
+ *
9
+ * @param instance - An object with `setLanguage` and `config`. Compatible with
10
+ * the kotori instance returned by `kotori()`, or any custom object that satisfies
11
+ * the same shape — allowing use with non-kotori setups.
12
+ * @param instance.setLanguage - `(language: BCP47LanguageTagNameWithSubTag) => void` — Called with the
13
+ * first matched language. Can be any callback — a kotori `setLanguage`, a React
14
+ * state setter, a Zustand action, etc.
15
+ * @param instance.config - Language configuration.
16
+ * @param instance.config.primary - `BCP47LanguageTagNameWithSubTag` — The primary/fallback language tag.
17
+ * @param instance.config.secondaries - `Exclude<AllTags, primary>[]` — Additional supported language tags. Cannot include the primary language.
18
+ * @param options - Detection options.
19
+ * @param options.fallbackToSubtag - `boolean` — If no exact match is found (e.g. `'zh-CN'`),
20
+ * fall back to the subtag (e.g. `'zh'`). Defaults to `true`.
21
+ * @returns `void` — Calls `setLanguage` on the first match. No-op if no match is found.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * // with kotori instance
26
+ * import { kotori, detectLanguage } from 'kotori'
27
+ *
28
+ * const i18n = kotori({
29
+ * primary: 'en',
30
+ * secondaries: ['zh', 'ja', 'ms'],
31
+ * })
32
+ *
33
+ * detectLanguage(i18n)
34
+ * // browser: ['zh-CN', 'en-US'] → subtag 'zh' → setLanguage('zh') ✅
35
+ *
36
+ * detectLanguage(i18n, { fallbackToSubtag: false })
37
+ * // browser: ['zh-CN'] → no exact match → no-op, stays 'en'
38
+ * ```
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * // ✅ setLanguage type exactly matches primary + secondaries union
43
+ * detectLanguage({
44
+ * setLanguage: (lang: 'en' | 'zh' | 'ja') => setLang(lang),
45
+ * config: {
46
+ * primary: 'en',
47
+ * secondaries: ['zh', 'ja'],
48
+ * },
49
+ * })
50
+ *
51
+ * // ❌ compile error — setLanguage accepts wider type than declared
52
+ * detectLanguage({
53
+ * setLanguage: (lang: string) => setLang(lang),
54
+ * config: { primary: 'en', secondaries: ['zh'] },
55
+ * })
56
+ * ```
57
+ */
58
+ const detectLanguage = (instance, options = { fallbackToSubtag: true }) => {
59
+ const languages = [instance.config.primary, ...instance.config.secondaries];
60
+ for (const browserLang of navigator.languages) {
61
+ if (languages.includes(browserLang)) return instance.setLanguage(browserLang);
62
+ if (options.fallbackToSubtag) {
63
+ const subtag = browserLang.split("-")[0];
64
+ if (languages.includes(subtag)) return instance.setLanguage(subtag);
65
+ }
66
+ }
67
+ };
68
+ //#endregion
69
+ //#region src/kotori.ts
70
+ /**
71
+ * Creates a scoped i18n instance for your app.
72
+ * Call once and export the returned utilities.
73
+ *
74
+ * @param config - Configuration options.
75
+ * @param config.primary - `BCP47LanguageTag` — The source language. Variable inference and
76
+ * secondary string validation are both driven by this language's strings.
77
+ * @param config.secondaries - `Exclude<BCP47LanguageTag, primary>[]` — Additional supported languages.
78
+ * Cannot include the primary language.
79
+ * @returns `{ d, r, useT, setLanguage, config }`
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * // locales.ts
84
+ * import { kotori } from 'kotori'
85
+ *
86
+ * export const { d, r, useT, setLanguage } = kotori({
87
+ * primary: 'en',
88
+ * secondaries: ['zh', 'ja', 'ms'],
89
+ * })
90
+ * ```
91
+ */
92
+ const kotori = (config) => {
5
93
  const listeners = /* @__PURE__ */ new Set();
94
+ /**
95
+ * Translates a dict to the current language and interpolates variables.
96
+ *
97
+ * @param dictionary - A dict created by `d()`.
98
+ * @param args - `Record<string, string | number>` — Variable values to interpolate. Required if the dict has variables, omitted if it doesn't.
99
+ * @returns `string` — The translated and interpolated string.
100
+ *
101
+ * @example
102
+ * const Intro = () => {
103
+ * const { t } = useT()
104
+ *
105
+ * return (
106
+ * <p>{t(greeting)}</p> // ✅ no args needed
107
+ * <p>{t(intro, { name: 'John', age: 30 })}</p> // ✅ typed args
108
+ * <p>{t(intro)}</p> // ❌ compile error: missing args
109
+ * <p>{t(intro, { name: 'John', x: 30 })}</p> // ❌ compile error: unknown key 'x'
110
+ * )
111
+ * }
112
+ */
6
113
  const t = (dictionary, ...args) => (dictionary().d[snapshot.language] || "").replace(/\{\{\s*([\w-]+)\s*\}\}/g, (_, key) => String(args[0]?.[key]));
7
114
  let snapshot = {
8
- language: props.primary,
115
+ language: config.primary,
9
116
  t
10
117
  };
118
+ /**
119
+ * Updates the current language and triggers a rerender in all active
120
+ * `useT()` consumers across all pages. Safe to call outside React.
121
+ *
122
+ * @param language - `Language` — Must be one of the tags declared in
123
+ * `primary` or `secondaries`. Any other value is a compile error.
124
+ * @returns `void`
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * import { setLanguage } from './locales'
129
+ *
130
+ * setLanguage('zh') // ✅
131
+ * setLanguage('ms') // ✅
132
+ * setLanguage('de') // ❌ compile error: 'de' was not declared
133
+ *
134
+ * // persist and restore language selection
135
+ * setLanguage('zh')
136
+ * localStorage.setItem('lang', 'zh')
137
+ *
138
+ * // restore on app startup
139
+ * const saved = localStorage.getItem('lang')
140
+ * if (saved) setLanguage(saved as 'en')
141
+ * ```
142
+ */
11
143
  const setLanguage = (language) => {
12
144
  snapshot = {
13
145
  language,
@@ -24,11 +156,130 @@ const kotori = (props) => {
24
156
  };
25
157
  };
26
158
  return {
159
+ config,
27
160
  setLanguage,
161
+ /**
162
+ * Translates a dict to the current language and interpolates variables.
163
+ * For use **outside** React components — route guards, axios interceptors,
164
+ * utility functions, etc.
165
+ *
166
+ * ⚠️ Do not use inside React components. The React Compiler will memoize
167
+ * the result permanently since `r` is a stable module-level reference,
168
+ * causing stale translations after a language change. Use `t` from `useT()`
169
+ * inside components instead.
170
+ *
171
+ * @param dictionary - A dict created by `d()`.
172
+ * @param args - `Record<string, string | number>` — Variable values to interpolate.
173
+ * Required if the dict has variables, omitted if it doesn't.
174
+ * @returns `string` — The translated and interpolated string in the current language.
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * import { r, greeting, intro } from './locales'
179
+ *
180
+ * r(greeting) // ✅ no args needed
181
+ * r(intro, { name: 'John', age: 30 }) // ✅ typed args
182
+ * r(intro) // ❌ compile error: missing args
183
+ * r(intro, { name: 'John', x: 1 }) // ❌ compile error: unknown key 'x'
184
+ *
185
+ * // route guard
186
+ * router.beforeEach(() => {
187
+ * document.title = r(pageTitle)
188
+ * })
189
+ *
190
+ * // axios interceptor
191
+ * axios.interceptors.response.use(null, () => {
192
+ * toast.error(r(errorMessage))
193
+ * })
194
+ * ```
195
+ */
28
196
  r: t,
197
+ /**
198
+ * Defines a translation unit. The primary language string drives variable
199
+ * inference — all secondary strings are validated against it at compile time.
200
+ *
201
+ * Call with an optional generic to narrow variable types beyond the default
202
+ * `string | number`. Supports TypeScript template literal types.
203
+ *
204
+ * @param dictionary - `Record<Language, string>` — One string per language.
205
+ * Variables are declared with `{{variableName}}` syntax. Spaces inside braces
206
+ * are trimmed — `{{ name }}` and `{{name}}` are equivalent.
207
+ * Secondary strings must use exactly the same variable names as the primary.
208
+ * @returns A function optionally accepting a generic to narrow variable types,
209
+ * which returns the translation unit.
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * // no variables
214
+ * const greeting = d({ en: 'Hello', zh: '你好', ja: 'こんにちは', ms: 'Helo' })()
215
+ *
216
+ * // variables — inferred as Record<'name' | 'age', string | number> by default
217
+ * const intro = d({
218
+ * en: 'My name is {{name}}, I am {{age}} years old.',
219
+ * zh: '我叫{{name}},我今年{{age}}岁了。',
220
+ * ja: '私の名前は{{name}}で、{{age}}歳です。',
221
+ * ms: 'Nama saya {{name}}, saya berumur {{age}} tahun.',
222
+ * })()
223
+ *
224
+ * // narrowed variable types via generic
225
+ * const clock = d({
226
+ * en: 'Current time: {{hour}}:{{minute}}',
227
+ * zh: '现在时间:{{hour}}:{{minute}}',
228
+ * ja: '現在時刻:{{hour}}:{{minute}}',
229
+ * ms: 'Masa semasa: {{hour}}:{{minute}}',
230
+ * })<{ hour: number; minute: number }>()
231
+ *
232
+ * // TypeScript template literal types work too
233
+ * const lastLogin = d({
234
+ * en: 'Last login: {{date}}',
235
+ * zh: '上次登录:{{date}}',
236
+ * ja: '最終ログイン:{{date}}',
237
+ * ms: 'Log masuk terakhir: {{date}}',
238
+ * })<{ date: `${number}-${number}-${number}` }>()
239
+ *
240
+ * // ❌ compile error — 'ja' translation missing
241
+ * const bad1 = d({ en: 'Hello', zh: '你好', ms: 'Helo' })()
242
+ *
243
+ * // ❌ compile error — secondary string has wrong variable name
244
+ * const bad2 = d({ en: 'Hello {{name}}', zh: '你好 {{naam}}', ja: 'こんにちは {{name}}', ms: 'Helo {{name}}' })()
245
+ *
246
+ * // ❌ compile error — secondary string missing a variable
247
+ * const bad3 = d({ en: '{{x}} {{y}}', zh: '{{x}}', ja: '{{x}} {{y}}', ms: '{{x}} {{y}}' })()
248
+ * ```
249
+ */
29
250
  d: (dictionary) => () => ({ d: dictionary }),
251
+ /**
252
+ * React hook. Subscribes the component to language changes.
253
+ * Returns a snapshot containing the current `language` and a
254
+ * React Compiler-safe `t` function.
255
+ *
256
+ * Call in every component that renders translated strings.
257
+ * When `setLanguage` is called anywhere, all `useT()` consumers rerender.
258
+ *
259
+ * @returns `{ language: Language, t: TranslateFunction }`
260
+ * @returns `language` - `Language` — The current active language tag as a reactive value.
261
+ * @returns `t` - `TranslateFunction` — React Compiler-safe translate function. Use this inside components instead of the instance-level `t`.
262
+ *
263
+ * @example
264
+ * ```tsx
265
+ * import { useT, intro, time } from './locales'
266
+ *
267
+ * const Page = () => {
268
+ * const { t, language } = useT()
269
+ *
270
+ * return (
271
+ * <>
272
+ * <p>{t(intro, { name: 'John', age: 30 })}</p>
273
+ * <p>{t(time, { hour: 12, minute: 0 })}</p>
274
+ * <p>Current language: {language}</p>
275
+ * </>
276
+ * )
277
+ * }
278
+ * ```
279
+ */
30
280
  useT: () => (0, react.useSyncExternalStore)(subscribe, () => snapshot, () => snapshot)
31
281
  };
32
282
  };
33
283
  //#endregion
284
+ exports.detectLanguage = detectLanguage;
34
285
  exports.kotori = kotori;
package/dist/index.d.cts CHANGED
@@ -1,26 +1,169 @@
1
1
  //#region node_modules/bcp47-language-tags/dist/zh.d.ts
2
2
  type BCP47LanguageTagName = "zh-CN" | "zh-TW" | "zh-HK" | "zh-MO" | "zh-SG" | "zh-CHS" | "zh-CHT" | "en-US" | "en-GB" | "en-CA" | "en-AU" | "en-IN" | "en-ZA" | "en-NZ" | "en-IE" | "en-PH" | "en-ZW" | "en-BZ" | "en-CB" | "en-JM" | "en-TT" | "hi-IN" | "es-ES" | "es-MX" | "es-AR" | "es-CO" | "es-PE" | "es-VE" | "es-CL" | "es-EC" | "es-GT" | "es-CU" | "es-BO" | "es-DO" | "es-HN" | "es-PY" | "es-SV" | "es-NI" | "es-PR" | "es-UY" | "es-PA" | "es-CR" | "ar-EG" | "ar-SA" | "ar-DZ" | "ar-MA" | "ar-IQ" | "ar-SD" | "ar-YE" | "ar-SY" | "ar-TN" | "ar-LY" | "ar-JO" | "ar-LB" | "ar-KW" | "ar-AE" | "ar-BH" | "ar-QA" | "ar-OM" | "pt-BR" | "pt-PT" | "ru-RU" | "ja-JP" | "de-DE" | "de-AT" | "de-CH" | "fr-FR" | "fr-CA" | "fr-BE" | "fr-CH" | "fr-LU" | "fr-MC" | "ko-KR" | "it-IT" | "it-CH" | "tr-TR" | "th-TH" | "el-GR" | "cs-CZ" | "sv-SE" | "sv-FI" | "hu-HU" | "fi-FI" | "da-DK" | "nb-NO" | "nn-NO" | "he-IL" | "id-ID" | "ms-MY" | "ms-BN" | "ro-RO" | "bg-BG" | "uk-UA" | "sk-SK" | "sl-SI" | "hr-HR" | "ca-ES" | "lt-LT" | "lv-LV" | "et-EE" | "sq-AL" | "mk-MK" | "be-BY" | "is-IS" | "gl-ES" | "eu-ES" | "af-ZA" | "sw-KE" | "ta-IN" | "te-IN" | "kn-IN" | "mr-IN" | "gu-IN" | "pa-IN" | "kok-IN" | "sa-IN" | "ur-PK" | "fa-IR" | "syr-SY" | "div-MV" | "ka-GE";
3
3
  //#endregion
4
- //#region src/index.d.ts
4
+ //#region src/kotori.d.ts
5
5
  type Tags = BCP47LanguageTagName;
6
6
  type SubTags = BCP47LanguageTagName extends `${infer SubTag}-${string}` ? SubTag : never;
7
- type AllTags = Tags | SubTags;
7
+ type BCP47LanguageTagNameWithSubTag = Tags | SubTags;
8
8
  type Trim<T extends string> = T extends ` ${infer R}` ? Trim<R> : T extends `${infer L} ` ? Trim<L> : T;
9
9
  type ExtractVariables<T extends string> = T extends `${string}{{${infer P}}}${infer Q}` ? Trim<P> | ExtractVariables<Q> : never;
10
10
  declare const _args: unique symbol;
11
- declare const kotori: <const Primary extends AllTags, const Secondary extends Exclude<AllTags, Primary>>(props: {
11
+ /**
12
+ * Creates a scoped i18n instance for your app.
13
+ * Call once and export the returned utilities.
14
+ *
15
+ * @param config - Configuration options.
16
+ * @param config.primary - `BCP47LanguageTag` — The source language. Variable inference and
17
+ * secondary string validation are both driven by this language's strings.
18
+ * @param config.secondaries - `Exclude<BCP47LanguageTag, primary>[]` — Additional supported languages.
19
+ * Cannot include the primary language.
20
+ * @returns `{ d, r, useT, setLanguage, config }`
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * // locales.ts
25
+ * import { kotori } from 'kotori'
26
+ *
27
+ * export const { d, r, useT, setLanguage } = kotori({
28
+ * primary: 'en',
29
+ * secondaries: ['zh', 'ja', 'ms'],
30
+ * })
31
+ * ```
32
+ */
33
+ declare const kotori: <const Primary extends BCP47LanguageTagNameWithSubTag, const Secondary extends Exclude<BCP47LanguageTagNameWithSubTag, Primary>>(config: {
12
34
  primary: Primary;
13
35
  secondaries: Secondary[];
14
36
  }) => {
37
+ config: {
38
+ primary: Primary;
39
+ secondaries: Secondary[];
40
+ };
15
41
  setLanguage: (language: Primary | Secondary) => void;
42
+ /**
43
+ * Translates a dict to the current language and interpolates variables.
44
+ * For use **outside** React components — route guards, axios interceptors,
45
+ * utility functions, etc.
46
+ *
47
+ * ⚠️ Do not use inside React components. The React Compiler will memoize
48
+ * the result permanently since `r` is a stable module-level reference,
49
+ * causing stale translations after a language change. Use `t` from `useT()`
50
+ * inside components instead.
51
+ *
52
+ * @param dictionary - A dict created by `d()`.
53
+ * @param args - `Record<string, string | number>` — Variable values to interpolate.
54
+ * Required if the dict has variables, omitted if it doesn't.
55
+ * @returns `string` — The translated and interpolated string in the current language.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * import { r, greeting, intro } from './locales'
60
+ *
61
+ * r(greeting) // ✅ no args needed
62
+ * r(intro, { name: 'John', age: 30 }) // ✅ typed args
63
+ * r(intro) // ❌ compile error: missing args
64
+ * r(intro, { name: 'John', x: 1 }) // ❌ compile error: unknown key 'x'
65
+ *
66
+ * // route guard
67
+ * router.beforeEach(() => {
68
+ * document.title = r(pageTitle)
69
+ * })
70
+ *
71
+ * // axios interceptor
72
+ * axios.interceptors.response.use(null, () => {
73
+ * toast.error(r(errorMessage))
74
+ * })
75
+ * ```
76
+ */
16
77
  r: <Dictionary extends () => Readonly<{
17
78
  d: Record<Primary | Secondary, string>;
18
79
  [_args]?: Record<string, string | number>;
19
80
  }>>(dictionary: Dictionary, ...args: keyof NonNullable<ReturnType<Dictionary>[typeof _args]> extends never ? [] : [NonNullable<ReturnType<Dictionary>[typeof _args]>]) => string;
81
+ /**
82
+ * Defines a translation unit. The primary language string drives variable
83
+ * inference — all secondary strings are validated against it at compile time.
84
+ *
85
+ * Call with an optional generic to narrow variable types beyond the default
86
+ * `string | number`. Supports TypeScript template literal types.
87
+ *
88
+ * @param dictionary - `Record<Language, string>` — One string per language.
89
+ * Variables are declared with `{{variableName}}` syntax. Spaces inside braces
90
+ * are trimmed — `{{ name }}` and `{{name}}` are equivalent.
91
+ * Secondary strings must use exactly the same variable names as the primary.
92
+ * @returns A function optionally accepting a generic to narrow variable types,
93
+ * which returns the translation unit.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * // no variables
98
+ * const greeting = d({ en: 'Hello', zh: '你好', ja: 'こんにちは', ms: 'Helo' })()
99
+ *
100
+ * // variables — inferred as Record<'name' | 'age', string | number> by default
101
+ * const intro = d({
102
+ * en: 'My name is {{name}}, I am {{age}} years old.',
103
+ * zh: '我叫{{name}},我今年{{age}}岁了。',
104
+ * ja: '私の名前は{{name}}で、{{age}}歳です。',
105
+ * ms: 'Nama saya {{name}}, saya berumur {{age}} tahun.',
106
+ * })()
107
+ *
108
+ * // narrowed variable types via generic
109
+ * const clock = d({
110
+ * en: 'Current time: {{hour}}:{{minute}}',
111
+ * zh: '现在时间:{{hour}}:{{minute}}',
112
+ * ja: '現在時刻:{{hour}}:{{minute}}',
113
+ * ms: 'Masa semasa: {{hour}}:{{minute}}',
114
+ * })<{ hour: number; minute: number }>()
115
+ *
116
+ * // TypeScript template literal types work too
117
+ * const lastLogin = d({
118
+ * en: 'Last login: {{date}}',
119
+ * zh: '上次登录:{{date}}',
120
+ * ja: '最終ログイン:{{date}}',
121
+ * ms: 'Log masuk terakhir: {{date}}',
122
+ * })<{ date: `${number}-${number}-${number}` }>()
123
+ *
124
+ * // ❌ compile error — 'ja' translation missing
125
+ * const bad1 = d({ en: 'Hello', zh: '你好', ms: 'Helo' })()
126
+ *
127
+ * // ❌ compile error — secondary string has wrong variable name
128
+ * const bad2 = d({ en: 'Hello {{name}}', zh: '你好 {{naam}}', ja: 'こんにちは {{name}}', ms: 'Helo {{name}}' })()
129
+ *
130
+ * // ❌ compile error — secondary string missing a variable
131
+ * const bad3 = d({ en: '{{x}} {{y}}', zh: '{{x}}', ja: '{{x}} {{y}}', ms: '{{x}} {{y}}' })()
132
+ * ```
133
+ */
20
134
  d: <const PrimaryString extends string, const SecondaryObject extends { [Key in Secondary]: ExtractVariables<PrimaryString> extends infer PrimaryVariables ? ExtractVariables<SecondaryObject[Key] & string> extends infer SecondaryVariables ? PrimaryVariables[] extends SecondaryVariables[] ? SecondaryVariables[] extends PrimaryVariables[] ? SecondaryObject[Key] : "variables not match!" : "variables not match!!" : never : never }>(dictionary: { [Key in Primary]: PrimaryString } & SecondaryObject) => <const ArgsType extends Record<ExtractVariables<PrimaryString>, string | number> = Record<ExtractVariables<PrimaryString>, string | number>>() => Readonly<{
21
135
  d: typeof dictionary;
22
136
  [_args]?: ArgsType;
23
137
  }>;
138
+ /**
139
+ * React hook. Subscribes the component to language changes.
140
+ * Returns a snapshot containing the current `language` and a
141
+ * React Compiler-safe `t` function.
142
+ *
143
+ * Call in every component that renders translated strings.
144
+ * When `setLanguage` is called anywhere, all `useT()` consumers rerender.
145
+ *
146
+ * @returns `{ language: Language, t: TranslateFunction }`
147
+ * @returns `language` - `Language` — The current active language tag as a reactive value.
148
+ * @returns `t` - `TranslateFunction` — React Compiler-safe translate function. Use this inside components instead of the instance-level `t`.
149
+ *
150
+ * @example
151
+ * ```tsx
152
+ * import { useT, intro, time } from './locales'
153
+ *
154
+ * const Page = () => {
155
+ * const { t, language } = useT()
156
+ *
157
+ * return (
158
+ * <>
159
+ * <p>{t(intro, { name: 'John', age: 30 })}</p>
160
+ * <p>{t(time, { hour: 12, minute: 0 })}</p>
161
+ * <p>Current language: {language}</p>
162
+ * </>
163
+ * )
164
+ * }
165
+ * ```
166
+ */
24
167
  useT: () => {
25
168
  language: Primary | Secondary;
26
169
  t: <Dictionary extends () => Readonly<{
@@ -30,4 +173,75 @@ declare const kotori: <const Primary extends AllTags, const Secondary extends Ex
30
173
  };
31
174
  };
32
175
  //#endregion
33
- export { AllTags, SubTags, Tags, kotori };
176
+ //#region src/detectLanguage.d.ts
177
+ /**
178
+ * Detects the user's preferred language from browser settings and sets it
179
+ * on the kotori instance. Iterates through the user's full language preference
180
+ * list in order, stopping at the first match.
181
+ *
182
+ * @param instance - An object with `setLanguage` and `config`. Compatible with
183
+ * the kotori instance returned by `kotori()`, or any custom object that satisfies
184
+ * the same shape — allowing use with non-kotori setups.
185
+ * @param instance.setLanguage - `(language: BCP47LanguageTagNameWithSubTag) => void` — Called with the
186
+ * first matched language. Can be any callback — a kotori `setLanguage`, a React
187
+ * state setter, a Zustand action, etc.
188
+ * @param instance.config - Language configuration.
189
+ * @param instance.config.primary - `BCP47LanguageTagNameWithSubTag` — The primary/fallback language tag.
190
+ * @param instance.config.secondaries - `Exclude<AllTags, primary>[]` — Additional supported language tags. Cannot include the primary language.
191
+ * @param options - Detection options.
192
+ * @param options.fallbackToSubtag - `boolean` — If no exact match is found (e.g. `'zh-CN'`),
193
+ * fall back to the subtag (e.g. `'zh'`). Defaults to `true`.
194
+ * @returns `void` — Calls `setLanguage` on the first match. No-op if no match is found.
195
+ *
196
+ * @example
197
+ * ```ts
198
+ * // with kotori instance
199
+ * import { kotori, detectLanguage } from 'kotori'
200
+ *
201
+ * const i18n = kotori({
202
+ * primary: 'en',
203
+ * secondaries: ['zh', 'ja', 'ms'],
204
+ * })
205
+ *
206
+ * detectLanguage(i18n)
207
+ * // browser: ['zh-CN', 'en-US'] → subtag 'zh' → setLanguage('zh') ✅
208
+ *
209
+ * detectLanguage(i18n, { fallbackToSubtag: false })
210
+ * // browser: ['zh-CN'] → no exact match → no-op, stays 'en'
211
+ * ```
212
+ *
213
+ * @example
214
+ * ```ts
215
+ * // ✅ setLanguage type exactly matches primary + secondaries union
216
+ * detectLanguage({
217
+ * setLanguage: (lang: 'en' | 'zh' | 'ja') => setLang(lang),
218
+ * config: {
219
+ * primary: 'en',
220
+ * secondaries: ['zh', 'ja'],
221
+ * },
222
+ * })
223
+ *
224
+ * // ❌ compile error — setLanguage accepts wider type than declared
225
+ * detectLanguage({
226
+ * setLanguage: (lang: string) => setLang(lang),
227
+ * config: { primary: 'en', secondaries: ['zh'] },
228
+ * })
229
+ * ```
230
+ */
231
+ declare const detectLanguage: <const T extends {
232
+ setLanguage: (language: Parameters<T["setLanguage"]>[0]) => void;
233
+ config: {
234
+ primary: BCP47LanguageTagNameWithSubTag;
235
+ secondaries: BCP47LanguageTagNameWithSubTag[];
236
+ };
237
+ }>(instance: T["config"]["primary"] | T["config"]["secondaries"][number] extends infer A ? {
238
+ setLanguage: Parameters<T["setLanguage"]>[0] extends infer L ? L[] extends A[] ? A[] extends L[] ? (language: L) => void : "language param does not match primary or secondaries language types" : A : never;
239
+ config: {
240
+ primary: T["config"]["primary"];
241
+ secondaries: Exclude<BCP47LanguageTagNameWithSubTag, T["config"]["primary"]>[];
242
+ };
243
+ } : T, options?: {
244
+ fallbackToSubtag?: boolean;
245
+ }) => void;
246
+ //#endregion
247
+ export { BCP47LanguageTagNameWithSubTag, SubTags, Tags, detectLanguage, kotori };
package/dist/index.d.mts CHANGED
@@ -1,26 +1,169 @@
1
1
  //#region node_modules/bcp47-language-tags/dist/zh.d.ts
2
2
  type BCP47LanguageTagName = "zh-CN" | "zh-TW" | "zh-HK" | "zh-MO" | "zh-SG" | "zh-CHS" | "zh-CHT" | "en-US" | "en-GB" | "en-CA" | "en-AU" | "en-IN" | "en-ZA" | "en-NZ" | "en-IE" | "en-PH" | "en-ZW" | "en-BZ" | "en-CB" | "en-JM" | "en-TT" | "hi-IN" | "es-ES" | "es-MX" | "es-AR" | "es-CO" | "es-PE" | "es-VE" | "es-CL" | "es-EC" | "es-GT" | "es-CU" | "es-BO" | "es-DO" | "es-HN" | "es-PY" | "es-SV" | "es-NI" | "es-PR" | "es-UY" | "es-PA" | "es-CR" | "ar-EG" | "ar-SA" | "ar-DZ" | "ar-MA" | "ar-IQ" | "ar-SD" | "ar-YE" | "ar-SY" | "ar-TN" | "ar-LY" | "ar-JO" | "ar-LB" | "ar-KW" | "ar-AE" | "ar-BH" | "ar-QA" | "ar-OM" | "pt-BR" | "pt-PT" | "ru-RU" | "ja-JP" | "de-DE" | "de-AT" | "de-CH" | "fr-FR" | "fr-CA" | "fr-BE" | "fr-CH" | "fr-LU" | "fr-MC" | "ko-KR" | "it-IT" | "it-CH" | "tr-TR" | "th-TH" | "el-GR" | "cs-CZ" | "sv-SE" | "sv-FI" | "hu-HU" | "fi-FI" | "da-DK" | "nb-NO" | "nn-NO" | "he-IL" | "id-ID" | "ms-MY" | "ms-BN" | "ro-RO" | "bg-BG" | "uk-UA" | "sk-SK" | "sl-SI" | "hr-HR" | "ca-ES" | "lt-LT" | "lv-LV" | "et-EE" | "sq-AL" | "mk-MK" | "be-BY" | "is-IS" | "gl-ES" | "eu-ES" | "af-ZA" | "sw-KE" | "ta-IN" | "te-IN" | "kn-IN" | "mr-IN" | "gu-IN" | "pa-IN" | "kok-IN" | "sa-IN" | "ur-PK" | "fa-IR" | "syr-SY" | "div-MV" | "ka-GE";
3
3
  //#endregion
4
- //#region src/index.d.ts
4
+ //#region src/kotori.d.ts
5
5
  type Tags = BCP47LanguageTagName;
6
6
  type SubTags = BCP47LanguageTagName extends `${infer SubTag}-${string}` ? SubTag : never;
7
- type AllTags = Tags | SubTags;
7
+ type BCP47LanguageTagNameWithSubTag = Tags | SubTags;
8
8
  type Trim<T extends string> = T extends ` ${infer R}` ? Trim<R> : T extends `${infer L} ` ? Trim<L> : T;
9
9
  type ExtractVariables<T extends string> = T extends `${string}{{${infer P}}}${infer Q}` ? Trim<P> | ExtractVariables<Q> : never;
10
10
  declare const _args: unique symbol;
11
- declare const kotori: <const Primary extends AllTags, const Secondary extends Exclude<AllTags, Primary>>(props: {
11
+ /**
12
+ * Creates a scoped i18n instance for your app.
13
+ * Call once and export the returned utilities.
14
+ *
15
+ * @param config - Configuration options.
16
+ * @param config.primary - `BCP47LanguageTag` — The source language. Variable inference and
17
+ * secondary string validation are both driven by this language's strings.
18
+ * @param config.secondaries - `Exclude<BCP47LanguageTag, primary>[]` — Additional supported languages.
19
+ * Cannot include the primary language.
20
+ * @returns `{ d, r, useT, setLanguage, config }`
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * // locales.ts
25
+ * import { kotori } from 'kotori'
26
+ *
27
+ * export const { d, r, useT, setLanguage } = kotori({
28
+ * primary: 'en',
29
+ * secondaries: ['zh', 'ja', 'ms'],
30
+ * })
31
+ * ```
32
+ */
33
+ declare const kotori: <const Primary extends BCP47LanguageTagNameWithSubTag, const Secondary extends Exclude<BCP47LanguageTagNameWithSubTag, Primary>>(config: {
12
34
  primary: Primary;
13
35
  secondaries: Secondary[];
14
36
  }) => {
37
+ config: {
38
+ primary: Primary;
39
+ secondaries: Secondary[];
40
+ };
15
41
  setLanguage: (language: Primary | Secondary) => void;
42
+ /**
43
+ * Translates a dict to the current language and interpolates variables.
44
+ * For use **outside** React components — route guards, axios interceptors,
45
+ * utility functions, etc.
46
+ *
47
+ * ⚠️ Do not use inside React components. The React Compiler will memoize
48
+ * the result permanently since `r` is a stable module-level reference,
49
+ * causing stale translations after a language change. Use `t` from `useT()`
50
+ * inside components instead.
51
+ *
52
+ * @param dictionary - A dict created by `d()`.
53
+ * @param args - `Record<string, string | number>` — Variable values to interpolate.
54
+ * Required if the dict has variables, omitted if it doesn't.
55
+ * @returns `string` — The translated and interpolated string in the current language.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * import { r, greeting, intro } from './locales'
60
+ *
61
+ * r(greeting) // ✅ no args needed
62
+ * r(intro, { name: 'John', age: 30 }) // ✅ typed args
63
+ * r(intro) // ❌ compile error: missing args
64
+ * r(intro, { name: 'John', x: 1 }) // ❌ compile error: unknown key 'x'
65
+ *
66
+ * // route guard
67
+ * router.beforeEach(() => {
68
+ * document.title = r(pageTitle)
69
+ * })
70
+ *
71
+ * // axios interceptor
72
+ * axios.interceptors.response.use(null, () => {
73
+ * toast.error(r(errorMessage))
74
+ * })
75
+ * ```
76
+ */
16
77
  r: <Dictionary extends () => Readonly<{
17
78
  d: Record<Primary | Secondary, string>;
18
79
  [_args]?: Record<string, string | number>;
19
80
  }>>(dictionary: Dictionary, ...args: keyof NonNullable<ReturnType<Dictionary>[typeof _args]> extends never ? [] : [NonNullable<ReturnType<Dictionary>[typeof _args]>]) => string;
81
+ /**
82
+ * Defines a translation unit. The primary language string drives variable
83
+ * inference — all secondary strings are validated against it at compile time.
84
+ *
85
+ * Call with an optional generic to narrow variable types beyond the default
86
+ * `string | number`. Supports TypeScript template literal types.
87
+ *
88
+ * @param dictionary - `Record<Language, string>` — One string per language.
89
+ * Variables are declared with `{{variableName}}` syntax. Spaces inside braces
90
+ * are trimmed — `{{ name }}` and `{{name}}` are equivalent.
91
+ * Secondary strings must use exactly the same variable names as the primary.
92
+ * @returns A function optionally accepting a generic to narrow variable types,
93
+ * which returns the translation unit.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * // no variables
98
+ * const greeting = d({ en: 'Hello', zh: '你好', ja: 'こんにちは', ms: 'Helo' })()
99
+ *
100
+ * // variables — inferred as Record<'name' | 'age', string | number> by default
101
+ * const intro = d({
102
+ * en: 'My name is {{name}}, I am {{age}} years old.',
103
+ * zh: '我叫{{name}},我今年{{age}}岁了。',
104
+ * ja: '私の名前は{{name}}で、{{age}}歳です。',
105
+ * ms: 'Nama saya {{name}}, saya berumur {{age}} tahun.',
106
+ * })()
107
+ *
108
+ * // narrowed variable types via generic
109
+ * const clock = d({
110
+ * en: 'Current time: {{hour}}:{{minute}}',
111
+ * zh: '现在时间:{{hour}}:{{minute}}',
112
+ * ja: '現在時刻:{{hour}}:{{minute}}',
113
+ * ms: 'Masa semasa: {{hour}}:{{minute}}',
114
+ * })<{ hour: number; minute: number }>()
115
+ *
116
+ * // TypeScript template literal types work too
117
+ * const lastLogin = d({
118
+ * en: 'Last login: {{date}}',
119
+ * zh: '上次登录:{{date}}',
120
+ * ja: '最終ログイン:{{date}}',
121
+ * ms: 'Log masuk terakhir: {{date}}',
122
+ * })<{ date: `${number}-${number}-${number}` }>()
123
+ *
124
+ * // ❌ compile error — 'ja' translation missing
125
+ * const bad1 = d({ en: 'Hello', zh: '你好', ms: 'Helo' })()
126
+ *
127
+ * // ❌ compile error — secondary string has wrong variable name
128
+ * const bad2 = d({ en: 'Hello {{name}}', zh: '你好 {{naam}}', ja: 'こんにちは {{name}}', ms: 'Helo {{name}}' })()
129
+ *
130
+ * // ❌ compile error — secondary string missing a variable
131
+ * const bad3 = d({ en: '{{x}} {{y}}', zh: '{{x}}', ja: '{{x}} {{y}}', ms: '{{x}} {{y}}' })()
132
+ * ```
133
+ */
20
134
  d: <const PrimaryString extends string, const SecondaryObject extends { [Key in Secondary]: ExtractVariables<PrimaryString> extends infer PrimaryVariables ? ExtractVariables<SecondaryObject[Key] & string> extends infer SecondaryVariables ? PrimaryVariables[] extends SecondaryVariables[] ? SecondaryVariables[] extends PrimaryVariables[] ? SecondaryObject[Key] : "variables not match!" : "variables not match!!" : never : never }>(dictionary: { [Key in Primary]: PrimaryString } & SecondaryObject) => <const ArgsType extends Record<ExtractVariables<PrimaryString>, string | number> = Record<ExtractVariables<PrimaryString>, string | number>>() => Readonly<{
21
135
  d: typeof dictionary;
22
136
  [_args]?: ArgsType;
23
137
  }>;
138
+ /**
139
+ * React hook. Subscribes the component to language changes.
140
+ * Returns a snapshot containing the current `language` and a
141
+ * React Compiler-safe `t` function.
142
+ *
143
+ * Call in every component that renders translated strings.
144
+ * When `setLanguage` is called anywhere, all `useT()` consumers rerender.
145
+ *
146
+ * @returns `{ language: Language, t: TranslateFunction }`
147
+ * @returns `language` - `Language` — The current active language tag as a reactive value.
148
+ * @returns `t` - `TranslateFunction` — React Compiler-safe translate function. Use this inside components instead of the instance-level `t`.
149
+ *
150
+ * @example
151
+ * ```tsx
152
+ * import { useT, intro, time } from './locales'
153
+ *
154
+ * const Page = () => {
155
+ * const { t, language } = useT()
156
+ *
157
+ * return (
158
+ * <>
159
+ * <p>{t(intro, { name: 'John', age: 30 })}</p>
160
+ * <p>{t(time, { hour: 12, minute: 0 })}</p>
161
+ * <p>Current language: {language}</p>
162
+ * </>
163
+ * )
164
+ * }
165
+ * ```
166
+ */
24
167
  useT: () => {
25
168
  language: Primary | Secondary;
26
169
  t: <Dictionary extends () => Readonly<{
@@ -30,4 +173,75 @@ declare const kotori: <const Primary extends AllTags, const Secondary extends Ex
30
173
  };
31
174
  };
32
175
  //#endregion
33
- export { AllTags, SubTags, Tags, kotori };
176
+ //#region src/detectLanguage.d.ts
177
+ /**
178
+ * Detects the user's preferred language from browser settings and sets it
179
+ * on the kotori instance. Iterates through the user's full language preference
180
+ * list in order, stopping at the first match.
181
+ *
182
+ * @param instance - An object with `setLanguage` and `config`. Compatible with
183
+ * the kotori instance returned by `kotori()`, or any custom object that satisfies
184
+ * the same shape — allowing use with non-kotori setups.
185
+ * @param instance.setLanguage - `(language: BCP47LanguageTagNameWithSubTag) => void` — Called with the
186
+ * first matched language. Can be any callback — a kotori `setLanguage`, a React
187
+ * state setter, a Zustand action, etc.
188
+ * @param instance.config - Language configuration.
189
+ * @param instance.config.primary - `BCP47LanguageTagNameWithSubTag` — The primary/fallback language tag.
190
+ * @param instance.config.secondaries - `Exclude<AllTags, primary>[]` — Additional supported language tags. Cannot include the primary language.
191
+ * @param options - Detection options.
192
+ * @param options.fallbackToSubtag - `boolean` — If no exact match is found (e.g. `'zh-CN'`),
193
+ * fall back to the subtag (e.g. `'zh'`). Defaults to `true`.
194
+ * @returns `void` — Calls `setLanguage` on the first match. No-op if no match is found.
195
+ *
196
+ * @example
197
+ * ```ts
198
+ * // with kotori instance
199
+ * import { kotori, detectLanguage } from 'kotori'
200
+ *
201
+ * const i18n = kotori({
202
+ * primary: 'en',
203
+ * secondaries: ['zh', 'ja', 'ms'],
204
+ * })
205
+ *
206
+ * detectLanguage(i18n)
207
+ * // browser: ['zh-CN', 'en-US'] → subtag 'zh' → setLanguage('zh') ✅
208
+ *
209
+ * detectLanguage(i18n, { fallbackToSubtag: false })
210
+ * // browser: ['zh-CN'] → no exact match → no-op, stays 'en'
211
+ * ```
212
+ *
213
+ * @example
214
+ * ```ts
215
+ * // ✅ setLanguage type exactly matches primary + secondaries union
216
+ * detectLanguage({
217
+ * setLanguage: (lang: 'en' | 'zh' | 'ja') => setLang(lang),
218
+ * config: {
219
+ * primary: 'en',
220
+ * secondaries: ['zh', 'ja'],
221
+ * },
222
+ * })
223
+ *
224
+ * // ❌ compile error — setLanguage accepts wider type than declared
225
+ * detectLanguage({
226
+ * setLanguage: (lang: string) => setLang(lang),
227
+ * config: { primary: 'en', secondaries: ['zh'] },
228
+ * })
229
+ * ```
230
+ */
231
+ declare const detectLanguage: <const T extends {
232
+ setLanguage: (language: Parameters<T["setLanguage"]>[0]) => void;
233
+ config: {
234
+ primary: BCP47LanguageTagNameWithSubTag;
235
+ secondaries: BCP47LanguageTagNameWithSubTag[];
236
+ };
237
+ }>(instance: T["config"]["primary"] | T["config"]["secondaries"][number] extends infer A ? {
238
+ setLanguage: Parameters<T["setLanguage"]>[0] extends infer L ? L[] extends A[] ? A[] extends L[] ? (language: L) => void : "language param does not match primary or secondaries language types" : A : never;
239
+ config: {
240
+ primary: T["config"]["primary"];
241
+ secondaries: Exclude<BCP47LanguageTagNameWithSubTag, T["config"]["primary"]>[];
242
+ };
243
+ } : T, options?: {
244
+ fallbackToSubtag?: boolean;
245
+ }) => void;
246
+ //#endregion
247
+ export { BCP47LanguageTagNameWithSubTag, SubTags, Tags, detectLanguage, kotori };
package/dist/index.mjs CHANGED
@@ -1,12 +1,144 @@
1
1
  import { useSyncExternalStore } from "react";
2
- //#region src/index.ts
3
- const kotori = (props) => {
2
+ //#region src/detectLanguage.ts
3
+ /**
4
+ * Detects the user's preferred language from browser settings and sets it
5
+ * on the kotori instance. Iterates through the user's full language preference
6
+ * list in order, stopping at the first match.
7
+ *
8
+ * @param instance - An object with `setLanguage` and `config`. Compatible with
9
+ * the kotori instance returned by `kotori()`, or any custom object that satisfies
10
+ * the same shape — allowing use with non-kotori setups.
11
+ * @param instance.setLanguage - `(language: BCP47LanguageTagNameWithSubTag) => void` — Called with the
12
+ * first matched language. Can be any callback — a kotori `setLanguage`, a React
13
+ * state setter, a Zustand action, etc.
14
+ * @param instance.config - Language configuration.
15
+ * @param instance.config.primary - `BCP47LanguageTagNameWithSubTag` — The primary/fallback language tag.
16
+ * @param instance.config.secondaries - `Exclude<AllTags, primary>[]` — Additional supported language tags. Cannot include the primary language.
17
+ * @param options - Detection options.
18
+ * @param options.fallbackToSubtag - `boolean` — If no exact match is found (e.g. `'zh-CN'`),
19
+ * fall back to the subtag (e.g. `'zh'`). Defaults to `true`.
20
+ * @returns `void` — Calls `setLanguage` on the first match. No-op if no match is found.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * // with kotori instance
25
+ * import { kotori, detectLanguage } from 'kotori'
26
+ *
27
+ * const i18n = kotori({
28
+ * primary: 'en',
29
+ * secondaries: ['zh', 'ja', 'ms'],
30
+ * })
31
+ *
32
+ * detectLanguage(i18n)
33
+ * // browser: ['zh-CN', 'en-US'] → subtag 'zh' → setLanguage('zh') ✅
34
+ *
35
+ * detectLanguage(i18n, { fallbackToSubtag: false })
36
+ * // browser: ['zh-CN'] → no exact match → no-op, stays 'en'
37
+ * ```
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * // ✅ setLanguage type exactly matches primary + secondaries union
42
+ * detectLanguage({
43
+ * setLanguage: (lang: 'en' | 'zh' | 'ja') => setLang(lang),
44
+ * config: {
45
+ * primary: 'en',
46
+ * secondaries: ['zh', 'ja'],
47
+ * },
48
+ * })
49
+ *
50
+ * // ❌ compile error — setLanguage accepts wider type than declared
51
+ * detectLanguage({
52
+ * setLanguage: (lang: string) => setLang(lang),
53
+ * config: { primary: 'en', secondaries: ['zh'] },
54
+ * })
55
+ * ```
56
+ */
57
+ const detectLanguage = (instance, options = { fallbackToSubtag: true }) => {
58
+ const languages = [instance.config.primary, ...instance.config.secondaries];
59
+ for (const browserLang of navigator.languages) {
60
+ if (languages.includes(browserLang)) return instance.setLanguage(browserLang);
61
+ if (options.fallbackToSubtag) {
62
+ const subtag = browserLang.split("-")[0];
63
+ if (languages.includes(subtag)) return instance.setLanguage(subtag);
64
+ }
65
+ }
66
+ };
67
+ //#endregion
68
+ //#region src/kotori.ts
69
+ /**
70
+ * Creates a scoped i18n instance for your app.
71
+ * Call once and export the returned utilities.
72
+ *
73
+ * @param config - Configuration options.
74
+ * @param config.primary - `BCP47LanguageTag` — The source language. Variable inference and
75
+ * secondary string validation are both driven by this language's strings.
76
+ * @param config.secondaries - `Exclude<BCP47LanguageTag, primary>[]` — Additional supported languages.
77
+ * Cannot include the primary language.
78
+ * @returns `{ d, r, useT, setLanguage, config }`
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * // locales.ts
83
+ * import { kotori } from 'kotori'
84
+ *
85
+ * export const { d, r, useT, setLanguage } = kotori({
86
+ * primary: 'en',
87
+ * secondaries: ['zh', 'ja', 'ms'],
88
+ * })
89
+ * ```
90
+ */
91
+ const kotori = (config) => {
4
92
  const listeners = /* @__PURE__ */ new Set();
93
+ /**
94
+ * Translates a dict to the current language and interpolates variables.
95
+ *
96
+ * @param dictionary - A dict created by `d()`.
97
+ * @param args - `Record<string, string | number>` — Variable values to interpolate. Required if the dict has variables, omitted if it doesn't.
98
+ * @returns `string` — The translated and interpolated string.
99
+ *
100
+ * @example
101
+ * const Intro = () => {
102
+ * const { t } = useT()
103
+ *
104
+ * return (
105
+ * <p>{t(greeting)}</p> // ✅ no args needed
106
+ * <p>{t(intro, { name: 'John', age: 30 })}</p> // ✅ typed args
107
+ * <p>{t(intro)}</p> // ❌ compile error: missing args
108
+ * <p>{t(intro, { name: 'John', x: 30 })}</p> // ❌ compile error: unknown key 'x'
109
+ * )
110
+ * }
111
+ */
5
112
  const t = (dictionary, ...args) => (dictionary().d[snapshot.language] || "").replace(/\{\{\s*([\w-]+)\s*\}\}/g, (_, key) => String(args[0]?.[key]));
6
113
  let snapshot = {
7
- language: props.primary,
114
+ language: config.primary,
8
115
  t
9
116
  };
117
+ /**
118
+ * Updates the current language and triggers a rerender in all active
119
+ * `useT()` consumers across all pages. Safe to call outside React.
120
+ *
121
+ * @param language - `Language` — Must be one of the tags declared in
122
+ * `primary` or `secondaries`. Any other value is a compile error.
123
+ * @returns `void`
124
+ *
125
+ * @example
126
+ * ```ts
127
+ * import { setLanguage } from './locales'
128
+ *
129
+ * setLanguage('zh') // ✅
130
+ * setLanguage('ms') // ✅
131
+ * setLanguage('de') // ❌ compile error: 'de' was not declared
132
+ *
133
+ * // persist and restore language selection
134
+ * setLanguage('zh')
135
+ * localStorage.setItem('lang', 'zh')
136
+ *
137
+ * // restore on app startup
138
+ * const saved = localStorage.getItem('lang')
139
+ * if (saved) setLanguage(saved as 'en')
140
+ * ```
141
+ */
10
142
  const setLanguage = (language) => {
11
143
  snapshot = {
12
144
  language,
@@ -23,11 +155,129 @@ const kotori = (props) => {
23
155
  };
24
156
  };
25
157
  return {
158
+ config,
26
159
  setLanguage,
160
+ /**
161
+ * Translates a dict to the current language and interpolates variables.
162
+ * For use **outside** React components — route guards, axios interceptors,
163
+ * utility functions, etc.
164
+ *
165
+ * ⚠️ Do not use inside React components. The React Compiler will memoize
166
+ * the result permanently since `r` is a stable module-level reference,
167
+ * causing stale translations after a language change. Use `t` from `useT()`
168
+ * inside components instead.
169
+ *
170
+ * @param dictionary - A dict created by `d()`.
171
+ * @param args - `Record<string, string | number>` — Variable values to interpolate.
172
+ * Required if the dict has variables, omitted if it doesn't.
173
+ * @returns `string` — The translated and interpolated string in the current language.
174
+ *
175
+ * @example
176
+ * ```ts
177
+ * import { r, greeting, intro } from './locales'
178
+ *
179
+ * r(greeting) // ✅ no args needed
180
+ * r(intro, { name: 'John', age: 30 }) // ✅ typed args
181
+ * r(intro) // ❌ compile error: missing args
182
+ * r(intro, { name: 'John', x: 1 }) // ❌ compile error: unknown key 'x'
183
+ *
184
+ * // route guard
185
+ * router.beforeEach(() => {
186
+ * document.title = r(pageTitle)
187
+ * })
188
+ *
189
+ * // axios interceptor
190
+ * axios.interceptors.response.use(null, () => {
191
+ * toast.error(r(errorMessage))
192
+ * })
193
+ * ```
194
+ */
27
195
  r: t,
196
+ /**
197
+ * Defines a translation unit. The primary language string drives variable
198
+ * inference — all secondary strings are validated against it at compile time.
199
+ *
200
+ * Call with an optional generic to narrow variable types beyond the default
201
+ * `string | number`. Supports TypeScript template literal types.
202
+ *
203
+ * @param dictionary - `Record<Language, string>` — One string per language.
204
+ * Variables are declared with `{{variableName}}` syntax. Spaces inside braces
205
+ * are trimmed — `{{ name }}` and `{{name}}` are equivalent.
206
+ * Secondary strings must use exactly the same variable names as the primary.
207
+ * @returns A function optionally accepting a generic to narrow variable types,
208
+ * which returns the translation unit.
209
+ *
210
+ * @example
211
+ * ```ts
212
+ * // no variables
213
+ * const greeting = d({ en: 'Hello', zh: '你好', ja: 'こんにちは', ms: 'Helo' })()
214
+ *
215
+ * // variables — inferred as Record<'name' | 'age', string | number> by default
216
+ * const intro = d({
217
+ * en: 'My name is {{name}}, I am {{age}} years old.',
218
+ * zh: '我叫{{name}},我今年{{age}}岁了。',
219
+ * ja: '私の名前は{{name}}で、{{age}}歳です。',
220
+ * ms: 'Nama saya {{name}}, saya berumur {{age}} tahun.',
221
+ * })()
222
+ *
223
+ * // narrowed variable types via generic
224
+ * const clock = d({
225
+ * en: 'Current time: {{hour}}:{{minute}}',
226
+ * zh: '现在时间:{{hour}}:{{minute}}',
227
+ * ja: '現在時刻:{{hour}}:{{minute}}',
228
+ * ms: 'Masa semasa: {{hour}}:{{minute}}',
229
+ * })<{ hour: number; minute: number }>()
230
+ *
231
+ * // TypeScript template literal types work too
232
+ * const lastLogin = d({
233
+ * en: 'Last login: {{date}}',
234
+ * zh: '上次登录:{{date}}',
235
+ * ja: '最終ログイン:{{date}}',
236
+ * ms: 'Log masuk terakhir: {{date}}',
237
+ * })<{ date: `${number}-${number}-${number}` }>()
238
+ *
239
+ * // ❌ compile error — 'ja' translation missing
240
+ * const bad1 = d({ en: 'Hello', zh: '你好', ms: 'Helo' })()
241
+ *
242
+ * // ❌ compile error — secondary string has wrong variable name
243
+ * const bad2 = d({ en: 'Hello {{name}}', zh: '你好 {{naam}}', ja: 'こんにちは {{name}}', ms: 'Helo {{name}}' })()
244
+ *
245
+ * // ❌ compile error — secondary string missing a variable
246
+ * const bad3 = d({ en: '{{x}} {{y}}', zh: '{{x}}', ja: '{{x}} {{y}}', ms: '{{x}} {{y}}' })()
247
+ * ```
248
+ */
28
249
  d: (dictionary) => () => ({ d: dictionary }),
250
+ /**
251
+ * React hook. Subscribes the component to language changes.
252
+ * Returns a snapshot containing the current `language` and a
253
+ * React Compiler-safe `t` function.
254
+ *
255
+ * Call in every component that renders translated strings.
256
+ * When `setLanguage` is called anywhere, all `useT()` consumers rerender.
257
+ *
258
+ * @returns `{ language: Language, t: TranslateFunction }`
259
+ * @returns `language` - `Language` — The current active language tag as a reactive value.
260
+ * @returns `t` - `TranslateFunction` — React Compiler-safe translate function. Use this inside components instead of the instance-level `t`.
261
+ *
262
+ * @example
263
+ * ```tsx
264
+ * import { useT, intro, time } from './locales'
265
+ *
266
+ * const Page = () => {
267
+ * const { t, language } = useT()
268
+ *
269
+ * return (
270
+ * <>
271
+ * <p>{t(intro, { name: 'John', age: 30 })}</p>
272
+ * <p>{t(time, { hour: 12, minute: 0 })}</p>
273
+ * <p>Current language: {language}</p>
274
+ * </>
275
+ * )
276
+ * }
277
+ * ```
278
+ */
29
279
  useT: () => useSyncExternalStore(subscribe, () => snapshot, () => snapshot)
30
280
  };
31
281
  };
32
282
  //#endregion
33
- export { kotori };
283
+ export { detectLanguage, kotori };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kotori",
3
3
  "description": "0.28kB Strongly-typed and tree-shakeable internationalization library for React",
4
- "version": "6.0.0",
4
+ "version": "6.1.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",