Internationalization

Internationalization and Localization

Internationalization and Localization aims to help you create web apps for multiple regions and languages. The support for i18n (shorthand for Internationalization) is provided by the @adonisjs/i18n package.

  • Localization is the process of translating the text of your application to multiple languages. You must write copy for each language and reference them within Edge templates, validation error messages, or using i18n API directly.

  • Internationalization is the process of formatting values such as date, time, numbers as per a specific region or country.

Installation

Install the package from the npm packages registry using one of the following commands.

npm i @adonisjs/i18n

Once done, you must run the following command to configure the i18n package.

node ace configure @adonisjs/i18n
  1. Registers the following service provider inside the adonisrc.ts file.

    {
    providers: [
    // ...other providers
    () => import('@adonisjs/i18n/i18n_provider')
    ]
    }
  2. Creates the config/i18n.ts file.

  3. Creates detect_user_locale_middleware inside the middleware directory.

  4. register the following middleware inside the start/kernel.ts file.

    router.use([
    () => import('#middleware/detect_user_locale_middleware')
    ])

Configuration

The configuration for the i18n package is stored within the config/i18n.ts file.

See also: Config stub

import app from '@adonisjs/core/services/app'
import { defineConfig, formatters, loaders } from '@adonisjs/i18n'
const i18nConfig = defineConfig({
defaultLocale: 'en',
formatter: formatters.icu(),
loaders: [
loaders.fs({
location: app.languageFilesPath()
})
],
})
export default i18nConfig

formatter

Defines the format to use for storing translations. AdonisJS supports the ICU message format.

The ICU message format is a widely accepted standard supported by many translation services like Crowdin and Lokalise.

Also, you can add custom message formatters.

defaultLocale

The default locale for the application. Translations and values formatting will fall back to this locale when your application does not support the user language.

fallbackLocales

A key-value pair that defines a collection of locales and their fallback locales. For example, if your application supports Spanish, you may define it as a fallback for the Catalin language.

export default defineConfig({
formatter: 'icu',
defaultLocale: 'en',
fallbackLocales: {
ca: 'es' // show Spanish content when a user speaks Catalin
}
})

supportedLocales

An array of locales supported by your application.

export default defineConfig({
formatter: 'icu',
defaultLocale: 'en',
supportedLocales: ['en', 'fr', 'it']
})

If you do not define this value, we will infer the supportedLocales from translations. For example, if you have defined translations for English, French, and Spanish, the value of supportedLocales will be ['en', 'es', 'fr']

loaders

A collection of loaders to use for loading translations. By default, we only support the File system loader. However, you can add custom loaders.

Storing translations

The translations are stored inside the resources/lang directory, and you must create a sub-directory for every language as per IETF language tag format. For example:

resources
├── lang
│ ├── en
│ └── fr

You can define translations for a specific region by creating sub-directories with the region code. In the following example, we define different translations for English (Global), English (United States), and English (United Kingdom).

AdonisJS will automatically fall back to English (Global) when you have a missing translation in a region-specific translations set.

See also: ISO Language code

resources
├── lang
│ ├── en
│ ├── en-us
│ ├── en-uk

Files format

Translations must be stored inside .json or .yaml files. Feel free to create a nested directory structure for better organization.

resources
├── lang
│ ├── en
│ │ └── messages.json
│ └── fr
│ └── messages.json

Translations must be formatted per the ICU message syntax.

resources/lang/en/messages.json
{
"greeting": "Hello world"
}
resources/lang/fr/messages.json
{
"greeting": "Bonjour le monde"
}

Resolving translations

Before you can look up and format translations, you will have to create a locale-specific instance of the I18n class using the i18nManager.locale method.

import i18nManager from '@adonisjs/i18n/services/main'
// I18n instance for English
const en = i18nManager.locale('en')
// I18n instance for French
const fr = i18nManager.locale('fr')

Once you have an instance of the I18n class, you may use the .t method to format a translation.

const i18n = i18nManager.locale('en')
i18n.t('messages.greeting') // Hello world
const i18n = i18nManager.locale('fr')
i18n.t('messages.greeting') // Bonjour le monde

Fallback locale

Each instance has a pre-configured fallback language based upon the config.fallbackLocales collection. The fallback language is used when a translation is missing for the main language.

