import {Injectable} from '../../di' import {ErrorWithContext} from '../../util' import * as pluralize from 'pluralize' import {AppClass} from '../../lifecycle/AppClass' import {Config} from '../../service/Config' /** * Type name for the standalone localization helper function that can be passed around * in lieu of the Locale service. */ export type LocaleHelper = (phrase: string, { plural, fallback, interp }: {plural?: number, fallback?: string, interp?: {[key: string]: any}}) => string /** * Request-level service that provides localization of phrases based on config files. */ @Injectable() export class Locale extends AppClass { protected get config(): Config { // For some reason, was having issues with this not injecting properly. // TODO convert this back to @Inject() and solve that bug return this.app().make(Config) } constructor( /** * The preferred locale. This corresponds to the config prefix. * @example en_US means "lang:en_US" config scope * @example es_MX means "lang:es_MX" config scope */ protected locale?: string, ) { super() if ( !this.locale ) { this.locale = this.getDefaultLocale() } } /** * Get the default locale that should be assigned if none is specified in the session. * @return string */ getDefaultLocale(): string { return this.config.get('locale.default', 'en_US') } /** * Set the preferred locale for lookups by this service. * @param locale */ setLocale(locale: string): void { this.locale = locale } /** * Get a wrapped helper function that can be passed around instead of this service. */ helper(): LocaleHelper { return (phrase: string, { plural, fallback, interp }: {plural?: number, fallback?: string, interp?: {[key: string]: any}} = {}) => { return this.get(phrase, {plural, fallback, interp}) } } /** * Resolve the given phrase to the locale of this service. This is where all the magic happens. * * This method tries to load the given phrase from the config using `load()` then localizes it * using the specified parameters. * * @example * The pluralization can be specified using the `plural` parameter. If an explicit pluralization * is specified in the phrase config, that will be used. Otherwise, the `pluralize` library is * used to generate one automatically. * * ```typescript * // Example phrase config: * { * apple: 'Apple', * bunch_of_bananas: { * one: 'Bunch of Bananas', * many: 'Bunches of Bananas', * } * } * * // Example usage: * locale.get('apple', { plural: 3 }) // => 'Apples' * locale.get('apple') // => 'Apple' * locale.get('bunch_of_bananas', { plural: 2 }) // => 'Bunches of Bananas' * locale.get('bunch_of_bananas', { plural: 1}) // => 'Bunch of Bananas' * ``` * * @example * If a translation cannot be found, and a fallback is specified, the fallback will be returned. * Otherwise, the value of `phrase` is returned instead. * * ```typescript * locale.get('nonexistent_phrase', { fallback: 'Hello, world!' }) // => 'Hello, world!' * ``` * * @example * Values can be interpolated into phrases using the `interp` parameter. For example, if there is * a phrase `my_phrase: 'Hello, :name:!`, the value of `:name:` can be replaced like so: * * ```typescript * locale.get('my_phrase', {interp: {name: 'John Doe'}}) // => 'Hello, John Doe!' * ``` * * @example * If a phrase cannot be found in the specific locale config, the service will try to load it from * the equivalent `common` locale config. For example, if `this.locale` is `es_MX`: * * ```typescript * // Example "lang:common:dashboard" config: * { * title: "MyDash2.0", * header: "Welcome to the dashboard!", * } * * // Example "lang:es_MX:dashboard" config: * { * header: "¡Bienvenido al panel de control!", * } * * // Example usage: * locale.get('dashboard.title') // => 'MyDash2.0' * locale.get('dashboard.header') // => '¡Bienvenido al panel de control!' * ``` * * @param phrase * @param plural * @param fallback * @param interp */ get(phrase: string, { plural, fallback, interp }: {plural?: number, fallback?: string, interp?: {[key: string]: any}} = {}): string { const scope = phrase.split(':').reverse() .slice(1) .reverse() .join(':') const specific = phrase.split(':').reverse()[0] const load = `${this.locale}${scope ? ':' + scope : ''}.${specific}` const translated = this.load(load) const isPlural = plural && plural !== 1 if ( !translated ) { return fallback ?? specific } let ret = '' if ( typeof translated === 'object' ) { if ( isPlural && translated.many ) { ret = translated.many } else if ( isPlural && translated.one ) { ret = pluralize(translated.one, plural) } else if ( !isPlural && translated.one ) { ret = translated.one } else if ( !isPlural && translated.many ) { ret = pluralize(translated.many, 1) } else { throw new ErrorWithContext(`Invalid translation config for ${phrase}. Must provide 'one' or 'many' keys.`, { locale: this.locale, phrase, plural, fallback, translated, }) } } else if ( typeof translated === 'string' ) { ret = pluralize(translated, isPlural ? 5 : 1) } else { throw new ErrorWithContext(`Invalid translation object for ${phrase}.`, { locale: this.locale, phrase, plural, fallback, translated, }) } if ( interp ) { for ( const key in interp ) { if ( !Object.prototype.hasOwnProperty.call(interp, key) ) { continue } const rex = new RegExp(`:${key}:`, 'g') ret = ret.replace(rex, interp[key]) } } return ret } /** * Attempt to load the given locale string, merging in the appropriate common locale values. * @param locale * @protected */ protected load(locale: string): {[key: string]: string} | string { const subloc = locale.split(':').slice(1) .join(':') const specificLocale = locale.split(':').reverse()[0].split('.').slice(1) .join('.') let common: any = this.config.get(`lang:common${subloc ? ':' + subloc : ''}${specificLocale ? '.' + specificLocale : ''}`, undefined) let specific: any = this.config.get(`lang:${locale}`, undefined) if ( typeof specific === 'string' ) { return specific } if ( typeof common === 'string' && typeof specific === 'undefined' ) { return common } if ( !common ) { common = {} } if ( !specific ) { specific = {} } return {...common, ...specific} } }