canopy-i18n 0.6.1 → 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 +1 -1
- package/skills/SKILL.md +49 -149
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
package/skills/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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
|
|
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
4
|
---
|
|
5
5
|
|
|
6
6
|
# canopy-i18n — AI Code Generation Reference
|
|
@@ -57,70 +57,44 @@ const builder = createI18n(['en', 'ja']);
|
|
|
57
57
|
|
|
58
58
|
---
|
|
59
59
|
|
|
60
|
-
### `.add
|
|
60
|
+
### `.add(entries)`
|
|
61
61
|
|
|
62
|
-
Adds multiple static
|
|
62
|
+
Adds multiple messages at once. Each entry can be a static locale record or a template function.
|
|
63
63
|
|
|
64
64
|
```ts
|
|
65
|
-
//
|
|
65
|
+
// Static messages
|
|
66
66
|
const builder = createI18n(['en', 'ja'] as const)
|
|
67
67
|
.add({
|
|
68
68
|
title: { en: 'Title', ja: 'タイトル' },
|
|
69
69
|
greeting: { en: 'Hello', ja: 'こんにちは' },
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
},
|
|
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
|
+
}),
|
|
103
79
|
});
|
|
104
80
|
|
|
105
|
-
//
|
|
106
|
-
const
|
|
107
|
-
.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
}),
|
|
112
89
|
});
|
|
113
90
|
```
|
|
114
91
|
|
|
115
|
-
- **
|
|
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>>`
|
|
92
|
+
- **entries**: `Record<K, Record<Locale, string> | ((ctx: C) => Record<Locale, string>)>`
|
|
119
93
|
- **Returns**: new `ChainBuilder` (immutable)
|
|
120
94
|
|
|
121
95
|
---
|
|
122
96
|
|
|
123
|
-
### `.build(locale
|
|
97
|
+
### `.build(locale)`
|
|
124
98
|
|
|
125
99
|
Builds the final messages object.
|
|
126
100
|
|
|
@@ -128,19 +102,15 @@ Builds the final messages object.
|
|
|
128
102
|
const builder = createI18n(['en', 'ja'] as const)
|
|
129
103
|
.add({ title: { en: 'Title', ja: 'タイトル' } });
|
|
130
104
|
|
|
131
|
-
// With specific locale
|
|
132
105
|
const enMessages = builder.build('en');
|
|
133
106
|
const jaMessages = builder.build('ja');
|
|
134
107
|
|
|
135
|
-
// Without locale — defaults to first locale in array
|
|
136
|
-
const defaultMessages = builder.build(); // uses 'en'
|
|
137
|
-
|
|
138
108
|
// All messages are called as functions
|
|
139
109
|
console.log(enMessages.title()); // "Title"
|
|
140
110
|
console.log(jaMessages.title()); // "タイトル"
|
|
141
111
|
```
|
|
142
112
|
|
|
143
|
-
- **Argument `locale`**:
|
|
113
|
+
- **Argument `locale`**: required
|
|
144
114
|
- **Returns**: `{ [key]: () => R }` or `{ [key]: (ctx: C) => R }`
|
|
145
115
|
- **Immutable**: `.build()` does not mutate the builder — you can generate multiple locales from one builder
|
|
146
116
|
|
|
@@ -148,7 +118,7 @@ console.log(jaMessages.title()); // "タイトル"
|
|
|
148
118
|
|
|
149
119
|
### `bindLocale(obj, locale)`
|
|
150
120
|
|
|
151
|
-
Recursively traverses an object/array and calls `.build(locale)` on all `ChainBuilder` instances found. Used for the namespace pattern (split files).
|
|
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.
|
|
152
122
|
|
|
153
123
|
```ts
|
|
154
124
|
import { bindLocale } from 'canopy-i18n';
|
|
@@ -183,21 +153,7 @@ createI18n(['en', 'ja'] as const)
|
|
|
183
153
|
createI18n(['en', 'ja'])
|
|
184
154
|
```
|
|
185
155
|
|
|
186
|
-
### 2. `
|
|
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
|
|
156
|
+
### 2. `.build()` is immutable
|
|
201
157
|
|
|
202
158
|
```ts
|
|
203
159
|
const builder = createI18n(['en', 'ja'] as const).add({ ... });
|
|
@@ -207,14 +163,14 @@ const enMessages = builder.build('en');
|
|
|
207
163
|
const jaMessages = builder.build('ja');
|
|
208
164
|
```
|
|
209
165
|
|
|
210
|
-
###
|
|
166
|
+
### 3. ESM only
|
|
211
167
|
|
|
212
168
|
```json
|
|
213
169
|
// Required in package.json
|
|
214
170
|
{ "type": "module" }
|
|
215
171
|
```
|
|
216
172
|
|
|
217
|
-
###
|
|
173
|
+
### 4. All messages must be called as functions
|
|
218
174
|
|
|
219
175
|
```ts
|
|
220
176
|
const m = builder.build('en');
|
|
@@ -254,11 +210,11 @@ console.log(messages.greeting()); // "Hello"
|
|
|
254
210
|
import { createI18n } from 'canopy-i18n';
|
|
255
211
|
|
|
256
212
|
const messages = createI18n(['en', 'ja'] as const)
|
|
257
|
-
.
|
|
258
|
-
profile: {
|
|
259
|
-
en:
|
|
260
|
-
ja:
|
|
261
|
-
},
|
|
213
|
+
.add({
|
|
214
|
+
profile: (ctx: { name: string; age: number }) => ({
|
|
215
|
+
en: `Name: ${ctx.name}, Age: ${ctx.age}`,
|
|
216
|
+
ja: `名前: ${ctx.name}、年齢: ${ctx.age}歳`,
|
|
217
|
+
}),
|
|
262
218
|
})
|
|
263
219
|
.build('en');
|
|
264
220
|
|
|
@@ -274,12 +230,10 @@ import { createI18n } from 'canopy-i18n';
|
|
|
274
230
|
const messages = createI18n(['en', 'ja'] as const)
|
|
275
231
|
.add({
|
|
276
232
|
title: { en: 'Items', ja: 'アイテム' },
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
ja: (ctx) => `${ctx.count}個のアイテム`,
|
|
282
|
-
},
|
|
233
|
+
count: (ctx: { count: number }) => ({
|
|
234
|
+
en: `${ctx.count} items`,
|
|
235
|
+
ja: `${ctx.count}個のアイテム`,
|
|
236
|
+
}),
|
|
283
237
|
})
|
|
284
238
|
.build('en');
|
|
285
239
|
|
|
@@ -287,55 +241,6 @@ console.log(messages.title()); // "Items"
|
|
|
287
241
|
console.log(messages.count({ count: 5 })); // "5 items"
|
|
288
242
|
```
|
|
289
243
|
|
|
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
244
|
### Namespace Pattern (Split Files + bindLocale)
|
|
340
245
|
|
|
341
246
|
```ts
|
|
@@ -357,11 +262,11 @@ import { createI18n } from 'canopy-i18n';
|
|
|
357
262
|
import { LOCALES } from './locales';
|
|
358
263
|
|
|
359
264
|
export const user = createI18n(LOCALES)
|
|
360
|
-
.
|
|
361
|
-
welcome: {
|
|
362
|
-
en:
|
|
363
|
-
ja:
|
|
364
|
-
},
|
|
265
|
+
.add({
|
|
266
|
+
welcome: (ctx: { name: string }) => ({
|
|
267
|
+
en: `Welcome, ${ctx.name}`,
|
|
268
|
+
ja: `ようこそ、${ctx.name}さん`,
|
|
269
|
+
}),
|
|
365
270
|
});
|
|
366
271
|
|
|
367
272
|
// i18n/index.ts
|
|
@@ -454,12 +359,10 @@ export const appI18n = defineMessage()
|
|
|
454
359
|
.add({
|
|
455
360
|
title: { en: 'My App', ja: 'マイアプリ' },
|
|
456
361
|
description: { en: 'Welcome!', ja: 'ようこそ!' },
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
ja: (ctx) => `こんにちは、${ctx.name}さん!`,
|
|
462
|
-
},
|
|
362
|
+
greeting: (ctx: { name: string }) => ({
|
|
363
|
+
en: `Hello, ${ctx.name}!`,
|
|
364
|
+
ja: `こんにちは、${ctx.name}さん!`,
|
|
365
|
+
}),
|
|
463
366
|
});
|
|
464
367
|
|
|
465
368
|
// App.tsx — apply locale with useBindLocale
|
|
@@ -491,12 +394,10 @@ const profileI18n = createI18n(['en', 'ja'] as const)
|
|
|
491
394
|
.add({
|
|
492
395
|
title: { en: 'User Profile', ja: 'ユーザープロフィール' },
|
|
493
396
|
editButton: { en: 'Edit Profile', ja: 'プロフィール編集' },
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
ja: ({ name }) => <strong>ようこそ、{name}さん!</strong>,
|
|
499
|
-
},
|
|
397
|
+
greeting: (ctx: { name: string }) => ({
|
|
398
|
+
en: `Welcome, ${ctx.name}!`,
|
|
399
|
+
ja: `ようこそ、${ctx.name}さん!`,
|
|
400
|
+
}),
|
|
500
401
|
});
|
|
501
402
|
|
|
502
403
|
export function ProfileCard({ name }: { name: string }) {
|
|
@@ -505,7 +406,7 @@ export function ProfileCard({ name }: { name: string }) {
|
|
|
505
406
|
return (
|
|
506
407
|
<div>
|
|
507
408
|
<h2>{m.title()}</h2>
|
|
508
|
-
<
|
|
409
|
+
<p>{m.greeting({ name })}</p>
|
|
509
410
|
<button>{m.editButton()}</button>
|
|
510
411
|
</div>
|
|
511
412
|
);
|
|
@@ -570,7 +471,6 @@ type LocalizedMessage<Ls, C, R = string> =
|
|
|
570
471
|
| Mistake | Fix |
|
|
571
472
|
|---------|-----|
|
|
572
473
|
| `createI18n(['en', 'ja'])` | `createI18n(['en', 'ja'] as const)` |
|
|
573
|
-
| `.addTemplates<C>({ ... })` | `.addTemplates<C>()({ ... })` (two-step) |
|
|
574
474
|
| `messages.title` | `messages.title()` (call as function) |
|
|
575
475
|
| CommonJS `require()` | Use ESM `import` |
|
|
576
476
|
| Typo in locale key | TypeScript catches it at compile time |
|