From 4dece6afb98d60bc5559b44d49a48e8f970ff18b Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 24 Jan 2024 13:36:16 +0300 Subject: [PATCH 01/12] feat: add data, defaultLang & lang options as constructor arguments --- src/index.spec.ts | 87 ++++++++++++ src/index.ts | 282 ++++++++++++++++++++++++++----------- src/translation-helpers.ts | 58 ++++++++ 3 files changed, 344 insertions(+), 83 deletions(-) create mode 100644 src/translation-helpers.ts diff --git a/src/index.spec.ts b/src/index.spec.ts index 76d07ed..0408429 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('defaultLang', () => { + it('should return translation from default language in case of language data absence', () => { + i18n = new I18N({ + lang: 'sr', + defaultLang: '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', defaultLang: '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', + defaultLang: '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', + defaultLang: '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', + defaultLang: '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..d1a2322 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,111 @@ 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', + defaultLang: 'en', + data: { + en: {notification: {title: 'New version'}}, + sr: {notification: {}}, + }, + }); + i18n.i18n('notification', 'title'); // 'New version' + * ``` + */ + defaultLang?: 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; + fallbackText?: string; + details?: { + code: ErrorCodeType; + error?: boolean; + keysetName?: string; + key?: string; + }; +}; + export class I18N { data: Record = {}; - lang?: string = undefined; pluralizers: Record = { en: pluralizerEn, ru: pluralizerRu, }; logger: Logger | null = null; + defaultLang?: string; + lang?: string; + + constructor(options: I18NOptions = {}) { + const {data, defaultLang, lang, logger = null} = options; + this.defaultLang = defaultLang; + 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; } + setDefaultLang(defaultLang: string) { + this.defaultLang = defaultLang; + } + configurePluralization(pluralizers: Record) { this.pluralizers = Object.assign({}, this.pluralizers, pluralizers); } @@ -47,89 +130,38 @@ export class I18N { } 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 (Object.keys(languageData).length === 0) { - this.warn('Language data is empty.'); - - return key; - } - - 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, - ); - - return key; - } - - const keyValue = keyset && keyset[key]; - let result: string; - - if (typeof keyValue === 'undefined') { - this.warn( - 'Missing key.', - keysetName, - key, - ); - - return key; - } - - if (Array.isArray(keyValue)) { - if (keyValue.length < 3) { - this.warn( - 'Missing required plurals', - keysetName, - key, - ); - - return key; - } - - const count = Number(params?.count); - - if (Number.isNaN(count)) { - this.warn( - 'Missing params.count for key.', - keysetName, - key, - ); - - return key; + const languages = [this.lang, this.defaultLang].filter(Boolean) as string[]; + let text: string | undefined; + let fallbackText: string | undefined; + let details: TranslationData['details']; + + for (const lang of languages) { + ({text, fallbackText, details} = this.getTranslation({keysetName, key, params, lang})); + + if (details && (!details.error || (details.error && lang !== this.defaultLang))) { + const message = mapErrorCodeToMessage({ + code: details.code, + lang, + ...(this.defaultLang && lang !== this.defaultLang && {defaultLang: this.defaultLang}) + }); + + this.warn(message, details.keysetName, details.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); + if (text) { + break; } - } else { - result = keyValue; } - - if (params) { - result = replaceParams(result, params); + + if (details?.error) { + const message = mapErrorCodeToMessage({ + code: details.code, + lang: languages[languages.length - 1], + }); + throw new Error(message); } - - return result; + + return (text || fallbackText) as string; } keyset(keysetName: string) { @@ -172,4 +204,88 @@ export class I18N { } return pluralizer || pluralizerEn; } + + private getTranslation(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.NO_LANGUAGE_DATA, error: true}}; + } + + if (Object.keys(languageData).length === 0) { + return { + fallbackText: key, + details: {code: ErrorCode.EMPTY_LANGUAGE_DATA}, + }; + } + + const keyset = languageData[keysetName]; + + if (!keyset) { + return { + fallbackText: key, + details: {code: ErrorCode.KEYSET_NOT_FOUND, keysetName}, + }; + } + + if (Object.keys(keyset).length === 0) { + return { + fallbackText: key, + details: {code: ErrorCode.EMPTY_KEYSET, keysetName}, + }; + } + + const keyValue = keyset && keyset[key]; + const result: TranslationData = {}; + + if (typeof keyValue === 'undefined') { + return { + fallbackText: key, + details: {code: ErrorCode.MISSING_KEY, keysetName, key}, + }; + } + + if (Array.isArray(keyValue)) { + if (keyValue.length < 3) { + return { + fallbackText: key, + details: {code: ErrorCode.MISSING_REQUIRED_PLURALS, keysetName, key}, + }; + } + + const count = Number(params?.count); + + if (Number.isNaN(count)) { + return { + fallbackText: key, + details: {code: ErrorCode.MISSING_KEY_PARAMS_COUNT, keysetName, key}, + }; + } + + const pluralizer = this.getLanguagePluralizer(lang); + result.text = keyValue[pluralizer(count, PluralForm)] || keyValue[PluralForm.Many]; + + if (keyValue[PluralForm.None] === undefined) { + result.details = { + code: ErrorCode.MISSING_KEY_FOR_0, + keysetName, + key, + }; + } + } else { + result.text = keyValue; + } + + if (params) { + result.text = replaceParams(result.text, params); + } + + return result; + } } diff --git a/src/translation-helpers.ts b/src/translation-helpers.ts new file mode 100644 index 0000000..fa7fb17 --- /dev/null +++ b/src/translation-helpers.ts @@ -0,0 +1,58 @@ +export const ErrorCode = { + NO_LANGUAGE_DATA: 'NO_LANGUAGE_DATA', + EMPTY_LANGUAGE_DATA: 'EMPTY_LANGUAGE_DATA', + KEYSET_NOT_FOUND: 'KEYSET_NOT_FOUND', + EMPTY_KEYSET: 'EMPTY_KEYSET', + MISSING_KEY: 'MISSING_KEY', + MISSING_REQUIRED_PLURALS: 'MISSING_REQUIRED_PLURALS', + MISSING_KEY_PARAMS_COUNT: 'MISSING_KEY_PARAMS_COUNT', + MISSING_KEY_FOR_0: 'MISSING_KEY_FOR_0', +} as const; + +const codeValues = Object.values(ErrorCode); +export type ErrorCodeType = (typeof codeValues)[number]; + +export function mapErrorCodeToMessage(args: {lang: string; defaultLang?: string; code?: ErrorCodeType}) { + const {code, defaultLang, lang} = args; + let message = ''; + + switch (code) { + case 'EMPTY_KEYSET': { + message = `Keyset is empty.`; + break; + } + case 'EMPTY_LANGUAGE_DATA': { + message = 'Language data is empty.'; + break; + } + case 'KEYSET_NOT_FOUND': { + message = 'Keyset not found.'; + break; + } + case 'MISSING_KEY': { + message = 'Missing key.'; + break; + } + case 'MISSING_KEY_FOR_0': { + message = 'Missing key for 0'; + break; + } + case 'MISSING_KEY_PARAMS_COUNT': { + message = 'Missing params.count for key.'; + break; + } + case 'MISSING_REQUIRED_PLURALS': { + message = 'Missing required plurals'; + break; + } + case 'NO_LANGUAGE_DATA': { + message = `Language "${lang}" is not defined, make sure you call setLang for the same language you called registerKeysets for!`; + } + } + + if (defaultLang) { + message += ` Trying to use default language "${defaultLang}"...`; + } + + return message; +} From 534ec5cb60663f64c80a8fe067b99483fc368009 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 24 Jan 2024 14:12:52 +0300 Subject: [PATCH 02/12] refactor: rename defaultLang to fallbackLang --- src/index.spec.ts | 12 ++++++------ src/index.ts | 27 +++++++++++++++++---------- src/translation-helpers.ts | 8 ++++---- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 0408429..a3e0a1a 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -343,25 +343,25 @@ describe('constructor options', () => { }); }); - describe('defaultLang', () => { + describe('fallbackLang', () => { it('should return translation from default language in case of language data absence', () => { i18n = new I18N({ lang: 'sr', - defaultLang: 'en', + 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', defaultLang: 'en', data: {en: {notification: {}}}}); + 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', - defaultLang: 'en', + fallbackLang: 'en', data: { en: {notification: {title: 'New version'}}, sr: {notification: {}}, @@ -373,7 +373,7 @@ describe('constructor options', () => { it('should return translation from default language in case of missing key', () => { i18n = new I18N({ lang: 'sr', - defaultLang: 'en', + fallbackLang: 'en', data: { en: {notification: {title: 'New version'}}, sr: {notification: {hey: 'Zdravo!'}}, @@ -385,7 +385,7 @@ describe('constructor options', () => { it('should return fallback from default language in case of missing key', () => { i18n = new I18N({ lang: 'sr', - defaultLang: 'en', + fallbackLang: 'en', data: { en: {notification: {hey: 'Hello!'}}, sr: {notification: {hey: 'Zdravo!'}}, diff --git a/src/index.ts b/src/index.ts index d1a2322..2f80ee1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,16 +38,23 @@ type I18NOptions = { const i18n = new I18N({ lang: 'sr', - defaultLang: 'en', + 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' * ``` */ - defaultLang?: string; + fallbackLang?: string; /** * Target language for the i18n instance. * @example @@ -82,12 +89,12 @@ export class I18N { ru: pluralizerRu, }; logger: Logger | null = null; - defaultLang?: string; + fallbackLang?: string; lang?: string; constructor(options: I18NOptions = {}) { - const {data, defaultLang, lang, logger = null} = options; - this.defaultLang = defaultLang; + const {data, fallbackLang, lang, logger = null} = options; + this.fallbackLang = fallbackLang; this.lang = lang; this.logger = logger; @@ -102,8 +109,8 @@ export class I18N { this.lang = lang; } - setDefaultLang(defaultLang: string) { - this.defaultLang = defaultLang; + setFallbackLang(fallbackLang: string) { + this.fallbackLang = fallbackLang; } configurePluralization(pluralizers: Record) { @@ -130,7 +137,7 @@ export class I18N { } i18n(keysetName: string, key: string, params?: Params): string { - const languages = [this.lang, this.defaultLang].filter(Boolean) as string[]; + const languages = [this.lang, this.fallbackLang].filter(Boolean) as string[]; let text: string | undefined; let fallbackText: string | undefined; let details: TranslationData['details']; @@ -138,11 +145,11 @@ export class I18N { for (const lang of languages) { ({text, fallbackText, details} = this.getTranslation({keysetName, key, params, lang})); - if (details && (!details.error || (details.error && lang !== this.defaultLang))) { + if (details && (!details.error || (details.error && lang !== this.fallbackLang))) { const message = mapErrorCodeToMessage({ code: details.code, lang, - ...(this.defaultLang && lang !== this.defaultLang && {defaultLang: this.defaultLang}) + ...(this.fallbackLang && lang !== this.fallbackLang && {fallbackLang: this.fallbackLang}) }); this.warn(message, details.keysetName, details.key); diff --git a/src/translation-helpers.ts b/src/translation-helpers.ts index fa7fb17..46c6b9d 100644 --- a/src/translation-helpers.ts +++ b/src/translation-helpers.ts @@ -12,8 +12,8 @@ export const ErrorCode = { const codeValues = Object.values(ErrorCode); export type ErrorCodeType = (typeof codeValues)[number]; -export function mapErrorCodeToMessage(args: {lang: string; defaultLang?: string; code?: ErrorCodeType}) { - const {code, defaultLang, lang} = args; +export function mapErrorCodeToMessage(args: {lang: string; fallbackLang?: string; code?: ErrorCodeType}) { + const {code, fallbackLang, lang} = args; let message = ''; switch (code) { @@ -50,8 +50,8 @@ export function mapErrorCodeToMessage(args: {lang: string; defaultLang?: string; } } - if (defaultLang) { - message += ` Trying to use default language "${defaultLang}"...`; + if (fallbackLang) { + message += ` Trying to use default language "${fallbackLang}"...`; } return message; From 2a4f3f8eccb0e5d79142dbcbefe062da478c7064 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 24 Jan 2024 15:00:11 +0300 Subject: [PATCH 03/12] refactor: throw an error in case of languages absence --- src/index.spec.ts | 5 +++++ src/index.ts | 5 +++++ src/translation-helpers.ts | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index a3e0a1a..1df8307 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -310,6 +310,11 @@ describe('i18n', () => { }); describe('constructor options', () => { + it('should throw an error in case of languages absence', () => { + i18n = new I18N(); + expect(() => i18n.i18n('notification', 'title')).toThrow(); + }); + describe('lang', () => { it('should return translation [set lang via options.lang]', () => { i18n = new I18N({lang: 'en'}); diff --git a/src/index.ts b/src/index.ts index 2f80ee1..4483607 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,6 +138,11 @@ export class I18N { i18n(keysetName: string, key: string, params?: Params): string { const languages = [this.lang, this.fallbackLang].filter(Boolean) as string[]; + + if (!languages.length) { + throw new Error('There are no defined languages. You should define at least one of these languages: lang, fallbackLang'); + } + let text: string | undefined; let fallbackText: string | undefined; let details: TranslationData['details']; diff --git a/src/translation-helpers.ts b/src/translation-helpers.ts index 46c6b9d..5ec655a 100644 --- a/src/translation-helpers.ts +++ b/src/translation-helpers.ts @@ -12,7 +12,7 @@ export const ErrorCode = { const codeValues = Object.values(ErrorCode); export type ErrorCodeType = (typeof codeValues)[number]; -export function mapErrorCodeToMessage(args: {lang: string; fallbackLang?: string; code?: ErrorCodeType}) { +export function mapErrorCodeToMessage(args: {lang?: string; fallbackLang?: string; code?: ErrorCodeType}) { const {code, fallbackLang, lang} = args; let message = ''; From 106c5a25394bf9e2a5e40f431561c3d76b116fad Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 24 Jan 2024 16:02:01 +0300 Subject: [PATCH 04/12] refactor: rework i18n method --- src/index.ts | 53 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4483607..96c5a39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,7 +76,6 @@ type TranslationData = { fallbackText?: string; details?: { code: ErrorCodeType; - error?: boolean; keysetName?: string; key?: string; }; @@ -137,42 +136,52 @@ export class I18N { } i18n(keysetName: string, key: string, params?: Params): string { - const languages = [this.lang, this.fallbackLang].filter(Boolean) as string[]; - - if (!languages.length) { - throw new Error('There are no defined languages. You should define at least one of these languages: lang, fallbackLang'); + if (!this.lang && !this.fallbackLang) { + throw new Error('There are no available languages. You should set at least one of these languages: lang, fallbackLang'); } let text: string | undefined; let fallbackText: string | undefined; let details: TranslationData['details']; - for (const lang of languages) { - ({text, fallbackText, details} = this.getTranslation({keysetName, key, params, lang})); + ({text, fallbackText, details} = this.getTranslationData({keysetName, key, params, lang: this.lang})); + + if (details && details.code !== ErrorCode.NO_LANGUAGE_DATA) { + const message = mapErrorCodeToMessage({ + code: details.code, + lang: this.lang, + fallbackLang: this.fallbackLang, + }); + this.warn(message, details.keysetName, details.key); + } + + if (this.fallbackLang) { + ({text, fallbackText, details} = this.getTranslationData({ + keysetName, + key, + params, + lang: this.fallbackLang, + })); - if (details && (!details.error || (details.error && lang !== this.fallbackLang))) { + if (details && details.code !== ErrorCode.NO_LANGUAGE_DATA) { const message = mapErrorCodeToMessage({ code: details.code, - lang, - ...(this.fallbackLang && lang !== this.fallbackLang && {fallbackLang: this.fallbackLang}) + lang: this.lang, + fallbackLang: this.fallbackLang, }); - this.warn(message, details.keysetName, details.key); } - - if (text) { - break; - } } - - if (details?.error) { + + if (!text && !fallbackText) { const message = mapErrorCodeToMessage({ - code: details.code, - lang: languages[languages.length - 1], + code: details?.code, + lang: this.lang, + fallbackLang: this.fallbackLang, }); throw new Error(message); } - + return (text || fallbackText) as string; } @@ -217,7 +226,7 @@ export class I18N { return pluralizer || pluralizerEn; } - private getTranslation(args: { + private getTranslationData(args: { keysetName: string; key: string; lang?: string; @@ -227,7 +236,7 @@ export class I18N { const languageData = this.getLanguageData(lang); if (typeof languageData === 'undefined') { - return {details: {code: ErrorCode.NO_LANGUAGE_DATA, error: true}}; + return {details: {code: ErrorCode.NO_LANGUAGE_DATA}}; } if (Object.keys(languageData).length === 0) { From 2410a721d9bf3c4225738b4fe10c0e912910feeb Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 24 Jan 2024 17:14:23 +0300 Subject: [PATCH 05/12] refactor: change ErrorCode naming --- src/index.ts | 20 ++++++++++---------- src/translation-helpers.ts | 18 +++++++++--------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index 96c5a39..b749821 100644 --- a/src/index.ts +++ b/src/index.ts @@ -146,7 +146,7 @@ export class I18N { ({text, fallbackText, details} = this.getTranslationData({keysetName, key, params, lang: this.lang})); - if (details && details.code !== ErrorCode.NO_LANGUAGE_DATA) { + if (details && details.code !== ErrorCode.NoLanguageData) { const message = mapErrorCodeToMessage({ code: details.code, lang: this.lang, @@ -163,7 +163,7 @@ export class I18N { lang: this.fallbackLang, })); - if (details && details.code !== ErrorCode.NO_LANGUAGE_DATA) { + if (details && details.code !== ErrorCode.NoLanguageData) { const message = mapErrorCodeToMessage({ code: details.code, lang: this.lang, @@ -236,13 +236,13 @@ export class I18N { const languageData = this.getLanguageData(lang); if (typeof languageData === 'undefined') { - return {details: {code: ErrorCode.NO_LANGUAGE_DATA}}; + return {details: {code: ErrorCode.NoLanguageData}}; } if (Object.keys(languageData).length === 0) { return { fallbackText: key, - details: {code: ErrorCode.EMPTY_LANGUAGE_DATA}, + details: {code: ErrorCode.EmptyLanguageData}, }; } @@ -251,14 +251,14 @@ export class I18N { if (!keyset) { return { fallbackText: key, - details: {code: ErrorCode.KEYSET_NOT_FOUND, keysetName}, + details: {code: ErrorCode.KeysetNotFound, keysetName}, }; } if (Object.keys(keyset).length === 0) { return { fallbackText: key, - details: {code: ErrorCode.EMPTY_KEYSET, keysetName}, + details: {code: ErrorCode.EmptyKeyset, keysetName}, }; } @@ -268,7 +268,7 @@ export class I18N { if (typeof keyValue === 'undefined') { return { fallbackText: key, - details: {code: ErrorCode.MISSING_KEY, keysetName, key}, + details: {code: ErrorCode.MissingKey, keysetName, key}, }; } @@ -276,7 +276,7 @@ export class I18N { if (keyValue.length < 3) { return { fallbackText: key, - details: {code: ErrorCode.MISSING_REQUIRED_PLURALS, keysetName, key}, + details: {code: ErrorCode.MissingKeyPlurals, keysetName, key}, }; } @@ -285,7 +285,7 @@ export class I18N { if (Number.isNaN(count)) { return { fallbackText: key, - details: {code: ErrorCode.MISSING_KEY_PARAMS_COUNT, keysetName, key}, + details: {code: ErrorCode.MissingKeyParamsCount, keysetName, key}, }; } @@ -294,7 +294,7 @@ export class I18N { if (keyValue[PluralForm.None] === undefined) { result.details = { - code: ErrorCode.MISSING_KEY_FOR_0, + code: ErrorCode.MissingKeyFor0, keysetName, key, }; diff --git a/src/translation-helpers.ts b/src/translation-helpers.ts index 5ec655a..783de99 100644 --- a/src/translation-helpers.ts +++ b/src/translation-helpers.ts @@ -1,12 +1,12 @@ export const ErrorCode = { - NO_LANGUAGE_DATA: 'NO_LANGUAGE_DATA', - EMPTY_LANGUAGE_DATA: 'EMPTY_LANGUAGE_DATA', - KEYSET_NOT_FOUND: 'KEYSET_NOT_FOUND', - EMPTY_KEYSET: 'EMPTY_KEYSET', - MISSING_KEY: 'MISSING_KEY', - MISSING_REQUIRED_PLURALS: 'MISSING_REQUIRED_PLURALS', - MISSING_KEY_PARAMS_COUNT: 'MISSING_KEY_PARAMS_COUNT', - MISSING_KEY_FOR_0: 'MISSING_KEY_FOR_0', + 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); @@ -41,7 +41,7 @@ export function mapErrorCodeToMessage(args: {lang?: string; fallbackLang?: strin message = 'Missing params.count for key.'; break; } - case 'MISSING_REQUIRED_PLURALS': { + case 'MISSING_KEY_PLURALS': { message = 'Missing required plurals'; break; } From cec4ee9cd8dcd9ee58b2ddfe08b77d6971b9dddb Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Thu, 25 Jan 2024 14:14:09 +0300 Subject: [PATCH 06/12] fix: use object properties instead strings in mapErrorCodeToMessage switch cases --- src/index.ts | 2 +- src/translation-helpers.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index b749821..bb0f827 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,7 +137,7 @@ export class I18N { i18n(keysetName: string, key: string, params?: Params): string { if (!this.lang && !this.fallbackLang) { - throw new Error('There are no available languages. You should set at least one of these languages: lang, fallbackLang'); + throw new Error('Language not specified. You should set at least one of these: "lang", "fallbackLang"'); } let text: string | undefined; diff --git a/src/translation-helpers.ts b/src/translation-helpers.ts index 783de99..0d0a1e0 100644 --- a/src/translation-helpers.ts +++ b/src/translation-helpers.ts @@ -17,35 +17,35 @@ export function mapErrorCodeToMessage(args: {lang?: string; fallbackLang?: strin let message = ''; switch (code) { - case 'EMPTY_KEYSET': { + case ErrorCode.EmptyKeyset: { message = `Keyset is empty.`; break; } - case 'EMPTY_LANGUAGE_DATA': { + case ErrorCode.EmptyLanguageData: { message = 'Language data is empty.'; break; } - case 'KEYSET_NOT_FOUND': { + case ErrorCode.KeysetNotFound: { message = 'Keyset not found.'; break; } - case 'MISSING_KEY': { + case ErrorCode.MissingKey: { message = 'Missing key.'; break; } - case 'MISSING_KEY_FOR_0': { + case ErrorCode.MissingKeyFor0: { message = 'Missing key for 0'; break; } - case 'MISSING_KEY_PARAMS_COUNT': { + case ErrorCode.MissingKeyParamsCount: { message = 'Missing params.count for key.'; break; } - case 'MISSING_KEY_PLURALS': { + case ErrorCode.MissingKeyPlurals: { message = 'Missing required plurals'; break; } - case 'NO_LANGUAGE_DATA': { + case ErrorCode.NoLanguageData: { message = `Language "${lang}" is not defined, make sure you call setLang for the same language you called registerKeysets for!`; } } From cd62e12069bbb7a70e9d9b980be68cb30956e450 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Fri, 26 Jan 2024 14:38:47 +0300 Subject: [PATCH 07/12] fix: review fixes --- src/index.ts | 22 ++++++++++++++-------- src/replace-params.ts | 2 +- src/translation-helpers.ts | 4 ++-- tsconfig.json | 3 ++- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index bb0f827..b38d141 100644 --- a/src/index.ts +++ b/src/index.ts @@ -132,7 +132,7 @@ 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 { @@ -150,12 +150,12 @@ export class I18N { const message = mapErrorCodeToMessage({ code: details.code, lang: this.lang, - fallbackLang: this.fallbackLang, + fallbackLang: this.fallbackLang === this.lang ? undefined : this.fallbackLang, }); this.warn(message, details.keysetName, details.key); } - if (this.fallbackLang) { + if (text === undefined && this.fallbackLang && this.fallbackLang !== this.lang) { ({text, fallbackText, details} = this.getTranslationData({ keysetName, key, @@ -166,14 +166,14 @@ export class I18N { if (details && details.code !== ErrorCode.NoLanguageData) { const message = mapErrorCodeToMessage({ code: details.code, - lang: this.lang, - fallbackLang: this.fallbackLang, + lang: this.fallbackLang, }); this.warn(message, details.keysetName, details.key); } } - if (!text && !fallbackText) { + const result = text ?? fallbackText; + if (result === undefined) { const message = mapErrorCodeToMessage({ code: details?.code, lang: this.lang, @@ -182,7 +182,7 @@ export class I18N { throw new Error(message); } - return (text || fallbackText) as string; + return result; } keyset(keysetName: string) { @@ -265,7 +265,7 @@ export class I18N { const keyValue = keyset && keyset[key]; const result: TranslationData = {}; - if (typeof keyValue === 'undefined') { + if (keyValue === undefined) { return { fallbackText: key, details: {code: ErrorCode.MissingKey, keysetName, key}, @@ -291,6 +291,12 @@ export class I18N { const pluralizer = this.getLanguagePluralizer(lang); result.text = keyValue[pluralizer(count, PluralForm)] || keyValue[PluralForm.Many]; + if (result.text === undefined) { + return { + fallbackText: key, + details: {code: ErrorCode.MissingKeyPlurals, keysetName, key}, + }; + } if (keyValue[PluralForm.None] === undefined) { result.details = { 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 index 0d0a1e0..239ff33 100644 --- a/src/translation-helpers.ts +++ b/src/translation-helpers.ts @@ -42,7 +42,7 @@ export function mapErrorCodeToMessage(args: {lang?: string; fallbackLang?: strin break; } case ErrorCode.MissingKeyPlurals: { - message = 'Missing required plurals'; + message = 'Missing required plurals.'; break; } case ErrorCode.NoLanguageData: { @@ -51,7 +51,7 @@ export function mapErrorCodeToMessage(args: {lang?: string; fallbackLang?: strin } if (fallbackLang) { - message += ` Trying to use default language "${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"] From d27bad6b9b581a6e2f50a72a951e3a68ce3beea8 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Fri, 26 Jan 2024 14:41:49 +0300 Subject: [PATCH 08/12] refactor: mark code property in mapErrorCodeToMessage as required --- src/index.ts | 2 +- src/translation-helpers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index b38d141..7a8384e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -175,7 +175,7 @@ export class I18N { const result = text ?? fallbackText; if (result === undefined) { const message = mapErrorCodeToMessage({ - code: details?.code, + code: ErrorCode.NoLanguageData, lang: this.lang, fallbackLang: this.fallbackLang, }); diff --git a/src/translation-helpers.ts b/src/translation-helpers.ts index 239ff33..c4cb53b 100644 --- a/src/translation-helpers.ts +++ b/src/translation-helpers.ts @@ -12,7 +12,7 @@ export const ErrorCode = { const codeValues = Object.values(ErrorCode); export type ErrorCodeType = (typeof codeValues)[number]; -export function mapErrorCodeToMessage(args: {lang?: string; fallbackLang?: string; code?: ErrorCodeType}) { +export function mapErrorCodeToMessage(args: {code: ErrorCodeType; lang?: string; fallbackLang?: string}) { const {code, fallbackLang, lang} = args; let message = ''; From 10dec1fa1fb084bfc2b2c9266815e34e8a2e1fdb Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Fri, 26 Jan 2024 18:03:47 +0300 Subject: [PATCH 09/12] fix: do not throw an error in case of NoLanguageData --- src/index.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7a8384e..fa8c2ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -146,7 +146,7 @@ export class I18N { ({text, fallbackText, details} = this.getTranslationData({keysetName, key, params, lang: this.lang})); - if (details && details.code !== ErrorCode.NoLanguageData) { + if (details) { const message = mapErrorCodeToMessage({ code: details.code, lang: this.lang, @@ -163,7 +163,7 @@ export class I18N { lang: this.fallbackLang, })); - if (details && details.code !== ErrorCode.NoLanguageData) { + if (details) { const message = mapErrorCodeToMessage({ code: details.code, lang: this.fallbackLang, @@ -172,17 +172,9 @@ export class I18N { } } - const result = text ?? fallbackText; - if (result === undefined) { - const message = mapErrorCodeToMessage({ - code: ErrorCode.NoLanguageData, - lang: this.lang, - fallbackLang: this.fallbackLang, - }); - throw new Error(message); - } - - return result; + // this.getTranslationData method necessarily returns either a `text` or a `fallbackText` value. + // Both of these fields are described in the `TranslationData` type as optional so as not to complicate the code. + return (text ?? fallbackText) as string; } keyset(keysetName: string) { @@ -236,7 +228,10 @@ export class I18N { const languageData = this.getLanguageData(lang); if (typeof languageData === 'undefined') { - return {details: {code: ErrorCode.NoLanguageData}}; + return { + fallbackText: key, + details: {code: ErrorCode.NoLanguageData} + }; } if (Object.keys(languageData).length === 0) { @@ -291,6 +286,7 @@ export class I18N { const pluralizer = this.getLanguagePluralizer(lang); result.text = keyValue[pluralizer(count, PluralForm)] || keyValue[PluralForm.Many]; + if (result.text === undefined) { return { fallbackText: key, From 3bf83ad09871f5944dc22e99b473306c06b2d2a3 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Fri, 26 Jan 2024 18:39:44 +0300 Subject: [PATCH 10/12] fix: remove fallbackLamg from getTranslationData method --- src/index.ts | 56 +++++++++++----------------------------------------- 1 file changed, 12 insertions(+), 44 deletions(-) diff --git a/src/index.ts b/src/index.ts index fa8c2ba..05f3230 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,7 +73,6 @@ type I18NOptions = { type TranslationData = { text?: string; - fallbackText?: string; details?: { code: ErrorCodeType; keysetName?: string; @@ -141,10 +140,9 @@ export class I18N { } let text: string | undefined; - let fallbackText: string | undefined; let details: TranslationData['details']; - ({text, fallbackText, details} = this.getTranslationData({keysetName, key, params, lang: this.lang})); + ({text, details} = this.getTranslationData({keysetName, key, params, lang: this.lang})); if (details) { const message = mapErrorCodeToMessage({ @@ -156,7 +154,7 @@ export class I18N { } if (text === undefined && this.fallbackLang && this.fallbackLang !== this.lang) { - ({text, fallbackText, details} = this.getTranslationData({ + ({text, details} = this.getTranslationData({ keysetName, key, params, @@ -172,9 +170,7 @@ export class I18N { } } - // this.getTranslationData method necessarily returns either a `text` or a `fallbackText` value. - // Both of these fields are described in the `TranslationData` type as optional so as not to complicate the code. - return (text ?? fallbackText) as string; + return text ?? key; } keyset(keysetName: string) { @@ -228,78 +224,50 @@ export class I18N { const languageData = this.getLanguageData(lang); if (typeof languageData === 'undefined') { - return { - fallbackText: key, - details: {code: ErrorCode.NoLanguageData} - }; + return {details: {code: ErrorCode.NoLanguageData}}; } if (Object.keys(languageData).length === 0) { - return { - fallbackText: key, - details: {code: ErrorCode.EmptyLanguageData}, - }; + return {details: {code: ErrorCode.EmptyLanguageData}}; } const keyset = languageData[keysetName]; if (!keyset) { - return { - fallbackText: key, - details: {code: ErrorCode.KeysetNotFound, keysetName}, - }; + return {details: {code: ErrorCode.KeysetNotFound, keysetName}}; } if (Object.keys(keyset).length === 0) { - return { - fallbackText: key, - details: {code: ErrorCode.EmptyKeyset, keysetName}, - }; + return {details: {code: ErrorCode.EmptyKeyset, keysetName}}; } const keyValue = keyset && keyset[key]; const result: TranslationData = {}; if (keyValue === undefined) { - return { - fallbackText: key, - details: {code: ErrorCode.MissingKey, keysetName, key}, - }; + return {details: {code: ErrorCode.MissingKey, keysetName, key}}; } if (Array.isArray(keyValue)) { if (keyValue.length < 3) { - return { - fallbackText: key, - details: {code: ErrorCode.MissingKeyPlurals, keysetName, key}, - }; + return {details: {code: ErrorCode.MissingKeyPlurals, keysetName, key}}; } const count = Number(params?.count); if (Number.isNaN(count)) { - return { - fallbackText: key, - details: {code: ErrorCode.MissingKeyParamsCount, keysetName, key}, - }; + 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 { - fallbackText: key, - details: {code: ErrorCode.MissingKeyPlurals, keysetName, key}, - }; + return {details: {code: ErrorCode.MissingKeyPlurals, keysetName, key}}; } if (keyValue[PluralForm.None] === undefined) { - result.details = { - code: ErrorCode.MissingKeyFor0, - keysetName, - key, - }; + result.details = {code: ErrorCode.MissingKeyFor0, keysetName, key}; } } else { result.text = keyValue; From 407d34495e63a9b74437ebed4ab43fd1e8aecc8b Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Fri, 26 Jan 2024 18:43:33 +0300 Subject: [PATCH 11/12] fix: remove none actual test --- src/index.spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 1df8307..a3e0a1a 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -310,11 +310,6 @@ describe('i18n', () => { }); describe('constructor options', () => { - it('should throw an error in case of languages absence', () => { - i18n = new I18N(); - expect(() => i18n.i18n('notification', 'title')).toThrow(); - }); - describe('lang', () => { it('should return translation [set lang via options.lang]', () => { i18n = new I18N({lang: 'en'}); From 55a48905b686f20b6125db5e4d41f36d7cad5e36 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Fri, 26 Jan 2024 19:17:14 +0300 Subject: [PATCH 12/12] fix: i18n method fixes --- src/index.ts | 24 ++++++++++++++---------- src/translation-helpers.ts | 20 ++++++++++---------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/index.ts b/src/index.ts index 05f3230..89e8e45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -136,21 +136,25 @@ export class I18N { i18n(keysetName: string, key: string, params?: Params): string { if (!this.lang && !this.fallbackLang) { - throw new Error('Language not specified. You should set at least one of these: "lang", "fallbackLang"'); + throw new Error('Language is not specified. You should set at least one of these: "lang", "fallbackLang"'); } let text: string | undefined; let details: TranslationData['details']; - ({text, details} = this.getTranslationData({keysetName, key, params, lang: this.lang})); + if (this.lang) { + ({text, details} = this.getTranslationData({keysetName, key, params, lang: this.lang})); - 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); + 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.'); } if (text === undefined && this.fallbackLang && this.fallbackLang !== this.lang) { @@ -217,7 +221,7 @@ export class I18N { private getTranslationData(args: { keysetName: string; key: string; - lang?: string; + lang: string; params?: Params; }): TranslationData { const {lang, key, keysetName, params} = args; diff --git a/src/translation-helpers.ts b/src/translation-helpers.ts index c4cb53b..8dc5a6a 100644 --- a/src/translation-helpers.ts +++ b/src/translation-helpers.ts @@ -12,37 +12,37 @@ export const ErrorCode = { const codeValues = Object.values(ErrorCode); export type ErrorCodeType = (typeof codeValues)[number]; -export function mapErrorCodeToMessage(args: {code: ErrorCodeType; lang?: string; fallbackLang?: string}) { +export function mapErrorCodeToMessage(args: {code: ErrorCodeType; lang: string; fallbackLang?: string}) { const {code, fallbackLang, lang} = args; - let message = ''; + let message = `Using language ${lang}. `; switch (code) { case ErrorCode.EmptyKeyset: { - message = `Keyset is empty.`; + message += `Keyset is empty.`; break; } case ErrorCode.EmptyLanguageData: { - message = 'Language data is empty.'; + message += 'Language data is empty.'; break; } case ErrorCode.KeysetNotFound: { - message = 'Keyset not found.'; + message += 'Keyset not found.'; break; } case ErrorCode.MissingKey: { - message = 'Missing key.'; + message += 'Missing key.'; break; } case ErrorCode.MissingKeyFor0: { - message = 'Missing key for 0'; - break; + message += 'Missing key for 0'; + return message } case ErrorCode.MissingKeyParamsCount: { - message = 'Missing params.count for key.'; + message += 'Missing params.count for key.'; break; } case ErrorCode.MissingKeyPlurals: { - message = 'Missing required plurals.'; + message += 'Missing required plurals.'; break; } case ErrorCode.NoLanguageData: {