export default defineConfig({
fallbackLocales: {
'de-CH': 'de',
'fr-CH': 'fr'
}
})
const i18n = i18nManager.locale('de-CH')
i18n.fallbackLocale // de (using fallback collection)
const i18n = i18nManager.locale('fr-CH')
i18n.fallbackLocale // fr (using fallback collection)
const i18n = i18nManager.locale('en')
i18n.fallbackLocale // en (using default locale)

Missing translations

If a translation is missing in the main and the fallback locales, the .t method will return an error string formatted as follows.

const i18n = i18nManager.locale('en')
i18n.t('messages.hero_title')
// translation missing: en, messages.hero_title

You can replace this message with a different message or an empty string by defining a fallback value as the second parameter.

const fallbackValue = ''
i18n.t('messages.hero_title', fallbackValue)
// output: ''

You may also compute a fallback value globally via the config file. The fallback method receives the translation path as the first parameter and the locale code as the second parameter. Make sure always to return a string value.

import { defineConfig } from '@adonisjs/i18n'
export default defineConfig({
fallback: (identifier, locale) => {
return ''
},
})

Detecting user locale during an HTTP request

During the initial setup, we create a detect_user_locale_middleware.ts file inside the ./app/middleware directory. The middleware performs the following actions.

  • Detect the locale of the request using the Accept-language header.

  • Create an instance of the I18n class for the request locale and share it with the rest of the request pipeline using the HTTP Context.

  • Share the same instance with Edge templates as a global i18n property.

  • Finally, hook into the Request validator and provide validation messages using translation files.

If this middleware is active, you can translate messages inside your controllers and Edge templates as follows.

import { HttpContext } from '@adonisjs/core/http'
export default class PostsControlle {
async store({ i18n, session }: HttpContext) {
session.flash('success', {
message: i18n.t('post.created')
})
}
}
<h1> {{ t('messages.heroTitle') }} </h1>

Changing the user language detection code

Since the detect_user_locale_middleware is part of your application codebase, you may modify the getRequestLocale method and use custom logic to find the user language.

Translating validation messages

The detect_user_locale_middleware hooks into the Request validator and provides validation messages using the translation files.

export default class DetectUserLocaleMiddleware {
static {
RequestValidator.messagesProvider = (ctx) => {
return ctx.i18n.createMessagesProvider()
}
}
}

The translations must be stored inside the validator.json file under the shared key. The validation messages can be defined for the validation rule or the field + rule combination.

resources/lang/en/validator.json
{
"shared": {
"fields": {
"first_name": "first name"
},
"messages": {
"required": "Enter {field}",
"username.required": "Choose a username for your account",
"email": "The email must be valid"
}
}
}
resources/lang/fr/validator.json
{
"shared": {
"fields": {
"first_name": "Prénom"
},
"messages": {
"required": "Remplisser le champ {field}",
"username.required": "Choissisez un nom d'utilisateur pour votre compte",
"email": "L'email doit être valide"
}
}
}

Using translations with VineJS directly

During an HTTP request, the detect_user_locale_middleware hooks into the Request validator and registers a custom messages provider to lookup validation errors from translation files.

However, if you use VineJS outside of an HTTP request, in Ace commands or queue jobs, you must explicitly register a custom messages provider when calling the validator.validate method.

import { createJobValidator } from '#validators/jobs'
import i18nManager from '@adonisjs/i18n/services/main'
/**
* Get an i18n instance for a specific locale
*/
const i18n = i18nManager.locale('fr')
await createJobValidator.validate(data, {
/**
* Register a messages provider for using
* translations
*/
messagesProvider: i18n.createMessagesProvider()
})

ICU message format

Interpolation

The ICU messages syntax uses a single curly brace for referencing dynamic values. For example:

The ICU messages syntax does not support nested data sets, and hence, you can only access properties from a flat object during interpolation.

{
"greeting": "Hello { username }"
}
{{ t('messages.greeting', { username: 'Virk' }) }}

You can also write HTML within the messages. However, use three curly braces within the Edge templates to render HTML without escaping it.

{
"greeting": "<p> Hello { username } </p>"
}
{{{ t('messages.greeting', { username: 'Virk' }) }}}

