diff --git a/.eslintrc.js b/.eslintrc.js index 933bc671..78637d1e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,10 +5,7 @@ module.exports = { tsconfigRootDir: __dirname, project: ['./tsconfig.eslint.json'], }, - plugins: [ - 'import', - 'tsdoc', - ], + plugins: ['import', 'tsdoc'], extends: [ '@concepta/eslint-config/nest', 'plugin:jsdoc/recommended-typescript', @@ -21,6 +18,7 @@ module.exports = { '**/tsconfig.json', '**/tsconfig.eslint.json', '**/commitlint.config.js', + 'packages/i18n/src/__fixtures__/locales/**/*.json', ], settings: { jsdoc: { diff --git a/packages/i18n/README.md b/packages/i18n/README.md new file mode 100644 index 00000000..c4d4117b --- /dev/null +++ b/packages/i18n/README.md @@ -0,0 +1,294 @@ + +# i18n Module for React + +An advanced i18n (internationalization) utility for managing multiple +languages in React applications with seamless integration and modularity. + +## Project + +[![NPM Latest](https://img.shields.io/npm/v/@concepta/i18n)](https://www.npmjs.com/package/@concepta/i18n) +[![NPM Downloads](https://img.shields.io/npm/dw/@concepta/i18n)](https://www.npmjs.com/package/@concepta/i18n) +[![GH Last Commit](https://img.shields.io/github/last-commit/@concepta/i18n?logo=github)](https://github.com/@concepta/i18n) +[![GH Contrib](https://img.shields.io/github/contributors/@concepta/i18n?logo=github)](https://github.com/@concepta/i18n/graphs/contributors) + +# Table of Contents + +1. [Tutorials](#tutorials) + - [Getting Started with i18n](#getting-started-with-i18n) + - [Installation](#installation) + - [Basic Setup](#basic-setup) + - [Example](#example) + - [Adding a Custom Backend Module](#adding-a-custom-backend-module) + - [Changing Language Dynamically](#changing-language-dynamically) + - [Adding Translations](#adding-translations) +2. [How-To Guides](#how-to-guides) + - [Creating a Custom Translation Module](#creating-a-custom-translation-module) + - [Handling Missing Translations](#handling-missing-translations) +3. [Reference](#reference) +4. [Explanation](#explanation) + - [What is i18n?](#what-is-i18n) + - [How i18n Module Works](#how-i18n-module-works) + - [Benefits of Using i18n](#benefits-of-using-i18n) + - [Common i18n Pitfalls](#common-i18n-pitfalls) + +# Tutorials + +## Getting Started with i18n + +### Installation + +Install the `@concepta/i18n` package using yarn or npm: + +yarn add @concepta/i18n + +npm install @concepta/i18n + +## Basic Setup + +To set up the i18n module, you need to initialize it with your desired +settings and add your translations. For more detailed settings documentation, +check [i18next](https://www.i18next.com/). + +```ts +// i18nSetup.js +import { I18n, t } from '@concepta/i18n'; + +export function initI18n() { + I18n.init({ + options: { + fallbackLng: 'en', + resources: { + en: { + 'translation': { + hello: "Hi" + } + }, + pt: { + 'translation': { + hello: "Olá" + } + } + } + } + }); + // initialized and ready to go! + // i18n is already initialized, because the translation resources were + // passed via init function +} + +export { t }; +``` + +```ts +import { initI18n, t } from './i18nSetup'; + +// Initialize i18n when the application loads +initI18n(); + +// result: Hi +console.log(t({ key: 'hello' })); + +// result: Olá +console.log(t({ + key: 'hello', + language: 'pt' +})); +``` + +## Example + +These are basic examples to help you get started with the i18n module. + +### Adding a Custom Backend Module + +To add a custom backend module for fetching translations: + +```json +//./locales/en/translation.json +{ + "hello": "Hi" +} +``` + +```json +//./locales/pt/translation.json +{ + "hello": "Olá" +} +``` + +```ts +import I18NexFsBackend, { FsBackendOptions } from 'i18next-fs-backend'; +import { I18n, t } from '@concepta/i18n'; + +//... +I18n.init({ + modules: [I18NexFsBackend], + options: { + initImmediate: false, + ns: ['translation'], + backend: { + loadPath: join(__dirname, './locales/{{lng}}/{{ns}}.json') + }, + supportedLngs: ['en', 'pt'], + preload: ['en', 'pt'], + fallbackLng: 'en' + } +}); +//... +``` + +# How-To Guides + +## Changing Language Dynamically + +You can change the language at runtime: + +```ts +//... +I18n.init({ + options: { + resources: { + en: { + 'translation': { + hello: "Hi" + } + }, + pt: { + 'translation': { + hello: "Olá" + } + } + } + } +}); +// result: Hi +console.log(t({ key: 'hello' })); + +I18n.changeLanguage('pt'); + +// result: Olá +console.log(t({ key: 'hello' })); +``` + +## Adding Translations + +To add translations dynamically, you can initialize the I18n instance first +and addtranslation resources at a later time. This approach is useful in +scenarios where translations are fetched from an external source or need to be +updated without reinitializing the entire i18n setup. + +This method is particularly beneficial in the following situations: + +- **Fetching Translations from an API**: When translations are retrieved from a + remote server, you can initialize i18n once and update translations as they + become available. +- **Modular Applications**: In large applications with multiple modules, + translations can be added as each module is loaded, reducing the initial load + time. +- **Real-time Updates**: If your application supports real-time updates, you + can dynamically add new translations without disrupting the user experience. + +```ts +//... +I18n.init(); + +const locales = [ + { + namespace: 'common', + language: 'en', + resource: { welcome: 'Welcome' }, + }, + { + namespace: 'common', + language: 'fr', + resource: { welcome: 'Bienvenue' }, + }, +]; + +I18n.addTranslations(locales); + +// Welcome +console.log(t({ + namespace: 'common', + key: 'welcome', + language: 'en' +})); + +// Bienvenue +console.log(t({ + namespace: 'common', + key: 'welcome', + language: 'fr' +})); +``` + +## Creating a Custom Translation Module + +You can create custom translation modules to extend the i18n functionality. +Here’s how: + +### **Define the Module**: Add modules on initialization + +```ts +import { MyCustomModule } from './my-custom-module'; +import { I18n } from '@concepta/i18n'; + +I18n.init({ + modules: [MyCustomModule], + options: { + //... + } +}); +``` + +## Handling Missing Translations + +To handle missing translations gracefully: + +```ts +import { I18n, t } from '@concepta/i18n'; +I18n.translate({ + key: 'missing_key', + namespace: 'common', + defaultMessage: 'This is the default text', +}); + +// or +t({ + key: 'missing_key', + namespace: 'common', + defaultMessage: 'This is the default text', +}); +``` + +# Reference + +Please check (API Reference)[] for more information. + +# Explanation + +## What is i18n? + +i18n, short for internationalization, refers to the process of designing +software applications that can be adapted to various languages and regions +without engineering changes. + +## How i18n Module Works + +The i18n module in this package provides a wrapper around i18next, allowing +seamless integration of translation modules and dynamic language switching. + +### Benefits of Using i18n + +- **Flexibility**: Supports multiple languages and dynamic translation updates. +- **Modularity**: Easily extendable with custom modules for translation +management. +- **Ease of Use**: Simple API to manage translations and switch languages. + +### Common i18n Pitfalls + +- **Missing Translations**: Ensure all necessary translations are provided for +each language. +- **Performance Overheads**: Avoid loading unnecessary translation files by +using modular imports. diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 00000000..b7eb243e --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,21 @@ +{ + "name": "@concepta/i18n", + "version": "6.0.0-alpha.3", + "description": "Rockets i18n", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + ], + "dependencies": { + "i18next": "^23.12.2" + }, + "devDependencies": { + "i18next-fs-backend": "^2.3.1", + "i18next-http-middleware": "^3.6.0" + } +} diff --git a/packages/i18n/src/__fixtures__/locales/en/my-namespace.json b/packages/i18n/src/__fixtures__/locales/en/my-namespace.json new file mode 100644 index 00000000..1d901363 --- /dev/null +++ b/packages/i18n/src/__fixtures__/locales/en/my-namespace.json @@ -0,0 +1,3 @@ +{ + "hello": "Hi" +} diff --git a/packages/i18n/src/__fixtures__/locales/en/translation.json b/packages/i18n/src/__fixtures__/locales/en/translation.json new file mode 100644 index 00000000..8d8f0268 --- /dev/null +++ b/packages/i18n/src/__fixtures__/locales/en/translation.json @@ -0,0 +1,3 @@ +{ + "hello": "Hi from translation" +} \ No newline at end of file diff --git a/packages/i18n/src/__fixtures__/locales/pt/my-namespace.json b/packages/i18n/src/__fixtures__/locales/pt/my-namespace.json new file mode 100644 index 00000000..98655875 --- /dev/null +++ b/packages/i18n/src/__fixtures__/locales/pt/my-namespace.json @@ -0,0 +1,3 @@ +{ + "hello": "Olá" +} diff --git a/packages/i18n/src/__fixtures__/locales/pt/translation.json b/packages/i18n/src/__fixtures__/locales/pt/translation.json new file mode 100644 index 00000000..ee871bd1 --- /dev/null +++ b/packages/i18n/src/__fixtures__/locales/pt/translation.json @@ -0,0 +1,3 @@ +{ + "hello": "Olá do translation" +} diff --git a/packages/i18n/src/__fixtures__/moduleA/index.ts b/packages/i18n/src/__fixtures__/moduleA/index.ts new file mode 100644 index 00000000..0cdc765c --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleA/index.ts @@ -0,0 +1,12 @@ +import { I18n } from '../../i18n'; +import LOCALES from './locales'; + +let loaded = false; +export const initModuleATranslation = () => { + if (!loaded) { + I18n.addTranslations(LOCALES); + loaded = true; + } +}; + +export * from './moduleA.module.fixture'; diff --git a/packages/i18n/src/__fixtures__/moduleA/locales/en/ModuleAFixture.ts b/packages/i18n/src/__fixtures__/moduleA/locales/en/ModuleAFixture.ts new file mode 100644 index 00000000..9c4c6d50 --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleA/locales/en/ModuleAFixture.ts @@ -0,0 +1,4 @@ +const enUS = { + hello: 'Hi from module A', +}; +export default enUS; diff --git a/packages/i18n/src/__fixtures__/moduleA/locales/index.ts b/packages/i18n/src/__fixtures__/moduleA/locales/index.ts new file mode 100644 index 00000000..04a16f00 --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleA/locales/index.ts @@ -0,0 +1,19 @@ +import { I18nLocalesInterface } from '../../../interfaces/i18n-locales.interface'; +import { ModuleAFixture } from '../moduleA.module.fixture'; +import enUS from './en/ModuleAFixture'; +import ptBR from './pt/ModuleAFixture'; + +const LOCALES: I18nLocalesInterface[] = [ + { + namespace: ModuleAFixture.name, + language: 'en', + resource: enUS, + }, + { + namespace: ModuleAFixture.name, + language: 'pt', + resource: ptBR, + }, +]; + +export default LOCALES; diff --git a/packages/i18n/src/__fixtures__/moduleA/locales/pt/ModuleAFixture.ts b/packages/i18n/src/__fixtures__/moduleA/locales/pt/ModuleAFixture.ts new file mode 100644 index 00000000..b55357f7 --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleA/locales/pt/ModuleAFixture.ts @@ -0,0 +1,4 @@ +const ptBR = { + hello: 'Olá do modulo A', +}; +export default ptBR; diff --git a/packages/i18n/src/__fixtures__/moduleA/moduleA.module.fixture.ts b/packages/i18n/src/__fixtures__/moduleA/moduleA.module.fixture.ts new file mode 100644 index 00000000..040e4dd8 --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleA/moduleA.module.fixture.ts @@ -0,0 +1,44 @@ +import { initModuleATranslation } from './'; +import { I18n } from '../../i18n'; + +import { ModuleBFixture, initModuleBTranslation } from '../moduleB'; +import { ModuleCFixture, initModuleCTranslation } from '../moduleC'; + +// Non-NestJS module +export class ModuleAFixture { + constructor( + private moduleB: ModuleBFixture, + private moduleC: ModuleCFixture, + ) { + initModuleATranslation(); + initModuleBTranslation(); + initModuleCTranslation(); + } + translationModuleA() { + return I18n.translate({ + namespace: ModuleAFixture.name, + key: 'hello', + language: 'en', + }); + } + + translationModuleB() { + return I18n.translate({ + namespace: ModuleBFixture.name, + key: 'hello', + language: 'en', + }); + } + + translationCFromModuleB() { + return this.moduleB.translationC(); + } + + translationC(key: string = 'hello', language: string = 'en') { + return I18n.translate({ + namespace: ModuleCFixture.name, + key: key, + language: language, + }); + } +} diff --git a/packages/i18n/src/__fixtures__/moduleB/index.ts b/packages/i18n/src/__fixtures__/moduleB/index.ts new file mode 100644 index 00000000..2b054598 --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleB/index.ts @@ -0,0 +1,12 @@ +import { I18n } from '../../i18n'; +import LOCALES from './locales'; + +let loaded = false; +export const initModuleBTranslation = () => { + if (!loaded) { + I18n.addTranslations(LOCALES); + loaded = true; + } +}; + +export * from './moduleB.module.fixture'; diff --git a/packages/i18n/src/__fixtures__/moduleB/locales/en/ModuleBFixture.ts b/packages/i18n/src/__fixtures__/moduleB/locales/en/ModuleBFixture.ts new file mode 100644 index 00000000..4c5481db --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleB/locales/en/ModuleBFixture.ts @@ -0,0 +1,4 @@ +const enUS = { + hello: 'Hi from module B', +}; +export default enUS; diff --git a/packages/i18n/src/__fixtures__/moduleB/locales/index.ts b/packages/i18n/src/__fixtures__/moduleB/locales/index.ts new file mode 100644 index 00000000..6d1915bd --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleB/locales/index.ts @@ -0,0 +1,19 @@ +import { I18nLocalesInterface } from '../../../interfaces/i18n-locales.interface'; +import { ModuleBFixture } from '../moduleB.module.fixture'; +import enUS from './en/ModuleBFixture'; +import ptBR from './pt/ModuleBFixture'; + +const LOCALES: I18nLocalesInterface[] = [ + { + namespace: ModuleBFixture.name, + language: 'en', + resource: enUS, + }, + { + namespace: ModuleBFixture.name, + language: 'pt', + resource: ptBR, + }, +]; + +export default LOCALES; diff --git a/packages/i18n/src/__fixtures__/moduleB/locales/pt/ModuleBFixture.ts b/packages/i18n/src/__fixtures__/moduleB/locales/pt/ModuleBFixture.ts new file mode 100644 index 00000000..3065099f --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleB/locales/pt/ModuleBFixture.ts @@ -0,0 +1,4 @@ +const ptBR = { + hello: 'Olá do modulo B', +}; +export default ptBR; diff --git a/packages/i18n/src/__fixtures__/moduleB/moduleB.module.fixture.ts b/packages/i18n/src/__fixtures__/moduleB/moduleB.module.fixture.ts new file mode 100644 index 00000000..899b21d2 --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleB/moduleB.module.fixture.ts @@ -0,0 +1,26 @@ +import { initModuleBTranslation } from './'; +import { I18n } from '../../i18n'; +import { ModuleCFixture, initModuleCTranslation } from '../moduleC'; + +// Non-NestJS module +export class ModuleBFixture { + constructor(private moduleC: ModuleCFixture) { + initModuleBTranslation(); + initModuleCTranslation(); + } + translationB() { + return I18n.translate({ + namespace: ModuleBFixture.name, + key: 'hello', + language: 'en', + }); + } + + translationC() { + return I18n.translate({ + namespace: ModuleCFixture.name, + key: 'hello', + language: 'en', + }); + } +} diff --git a/packages/i18n/src/__fixtures__/moduleC/index.ts b/packages/i18n/src/__fixtures__/moduleC/index.ts new file mode 100644 index 00000000..21414b0b --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleC/index.ts @@ -0,0 +1,12 @@ +import { I18n } from '../../i18n'; +import LOCALES from './locales'; + +let loaded = false; +export const initModuleCTranslation = () => { + if (!loaded) { + I18n.addTranslations(LOCALES); + loaded = true; + } +}; + +export * from './moduleC.module.fixture'; diff --git a/packages/i18n/src/__fixtures__/moduleC/locales/en/ModuleCFixture.ts b/packages/i18n/src/__fixtures__/moduleC/locales/en/ModuleCFixture.ts new file mode 100644 index 00000000..f2ffacf9 --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleC/locales/en/ModuleCFixture.ts @@ -0,0 +1,5 @@ +const enUS = { + hello: 'Hi from module C', + bye: 'Bye from module C', +}; +export default enUS; diff --git a/packages/i18n/src/__fixtures__/moduleC/locales/index.ts b/packages/i18n/src/__fixtures__/moduleC/locales/index.ts new file mode 100644 index 00000000..814384aa --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleC/locales/index.ts @@ -0,0 +1,19 @@ +import { I18nLocalesInterface } from '../../../interfaces/i18n-locales.interface'; +import { ModuleCFixture } from '../moduleC.module.fixture'; +import enUS from './en/ModuleCFixture'; +import ptCR from './pt/ModuleCFixture'; + +const LOCALES: I18nLocalesInterface[] = [ + { + namespace: ModuleCFixture.name, + language: 'en', + resource: enUS, + }, + { + namespace: ModuleCFixture.name, + language: 'pt', + resource: ptCR, + }, +]; + +export default LOCALES; diff --git a/packages/i18n/src/__fixtures__/moduleC/locales/pt/ModuleCFixture.ts b/packages/i18n/src/__fixtures__/moduleC/locales/pt/ModuleCFixture.ts new file mode 100644 index 00000000..4bb2cc32 --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleC/locales/pt/ModuleCFixture.ts @@ -0,0 +1,4 @@ +const ptCR = { + hello: 'Olá do modulo C', +}; +export default ptCR; diff --git a/packages/i18n/src/__fixtures__/moduleC/moduleC.module.fixture.ts b/packages/i18n/src/__fixtures__/moduleC/moduleC.module.fixture.ts new file mode 100644 index 00000000..6207d6f4 --- /dev/null +++ b/packages/i18n/src/__fixtures__/moduleC/moduleC.module.fixture.ts @@ -0,0 +1,16 @@ +import { initModuleCTranslation } from './'; +import { I18n } from '../../i18n'; + +// Non-NestJS module +export class ModuleCFixture { + constructor() { + initModuleCTranslation(); + } + translationC() { + return I18n.translate({ + namespace: ModuleCFixture.name, + key: 'hello', + language: 'en', + }); + } +} diff --git a/packages/i18n/src/i18n.e2e-spec.ts b/packages/i18n/src/i18n.e2e-spec.ts new file mode 100644 index 00000000..7b8ac582 --- /dev/null +++ b/packages/i18n/src/i18n.e2e-spec.ts @@ -0,0 +1,75 @@ +import { ModuleAFixture } from './__fixtures__/moduleA'; +import { ModuleBFixture } from './__fixtures__/moduleB'; +import { ModuleCFixture } from './__fixtures__/moduleC'; +import { I18n } from './i18n'; +import { I18nLocalesInterface } from './interfaces/i18n-locales.interface'; + +describe('i18n module', () => { + describe('ModuleAFixture E2E', () => { + let moduleA: ModuleAFixture; + let moduleB: ModuleBFixture; + let moduleC: ModuleCFixture; + + beforeAll(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + // Initialize translations + I18n.init({ + options: { + initImmediate: false, + }, + }); + // Create instances of the modules + moduleC = new ModuleCFixture(); + moduleB = new ModuleBFixture(new ModuleCFixture()); + moduleA = new ModuleAFixture(moduleB, moduleC); + }); + + it('should translate for module A', () => { + const result = moduleA.translationModuleA(); + expect(result).toBe('Hi from module A'); + }); + + it('should translate for module B', () => { + const result = moduleA.translationModuleB(); + expect(result).toBe('Hi from module B'); + }); + + it('should translate C from module B', () => { + const result = moduleA.translationCFromModuleB(); + expect(result).toBe('Hi from module C'); + }); + + it('should translate for module C', () => { + const result = moduleA.translationC(); + expect(result).toBe('Hi from module C'); + }); + + it('should translate for module C', () => { + const LOCALES: I18nLocalesInterface[] = [ + { + namespace: ModuleCFixture.name, + resource: { + hello: 'overwrite english', + hi: 'new hi', + }, + deep: false, + overwrite: false, + }, + ]; + I18n.addTranslations(LOCALES); + + const result = moduleA.translationC('hello', 'pt'); + expect(result).toBe('Olá do modulo C'); + + const result1 = moduleA.translationC('hello', 'en'); + expect(result1).toBe('overwrite english'); + + const resultExtend = moduleA.translationC('hi', 'en'); + expect(resultExtend).toBe('new hi'); + + const resultBye = moduleA.translationC('bye', 'en'); + expect(resultBye).toBe('Bye from module C'); + }); + }); +}); diff --git a/packages/i18n/src/i18n.spec.ts b/packages/i18n/src/i18n.spec.ts new file mode 100644 index 00000000..88bf6e59 --- /dev/null +++ b/packages/i18n/src/i18n.spec.ts @@ -0,0 +1,258 @@ +import I18NexFsBackend, { FsBackendOptions } from 'i18next-fs-backend'; +import { join } from 'path'; +import { ModuleAFixture } from './__fixtures__/moduleA'; +import { ModuleBFixture } from './__fixtures__/moduleB'; +import { ModuleCFixture } from './__fixtures__/moduleC'; +import { I18n } from './i18n'; +import { I18nLocalesInterface } from './interfaces/i18n-locales.interface'; +import { t } from './'; + +describe('i18n module', () => { + beforeAll(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('should not initialize ', () => { + const LOCALES: I18nLocalesInterface[] = [ + { + namespace: 'my-namespace', + language: 'en', + resource: require(__dirname + + '/__fixtures__/locales/en/my-namespace.json'), + }, + ]; + + const consoleErrorSpy = jest.spyOn(console, 'error').mockReturnValueOnce(); + + I18n.addTranslations(LOCALES); + expect(consoleErrorSpy).toBeCalledWith( + 'i18next was not isInitialized, please call init', + ); + }); + + describe('i18n configuration', () => { + afterEach(() => { + I18n.reset(); + }); + + it('should initialize translations from constant', async () => { + const enUS = { + hello: 'Hi', + }; + + const ptBR = { + hello: 'Olá', + }; + + // TODO: maybe the interface can be improved + const LOCALES: I18nLocalesInterface[] = [ + { + namespace: 'my-namespace', + language: 'en', + resource: enUS, + }, + { + namespace: 'my-namespace', + language: 'pt', + resource: ptBR, + }, + ]; + I18n.init({ + options: { + initImmediate: false, + }, + }); + I18n.addTranslations(LOCALES); + + const enTranslation = I18n.translate({ + namespace: 'my-namespace', + key: 'hello', + language: 'en', + }); + const ptTranslation = I18n.translate({ + namespace: 'my-namespace', + key: 'hello', + language: 'pt', + }); + + expect(enTranslation).toBe('Hi'); + expect(ptTranslation).toBe('Olá'); + }); + + it('should initialize translations from json', async () => { + const LOCALES: I18nLocalesInterface[] = [ + { + namespace: 'my-namespace', + language: 'en', + resource: require(__dirname + + '/__fixtures__/locales/en/my-namespace.json'), + }, + { + namespace: 'my-namespace', + language: 'pt', + resource: require(__dirname + + '/__fixtures__/locales/pt/my-namespace.json'), + }, + ]; + + I18n.init({ + options: { + initImmediate: false, + }, + }); + I18n.addTranslations(LOCALES); + + const enTranslation = I18n.translate({ + namespace: 'my-namespace', + key: 'hello', + language: 'en', + }); + const ptTranslation = I18n.translate({ + namespace: 'my-namespace', + key: 'hello', + language: 'pt', + }); + + expect(enTranslation).toBe('Hi'); + expect(ptTranslation).toBe('Olá'); + }); + + it('should initialize translations from constant', async () => { + I18n.init({ + options: { + resources: { + en: { + translation: { + hello: 'Hi', + }, + }, + pt: { + translation: { + hello: 'Olá', + }, + }, + }, + }, + }); + + const enTranslation = t({ key: 'hello' }); + const ptTranslation = t({ key: 'hello', language: 'pt' }); + + expect(enTranslation).toBe('Hi'); + expect(ptTranslation).toBe('Olá'); + }); + + it('should initialize translations from constant changing language', async () => { + I18n.init({ + options: { + resources: { + en: { + translation: { + hello: 'Hi', + }, + }, + pt: { + translation: { + hello: 'Olá', + }, + }, + }, + }, + }); + + const enTranslation = t({ key: 'hello' }); + expect(enTranslation).toBe('Hi'); + + I18n.changeLanguage('pt'); + + const ptTranslation = t({ key: 'hello' }); + expect(ptTranslation).toBe('Olá'); + }); + + it('should initialize translations from settings', async () => { + I18n.init({ + modules: [I18NexFsBackend], + options: { + initImmediate: false, + ns: ['translation', 'my-namespace'], + backend: { + loadPath: join( + __dirname, + './__fixtures__/locales/{{lng}}/{{ns}}.json', + ), + }, + supportedLngs: ['en', 'pt'], + preload: ['en', 'pt'], + fallbackLng: 'en', + }, + }); + + const enTranslation = t({ + namespace: 'my-namespace', + key: 'hello', + language: 'en', + }); + const ptTranslation = t({ + namespace: 'my-namespace', + key: 'hello', + language: 'pt', + }); + const defaultEnTranslation = t({ + namespace: 'translation', + key: 'hello', + language: 'en', + }); + const defaultPtTranslation = t({ + namespace: 'translation', + key: 'hello', + language: 'pt', + }); + + expect(enTranslation).toBe('Hi'); + expect(ptTranslation).toBe('Olá'); + expect(defaultEnTranslation).toBe('Hi from translation'); + expect(defaultPtTranslation).toBe('Olá do translation'); + }); + }); + + describe('ModuleAFixture E2E', () => { + let moduleA: ModuleAFixture; + let moduleB: ModuleBFixture; + let moduleC: ModuleCFixture; + + beforeAll(() => { + // Initialize translations + I18n.init({ + options: { + initImmediate: false, + }, + }); + + // Create instances of the modules + moduleB = new ModuleBFixture(new ModuleCFixture()); + moduleC = new ModuleCFixture(); + moduleA = new ModuleAFixture(moduleB, moduleC); + }); + + it('should translate for module A', () => { + const result = moduleA.translationModuleA(); + expect(result).toBe('Hi from module A'); + }); + + it('should translate for module B', () => { + const result = moduleA.translationModuleB(); + expect(result).toBe('Hi from module B'); + }); + + it('should translate C from module B', () => { + const result = moduleA.translationCFromModuleB(); + expect(result).toBe('Hi from module C'); + }); + + it('should translate for module C', () => { + const result = moduleA.translationC(); + expect(result).toBe('Hi from module C'); + }); + }); +}); diff --git a/packages/i18n/src/i18n.ts b/packages/i18n/src/i18n.ts new file mode 100644 index 00000000..922d5af8 --- /dev/null +++ b/packages/i18n/src/i18n.ts @@ -0,0 +1,294 @@ +// TODO: should we create a wrapper for this? +import i18next, { Callback } from 'i18next'; +import { + I18nCallback, + I18nObjectModule, + I18nextModuleType, +} from './interfaces/i18n.types'; +import { I18nSettings } from './interfaces/i18n-settings.interface'; +import { TranslateParams } from './interfaces/i18n-translate-params.interface'; +import { I18nLocalesInterface } from './interfaces/i18n-locales.interface'; +import { I18nOptions } from './interfaces/i18n-options.interface'; +import { I18nTranslationKeys } from './interfaces/i18n-translation-keys.interface copy'; + +export class I18n { + static instance = i18next.createInstance(); + + /** + * Adds a module to the i18next instance. + * + * @param module - The module to add. + * @returns The i18next instance. + * + * @example + * ```typescript + * // Example of adding a custom backend module + * import Backend from 'i18next-custom-backend'; + * + * I18n.addModule(Backend); + * ``` + */ + static addModule = ( + module: I18nextModuleType, + ) => { + return I18n.instance.use(module); + }; + + /** + * Adds multiple modules to the i18next instance. + * + * @param modules - An array of modules to add. + * + * @example + * ```ts + * // Example of adding multiple custom backend modules + * import Backend1 from 'i18next-custom-backend1'; + * import Backend2 from 'i18next-custom-backend2'; + * + * I18n.addModules([Backend1, Backend2]); + * ``` + */ + static addModules = ( + modules?: I18nextModuleType[], + ) => { + try { + if (modules && modules.length > 0) + modules.forEach((m) => { + I18n.addModule(m); + }); + } catch (e) { + console.error('Error adding module'); + } + }; + + /** + * Retrieves the i18next instance. + * + * @returns The i18next instance. + * + * @example + * ```ts + * const i18nInstance = I18n.getInstance(); + * ``` + */ + static getInstance = () => { + return I18n.instance; + }; + + /** + * Changes the language. The callback will be called as soon translations were + * loaded or an error occurs while loading. HINT: For easy testing - setting + * lng to 'cimode' will set t function to always return the key. + * + * @param language - The language to change to. + * @returns A promise that resolves when the language change is complete. + * + * @example + * ```ts + * await I18n.changeLanguage('fr'); + * ``` + */ + static changeLanguage = async ( + language: string, + callback?: Callback, + ): Promise => { + try { + await I18n.instance.changeLanguage(language, callback); + } catch (error) { + console.error('Error changing language:', error); + } + }; + + /** + * Resets the i18next instance by creating a new instance. + * + * @example + * ```ts + * I18n.reset(); + * ``` + */ + static reset = () => { + I18n.instance = i18next.createInstance(); + }; + + /** + * Initializes the i18next instance with the provided settings. + * + * @param settings - The settings to initialize i18next with. + * @param callback - Optional callback to be called after initialization. + * + * @example + * ```ts + * const settings = { + * modules: [Backend], + * options: { + * lng: 'en', + * resources: { + * en: { + * translation: { + * hello: "hello world" + * } + * } + * } + * } + * }; + * I18n.init(settings); + * ``` + */ + static init = (settings?: I18nSettings, callback?: I18nCallback) => { + I18n.addModules(settings?.modules); + + const i18nextOptions: I18nOptions = { + debug: true, + preload: ['en'], + fallbackLng: 'en', + ns: ['translation'], + defaultNS: 'translation', + ...settings?.options, + }; + + I18n.instance.init(i18nextOptions, callback); + }; + + /** + * Translates a given key using the provided parameters. + * + * @param params - The parameters for translation. + * @returns The translated string. + * + * @example + * ```ts + * const translation = I18n.translate({ + * key: 'hello', + * namespace: 'common', + * language: 'fr', + * defaultMessage: 'Bonjour', + * }); + * ``` + */ + static translate = (params: TranslateParams): string => { + const { + namespace = I18n.getDefaultNamespace(), + language = I18n.getCurrentLanguage(), + options = {}, + key, + defaultMessage = '', + } = params; + const doesKeyExists = I18n.keyExists({ + namespace, + key, + language, + }); + + const t = I18n.instance.getFixedT(language); + const result = doesKeyExists + ? t(`${namespace}:${key}`, options) + : defaultMessage; + return result; + }; + + /** + * Retrieves the default namespace from the i18next instance options. + * + * @returns The default namespace as a string. + * + * @example + * ```ts + * const defaultNamespace = I18n.getDefaultNamespace(); + * ``` + */ + static getDefaultNamespace = (): string => { + // TODO: need to validate what to do if defaultNS is set to false + const defaultNS = I18n.instance.options.defaultNS; + if (typeof defaultNS === 'string') { + return defaultNS; + } else if (Array.isArray(defaultNS) && defaultNS.length > 0) { + return defaultNS[0]; + } else { + return 'translation'; // Fallback to a default namespace + } + }; + + /** + * Retrieves the current language detected from the i18next instance. + * + * @returns The default language as a string. + * + * @example + * ```ts + * const defaultLanguage = I18n.getDefaultLanguage(); + * ``` + */ + static getCurrentLanguage = (): string => { + return I18n.instance.language; + }; + + /** + * Adds translations to the i18next instance. + * Adds a complete bundle. Setting deep param to true will extend existing + * translations in that file. Setting overwrite to true it will overwrite + * existing key translations in that file. + * + * @param locales - An array of locale objects containing translation data. + * + * @example + * ```ts + * const locales = [ + * { + * namespace: 'common', + * resource: { key: 'value' }, + * language: 'en', + * deep: true, + * overwrite: true + * } + * ]; + * I18n.addTranslations(locales); + * ``` + */ + static addTranslations = (locales: I18nLocalesInterface[]) => { + if (!I18n.instance.isInitialized) { + console.error('i18next was not isInitialized, please call init'); + return; + } + locales.forEach((locale) => { + const { + namespace = I18n.getDefaultNamespace(), + resource, + language = I18n.getCurrentLanguage(), + deep, + overwrite, + } = locale; + I18n.instance.addResourceBundle( + language, + namespace, + resource, + deep, + overwrite, + ); + }); + }; + + /** + * Checks if a translation key exists in the i18next instance. + * + * @param params - The parameters to check if the key exists. + * @returns A boolean indicating whether the key exists. + * + * @example + * ```ts + * const exists = I18n.keyExists({ + * key: 'hello', + * namespace: 'common', + * language: 'fr' + * }); + * ``` + */ + static keyExists = (params: I18nTranslationKeys): boolean => { + const { + namespace = I18n.getDefaultNamespace(), + key, + language = I18n.getCurrentLanguage(), + } = params; + return I18n.instance.exists(`${namespace}:${key}`, { lng: language }); + }; +} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 00000000..cca9e9ea --- /dev/null +++ b/packages/i18n/src/index.ts @@ -0,0 +1,9 @@ +import { I18n } from './i18n'; +import { TranslateParams } from './interfaces/i18n-translate-params.interface'; + +export { I18n } from './i18n'; +export { I18nLocalesInterface } from './interfaces/i18n-locales.interface'; + +export const t = (args: TranslateParams) => { + return I18n.translate(args); +}; diff --git a/packages/i18n/src/interfaces/i18n-locales.interface.ts b/packages/i18n/src/interfaces/i18n-locales.interface.ts new file mode 100644 index 00000000..1dca9640 --- /dev/null +++ b/packages/i18n/src/interfaces/i18n-locales.interface.ts @@ -0,0 +1,22 @@ +import { I18nTranslationKeys } from './i18n-translation-keys.interface copy'; + +/** + * Interface representing the structure of localization data. + */ +export interface I18nLocalesInterface + extends Partial> { + /** + * A record of localization keys and their corresponding translations. + */ + resource: Record; + + /** + * Optional flag indicating whether the localization should be extends the other resources. + */ + deep?: boolean; + + /** + * Optional flag indicating whether the localization should overwrite existing keys. + */ + overwrite?: boolean; +} diff --git a/packages/i18n/src/interfaces/i18n-module.interface.ts b/packages/i18n/src/interfaces/i18n-module.interface.ts new file mode 100644 index 00000000..ed90e187 --- /dev/null +++ b/packages/i18n/src/interfaces/i18n-module.interface.ts @@ -0,0 +1,12 @@ +import { Newable, NewableModule } from 'i18next'; +import { I18nObjectModule } from './i18n.types'; + +/** + * Interface representing an I18n module. + */ +export interface I18nModule { + /** + * The module property can be of type T, NewableModule, or Newable. + */ + module: T | NewableModule | Newable; +} diff --git a/packages/i18n/src/interfaces/i18n-options.interface.ts b/packages/i18n/src/interfaces/i18n-options.interface.ts new file mode 100644 index 00000000..44539367 --- /dev/null +++ b/packages/i18n/src/interfaces/i18n-options.interface.ts @@ -0,0 +1,6 @@ +import { InitOptions } from 'i18next'; + +/** + * Interface extending InitOptions from i18next with generic type T. + */ +export interface I18nOptions extends InitOptions {} diff --git a/packages/i18n/src/interfaces/i18n-settings.interface.ts b/packages/i18n/src/interfaces/i18n-settings.interface.ts new file mode 100644 index 00000000..de938e34 --- /dev/null +++ b/packages/i18n/src/interfaces/i18n-settings.interface.ts @@ -0,0 +1,17 @@ +import { I18nOptions } from './i18n-options.interface'; +import { I18nObjectModule, I18nextModuleType } from './i18n.types'; + +/** + * Interface representing the settings for internationalization (i18n). + */ +export interface I18nSettings { + /** + * An array of i18n modules. + */ + modules?: I18nextModuleType[]; + + /** + * Options for configuring i18n. + */ + options?: I18nOptions; +} diff --git a/packages/i18n/src/interfaces/i18n-translate-params.interface.ts b/packages/i18n/src/interfaces/i18n-translate-params.interface.ts new file mode 100644 index 00000000..4d676f2e --- /dev/null +++ b/packages/i18n/src/interfaces/i18n-translate-params.interface.ts @@ -0,0 +1,8 @@ +import { I18nTranslationKeys } from './i18n-translation-keys.interface copy'; +import { I18nTranslationOptions } from './i18n.types'; + +export interface TranslateParams extends I18nTranslationKeys { + // TODO: change default message to inside options.defaultValue + defaultMessage?: string; + options?: I18nTranslationOptions; +} diff --git a/packages/i18n/src/interfaces/i18n-translation-keys.interface copy.ts b/packages/i18n/src/interfaces/i18n-translation-keys.interface copy.ts new file mode 100644 index 00000000..9c758228 --- /dev/null +++ b/packages/i18n/src/interfaces/i18n-translation-keys.interface copy.ts @@ -0,0 +1,22 @@ +/** + * Parameters for checking if a translation key exists. + */ +export interface I18nTranslationKeys { + /** + * The namespace of the translation key. + * Optional. + */ + namespace?: string; + + /** + * The translation key to check. + * Required. + */ + key: string; + + /** + * The language of the translation key. + * Optional. + */ + language?: string; +} diff --git a/packages/i18n/src/interfaces/i18n.types.ts b/packages/i18n/src/interfaces/i18n.types.ts new file mode 100644 index 00000000..7e4a61c8 --- /dev/null +++ b/packages/i18n/src/interfaces/i18n.types.ts @@ -0,0 +1,25 @@ +import { Callback, Module, Newable, NewableModule, TOptions } from 'i18next'; + +/** + * Represents an i18n module object. + */ +export type I18nObjectModule = Module; + +/** + * Options for i18n translation. + */ +export type I18nTranslationOptions = TOptions; + +/** + * Callback function for i18n operations. + */ +export type I18nCallback = Callback; + +/** + * Type representing an i18next module, which can be an instance of the module, + * a newable module, or a newable type. + */ +export type I18nextModuleType = + | T + | NewableModule + | Newable; diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 00000000..ef998095 --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "typeRoots": [ + "./node_modules/@types", + "../../node_modules/@types" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/i18n/typedoc.json b/packages/i18n/typedoc.json new file mode 100644 index 00000000..944fda5a --- /dev/null +++ b/packages/i18n/typedoc.json @@ -0,0 +1,3 @@ +{ + "entryPoints": ["src/index.ts"] +} \ No newline at end of file diff --git a/packages/nestjs-cache/package.json b/packages/nestjs-cache/package.json index 3cb0ba21..b52c4958 100644 --- a/packages/nestjs-cache/package.json +++ b/packages/nestjs-cache/package.json @@ -12,6 +12,7 @@ "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" ], "dependencies": { + "@concepta/i18n": "^6.0.0-alpha.3", "@concepta/nestjs-access-control": "^6.0.0-alpha.3", "@concepta/nestjs-common": "^6.0.0-alpha.3", "@concepta/nestjs-crud": "^6.0.0-alpha.3", diff --git a/packages/nestjs-cache/src/cache.module.spec.ts b/packages/nestjs-cache/src/cache.module.spec.ts index 218761aa..88602983 100644 --- a/packages/nestjs-cache/src/cache.module.spec.ts +++ b/packages/nestjs-cache/src/cache.module.spec.ts @@ -14,6 +14,9 @@ describe(CacheModule.name, () => { let cacheDynamicRepo: Record>; beforeEach(async () => { + // to avoid error log from i18next not initialized + jest.spyOn(console, 'error').mockImplementation(() => {}); + const testModule: TestingModule = await Test.createTestingModule({ imports: [AppModuleFixture], }).compile(); diff --git a/packages/nestjs-cache/src/cache.module.ts b/packages/nestjs-cache/src/cache.module.ts index 00557f3f..f532a38f 100644 --- a/packages/nestjs-cache/src/cache.module.ts +++ b/packages/nestjs-cache/src/cache.module.ts @@ -8,6 +8,8 @@ import { createCacheImports, createCacheProviders, } from './cache.module-definition'; +import LOCALES from './locales'; +import { I18n } from '@concepta/i18n'; import { CacheMissingEntitiesOptionException } from './exceptions/cache-missing-entities-option.exception'; /** @@ -15,6 +17,11 @@ import { CacheMissingEntitiesOptionException } from './exceptions/cache-missing- */ @Module({}) export class CacheModule extends CacheModuleClass { + constructor() { + super(); + I18n.addTranslations(LOCALES); + } + static register(options: CacheOptions): DynamicModule { return super.register(options); } diff --git a/packages/nestjs-cache/src/locales/en/CacheModule.ts b/packages/nestjs-cache/src/locales/en/CacheModule.ts new file mode 100644 index 00000000..2edb1294 --- /dev/null +++ b/packages/nestjs-cache/src/locales/en/CacheModule.ts @@ -0,0 +1,4 @@ +const enUS = { + REFERENCE_MUTATE_ERROR: 'Error while trying to mutate a %s reference', +}; +export default enUS; diff --git a/packages/nestjs-cache/src/locales/index.ts b/packages/nestjs-cache/src/locales/index.ts new file mode 100644 index 00000000..a349c338 --- /dev/null +++ b/packages/nestjs-cache/src/locales/index.ts @@ -0,0 +1,18 @@ +import { I18nLocalesInterface } from '@concepta/i18n'; +import enUS from './en/CacheModule'; +import ptBR from './pt/CacheModule'; + +const LOCALES: I18nLocalesInterface[] = [ + { + namespace: 'CacheModule', + language: 'en', + resource: enUS, + }, + { + namespace: 'CacheModule', + language: 'pt', + resource: ptBR, + }, +]; + +export default LOCALES; diff --git a/packages/nestjs-cache/src/locales/pt/CacheModule.ts b/packages/nestjs-cache/src/locales/pt/CacheModule.ts new file mode 100644 index 00000000..420b2017 --- /dev/null +++ b/packages/nestjs-cache/src/locales/pt/CacheModule.ts @@ -0,0 +1,4 @@ +const ptBR = { + REFERENCE_MUTATE_ERROR: 'Error ao tentar alterar uma referência de %s', +}; +export default ptBR; diff --git a/packages/nestjs-cache/src/services/cache.service.spec.ts b/packages/nestjs-cache/src/services/cache.service.spec.ts index 4dd54fd6..6edc1eaa 100644 --- a/packages/nestjs-cache/src/services/cache.service.spec.ts +++ b/packages/nestjs-cache/src/services/cache.service.spec.ts @@ -10,11 +10,14 @@ import { ReferenceMutateException, ReferenceValidationException, RepositoryProxy, + initTypeOrmCommonTranslation, } from '@concepta/typeorm-common'; import { CacheService } from './cache.service'; import { CacheSettingsInterface } from '../interfaces/cache-settings.interface'; import { CacheCreateDto } from '../dto/cache-create.dto'; +import { I18n } from '@concepta/i18n'; +import LOCALES from '../locales'; const expirationDate = new Date(); expirationDate.setHours(expirationDate.getHours() + 1); @@ -51,6 +54,10 @@ describe('CacheService', () => { service = new CacheService({ testAssignment: repo }, settings); }); + afterEach(() => { + I18n.reset(); + }); + describe(CacheService.prototype.create, () => { it('should create a cache entry', async () => { Object.assign(cacheCreateDto, cacheDto); @@ -143,14 +150,31 @@ describe('CacheService', () => { ).rejects.toThrow(ReferenceValidationException); }); - it('should throw a ReferenceMutateException on error', async () => { + it('should throw a ReferenceMutateException on error with translations in pt', async () => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + I18n.init({ + options: { + initImmediate: false, + fallbackLng: 'pt', + }, + }); + // load local translations + I18n.addTranslations(LOCALES); + + // load typeorm translations + initTypeOrmCommonTranslation(); + + // need to call this to load the translations const assignment: ReferenceAssignment = 'testAssignment'; const error = new Error('error'); service['mergeEntity'] = jest.fn().mockResolvedValue(error); const t = () => service.update(assignment, cacheDto, queryOptions); - await expect(t).rejects.toThrow(ReferenceMutateException); + await expect(t).rejects.toThrowError( + 'Erro ao tentar alterar uma referência de undefined', + ); }); }); }); diff --git a/packages/typeorm-common/package.json b/packages/typeorm-common/package.json index c6e1f54d..e3e201f7 100644 --- a/packages/typeorm-common/package.json +++ b/packages/typeorm-common/package.json @@ -12,6 +12,7 @@ "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" ], "dependencies": { + "@concepta/i18n": "^6.0.0-alpha.3", "@faker-js/faker": "^8.4.1", "@nestjs/common": "^10.4.1" }, diff --git a/packages/typeorm-common/src/constants.ts b/packages/typeorm-common/src/constants.ts new file mode 100644 index 00000000..5ae1a488 --- /dev/null +++ b/packages/typeorm-common/src/constants.ts @@ -0,0 +1,4 @@ +export const REFERENCE_ID_NO_MATCH = 'REFERENCE_ID_NO_MATCH'; +export const REFERENCE_LOOKUP_ERROR = 'REFERENCE_LOOKUP_ERROR'; +export const REFERENCE_MUTATE_ERROR = 'REFERENCE_MUTATE_ERROR'; +export const REFERENCE_VALIDATION_ERROR = 'REFERENCE_VALIDATION_ERROR'; diff --git a/packages/typeorm-common/src/exceptions/reference-id-no-match.exception.ts b/packages/typeorm-common/src/exceptions/reference-id-no-match.exception.ts index 2fbeca4e..84480579 100644 --- a/packages/typeorm-common/src/exceptions/reference-id-no-match.exception.ts +++ b/packages/typeorm-common/src/exceptions/reference-id-no-match.exception.ts @@ -3,6 +3,8 @@ import { RuntimeException, RuntimeExceptionOptions, } from '@concepta/nestjs-exception'; +import { t } from '@concepta/i18n'; +import { REFERENCE_ID_NO_MATCH } from '../constants'; export class ReferenceIdNoMatchException extends RuntimeException { context: RuntimeException['context'] & { @@ -16,12 +18,15 @@ export class ReferenceIdNoMatchException extends RuntimeException { options?: RuntimeExceptionOptions, ) { super({ - message: 'No match for %s reference id %s.', + message: t({ + key: REFERENCE_ID_NO_MATCH, + defaultMessage: 'No match for %s reference id %s.', + }), messageParams: [entityName, id], ...options, }); - this.errorCode = 'REFERENCE_ID_NO_MATCH'; + this.errorCode = REFERENCE_ID_NO_MATCH; this.context = { ...super.context, diff --git a/packages/typeorm-common/src/exceptions/reference-lookup.exception.ts b/packages/typeorm-common/src/exceptions/reference-lookup.exception.ts index 95478947..61bb4619 100644 --- a/packages/typeorm-common/src/exceptions/reference-lookup.exception.ts +++ b/packages/typeorm-common/src/exceptions/reference-lookup.exception.ts @@ -2,6 +2,8 @@ import { RuntimeException, RuntimeExceptionOptions, } from '@concepta/nestjs-exception'; +import { t } from '@concepta/i18n'; +import { REFERENCE_LOOKUP_ERROR } from '../constants'; export class ReferenceLookupException extends RuntimeException { context: RuntimeException['context'] & { @@ -10,7 +12,10 @@ export class ReferenceLookupException extends RuntimeException { constructor(entityName: string, options?: RuntimeExceptionOptions) { super({ - message: 'Error while trying to lookup a %s reference', + message: t({ + key: REFERENCE_LOOKUP_ERROR, + defaultMessage: 'Error while trying to lookup a %s reference', + }), messageParams: [entityName], ...options, }); @@ -20,6 +25,6 @@ export class ReferenceLookupException extends RuntimeException { entityName, }; - this.errorCode = 'REFERENCE_LOOKUP_ERROR'; + this.errorCode = REFERENCE_LOOKUP_ERROR; } } diff --git a/packages/typeorm-common/src/exceptions/reference-mutate.exception.ts b/packages/typeorm-common/src/exceptions/reference-mutate.exception.ts index ddc2d51a..4cdb7b1b 100644 --- a/packages/typeorm-common/src/exceptions/reference-mutate.exception.ts +++ b/packages/typeorm-common/src/exceptions/reference-mutate.exception.ts @@ -2,6 +2,8 @@ import { RuntimeException, RuntimeExceptionOptions, } from '@concepta/nestjs-exception'; +import { t } from '@concepta/i18n'; +import { REFERENCE_MUTATE_ERROR } from '../constants'; export class ReferenceMutateException extends RuntimeException { context: RuntimeException['context'] & { @@ -10,7 +12,10 @@ export class ReferenceMutateException extends RuntimeException { constructor(entityName: string, options?: RuntimeExceptionOptions) { super({ - message: 'Error while trying to mutate a %s reference', + message: t({ + key: REFERENCE_MUTATE_ERROR, + defaultMessage: 'Error Default while trying to mutate a %s reference', + }), messageParams: [entityName], ...options, }); @@ -20,6 +25,6 @@ export class ReferenceMutateException extends RuntimeException { entityName, }; - this.errorCode = 'REFERENCE_MUTATE_ERROR'; + this.errorCode = REFERENCE_MUTATE_ERROR; } } diff --git a/packages/typeorm-common/src/exceptions/reference-validation.exception.ts b/packages/typeorm-common/src/exceptions/reference-validation.exception.ts index 0e3e5b1e..0b24b0bd 100644 --- a/packages/typeorm-common/src/exceptions/reference-validation.exception.ts +++ b/packages/typeorm-common/src/exceptions/reference-validation.exception.ts @@ -3,6 +3,8 @@ import { RuntimeException, RuntimeExceptionOptions, } from '@concepta/nestjs-exception'; +import { t } from '@concepta/i18n'; +import { REFERENCE_VALIDATION_ERROR } from '../constants'; export class ReferenceValidationException extends RuntimeException { context: RuntimeException['context'] & { @@ -16,7 +18,10 @@ export class ReferenceValidationException extends RuntimeException { options?: RuntimeExceptionOptions, ) { super({ - message: 'Data for the %s reference is not valid', + message: t({ + key: REFERENCE_VALIDATION_ERROR, + defaultMessage: 'Data for the %s reference is not valid', + }), messageParams: [entityName], ...options, }); @@ -27,6 +32,6 @@ export class ReferenceValidationException extends RuntimeException { validationErrors, }; - this.errorCode = 'REFERENCE_VALIDATION_ERROR'; + this.errorCode = REFERENCE_VALIDATION_ERROR; } } diff --git a/packages/typeorm-common/src/index.ts b/packages/typeorm-common/src/index.ts index 74233f0e..c3985e26 100644 --- a/packages/typeorm-common/src/index.ts +++ b/packages/typeorm-common/src/index.ts @@ -1,3 +1,9 @@ +import { I18n } from '@concepta/i18n'; +import LOCALES from './locales'; + +export const initTypeOrmCommonTranslation = () => { + I18n.addTranslations(LOCALES); +}; // interfaces export { QueryOptionsInterface } from './interfaces/query-options.interface'; export { SafeTransactionOptionsInterface } from './interfaces/safe-transaction-options.interface'; diff --git a/packages/typeorm-common/src/locales/en/translation.ts b/packages/typeorm-common/src/locales/en/translation.ts new file mode 100644 index 00000000..87ef3354 --- /dev/null +++ b/packages/typeorm-common/src/locales/en/translation.ts @@ -0,0 +1,14 @@ +import { + REFERENCE_ID_NO_MATCH, + REFERENCE_LOOKUP_ERROR, + REFERENCE_MUTATE_ERROR, + REFERENCE_VALIDATION_ERROR, +} from '../../constants'; + +const enUS = { + [REFERENCE_MUTATE_ERROR]: 'Error while trying to mutate a %s reference', + [REFERENCE_LOOKUP_ERROR]: 'Error while trying to lookup a %s reference', + [REFERENCE_ID_NO_MATCH]: 'No match for %s reference id %s.', + [REFERENCE_VALIDATION_ERROR]: 'Data for the %s reference is not valid', +}; +export default enUS; diff --git a/packages/typeorm-common/src/locales/index.ts b/packages/typeorm-common/src/locales/index.ts new file mode 100644 index 00000000..91af10c3 --- /dev/null +++ b/packages/typeorm-common/src/locales/index.ts @@ -0,0 +1,17 @@ +import { I18nLocalesInterface } from '@concepta/i18n'; +import enUS from './en/translation'; +import ptBR from './pt/translation'; + +// TODO: should we keep this under default namespace? +const LOCALES: I18nLocalesInterface[] = [ + { + language: 'en', + resource: enUS, + }, + { + language: 'pt', + resource: ptBR, + }, +]; + +export default LOCALES; diff --git a/packages/typeorm-common/src/locales/pt/translation.ts b/packages/typeorm-common/src/locales/pt/translation.ts new file mode 100644 index 00000000..18ac4a00 --- /dev/null +++ b/packages/typeorm-common/src/locales/pt/translation.ts @@ -0,0 +1,15 @@ +import { + REFERENCE_ID_NO_MATCH, + REFERENCE_LOOKUP_ERROR, + REFERENCE_MUTATE_ERROR, + REFERENCE_VALIDATION_ERROR, +} from '../../constants'; + +const ptBR = { + [REFERENCE_MUTATE_ERROR]: 'Erro ao tentar alterar uma referência de %s', + [REFERENCE_LOOKUP_ERROR]: 'Erro ao tentar procurar uma referência de %s', + [REFERENCE_ID_NO_MATCH]: + 'Nenhuma correspondência da referência %s para o id %s.', + [REFERENCE_VALIDATION_ERROR]: 'Os dados relacionados a %s não são válidos', +}; +export default ptBR; diff --git a/packages/typeorm-common/src/services/mutate.service.spec.ts b/packages/typeorm-common/src/services/mutate.service.spec.ts index 37012d09..aec23334 100644 --- a/packages/typeorm-common/src/services/mutate.service.spec.ts +++ b/packages/typeorm-common/src/services/mutate.service.spec.ts @@ -14,6 +14,8 @@ import { TestMutateServiceFixture } from '../__fixtures__/services/test-mutate.s import { TestModuleFixture } from '../__fixtures__/test.module.fixture'; import { TestEntityFixture } from '../__fixtures__/test.entity.fixture'; import { TestFactoryFixture } from '../__fixtures__/test.factory.fixture'; +import { initTypeOrmCommonTranslation } from '..'; +import { I18n } from '@concepta/i18n'; describe(MutateService, () => { const WRONG_UUID = '3bfd065e-0c30-11ed-861d-0242ac120002'; @@ -24,6 +26,13 @@ describe(MutateService, () => { let testFactory: TestFactoryFixture; beforeEach(async () => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + I18n.init({ + options: { + initImmediate: false, + }, + }); const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModuleFixture], }).compile(); @@ -244,7 +253,37 @@ describe(MutateService, () => { }); }; - await expect(t).rejects.toThrow(ReferenceIdNoMatchException); + await expect(t).rejects.toThrowError( + `No match for TestEntityFixture reference id ${WRONG_UUID}.`, + ); + }); + + it('id does not match with translation pt', async () => { + I18n.changeLanguage('pt'); + initTypeOrmCommonTranslation(); + const t = async () => { + return testMutateService.remove({ + id: WRONG_UUID, + }); + }; + + await expect(t).rejects.toThrowError( + `Nenhuma correspondência da referência TestEntityFixture para o id ${WRONG_UUID}.`, + ); + }); + + it('id does not match with translation en', async () => { + I18n.changeLanguage('en'); + initTypeOrmCommonTranslation(); + const t = async () => { + return testMutateService.remove({ + id: WRONG_UUID, + }); + }; + + await expect(t).rejects.toThrowError( + `No match for TestEntityFixture reference id ${WRONG_UUID}.`, + ); }); it('exception', async () => { diff --git a/packages/typeorm-common/tsconfig.json b/packages/typeorm-common/tsconfig.json index bb399af7..6613d9a1 100644 --- a/packages/typeorm-common/tsconfig.json +++ b/packages/typeorm-common/tsconfig.json @@ -15,6 +15,9 @@ "references": [ { "path": "../nestjs-typeorm-ext" + }, + { + "path": "../i18n" } ] } diff --git a/tsconfig.json b/tsconfig.json index 15d05093..ee219220 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -93,7 +93,7 @@ "path": "packages/nestjs-cache" }, { - "path": "packages/nestjs-auth-google" + "path": "packages/nestjs-auth-google" }, { "path": "packages/nestjs-logger-coralogix" @@ -101,6 +101,9 @@ { "path": "packages/nestjs-logger-sentry" }, + { + "path": "packages/i18n" + }, { "path": "packages/nestjs-file" }, diff --git a/yarn.lock b/yarn.lock index 619348a5..d1d43d0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -455,6 +455,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.23.2": + version: 7.26.10 + resolution: "@babel/runtime@npm:7.26.10" + dependencies: + regenerator-runtime: "npm:^0.14.0" + checksum: 10c0/6dc6d88c7908f505c4f7770fb4677dfa61f68f659b943c2be1f2a99cb6680343462867abf2d49822adc435932919b36c77ac60125793e719ea8745f2073d3745 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.23.9": version: 7.25.6 resolution: "@babel/runtime@npm:7.25.6" @@ -761,6 +770,16 @@ __metadata: languageName: node linkType: hard +"@concepta/i18n@npm:^6.0.0-alpha.3, @concepta/i18n@workspace:packages/i18n": + version: 0.0.0-use.local + resolution: "@concepta/i18n@workspace:packages/i18n" + dependencies: + i18next: "npm:^23.12.2" + i18next-fs-backend: "npm:^2.3.1" + i18next-http-middleware: "npm:^3.6.0" + languageName: unknown + linkType: soft + "@concepta/nestjs-access-control@npm:^6.0.0-alpha.3, @concepta/nestjs-access-control@workspace:packages/nestjs-access-control": version: 0.0.0-use.local resolution: "@concepta/nestjs-access-control@workspace:packages/nestjs-access-control" @@ -1035,6 +1054,7 @@ __metadata: version: 0.0.0-use.local resolution: "@concepta/nestjs-cache@workspace:packages/nestjs-cache" dependencies: + "@concepta/i18n": "npm:^6.0.0-alpha.3" "@concepta/nestjs-access-control": "npm:^6.0.0-alpha.3" "@concepta/nestjs-common": "npm:^6.0.0-alpha.3" "@concepta/nestjs-crud": "npm:^6.0.0-alpha.3" @@ -1529,6 +1549,7 @@ __metadata: version: 0.0.0-use.local resolution: "@concepta/typeorm-common@workspace:packages/typeorm-common" dependencies: + "@concepta/i18n": "npm:^6.0.0-alpha.3" "@concepta/nestjs-common": "npm:^6.0.0-alpha.3" "@concepta/nestjs-exception": "npm:^6.0.0-alpha.3" "@concepta/nestjs-typeorm-ext": "npm:^6.0.0-alpha.3" @@ -10984,6 +11005,29 @@ __metadata: languageName: node linkType: hard +"i18next-fs-backend@npm:^2.3.1": + version: 2.6.0 + resolution: "i18next-fs-backend@npm:2.6.0" + checksum: 10c0/d54169d0fe757f2b0ec31ef5dcf5fe57e209a1afface07b397ec857e18a695c122aab79567d54b98e01185f6a5809cc9d881a412942fb6a06bbbde95a926690b + languageName: node + linkType: hard + +"i18next-http-middleware@npm:^3.6.0": + version: 3.7.1 + resolution: "i18next-http-middleware@npm:3.7.1" + checksum: 10c0/fa56108cdeab7ebd9a8232a08e4b6fc8526aeb443a0e45e35393279b48f77b767ce6b2238567b84f2c89a0829d8ae3ba315ff56126f19c15c8c6ac2635d393d9 + languageName: node + linkType: hard + +"i18next@npm:^23.12.2": + version: 23.16.8 + resolution: "i18next@npm:23.16.8" + dependencies: + "@babel/runtime": "npm:^7.23.2" + checksum: 10c0/57d249191e8a39bbbbe190cfa2e2bb651d0198e14444fe80453d3df8d02927de3c147c77724e9ae6c72fa241898cd761e3fdcd55d053db373471f1ac084bf345 + languageName: node + linkType: hard + "iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24"