canopy-i18n 0.5.1 → 0.6.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
@@ -147,11 +147,12 @@ const builder = createI18n(['ja', 'en', 'fr'] as const);
147
147
  ### `ChainBuilder`
148
148
  A builder class for creating multiple localized messages with method chaining.
149
149
 
150
- #### `.add<ReturnType = string>(entries)`
150
+ #### `.add<ReturnType = string, K extends string = string>(entries)`
151
151
  Adds multiple messages at once. By default, returns `string`, but you can specify a custom return type.
152
152
 
153
153
  - **ReturnType**: (optional) Type parameter for the return value (defaults to `string`)
154
- - **entries**: `Record<string, Record<Locale, ReturnType>>`
154
+ - **K**: (optional) Type parameter for the keys of the entries record (defaults to `string`)
155
+ - **entries**: `Record<K, Record<Locale, ReturnType>>`
155
156
  - Returns: `ChainBuilder` with added messages
156
157
 
157
158
  ```ts
@@ -187,14 +188,15 @@ const menu = createI18n(['ja', 'en'] as const)
187
188
  });
188
189
  ```
189
190
 
190
- #### `.addTemplates<Context, ReturnType = string>()(entries)`
191
+ #### `.addTemplates<Context, ReturnType = string, K extends string = string>()(entries)`
191
192
  Adds multiple template function messages at once with a unified context type and custom return type.
192
193
 
193
- Note: This uses a curried API for better type inference. Call `addTemplates<Context, ReturnType>()` first, then call the returned function with entries.
194
+ Note: This uses a curried API for better type inference. Call `addTemplates<Context, ReturnType, K>()` first, then call the returned function with entries.
194
195
 
195
196
  - **Context**: Type parameter for the template function context
196
197
  - **ReturnType**: (optional) Type parameter for the return value (defaults to `string`)
197
- - **entries**: `Record<string, Record<Locale, (ctx: Context) => ReturnType>>`
198
+ - **K**: (optional) Type parameter for the keys of the entries record (defaults to `string`)
199
+ - **entries**: `Record<K, Record<Locale, (ctx: Context) => ReturnType>>`
198
200
  - Returns: `ChainBuilder` with added template messages
199
201
 