Number format

You can format numeric values within the translation messages using the {key, type, format} syntax. In the following example:

  • The amount is the runtime value.
  • The number is the formatting type.
  • And the ::currency/USD is the currency format with a number skeleton
{
"bagel_price": "The price of this bagel is {amount, number, ::currency/USD}"
}
{{ t('bagel_price', { amount: 2.49 }) }}
The price of this bagel is $2.49

The following are examples of using the number format with different formatting styles and number skeletons.

Length of the pole: {price, number, ::measure-unit/length-meter}
Account balance: {price, number, ::currency/USD compact-long}

Date/time format

You may format the Date instances or the luxon DateTime instances using the {key, type, format} syntax. In the following example:

  • The expectedDate is the runtime value.
  • The date is the formatting type.
  • And the medium is the date format.
{
"shipment_update": "Your package will arrive on {expectedDate, date, medium}"
}
{{ t('shipment_update', { expectedDate: luxonDateTime }) }}
Your package will arrive on Oct 16, 2023

You can use the time format to format the value as a time.

{
"appointment": "You have an appointment today at {appointmentAt, time, ::h:m a}"
}
You have an appointment today at 2:48 PM

ICU provides a wide array of patterns to customize the date-time format. However, not all of them are available via ECMA402's Intl API. Therefore, we only support the following patterns.

SymbolDescription
GEra designator
yyear
Mmonth in year
Lstand-alone month in year
dday in month
Eday of week
elocal day of week e..eee is not supported
cstand-alone local day of week c..ccc is not supported
aAM/PM marker
hHour [1-12]
HHour [0-23]
KHour [0-11]
kHour [1-24]
mMinute
sSecond
zTime Zone

Plural rules

ICU message syntax has first-class support for defining the plural rules within your messages. For example:

In the following example, we use YAML over JSON since writing multiline text in YAML is easier.

cart_summary:
"You have {itemsCount, plural,
=0 {no items}
one {1 item}
other {# items}
} in your cart"
{{ t('messages.cart_summary', { itemsCount: 1 }) }}
You have 1 item in your cart

The # is a special token to be used as a placeholder for the numeric value. It will be formatted as {key, number}.

{{ t('messages.cart_summary', { itemsCount: 1000 }) }}
{{-- Output --}}
{{-- You have 1,000 items in your cart --}}

The plural rule uses the {key, plural, matches} syntax. The matches is a literal value matched to one of the following plural categories.

CategoryDescription
zeroThis category is used for languages with grammar specialized specifically for zero number of items. (Examples are Arabic and Latvian)
oneThis category is used for languages with grammar explicitly specialized for one item. Many languages, but not all, use this plural category. (Many popular Asian languages, such as Chinese and Japanese, do not use this category.)
twoThis category is used for languages that have grammar explicitly specialized for two items. (Examples are Arabic and Welsh.)
fewThis category is used for languages with grammar explicitly specialized for a small number of items. For some languages, this is used for 2-4 items, for some 3-10 items, and other languages have even more complex rules.
manyThis category is used for languages with specialized grammar for a more significant number of items. (Examples are Arabic, Polish, and Russian.)
otherThis category is used if the value doesn't match one of the other plural categories. Note that this is used for "plural" for languages (such as English) that have a simple "singular" versus "plural" dichotomy.
=valueThis is used to match a specific value regardless of the plural categories of the current locale.

The table's content is referenced from formatjs.io

Select

The select format lets you choose the output by matching a value against one of the many choices. Writing gender-specific text is an excellent example of the select format.

Yaml
auto_reply:
"{gender, select,
male {He}
female {She}
other {They}
} will respond shortly."
{{ t('messages.auto_reply', { gender: 'female' }) }}
She will respond shortly.

Select ordinal

The select ordinal format allows you to choose the output based on the ordinal pluralization rules. The format is similar to the select format. However, the value is mapped to an ordinal plural category.

