This is a localization library that uses i18next, react-i18next, and a custom OTA SDK for translations.
In this document
The stored translation directory must have the following structure:
{{ BASE_URL }}/translations/{lang}.json
base_url is the cdnUrl passed to the initializeI18n function, and lang is the language code. Refer to the example
To get started, one would need few things to setup first which are:
- A Crowdin project which will be used to manage translations for your project.
- A Cloudflare R2 account to store the translations.
- Create a new bucket in the R2 account.
- Create a new access key in the R2 account.
- Add the following secrets to your repository:
CROWDIN_PROJECT_ID: Open your crowdin project and navigate toToolstab, you will be able to get the Project ID, store that in your github secrets.CROWDIN_PERSONAL_TOKEN: This can be obtained by logging in to your crowdin account and clicking on your profile icon and gotoSettings > APIand create a new personal access token, store that in your github secrets.R2_ACCOUNT_ID: R2 account ID from the Cloudflare R2 dashboard.R2_ACCESS_KEY_ID: R2 access key ID from the Cloudflare R2 dashboard.R2_SECRET_ACCESS_KEY: R2 secret access key from the Cloudflare R2 dashboard.R2_BUCKET_NAME: R2 bucket name from the Cloudflare R2 dashboard.
- Add the following environment variable in your repository:
PROJECT_NAME: Add project name in your environment variable which will be used for Cloudflare R2 folder name for translations.CROWDIN_BRANCH_NAME: Add the branch name for your crowdin project to separate the translations based on environment, this needs to be put into the environment variable instead of secret because the consumer of the package needs it while defining thecdnUrlto access the translations.
- Setup the github action to sync the translations to the CDN, refer to the Syncing translations section for more details.
Install the package by running:
npm install @deriv-com/translations- initialize translations in main component by importing and calling
initializeI18noutside of the component function. - pass the return value to the
TranslationProvidercomponent from@deriv-com/translations. - pass default language to the
TranslationProvidercomponent.
import { initializeI18n, TranslationProvider } from '@deriv-com/translations';
...
const i18nInstance = initializeI18n({ cdnUrl: 'https://cdn.example.com' })
const App = () => {
...
return (
<TranslationProvider defaultLang={'EN'} i18nInstance={i18nInstance}>
<App />
</TranslationProvider>
)
}The initializeI18n function initializes the i18next instance with the OTA SDK, react-i18next, and a language detector. It takes an object with a cdnUrl property, which is the URL of the CDN where the translations are stored.
import initializeI18n from "@deriv-com/translations";
initializeI18n({ cdnUrl: "https://cdn.example.com" });- For strings use either
localize(...)or<Localize />.
import { Localize } from "@deriv-com/translations";
<Localize
i18n_default_text="You cannot use your real money account with {{website_name}} at this time."
values={{ website_name }}
/>;Note that the
localizefunction will not get the update from i18n instance once there is any changes like resource loaded or language change. Thislocalizefunction is not to be used to wrap strings in components and only suitable for util/mapper functions which would not cause issues with string not getting updated.
import { localize } from "@deriv-com/translations";
const getNotification = () => ({
all: localize("all notifications"),
});The useTranslations hook is a custom hook that adds some more returned values on top of the useTranslation hook from react-i18next. It can be used to translate strings in your components, toggle the language of the app and see the current language etc.
Example usage:
import { useTranslations } from "@deriv-com/translations";
const MyComponent = () => {
const { localize, switchLanguage, currentLang } = useTranslations();
const handleLanguageChange = () => {
switchLanguage(currentLang === "EN" ? "DE" : "EN");
};
return <p onClick={handleLanguageChange}>{localize("Change language")}</p>;
};There is a github action that syncs the translations from Crowdin to the CDN.
The action takes following inputs:
PROJECT_SOURCE_DIRECTORY: Source directory of your project by default it issrc.CROWDIN_BASE_PATH: Base path of the translations in the Crowdin project by default it is..CROWDIN_BASE_URL: Base URL of the CDN where the translations are stored, default ishttps://api.crowdin.com.CROWDIN_BRANCH_NAME: Running on production, test or staging etc.CROWDIN_PROJECT_ID: Crowdin project ID which can be found in the crowdin project settings.CROWDIN_PERSONAL_TOKEN: Crowdin personal token which can be found in the crowdin account settings.R2_ACCOUNT_ID: R2 account ID from the Cloudflare R2 dashboard.R2_ACCESS_KEY_ID: R2 access key ID from the Cloudflare R2 dashboard.R2_SECRET_ACCESS_KEY: R2 secret access key from the Cloudflare R2 dashboard.R2_BUCKET_NAME: R2 bucket name from the Cloudflare R2 dashboard.
Refer to the action file here.
Copy and paste the following workflow code in your repository in this path.github/workflows/sync_translations.yml.
name: Sync translations
on:
push:
branches:
- 'main'
schedule:
- cron: '0 */12 * * *'
jobs:
sync_translations:
runs-on: ubuntu-latest
steps:
- name: Sync translations
uses: deriv-com/translations/.github/actions/extract_and_sync_translations@master
with:
PROJECT_NAME: ${{ env.PROJECT_NAME }}
CROWDIN_BRANCH_NAME: ${{ env.CROWDIN_BRANCH_NAME }}
CROWDIN_BASE_URL: ${{ env.CROWDIN_BASE_URL }}
CROWDIN_BASE_PATH: ${{ env.CROWDIN_BASE_PATH }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}Crowdin is being decommissioned in favour of the internal Makima translation
gateway. extract_and_sync_translations_makima is a drop-in sibling of the
Crowdin action: it runs the same extractor and uploads the same
translations/<locale>.json files to the same R2 folder, so the runtime needs
no change. Only the middle step differs — new strings are translated by Makima
instead of downloaded from Crowdin.
How it keeps translations stable:
-
Delta only. The current
<locale>.jsonis fetched from the CDN first and used as the translation memory. Keys already there are kept verbatim, keys no longer inen.jsonare pruned, and only missing keys are sent to Makima. Because keys are the crc32 of the English text, a reworded string is a new key. -
Protected tokens.
{{placeholders}},<0>…</0>component markers and any terms listed inPROTECTED_TERMS_FILE(brand and product names) are swapped for HTML tokens Makima preserves, restored afterwards, and verified to appear exactly once. A string that fails the check is left out of the file — the runtime falls back to English for it and the next run retries it — and is listed in the job summary. -
Partial results. If Makima fails a language, the locales that succeeded are published, the failed locale's file is left as the CDN had it, and the run is marked failed after the upload so the site keeps working.
-
Dry run. With
DRY_RUN: "true"the catalogues are attached as a workflow artifact instead of being uploaded; use it for the first run on each environment and diff the artifact against the live files. Make sure that first run includes a few new strings with&,<, quotes and a{{x}}placeholder, so the gateway's handling of them is seen before anything is published. -
What counts as translated. Any string value already on the CDN is reused, including one identical to the English. That is deliberate (product names such as "Wallet API" are kept in English on purpose), but it also means a key that was once published untranslated is never resent; delete it from the CDN file to have it translated.
-
Reviewed values travel between environments. With
FALLBACK_ENVIRONMENT: stagingon the production sync, a key production lacks takes the value in the staging catalogue (the one QA reviewed) before Makima is asked; equal keys mean equal English, because keys are the crc32 of the text.FALLBACK_MODE: prefergoes further: staging's value also replaces a different one production already has, so the two catalogues converge. A staging value must pass the same placeholder, marker and protected-term check as Makima's output; one that does not is listed in the job summary and the key falls through to production's own value (prefer) or Makima (missing). Keys only staging has are never added, and production keys no longer inen.jsonare still pruned. -
Corrections go through a pull request.
OVERRIDES_FILEpoints at a JSON file in your repository, keyed by locale and then by the English source text:{ "fr": { "1 Change": "1 modification" }, "es": { "Edited {{time}} ago": "Editado hace {{time}}" } }Overrides win over every other source, in every environment that passes the file. An entry that fails the token check fails the run, even when the run does not target its locale, and so does a language named twice (
zh-CNandzh_cn); an entry whose English is no longer extracted (the string was reworded or removed) is skipped and listed in the job summary, so remove or update it.
The action takes the following inputs:
PROJECT_NAME,PROJECT_SOURCE_DIRECTORY,R2_ACCOUNT_ID,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET_NAME: as for the Crowdin action.ENVIRONMENT:stagingorproduction— the R2 sub-folder (whatCROWDIN_BRANCH_NAMEwas for).TARGET_LOCALES: comma-separated locale codes to translate into, e.g.es,fr,pt. Chinese also accepts the runtime'szh_cn/zh_tw(theZH_CN/ZH_TWlanguage codes), and the file keeps that name.MAKIMA_API_KEY: Bearer token for the gateway (a secret, minted by the platform team against your team's LiteLLM service-account key).MAKIMA_API_BASE: base URL of the gateway as given by the platform team. Required, with no default, so no consumer silently relies on a dev host.CDN_BASE_URL: where the current catalogues are served (defaulthttps://translations.deriv.com).PROTECTED_TERMS_FILE: optional path to a JSON array of strings that must never be translated.FALLBACK_ENVIRONMENT: optionalstagingorproduction, another environment whose catalogue is consulted before Makima. Must differ fromENVIRONMENT.FALLBACK_MODE:missing(default) uses the fallback only for keys this environment lacks;preferalso lets it replace this environment's own values.OVERRIDES_FILE: optional path to reviewed corrections, shaped{"<locale>": {"<English text>": "<translation>"}}.DRY_RUN:trueto write an artifact instead of uploading.
It exposes four outputs: status (noop when the catalogues are unchanged,
completed when new translations, the fallback, overrides or pruning changed
them, or partial), kept_back (the number of strings left out by the token
check), from_fallback (values taken from FALLBACK_ENVIRONMENT) and
overridden (values OVERRIDES_FILE changed or added; an override already
published counts as reused).
Pin the action to a release commit by SHA (…_makima@<sha> # v1.x.y) rather
than @master. Both actions run their scripts from their own commit, with
dependencies from that commit's package-lock.json, and never install
@deriv-com/translations from npm. So the pin also covers the extractor and
the sync that runs with MAKIMA_API_KEY, not only the action's steps.
name: Sync translations
on:
push:
branches:
- 'main'
schedule:
- cron: '0 */12 * * *'
# Two runs reading the same CDN snapshot would both upload, last writer wins;
# serialise them.
concurrency:
group: sync-translations-makima
cancel-in-progress: false
jobs:
sync_translations:
runs-on: ubuntu-latest
steps:
- name: Sync translations
uses: deriv-com/translations/.github/actions/extract_and_sync_translations_makima@master
with:
PROJECT_NAME: ${{ vars.R2_PROJECT_NAME }}
PROJECT_SOURCE_DIRECTORY: './src'
ENVIRONMENT: staging
TARGET_LOCALES: es,fr,pt
MAKIMA_API_KEY: ${{ secrets.MAKIMA_API_KEY }}
MAKIMA_API_BASE: ${{ vars.MAKIMA_API_BASE }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}The same bin can be run locally for a dry run after npx deriv-extract-translations ./src:
PROJECT_NAME=my-project ENVIRONMENT=staging TARGET_LOCALES=es,fr,pt \
MAKIMA_API_BASE=https://… MAKIMA_API_KEY=… DRY_RUN=true npx deriv-makima-syncContributions are welcome. Please open a pull request with your changes.