diff --git a/src/index.spec.ts b/src/index.spec.ts index 76d07ed..a3e0a1a 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -308,3 +308,90 @@ describe('i18n', () => { expect(logger.log).toHaveBeenCalledTimes(callsLength + 1); }); }); + +describe('constructor options', () => { + describe('lang', () => { + it('should return translation [set lang via options.lang]', () => { + i18n = new I18N({lang: 'en'}); + i18n.registerKeyset('en', 'notification', { + title: 'New version' + }); + expect(i18n.i18n('notification', 'title')).toBe('New version'); + }); + + it('should return translation [set lang via i18n.setLang]', () => { + i18n.setLang('en'); + i18n.registerKeyset('en', 'notification', { + title: 'New version' + }); + expect(i18n.i18n('notification', 'title')).toBe('New version'); + }); + }); + + describe('data', () => { + it('should return translation [set data via options.data]', () => { + i18n = new I18N({lang: 'en', data: {en: {notification: {title: 'New version'}}}}); + expect(i18n.i18n('notification', 'title')).toBe('New version'); + }); + + it('should return translation [set data via i18n.registerKeyset]', () => { + i18n = new I18N({lang: 'en'}); + i18n.registerKeyset('en', 'notification', { + title: 'New version' + }); + expect(i18n.i18n('notification', 'title')).toBe('New version'); + }); + }); + + describe('fallbackLang', () => { + it('should return translation from default language in case of language data absence', () => { + i18n = new I18N({ + lang: 'sr', + fallbackLang: 'en', + data: {en: {notification: {title: 'New version'}}}, + }); + expect(i18n.i18n('notification', 'title')).toBe('New version'); + }); + + it('should return fallback from default language in case of language data absence', () => { + i18n = new I18N({lang: 'sr', fallbackLang: 'en', data: {en: {notification: {}}}}); + expect(i18n.i18n('notification', 'title')).toBe('title'); + }); + + it('should return translation from default language in case of empty keyset', () => { + i18n = new I18N({ + lang: 'sr', + fallbackLang: 'en', + data: { + en: {notification: {title: 'New version'}}, + sr: {notification: {}}, + }, + }); + expect(i18n.i18n('notification', 'title')).toBe('New version'); + }); + + it('should return translation from default language in case of missing key', () => { + i18n = new I18N({ + lang: 'sr', + fallbackLang: 'en', + data: { + en: {notification: {title: 'New version'}}, + sr: {notification: {hey: 'Zdravo!'}}, + }, + }); + expect(i18n.i18n('notification', 'title')).toBe('New version'); + }); + + it('should return fallback from default language in case of missing key', () => { + i18n = new I18N({ + lang: 'sr', + fallbackLang: 'en', + data: { + en: {notification: {hey: 'Hello!'}}, + sr: {notification: {hey: 'Zdravo!'}}, + }, + }); + expect(i18n.i18n('notification', 'title')).toBe('title'); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 9c205e3..89e8e45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,116 @@ import {replaceParams} from './replace-params'; -import {KeysData, KeysetData, Logger, Params, PluralForm, Pluralizer} from './types'; - +import {ErrorCode, mapErrorCodeToMessage} from './translation-helpers'; +import type {ErrorCodeType} from './translation-helpers'; +import {PluralForm} from './types'; +import type {KeysData, KeysetData, Logger, Params, Pluralizer} from './types'; import pluralizerEn from './plural/en'; import pluralizerRu from './plural/ru'; export * from './types'; +type I18NOptions = { + /** + * Keysets mapped data. + * @example + * ``` + import {I18N} from '@gravity-ui/i18n'; + + let i18n = new I18N({ + lang: 'en', + data: { + en: {notification: {title: 'New version'}}, + sr: {notification: {title: 'Нова верзија'}}, + }, + }); + // Equivalent approach via public api of i18n instance + i18n = new I18N(); + i18n.setLang('en'); + i18n.registerKeysets('en', {notification: {title: 'New version'}}); + i18n.registerKeysets('sr', {notification: {title: 'Нова верзија'}}); + * ``` + */ + data?: Record; + /** + * Language used as fallback in case there is no translation in the target language. + * @example + * ``` + import {I18N} from '@gravity-ui/i18n'; + + const i18n = new I18N({ + lang: 'sr', + fallbackLang: 'en', + data: { + en: {notification: {title: 'New version'}}, + sr: {notification: {}}, + }, + }); + i18n.i18n('notification', 'title'); // 'New version' + // Equivalent approach via public api of i18n instance + i18n = new I18N(); + i18n.setLang('sr'); + i18n.setFallbackLang('en'); + i18n.registerKeysets('en', {notification: {title: 'New version'}}); + i18n.registerKeysets('sr', {notification: {}}); + i18n.i18n('notification', 'title'); // 'New version' + * ``` + */ + fallbackLang?: string; + /** + * Target language for the i18n instance. + * @example + * ``` + import {I18N} from '@gravity-ui/i18n'; + + let i18n = new I18N({lang: 'en'}); + // Equivalent approach via public api of i18n instance + i18n = new I18N(); + i18n.setLang('en'); + * ``` + */ + lang?: string; + logger?: Logger; +}; + +type TranslationData = { + text?: string; + details?: { + code: ErrorCodeType; + keysetName?: string; + key?: string; + }; +}; + export class I18N { data: Record = {}; - lang?: string = undefined; pluralizers: Record = { en: pluralizerEn, ru: pluralizerRu, }; logger: Logger | null = null; + fallbackLang?: string; + lang?: string; + + constructor(options: I18NOptions = {}) { + const {data, fallbackLang, lang, logger = null} = options; + this.fallbackLang = fallbackLang; + this.lang = lang; + this.logger = logger; - constructor(options?: {logger?: Logger}) { - this.logger = options?.logger || null; + if (data) { + Object.entries(data).forEach(([keysetLang, keysetData]) => { + this.registerKeysets(keysetLang, keysetData) + }); + } } setLang(lang: string) { this.lang = lang; } + setFallbackLang(fallbackLang: string) { + this.fallbackLang = fallbackLang; + } + configurePluralization(pluralizers: Record) { this.pluralizers = Object.assign({}, this.pluralizers, pluralizers); } @@ -43,93 +131,50 @@ export class I18N { has(keysetName: string, key: string, lang?: string) { const languageData = this.getLanguageData(lang); - return Boolean(languageData && languageData[keysetName] && languageData[keysetName][key]); + return Boolean(languageData && languageData[keysetName] && languageData[keysetName]?.[key]); } i18n(keysetName: string, key: string, params?: Params): string { - const languageData = this.getLanguageData(this.lang); - - if (typeof languageData === 'undefined') { - throw new Error(`Language '${this.lang}' is not defined, make sure you call setLang for the same language you called registerKeysets for!`); + if (!this.lang && !this.fallbackLang) { + throw new Error('Language is not specified. You should set at least one of these: "lang", "fallbackLang"'); } - if (Object.keys(languageData).length === 0) { - this.warn('Language data is empty.'); - - return key; - } + let text: string | undefined; + let details: TranslationData['details']; - const keyset = languageData[keysetName]; - - if (!keyset) { - this.warn( - 'Keyset not found.', - keysetName, - ); - - return key; - } - - if (Object.keys(keyset).length === 0) { - this.warn( - 'Keyset is empty.', - keysetName, - ); + if (this.lang) { + ({text, details} = this.getTranslationData({keysetName, key, params, lang: this.lang})); - return key; + if (details) { + const message = mapErrorCodeToMessage({ + code: details.code, + lang: this.lang, + fallbackLang: this.fallbackLang === this.lang ? undefined : this.fallbackLang, + }); + this.warn(message, details.keysetName, details.key); + } + } else { + this.warn('Target language is not specified.'); } - const keyValue = keyset && keyset[key]; - let result: string; - - if (typeof keyValue === 'undefined') { - this.warn( - 'Missing key.', + if (text === undefined && this.fallbackLang && this.fallbackLang !== this.lang) { + ({text, details} = this.getTranslationData({ keysetName, key, - ); - - return key; - } - - if (Array.isArray(keyValue)) { - if (keyValue.length < 3) { - this.warn( - 'Missing required plurals', - keysetName, - key, - ); - - return key; + params, + lang: this.fallbackLang, + })); + + if (details) { + const message = mapErrorCodeToMessage({ + code: details.code, + lang: this.fallbackLang, + }); + this.warn(message, details.keysetName, details.key); } - - const count = Number(params?.count); - - if (Number.isNaN(count)) { - this.warn( - 'Missing params.count for key.', - keysetName, - key, - ); - - return key; - } - - const pluralizer = this.getLanguagePluralizer(this.lang); - result = keyValue[pluralizer(count, PluralForm)] || keyValue[PluralForm.Many]; - - if (keyValue[PluralForm.None] === undefined) { - this.warn('Missing key for 0', keysetName, key); - } - } else { - result = keyValue; - } - - if (params) { - result = replaceParams(result, params); } - return result; + return text ?? key; } keyset(keysetName: string) { @@ -172,4 +217,70 @@ export class I18N { } return pluralizer || pluralizerEn; } + + private getTranslationData(args: { + keysetName: string; + key: string; + lang: string; + params?: Params; + }): TranslationData { + const {lang, key, keysetName, params} = args; + const languageData = this.getLanguageData(lang); + + if (typeof languageData === 'undefined') { + return {details: {code: ErrorCode.NoLanguageData}}; + } + + if (Object.keys(languageData).length === 0) { + return {details: {code: ErrorCode.EmptyLanguageData}}; + } + + const keyset = languageData[keysetName]; + + if (!keyset) { + return {details: {code: ErrorCode.KeysetNotFound, keysetName}}; + } + + if (Object.keys(keyset).length === 0) { + return {details: {code: ErrorCode.EmptyKeyset, keysetName}}; + } + + const keyValue = keyset && keyset[key]; + const result: TranslationData = {}; + + if (keyValue === undefined) { + return {details: {code: ErrorCode.MissingKey, keysetName, key}}; + } + + if (Array.isArray(keyValue)) { + if (keyValue.length < 3) { + return {details: {code: ErrorCode.MissingKeyPlurals, keysetName, key}}; + } + + const count = Number(params?.count); + + if (Number.isNaN(count)) { + return {details: {code: ErrorCode.MissingKeyParamsCount, keysetName, key}}; + } + + const pluralizer = this.getLanguagePluralizer(lang); + result.text = keyValue[pluralizer(count, PluralForm)] || keyValue[PluralForm.Many]; + + if (result.text === undefined) { + return {details: {code: ErrorCode.MissingKeyPlurals, keysetName, key}}; + } + + if (keyValue[PluralForm.None] === undefined) { + result.details = {code: ErrorCode.MissingKeyFor0, keysetName, key}; + } + } else { + result.text = keyValue; + } + + if (params) { + result.text = replaceParams(result.text, params); + } + + return result; + } } diff --git a/src/replace-params.ts b/src/replace-params.ts index d2106e7..f8e79a9 100644 --- a/src/replace-params.ts +++ b/src/replace-params.ts @@ -14,7 +14,7 @@ export function replaceParams(keyValue: string, params: Params): string { lastIndex = PARAM_REGEXP.lastIndex; const [all, key] = match; - if (Object.prototype.hasOwnProperty.call(params, key)) { + if (key && Object.prototype.hasOwnProperty.call(params, key)) { result += params[key]; } else { result += all; diff --git a/src/translation-helpers.ts b/src/translation-helpers.ts new file mode 100644 index 0000000..8dc5a6a --- /dev/null +++ b/src/translation-helpers.ts @@ -0,0 +1,58 @@ +export const ErrorCode = { + EmptyKeyset: 'EMPTY_KEYSET', + EmptyLanguageData: 'EMPTY_LANGUAGE_DATA', + KeysetNotFound: 'KEYSET_NOT_FOUND', + MissingKey: 'MISSING_KEY', + MissingKeyFor0: 'MISSING_KEY_FOR_0', + MissingKeyParamsCount: 'MISSING_KEY_PARAMS_COUNT', + MissingKeyPlurals: 'MISSING_KEY_PLURALS', + NoLanguageData: 'NO_LANGUAGE_DATA', +} as const; + +const codeValues = Object.values(ErrorCode); +export type ErrorCodeType = (typeof codeValues)[number]; + +export function mapErrorCodeToMessage(args: {code: ErrorCodeType; lang: string; fallbackLang?: string}) { + const {code, fallbackLang, lang} = args; + let message = `Using language ${lang}. `; + + switch (code) { + case ErrorCode.EmptyKeyset: { + message += `Keyset is empty.`; + break; + } + case ErrorCode.EmptyLanguageData: { + message += 'Language data is empty.'; + break; + } + case ErrorCode.KeysetNotFound: { + message += 'Keyset not found.'; + break; + } + case ErrorCode.MissingKey: { + message += 'Missing key.'; + break; + } + case ErrorCode.MissingKeyFor0: { + message += 'Missing key for 0'; + return message + } + case ErrorCode.MissingKeyParamsCount: { + message += 'Missing params.count for key.'; + break; + } + case ErrorCode.MissingKeyPlurals: { + message += 'Missing required plurals.'; + break; + } + case ErrorCode.NoLanguageData: { + message = `Language "${lang}" is not defined, make sure you call setLang for the same language you called registerKeysets for!`; + } + } + + if (fallbackLang) { + message += ` Trying to use fallback language "${fallbackLang}"...`; + } + + return message; +} diff --git a/tsconfig.json b/tsconfig.json index 1c06f60..940e607 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,8 @@ "module": "esnext", "baseUrl": ".", "importHelpers": true, - "declaration": true + "declaration": true, + "noUncheckedIndexedAccess": true }, "include": ["src/*.ts"], "exclude": ["**/*.spec.ts"]