Explorar el Código

feat(core): Add server translations via plugin application bootstrap

Co-authored-by: Kevin Mattutat <kevin.mattutat@spaceparrots.de>

Closes #810
Drayke hace 4 años
padre
commit
13a4b68800

+ 65 - 0
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');`

+ 5 - 0
packages/core/e2e/fixtures/i18n/de.json

@@ -0,0 +1,5 @@
+{
+  "errorResult": {
+    "NEW_ERROR": "Neuer Fehler"
+  }
+}

+ 5 - 0
packages/core/e2e/fixtures/i18n/en.json

@@ -0,0 +1,5 @@
+{
+  "errorResult": {
+    "NEW_ERROR": "New Error"
+  }
+}

+ 84 - 0
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'))
+    }
+}

+ 86 - 0
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
+            }
+        }
+    }
+`;

+ 1 - 1
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",

+ 57 - 2
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;

+ 2 - 0
packages/core/src/i18n/index.ts

@@ -0,0 +1,2 @@
+export * from './i18n.service';
+export * from './i18n-error';

+ 1 - 0
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,

+ 1 - 1
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
 }

+ 6 - 24
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"