200
202
  ```ts
@@ -8,18 +8,18 @@ export declare class ChainBuilder<const Ls extends readonly string[], Messages e
8
8
  * 複数のメッセージを一度に追加
9
9
  * 型パラメータRでカスタム型も指定可能(デフォルトはstring)
10
10
  */
11
- add<R = string, Entries extends Record<string, Record<Ls[number], R>> = Record<string, Record<Ls[number], R>>>(entries: {
12
- [K in keyof Entries]: K extends keyof Messages ? never : Entries[K];
11
+ add<R = string, K extends string = string, Entries extends Record<K, Record<Ls[number], R>> = Record<K, Record<Ls[number], R>>>(entries: {
12
+ [Key in keyof Entries]: Key extends keyof Messages ? never : Entries[Key];
13
13
  }): ChainBuilder<Ls, Messages & {
14
- [K in keyof Entries]: I18nMessage<Ls, void, R>;
14
+ [Key in keyof Entries]: I18nMessage<Ls, void, R>;
15
15
  }>;
16
16
  /**
17
17
  * 関数指定版: 複数のテンプレート関数を一度に追加(型は統一)
18
18
  */
19
- addTemplates<C, R = string>(): <Entries extends Record<string, Record<Ls[number], (ctx: C) => R>>>(entries: {
20
- [K in keyof Entries]: K extends keyof Messages ? never : Entries[K];
19
+ addTemplates<C, R = string, K extends string = string>(): <Entries extends Record<K, Record<Ls[number], (ctx: C) => R>>>(entries: {
20
+ [Key in keyof Entries]: Key extends keyof Messages ? never : Entries[Key];
21
21
  }) => ChainBuilder<Ls, Messages & {
22
- [K in keyof Entries]: I18nMessage<Ls, C, R>;
22
+ [Key in keyof Entries]: I18nMessage<Ls, C, R>;
23
23
  }>;
24
24
  private deepCloneWithLocale;
25
25
  build<M = {
package/dist/testtest.js CHANGED
@@ -7,6 +7,14 @@ const b = a
7
7
  });
8
8
  const c = b.build("en");
9
9
  const e = { ...c.abc, toString: () => "aaa" };
10
+ const d = b.add({
11
+ aaa: { ja: "aaa", en: "aaa" },
12
+ bbb: { ja: "bbb", en: "bbb" },
13
+ });
14
+ const f = b.addTemplates()({
15
+ aaa: { ja: ({ a }) => `${a}aaa`, en: ({ a }) => `${a}aaa` },
16
+ bbb: { ja: ({ a }) => `${a}aaa`, en: ({ a }) => `${a}aaa` },
17
+ });
10
18
  console.log(c.bb({ aa: "name" }));
11
19
  console.log(`${c.bb({ aa: "name" })}`);
12
20
  console.log("aaa" + c.bb({ aa: "name" }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-i18n",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "The Type-Safe i18n library that your IDE will Love",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "sideEffects": false,
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "skills/SKILL.md"
18
19
  ],
19
20
  "publishConfig": {
20
21
  "access": "public"
@@ -29,8 +30,8 @@
29
30
  "release": "release-it"
30
31
  },
31
32
  "devDependencies": {
32
- "@tsconfig/node20": "^20.1.8",
33
- "@types/node": "^25.0.10",
33
+ "@tsconfig/node20": "^20.1.9",
34
+ "@types/node": "^25.3.0",
34
35
  "release-it": "^19.2.4",
35
36
  "typescript": "^5.9.3",
36
37
  "vitest": "^4.0.18"
@@ -0,0 +1,576 @@
1
+ ---
2
+ name: canopy-i18n
3
+ description: Use this skill when writing code that uses the canopy-i18n package — a type-safe, zero-dependency i18n library with a builder pattern API. Covers createI18n, add, addTemplates (curried), build, bindLocale, React integration, and common gotchas like required `as const` and the two-step curried call syntax.
4
+ ---
5
+
6
+ # canopy-i18n — AI Code Generation Reference
7
+
8
+ A type-safe i18n library using the builder pattern. This reference helps AI assistants generate accurate code for this package.
9
+
10
+ ## Package Overview
11
+
12
+ - **Type-safe**: Compile-time detection of typos in locale keys via TypeScript inference
13
+ - **Builder pattern**: Define translations with method chaining
14
+ - **Zero dependencies**: Native TypeScript only
15
+ - **ESM only**: Requires `"type": "module"` in `package.json`
16
+ - **Node.js 20+**
17
+
18
+ ---
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install canopy-i18n
24
+ # or
25
+ pnpm add canopy-i18n
26
+ bun add canopy-i18n
27
+ ```
28
+
29
+ `package.json` must include `"type": "module"`:
30
+
31
+ ```json
32
+ {
33
+ "type": "module"
34
+ }
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Core API
40
+
41
+ ### `createI18n(locales)`
42
+
43
+ Creates a builder instance. **`as const` is required** for type inference.
44
+
45
+ ```ts
46
+ import { createI18n } from 'canopy-i18n';
47
+
48
+ // ✅ Correct: use as const
49
+ const builder = createI18n(['en', 'ja'] as const);
50
+
51
+ // ❌ Wrong: without as const, type becomes string[] and type inference is lost
52
+ const builder = createI18n(['en', 'ja']);
53
+ ```
54
+
55
+ - **Argument**: `readonly string[]` — allowed locale keys
56
+ - **Returns**: `ChainBuilder<Locales, {}>` — a chain builder instance
57
+
58
+ ---
59
+
60
+ ### `.add<R, K>(entries)`
61
+
62
+ Adds multiple static messages (string or custom type).
63
+
64
+ ```ts
65
+ // Default (string type)
66
+ const builder = createI18n(['en', 'ja'] as const)
67
+ .add({
68
+ title: { en: 'Title', ja: 'タイトル' },
69
+ greeting: { en: 'Hello', ja: 'こんにちは' },
70
+ });
71
+
72
+ // Custom return type (object)
73
+ type MenuItem = { label: string; url: string };
74
+
75
+ const menu = createI18n(['en', 'ja'] as const)
76
+ .add<MenuItem>({
77
+ home: {
78
+ en: { label: 'Home', url: '/en' },
79
+ ja: { label: 'Home', url: '/ja' },
80
+ },
81
+ });
82
+ ```
83
+
84
+ - **Type param `R`**: return value type (default: `string`)
85
+ - **Type param `K`**: key type for entries (usually omitted)
86
+ - **entries**: `Record<K, Record<Locale, R>>`
87
+ - **Returns**: new `ChainBuilder` (immutable)
88
+
89
+ ---
90
+
91
+ ### `.addTemplates<C, R, K>()(entries)`
92
+
93
+ **Curried API — two-step call required.** Adds template functions that receive a context object of type `C`.
94
+
95
+ ```ts
96
+ // ⚠️ Curried: two-step call ()() is mandatory
97
+ const builder = createI18n(['en', 'ja'] as const)
98
+ .addTemplates<{ name: string; age: number }>()({ // note: ()() two steps
99
+ greeting: {
100
+ en: (ctx) => `Hello, ${ctx.name}. You are ${ctx.age}.`,
101
+ ja: (ctx) => `こんにちは、${ctx.name}さん。${ctx.age}歳です。`,
102
+ },
103
+ });
104
+
105
+ // Custom return type (JSX.Element)
106
+ const jsxBuilder = createI18n(['en', 'ja'] as const)
107
+ .addTemplates<{ name: string }, JSX.Element>()({
108
+ badge: {
109
+ en: ({ name }) => <strong>Welcome, {name}!</strong>,
110
+ ja: ({ name }) => <strong>ようこそ、{name}さん!</strong>,
111
+ },
112
+ });
113
+ ```
114
+
115
+ - **Type param `C`**: context object type (**required**)
116
+ - **Type param `R`**: return value type (default: `string`)
117
+ - **Type param `K`**: key type (usually omitted)
118
+ - **entries**: `Record<K, Record<Locale, (ctx: C) => R>>`
119
+ - **Returns**: new `ChainBuilder` (immutable)
120
+
121
+ ---
122
+
123
+ ### `.build(locale?)`
124
+
125
+ Builds the final messages object.
126
+
127
+ ```ts
128
+ const builder = createI18n(['en', 'ja'] as const)
129
+ .add({ title: { en: 'Title', ja: 'タイトル' } });
130
+
131
+ // With specific locale
132
+ const enMessages = builder.build('en');
133
+ const jaMessages = builder.build('ja');
134
+
135
+ // Without locale — defaults to first locale in array
136
+ const defaultMessages = builder.build(); // uses 'en'
137
+
138
+ // All messages are called as functions
139
+ console.log(enMessages.title()); // "Title"
140
+ console.log(jaMessages.title()); // "タイトル"
141
+ ```
142
+
143
+ - **Argument `locale`**: optional; defaults to first locale in array
144
+ - **Returns**: `{ [key]: () => R }` or `{ [key]: (ctx: C) => R }`
145
+ - **Immutable**: `.build()` does not mutate the builder — you can generate multiple locales from one builder
146
+
147
+ ---
148
+
149
+ ### `bindLocale(obj, locale)`
150
+
151
+ Recursively traverses an object/array and calls `.build(locale)` on all `ChainBuilder` instances found. Used for the namespace pattern (split files).
152
+
153
+ ```ts
154
+ import { bindLocale } from 'canopy-i18n';
155
+
156
+ const data = {
157
+ common: commonBuilder,
158
+ nested: {
159
+ user: userBuilder,
160
+ },
161
+ };
162
+
163
+ const messages = bindLocale(data, 'en');
164
+ console.log(messages.common.hello()); // "Hello"
165
+ console.log(messages.nested.user.welcome({ name: 'John' })); // "Welcome, John"
166
+ ```
167
+
168
+ - **Argument `obj`**: any object/array containing `ChainBuilder` instances
169
+ - **Argument `locale`**: locale string to apply
170
+ - **Returns**: new structure with all builders resolved
171
+
172
+ ---
173
+
174
+ ## Critical Gotchas
175
+
176
+ ### 1. `as const` is required
177
+
178
+ ```ts
179
+ // ✅ Correct
180
+ createI18n(['en', 'ja'] as const)
181
+
182
+ // ❌ Type error — locale keys become string, inference breaks
183
+ createI18n(['en', 'ja'])
184
+ ```
185
+
186
+ ### 2. `addTemplates` is curried — two-step call
187
+
188
+ ```ts
189
+ // ✅ Correct: ()() two steps
190
+ .addTemplates<{ name: string }>()({
191
+ key: { en: (ctx) => `Hello, ${ctx.name}` }
192
+ })
193
+
194
+ // ❌ Wrong: one-step call causes type error
195
+ .addTemplates<{ name: string }>({
196
+ key: { en: (ctx) => `Hello, ${ctx.name}` }
197
+ })
198
+ ```
199
+
200
+ ### 3. `.build()` is immutable
201
+
202
+ ```ts
203
+ const builder = createI18n(['en', 'ja'] as const).add({ ... });
204
+
205
+ // ✅ Multiple locales from one builder
206
+ const enMessages = builder.build('en');
207
+ const jaMessages = builder.build('ja');
208
+ ```
209
+
210
+ ### 4. ESM only
211
+
212
+ ```json
213
+ // Required in package.json
214
+ { "type": "module" }
215
+ ```
216
+
217
+ ### 5. All messages must be called as functions
218
+
219
+ ```ts
220
+ const m = builder.build('en');
221
+
222
+ // ✅ Call as a function
223
+ m.title()
224
+ m.greeting({ name: 'Alice' })
225
+
226
+ // ❌ Do not access as property — it is a function object, not a string
227
+ m.title
228
+ ```
229
+
230
+ ---
231
+
232
+ ## Common Patterns
233
+
234
+ ### Basic String Messages
235
+
236
+ ```ts
237
+ import { createI18n } from 'canopy-i18n';
238
+
239
+ const messages = createI18n(['en', 'ja'] as const)
240
+ .add({
241
+ title: { en: 'Title', ja: 'タイトル' },
242
+ greeting: { en: 'Hello', ja: 'こんにちは' },
243
+ farewell: { en: 'Goodbye', ja: 'さようなら' },
244
+ })
245
+ .build('en');
246
+
247
+ console.log(messages.title()); // "Title"
248
+ console.log(messages.greeting()); // "Hello"
249
+ ```
250
+
251
+ ### Template Functions (Variable Interpolation)
252
+
253
+ ```ts
254
+ import { createI18n } from 'canopy-i18n';
255
+
256
+ const messages = createI18n(['en', 'ja'] as const)
257
+ .addTemplates<{ name: string; age: number }>()({
258
+ profile: {
259
+ en: (ctx) => `Name: ${ctx.name}, Age: ${ctx.age}`,
260
+ ja: (ctx) => `名前: ${ctx.name}、年齢: ${ctx.age}歳`,
261
+ },
262
+ })
263
+ .build('en');
264
+
265
+ console.log(messages.profile({ name: 'Taro', age: 25 }));
266
+ // "Name: Taro, Age: 25"
267
+ ```
268
+
269
+ ### Mixing Static and Template Messages
270
+
271
+ ```ts
272
+ import { createI18n } from 'canopy-i18n';
273
+
274
+ const messages = createI18n(['en', 'ja'] as const)
275
+ .add({
276
+ title: { en: 'Items', ja: 'アイテム' },
277
+ })
278
+ .addTemplates<{ count: number }>()({
279
+ count: {
280
+ en: (ctx) => `${ctx.count} items`,
281
+ ja: (ctx) => `${ctx.count}個のアイテム`,
282
+ },
283
+ })
284
+ .build('en');
285
+
286
+ console.log(messages.title()); // "Items"
287
+ console.log(messages.count({ count: 5 })); // "5 items"
288
+ ```
289
+
290
+ ### Custom Return Type (Object)
291
+
292
+ ```ts
293
+ import { createI18n } from 'canopy-i18n';
294
+
295
+ type MenuItem = { label: string; url: string; icon: string };
296
+
297
+ const menu = createI18n(['en', 'ja'] as const)
298
+ .add<MenuItem>({
299
+ home: {
300
+ en: { label: 'Home', url: '/en', icon: '🏡' },
301
+ ja: { label: 'ホーム', url: '/ja', icon: '🏠' },
302
+ },
303
+ about: {
304
+ en: { label: 'About', url: '/en/about', icon: 'ℹ️' },
305
+ ja: { label: '概要', url: '/ja/about', icon: 'ℹ️' },
306
+ },
307
+ })
308
+ .build('en');
309
+
310
+ console.log(menu.home().label); // "Home"
311
+ console.log(menu.home().url); // "/en"
312
+ ```
313
+
314
+ ### Custom Return Type (JSX)
315
+
316
+ ```tsx
317
+ import { createI18n } from 'canopy-i18n';
318
+ import type { JSX } from 'react';
319
+
320
+ const messages = createI18n(['en', 'ja'] as const)
321
+ .add<JSX.Element>({
322
+ badge: {
323
+ en: <span style={{ background: '#4caf50', color: 'white' }}>NEW</span>,
324
+ ja: <span style={{ background: '#ff4444', color: 'white' }}>新着</span>,
325
+ },
326
+ })
327
+ .addTemplates<{ name: string }, JSX.Element>()({
328
+ greeting: {
329
+ en: ({ name }) => <strong>Welcome, {name}!</strong>,
330
+ ja: ({ name }) => <strong>ようこそ、{name}さん!</strong>,
331
+ },
332
+ })
333
+ .build('en');
334
+
335
+ const badge = messages.badge();
336
+ const greeting = messages.greeting({ name: 'Alice' });
337
+ ```
338
+
339
+ ### Namespace Pattern (Split Files + bindLocale)
340
+
341
+ ```ts
342
+ // i18n/locales.ts
343
+ export const LOCALES = ['en', 'ja'] as const;
344
+ export type Locale = (typeof LOCALES)[number];
345
+
346
+ // i18n/common.ts
347
+ import { createI18n } from 'canopy-i18n';
348
+ import { LOCALES } from './locales';
349
+
350
+ export const common = createI18n(LOCALES).add({
351
+ hello: { en: 'Hello', ja: 'こんにちは' },
352
+ goodbye: { en: 'Goodbye', ja: 'さようなら' },
353
+ });
354
+
355
+ // i18n/user.ts
356
+ import { createI18n } from 'canopy-i18n';
357
+ import { LOCALES } from './locales';
358
+
359
+ export const user = createI18n(LOCALES)
360
+ .addTemplates<{ name: string }>()({
361
+ welcome: {
362
+ en: (ctx) => `Welcome, ${ctx.name}`,
363
+ ja: (ctx) => `ようこそ、${ctx.name}さん`,
364
+ },
365
+ });
366
+
367
+ // i18n/index.ts
368
+ export { common } from './common';
369
+ export { user } from './user';
370
+
371
+ // app.ts
372
+ import { bindLocale } from 'canopy-i18n';
373
+ import * as i18n from './i18n';
374
+
375
+ const messages = bindLocale(i18n, 'en');
376
+ console.log(messages.common.hello()); // "Hello"
377
+ console.log(messages.user.welcome({ name: 'John' })); // "Welcome, John"
378
+ ```
379
+
380
+ ### Deep Nested Structures
381
+
382
+ ```ts
383
+ import { createI18n, bindLocale } from 'canopy-i18n';
384
+
385
+ const structure = {
386
+ header: createI18n(['en', 'ja'] as const)
387
+ .add({ title: { en: 'Header', ja: 'ヘッダー' } }),
388
+ content: {
389
+ main: createI18n(['en', 'ja'] as const)
390
+ .add({ body: { en: 'Body', ja: '本文' } }),
391
+ sidebar: createI18n(['en', 'ja'] as const)
392
+ .add({ widget: { en: 'Widget', ja: 'ウィジェット' } }),
393
+ },
394
+ };
395
+
396
+ const localized = bindLocale(structure, 'en');
397
+ console.log(localized.header.title()); // "Header"
398
+ console.log(localized.content.main.body()); // "Body"
399
+ console.log(localized.content.sidebar.widget()); // "Widget"
400
+ ```
401
+
402
+ ---
403
+
404
+ ## React Integration
405
+
406
+ ### Locale Context
407
+
408
+ ```tsx
409
+ // LocaleContext.tsx
410
+ import { bindLocale } from 'canopy-i18n';
411
+ import { createContext, useContext, useState } from 'react';
412
+
413
+ type Locale = 'en' | 'ja';
414
+
415
+ type ContextType = {
416
+ locale: Locale;
417
+ setLocale: (locale: Locale) => void;
418
+ };
419
+
420
+ const LocaleContext = createContext<ContextType | undefined>(undefined);
421
+
422
+ export function LocaleProvider({ children }: { children: React.ReactNode }) {
423
+ const [locale, setLocale] = useState<Locale>('en');
424
+ return (
425
+ <LocaleContext.Provider value={{ locale, setLocale }}>
426
+ {children}
427
+ </LocaleContext.Provider>
428
+ );
429
+ }
430
+
431
+ export function useLocale() {
432
+ const ctx = useContext(LocaleContext);
433
+ if (!ctx) throw new Error('useLocale must be used within a LocaleProvider');
434
+ return ctx;
435
+ }
436
+
437
+ // Reactively applies bindLocale based on current locale
438
+ export function useBindLocale<T extends object>(msgsDef: T) {
439
+ const { locale } = useLocale();
440
+ return bindLocale(msgsDef, locale);
441
+ }
442
+ ```
443
+
444
+ ### Usage in Components
445
+
446
+ ```tsx
447
+ // i18n.ts — export ChainBuilders (not yet built)
448
+ import { createI18n } from 'canopy-i18n';
449
+
450
+ const LOCALES = ['en', 'ja'] as const;
451
+ export const defineMessage = () => createI18n(LOCALES);
452
+
453
+ export const appI18n = defineMessage()
454
+ .add({
455
+ title: { en: 'My App', ja: 'マイアプリ' },
456
+ description: { en: 'Welcome!', ja: 'ようこそ!' },
457
+ })
458
+ .addTemplates<{ name: string }>()({
459
+ greeting: {
460
+ en: (ctx) => `Hello, ${ctx.name}!`,
461
+ ja: (ctx) => `こんにちは、${ctx.name}さん!`,
462
+ },
463
+ });
464
+
465
+ // App.tsx — apply locale with useBindLocale
466
+ import { useBindLocale } from './LocaleContext';
467
+ import { appI18n } from './i18n';
468
+
469
+ export default function App() {
470
+ const m = useBindLocale(appI18n);
471
+
472
+ return (
473
+ <div>
474
+ <h1>{m.title()}</h1>
475
+ <p>{m.description()}</p>
476
+ <p>{m.greeting({ name: 'Taro' })}</p>
477
+ </div>
478
+ );
479
+ }
480
+ ```
481
+
482
+ ### Component-Local i18n (Colocation)
483
+
484
+ ```tsx
485
+ // ProfileCard.tsx — define and use i18n in the same file
486
+ import { createI18n } from 'canopy-i18n';
487
+ import type { JSX } from 'react';
488
+ import { useBindLocale } from './LocaleContext';
489
+
490
+ const profileI18n = createI18n(['en', 'ja'] as const)
491
+ .add({
492
+ title: { en: 'User Profile', ja: 'ユーザープロフィール' },
493
+ editButton: { en: 'Edit Profile', ja: 'プロフィール編集' },
494
+ })
495
+ .addTemplates<{ name: string }, JSX.Element>()({
496
+ greeting: {
497
+ en: ({ name }) => <strong>Welcome, {name}!</strong>,
498
+ ja: ({ name }) => <strong>ようこそ、{name}さん!</strong>,
499
+ },
500
+ });
501
+
502
+ export function ProfileCard({ name }: { name: string }) {
503
+ const m = useBindLocale(profileI18n);
504
+
505
+ return (
506
+ <div>
507
+ <h2>{m.title()}</h2>
508
+ <div>{m.greeting({ name })}</div>
509
+ <button>{m.editButton()}</button>
510
+ </div>
511
+ );
512
+ }
513
+ ```
514
+
515
+ ### Language Switcher Component
516
+
517
+ ```tsx
518
+ // LanguageSwitcher.tsx
519
+ import { useLocale } from './LocaleContext';
520
+
521
+ export function LanguageSwitcher() {
522
+ const { locale, setLocale } = useLocale();
523
+
524
+ return (
525
+ <div>
526
+ <button onClick={() => setLocale('en')} disabled={locale === 'en'}>EN</button>
527
+ <button onClick={() => setLocale('ja')} disabled={locale === 'ja'}>JA</button>
528
+ </div>
529
+ );
530
+ }
531
+ ```
532
+
533
+ ---
534
+
535
+ ## Exports Reference
536
+
537
+ ```ts
538
+ // Functions & Classes
539
+ export { createI18n } from 'canopy-i18n'; // create a builder
540
+ export { ChainBuilder } from 'canopy-i18n'; // builder class
541
+ export { I18nMessage } from 'canopy-i18n'; // message class
542
+ export { isI18nMessage } from 'canopy-i18n'; // type guard
543
+ export { bindLocale } from 'canopy-i18n'; // apply locale to nested structure
544
+ export { isChainBuilder } from 'canopy-i18n'; // type guard
545
+
546
+ // Types
547
+ export type { Template } from 'canopy-i18n'; // R | ((ctx: C) => R)
548
+ export type { LocalizedMessage } from 'canopy-i18n'; // built message function type
549
+ ```
550
+
551
+ ### Type Details
552
+
553
+ ```ts
554
+ // Template<C, R>: a static value or a function that receives context
555
+ type Template<C, R = string> = R | ((ctx: C) => R);
556
+
557
+ // LocalizedMessage<Ls, C, R>: the function type after build()
558
+ // - when C is void: () => R
559
+ // - when C is present: (ctx: C) => R
560
+ type LocalizedMessage<Ls, C, R = string> =
561
+ C extends void
562
+ ? (() => R) & { __brand: "I18nMessage" }
563
+ : ((ctx: C) => R) & { __brand: "I18nTemplateMessage" };
564
+ ```
565
+
566
+ ---
567
+
568
+ ## Common Mistakes
569
+
570
+ | Mistake | Fix |
571
+ |---------|-----|
572
+ | `createI18n(['en', 'ja'])` | `createI18n(['en', 'ja'] as const)` |
573
+ | `.addTemplates<C>({ ... })` | `.addTemplates<C>()({ ... })` (two-step) |
574
+ | `messages.title` | `messages.title()` (call as function) |
575
+ | CommonJS `require()` | Use ESM `import` |
576
+ | Typo in locale key | TypeScript catches it at compile time |