Internationalization (i18n) (DRAFT)

The storefront is driven by different content streams. Most of the content is driven by either the CMS or by the product content. There’s however a portion of content that is provided by so-called site labels (i.e. texts in buttons or form placeholders).

Site labels are stored in separated files and can be translated into various languages.

Dependencies

Under the hood Spartacus uses i18next library for translation mechanism and i18next-xhr-backend for lazy loading of translation chunks. Those libraries have rich API, but we support only parts of it and treat as an implementation detail. In other words, Spartacus doesn’t support a custom usage of i18next in your application.

Getting started

For a quick start, just import predefined Spartacus translations (currently only in English) from @spartacus/assets and register them in the config of the B2cStorefrontModule:

import { translations, translationChunksConfig } from '@spartacus/assets';

// ...

imports: [
  B2cStorefrontModule.withConfig({
    i18n: {
        resources: translations,
        chunks: translationChunksConfig
    }
  })
];

How to add translations for other languages

You can also provide your own English translations and add for other languages, for example:

import { translations, translationChunksConfig } from '@spartacus/assets';

// ...

imports: [
  B2cStorefrontModule.withConfig({
    i18n: {
        resources: {
            en: translations, // or YOUR_ENGLISH_TRANSLATIONS,
            de: YOUR_GERMAN_TRANSLATIONS,
            ...
        },
        chunks: translationChunksConfig
    }
  })
];

This will compile the translations in to your application JS bundle. While it’s a good way for a quick start, in production you would like to take advantage from lazy loading of translation chunks.

How to overwrite individual translations

To overwrite individual translations, the object with overwrites needs to be provided after the default translations. For example:

// app.module

import { translations } from '@spartacus/assets';

// ...

export const translationOverwrites = {
  en: { // lang
    cart: { // chunk
      cartDetails: { // keys (nested)
        proceedToCheckout: "Let's checkout!",
      },
    },
  },
};

// ...

imports: [
    B2cStorefrontModule.withConfig({
        i18n: { resources: translations }
    }),
    ConfigModule.withConfig({
        i18n: { resources: translationOverwrites }
    })
]

Fallback language

In case of translation missing for a particular key, storefront in production mode displays non-breaking space character. To make it easier to catch those missing keys in development mode you will see the translation key preceded with the chunk’s name and colon (eg. [common:form.confirm]).

To provide better UX in case of missing translation you can specify a fallback language. Setting fallbackLang option ensures that for every missing translation equivalent from fallback language will be used.

Example configuration with English as a fallback language:

import { translations, translationChunksConfig } from '@spartacus/assets';

// ...

imports: [
  B2cStorefrontModule.withConfig({
    i18n: {
        resources: translations,
        chunks: translationChunksConfig,
        fallbackLang: 'en',
    }
  })
];

Lazy loading

It makes a lot of sense to load translation resources only for the current language and only for the current page. That is why translations are structured by language and named chunks:

Translation resources have a structure:

interface TranslationResources {
  [lang: string]: {
    [chunkName: string]: {
      [key: string]: any; // value or nested object with keys
    };
  };
}

To take advantage from lazy loading, you need to serve different JSON files, each for a specific language and chunk, and configure the URL to them using placeholders: {{lng}} for language and {{ns}} for chunk. For example:

imports: [
  B2cStorefrontModule.withConfig({
    i18n: {
        backend: {
            loadPath: 'assets/i18n-assets/{{lng}}/{{ns}}.json'
            // crossOrigin: true, - use this option when i18n assets come from a different domain
        },
        chunks: translationChunksConfig
    }
  })
];

You can find the predefined Spartacus’ JSON files with translations in the folder /i18n-assets of @spartacus/storefront. But you need to serve them: either from your custom endpoint, or simply by copying them into /assets folder of your Angular application. For example:

$ cp ./node_modules/@spartacus/assets/i18n-assets ./src/assets -r

Note: ./src/assets is a standard path for apps created by Angular CLI. Your path to assets may be different.

When to create a new chunk

One chunk should group texts used for one functionality in the user journey (i.e. product list/details, cart, checkout or my account section). When a new functionality is added, one should consider whether to add it’s texts into existing translation chunk or to create a new chunk for it. It’s all about optimization. Your decision will be subjective and related to your CMS components structure. Too fine-grained chunks may result in loading many JSONs only to display one page. On the other hand, too coarse chunks with not related parts won’t be effective.

Things to consider when adding the feature with translation keys:

  • will it appear very often (i.e. header) or only in some specific pages (i.e. checkout)
  • will it appear only for signed in / privileged users (i.e. my account section) or for all users, etc.

When a feature appears only under some conditions, it’s a sign it may be worth to have a separate chunk for it.

Note: for smooth upgrade path, please avoid adding custom keys to existing chunks of Spartacus. See section Upgrading.

Using translations in HTML

You can use the cxTranslate pipe, for example:

<input placeholder="{{ 'searchBox.searchHere' | cxTranslate }}" />

Config of chunks and namespaces mapping

Every key belongs to some named space (namespace). Each namespace is encapsulated in a chunk (i18n.chunks below). But in order to resolve which key is in which namespace and chunk, a config is needed:

imports: [
  B2cStorefrontModule.withConfig({
    i18n: {
        backend: {
            loadPath: 'assets/i18n-assets/{{lng}}/{{ns}}.json'
        },
        chunks: {
            ...
            common: ['searchBox', 'sorting', ...],
            cart: ['cartDetails', 'cartItems', ...]
            product: ['productDetails', 'productList', ...]
            ...
        }
    }
  })
];
// common.json
{
    ...
    "searchBox": {
        "searchHere": "Search here..."
    },
    "sorting": {
        "date": "Date",
        "orderNumber": "Order Number"
    },
    ...
}
// cart.json
{
    "cartItems": {
        ...
        "itemPrice": "Item price",
        ...
    },
}

Note: if the key namespace and chunk’s name are the same, no explicit chunk config is needed for it. So there is no need for:

chunks: [
    myAddon: ['myAddon']
]

Parameters

You can also pass parameters into the translation pipe, for example:

<p>{{ 'miniLogin.hello' | cxTranslate : { name: person.name } }}</p>
// resources
{
    "miniLogin": {
        "hello": "Hello, {{ name }}"
    }
}

Special parameters

There are two special parameters of translation pipe used for pluralization and conditional translations: count and context.

count parameter

You can pass the count parameter to differ translations for the same key depending on the integer value of count. It takes the current language into consideration. Different languages have different plural rules. Languages as simple as English have only 2 forms: singular and plural, but other languages may have more. For example:

<p>{{ 'miniCart.item' | cxTranslate : { count: cart.totalItem } }}</p>

Translation resources:

// cart.en.json (ENGLISH)
{
    "miniCart": {
        "item": "{{count}} item currently in your cart",
        "item_plural": "{{count}} items currently in your cart"
    }
}

But for languages with more than 2 forms, numeric suffixes are used _0, _1, … _5. For example in Polish there are 3 rules:

// cart.pl.json (POLISH)
{
    "miniCart": {
        "item": "Masz {{ count }} przedmiot w koszyku",
        "item_2": "Masz {{ count }} przedmioty w koszyku",
        "item_5": "Masz {{ count }} przedmiotów w koszyku",
    }
}

Here is a small tool which helps figuring out the correct plural suffixes for different languages.

More about the special count parameter in the official i18next docs: https://www.i18next.com/translation-function/plurals

context parameter

You can pass the special parameter context to differ translations for the same key depending on the string value of context. It’s useful to translate enum values from backend. For example:

<p>{{ 'order.status' | cxTranslate : { context: order.status } }}</p>

Translation resources:

{
    "order": {
        "status_processing": "In process...",
        "status_completed": "Completed",
        "status_cancelled": "Cancelled",
    }
}

More about the special context parameter in the official i18next docs: https://www.i18next.com/translation-function/plurals

Other special parameters (unsupported)

There are few special parameters which interfere with the i18next translation mechanism, but Spartacus doesn’t support them and we recommend to avoid using them:

defaultValue, replace, lng, lngs, fallbackLng, ns, keySeparator, nsSeparator, returnObjects, joinArrays, postProcess, interpolation, skipInterpolation

That being said, here are official i18next docs for those unsupported parameters: https://www.i18next.com/translation-function/essentials#overview-options

Using translations in TS code

If you need to make use of translations before the template inject the translation service as follows:

import { TranslationService } from '@spartacus/core';

constructor(
    private translation: TranslationService
) {}

getPaymentCardContent(payment: PaymentDetails): Observable<Card> {
   return combineLatest([
     this.translation.translate('paymentForm.payment'),
     this.translation.translate('paymentCard.expires', {
       month: payment.expiryMonth,
       year: payment.expiryYear,
     }),
   ]).pipe(
     map(([textTitle, textExpires]) => {
       return {
         title: textTitle,
         textBold: payment.accountHolderName,
         text: [payment.cardType.name, payment.cardNumber, textExpires],
       };
     })
   );
}

It is observable so please remember to add async pipe in the template:

<cx-card
    [content]="getPaymentCardContent(order.paymentInfo) | async"
></cx-card>

Upgrading

When Spartacus comes out with new features, it publishes new JSON files with predefined translations. Then any differences need to be taken into account in the JSON files translated into other languages.

For easier upgrades, we recommend NOT to add any custom keys in the namespaces of Spartacus, but rather to create custom chunks and namespaces for your custom features. Otherwise on every new version of Spartacus shipping with new translations, you will have to add your custom keys to new Spartacus JSON files.

Extending translations

When you add new custom features and want to add new translations, there is the risk that in future Spartacus Storefront or other library from Spartacus’ ecosystem will come out with similar feature with the same translation key. To avoid conflicts of keys, we recommend prefixing your feature keys with your app/library abbreviation. For example a library called Super Library that has custom feature, can have key:

<p>{{ 'slCustomFeature.subKey' | cxTranslate }}</p>
{
    "slCustomFeature": {
        "subKey": "value",
    }
}

Localizing and formatting dates

You can format a date for the active language using the special pipe in the HTML template, for example:

{{ order.created | cxDate }}

The cxDate pipe is just a wrapper on the Angular’s date pipe, so it accepts the same arguments (format and timezone). For example:

{{ order.created | cxDate: 'longDate' }}

Note: cxDate pipe uses the Angular’s locale data - which comes only with English by default. For other locales, you need to register them explicitly in your app.module. For example:

// app.module

import localeDe from '@angular/common/locales/de';
import localeJa from '@angular/common/locales/ja';

registerLocaleData(localeDe);
registerLocaleData(localeJa);

Note: If locale is not registered for the active language, the cxDate pipe will fallback to English.