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 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.1",
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",
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, addTemplates (curried), build, bindLocale, React integration, and common gotchas like required `as const` and the two-step curried call syntax.
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<R, K>(entries)`
60
+ ### `.add(entries)`
61
61
 
62
- Adds multiple static messages (string or custom type).
62
+ Adds multiple messages at once. Each entry can be a static locale record or a template function.
63
63
 
64
64
  ```ts
65
- // Default (string type)
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
- // Custom return type (object)
73
- type MenuItem = { label: string; url: string };
74
-
75
- const menu = createI18n(['en', 'ja'] as const)
76
- .add<MenuItem>({
77
- home: {
78
- en: { label: 'Home', url: '/en' },
79
- ja: { label: 'Home', url: '/ja' },
80
- },
81
- });
82
- ```
83
-
84
- - **Type param `R`**: return value type (default: `string`)
85
- - **Type param `K`**: key type for entries (usually omitted)
86
- - **entries**: `Record<K, Record<Locale, R>>`
87
- - **Returns**: new `ChainBuilder` (immutable)
88
-
89
- ---
90
-
91
- ### `.addTemplates<C, R, K>()(entries)`
92
-
93
- **Curried API — two-step call required.** Adds template functions that receive a context object of type `C`.
94
-
95
- ```ts
96
- // ⚠️ Curried: two-step call ()() is mandatory
97
- const builder = createI18n(['en', 'ja'] as const)
98
- .addTemplates<{ name: string; age: number }>()({ // note: ()() two steps
99
- greeting: {
100
- en: (ctx) => `Hello, ${ctx.name}. You are ${ctx.age}.`,
101
- ja: (ctx) => `こんにちは、${ctx.name}さん。${ctx.age}歳です。`,
102
- },
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
- // Custom return type (JSX.Element)
106
- const jsxBuilder = createI18n(['en', 'ja'] as const)
107
- .addTemplates<{ name: string }, JSX.Element>()({
108
- badge: {
109
- en: ({ name }) => <strong>Welcome, {name}!</strong>,
110
- ja: ({ name }) => <strong>ようこそ、{name}さん!</strong>,
111
- },
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
- - **Type param `C`**: context object type (**required**)
116
- - **Type param `R`**: return value type (default: `string`)
117
- - **Type param `K`**: key type (usually omitted)
118
- - **entries**: `Record<K, Record<Locale, (ctx: C) => R>>`
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`**: optional; defaults to first locale in array
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. `addTemplates` is curried — two-step call
187
-
188
- ```ts
189
- // ✅ Correct: ()() two steps
190
- .addTemplates<{ name: string }>()({
191
- key: { en: (ctx) => `Hello, ${ctx.name}` }
192
- })
193
-
194
- // ❌ Wrong: one-step call causes type error
195
- .addTemplates<{ name: string }>({
196
- key: { en: (ctx) => `Hello, ${ctx.name}` }
197
- })
198
- ```
199
-
200
- ### 3. `.build()` is immutable
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
- ### 4. ESM only
166
+ ### 3. ESM only
211
167
 
212
168
  ```json
213
169
  // Required in package.json
214
170
  { "type": "module" }
215
171
  ```
216
172
 
217
- ### 5. All messages must be called as functions
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
- .addTemplates<{ name: string; age: number }>()({
258
- profile: {
259
- en: (ctx) => `Name: ${ctx.name}, Age: ${ctx.age}`,
260
- ja: (ctx) => `名前: ${ctx.name}、年齢: ${ctx.age}歳`,
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
- .addTemplates<{ count: number }>()({
279
- count: {
280
- en: (ctx) => `${ctx.count} items`,
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
- .addTemplates<{ name: string }>()({
361
- welcome: {
362
- en: (ctx) => `Welcome, ${ctx.name}`,
363
- ja: (ctx) => `ようこそ、${ctx.name}さん`,
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
- .addTemplates<{ name: string }>()({
459
- greeting: {
460
- en: (ctx) => `Hello, ${ctx.name}!`,
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
- .addTemplates<{ name: string }, JSX.Element>()({
496
- greeting: {
497
- en: ({ name }) => <strong>Welcome, {name}!</strong>,
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
- <div>{m.greeting({ name })}</div>
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 |