Internationalization
Gunshi provides built-in internationalization (i18n) support, allowing you to create command-line interfaces that can be used in multiple languages. This feature is particularly useful for global applications or projects that need to support users from different regions.
Why Use Internationalization?
Internationalization offers several benefits:
- Broader audience: Make your CLI accessible to users who speak different languages
- Better user experience: Users can interact with your CLI in their preferred language
- Consistency: Maintain a consistent approach to translations across your application
Basic Internationalization
Here's how to implement basic internationalization in Gunshi:
import { cli } from 'gunshi'
// Define a command with i18n support
const command = {
name: 'greeter',
args: {
name: {
type: 'string',
short: 'n'
},
formal: {
type: 'boolean',
short: 'f'
}
},
// Define a resource fetcher for translations
resource: async ctx => {
// Check the locale and return appropriate translations
if (ctx.locale.toString() === 'ja-JP') {
return {
description: '挨拶アプリケーション',
'arg:name': '挨拶する相手の名前',
'arg:formal': '丁寧な挨拶を使用する',
informal_greeting: 'こんにちは',
formal_greeting: 'はじめまして'
}
}
// Default to English
return {
description: 'Greeting application',
'arg:name': 'Name to greet',
'arg:formal': 'Use formal greeting',
informal_greeting: 'Hello',
formal_greeting: 'Good day'
}
},
// Command execution function
run: ctx => {
const { name = 'World', formal } = ctx.values
// Use translated greeting based on the formal option
const greeting = formal ? ctx.translate('formal_greeting') : ctx.translate('informal_greeting')
console.log(`${greeting}, ${name}!`)
// Show translation information
console.log('\nTranslation Information:')
console.log(`Current locale: ${ctx.locale}`)
console.log(`Command Description: ${ctx.translate('description')}`)
}
}
// Run the command with i18n support
await cli(process.argv.slice(2), command, {
name: 'i18n-example',
version: '1.0.0',
// Set the locale via an environment variable
locale: new Intl.Locale(process.env.MY_LOCALE || 'en-US')
})
To run this example with different locales:
# English (default)
node index.js --name John
# i18n-example (i18n-example v1.0.0)
#
# Hello, John!
#
# Translation Information:
# Current locale: en-US
# Command Description: Greeting application
# Japanese
MY_LOCALE=ja-JP node index.js --name 田中 --formal
# i18n-example (i18n-example v1.0.0)
#
# こんにちは, 田中!
#
# Translation Information:
# Current locale: ja-JP
# Command Description: 挨拶アプリケーション
Translations with loading resources
For better organization, you can load translations from separate JSON files:
import { cli } from 'gunshi'
import enUS from './locales/en-US.json' with { type: 'json' }
const command = {
name: 'greeter',
args: {
name: { type: 'string', short: 'n' },
formal: { type: 'boolean', short: 'f' }
},
// Resource fetcher for translations
resource: async ctx => {
if (ctx.locale.toString() === 'ja-JP') {
// Dynamic import for lazy loading
const resource = await import('./locales/ja-JP.json', { with: { type: 'json' } })
return resource.default
}
// Default to English
return enUS
},
run: ctx => {
const { name = 'World', formal } = ctx.values
const greeting = formal ? ctx.translate('formal_greeting') : ctx.translate('informal_greeting')
console.log(`${greeting}, ${name}!`)
}
}
await cli(process.argv.slice(2), command, {
name: 'i18n-example',
version: '1.0.0',
locale: new Intl.Locale(process.env.MY_LOCALE || 'en-US')
})
Example locale files:
locales/en-US.json
:
{
"description": "Greeting application",
"arg:name": "Name to greet",
"arg:formal": "Use formal greeting",
"informal_greeting": "Hello",
"formal_greeting": "Good day"
}
locales/ja-JP.json
:
{
"description": "挨拶アプリケーション",
"arg:name": "挨拶する相手の名前",
"arg:formal": "丁寧な挨拶を使用する",
"informal_greeting": "こんにちは",
"formal_greeting": "はじめまして"
}
Translating Help Messages
Gunshi automatically uses your translations for help messages:
const command = {
name: 'greeter',
args: {
name: { type: 'string', short: 'n' },
formal: { type: 'boolean', short: 'f' }
},
resource: async ctx => {
// Return translations based on locale
// ...
},
run: ctx => {
// Command implementation
}
}
When users run node index.js --help
with different locales, they'll see help messages in their language:
English:
USAGE:
COMMAND <OPTIONS>
OPTIONS:
-n, --name <name> Name to greet
-f, --formal Use formal greeting
-h, --help Display this help message
-v, --version Display this version
Japanese:
USAGE:
COMMAND <OPTIONS>
OPTIONS:
-n, --name <name> 挨拶する相手の名前
-f, --formal 丁寧な挨拶を使用する
-h, --help このヘルプメッセージを表示
-v, --version このバージョンを表示"
Detecting the User's Locale
In Node.js v21 or later, you can use the built-in navigator.language
to detect the user's locale:
await cli(process.argv.slice(2), command, {
name: 'i18n-example',
version: '1.0.0',
// Use the system locale if available, otherwise fall back to en-US
locale:
typeof navigator !== 'undefined' && navigator.language
? new Intl.Locale(navigator.language)
: new Intl.Locale('en-US')
})
For earlier Node.js versions, you can use environment variables or configuration files to determine the locale.
Resource Key Naming Conventions
When defining your localization resources (either directly in the resource
function or in separate files), there are specific naming conventions to follow for the keys:
- Command Description: Use the key
description
for the main description of the command. - Examples: Use the key
examples
for usage examples. - Argument Descriptions: Keys for the descriptions of command arguments (options and operands) must be prefixed with
arg:
. For example, if you have an argument namedtarget
, its description key must bearg:target
.- Negatable Argument Descriptions: For boolean options (e.g.,
--verbose
), Gunshi automatically generates a description for the negatable version (e.g.,--no-verbose
) using the built-inNEGATABLE
key (e.g., "Negatable of --verbose"). To provide a custom translation for a specific negatable option, use the patternarg:no-<optionName>
, for example,arg:no-verbose
.
- Negatable Argument Descriptions: For boolean options (e.g.,
- Custom Keys: Any other keys you define for custom translation messages (like greetings, error messages, etc.) do not require a prefix and can be named freely (e.g.,
informal_greeting
,error_file_not_found
). - Built-in Keys: Keys for built-in functionalities like
help
,version
,USAGE
,OPTIONS
,EXAMPLES
,FORMORE
, and the newNEGATABLE
key are handled by Gunshi's default locales (found insrc/locales
). You can override these by defining them in your resource file (e.g., providing your own translation forNEGATABLE
).
Here's an example illustrating the convention:
import { define } from 'gunshi'
const command = define({
name: 'my-command',
args: {
target: { type: 'string' },
verbose: { type: 'boolean' }
},
resource: async ctx => {
// Example for 'en-US' locale
return {
description: 'This is my command.', // No prefix
examples: '$ my-command --target file.txt', // No prefix
'arg:target': 'The target file to process.', // 'arg:' prefix
'arg:verbose': 'Enable verbose output.', // 'arg:' prefix
'arg:no-verbose': 'Disable verbose logging specifically.', // Optional custom translation for the negatable option
processing_message: 'Processing target...' // No prefix
}
},
run: ctx => {
/* ... */
}
})
Adhering to these conventions ensures that Gunshi correctly identifies and uses your translations for descriptions, help messages, and within your command's logic via ctx.translate()
.
IMPORTANT
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 ctx.translate()
. Keep your translation keys simple and at the top level.
Good Flat structure:
{
"greeting": "Hello",
"farewell": "Goodbye"
}
Bad Nested structure (won't work with ctx.translate('messages.greeting')
:
{
"messages": {
"greeting": "Hello",
"farewell": "Goodbye"
}
}
Internationalization with Sub-commands
You can apply internationalization to CLIs with sub-commands:
import { cli } from 'gunshi'
import enUSForCreate from './locales/create/en-US.json' with { type: 'json' }
import enUSForMain from './locales/main/en-US.json' with { type: 'json' }
// Define sub-commands
const createCommand = {
name: 'create',
args: {
name: { type: 'string', short: 'n' }
},
// Resource fetcher for the create command
resource: async ctx => {
if (ctx.locale.toString() === 'ja-JP') {
const resource = await import('./locales/create/ja-JP.json', { with: { type: 'json' } })
return resource.default
}
return enUSForCreate
},
run: ctx => {
console.log(`Creating resource: ${ctx.values.name}`)
}
}
// Define the main command
const mainCommand = {
name: 'resource-manager',
// Resource fetcher for the main command
resource: async ctx => {
if (ctx.locale.toString() === 'ja-JP') {
const resource = await import('./locales/main/ja-JP.json', { with: { type: 'json' } })
return resource.default
}
return enUSForMain
},
run: () => {
console.log('Use a sub-command')
}
}
// Create a Map of sub-commands
const subCommands = new Map()
subCommands.set('create', createCommand)
// Run the CLI with i18n support
await cli(process.argv.slice(2), mainCommand, {
name: 'i18n-example',
version: '1.0.0',
locale: new Intl.Locale(process.env.MY_LOCALE || 'en-US'),
subCommands
})
Complete Example
Here's a complete example of a CLI with internationalization:
import { cli } from 'gunshi'
import enUS from './locales/en-US.json' with { type: 'json' }
const command = {
name: 'greeter',
args: {
name: {
type: 'string',
short: 'n'
},
formal: {
type: 'boolean',
short: 'f'
}
},
// Define a resource fetcher for translations
resource: async ctx => {
// Check the locale and return appropriate translations
if (ctx.locale.toString() === 'ja-JP') {
const resource = await import('./locales/ja-JP.json', { with: { type: 'json' } })
return resource.default
}
// Default to English
return enUS
},
// Define examples
examples:
'# Basic greeting\n$ node index.js --name John\n\n# Formal greeting in Japanese\n$ MY_LOCALE=ja-JP node index.js --name 田中 --formal',
// Command execution function
run: ctx => {
const { name = 'World', formal } = ctx.values
const locale = ctx.locale.toString()
console.log(`Current locale: ${locale}`)
// Choose between formal and informal greeting
const greeting = formal ? ctx.translate('formal_greeting') : ctx.translate('informal_greeting')
// Display the greeting
console.log(`${greeting}, ${name}!`)
// Show translation information
console.log('\nTranslation Information:')
console.log(`Command Description: ${ctx.translate('description')}`)
console.log(`Name Argument: ${ctx.translate('arg:name')}`)
console.log(`Formal Argument: ${ctx.translate('arg:formal')}`)
}
}
// Run the command with i18n support
await cli(process.argv.slice(2), command, {
name: 'i18n-example',
version: '1.0.0',
description: 'Example of internationalization support',
// Set the locale via an environment variable
locale: new Intl.Locale(process.env.MY_LOCALE || 'en-US')
})
With locale files:
locales/en-US.json
:
{
"description": "Greeting application",
"arg:name": "Name to greet",
"arg:formal": "Use formal greeting",
"informal_greeting": "Hello",
"formal_greeting": "Good day"
}
locales/ja-JP.json
:
{
"description": "挨拶アプリケーション",
"arg:name": "挨拶する相手の名前",
"arg:formal": "丁寧な挨拶を使用する",
"informal_greeting": "こんにちは",
"formal_greeting": "はじめまして"
}