canopy-i18n 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -106
- 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
|
@@ -2,14 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
A tiny, type-safe i18n library for building localized messages with builder pattern and applying locales across nested data structures.
|
|
4
4
|
|
|
5
|
-

|
|
6
|
-
|
|
7
|
-
|
|
8
5
|
## Features
|
|
9
6
|
- **AI-friendly**: Full type safety and single-file colocation give AI assistants complete context for accurate code generation.
|
|
10
7
|
- **Type-safe**: Compile-time safety for locale keys with full TypeScript IntelliSense support.
|
|
11
8
|
- **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
9
|
- **Zero dependencies**: Lightweight with native TypeScript syntax, no custom {{placeholder}} format.
|
|
14
10
|
## Why Canopy i18n?
|
|
15
11
|
|
|
@@ -45,32 +41,16 @@ console.log(messages.greeting()); // Fully type-safe, autocomplete works
|
|
|
45
41
|
**With template functions:**
|
|
46
42
|
```ts
|
|
47
43
|
const messages = createI18n(['en', 'ja'] as const)
|
|
48
|
-
.
|
|
49
|
-
welcome: {
|
|
50
|
-
en:
|
|
51
|
-
ja:
|
|
52
|
-
}
|
|
44
|
+
.add({
|
|
45
|
+
welcome: (ctx: { name: string }) => ({
|
|
46
|
+
en: `Welcome, ${ctx.name}!`,
|
|
47
|
+
ja: `ようこそ、${ctx.name}さん!`
|
|
48
|
+
})
|
|
53
49
|
}).build('en');
|
|
54
50
|
|
|
55
51
|
console.log(messages.welcome({ name: 'Alice' })); // "Welcome, Alice!"
|
|
56
52
|
```
|
|
57
53
|
|
|
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
54
|
**Benefits:**
|
|
75
55
|
- 🔒 **Type safety**: Typos caught at compile time, full autocomplete support
|
|
76
56
|
- 📁 **Colocation**: All translations in one place, no file jumping
|
|
@@ -109,12 +89,10 @@ const builder = baseBuilder
|
|
|
109
89
|
ja: 'こんにちは',
|
|
110
90
|
en: 'Hello',
|
|
111
91
|
},
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
en: (ctx) => `Hello, ${ctx.name}. You are ${ctx.age} years old.`,
|
|
117
|
-
},
|
|
92
|
+
welcome: (ctx: { name: string; age: number }) => ({
|
|
93
|
+
ja: `こんにちは、${ctx.name}さん。あなたは${ctx.age}歳です。`,
|
|
94
|
+
en: `Hello, ${ctx.name}. You are ${ctx.age} years old.`,
|
|
95
|
+
}),
|
|
118
96
|
});
|
|
119
97
|
|
|
120
98
|
// 3) Reuse the builder to create messages for different locales
|
|
@@ -147,85 +125,53 @@ const builder = createI18n(['ja', 'en', 'fr'] as const);
|
|
|
147
125
|
### `ChainBuilder`
|
|
148
126
|
A builder class for creating multiple localized messages with method chaining.
|
|
149
127
|
|
|
150
|
-
#### `.add
|
|
151
|
-
Adds multiple messages at once.
|
|
128
|
+
#### `.add(entries)`
|
|
129
|
+
Adds multiple messages at once. Each entry can be a static locale record or a template function.
|
|
152
130
|
|
|
153
|
-
- **
|
|
154
|
-
- **K**: (optional) Type parameter for the keys of the entries record (defaults to `string`)
|
|
155
|
-
- **entries**: `Record<K, Record<Locale, ReturnType>>`
|
|
131
|
+
- **entries**: `Record<K, Record<Locale, string> | ((ctx: C) => Record<Locale, string>)>`
|
|
156
132
|
- Returns: `ChainBuilder` with added messages
|
|
157
133
|
|
|
158
134
|
```ts
|
|
159
|
-
//
|
|
135
|
+
// Static messages
|
|
160
136
|
const builder = createI18n(['ja', 'en'] as const)
|
|
161
137
|
.add({
|
|
162
138
|
title: { ja: 'タイトル', en: 'Title' },
|
|
163
139
|
greeting: { ja: 'こんにちは', en: 'Hello' },
|
|
164
140
|
});
|
|
165
141
|
|
|
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
|
-
},
|
|
142
|
+
// Template functions
|
|
143
|
+
const builder2 = createI18n(['ja', 'en'] as const)
|
|
144
|
+
.add({
|
|
145
|
+
greet: (ctx: { name: string; age: number }) => ({
|
|
146
|
+
ja: `こんにちは、${ctx.name}さん。${ctx.age}歳ですね。`,
|
|
147
|
+
en: `Hello, ${ctx.name}. You are ${ctx.age}.`,
|
|
148
|
+
}),
|
|
149
|
+
farewell: (ctx: { name: string }) => ({
|
|
150
|
+
ja: `さようなら、${ctx.name}さん。`,
|
|
151
|
+
en: `Goodbye, ${ctx.name}.`,
|
|
152
|
+
}),
|
|
188
153
|
});
|
|
189
|
-
```
|
|
190
|
-
|
|
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
154
|
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
ja: (ctx) => `さようなら、${ctx.name}さん。`,
|
|
211
|
-
en: (ctx) => `Goodbye, ${ctx.name}.`,
|
|
212
|
-
},
|
|
155
|
+
// Mixing static and template messages
|
|
156
|
+
const builder3 = createI18n(['ja', 'en'] as const)
|
|
157
|
+
.add({
|
|
158
|
+
title: { ja: 'タイトル', en: 'Title' },
|
|
159
|
+
greet: (ctx: { name: string }) => ({
|
|
160
|
+
ja: `こんにちは、${ctx.name}さん`,
|
|
161
|
+
en: `Hello, ${ctx.name}`,
|
|
162
|
+
}),
|
|
213
163
|
});
|
|
214
164
|
```
|
|
215
165
|
|
|
216
166
|
|
|
217
167
|
|
|
218
|
-
#### `.build(locale
|
|
168
|
+
#### `.build(locale)`
|
|
219
169
|
Builds the final messages object.
|
|
220
170
|
|
|
221
|
-
- **locale**:
|
|
171
|
+
- **locale**: `Locale` — Sets this locale on all messages before returning.
|
|
222
172
|
- Returns: `Messages` — An object containing all defined messages
|
|
223
173
|
|
|
224
174
|
```ts
|
|
225
|
-
// Build with default locale (first in array)
|
|
226
|
-
const defaultMessages = builder.build();
|
|
227
|
-
|
|
228
|
-
// Build with specific locale
|
|
229
175
|
const englishMessages = builder.build('en');
|
|
230
176
|
const japaneseMessages = builder.build('ja');
|
|
231
177
|
```
|
|
@@ -258,7 +204,7 @@ console.log(localized.nested.special.msg()); // English version
|
|
|
258
204
|
|
|
259
205
|
```ts
|
|
260
206
|
export type Template<C, R = string> = R | ((ctx: C) => R);
|
|
261
|
-
export type LocalizedMessage<Locales, Context
|
|
207
|
+
export type LocalizedMessage<Locales, Context> = I18nMessage<Locales, Context>;
|
|
262
208
|
```
|
|
263
209
|
|
|
264
210
|
## Exports
|
|
@@ -292,11 +238,11 @@ console.log(messages.greeting()); // "Hello"
|
|
|
292
238
|
|
|
293
239
|
```ts
|
|
294
240
|
const messages = createI18n(['ja', 'en'] as const)
|
|
295
|
-
.
|
|
296
|
-
profile: {
|
|
297
|
-
ja:
|
|
298
|
-
en:
|
|
299
|
-
},
|
|
241
|
+
.add({
|
|
242
|
+
profile: (ctx: { name: string; age: number }) => ({
|
|
243
|
+
ja: `名前: ${ctx.name}、年齢: ${ctx.age}歳`,
|
|
244
|
+
en: `Name: ${ctx.name}, Age: ${ctx.age}`,
|
|
245
|
+
}),
|
|
300
246
|
})
|
|
301
247
|
.build('en');
|
|
302
248
|
|
|
@@ -304,18 +250,16 @@ console.log(messages.profile({ name: 'Taro', age: 25 }));
|
|
|
304
250
|
// "Name: Taro, Age: 25"
|
|
305
251
|
```
|
|
306
252
|
|
|
307
|
-
### Mixing
|
|
253
|
+
### Mixing Static and Template Messages
|
|
308
254
|
|
|
309
255
|
```ts
|
|
310
256
|
const messages = createI18n(['ja', 'en'] as const)
|
|
311
257
|
.add({
|
|
312
258
|
title: { ja: 'タイトル', en: 'Title' },
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
en: (ctx) => `${ctx.count} items`,
|
|
318
|
-
},
|
|
259
|
+
items: (ctx: { count: number }) => ({
|
|
260
|
+
ja: `${ctx.count}個のアイテム`,
|
|
261
|
+
en: `${ctx.count} items`,
|
|
262
|
+
}),
|
|
319
263
|
})
|
|
320
264
|
.build('ja');
|
|
321
265
|
|
|
@@ -344,11 +288,11 @@ export const common = createI18n(LOCALES).add({
|
|
|
344
288
|
import { createI18n } from 'canopy-i18n';
|
|
345
289
|
import { LOCALES } from './locales';
|
|
346
290
|
|
|
347
|
-
export const user = createI18n(LOCALES).
|
|
348
|
-
welcome: {
|
|
349
|
-
ja:
|
|
350
|
-
en:
|
|
351
|
-
},
|
|
291
|
+
export const user = createI18n(LOCALES).add({
|
|
292
|
+
welcome: (ctx: { name: string }) => ({
|
|
293
|
+
ja: `ようこそ、${ctx.name}さん`,
|
|
294
|
+
en: `Welcome, ${ctx.name}`,
|
|
295
|
+
}),
|
|
352
296
|
});
|
|
353
297
|
|
|
354
298
|
// 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 |
|