UNPKG

@koishijs/core

Version:

Core Features for Koishi

212 lines (180 loc) 6.52 kB
import { distance } from 'fastest-levenshtein' import { Dict, isNullable } from 'cosmokit' import { fallback, LocaleTree } from '@koishijs/i18n-utils' import { h, Logger, Schema } from '@satorijs/core' import { Context } from './context' import zhCN from './locales/zh-CN.yml' import enUS from './locales/en-US.yml' const logger = new Logger('i18n') const kTemplate = Symbol('template') declare module './context' { interface Context { i18n: I18n } interface Events { 'internal/i18n'(): void } } type GroupNames<P extends string, K extends string = never> = | P extends `${string}(${infer R})${infer S}` ? GroupNames<S, K | R> : K export type MatchResult<P extends string = never> = Record<GroupNames<P>, string> export function createMatch<P extends string>(pattern: P): (string: string) => undefined | MatchResult<P> { const groups: string[] = [] const source = pattern.replace(/\(([^)]+)\)/g, (_, name) => { groups.push(name) return '(.+)' }) const regexp = new RegExp(`^${source}$`) return (string: string) => { const capture = regexp.exec(string) if (!capture) return const data: any = {} for (let i = 0; i < groups.length; i++) { data[groups[i]] = capture[i + 1] } return data } } export interface CompareOptions { minSimilarity?: number } export namespace I18n { export type Node = string | Store export interface Store { [kTemplate]?: string [K: string]: Node } export type Formatter = (value: any, args: string[], locale: string) => string export type Renderer = (dict: Dict, params: any, locale: string) => string export interface FindOptions extends CompareOptions {} export interface FindResult<P extends string> { locale: string data: MatchResult<P> similarity: number } } export class I18n { _data: Dict<Dict<string>> = {} _presets: Dict<I18n.Renderer> = {} locales: LocaleTree constructor(public ctx: Context, config: I18n.Config) { this.locales = LocaleTree.from(config.locales) this.define('', { '': '' }) this.define('zh-CN', zhCN) this.define('en-US', enUS) } fallback(locales: string[]) { return fallback(this.locales, locales) } compare(expect: string, actual: string, options: CompareOptions = {}) { const value = 1 - distance(expect, actual) / expect.length const threshold = options.minSimilarity ?? this.ctx.root.config.minSimilarity return value >= threshold ? value : 0 } get(key: string, locales: string[] = []): Dict<string> { const result: Dict<string> = {} for (const locale of this.fallback(locales)) { const value = this._data[locale]?.[key] if (value) result[locale] = value } return result } private* set(locale: string, prefix: string, value: I18n.Node): Generator<string> { if (typeof value === 'object' && value && !prefix.includes('@')) { for (const key in value) { if (key.startsWith('_')) continue yield* this.set(locale, prefix + key + '.', value[key]) } } else if (prefix.includes('@')) { throw new Error('preset is deprecated') } else if (typeof value === 'string') { const dict = this._data[locale] const path = prefix.slice(0, -1) if (!isNullable(dict[path]) && !locale.startsWith('$') && dict[path] !== value) { logger.warn('override', locale, path) } dict[path] = value yield path } else { delete this._data[locale][prefix.slice(0, -1)] } } define(locale: string, dict: I18n.Store): () => void define(locale: string, key: string, value: I18n.Node): () => void define(locale: string, ...args: [I18n.Store] | [string, I18n.Node]) { const dict = this._data[locale] ||= {} const paths = [...typeof args[0] === 'string' ? this.set(locale, args[0] + '.', args[1]) : this.set(locale, '', args[0])] this.ctx.emit('internal/i18n') return this.ctx.collect('i18n', () => { for (const path of paths) { delete dict[path] } this.ctx.emit('internal/i18n') }) } find<P extends string>(pattern: P, actual: string, options: I18n.FindOptions = {}): I18n.FindResult<P>[] { if (!actual) return [] const match = createMatch(pattern) const results: I18n.FindResult<P>[] = [] for (const locale in this._data) { for (const path in this._data[locale]) { const data = match(path) if (!data) continue const expect = this._data[locale][path] if (typeof expect !== 'string') continue const similarity = this.compare(expect, actual, options) if (!similarity) continue results.push({ locale, data, similarity }) } } return results } _render(value: I18n.Node, params: any, locale: string) { if (value === undefined) return if (typeof value !== 'string') { const preset = value[kTemplate] const render = this._presets[preset] if (!render) throw new Error(`Preset "${preset}" not found`) return [h.text(render(value, params, locale))] } return h.parse(value, params) } /** @deprecated */ text(locales: string[], paths: string[], params: object) { return this.render(locales, paths, params).join('') } render(locales: string[], paths: string[], params: object) { locales = this.fallback(locales) // try every locale for (const path of paths) { for (const locale of locales) { for (const key of ['$' + locale, locale]) { const value = this._data[key]?.[path] if (value === undefined || !value && !locale && path !== '') continue return this._render(value, params, locale) } } } // path not found logger.warn('missing', paths[0]) return [h.text(paths[0])] } } export namespace I18n { export interface Config { locales?: string[] output?: 'prefer-user' | 'prefer-channel' match?: 'strict' | 'prefer-input' | 'prefer-output' } export const Config: Schema<Config> = Schema.object({ locales: Schema.array(String).role('table').default(['zh-CN', 'en-US', 'fr-FR', 'ja-JP', 'de-DE', 'ru-RU']).description('可用的语言列表。按照回退顺序排列。'), output: Schema.union([ Schema.const('prefer-user').description('优先使用用户语言'), Schema.const('prefer-channel').description('优先使用频道语言'), ]).default('prefer-channel').description('输出语言偏好设置。'), }).description('国际化设置') }