diff --git a/eslint.config.ts b/eslint.config.ts index 8aff335d..29ba42ae 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -17,6 +17,7 @@ const config: ReturnType = defineConfig( javascript(), typescript({ rules: { + '@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/ban-ts-comment': 'off' } }), diff --git a/package.json b/package.json index 2b2da41d..b5ab6830 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35583c47..e734dd1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@eslint/markdown': specifier: ^6.2.2 version: 6.2.2 + '@intlify/core': + specifier: next + version: 12.0.0-alpha.1 '@kazupon/eslint-config': specifier: ^0.22.0 version: 0.22.0(@eslint/markdown@6.2.2)(@vitest/eslint-plugin@1.1.36(@typescript-eslint/utils@8.26.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.13.9)(jiti@2.4.2)(tsx@4.19.3)(yaml@2.7.0)))(eslint-config-prettier@10.0.2(eslint@9.21.0(jiti@2.4.2)))(eslint-plugin-jsonc@2.19.1(eslint@9.21.0(jiti@2.4.2)))(eslint-plugin-promise@7.2.1(eslint@9.21.0(jiti@2.4.2)))(eslint-plugin-regexp@2.7.0(eslint@9.21.0(jiti@2.4.2)))(eslint-plugin-unicorn@57.0.0(eslint@9.21.0(jiti@2.4.2)))(eslint-plugin-yml@1.17.0(eslint@9.21.0(jiti@2.4.2)))(eslint@9.21.0(jiti@2.4.2))(typescript-eslint@8.26.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(typescript@5.8.2) @@ -69,6 +72,9 @@ importers: lint-staged: specifier: ^15.4.3 version: 15.4.3 + messageformat: + specifier: 4.0.0-9 + version: 4.0.0-9 pkg-pr-new: specifier: ^0.0.41 version: 0.0.41 @@ -550,6 +556,22 @@ packages: resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} engines: {node: '>=18.18'} + '@intlify/core-base@12.0.0-alpha.1': + resolution: {integrity: sha512-1H0AKcnN/bqUBtNuMGmFwMJAkVZ1H1KATvoZGChXj46tgIxSEjQW/Es7R/jTewsFwXGQjO2rvxEk1kADFC1G8A==} + engines: {node: '>= 16'} + + '@intlify/core@12.0.0-alpha.1': + resolution: {integrity: sha512-ZacnCWmzDc2amU4ij9hrE5Ecg1XJueev4i9e62XyDQFUz0xnAL4CPd20HeI1dp3PHaUYrirBnLUCpVyirwq5rg==} + engines: {node: '>= 16'} + + '@intlify/message-compiler@12.0.0-alpha.1': + resolution: {integrity: sha512-rS1Lc99D2uaGqWxlrpGPWdgkq2Jox8xxOS9gdIRhuF2CsuJISWQmwd/TjMnWNhwv9olE0aPEBh1323a61Tfp+g==} + engines: {node: '>= 16'} + + '@intlify/shared@12.0.0-alpha.1': + resolution: {integrity: sha512-ZZ5rtlUcEnhhFS+MTrl0V1UoN3yRninGawP3f1YituJN9217xJvpqCSLa9t8NLaVwVIxsRcq4lQe48D0SigmBg==} + engines: {node: '>= 16'} + '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} @@ -1957,6 +1979,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + messageformat@4.0.0-9: + resolution: {integrity: sha512-4QHEgN5EiK0w8XmMztn+hdnuJuaChp0bv6D1iw1GvFMR9FVDF9sDk0KfIM9XnBjqa0ekK8wBsX7lyMNfD53dqQ==} + micromark-core-commonmark@2.0.2: resolution: {integrity: sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==} @@ -3024,6 +3049,23 @@ snapshots: '@humanwhocodes/retry@0.4.2': {} + '@intlify/core-base@12.0.0-alpha.1': + dependencies: + '@intlify/message-compiler': 12.0.0-alpha.1 + '@intlify/shared': 12.0.0-alpha.1 + + '@intlify/core@12.0.0-alpha.1': + dependencies: + '@intlify/core-base': 12.0.0-alpha.1 + '@intlify/shared': 12.0.0-alpha.1 + + '@intlify/message-compiler@12.0.0-alpha.1': + dependencies: + '@intlify/shared': 12.0.0-alpha.1 + source-map-js: 1.2.1 + + '@intlify/shared@12.0.0-alpha.1': {} + '@jridgewell/sourcemap-codec@1.5.0': {} '@jsdevtools/ez-spawn@3.0.4': @@ -4417,6 +4459,8 @@ snapshots: merge2@1.4.1: {} + messageformat@4.0.0-9: {} + micromark-core-commonmark@2.0.2: dependencies: decode-named-character-reference: 1.0.2 diff --git a/src/constants.ts b/src/constants.ts index 4b7f1e0d..a78082c9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 = { @@ -19,6 +20,7 @@ type CommonOptionType = { readonly short: 'v' } } + export const COMMON_OPTIONS: CommonOptionType = { help: { type: 'boolean', @@ -41,7 +43,8 @@ export const COMMAND_OPTIONS_DEFAULT: CommandOptions = { usageOptionType: false, renderHeader: undefined, renderUsage: undefined, - renderValidationErrors: undefined + renderValidationErrors: undefined, + translationAdapterFactory: undefined } export const COMMAND_BUILTIN_RESOURCE_KEYS = [ diff --git a/src/context.test.ts b/src/context.test.ts index 9797e612..e2ba94bb 100644 --- a/src/context.test.ts +++ b/src/context.test.ts @@ -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' @@ -336,3 +341,112 @@ describe('translation', () => { expect(ctx.translate('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 + + const loadLocale = 'ja-JP' + + const mockResource = vi.fn>().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 + + 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 + + const loadLocale = 'ja-JP' + + const mockResource = vi.fn>().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 + + 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`) + }) +}) diff --git a/src/context.ts b/src/context.ts index c9cff362..81a7b899 100644 --- a/src/context.ts +++ b/src/context.ts @@ -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, @@ -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> = new Map() - // store command resources in sub-commands - const commandResources = new Map>() let builtInLoadedResources: Record | undefined @@ -116,21 +121,23 @@ export async function createCommandContext< * */ - function translate(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 = create>()): 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) || '' } } @@ -187,7 +194,7 @@ export async function createCommandContext< }, create>()) 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) { @@ -199,21 +206,11 @@ export async function createCommandContext< } as Record, originalResource as Record ) - // const resource = Object.entries(originalResource.options).reduce( - // (res, [key, value]) => { - // res[key] = value - // return res - // }, - // Object.assign(create>(), { - // description: originalResource.description, - // examples: originalResource.examples - // } as Record) - // ) if (builtInLoadedResources) { resource.help = builtInLoadedResources.help resource.version = builtInLoadedResources.version } - commandResources.set(locale.toString(), resource) + adapter.setResource(locale.toString(), resource) } return ctx diff --git a/src/index.ts b/src/index.ts index 3b86436f..0c4171c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/translation.ts b/src/translation.ts new file mode 100644 index 00000000..f0e831fc --- /dev/null +++ b/src/translation.ts @@ -0,0 +1,49 @@ +import { create } from './utils.js' + +import type { TranslationAdapter, TranslationAdapterFactoryOptions } from './types' + +export function createTranslationAdapter( + options: TranslationAdapterFactoryOptions +): TranslationAdapter { + return new DefaultTranslation(options) +} + +export class DefaultTranslation implements TranslationAdapter { + #resources: Map> = new Map() + options: TranslationAdapterFactoryOptions + + constructor(options: TranslationAdapterFactoryOptions) { + this.options = options + this.#resources = new Map() + } + + getResource(locale: string): Record | undefined { + return this.#resources.get(locale) + } + + setResource(locale: string, resource: Record): void { + this.#resources.set(locale, resource) + } + + getMessage(locale: string, key: string): string | undefined { + const resource = this.getResource(locale) + if (resource) { + return resource[key] + } + return undefined + } + + translate( + locale: string, + key: string, + _values: Record = create>() + ): string | undefined { + /** + * NOTE: + * DefaultTranslation support static message only + * If you want to resolve message with values and use the complex message format, + * you should inherit this class or implement your own translation adapter. + */ + return this.getMessage(locale, key) || this.getMessage(this.options.fallbackLocale, key) + } +} diff --git a/src/types.ts b/src/types.ts index 523fc1a2..6335e9b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -156,6 +156,11 @@ export interface CommandOptions { renderValidationErrors?: | ((ctx: Readonly>, error: AggregateError) => Promise) | null + /** + * Translation adapter factory + * @experimental + */ + translationAdapterFactory?: TranslationAdapterFactory } /** @@ -218,11 +223,13 @@ export interface CommandContext< /** * Translate function * @param key the key to be translated + * @param values the values to be formatted * @returns A translated string * @experimental */ translate: ( - key: Key + key: Key, + values?: Record ) => string } @@ -309,6 +316,64 @@ export type CommandResourceFetcher = ( ctx: Readonly> ) => Promise> +/** + * Translation adapter factory + */ +export type TranslationAdapterFactory = ( + options: TranslationAdapterFactoryOptions +) => TranslationAdapter + +/** + * Translation adapter factory options + */ +export interface TranslationAdapterFactoryOptions { + /** + * A locale + */ + locale: string + /** + * A fallback locale + */ + fallbackLocale: string +} + +/** + * Translation adapter + * + * @description + * This adapter is used to custom message formatter like {@link https://github.com/intlify/vue-i18n/blob/master/spec/syntax.ebnf | Intlify message format}, {@link https://github.com/tc39/proposal-intl-messageformat | `Intl.MessageFormat` (MF2)}, and etc. + * This adapter will support localization with your preferred message format + */ +export interface TranslationAdapter { + /** + * Get a resource of locale + * @param locale A Locale at the time of command execution. That is Unicord locale ID (BCP 47) + * @returns A resource of locale. if resource not found, return `undefined` + */ + getResource(locale: string): Record | undefined + /** + * Set a resource of locale + * @param locale A Locale at the time of command execution. That is Unicord locale ID (BCP 47) + * @param resource A resource of locale + */ + setResource(locale: string, resource: Record): void + /** + * Get a message of locale + * @param locale A Locale at the time of command execution. That is Unicord locale ID (BCP 47) + * @param key A key of message resource + * @returns A message of locale. if message not found, return `undefined` + */ + getMessage(locale: string, key: string): MessageResource | undefined + /** + * Translate a message + * @param locale A Locale at the time of command execution. That is Unicord locale ID (BCP 47) + * @param key A key of message resource + * @param values A values to be resolved in the message + * @returns A translated message, if message is not translated, return `undefined` + */ + translate(locale: string, key: string, values?: Record): string | undefined +} + /** * Command runner * @param ctx A {@link CommandContext | command context} diff --git a/test/utils.ts b/test/utils.ts index 1e335d6e..3431a55f 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,4 +1,16 @@ +import { + createCoreContext, + getLocaleMessage, + NOT_REOSLVED, + setLocaleMessage, + translate +} from '@intlify/core' +import { MessageFormat } from 'messageformat' import { vi } from 'vitest' +import { DefaultTranslation } from '../src/translation.js' + +import type { CoreContext, LocaleMessage, LocaleMessageValue } from '@intlify/core' +import type { TranslationAdapter, TranslationAdapterFactoryOptions } from '../src/types' export function defineMockLog(utils: typeof import('../src/utils')) { const logs: unknown[] = [] @@ -12,3 +24,109 @@ export function defineMockLog(utils: typeof import('../src/utils')) { export function hasPrototype(obj: unknown): boolean { return Object.getPrototypeOf(obj) !== null } + +export function createTranslationAdapterForMessageFormat2( + options: TranslationAdapterFactoryOptions +): TranslationAdapter { + return new MessageFormat2Translation(options) +} + +class MessageFormat2Translation extends DefaultTranslation { + #messageFormatCaches: Map< + string, + (values: Record, onError: (err: Error) => void) => string | undefined + > + + constructor(options: TranslationAdapterFactoryOptions) { + super(options) + this.#messageFormatCaches = new Map() + } + + // override + translate(locale: string, key: string, values: Record): string | undefined { + const message = super.translate(locale, key, values) + if (message == null) { + return message + } + + const cacheKey = `${locale}:${key}:${message}` + let detectError = false + const onError = (err: Error) => { + console.error('[gunshi] message format2 error', err.message) + detectError = true + } + + if (this.#messageFormatCaches.has(cacheKey)) { + const format = this.#messageFormatCaches.get(cacheKey)! + const formatted = format(values, onError) + return detectError ? undefined : formatted + } + + const messageFormat = new MessageFormat(locale, message) + const format = (values: Record, onError: (err: Error) => void) => { + return messageFormat.format(values, err => { + onError(err as Error) + }) + } + this.#messageFormatCaches.set(cacheKey, format) + + const formatted = format(values, onError) + return detectError ? undefined : formatted + } +} + +export function createTranslationAdapterForIntlifyMessageFormat( + options: TranslationAdapterFactoryOptions +): TranslationAdapter { + return new IntlifyMessageFormatTranslation(options) +} + +class IntlifyMessageFormatTranslation implements TranslationAdapter { + options: TranslationAdapterFactoryOptions + #context: CoreContext + constructor(options: TranslationAdapterFactoryOptions) { + this.options = options + + const { locale, fallbackLocale } = options + const messages: LocaleMessage = { + [locale]: {} + } + + if (locale !== fallbackLocale) { + messages[fallbackLocale] = {} + } + + this.#context = createCoreContext({ + locale, + fallbackLocale, + messages + }) + } + + getResource(locale: string): Record | undefined { + return getLocaleMessage(this.#context, locale) + } + + setResource(locale: string, resource: Record): void { + setLocaleMessage(this.#context, locale, resource as LocaleMessageValue) + } + + getMessage(locale: string, key: string): string | undefined { + const resource = this.getResource(locale) + if (resource) { + return resource[key] + } + return undefined + } + + translate(locale: string, key: string, values: Record): string | undefined { + const message = + this.getMessage(locale, key) || this.getMessage(this.options.fallbackLocale, key) + if (message == null) { + return undefined + } + + const ret = translate(this.#context, key, values) + return typeof ret === 'number' && ret === NOT_REOSLVED ? undefined : (ret as string) + } +}