@deriv-com/translations
This is a localization library that uses i18next, react-i18next, and a custom OTA SDK for translations.
In this document
Overview
Requirements
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
Getting Started
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
Setup
- 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>
)
}
Usage
initializeI18n
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 />.
Localize component example:
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 }}
/>;
localize example:
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"),
});
useTranslations Hook
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>;
};
Syncing translations
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.
Example usage of the action in the workflow file:
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 }}
Syncing translations with Rosetta
Crowdin is being decommissioned in favour of the internal Rosetta translation
API, which replaced the Makima gateway.
extract_and_sync_translations_rosetta 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 Rosetta
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 Rosetta. 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 Rosetta preserves, restored afterwards, and verified to appear exactly once. A string that fails the check, or whose translation still holds an HTML entity the source did not have, 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 Rosetta 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, and one that starts with a placeholder or marker (such as{{count}} items), 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 Rosetta 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 Rosetta's output; one that does not is listed in the job summary and the key falls through to production's own value (prefer) or Rosetta (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.ROSETTA_API_TOKEN: Bearer token for Rosetta (a secret). Create it at the Rosetta web app (ask the Rosetta team for access); it is shown only once.ROSETTA_API_BASE: base URL of the Rosetta API: its https host only, from the Rosetta team. Required, with no default, so a consumer always names the host it sends the token to.ROSETTA_CF_ACCESS_CLIENT_ID,ROSETTA_CF_ACCESS_CLIENT_SECRET: optional Cloudflare Access service token for CI runners; see "Runner network".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 Rosetta. 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 or for a leftover HTML entity), 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 (…_rosetta@<sha> # v1.x.y) rather
than @master. The Crowdin and Rosetta 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 ROSETTA_API_TOKEN, not only the action's steps.
(The deprecated Makima path runs the v1.6.1 action, itself pinned by commit.)
Runner network
The Rosetta API may require a Cloudflare Access service token from CI runners. If the Rosetta team issues one for GitHub Actions, pass it as:
ROSETTA_CF_ACCESS_CLIENT_ID: the token's Client ID (store it as a secret).ROSETTA_CF_ACCESS_CLIENT_SECRET: the token's Client Secret (a secret).
Set both or neither; they are sent as the CF-Access-Client-Id and
CF-Access-Client-Secret headers on every Rosetta request, and never logged.
Without them, a blocked run fails with
Rosetta submit was blocked by Cloudflare (HTTP 403, Ray ID …) or
… redirected to Cloudflare Access login. If a run with the token is still
blocked, share the Ray ID from the error with the Rosetta team privately.
Moving from the Makima action
Before this rename the action was extract_and_sync_translations_makima, with
the MAKIMA_API_KEY and MAKIMA_API_BASE inputs and the deriv-makima-sync
bin. Makima is sunset on 18 Nov 2026. To move, change the uses: path to
extract_and_sync_translations_rosetta, pinned to a release that contains that
folder, pass ROSETTA_API_TOKEN and ROSETTA_API_BASE (the Rosetta API host
only; both from the Rosetta team), and run
a dry run first. The settings are checked before anything is installed: the
base must be an https host with no path.
Until Makima is switched off on 18 Nov 2026, the old path still works: it runs
the v1.6.1 action and warns. The first release after the sunset is planned to
turn it into a step that only fails with a pointer here. The deriv-makima-sync
bin already only fails with a pointer to deriv-rosetta-sync.
A repo that loads the script directly should load src/scripts/deriv-rosetta-sync.cjs,
where makimaNameFor is now languageNameFor and buildSummary takes
rosettaErrors instead of makimaErrors.
Example usage of the Rosetta action in the workflow file:
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-rosetta
cancel-in-progress: false
jobs:
sync_translations:
runs-on: ubuntu-latest
steps:
- name: Sync translations
# Pin the release commit; never @master (see above).
uses: deriv-com/translations/.github/actions/extract_and_sync_translations_rosetta@<release-commit-sha> # v1.x.y
with:
PROJECT_NAME: ${{ vars.R2_PROJECT_NAME }}
PROJECT_SOURCE_DIRECTORY: './src'
ENVIRONMENT: staging
TARGET_LOCALES: es,fr,pt
ROSETTA_API_TOKEN: ${{ secrets.ROSETTA_API_TOKEN }}
ROSETTA_API_BASE: ${{ vars.ROSETTA_API_BASE }}
ROSETTA_CF_ACCESS_CLIENT_ID: ${{ secrets.ROSETTA_CF_ACCESS_CLIENT_ID }}
ROSETTA_CF_ACCESS_CLIENT_SECRET: ${{ secrets.ROSETTA_CF_ACCESS_CLIENT_SECRET }}
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 \
ROSETTA_API_BASE=https://<rosetta-api-host> ROSETTA_API_TOKEN=… DRY_RUN=true npx deriv-rosetta-sync
Contributing
Contributions are welcome. Please open a pull request with your changes.