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 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
- .addTemplates<{ name: string }>()({
49
- welcome: {
50
- en: ({ name }) => `Welcome, ${name}!`,
51
- ja: ({ name }) => `ようこそ、${name}さん!`
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
- .addTemplates<{ name: string; age: number }>()({
114
- welcome: {
115
- ja: (ctx) => `こんにちは、${ctx.name}さん。あなたは${ctx.age}歳です。`,
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<ReturnType = string, K extends string = string>(entries)`
151
- Adds multiple messages at once. By default, returns `string`, but you can specify a custom return type.
131
+ #### `.add(entries)`
132
+ Adds multiple messages at once. Each entry can be a static locale record or a template function.
152
133
 
153
- - **ReturnType**: (optional) Type parameter for the return value (defaults to `string`)
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
- // String messages (default)
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
- // Custom return type (e.g., React components)
167
- const messages = createI18n(['ja', 'en'] as const)
168
- .add<JSX.Element>({
169
- badge: {
170
- ja: <span style={{ background: '#ff4444', color: 'white', padding: '2px 6px', borderRadius: '2px' }}>🔴 新着</span>,
171
- en: <span style={{ background: '#4caf50', color: 'white', padding: '4px 12px', borderRadius: '16px' }}>✨ NEW</span>,
172
- },
173
- });
174
-
175
- // Custom return type (objects)
176
- type MenuItem = {
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
- #### `.addTemplates<Context, ReturnType = string, K extends string = string>()(entries)`
192
- Adds multiple template function messages at once with a unified context type and custom return type.
193
-
194
- Note: This uses a curried API for better type inference. Call `addTemplates<Context, ReturnType, K>()` first, then call the returned function with entries.
195
-
196
- - **Context**: Type parameter for the template function context
197
- - **ReturnType**: (optional) Type parameter for the return value (defaults to `string`)
198
- - **K**: (optional) Type parameter for the keys of the entries record (defaults to `string`)
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**: (optional) `Locale` — If provided, sets this locale on all messages before returning. If omitted, uses the first locale in the locales array as default.
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, ReturnType = string> = I18nMessage<Locales, Context, ReturnType>;
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
- .addTemplates<{ name: string; age: number }>()({
296
- profile: {
297
- ja: (ctx) => `名前: ${ctx.name}、年齢: ${ctx.age}歳`,
298
- en: (ctx) => `Name: ${ctx.name}, Age: ${ctx.age}`,
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 String and Template Messages
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
- .addTemplates<{ count: number }>()({
315
- items: {
316
- ja: (ctx) => `${ctx.count}個のアイテム`,
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).addTemplates<{ name: string }>()({
348
- welcome: {
349
- ja: (ctx) => `ようこそ、${ctx.name}さん`,
350
- en: (ctx) => `Welcome, ${ctx.name}`,
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
@@ -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, infer R> ? LocalizedMessage<Ls, C, R> : T extends ChainBuilder<infer Ls, infer Messages> ? {
5
- [K in keyof Messages]: Messages[K] extends I18nMessage<Ls, infer C, infer R> ? LocalizedMessage<Ls, C, R> : never;
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 ? {
@@ -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, 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<R = string, K extends string = string, Entries extends Record<K, Record<Ls[number], R>> = Record<K, Record<Ls[number], R>>>(entries: {
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, void, R>;
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, infer R> ? LocalizedMessage<Ls, C, R> : never;
27
- }>(locale?: Ls[number]): M;
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, {}>;
@@ -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, data] of Object.entries(entries)) {
16
- const msg = new I18nMessage(this.locales, this.locales[0]).setData(data);
17
- newMessages[key] = msg;
18
- }
19
- return new ChainBuilder(this.locales, newMessages);
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
- return new ChainBuilder(this.locales, newMessages);
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 !== undefined
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, R = string> = C extends void ? (() => R) & {
2
+ export type LocalizedMessage<Ls extends readonly string[], C> = C extends void ? (() => string) & {
3
3
  __brand: "I18nMessage";
4
- } : ((ctx: C) => R) & {
4
+ } : ((ctx: C) => string) & {
5
5
  __brand: "I18nTemplateMessage";
6
6
  };
7
- export declare function isI18nMessage(x: unknown): x is I18nMessage<any, any, any>;
8
- export declare class I18nMessage<Ls extends readonly string[], C, R = string> {
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, R>>;
16
- setData(data: Record<Ls[number], Template<C, R>>): this;
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, R>;
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({ abc: { ja: "abc", en: "abc" } })
5
- .addTemplates()({
6
- bb: { ja: ({ aa }) => `${aa}aa`, en: ({ aa }) => `${aa}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.addTemplates()({
15
- aaa: { ja: ({ a }) => `${a}aaa`, en: ({ a }) => `${a}aaa` },
16
- bbb: { ja: ({ a }) => `${a}aaa`, en: ({ a }) => `${a}aaa` },
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, R = string> = R | ((ctx: C) => R);
2
- export declare function isTemplateFunction<C, R = string>(t: Template<C, R>): t is (ctx: C) => R;
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.6.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.8",
33
- "@types/node": "^25.0.10",
33
+ "@tsconfig/node20": "^20.1.9",
34
+ "@types/node": "^25.3.0",
34
35
  "release-it": "^19.2.4",
35
36
  "typescript": "^5.9.3",
36
37
  "vitest": "^4.0.18"
@@ -0,0 +1,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 |