diff --git a/docs/content/docs/developer-guide/translations.md b/docs/content/docs/developer-guide/translations.md new file mode 100644 index 0000000000..1f5cc94161 --- /dev/null +++ b/docs/content/docs/developer-guide/translations.md @@ -0,0 +1,65 @@ +--- +title: "Translation" +showtoc: true +--- + +# Translation + +Using [`addTranslation`]({{< relref "i18n-service" >}}#addtranslation) inside the `onApplicationBootstrap` ([Nestjs lifecycle hooks](https://docs.nestjs.com/fundamentals/lifecycle-events)) of a Plugin is the easiest way to add new translations. +While vendure is only using `error`, `errorResult` and `message` resource keys you are free to use your own. + +## Translatable Error +This example shows how to create a custom translatable error +```typescript +/** + * Custom error class + */ +class CustomError extends ErrorResult { + readonly __typename = 'CustomError'; + readonly errorCode = 'CUSTOM_ERROR'; + readonly message = 'CUSTOM_ERROR'; //< looks up errorResult.CUSTOM_ERROR +} + +@VendurePlugin({ + imports: [PluginCommonModule], + providers: [I18nService], + // ... +}) +export class TranslationTestPlugin implements OnApplicationBootstrap { + + constructor(private i18nService: I18nService) { + + } + + onApplicationBootstrap(): any { + this.i18nService.addTranslation('en', { + errorResult: { + CUSTOM_ERROR: 'A custom error message', + }, + anything: { + foo: 'bar' + } + }); + + this.i18nService.addTranslation('de', { + errorResult: { + CUSTOM_ERROR: 'Eine eigene Fehlermeldung', + }, + anything: { + foo: 'bar' + } + }); + + } +} +``` + +To receive an error in a specific language you need to use the `languageCode` query parameter +`query(QUERY_WITH_ERROR_RESULT, { variables }, { languageCode: LanguageCode.de });` + +## Use translations + +Vendures uses the internationalization-framework [i18next](https://www.i18next.com/). + +Therefore you are free to use the i18next translate function to [access keys](https://www.i18next.com/translation-function/essentials#accessing-keys) \ +`i18next.t('error.any-message');` diff --git a/packages/core/e2e/fixtures/i18n/de.json b/packages/core/e2e/fixtures/i18n/de.json new file mode 100644 index 0000000000..bc0f0f2300 --- /dev/null +++ b/packages/core/e2e/fixtures/i18n/de.json @@ -0,0 +1,5 @@ +{ + "errorResult": { + "NEW_ERROR": "Neuer Fehler" + } +} diff --git a/packages/core/e2e/fixtures/i18n/en.json b/packages/core/e2e/fixtures/i18n/en.json new file mode 100644 index 0000000000..6db159ad02 --- /dev/null +++ b/packages/core/e2e/fixtures/i18n/en.json @@ -0,0 +1,5 @@ +{ + "errorResult": { + "NEW_ERROR": "New Error" + } +} diff --git a/packages/core/e2e/fixtures/test-plugins/translation-test-plugin.ts b/packages/core/e2e/fixtures/test-plugins/translation-test-plugin.ts new file mode 100644 index 0000000000..232e2d57fd --- /dev/null +++ b/packages/core/e2e/fixtures/test-plugins/translation-test-plugin.ts @@ -0,0 +1,84 @@ +import { OnApplicationBootstrap } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; +import { Ctx, ErrorResult, I18nService, PluginCommonModule, RequestContext, VendurePlugin } from '@vendure/core'; +import gql from 'graphql-tag'; +import path from 'path'; + +class CustomError extends ErrorResult { + readonly __typename = 'CustomError'; + readonly errorCode = 'CUSTOM_ERROR'; + readonly message = 'CUSTOM_ERROR'; +} + +class NewError extends ErrorResult { + readonly __typename = 'NewError'; + readonly errorCode = 'NEW_ERROR'; + readonly message = 'NEW_ERROR'; +} + +@Resolver() +class TestResolver { + + @Query() + async customErrorMessage(@Ctx() ctx: RequestContext, @Args() args: any) { + return new CustomError(); + } + + @Query() + async newErrorMessage(@Ctx() ctx: RequestContext, @Args() args: any) { + return new NewError(); + } + +} + +export const CUSTOM_ERROR_MESSAGE_TRANSLATION = 'A custom error message'; + +@VendurePlugin({ + imports: [PluginCommonModule], + providers: [I18nService], + adminApiExtensions: { + schema: gql` + extend type Query { + customErrorMessage: CustomResult + newErrorMessage: CustomResult + } + + type CustomError implements ErrorResult { + errorCode: ErrorCode! + message: String! + } + + type NewError implements ErrorResult { + errorCode: ErrorCode! + message: String! + } + + "Return anything and the error that should be thrown" + union CustomResult = Product | CustomError | NewError + `, + resolvers: [TestResolver], + }, +}) +export class TranslationTestPlugin implements OnApplicationBootstrap { + + constructor(private i18nService: I18nService) { + + } + + onApplicationBootstrap(): any { + this.i18nService.addTranslation('en', { + errorResult: { + CUSTOM_ERROR: CUSTOM_ERROR_MESSAGE_TRANSLATION, + }, + }); + + this.i18nService.addTranslation('de', { + errorResult: { + CUSTOM_ERROR: 'DE_' + CUSTOM_ERROR_MESSAGE_TRANSLATION, + }, + }); + + this.i18nService.addTranslationFile('en', path.join(__dirname, '../i18n/en.json')) + this.i18nService.addTranslationFile('de', path.join(__dirname, '../i18n/de.json')) + } +} diff --git a/packages/core/e2e/translations.e2e-spec.ts b/packages/core/e2e/translations.e2e-spec.ts new file mode 100644 index 0000000000..05d47a62fb --- /dev/null +++ b/packages/core/e2e/translations.e2e-spec.ts @@ -0,0 +1,86 @@ +import { LanguageCode, mergeConfig } from '@vendure/core'; +import { createTestEnvironment } from '@vendure/testing'; +import gql from 'graphql-tag'; +import path from 'path'; + +import { initialData } from '../../../e2e-common/e2e-initial-data'; +import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config'; + +import * as DE from './fixtures/i18n/de.json'; +import * as EN from './fixtures/i18n/en.json'; +import { + CUSTOM_ERROR_MESSAGE_TRANSLATION, + TranslationTestPlugin, +} from './fixtures/test-plugins/translation-test-plugin'; + +describe('Translation', () => { + const { server, adminClient } = createTestEnvironment( + mergeConfig(testConfig, { + plugins: [TranslationTestPlugin], + }), + ); + + beforeAll(async () => { + await server.init({ + initialData, + productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'), + customerCount: 0, + }); + await adminClient.asSuperAdmin(); + }, TEST_SETUP_TIMEOUT_MS); + + afterAll(async () => { + await server.destroy(); + }); + + describe('translations added manualy', () => { + it('shall receive custom error message', async () => { + const { customErrorMessage } = await adminClient.query(CUSTOM_ERROR); + expect(customErrorMessage.errorCode).toBe('CUSTOM_ERROR'); + expect(customErrorMessage.message).toBe(CUSTOM_ERROR_MESSAGE_TRANSLATION); + }); + + it('shall receive german error message', async () => { + const { customErrorMessage } = await adminClient.query(CUSTOM_ERROR, {}, { languageCode: LanguageCode.de }); + expect(customErrorMessage.errorCode).toBe('CUSTOM_ERROR'); + expect(customErrorMessage.message).toBe('DE_' + CUSTOM_ERROR_MESSAGE_TRANSLATION); + }); + }); + + describe('translation added by file', () => { + it('shall receive custom error message', async () => { + const { newErrorMessage } = await adminClient.query(NEW_ERROR); + expect(newErrorMessage.errorCode).toBe('NEW_ERROR'); + expect(newErrorMessage.message).toBe(EN.errorResult.NEW_ERROR); + }); + + it('shall receive german error message', async () => { + const { newErrorMessage } = await adminClient.query(NEW_ERROR, {}, { languageCode: LanguageCode.de }); + expect(newErrorMessage.errorCode).toBe('NEW_ERROR'); + expect(newErrorMessage.message).toBe(DE.errorResult.NEW_ERROR); + }); + }); + +}); + +const CUSTOM_ERROR = gql` + query CustomError { + customErrorMessage { + ... on ErrorResult { + errorCode + message + } + } + } +`; + +const NEW_ERROR = gql` + query NewError { + newErrorMessage { + ... on ErrorResult { + errorCode + message + } + } + } +`; diff --git a/packages/core/package.json b/packages/core/package.json index 906a14c5e3..88932a47d5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -65,8 +65,8 @@ "http-proxy-middleware": "^1.0.5", "i18next": "^19.8.1", "i18next-express-middleware": "^2.0.0", + "i18next-fs-backend": "^1.1.1", "i18next-icu": "^1.4.2", - "i18next-node-fs-backend": "^2.1.3", "image-size": "^0.9.1", "mime-types": "^2.1.27", "ms": "^2.1.2", diff --git a/packages/core/src/i18n/i18n.service.ts b/packages/core/src/i18n/i18n.service.ts index db2f1db96b..a7a95251b2 100644 --- a/packages/core/src/i18n/i18n.service.ts +++ b/packages/core/src/i18n/i18n.service.ts @@ -1,17 +1,31 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { Handler, Request } from 'express'; +import * as fs from 'fs'; import { GraphQLError } from 'graphql'; import i18next, { TFunction } from 'i18next'; import i18nextMiddleware from 'i18next-express-middleware'; +import Backend from 'i18next-fs-backend'; import ICU from 'i18next-icu'; -import Backend from 'i18next-node-fs-backend'; import path from 'path'; import { GraphQLErrorResult } from '../common/error/error-result'; +import { Logger } from '../config'; import { ConfigService } from '../config/config.service'; import { I18nError } from './i18n-error'; +/** + * @description + * I18n resources used for translations + * + * @docsCategory Translation + */ +export interface VendureTranslationResources { + error: any; + errorResult: any; + message: any; +} + export interface I18nRequest extends Request { t: TFunction; } @@ -21,11 +35,19 @@ export interface I18nRequest extends Request { * The `i18next-express-middleware` middleware detects the client's preferred language based on * the `Accept-Language` header or "lang" query param and adds language-specific translation * functions to the Express request / response objects. + * @docsCategory Translation */ @Injectable() export class I18nService implements OnModuleInit { + /** + * @internal + * @param configService + */ constructor(private configService: ConfigService) {} + /** + * @internal + */ onModuleInit() { return i18next .use(i18nextMiddleware.LanguageDetector) @@ -35,7 +57,7 @@ export class I18nService implements OnModuleInit { preload: ['en', 'de'], fallbackLng: 'en', detection: { - lookupQuerystring: 'lang', + lookupQuerystring: 'languageCode', }, backend: { loadPath: path.join(__dirname, 'messages/{{lng}}.json'), @@ -44,12 +66,44 @@ export class I18nService implements OnModuleInit { }); } + /** + * @internal + */ handle(): Handler { return i18nextMiddleware.handle(i18next); } + /** + * @description + * Add a I18n translation by json file + * + * @param langKey language key of the I18n translation file + * @param filePath path to the I18n translation file + */ + addTranslationFile(langKey: string, filePath: string): void { + try { + const rawData = fs.readFileSync(filePath); + const resources = JSON.parse(rawData.toString('utf-8')); + this.addTranslation(langKey, resources); + } catch (err) { + Logger.error(`Could not load resources file ${filePath}`, `I18nService`); + } + } + + /** + * @description + * Add a I18n translation (key-value) resource + * + * @param langKey language key of the I18n translation file + * @param resources key-value translations + */ + addTranslation(langKey: string, resources: VendureTranslationResources | any): void { + i18next.addResourceBundle(langKey, 'translation', resources, true, true); + } + /** * Translates the originalError if it is an instance of I18nError. + * @internal */ translateError(req: I18nRequest, error: GraphQLError) { const originalError = error.originalError; @@ -73,6 +127,7 @@ export class I18nService implements OnModuleInit { /** * Translates the message of an ErrorResult + * @internal */ translateErrorResult(req: I18nRequest, error: GraphQLErrorResult) { const t: TFunction = req.t; diff --git a/packages/core/src/i18n/index.ts b/packages/core/src/i18n/index.ts new file mode 100644 index 0000000000..a0759ad074 --- /dev/null +++ b/packages/core/src/i18n/index.ts @@ -0,0 +1,2 @@ +export * from './i18n.service'; +export * from './i18n-error'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9c398dc2c1..e1886295fb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export * from './process-context/index'; export * from './entity/index'; export * from './data-import/index'; export * from './service/index'; +export * from './i18n/index'; export * from '@vendure/common/lib/shared-types'; export { Permission, diff --git a/packages/core/typings.d.ts b/packages/core/typings.d.ts index dba245a8e1..c53d937072 100644 --- a/packages/core/typings.d.ts +++ b/packages/core/typings.d.ts @@ -9,6 +9,6 @@ declare module 'i18next-icu' { // default } -declare module 'i18next-node-fs-backend' { +declare module 'i18next-fs-backend' { // default } diff --git a/yarn.lock b/yarn.lock index a858336811..e6cc319735 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10205,6 +10205,11 @@ i18next-express-middleware@^2.0.0: dependencies: cookies "0.7.1" +i18next-fs-backend@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/i18next-fs-backend/-/i18next-fs-backend-1.1.1.tgz#1d8028926803f63784ffa0f2b1478fb369f92735" + integrity sha512-RFkfy10hNxJqc7MVAp5iAZq0Tum6msBCNebEe3OelOBvrROvzHUPaR8Qe10RQrOGokTm0W4vJGEJzruFkEt+hQ== + i18next-icu@^1.4.2: version "1.4.2" resolved "https://registry.npmjs.org/i18next-icu/-/i18next-icu-1.4.2.tgz#2b79d1ac2c2d542725219beac34a74db15cd2ff9" @@ -10212,14 +10217,6 @@ i18next-icu@^1.4.2: dependencies: intl-messageformat "2.2.0" -i18next-node-fs-backend@^2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/i18next-node-fs-backend/-/i18next-node-fs-backend-2.1.3.tgz#483fa9eda4c152d62a3a55bcae2a5727ba887559" - integrity sha512-CreMFiVl3ChlMc5ys/e0QfuLFOZyFcL40Jj6jaKD6DxZ/GCUMxPI9BpU43QMWUgC7r+PClpxg2cGXAl0CjG04g== - dependencies: - js-yaml "3.13.1" - json5 "2.0.0" - i18next@^19.8.1: version "19.9.1" resolved "https://registry.npmjs.org/i18next/-/i18next-19.9.1.tgz#7a072b75daf677aa51fd4ce55214f21702af3ffd" @@ -11534,14 +11531,6 @@ js-beautify@^1.6.14: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@3.13.1: - version "3.13.1" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" - integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - js-yaml@^3.13.1, js-yaml@^3.14.0: version "3.14.1" resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -11664,13 +11653,6 @@ json3@^3.3.3: resolved "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA== -json5@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/json5/-/json5-2.0.0.tgz#b61abf97aa178c4b5853a66cc8eecafd03045d78" - integrity sha512-0EdQvHuLm7yJ7lyG5dp7Q3X2ku++BG5ZHaJ5FTnaXpKqDrw4pMxel5Bt3oAYMthnrthFBdnZ1FcsXTPyrQlV0w== - dependencies: - minimist "^1.2.0" - json5@2.x, json5@^2.1.2: version "2.2.0" resolved "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" @@ -13853,7 +13835,7 @@ npm-package-arg@^8.0.0, npm-package-arg@^8.0.1, npm-package-arg@^8.1.0: npm-packlist@1.1.12, npm-packlist@^1.1.6, npm-packlist@^2.1.4: version "1.1.12" - resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.1.12.tgz#22bde2ebc12e72ca482abd67afc51eb49377243a" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.12.tgz#22bde2ebc12e72ca482abd67afc51eb49377243a" integrity sha512-WJKFOVMeAlsU/pjXuqVdzU0WfgtIBCupkEVwn+1Y0ERAbUfWw8R4GjgVbaKnUjRoD2FoQbHOCbOyT5Mbs9Lw4g== dependencies: ignore-walk "^3.0.1"