canopy-i18n 0.6.0 → 0.7.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 +50 -103
- package/dist/bindLocale.d.ts +2 -2
- package/dist/chainBuilder.d.ts +6 -15
- package/dist/chainBuilder.js +16 -21
- package/dist/chainBuilder.test.d.ts +1 -0
- package/dist/chainBuilder.test.js +130 -0
- package/dist/message.d.ts +7 -7
- package/dist/testtest.js +6 -6
- package/dist/types.d.ts +2 -2
- package/package.json +5 -4
- package/skills/SKILL.md +476 -0
package/README.md
CHANGED
|
@@ -9,7 +9,6 @@ A tiny, type-safe i18n library for building localized messages with builder patt
|
|
|
9
9
|
- **AI-friendly**: Full type safety and single-file colocation give AI assistants complete context for accurate code generation.
|
|
10
10
|
- **Type-safe**: Compile-time safety for locale keys with full TypeScript IntelliSense support.
|
|
11
11
|
- **Flexible templating**: Plain functions support any JavaScript logic, template literals, or formatting library.
|
|
12
|
-
- **Generic return types**: Return strings, React components, objects, or any custom type.
|
|
13
12
|
- **Zero dependencies**: Lightweight with native TypeScript syntax, no custom {{placeholder}} format.
|
|
14
13
|
## Why Canopy i18n?
|
|
15
14
|
|
|
@@ -45,32 +44,16 @@ console.log(messages.greeting()); // Fully type-safe, autocomplete works
|
|
|
45
44
|
**With template functions:**
|
|
46
45
|
```ts
|
|
47
46
|
const messages = createI18n(['en', 'ja'] as const)
|
|
48
|
-
.
|
|
49
|
-
welcome: {
|
|
50
|
-
en:
|
|
51
|
-
ja:
|
|
52
|
-
}
|
|
47
|
+
.add({
|
|
48
|
+
welcome: (ctx: { name: string }) => ({
|
|
49
|
+
en: `Welcome, ${ctx.name}!`,
|
|
50
|
+
ja: `ようこそ、${ctx.name}さん!`
|
|
51
|
+
})
|
|
53
52
|
}).build('en');
|
|
54
53
|
|
|
55
54
|
console.log(messages.welcome({ name: 'Alice' })); // "Welcome, Alice!"
|
|
56
55
|
```
|
|
57
56
|
|
|
58
|
-
**With custom return types:**
|
|
59
|
-
```ts
|
|
60
|
-
type MenuItem = { label: string; url: string };
|
|
61
|
-
|
|
62
|
-
const menu = createI18n(['en', 'ja'] as const)
|
|
63
|
-
.add<MenuItem>({
|
|
64
|
-
home: {
|
|
65
|
-
en: { label: 'Home', url: '/en' },
|
|
66
|
-
ja: { label: 'ホーム', url: '/ja' }
|
|
67
|
-
}
|
|
68
|
-
}).build('ja');
|
|
69
|
-
|
|
70
|
-
console.log(menu.home().label); // "ホーム"
|
|
71
|
-
console.log(menu.home().url); // "/ja"
|
|
72
|
-
```
|
|
73
|
-
|
|
74
57
|
**Benefits:**
|
|
75
58
|
- 🔒 **Type safety**: Typos caught at compile time, full autocomplete support
|
|
76
59
|
- 📁 **Colocation**: All translations in one place, no file jumping
|
|
@@ -109,12 +92,10 @@ const builder = baseBuilder
|
|
|
109
92
|
ja: 'こんにちは',
|
|
110
93
|
en: 'Hello',
|
|
111
94
|
},
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
en: (ctx) => `Hello, ${ctx.name}. You are ${ctx.age} years old.`,
|
|
117
|
-
},
|
|
95
|
+
welcome: (ctx: { name: string; age: number }) => ({
|
|
96
|
+
ja: `こんにちは、${ctx.name}さん。あなたは${ctx.age}歳です。`,
|
|
97
|
+
en: `Hello, ${ctx.name}. You are ${ctx.age} years old.`,
|
|
98
|
+
}),
|
|
118
99
|
});
|
|
119
100
|
|
|
120
101
|
// 3) Reuse the builder to create messages for different locales
|
|
@@ -147,85 +128,53 @@ const builder = createI18n(['ja', 'en', 'fr'] as const);
|
|
|
147
128
|
### `ChainBuilder`
|
|
148
129
|
A builder class for creating multiple localized messages with method chaining.
|
|
149
130
|
|
|
150
|
-
#### `.add
|
|
151
|
-
Adds multiple messages at once.
|
|
131
|
+
#### `.add(entries)`
|
|
132
|
+
Adds multiple messages at once. Each entry can be a static locale record or a template function.
|
|
152
133
|
|
|
153
|
-
- **
|
|
154
|
-
- **K**: (optional) Type parameter for the keys of the entries record (defaults to `string`)
|
|
155
|
-
- **entries**: `Record<K, Record<Locale, ReturnType>>`
|
|
134
|
+
- **entries**: `Record<K, Record<Locale, string> | ((ctx: C) => Record<Locale, string>)>`
|
|
156
135
|
- Returns: `ChainBuilder` with added messages
|
|
157
136
|
|
|
158
137
|
```ts
|
|
159
|
-
//
|
|
138
|
+
// Static messages
|
|
160
139
|
const builder = createI18n(['ja', 'en'] as const)
|
|
161
140
|
.add({
|
|
162
141
|
title: { ja: 'タイトル', en: 'Title' },
|
|
163
142
|
greeting: { ja: 'こんにちは', en: 'Hello' },
|
|
164
143
|
});
|
|
165
144
|
|
|
166
|
-
//
|
|
167
|
-
const
|
|
168
|
-
.add
|
|
169
|
-
|
|
170
|
-
ja:
|
|
171
|
-
en:
|
|
172
|
-
},
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
label: string;
|
|
178
|
-
url: string;
|
|
179
|
-
icon: string;
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
const menu = createI18n(['ja', 'en'] as const)
|
|
183
|
-
.add<MenuItem>({
|
|
184
|
-
home: {
|
|
185
|
-
ja: { label: 'ホーム', url: '/ja', icon: '🏠' },
|
|
186
|
-
en: { label: 'Home', url: '/en', icon: '🏡' },
|
|
187
|
-
},
|
|
145
|
+
// Template functions
|
|
146
|
+
const builder2 = createI18n(['ja', 'en'] as const)
|
|
147
|
+
.add({
|
|
148
|
+
greet: (ctx: { name: string; age: number }) => ({
|
|
149
|
+
ja: `こんにちは、${ctx.name}さん。${ctx.age}歳ですね。`,
|
|
150
|
+
en: `Hello, ${ctx.name}. You are ${ctx.age}.`,
|
|
151
|
+
}),
|
|
152
|
+
farewell: (ctx: { name: string }) => ({
|
|
153
|
+
ja: `さようなら、${ctx.name}さん。`,
|
|
154
|
+
en: `Goodbye, ${ctx.name}.`,
|
|
155
|
+
}),
|
|
188
156
|
});
|
|
189
|
-
```
|
|
190
157
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
- **entries**: `Record<K, Record<Locale, (ctx: Context) => ReturnType>>`
|
|
200
|
-
- Returns: `ChainBuilder` with added template messages
|
|
201
|
-
|
|
202
|
-
```ts
|
|
203
|
-
const builder = createI18n(['ja', 'en'] as const)
|
|
204
|
-
.addTemplates<{ name: string; age: number }>()({
|
|
205
|
-
greet: {
|
|
206
|
-
ja: (ctx) => `こんにちは、${ctx.name}さん。${ctx.age}歳ですね。`,
|
|
207
|
-
en: (ctx) => `Hello, ${ctx.name}. You are ${ctx.age}.`,
|
|
208
|
-
},
|
|
209
|
-
farewell: {
|
|
210
|
-
ja: (ctx) => `さようなら、${ctx.name}さん。`,
|
|
211
|
-
en: (ctx) => `Goodbye, ${ctx.name}.`,
|
|
212
|
-
},
|
|
158
|
+
// Mixing static and template messages
|
|
159
|
+
const builder3 = createI18n(['ja', 'en'] as const)
|
|
160
|
+
.add({
|
|
161
|
+
title: { ja: 'タイトル', en: 'Title' },
|
|
162
|
+
greet: (ctx: { name: string }) => ({
|
|
163
|
+
ja: `こんにちは、${ctx.name}さん`,
|
|
164
|
+
en: `Hello, ${ctx.name}`,
|
|
165
|
+
}),
|
|
213
166
|
});
|
|
214
167
|
```
|
|
215
168
|
|
|
216
169
|
|
|
217
170
|
|
|
218
|
-
#### `.build(locale
|
|
171
|
+
#### `.build(locale)`
|
|
219
172
|
Builds the final messages object.
|
|
220
173
|
|
|
221
|
-
- **locale**:
|
|
174
|
+
- **locale**: `Locale` — Sets this locale on all messages before returning.
|
|
222
175
|
- Returns: `Messages` — An object containing all defined messages
|
|
223
176
|
|
|
224
177
|
```ts
|
|
225
|
-
// Build with default locale (first in array)
|
|
226
|
-
const defaultMessages = builder.build();
|
|
227
|
-
|
|
228
|
-
// Build with specific locale
|
|
229
178
|
const englishMessages = builder.build('en');
|
|
230
179
|
const japaneseMessages = builder.build('ja');
|
|
231
180
|
```
|
|
@@ -258,7 +207,7 @@ console.log(localized.nested.special.msg()); // English version
|
|
|
258
207
|
|
|
259
208
|
```ts
|
|
260
209
|
export type Template<C, R = string> = R | ((ctx: C) => R);
|
|
261
|
-
export type LocalizedMessage<Locales, Context
|
|
210
|
+
export type LocalizedMessage<Locales, Context> = I18nMessage<Locales, Context>;
|
|
262
211
|
```
|
|
263
212
|
|
|
264
213
|
## Exports
|
|
@@ -292,11 +241,11 @@ console.log(messages.greeting()); // "Hello"
|
|
|
292
241
|
|
|
293
242
|
```ts
|
|
294
243
|
const messages = createI18n(['ja', 'en'] as const)
|
|
295
|
-
.
|
|
296
|
-
profile: {
|
|
297
|
-
ja:
|
|
298
|
-
en:
|
|
299
|
-
},
|
|
244
|
+
.add({
|
|
245
|
+
profile: (ctx: { name: string; age: number }) => ({
|
|
246
|
+
ja: `名前: ${ctx.name}、年齢: ${ctx.age}歳`,
|
|
247
|
+
en: `Name: ${ctx.name}, Age: ${ctx.age}`,
|
|
248
|
+
}),
|
|
300
249
|
})
|
|
301
250
|
.build('en');
|
|
302
251
|
|
|
@@ -304,18 +253,16 @@ console.log(messages.profile({ name: 'Taro', age: 25 }));
|
|
|
304
253
|
// "Name: Taro, Age: 25"
|
|
305
254
|
```
|
|
306
255
|
|
|
307
|
-
### Mixing
|
|
256
|
+
### Mixing Static and Template Messages
|
|
308
257
|
|
|
309
258
|
```ts
|
|
310
259
|
const messages = createI18n(['ja', 'en'] as const)
|
|
311
260
|
.add({
|
|
312
261
|
title: { ja: 'タイトル', en: 'Title' },
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
en: (ctx) => `${ctx.count} items`,
|
|
318
|
-
},
|
|
262
|
+
items: (ctx: { count: number }) => ({
|
|
263
|
+
ja: `${ctx.count}個のアイテム`,
|
|
264
|
+
en: `${ctx.count} items`,
|
|
265
|
+
}),
|
|
319
266
|
})
|
|
320
267
|
.build('ja');
|
|
321
268
|
|
|
@@ -344,11 +291,11 @@ export const common = createI18n(LOCALES).add({
|
|
|
344
291
|
import { createI18n } from 'canopy-i18n';
|
|
345
292
|
import { LOCALES } from './locales';
|
|
346
293
|
|
|
347
|
-
export const user = createI18n(LOCALES).
|
|
348
|
-
welcome: {
|
|
349
|
-
ja:
|
|
350
|
-
en:
|
|
351
|
-
},
|
|
294
|
+
export const user = createI18n(LOCALES).add({
|
|
295
|
+
welcome: (ctx: { name: string }) => ({
|
|
296
|
+
ja: `ようこそ、${ctx.name}さん`,
|
|
297
|
+
en: `Welcome, ${ctx.name}`,
|
|
298
|
+
}),
|
|
352
299
|
});
|
|
353
300
|
|
|
354
301
|
// i18n/index.ts
|
package/dist/bindLocale.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ChainBuilder } from "./chainBuilder.js";
|
|
2
2
|
import { I18nMessage, type LocalizedMessage } from "./message.js";
|
|
3
3
|
export declare function isChainBuilder(x: unknown): x is ChainBuilder<any, any>;
|
|
4
|
-
type DeepUnwrapChainBuilders<T> = T extends I18nMessage<infer Ls, infer C
|
|
5
|
-
[K in keyof Messages]: Messages[K] extends I18nMessage<Ls, infer C
|
|
4
|
+
type DeepUnwrapChainBuilders<T> = T extends I18nMessage<infer Ls, infer C> ? LocalizedMessage<Ls, C> : T extends ChainBuilder<infer Ls, infer Messages> ? {
|
|
5
|
+
[K in keyof Messages]: Messages[K] extends I18nMessage<Ls, infer C> ? LocalizedMessage<Ls, C> : never;
|
|
6
6
|
} : T extends readonly any[] ? {
|
|
7
7
|
[K in keyof T]: DeepUnwrapChainBuilders<T[K]>;
|
|
8
8
|
} : T extends object ? {
|
package/dist/chainBuilder.d.ts
CHANGED
|
@@ -1,29 +1,20 @@
|
|
|
1
1
|
import { I18nMessage } from "./message.js";
|
|
2
2
|
import type { LocalizedMessage } from "./message.js";
|
|
3
|
-
export declare class ChainBuilder<const Ls extends readonly string[], Messages extends Record<string, I18nMessage<Ls, any
|
|
3
|
+
export declare class ChainBuilder<const Ls extends readonly string[], Messages extends Record<string, I18nMessage<Ls, any>> = {}> {
|
|
4
4
|
private readonly locales;
|
|
5
5
|
private messages;
|
|
6
6
|
constructor(locales: Ls, messages?: Messages);
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
* 型パラメータRでカスタム型も指定可能(デフォルトはstring)
|
|
8
|
+
* 静的メッセージとテンプレート関数を一度に追加
|
|
10
9
|
*/
|
|
11
|
-
add<
|
|
10
|
+
add<Entries extends Record<string, Record<Ls[number], string> | ((ctx: any) => Record<Ls[number], string>)>>(entries: {
|
|
12
11
|
[Key in keyof Entries]: Key extends keyof Messages ? never : Entries[Key];
|
|
13
12
|
}): ChainBuilder<Ls, Messages & {
|
|
14
|
-
[Key in keyof Entries]: I18nMessage<Ls,
|
|
15
|
-
}>;
|
|
16
|
-
/**
|
|
17
|
-
* 関数指定版: 複数のテンプレート関数を一度に追加(型は統一)
|
|
18
|
-
*/
|
|
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
|
-
}) => ChainBuilder<Ls, Messages & {
|
|
22
|
-
[Key in keyof Entries]: I18nMessage<Ls, C, R>;
|
|
13
|
+
[Key in keyof Entries]: Entries[Key] extends (ctx: infer C) => any ? I18nMessage<Ls, C> : I18nMessage<Ls, void>;
|
|
23
14
|
}>;
|
|
24
15
|
private deepCloneWithLocale;
|
|
25
16
|
build<M = {
|
|
26
|
-
[K in keyof Messages]: Messages[K] extends I18nMessage<Ls, infer C
|
|
27
|
-
}>(locale
|
|
17
|
+
[K in keyof Messages]: Messages[K] extends I18nMessage<Ls, infer C> ? LocalizedMessage<Ls, C> : never;
|
|
18
|
+
}>(locale: Ls[number]): M;
|
|
28
19
|
}
|
|
29
20
|
export declare function createI18n<const Ls extends readonly string[]>(locales: Ls): ChainBuilder<Ls, {}>;
|
package/dist/chainBuilder.js
CHANGED
|
@@ -7,29 +7,26 @@ export class ChainBuilder {
|
|
|
7
7
|
this.messages = (messages ?? {});
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
* 型パラメータRでカスタム型も指定可能(デフォルトはstring)
|
|
10
|
+
* 静的メッセージとテンプレート関数を一度に追加
|
|
12
11
|
*/
|
|
13
12
|
add(entries) {
|
|
14
13
|
const newMessages = { ...this.messages };
|
|
15
|
-
for (const [key,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*/
|
|
24
|
-
addTemplates() {
|
|
25
|
-
return (entries) => {
|
|
26
|
-
const newMessages = { ...this.messages };
|
|
27
|
-
for (const [key, data] of Object.entries(entries)) {
|
|
28
|
-
const msg = new I18nMessage(this.locales, this.locales[0]).setData(data);
|
|
14
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
15
|
+
if (typeof value === "function") {
|
|
16
|
+
const fn = value;
|
|
17
|
+
const localeData = {};
|
|
18
|
+
for (const locale of this.locales) {
|
|
19
|
+
localeData[locale] = (ctx) => fn(ctx)[locale];
|
|
20
|
+
}
|
|
21
|
+
const msg = new I18nMessage(this.locales, this.locales[0]).setData(localeData);
|
|
29
22
|
newMessages[key] = msg;
|
|
30
23
|
}
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
else {
|
|
25
|
+
const msg = new I18nMessage(this.locales, this.locales[0]).setData(value);
|
|
26
|
+
newMessages[key] = msg;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return new ChainBuilder(this.locales, newMessages);
|
|
33
30
|
}
|
|
34
31
|
deepCloneWithLocale(obj, locale) {
|
|
35
32
|
if (isI18nMessage(obj)) {
|
|
@@ -51,9 +48,7 @@ export class ChainBuilder {
|
|
|
51
48
|
return obj;
|
|
52
49
|
}
|
|
53
50
|
build(locale) {
|
|
54
|
-
const clonedMessages = locale
|
|
55
|
-
? this.deepCloneWithLocale(this.messages, locale)
|
|
56
|
-
: this.messages;
|
|
51
|
+
const clonedMessages = this.deepCloneWithLocale(this.messages, locale);
|
|
57
52
|
const result = {};
|
|
58
53
|
for (const [key, msg] of Object.entries(clonedMessages)) {
|
|
59
54
|
if (isI18nMessage(msg)) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { bindLocale } from "./bindLocale.js";
|
|
3
|
+
import { createI18n } from "./chainBuilder.js";
|
|
4
|
+
const LOCALES = ["ja", "en"];
|
|
5
|
+
describe("ChainBuilder", () => {
|
|
6
|
+
it("builds multiple messages with method chaining", () => {
|
|
7
|
+
const messages = createI18n(LOCALES)
|
|
8
|
+
.add({
|
|
9
|
+
title: {
|
|
10
|
+
ja: "タイトルテスト",
|
|
11
|
+
en: "Title Test",
|
|
12
|
+
},
|
|
13
|
+
"greeting.hello": {
|
|
14
|
+
ja: "こんにちは",
|
|
15
|
+
en: "Hello",
|
|
16
|
+
},
|
|
17
|
+
"greeting.bye": {
|
|
18
|
+
ja: "さようなら",
|
|
19
|
+
en: "Goodbye",
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
.build("ja");
|
|
23
|
+
expect(messages.title()).toBe("タイトルテスト");
|
|
24
|
+
expect(messages["greeting.hello"]()).toBe("こんにちは");
|
|
25
|
+
expect(messages["greeting.bye"]()).toBe("さようなら");
|
|
26
|
+
});
|
|
27
|
+
it("supports template functions with type inference", () => {
|
|
28
|
+
const messages = createI18n(LOCALES)
|
|
29
|
+
.add({
|
|
30
|
+
welcome: {
|
|
31
|
+
ja: "ようこそ",
|
|
32
|
+
en: "Welcome",
|
|
33
|
+
},
|
|
34
|
+
greet: (ctx) => ({
|
|
35
|
+
ja: `こんにちは、${ctx.name}さん。${ctx.age}歳ですね。`,
|
|
36
|
+
en: `Hello, ${ctx.name}. You are ${ctx.age}.`,
|
|
37
|
+
}),
|
|
38
|
+
})
|
|
39
|
+
.build("ja");
|
|
40
|
+
expect(messages.welcome()).toBe("ようこそ");
|
|
41
|
+
expect(messages.greet({ name: "太郎", age: 25 })).toBe("こんにちは、太郎さん。25歳ですね。");
|
|
42
|
+
});
|
|
43
|
+
it("supports adding multiple template messages at once", () => {
|
|
44
|
+
const messages = createI18n(LOCALES)
|
|
45
|
+
.add({
|
|
46
|
+
greet: (ctx) => ({
|
|
47
|
+
ja: `こんにちは、${ctx.name}さん`,
|
|
48
|
+
en: `Hello, ${ctx.name}`,
|
|
49
|
+
}),
|
|
50
|
+
farewell: (ctx) => ({
|
|
51
|
+
ja: `さようなら、${ctx.name}さん`,
|
|
52
|
+
en: `Goodbye, ${ctx.name}`,
|
|
53
|
+
}),
|
|
54
|
+
})
|
|
55
|
+
.build("ja");
|
|
56
|
+
expect(messages.greet({ name: "太郎" })).toBe("こんにちは、太郎さん");
|
|
57
|
+
expect(messages.farewell({ name: "花子" })).toBe("さようなら、花子さん");
|
|
58
|
+
});
|
|
59
|
+
it("works with applyLocale function", () => {
|
|
60
|
+
const builder = createI18n(LOCALES)
|
|
61
|
+
.add({
|
|
62
|
+
title: { ja: "タイトル", en: "Title" },
|
|
63
|
+
msg: (c) => ({
|
|
64
|
+
ja: `こんにちは、${c.name}さん`,
|
|
65
|
+
en: `Hello, ${c.name}`,
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
const localized = bindLocale(builder, "en");
|
|
69
|
+
expect(localized.title()).toBe("Title");
|
|
70
|
+
expect(localized.msg({ name: "John" })).toBe("Hello, John");
|
|
71
|
+
});
|
|
72
|
+
it("build() with locale parameter applies locale before building", () => {
|
|
73
|
+
const messages = createI18n(LOCALES)
|
|
74
|
+
.add({
|
|
75
|
+
title: { ja: "タイトル", en: "Title" },
|
|
76
|
+
greeting: { ja: "こんにちは", en: "Hello" },
|
|
77
|
+
welcome: (ctx) => ({
|
|
78
|
+
ja: `ようこそ、${ctx.name}さん`,
|
|
79
|
+
en: `Welcome, ${ctx.name}`,
|
|
80
|
+
}),
|
|
81
|
+
})
|
|
82
|
+
.build("en");
|
|
83
|
+
expect(messages.title()).toBe("Title");
|
|
84
|
+
expect(messages.greeting()).toBe("Hello");
|
|
85
|
+
expect(messages.welcome({ name: "John" })).toBe("Welcome, John");
|
|
86
|
+
});
|
|
87
|
+
it("build(locale) does not mutate the builder instance", () => {
|
|
88
|
+
const builder = createI18n(LOCALES)
|
|
89
|
+
.add({
|
|
90
|
+
title: { ja: "タイトル", en: "Title" },
|
|
91
|
+
});
|
|
92
|
+
const englishMessages = builder.build("en");
|
|
93
|
+
const japaneseMessages = builder.build("ja");
|
|
94
|
+
expect(englishMessages.title()).toBe("Title");
|
|
95
|
+
expect(japaneseMessages.title()).toBe("タイトル");
|
|
96
|
+
});
|
|
97
|
+
it("applyLocale works with ChainBuilder instances", () => {
|
|
98
|
+
const builders = {
|
|
99
|
+
builder1: createI18n(LOCALES).add({ title: { ja: "タイトル", en: "Title" } }),
|
|
100
|
+
builder2: createI18n(LOCALES).add({ greeting: { ja: "こんにちは", en: "Hello" } }),
|
|
101
|
+
};
|
|
102
|
+
const buildersApplied = bindLocale(builders, "ja");
|
|
103
|
+
expect(buildersApplied.builder1.title()).toBe("タイトル");
|
|
104
|
+
expect(buildersApplied.builder2.greeting()).toBe("こんにちは");
|
|
105
|
+
});
|
|
106
|
+
it("applyLocale works deeply with nested ChainBuilder instances", () => {
|
|
107
|
+
const builders = {
|
|
108
|
+
builder1: {
|
|
109
|
+
builder1child: createI18n(LOCALES).add({ title: { ja: "タイトル", en: "Title" } }),
|
|
110
|
+
},
|
|
111
|
+
builder2: createI18n(LOCALES).add({ greeting: { ja: "こんにちは", en: "Hello" } }),
|
|
112
|
+
};
|
|
113
|
+
const buildersApplied = bindLocale(builders, "ja");
|
|
114
|
+
expect(buildersApplied.builder1.builder1child.title()).toBe("タイトル");
|
|
115
|
+
expect(buildersApplied.builder2.greeting()).toBe("こんにちは");
|
|
116
|
+
});
|
|
117
|
+
it("applyLocale works with very deep nested structures", () => {
|
|
118
|
+
const builders = {
|
|
119
|
+
level1: {
|
|
120
|
+
level2: {
|
|
121
|
+
level3: createI18n(LOCALES).add({ deep: { ja: "深い", en: "Deep" } }),
|
|
122
|
+
},
|
|
123
|
+
builder: createI18n(LOCALES).add({ msg: { ja: "メッセージ", en: "Message" } }),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const buildersApplied = bindLocale(builders, "en");
|
|
127
|
+
expect(buildersApplied.level1.level2.level3.deep()).toBe("Deep");
|
|
128
|
+
expect(buildersApplied.level1.builder.msg()).toBe("Message");
|
|
129
|
+
});
|
|
130
|
+
});
|
package/dist/message.d.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import type { Template } from "./types.js";
|
|
2
|
-
export type LocalizedMessage<Ls extends readonly string[], C
|
|
2
|
+
export type LocalizedMessage<Ls extends readonly string[], C> = C extends void ? (() => string) & {
|
|
3
3
|
__brand: "I18nMessage";
|
|
4
|
-
} : ((ctx: C) =>
|
|
4
|
+
} : ((ctx: C) => string) & {
|
|
5
5
|
__brand: "I18nTemplateMessage";
|
|
6
6
|
};
|
|
7
|
-
export declare function isI18nMessage(x: unknown): x is I18nMessage<any, any
|
|
8
|
-
export declare class I18nMessage<Ls extends readonly string[], C
|
|
7
|
+
export declare function isI18nMessage(x: unknown): x is I18nMessage<any, any>;
|
|
8
|
+
export declare class I18nMessage<Ls extends readonly string[], C> {
|
|
9
9
|
readonly locales: Ls;
|
|
10
10
|
private _locale;
|
|
11
11
|
private _data;
|
|
12
12
|
constructor(locales: Ls, locale: Ls[number]);
|
|
13
13
|
get locale(): Ls[number];
|
|
14
14
|
setLocale(locale: Ls[number]): this;
|
|
15
|
-
get data(): Record<Ls[number], Template<C
|
|
16
|
-
setData(data: Record<Ls[number], Template<C
|
|
15
|
+
get data(): Record<Ls[number], Template<C>>;
|
|
16
|
+
setData(data: Record<Ls[number], Template<C>>): this;
|
|
17
17
|
private render;
|
|
18
|
-
toFunction(): LocalizedMessage<Ls, C
|
|
18
|
+
toFunction(): LocalizedMessage<Ls, C>;
|
|
19
19
|
}
|
package/dist/testtest.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createI18n } from "./chainBuilder.js";
|
|
2
2
|
const a = createI18n(["ja", "en"]);
|
|
3
3
|
const b = a
|
|
4
|
-
.add({
|
|
5
|
-
|
|
6
|
-
bb:
|
|
4
|
+
.add({
|
|
5
|
+
abc: { ja: "abc", en: "abc" },
|
|
6
|
+
bb: (ctx) => ({ ja: `${ctx.aa}aa`, en: `${ctx.aa}bb` }),
|
|
7
7
|
});
|
|
8
8
|
const c = b.build("en");
|
|
9
9
|
const e = { ...c.abc, toString: () => "aaa" };
|
|
@@ -11,9 +11,9 @@ const d = b.add({
|
|
|
11
11
|
aaa: { ja: "aaa", en: "aaa" },
|
|
12
12
|
bbb: { ja: "bbb", en: "bbb" },
|
|
13
13
|
});
|
|
14
|
-
const f = b.
|
|
15
|
-
aaa:
|
|
16
|
-
bbb:
|
|
14
|
+
const f = b.add({
|
|
15
|
+
aaa: (ctx) => ({ ja: `${ctx.a}aaa`, en: `${ctx.a}aaa` }),
|
|
16
|
+
bbb: (ctx) => ({ ja: `${ctx.a}aaa`, en: `${ctx.a}aaa` }),
|
|
17
17
|
});
|
|
18
18
|
console.log(c.bb({ aa: "name" }));
|
|
19
19
|
console.log(`${c.bb({ aa: "name" })}`);
|
package/dist/types.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type Template<C
|
|
2
|
-
export declare function isTemplateFunction<C
|
|
1
|
+
export type Template<C> = string | ((ctx: C) => string);
|
|
2
|
+
export declare function isTemplateFunction<C>(t: Template<C>): t is (ctx: C) => string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canopy-i18n",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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,476 @@
|
|
|
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 (static and template), build, bindLocale, React integration, and common gotchas like required `as const`.
|
|
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(entries)`
|
|
61
|
+
|
|
62
|
+
Adds multiple messages at once. Each entry can be a static locale record or a template function.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// Static messages
|
|
66
|
+
const builder = createI18n(['en', 'ja'] as const)
|
|
67
|
+
.add({
|
|
68
|
+
title: { en: 'Title', ja: 'タイトル' },
|
|
69
|
+
greeting: { en: 'Hello', ja: 'こんにちは' },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Template functions
|
|
73
|
+
const builder2 = createI18n(['en', 'ja'] as const)
|
|
74
|
+
.add({
|
|
75
|
+
greeting: (ctx: { name: string; age: number }) => ({
|
|
76
|
+
en: `Hello, ${ctx.name}. You are ${ctx.age}.`,
|
|
77
|
+
ja: `こんにちは、${ctx.name}さん。${ctx.age}歳です。`,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Mixing static and template messages in a single add()
|
|
82
|
+
const builder3 = createI18n(['en', 'ja'] as const)
|
|
83
|
+
.add({
|
|
84
|
+
title: { en: 'Title', ja: 'タイトル' },
|
|
85
|
+
greeting: (ctx: { name: string }) => ({
|
|
86
|
+
en: `Hello, ${ctx.name}`,
|
|
87
|
+
ja: `こんにちは、${ctx.name}さん`,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **entries**: `Record<K, Record<Locale, string> | ((ctx: C) => Record<Locale, string>)>`
|
|
93
|
+
- **Returns**: new `ChainBuilder` (immutable)
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### `.build(locale)`
|
|
98
|
+
|
|
99
|
+
Builds the final messages object.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const builder = createI18n(['en', 'ja'] as const)
|
|
103
|
+
.add({ title: { en: 'Title', ja: 'タイトル' } });
|
|
104
|
+
|
|
105
|
+
const enMessages = builder.build('en');
|
|
106
|
+
const jaMessages = builder.build('ja');
|
|
107
|
+
|
|
108
|
+
// All messages are called as functions
|
|
109
|
+
console.log(enMessages.title()); // "Title"
|
|
110
|
+
console.log(jaMessages.title()); // "タイトル"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- **Argument `locale`**: required
|
|
114
|
+
- **Returns**: `{ [key]: () => R }` or `{ [key]: (ctx: C) => R }`
|
|
115
|
+
- **Immutable**: `.build()` does not mutate the builder — you can generate multiple locales from one builder
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### `bindLocale(obj, locale)`
|
|
120
|
+
|
|
121
|
+
Recursively traverses an object/array and calls `.build(locale)` on all `ChainBuilder` instances found. Used for the namespace pattern (split files). Since `build()` requires a locale, `bindLocale` provides it at the point of use.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { bindLocale } from 'canopy-i18n';
|
|
125
|
+
|
|
126
|
+
const data = {
|
|
127
|
+
common: commonBuilder,
|
|
128
|
+
nested: {
|
|
129
|
+
user: userBuilder,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const messages = bindLocale(data, 'en');
|
|
134
|
+
console.log(messages.common.hello()); // "Hello"
|
|
135
|
+
console.log(messages.nested.user.welcome({ name: 'John' })); // "Welcome, John"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- **Argument `obj`**: any object/array containing `ChainBuilder` instances
|
|
139
|
+
- **Argument `locale`**: locale string to apply
|
|
140
|
+
- **Returns**: new structure with all builders resolved
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Critical Gotchas
|
|
145
|
+
|
|
146
|
+
### 1. `as const` is required
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
// ✅ Correct
|
|
150
|
+
createI18n(['en', 'ja'] as const)
|
|
151
|
+
|
|
152
|
+
// ❌ Type error — locale keys become string, inference breaks
|
|
153
|
+
createI18n(['en', 'ja'])
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### 2. `.build()` is immutable
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const builder = createI18n(['en', 'ja'] as const).add({ ... });
|
|
160
|
+
|
|
161
|
+
// ✅ Multiple locales from one builder
|
|
162
|
+
const enMessages = builder.build('en');
|
|
163
|
+
const jaMessages = builder.build('ja');
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### 3. ESM only
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
// Required in package.json
|
|
170
|
+
{ "type": "module" }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### 4. All messages must be called as functions
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const m = builder.build('en');
|
|
177
|
+
|
|
178
|
+
// ✅ Call as a function
|
|
179
|
+
m.title()
|
|
180
|
+
m.greeting({ name: 'Alice' })
|
|
181
|
+
|
|
182
|
+
// ❌ Do not access as property — it is a function object, not a string
|
|
183
|
+
m.title
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Common Patterns
|
|
189
|
+
|
|
190
|
+
### Basic String Messages
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { createI18n } from 'canopy-i18n';
|
|
194
|
+
|
|
195
|
+
const messages = createI18n(['en', 'ja'] as const)
|
|
196
|
+
.add({
|
|
197
|
+
title: { en: 'Title', ja: 'タイトル' },
|
|
198
|
+
greeting: { en: 'Hello', ja: 'こんにちは' },
|
|
199
|
+
farewell: { en: 'Goodbye', ja: 'さようなら' },
|
|
200
|
+
})
|
|
201
|
+
.build('en');
|
|
202
|
+
|
|
203
|
+
console.log(messages.title()); // "Title"
|
|
204
|
+
console.log(messages.greeting()); // "Hello"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Template Functions (Variable Interpolation)
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { createI18n } from 'canopy-i18n';
|
|
211
|
+
|
|
212
|
+
const messages = createI18n(['en', 'ja'] as const)
|
|
213
|
+
.add({
|
|
214
|
+
profile: (ctx: { name: string; age: number }) => ({
|
|
215
|
+
en: `Name: ${ctx.name}, Age: ${ctx.age}`,
|
|
216
|
+
ja: `名前: ${ctx.name}、年齢: ${ctx.age}歳`,
|
|
217
|
+
}),
|
|
218
|
+
})
|
|
219
|
+
.build('en');
|
|
220
|
+
|
|
221
|
+
console.log(messages.profile({ name: 'Taro', age: 25 }));
|
|
222
|
+
// "Name: Taro, Age: 25"
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Mixing Static and Template Messages
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { createI18n } from 'canopy-i18n';
|
|
229
|
+
|
|
230
|
+
const messages = createI18n(['en', 'ja'] as const)
|
|
231
|
+
.add({
|
|
232
|
+
title: { en: 'Items', ja: 'アイテム' },
|
|
233
|
+
count: (ctx: { count: number }) => ({
|
|
234
|
+
en: `${ctx.count} items`,
|
|
235
|
+
ja: `${ctx.count}個のアイテム`,
|
|
236
|
+
}),
|
|
237
|
+
})
|
|
238
|
+
.build('en');
|
|
239
|
+
|
|
240
|
+
console.log(messages.title()); // "Items"
|
|
241
|
+
console.log(messages.count({ count: 5 })); // "5 items"
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Namespace Pattern (Split Files + bindLocale)
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
// i18n/locales.ts
|
|
248
|
+
export const LOCALES = ['en', 'ja'] as const;
|
|
249
|
+
export type Locale = (typeof LOCALES)[number];
|
|
250
|
+
|
|
251
|
+
// i18n/common.ts
|
|
252
|
+
import { createI18n } from 'canopy-i18n';
|
|
253
|
+
import { LOCALES } from './locales';
|
|
254
|
+
|
|
255
|
+
export const common = createI18n(LOCALES).add({
|
|
256
|
+
hello: { en: 'Hello', ja: 'こんにちは' },
|
|
257
|
+
goodbye: { en: 'Goodbye', ja: 'さようなら' },
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// i18n/user.ts
|
|
261
|
+
import { createI18n } from 'canopy-i18n';
|
|
262
|
+
import { LOCALES } from './locales';
|
|
263
|
+
|
|
264
|
+
export const user = createI18n(LOCALES)
|
|
265
|
+
.add({
|
|
266
|
+
welcome: (ctx: { name: string }) => ({
|
|
267
|
+
en: `Welcome, ${ctx.name}`,
|
|
268
|
+
ja: `ようこそ、${ctx.name}さん`,
|
|
269
|
+
}),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// i18n/index.ts
|
|
273
|
+
export { common } from './common';
|
|
274
|
+
export { user } from './user';
|
|
275
|
+
|
|
276
|
+
// app.ts
|
|
277
|
+
import { bindLocale } from 'canopy-i18n';
|
|
278
|
+
import * as i18n from './i18n';
|
|
279
|
+
|
|
280
|
+
const messages = bindLocale(i18n, 'en');
|
|
281
|
+
console.log(messages.common.hello()); // "Hello"
|
|
282
|
+
console.log(messages.user.welcome({ name: 'John' })); // "Welcome, John"
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Deep Nested Structures
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { createI18n, bindLocale } from 'canopy-i18n';
|
|
289
|
+
|
|
290
|
+
const structure = {
|
|
291
|
+
header: createI18n(['en', 'ja'] as const)
|
|
292
|
+
.add({ title: { en: 'Header', ja: 'ヘッダー' } }),
|
|
293
|
+
content: {
|
|
294
|
+
main: createI18n(['en', 'ja'] as const)
|
|
295
|
+
.add({ body: { en: 'Body', ja: '本文' } }),
|
|
296
|
+
sidebar: createI18n(['en', 'ja'] as const)
|
|
297
|
+
.add({ widget: { en: 'Widget', ja: 'ウィジェット' } }),
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const localized = bindLocale(structure, 'en');
|
|
302
|
+
console.log(localized.header.title()); // "Header"
|
|
303
|
+
console.log(localized.content.main.body()); // "Body"
|
|
304
|
+
console.log(localized.content.sidebar.widget()); // "Widget"
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## React Integration
|
|
310
|
+
|
|
311
|
+
### Locale Context
|
|
312
|
+
|
|
313
|
+
```tsx
|
|
314
|
+
// LocaleContext.tsx
|
|
315
|
+
import { bindLocale } from 'canopy-i18n';
|
|
316
|
+
import { createContext, useContext, useState } from 'react';
|
|
317
|
+
|
|
318
|
+
type Locale = 'en' | 'ja';
|
|
319
|
+
|
|
320
|
+
type ContextType = {
|
|
321
|
+
locale: Locale;
|
|
322
|
+
setLocale: (locale: Locale) => void;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const LocaleContext = createContext<ContextType | undefined>(undefined);
|
|
326
|
+
|
|
327
|
+
export function LocaleProvider({ children }: { children: React.ReactNode }) {
|
|
328
|
+
const [locale, setLocale] = useState<Locale>('en');
|
|
329
|
+
return (
|
|
330
|
+
<LocaleContext.Provider value={{ locale, setLocale }}>
|
|
331
|
+
{children}
|
|
332
|
+
</LocaleContext.Provider>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function useLocale() {
|
|
337
|
+
const ctx = useContext(LocaleContext);
|
|
338
|
+
if (!ctx) throw new Error('useLocale must be used within a LocaleProvider');
|
|
339
|
+
return ctx;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Reactively applies bindLocale based on current locale
|
|
343
|
+
export function useBindLocale<T extends object>(msgsDef: T) {
|
|
344
|
+
const { locale } = useLocale();
|
|
345
|
+
return bindLocale(msgsDef, locale);
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Usage in Components
|
|
350
|
+
|
|
351
|
+
```tsx
|
|
352
|
+
// i18n.ts — export ChainBuilders (not yet built)
|
|
353
|
+
import { createI18n } from 'canopy-i18n';
|
|
354
|
+
|
|
355
|
+
const LOCALES = ['en', 'ja'] as const;
|
|
356
|
+
export const defineMessage = () => createI18n(LOCALES);
|
|
357
|
+
|
|
358
|
+
export const appI18n = defineMessage()
|
|
359
|
+
.add({
|
|
360
|
+
title: { en: 'My App', ja: 'マイアプリ' },
|
|
361
|
+
description: { en: 'Welcome!', ja: 'ようこそ!' },
|
|
362
|
+
greeting: (ctx: { name: string }) => ({
|
|
363
|
+
en: `Hello, ${ctx.name}!`,
|
|
364
|
+
ja: `こんにちは、${ctx.name}さん!`,
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// App.tsx — apply locale with useBindLocale
|
|
369
|
+
import { useBindLocale } from './LocaleContext';
|
|
370
|
+
import { appI18n } from './i18n';
|
|
371
|
+
|
|
372
|
+
export default function App() {
|
|
373
|
+
const m = useBindLocale(appI18n);
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<div>
|
|
377
|
+
<h1>{m.title()}</h1>
|
|
378
|
+
<p>{m.description()}</p>
|
|
379
|
+
<p>{m.greeting({ name: 'Taro' })}</p>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Component-Local i18n (Colocation)
|
|
386
|
+
|
|
387
|
+
```tsx
|
|
388
|
+
// ProfileCard.tsx — define and use i18n in the same file
|
|
389
|
+
import { createI18n } from 'canopy-i18n';
|
|
390
|
+
import type { JSX } from 'react';
|
|
391
|
+
import { useBindLocale } from './LocaleContext';
|
|
392
|
+
|
|
393
|
+
const profileI18n = createI18n(['en', 'ja'] as const)
|
|
394
|
+
.add({
|
|
395
|
+
title: { en: 'User Profile', ja: 'ユーザープロフィール' },
|
|
396
|
+
editButton: { en: 'Edit Profile', ja: 'プロフィール編集' },
|
|
397
|
+
greeting: (ctx: { name: string }) => ({
|
|
398
|
+
en: `Welcome, ${ctx.name}!`,
|
|
399
|
+
ja: `ようこそ、${ctx.name}さん!`,
|
|
400
|
+
}),
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
export function ProfileCard({ name }: { name: string }) {
|
|
404
|
+
const m = useBindLocale(profileI18n);
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<div>
|
|
408
|
+
<h2>{m.title()}</h2>
|
|
409
|
+
<p>{m.greeting({ name })}</p>
|
|
410
|
+
<button>{m.editButton()}</button>
|
|
411
|
+
</div>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Language Switcher Component
|
|
417
|
+
|
|
418
|
+
```tsx
|
|
419
|
+
// LanguageSwitcher.tsx
|
|
420
|
+
import { useLocale } from './LocaleContext';
|
|
421
|
+
|
|
422
|
+
export function LanguageSwitcher() {
|
|
423
|
+
const { locale, setLocale } = useLocale();
|
|
424
|
+
|
|
425
|
+
return (
|
|
426
|
+
<div>
|
|
427
|
+
<button onClick={() => setLocale('en')} disabled={locale === 'en'}>EN</button>
|
|
428
|
+
<button onClick={() => setLocale('ja')} disabled={locale === 'ja'}>JA</button>
|
|
429
|
+
</div>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## Exports Reference
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
// Functions & Classes
|
|
440
|
+
export { createI18n } from 'canopy-i18n'; // create a builder
|
|
441
|
+
export { ChainBuilder } from 'canopy-i18n'; // builder class
|
|
442
|
+
export { I18nMessage } from 'canopy-i18n'; // message class
|
|
443
|
+
export { isI18nMessage } from 'canopy-i18n'; // type guard
|
|
444
|
+
export { bindLocale } from 'canopy-i18n'; // apply locale to nested structure
|
|
445
|
+
export { isChainBuilder } from 'canopy-i18n'; // type guard
|
|
446
|
+
|
|
447
|
+
// Types
|
|
448
|
+
export type { Template } from 'canopy-i18n'; // R | ((ctx: C) => R)
|
|
449
|
+
export type { LocalizedMessage } from 'canopy-i18n'; // built message function type
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Type Details
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
// Template<C, R>: a static value or a function that receives context
|
|
456
|
+
type Template<C, R = string> = R | ((ctx: C) => R);
|
|
457
|
+
|
|
458
|
+
// LocalizedMessage<Ls, C, R>: the function type after build()
|
|
459
|
+
// - when C is void: () => R
|
|
460
|
+
// - when C is present: (ctx: C) => R
|
|
461
|
+
type LocalizedMessage<Ls, C, R = string> =
|
|
462
|
+
C extends void
|
|
463
|
+
? (() => R) & { __brand: "I18nMessage" }
|
|
464
|
+
: ((ctx: C) => R) & { __brand: "I18nTemplateMessage" };
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
---
|
|
468
|
+
|
|
469
|
+
## Common Mistakes
|
|
470
|
+
|
|
471
|
+
| Mistake | Fix |
|
|
472
|
+
|---------|-----|
|
|
473
|
+
| `createI18n(['en', 'ja'])` | `createI18n(['en', 'ja'] as const)` |
|
|
474
|
+
| `messages.title` | `messages.title()` (call as function) |
|
|
475
|
+
| CommonJS `require()` | Use ESM `import` |
|
|
476
|
+
| Typo in locale key | TypeScript catches it at compile time |
|