@gunshi/docs 0.27.0-beta.4

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.
@@ -0,0 +1,677 @@
1
+ # Internationalization
2
+
3
+ Gunshi provides comprehensive internationalization (i18n) support through the official `@gunshi/plugin-i18n` plugin, allowing you to create command-line interfaces that can be used in multiple languages.
4
+
5
+ ## Why Use Internationalization?
6
+
7
+ Internationalization offers several benefits:
8
+
9
+ - **Broader audience**: Make your CLI accessible to users who speak different languages
10
+ - **Better user experience**: Users can interact with your CLI in their preferred language
11
+ - **Consistency**: Maintain a consistent approach to translations across your application
12
+ - **Type safety**: Full TypeScript support for translation keys and interpolation
13
+
14
+ ## Getting Started with i18n Plugin
15
+
16
+ First, install the i18n plugin and optional resource packages:
17
+
18
+ ```sh
19
+ npm install @gunshi/plugin-i18n @gunshi/resources
20
+ ```
21
+
22
+ ## Basic Internationalization
23
+
24
+ Here's how to implement basic internationalization using the i18n plugin:
25
+
26
+ ```ts [cli.ts]
27
+ import { cli } from 'gunshi'
28
+ import resources from '@gunshi/resources'
29
+ import i18n, { defineI18nWithTypes, pluginId as i18nId, resolveKey } from '@gunshi/plugin-i18n'
30
+
31
+ import type { I18nExtension } from '@gunshi/plugin-i18n'
32
+
33
+ // Define a command with i18n support
34
+ const command = defineI18nWithTypes<{ extensions: { [i18nId]: I18nExtension } }>()({
35
+ name: 'greeter',
36
+ args: {
37
+ name: {
38
+ type: 'string',
39
+ short: 'n',
40
+ description: 'Name to greet'
41
+ },
42
+ formal: {
43
+ type: 'boolean',
44
+ short: 'f',
45
+ description: 'Use formal greeting'
46
+ }
47
+ },
48
+
49
+ // Define translation resources for the command
50
+ resource: locale => {
51
+ if (locale.toString() === 'ja-JP') {
52
+ return {
53
+ description: '挨拶アプリケーション',
54
+ 'arg:name': '挨拶する相手の名前',
55
+ 'arg:formal': '丁寧な挨拶を使用する',
56
+ informal_greeting: 'こんにちは',
57
+ formal_greeting: 'はじめまして'
58
+ }
59
+ }
60
+
61
+ // Default to English
62
+ return {
63
+ description: 'Greeting application',
64
+ 'arg:name': 'Name to greet',
65
+ 'arg:formal': 'Use formal greeting',
66
+ informal_greeting: 'Hello',
67
+ formal_greeting: 'Good day'
68
+ }
69
+ },
70
+
71
+ // Command execution function
72
+ run: ctx => {
73
+ const { name = 'World', formal } = ctx.values
74
+ const t = ctx.extensions[i18nId].translate
75
+
76
+ // Use resolveKey for custom keys with proper namespacing
77
+ const greetingKey = formal
78
+ ? resolveKey('formal_greeting', ctx.name)
79
+ : resolveKey('informal_greeting', ctx.name)
80
+
81
+ const greeting = t(greetingKey)
82
+ console.log(`${greeting}, ${name}!`)
83
+
84
+ // Show translation information
85
+ const locale = ctx.extensions[i18nId].locale
86
+ console.log(`\nCurrent locale: ${locale}`)
87
+
88
+ // Use resolveKey for description as well
89
+ const descKey = resolveKey('description', ctx.name)
90
+ console.log(`Command Description: ${t(descKey)}`)
91
+ }
92
+ })
93
+
94
+ // Run the command with i18n plugin
95
+ await cli(process.argv.slice(2), command, {
96
+ name: 'i18n-example',
97
+ version: '1.0.0',
98
+ plugins: [
99
+ i18n({
100
+ // Set locale from environment or default to en-US
101
+ locale: process.env.MY_LANG || 'en-US',
102
+ // Provide built-in translations for common terms.
103
+ // See the support locales: https://github.com/kazupon/gunshi/tree/main/packages/resources#-supported-locales
104
+ builtinResources: resources
105
+ })
106
+ ]
107
+ })
108
+ ```
109
+
110
+ <!-- eslint-disable markdown/no-missing-label-refs -->
111
+
112
+ > [!TIP]
113
+ > The example fully code is [here](https://github.com/kazupon/gunshi/tree/main/playground/advanced/internationalization/basic).
114
+
115
+ <!-- eslint-enable markdown/no-missing-label-refs -->
116
+
117
+ <!-- eslint-disable markdown/no-missing-label-refs -->
118
+
119
+ > [!TIP]
120
+ > **About the helper functions used in this example:**
121
+ >
122
+ > - `defineI18nWithTypes`: A type-safe helper for creating commands with i18n support. It ensures proper TypeScript inference for translation keys. [Learn more](#defineI18nWithTypes)
123
+ > - `resolveKey`: A utility that handles namespace resolution for custom translation keys in commands and subcommands. Always use this for custom keys to ensure proper namespacing. [Learn more](#resolvekey)
124
+
125
+ <!-- eslint-enable markdown/no-missing-label-refs -->
126
+
127
+ To run this example with different locales:
128
+
129
+ ```sh
130
+ # English (default)
131
+ node cli.ts --name John
132
+
133
+ # i18n-example (i18n-example v1.0.0)
134
+ #
135
+ # Hello, John!
136
+ #
137
+ # Current locale: en-US
138
+ # Command Description: Greeting application
139
+
140
+ # Japanese
141
+ MY_LANG=ja-JP node cli.ts --name 田中 --formal
142
+
143
+ # i18n-example (i18n-example v1.0.0)
144
+ #
145
+ # はじめまして, 田中!
146
+ #
147
+ # Current locale: ja-JP
148
+ # Command Description: 挨拶アプリケーション
149
+ ```
150
+
151
+ ## Using Built-in Resources
152
+
153
+ The `@gunshi/resources` package provides pre-translated resources for common CLI terms:
154
+
155
+ ```ts
156
+ import { cli } from 'gunshi'
157
+ import i18n from '@gunshi/plugin-i18n'
158
+ import resources from '@gunshi/resources'
159
+
160
+ const command = {
161
+ name: 'app',
162
+ run: ctx => {
163
+ console.log('Application running')
164
+ }
165
+ }
166
+
167
+ await cli(process.argv.slice(2), command, {
168
+ name: 'my-app',
169
+ version: '1.0.0',
170
+ plugins: [
171
+ i18n({
172
+ locale: 'en-US',
173
+ // Provide built-in translations for common terms.
174
+ // See the support locales: https://github.com/kazupon/gunshi/tree/main/packages/resources#-supported-locales
175
+ builtinResources: resources
176
+ })
177
+ ]
178
+ })
179
+
180
+ // This automatically translates built-in messages like:
181
+ // - USAGE, OPTIONS, COMMANDS
182
+ // - Help and version descriptions
183
+ // - Error messages
184
+ ```
185
+
186
+ ## Loading Translations from Files
187
+
188
+ For better organization, load translations from separate files:
189
+
190
+ ```ts [cli.ts]
191
+ import i18n, { defineI18nWithTypes, pluginId as i18nId, resolveKey } from '@gunshi/plugin-i18n'
192
+ import resources from '@gunshi/resources'
193
+ import { cli } from 'gunshi'
194
+
195
+ import type { I18nExtension } from '@gunshi/plugin-i18n'
196
+
197
+ const command = defineI18nWithTypes<{ extensions: { [i18nId]: I18nExtension } }>()({
198
+ name: 'greeter',
199
+ args: {
200
+ name: { type: 'string', short: 'n' },
201
+ formal: { type: 'boolean', short: 'f' }
202
+ },
203
+
204
+ // Load translations from files
205
+ resource: locale => {
206
+ if (locale.toString() === 'ja-JP') {
207
+ // Dynamic import for lazy loading
208
+ const jaJP = await import('./locales/ja-JP.json', {
209
+ with: { type: 'json' }
210
+ })
211
+ return jaJP.default
212
+ }
213
+
214
+ // Default to English
215
+ const enUS = await import('./locales/en-US.json', {
216
+ with: { type: 'json' }
217
+ })
218
+ return enUS.default
219
+ },
220
+
221
+ run: ctx => {
222
+ const { name = 'World', formal } = ctx.values
223
+ const t = ctx.extensions[i18nId].translate
224
+
225
+ // Always use resolveKey for custom keys
226
+ const greetingKey = formal
227
+ ? resolveKey('formal_greeting', ctx.name)
228
+ : resolveKey('informal_greeting', ctx.name)
229
+
230
+ const greeting = t(greetingKey)
231
+ console.log(`${greeting}, ${name}!`)
232
+ }
233
+ })
234
+
235
+ await cli(process.argv.slice(2), command, {
236
+ name: 'i18n-example',
237
+ version: '1.0.0',
238
+ plugins: [
239
+ i18n({
240
+ locale: process.env.MY_LANG || 'en-US',
241
+ builtinResources: resources
242
+ })
243
+ ]
244
+ })
245
+ ```
246
+
247
+ <!-- eslint-disable markdown/no-missing-label-refs -->
248
+
249
+ > [!TIP]
250
+ > The example fully code is [here](https://github.com/kazupon/gunshi/tree/main/playground/advanced/internationalization/loading).
251
+
252
+ <!-- eslint-enable markdown/no-missing-label-refs -->
253
+
254
+ Example locale files:
255
+
256
+ ```json [locales/en-US.json]
257
+ {
258
+ "description": "Greeting application",
259
+ "arg:name": "Name to greet",
260
+ "arg:formal": "Use formal greeting",
261
+ "informal_greeting": "Hello",
262
+ "formal_greeting": "Good day"
263
+ }
264
+ ```
265
+
266
+ ```json [locales/ja-JP.json]
267
+ {
268
+ "description": "挨拶アプリケーション",
269
+ "arg:name": "挨拶する相手の名前",
270
+ "arg:formal": "丁寧な挨拶を使用する",
271
+ "informal_greeting": "こんにちは",
272
+ "formal_greeting": "はじめまして"
273
+ }
274
+ ```
275
+
276
+ ## Translation with Interpolation
277
+
278
+ The i18n plugin supports message interpolation for dynamic content:
279
+
280
+ ```ts
281
+ import i18n, { defineI18nWithTypes, pluginId as i18nId, resolveKey } from '@gunshi/plugin-i18n'
282
+
283
+ import type { I18nExtension } from '@gunshi/plugin-i18n'
284
+
285
+ const command = defineI18nWithTypes<{ extensions: { [i18nId]: I18nExtension } }>()({
286
+ name: 'deploy',
287
+ args: {
288
+ app: { type: 'string', required: true },
289
+ environment: { type: 'string', required: true }
290
+ },
291
+ resource: () => ({
292
+ deploying: 'Deploying {$app} to {$environment}...',
293
+ success: 'Successfully deployed {$app} to {$environment}!',
294
+ error: 'Failed to deploy: {$message}'
295
+ }),
296
+ run: ctx => {
297
+ const t = ctx.extensions[i18nId].translate
298
+ const { app, environment } = ctx.values
299
+
300
+ // Use resolveKey for all custom keys
301
+ const deployingKey = resolveKey('deploying', ctx.name)
302
+ const successKey = resolveKey('success', ctx.name)
303
+ const errorKey = resolveKey('error', ctx.name)
304
+
305
+ console.log(t(deployingKey, { app, environment }))
306
+
307
+ try {
308
+ // Deployment logic
309
+ console.log(t(successKey, { app, environment }))
310
+ } catch (error) {
311
+ console.error(t(errorKey, { message: error.message }))
312
+ }
313
+ }
314
+ })
315
+ ```
316
+
317
+ Note: Interpolation placeholders use the format `{$variableName}` in the i18n plugin.
318
+
319
+ ## Internationalization with Sub-commands
320
+
321
+ When working with sub-commands, each command has its own namespace for translations:
322
+
323
+ ```ts [cli.ts]
324
+ import i18n, { defineI18nWithTypes, pluginId as i18nId, resolveKey } from '@gunshi/plugin-i18n'
325
+ import resources from '@gunshi/resources'
326
+ import { cli } from 'gunshi'
327
+
328
+ import type { I18nExtension } from '@gunshi/plugin-i18n'
329
+
330
+ // Sub-command with its own translations
331
+ const createCommand = defineI18nWithTypes<{ extensions: { [i18nId]: I18nExtension } }>()({
332
+ name: 'create',
333
+ args: {
334
+ name: { type: 'string', required: true }
335
+ },
336
+ resource: locale => {
337
+ return locale.toString() === 'ja-JP'
338
+ ? {
339
+ description: 'リソースを作成',
340
+ 'arg:name': 'リソース名',
341
+ creating: '作成中: {$name}',
342
+ success: '作成完了!'
343
+ }
344
+ : {
345
+ description: 'Create a resource',
346
+ 'arg:name': 'Resource name',
347
+ creating: 'Creating: {$name}',
348
+ success: 'Created successfully!'
349
+ }
350
+ },
351
+ run: ctx => {
352
+ const t = ctx.extensions[i18nId].translate
353
+ const { name } = ctx.values
354
+
355
+ // For custom keys in subcommands, always use resolveKey helper
356
+ const creatingKey = resolveKey('creating', ctx.name)
357
+ const successKey = resolveKey('success', ctx.name)
358
+
359
+ console.log(t(creatingKey, { name }))
360
+ console.log(t(successKey))
361
+ }
362
+ })
363
+
364
+ // Main command
365
+ const mainCommand = defineI18nWithTypes<{ extensions: { [i18nId]: I18nExtension } }>()({
366
+ name: 'resource-manager',
367
+ resource: () => ({
368
+ description: 'Resource management tool',
369
+ usage_hint: 'Use a sub-command to manage resources'
370
+ }),
371
+ run: ctx => {
372
+ const t = ctx.extensions[i18nId].translate
373
+
374
+ // Use resolveKey for main command's custom keys too
375
+ const hintKey = resolveKey('usage_hint', ctx.name)
376
+ console.log(t(hintKey))
377
+ }
378
+ })
379
+
380
+ // Run with i18n plugin
381
+ await cli(process.argv.slice(2), mainCommand, {
382
+ name: 'resource-cli',
383
+ version: '1.0.0',
384
+ subCommands: {
385
+ create: createCommand
386
+ },
387
+ plugins: [
388
+ i18n({
389
+ locale: process.env.MY_LANG || 'en-US',
390
+ builtinResources: resources
391
+ })
392
+ ]
393
+ })
394
+ ```
395
+
396
+ <!-- eslint-disable markdown/no-missing-label-refs -->
397
+
398
+ > [!TIP]
399
+ > The example fully code is [here](https://github.com/kazupon/gunshi/tree/main/playground/advanced/internationalization/sub-command).
400
+
401
+ <!-- eslint-enable markdown/no-missing-label-refs -->
402
+
403
+ ## Helper Functions
404
+
405
+ The i18n plugin provides helpful utilities for working with translations:
406
+
407
+ ### `defineI18n`
408
+
409
+ Define an i18n-aware command.
410
+
411
+ ```ts
412
+ import { defineI18n } from '@gunshi/plugin-i18n'
413
+
414
+ const greetCommand = defineI18n({
415
+ name: 'greet',
416
+ description: 'Greet someone',
417
+ args: {
418
+ name: {
419
+ type: 'string',
420
+ description: 'Name to greet'
421
+ }
422
+ },
423
+ resource: locale => {
424
+ switch (locale.toString()) {
425
+ case 'ja-JP': {
426
+ return {
427
+ description: '誰かにあいさつ',
428
+ 'arg:name': 'あいさつするための名前'
429
+ }
430
+ }
431
+ // other locales ...
432
+ }
433
+ },
434
+ run: ctx => {
435
+ console.log(`Hello, ${ctx.values.name}!`)
436
+ }
437
+ })
438
+ ```
439
+
440
+ The difference from the `define` function is that you can define a `resource` option that can load a locale.
441
+
442
+ ### `defineI18nWithTypes`
443
+
444
+ Define an i18n-aware command with types
445
+
446
+ This helper function allows specifying the type parameter of `GunshiParams` while inferring the `Args` type, `ExtendContext` type from the definition.
447
+
448
+ ```ts
449
+ import { defineI18nWithTypes } from '@gunshi/plugin-i18n'
450
+
451
+ // Define a command with specific extensions type
452
+ type MyExtensions = { logger: { log: (message: string) => void } }
453
+
454
+ const greetCommand = defineI18nWithTypes<{ extensions: MyExtensions }>()({
455
+ name: 'greet',
456
+ args: {
457
+ name: { type: 'string', description: 'Name to greet' }
458
+ },
459
+ resource: locale => {
460
+ switch (locale.toString()) {
461
+ case 'ja-JP': {
462
+ return {
463
+ description: '誰かにあいさつ',
464
+ 'arg:name': 'あいさつするための名前'
465
+ }
466
+ }
467
+ // other locales ...
468
+ }
469
+ },
470
+ run: ctx => {
471
+ // ctx.values is inferred as { name?: string }
472
+ // ctx.extensions is MyExtensions
473
+ }
474
+ })
475
+ ```
476
+
477
+ ### `withI18nResource`
478
+
479
+ Add i18n resources to existing commands:
480
+
481
+ ```ts
482
+ import { define } from 'gunshi'
483
+ import { withI18nResource, resolveKey, pluginId as i18nId } from '@gunshi/plugin-i18n'
484
+
485
+ const existingCommand = define({
486
+ name: 'app',
487
+ run: ctx => {
488
+ const t = ctx.extensions[i18nId]?.translate
489
+ if (t) {
490
+ const messageKey = resolveKey('message', ctx.name)
491
+ console.log(t(messageKey))
492
+ }
493
+ }
494
+ })
495
+
496
+ const existingLocalizableCommand = withI18nResource(existingCommand, locale => ({
497
+ message: 'Hello from i18n!'
498
+ }))
499
+ ```
500
+
501
+ ### `resolveKey`
502
+
503
+ The `resolveKey` helper ensures proper command namespace handling for custom translation keys:
504
+
505
+ ```ts
506
+ import { resolveKey } from '@gunshi/plugin-i18n'
507
+
508
+ // For a command named 'build'
509
+ const key = resolveKey('starting', ctx.name)
510
+ // Returns: 'build:starting'
511
+ ```
512
+
513
+ ## Resource Key Naming Conventions
514
+
515
+ When defining translation resources, follow these conventions:
516
+
517
+ - **Command Description**: Use the key `description`
518
+ - **Examples**: Use the key `examples`
519
+ - **Argument Descriptions**: Prefix with `arg:` (e.g., `arg:name`)
520
+ - **Negatable Arguments**: Use `arg:no-<option>` for custom negation descriptions
521
+ - **Built-in Keys**: Keys like `_:USAGE`, `_:OPTIONS` are handled by built-in resources
522
+ - **Custom Keys**: Free naming for your application-specific messages, but always use `resolveKey()` when accessing them
523
+
524
+ Example:
525
+
526
+ <!-- eslint-skip -->
527
+
528
+ ```js
529
+ {
530
+ // Command metadata (accessed with resolveKey)
531
+ "description": "File processor",
532
+ "examples": "$ process --input file.txt",
533
+
534
+ // Argument descriptions (must use arg: prefix)
535
+ "arg:input": "Input file path",
536
+ "arg:verbose": "Enable verbose output",
537
+ "arg:no-verbose": "Disable verbose output",
538
+
539
+ // Custom application messages (accessed with resolveKey)
540
+ "processing": "Processing file...",
541
+ "complete": "Processing complete!",
542
+ "error_not_found": "File not found: {$path}"
543
+ }
544
+ ```
545
+
546
+ <!-- eslint-disable markdown/no-missing-label-refs -->
547
+
548
+ > [!IMPORTANT]
549
+ > The resource object returned by the `resource` function (or loaded from external files like JSON) **must** be a flat key-value structure. Nested objects are not supported for translations using `translate()`. Keep your translation keys simple and at the top level.
550
+
551
+ <!-- eslint-enable markdown/no-missing-label-refs -->
552
+
553
+ Good Flat structure:
554
+
555
+ ```json
556
+ {
557
+ "greeting": "Hello",
558
+ "farewell": "Goodbye"
559
+ }
560
+ ```
561
+
562
+ Bad Nested structure (won't work with `translate('messages.greeting')`):
563
+
564
+ ```json
565
+ {
566
+ "messages": {
567
+ "greeting": "Hello",
568
+ "farewell": "Goodbye"
569
+ }
570
+ }
571
+ ```
572
+
573
+ ## Detecting User Locale
574
+
575
+ The i18n plugin can automatically detect the user's locale:
576
+
577
+ ```js
578
+ import i18n from '@gunshi/plugin-i18n'
579
+
580
+ // Use various detection methods
581
+ await cli(process.argv.slice(2), command, {
582
+ plugins: [
583
+ i18n({
584
+ // From environment variable
585
+ locale: process.env.MY_LANG || 'en-US'
586
+ // Or using Intl.Locale for advanced locale handling
587
+ // locale: new Intl.Locale(process.env.MY_LANG || 'en-US')
588
+ })
589
+ ]
590
+ })
591
+ ```
592
+
593
+ In Node.js v21 and later, you can also detect locale using the navigator API:
594
+
595
+ <!-- eslint-skip -->
596
+
597
+ ```ts
598
+ // In browser or Node.js v21.2.0+ (experimental global navigator), use navigator.language
599
+ // Otherwise, fall back to environment- or Intl-based detection
600
+ const locale = (() => {
601
+ // Experimental global navigator in Node 21.2.0+
602
+ if (typeof globalThis.navigator !== 'undefined' && navigator.language) {
603
+ return navigator.language
604
+ }
605
+ // Fallback: read locale from environment variables
606
+ const env = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || 'en-US'
607
+ const base = env.split('.')[0].replace('_', '-')
608
+ try {
609
+ // Normalize and validate with Intl.Locale
610
+ return new Intl.Locale(base).toString()
611
+ } catch {
612
+ return 'en-US'
613
+ }
614
+ })()
615
+ ```
616
+
617
+ ## Custom Translation Adapters
618
+
619
+ For advanced scenarios requiring custom interpolation syntax or translation logic, you can create custom translation adapters by implementing the TranslationAdapter interface.
620
+
621
+ This allows full control over how translations are stored, retrieved, and interpolated.
622
+
623
+ For detailed implementation guidance and examples, see the [Custom Translation Adapter documentation](https://github.com/kazupon/gunshi/tree/main/packages/plugin-i18n#-custom-translation-adapter) in the `@gunshi/plugin-i18n` package.
624
+
625
+ ## Translating Help Messages
626
+
627
+ The i18n plugin automatically uses your translations for help messages.
628
+
629
+ When users run `--help` with different locales, they'll see help messages in their language:
630
+
631
+ English:
632
+
633
+ ```sh
634
+ USAGE:
635
+ COMMAND <OPTIONS>
636
+
637
+ OPTIONS:
638
+ -n, --name <name> Name to greet
639
+ -f, --formal Use formal greeting
640
+ -h, --help Display this help message
641
+ -v, --version Display version
642
+ ```
643
+
644
+ Japanese (with proper locale):
645
+
646
+ ```sh
647
+ 使用法:
648
+ COMMAND <オプション>
649
+
650
+ オプション:
651
+ -n, --name <name> 挨拶する相手の名前
652
+ -f, --formal 丁寧な挨拶を使用する
653
+ -h, --help このヘルプメッセージを表示
654
+ -v, --version バージョンを表示
655
+ ```
656
+
657
+ ## Important Notes on Custom Keys
658
+
659
+ <!-- eslint-disable markdown/no-missing-label-refs -->
660
+
661
+ > [!IMPORTANT]
662
+ > **Always use `resolveKey()` for custom translation keys!** This ensures proper namespace handling, especially in sub-commands. Without `resolveKey()`, your translations may not be found.
663
+
664
+ <!-- eslint-enable markdown/no-missing-label-refs -->
665
+
666
+ ```ts
667
+ // ❌ Wrong - Don't access custom keys directly
668
+ const message = t('welcome')
669
+
670
+ // ✅ Correct - Always use resolveKey for custom keys
671
+ const welcomeKey = resolveKey('welcome', ctx.name)
672
+ const message = t(welcomeKey)
673
+ ```
674
+
675
+ ## Migration from v0.26
676
+
677
+ If you're migrating from Gunshi v0.26 where i18n was built into the CLI options, see the [v0.27 Release Notes](../../release/v0.27.md#internationalization-migration) for detailed migration instructions.