anniversary_greeting:
"It's my {years, selectordinal,
one {#st}
two {#nd}
few {#rd}
other {#th}
} anniversary"
{{ t('messages.anniversary_greeting', { years: 2 }) }}
It's my 2nd anniversary

The select ordinal format uses the {key, selectordinal, matches} syntax. The match is a literal value and is matched to one of the following plural categories.

CategoryDescription
zeroThis category is used for languages with grammar specialized specifically for zero number of items. (Examples are Arabic and Latvian.)
oneThis category is used for languages with grammar explicitly specialized for one item. Many languages, but not all, use this plural category. (Many popular Asian languages, such as Chinese and Japanese, do not use this category.)
twoThis category is used for languages that have grammar explicitly specialized for two items. (Examples are Arabic and Welsh.)
fewThis category is used for languages with grammar explicitly specialized for a small number of items. For some languages, this is used for 2-4 items, for some 3-10 items, and other languages have even more complex rules.
manyThis category is used for languages with specialized grammar for a larger number of items. (Examples are Arabic, Polish, and Russian.)
otherThis category is used if the value doesn't match one of the other plural categories. Note that this is used for "plural" for languages (such as English) that have a simple "singular" versus "plural" dichotomy.
=valueThis is used to match a specific value regardless of the plural categories of the current locale.

The table's content is referenced from formatjs.io

Formatting values

The following methods under the hood use the Node.js Intl API but have better performance. See benchmarks

formatNumber

Format a numeric value using the Intl.NumberFormat class. You may pass the following arguments.

  1. The value to format.
  2. An optional options object.
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatNumber(123456.789, {
maximumSignificantDigits: 3
})

formatCurrency

Format a numeric value as a currency using the Intl.NumberFormat class. The formatCurrency method implicitly defines the style = currency option.

import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatCurrency(200, {
currency: 'USD'
})

formatDate

Format a date or a luxon date-time object using the Intl.DateTimeFormat class. You may pass the following arguments.

  1. The value to format. It could be a Date object or a luxon DateTime object.
  2. An optional options object.
import i18nManager from '@adonisjs/i18n/services/main'
import { DateTime } from 'luxon'
i18nManager
.locale('en')
.formatDate(new Date(), {
dateStyle: 'long'
})
// Format luxon date time instance
i18nManager
.locale('en')
.formatDate(DateTime.local(), {
dateStyle: 'long'
})

formatTime

Format a date value to a time string using the Intl.DateTimeFormat class. The formatTime method implicitly defines the timeStyle = medium option.

import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatTime(new Date())

formatRelativeTime

The formatRelativeTime method uses the Intl.RelativeTimeFormat class to format a value to a relative time representation string. The method accepts the following arguments.

import { DateTime } from 'luxon'
import i18nManager from '@adonisjs/i18n/services/main'
const luxonDate = DateTime.local().plus({ hours: 2 })
i18nManager
.locale('en')
.formatRelativeTime(luxonDate, 'hours')

Set the unit's value to auto to display the diff in the best matching unit.

const luxonDate = DateTime.local().plus({ hours: 2 })
I18n
.locale('en')
.formatRelativeTime(luxonDate, 'auto')
// In 2 hours 👈
const luxonDate = DateTime.local().plus({ hours: 200 })
I18n
.locale('en')
.formatRelativeTime(luxonDate, 'auto')
// In 8 days 👈

formatPlural

Find the plural category for a number using the Intl.PluralRules class. You may pass the following arguments.

  1. The numeric value for which to find the plural category.
  2. An optional options object.
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager.i18nManager('en').formatPlural(0)
// other
i18nManager.i18nManager('en').formatPlural(1)
// one
i18nManager.i18nManager('en').formatPlural(2)
// other

formatList

Format an array of strings to a sentence using the Intl.ListFormat class. You may pass the following arguments.

  1. The value to format.
  2. An optional options object.
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatList(['Me', 'myself', 'I'], { type: 'conjunction' })
// Me, myself and I
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatList(['5 hours', '3 minutes'], { type: 'unit' })
// 5 hours, 3 minutes

formatDisplayNames

Format currency, language, region, and calendar codes to their display names using the Intl.DisplayNames class. You may pass the following arguments.

  1. The code to format. The value of code varies depending on the type of formatting.
  2. Options object.
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatDisplayNames('INR', { type: 'currency' })
// Indian Rupee
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatDisplayNames('en-US', { type: 'language' })
// American English

Configuring the i18n Ally VSCode extension

The i18n Ally extension for VSCode provides an excellent workflow for storing, inspecting, and referencing translations with your code editor.

To make the extension work seamlessly with AdonisJS, you must create the following files inside the .vscode directory of your project root.

mkdir .vscode
touch .vscode/i18n-ally-custom-framework.yml
touch .vscode/settings.json

Copy/paste the following contents inside the settings.json file.

.vscode/settings.json
{
"i18n-ally.localesPaths": [
"resources/lang"
],
"i18n-ally.keystyle": "nested",
"i18n-ally.namespace": true,
"i18n-ally.editor.preferEditor": true,
"i18n-ally.refactor.templates": [
{
"templates": [
"{{ t('{key}'{args}) }}"
],
"include": [
"**/*.edge",
],
},
]
}

Copy/paste the following contents inside the .vscode/i18n-ally-custom-framework.yml file.

.vscode/i18n-ally-custom-framework.yml
languageIds:
- edge
usageMatchRegex:
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
sortKeys: true

Listening for missing translations event

You may listen to the i18n:missing:translation event to get notified about the missing translations in your app.

import emitter from '@adonisjs/core/services/emitter'
emitter.on('i18n:missing:translation', function (event) {
console.log(event.identifier)
console.log(event.hasFallback)
console.log(event.locale)
})

Force reloading translations

The @adonisjs/i18n package reads the translation files when booting the application and stores them within the memory for quick access.

However, if you modify the translation files while your application is running, you may use the reloadTranslations method to refresh the in-memory cache.

import i18nManager from '@adonisjs/i18n/services/main'
await i18nManager.reloadTranslations()

Creating a custom translation loader

A translations loader is responsible for loading translations from a persistent store. We ship with a file system loader and provide an API to register custom loaders.

A loader must implement the TranslationsLoaderContract interface and define the load method that returns an object with key-value pair. The key is the locale code, and the value is a flat object with a list of translations.

import type {
LoaderFactory,
TranslationsLoaderContract,
} from '@adonisjs/i18n/types'
/**
* Type for the configuration
*/
export type DbLoaderConfig = {
connection: string
tableName: string
}
/**
* Loader implementation
*/
export class DbLoader implements TranslationsLoaderContract {
constructor(public config: DbLoaderConfig) {
}
async load() {
return {
en: {
'messages.greeting': 'Hello world',
},
fr: {
'messages.greeting': 'Bonjour le monde',
}
}
}
}
/**
* Factory function to reference the loader
* inside the config file.
*/
export function dbLoader(config: DbLoaderConfig): LoaderFactory {
return () => {
return new DbLoader(config)
}
}

In the above code example, we export the following values.

  • DbLoaderConfig: TypeScript type for the configuration you want to accept.
  • DbLoader: The loaders's implementation as a class. It must adhere to the TranslationsLoaderContract interface.
  • dbLoader: Finally, a factory function to reference the loader inside the config file.

Using the loader

Once the loader has been created, you can reference it inside the config file using the dbLoader factory function.

import { defineConfig } from '@adonisjs/i18n'
import { dbLoader } from 'my-custom-package'
const i18nConfig = defineConfig({
loaders: [
dbLoader({
connection: 'pg',
tableName: 'translations'
})
]
})

Creating a custom translation formatter

Translation formatters are responsible for formatting the translations as per a specific format. We ship with an implementation for the ICU message syntax and provide additional APIs to register custom formatters.

A formatter must implement the TranslationsFormatterContract interface and define the format method to format a translation message.

import type {
FormatterFactory,
TranslationsLoaderContract,
} from '@adonisjs/i18n/types'
/**
* Formatter implementation
*/
export class FluentFormatter implements TranslationsFormatterContract {
format(
message: string,
locale: string,
data?: Record<string, any>
): string {
// return formatted value
}
}
/**
* Factory function to reference the formatter
* inside the config file.
*/
export function fluentFormatter(): FormatterFactory {
return () => {
return new FluentFormatter()
}
}

Using the formatter

Once the formatter has been created, you can reference it inside the config file using the fluentFormatter factory function.

import { defineConfig } from '@adonisjs/i18n'
import { fluentFormatter } from 'my-custom-package'
const i18nConfig = defineConfig({
formatter: fluentFormatter()
})