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 +7 -5
- package/dist/chainBuilder.d.ts +6 -6
- package/dist/testtest.js +8 -0
- package/package.json +5 -4
- package/skills/SKILL.md +576 -0
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
|
-
- **
|
|
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
|
-
- **
|
|
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
|
package/dist/chainBuilder.d.ts
CHANGED
|
@@ -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<
|
|
12
|
-
[
|
|
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
|
-
[
|
|
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<
|
|
20
|
-
[
|
|
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
|
-
[
|
|
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.
|
|
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.
|
|
33
|
-
"@types/node": "^25.0
|
|
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"
|
package/skills/SKILL.md
ADDED
|
@@ -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 |
|