Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const config: ReturnType<typeof defineConfig> = defineConfig(
javascript(),
typescript({
rules: {
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/ban-ts-comment': 'off'
}
}),
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
},
"devDependencies": {
"@eslint/markdown": "^6.2.2",
"@intlify/core": "next",
"@kazupon/eslint-config": "^0.22.0",
"@kazupon/prettier-config": "^0.1.1",
"@types/node": "^22.13.9",
Expand All @@ -109,6 +110,7 @@
"jsr": "^0.13.4",
"knip": "^5.45.0",
"lint-staged": "^15.4.3",
"messageformat": "4.0.0-9",
"pkg-pr-new": "^0.0.41",
"prettier": "^3.5.3",
"tsdown": "^0.6.4",
Expand Down
44 changes: 44 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { CommandOptions } from './types'
export const DEFAULT_LOCALE = 'en-US'

export const BUILT_IN_PREFIX = '_'

export const BUILT_IN_KEY_SEPARATOR = ':'

type CommonOptionType = {
Expand All @@ -19,6 +20,7 @@ type CommonOptionType = {
readonly short: 'v'
}
}

export const COMMON_OPTIONS: CommonOptionType = {
help: {
type: 'boolean',
Expand All @@ -41,7 +43,8 @@ export const COMMAND_OPTIONS_DEFAULT: CommandOptions<ArgOptions> = {
usageOptionType: false,
renderHeader: undefined,
renderUsage: undefined,
renderValidationErrors: undefined
renderValidationErrors: undefined,
translationAdapterFactory: undefined
}

export const COMMAND_BUILTIN_RESOURCE_KEYS = [
Expand Down
116 changes: 115 additions & 1 deletion src/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { MessageFormat } from 'messageformat'
import { describe, expect, test, vi } from 'vitest'
import DefaultLocale from '../locales/en-US.json'
import jaLocale from '../locales/ja-JP.json'
import { hasPrototype } from '../test/utils.js'
import {
createTranslationAdapterForIntlifyMessageFormat,
createTranslationAdapterForMessageFormat2,
hasPrototype
} from '../test/utils.js'
import { DEFAULT_LOCALE } from './constants.js'
import { createCommandContext } from './context.js'
import { resolveBuiltInKey } from './utils.js'
Expand Down Expand Up @@ -336,3 +341,112 @@ describe('translation', () => {
expect(ctx.translate<keyof typeof jaJPResource>('test')).toEqual(jaJPResource.test)
})
})

describe('translation adapter', () => {
test('Intl.MessageFormat (MF2)', async () => {
const options = {
foo: {
type: 'string',
short: 'f'
}
} satisfies ArgOptions

const jaJPResource = {
description: 'これはコマンド1です',
foo: 'これは foo オプションです',
examples: 'これはコマンド1の例です',
user: 'こんにちは、{$user}'
} satisfies CommandResource<typeof options>

const loadLocale = 'ja-JP'

const mockResource = vi.fn<CommandResourceFetcher<typeof options>>().mockImplementation(ctx => {
if (ctx.locale.toString() === loadLocale) {
return Promise.resolve(jaJPResource)
} else {
throw new Error('not found')
}
})

const command = {
name: 'cmd1',
usage: {
options: {
foo: 'this is foo option'
},
examples: 'this is an cmd1 example'
},
run: vi.fn(),
resource: mockResource
} satisfies Command<typeof options>

const ctx = await createCommandContext({
options,
values: { foo: 'foo', bar: true, baz: 42 },
positionals: ['bar'],
command,
omitted: false,
commandOptions: {
description: 'this is cmd1',
locale: new Intl.Locale(loadLocale),
translationAdapterFactory: createTranslationAdapterForMessageFormat2
}
})

const mf = new MessageFormat('ja-JP', jaJPResource.user)
expect(ctx.translate('user', { user: 'kazupon' })).toEqual(mf.format({ user: 'kazupon' }))
})

test('Intlify Message Format', async () => {
const options = {
foo: {
type: 'string',
short: 'f'
}
} satisfies ArgOptions

const jaJPResource = {
description: 'これはコマンド1です',
foo: 'これは foo オプションです',
examples: 'これはコマンド1の例です',
user: 'こんにちは、{user}'
} satisfies CommandResource<typeof options>

const loadLocale = 'ja-JP'

const mockResource = vi.fn<CommandResourceFetcher<typeof options>>().mockImplementation(ctx => {
if (ctx.locale.toString() === loadLocale) {
return Promise.resolve(jaJPResource)
} else {
throw new Error('not found')
}
})

const command = {
name: 'cmd1',
usage: {
options: {
foo: 'this is foo option'
},
examples: 'this is an cmd1 example'
},
run: vi.fn(),
resource: mockResource
} satisfies Command<typeof options>

const ctx = await createCommandContext({
options,
values: { foo: 'foo', bar: true, baz: 42 },
positionals: ['bar'],
command,
omitted: false,
commandOptions: {
description: 'this is cmd1',
locale: new Intl.Locale(loadLocale),
translationAdapterFactory: createTranslationAdapterForIntlifyMessageFormat
}
})

expect(ctx.translate('user', { user: 'kazupon' })).toEqual(`こんにちは、kazupon`)
})
})
39 changes: 18 additions & 21 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import DefaultResource from '../locales/en-US.json' with { type: 'json' }
import { BUILT_IN_PREFIX, COMMAND_OPTIONS_DEFAULT, DEFAULT_LOCALE } from './constants.js'
import { createTranslationAdapter } from './translation.js'
import { create, deepFreeze, mapResourceWithBuiltinKey, resolveLazyCommand } from './utils.js'

import type { ArgOptions, ArgOptionSchema, ArgValues } from 'args-tokens'
import type {
Command,
CommandBuiltinResourceKeys,
CommandBuiltinKeys,
CommandContext,
CommandEnvironment,
CommandOptions,
Expand Down Expand Up @@ -89,11 +90,15 @@ export async function createCommandContext<
)

const locale = resolveLocale(commandOptions.locale)
const translationAdapterFactory =
commandOptions.translationAdapterFactory || createTranslationAdapter
const adapter = translationAdapterFactory({
locale: locale.toString(),
fallbackLocale: DEFAULT_LOCALE
})

// store built-in locale resources in the environment
const localeResources: Map<string, Record<string, string>> = new Map()
// store command resources in sub-commands
const commandResources = new Map<string, Record<string, string>>()

let builtInLoadedResources: Record<string, string> | undefined

Expand All @@ -116,21 +121,23 @@ export async function createCommandContext<
*
*/

function translate<T, Key = CommandBuiltinResourceKeys | T>(key: Key): string {
if ((key as string).codePointAt(0) === BUILT_IN_PREFIX_CODE) {
function translate<
T extends string = CommandBuiltinKeys,
Key = CommandBuiltinKeys | keyof Options | T
>(key: Key, values: Record<string, unknown> = create<Record<string, unknown>>()): string {
const strKey = key as string
if (strKey.codePointAt(0) === BUILT_IN_PREFIX_CODE) {
// NOTE:
// if the key is one of the `COMMAND_BUILTIN_RESOURCE_KEYS` and the key is not found in the locale resources,
// then return the key itself.
const resource =
localeResources.get(locale.toString()) || localeResources.get(DEFAULT_LOCALE)!
return resource[key as CommandBuiltinResourceKeys] || (key as string)
return resource[strKey as CommandBuiltinKeys] || strKey
} else {
// NOTE:
// for otherwise, if the key is not found in the command resources, then return an empty string.
// because should not render the key in usage.
const resource =
commandResources.get(locale.toString()) || commandResources.get(DEFAULT_LOCALE)!
return resource[key as string] || ''
return adapter.translate(locale.toString(), strKey, values) || ''
}
}

Expand Down Expand Up @@ -187,7 +194,7 @@ export async function createCommandContext<
}, create<Record<string, string>>())
defaultCommandResource.description = command.description || ''
defaultCommandResource.examples = usage.examples || ''
commandResources.set(DEFAULT_LOCALE, defaultCommandResource)
adapter.setResource(DEFAULT_LOCALE, defaultCommandResource)

const originalResource = await loadCommandResource(ctx, command)
if (originalResource) {
Expand All @@ -199,21 +206,11 @@ export async function createCommandContext<
} as Record<string, string>,
originalResource as Record<string, string>
)
// const resource = Object.entries(originalResource.options).reduce(
// (res, [key, value]) => {
// res[key] = value
// return res
// },
// Object.assign(create<Record<string, string>>(), {
// description: originalResource.description,
// examples: originalResource.examples
// } as Record<string, string>)
// )
if (builtInLoadedResources) {
resource.help = builtInLoadedResources.help
resource.version = builtInLoadedResources.version
}
commandResources.set(locale.toString(), resource)
adapter.setResource(locale.toString(), resource)
}

return ctx
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export type { ArgOptions, ArgOptionSchema, ArgValues } from 'args-tokens'
export * from './cli.js'
export { DefaultTranslation } from './translation.js'
export type * from './types'
Loading