Internationalization
The @adonisjs/i18n
official package adds support for internationalization and localization
to your AdonisJS applications.
- The internationalization helpers allow you to perform language-sensitive formatting of specific values such as date, currency, and name.
- The localization layer allows you to store translations and reference them within the Edge templates, validation errors, auth exceptions, and so on.
The I18n (shorthand for Internationalization) package must be installed and configured separately.
npm i @adonisjs/i18n
node ace configure @adonisjs/i18n
# CREATE: app/Middleware/DetectUserLocale.ts
# CREATE: ./resources/lang
# CREATE: config/i18n.ts
# UPDATE: .adonisrc.json { providers += "@adonisjs/i18n" }
- Helpers to perform language-sensitive formatting for dates, currencies, names, and so on.
- Support for storing translations in ICU messages format .
- Add your custom messages formatter and translations loader.
Usage
Following is a basic example of importing the installed package and formatting values.
The I18n.locale
method returns an instance of I18n
class for a specific locale. The locale code must be a valid ISO 639-1
standard language code.
import I18n from '@ioc:Adonis/Addons/I18n'
I18n.locale('en-US').formatDate(new Date())
// 10/8/2021
I18n.locale('fr').formatCurrency(100, { currency: 'EUR' })
// 100,00 €
const luxonDate = DateTime.local().minus({ minutes: 10 })
I18n.locale('pt').formatRelativeTime(luxonDate, 'auto')
// há 10 minutos
You can make use of the formatMessage
method to format stored translations. The method accepts the message key as the first argument and the data as the second argument.
import I18n from '@ioc:Adonis/Addons/I18n'
I18n
.locale('en-US')
.formatMessage('messages.greeting', { name: 'Virk' })
Learn more about formatting translations →
Usage during HTTP requests
It is recommended to use the ctx.i18n
object during the HTTP requests. It is an isolated instance of I18n
class for the current request.
Route.get('/', async ({ i18n }) => {
return i18n.formatCurrency(100, { currency: 'EUR' })
})
By default, the locale of ctx.i18n
is set to the application default locale. Therefore, it is recommended to use the DetectUserLocale
middleware to find the user locale and update it for the rest of the request.
Config
The configuration is stored inside the config/i18n.ts
file. You can always find the up-to-date config stub on GitHub
.
import Application from '@ioc:Adonis/Core/Application'
import { I18nConfig } from '@ioc:Adonis/Addons/I18n'
const i18nConfig: I18nConfig = {
translationsFormat: 'icu',
defaultLocale: 'en',
// Optional
supportedLocales: [],
fallbackLocales: {},
provideValidatorMessages: true,
loaders: {
fs: {
enabled: true,
location: Application.resourcesPath('lang'),
},
},
}
export default i18nConfig
translationsFormat
The format to be used for formatting translations. Officially only the ICU messages format is supported.
defaultLocale
The defaultLocale
is the default language of your application. It is always static and cannot be changed at runtime. We look up translations from the default locale when the current user language is not supported, and also, there is no fallback available.
const i18nConfig: I18nConfig = {
defaultLocale: 'en'
}
supportedLocales
It is an array of ISO 639-1 formatted language codes that your application supports. If the user language is not mentioned inside this array, we will use the defaultLocale
to look up translations.
You can optionally define the supportedLocales
inside the config file. Otherwise, we will infer the supported locales from the language directories you have created inside the resources/lang
directory.
const i18nConfig: I18nConfig = {
supportedLocales: ['fr', 'en', 'it']
}
fallbackLocales
The fallbackLocales
is a key-value pair of the locales that your application supports along with their fallback locales.
For example: Using Spanish as a fallback for the Catalan language makes more sense than using English. Therefore, you can define the fallback locales yourself.
The locale for which you have defined the fallback should be part of the supportedLocales
array.
const i18nConfig: I18nConfig = {
fallbackLocales: {
ca: 'es'
}
}
provideValidatorMessages
Enable/disable the support for providing validator messages through translation files. The messages are provided when the flag is set to true
.
Learn more about translating validation messages .
loaders
The loaders
are used to load messages from some sort of storage. Officially we ship with an implementation of fs
loader that loads .json
or .yaml
files from the filesystem.
Locale matching
We allow you to define translations for a specific region or use a two-digit language code for generic support.
For example: If you store the translations for the French language inside the fr
directory, then all variations of the french language will see the same messages.
However, if you create region-specific directories such as fr-ca
or fr-ch
, the translations from the best matching locale will be served.
The style of locale matching is known as content negotiation. Instead of looking for the exact match, we negotiate for the closest match.
Finding the best matching locale
You should use the I18n.getSupportedLocale
method to find the best locale for the user language.
The method accepts a string or an array of user languages and returns the matching locale supported by your application. null
is returned when no match is found.
import I18n from '@ioc:Adonis/Addons/I18n'
const userLanguage = 'en-US'
const bestMatch = I18n.getSupportedLocale(userLanguage)
if (bestMatch) {
I18n.locale(bestMatch).formatMessage()
} else {
I18n.locale(I18n.defaultLocale).formatMessage()
}
Detecting user locale
You should use the DetectUserLocale
middleware stored inside the app/Middleware
directory to find the locale for the incoming HTTP request.
By default, the middleware uses the Accept-language HTTP header to find the language of the user's browser.
However, you can change the implementation of this middleware and use any strategy that fits your use case and application needs. Just keep the following points in mind.
- Make sure always to pass the user-selected locale to the
I18n.getSupportedLocale(userLocale)
method to find the best possible locale supported by your application. - If a match is found, call the
ctx.i18n.switchLocale(locale)
method to switch the locale for the rest of the request.
Also, make sure to register the middleware inside the start/kernel.ts
file.
Server.middleware.register([
// ... other middleware(s)
() => import('App/Middleware/DetectUserLocale')
])
Check out this example project that uses the in-application language switcher and sessions for managing the user preferred language.
Translations storage
The fs
(default) loader looks for the translations inside the resources/lang
directory. You must create a sub-directory for every locale that your application supports. For example:
The language directory must be named after a valid ISO 639-1 language code
resources/lang
├── en
└── fr
The loader will read all the .json
and .yaml
files. Also, feel free to create multiple sub-directories or files inside a language directory.
resources/lang
├── en
│ ├── emails.yaml
│ └── validator.json
└── fr
└── validator.json
{
"shared": {
"required": "Ce champ est requis"
}
}
welcome:
content: >-
<h2> Welcome to AdonisJS </h2>
<p> Click <a href="{ url }"> here </a> to verify your account </p>
Formatting translations
The icu
formatter lets you write translations using the ICU messages format
. It is an industry-standard format for writing translations and is supported by many translation services like Crowdin and Lokalise.
Given the following message inside the en/messages.json
file.
{
"title": "A fully featured web framework for Node.js."
}
You can render it as follows.
import I18n from '@ioc:Adonis/Addons/I18n'
I18n.locale('en').formatMessage('messages.title')
And render it inside templates using the t
helper method.
<h1> {{ t('messages.title') }} </h1>
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, do make sure to use three curly braces within the Edge templates to render HTML without escaping it.
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
Following are some examples 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 can 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, 2021
Similarly, you can use the time format to format time for the current locale.
{
"appointment": "You have an appointment today at {appointmentAt, time, ::h:m a}"
}
You have an appointment today at 2:48 PM
Available date/time skeletons
ICU provides a wide array of patterns to customize date-time format. However, not all of them are available via ECMA402's Intl API. Therefore, we only support the following patterns.
Symbol | Description |
---|---|
G | Era designator |
y | year |
M | month in year |
L | stand-alone month in year |
d | day in month |
E | day of week |
e | local day of week e..eee is not supported |
c | stand-alone local day of week c..ccc is not supported |
a | AM/PM marker |
h | Hour [1-12] |
H | Hour [0-23] |
K | Hour [0-11] |
k | Hour [1-24] |
m | Minute |
s | Second |
z | Time 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 it is easier to write multiline text in YAML.
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 -->
Available plural categories
The plural rule uses the {key, plural, matches}
syntax. The matches
is a literal value and is matched to one of the following plural categories.
Category | Description |
---|---|
zero | This category is used for languages with grammar specialized specifically for zero number of items. (Examples are Arabic and Latvian) |
one | This 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.) |
two | This category is used for languages that have grammar explicitly specialized for two items. (Examples are Arabic and Welsh.) |
few | This 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. |
many | This category is used for languages that have a specialized grammar for a more significant number of items. (Examples are Arabic, Polish, and Russian.) |
other | This 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. |
=value | This is used to match a specific value regardless of the plural categories of the current locale. |
Select
The select
format allows you to 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.
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 upon the ordinal pluralization rules. The format is similar to the plural
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
Available select ordinal categories
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.
Category | Description |
---|---|
zero | This category is used for languages with grammar specialized specifically for zero number of items. (Examples are Arabic and Latvian.) |
one | This 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.) |
two | This category is used for languages that have grammar explicitly specialized for two items. (Examples are Arabic and Welsh.) |
few | This 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. |
many | This category is used for languages with specialized grammar for a larger number of items. (Examples are Arabic, Polish, and Russian.) |
other | This 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. |
=value | This is used to match a specific value regardless of the plural categories of the current locale. |
Intl formatters
The Intl formatters are thin wrappers over the Node.js Intl API . Creating a new instance of the Intl classes is slow, so we memoize the constructors to speed up things. See benchmarks
formatNumber
The formatNumber
uses the Intl.NumberFormat
class to format a numeric value.
-
The first argument is the value to format. It must be a number, bigint, or a string representation of a number.
-
The second argument is the options. They are the same as the options accepted by the
Intl.NumberFormat
class.
import I18n from '@ioc:Adonis/Addons/I18n'
I18n
.locale('en')
.formatNumber(123456.789, {
maximumSignificantDigits: 3
})
formatCurrency
The formatCurrency
method uses the Intl.NumberFormat
class but implicitly sets the style
to currency.
import I18n from '@ioc:Adonis/Addons/I18n'
I18n
.locale('en')
.formatCurrency(200, {
currency: 'USD'
})
formatDate
The formatDate
method uses the Intl.DateTimeFormat
class to format a date.
-
The first argument is the date to format. It can be an
ISO date string
, atimestamp
, an instance of the JavaScriptDate
class, or a luxonDateTime
. -
The second argument is the options. They are the same as the options accepted by the
Intl.DateTimeFormat
class.
import I18n from '@ioc:Adonis/Addons/I18n'
I18n
.locale('en')
.formatDate(new Date(), {
dateStyle: 'long'
})
formatTime
The formatTime
method uses the Intl.DateTimeFormat
class, but implicitly sets the timeStyle
to medium.
import I18n from '@ioc:Adonis/Addons/I18n'
I18n
.locale('en')
.formatTime(new Date(), {
timeStyle: 'long'
})
formatRelativeTime
The formatRelativeTime
method using the Intl.RelativeTimeFormat
class to format a value to a relative time representation string.
-
The first argument is the value of the relative time. It can be an
ISO date string
, an absolute numeric diff, an instance of the JavaScriptDate
class, or an instance of luxonDateTime
. -
The second argument is the formatting unit. Along with the officially supported units , we also support an additional
auto
unit. -
The third argument is the options. They are the same as the options accepted by the
Intl.RelativeTimeFormat
class.
import { DateTime } from 'luxon'
import I18n from '@ioc:Adonis/Addons/I18n'
const luxonDate = DateTime.local().plus({ hours: 2 })
I18n
.locale('en')
.formatRelativeTime(luxonDate, 'hours')
We will find the best unit when using the formatting unit is set to auto
. For example:
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
The formatPlural
method uses the Intl.PluralRules
and returns a plural category for a given numeric value.
-
The first argument is the value. It must be a number or a string representation of a number.
-
The second argument is the options. They are the same as the options accepted by the
Intl.PluralRules
class.
import I18n from '@ioc:Adonis/Addons/I18n'
I18n.locale('en').formatPlural(0)
// other
I18n.locale('en').formatPlural(1)
// one
I18n.locale('en').formatPlural(2)
// other
Validator messages
Following are steps to configure the i18n
package to provide the validation messages from the translations files.
- Set the value of
provideValidatorMessages = true
inside the config file. - Create a
validator.json
file inside every language directory. - Define messages for the validation rules inside the
shared
object.
{
"shared": {
"required": "The value for the field is required",
"unique": "Email is already in use",
"minLength": "The field must have { minLength } items"
}
}
The messages from the shared
key are automatically provided to the validator. You can also be specific and define a message for a field + rule
combination. For example:
{
"shared": {
"required": "The value for the field is required",
"username.required": "Username is required to create an account"
}
}
Custom messages bag
If some part of your application needs specific validation messages, you can define them within the validator.json
file under a different top-level key and then reference them using the i18n.validatorMessages()
method.
{
"shared": {},
"contact": {
"email.required": "Enter the email so that we can contact you",
"message.required": "Describe your project in a few words."
}
}
Now, you can reference the messages from the contact
object on the validator as follows.
import { schema } from '@ioc:Adonis/Core/Validator'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
export default class ContactValidator {
constructor(protected ctx: HttpContextContract) {}
public schema = schema.create({})
public messages = this.ctx.i18n.validatorMessages('validator.contact')
}
Auth messages
You can also provide translations for the exceptions raised by the auth
package. The translations must be defined inside the auth.json
file using the exception code as the translation key.
The translations are used for the response text and not the error.message
property. They will still be in English and hardcoded.
{
"E_INVALID_AUTH_SESSION": "Your session has expired",
"E_INVALID_API_TOKEN": "Invalid or expired API token",
"E_INVALID_BASIC_CREDENTIALS": "Invalid credentials",
"E_INVALID_AUTH_UID": "Invalid credentials",
"E_INVALID_AUTH_PASSWORD": "Invalid credentials"
}
Translating emails
Since emails are usually sent in the background (outside of the HTTP request lifecycle), you must explicitly pass the i18n
instance to the email templates.
The t
helper method is an alias for the i18n.formatMessage
, it will format messages in the same language for which you created the i18n
class instance and passed it to the template state.
import Mail from '@ioc:Adonis/Addons/Mail'
import I18n from '@ioc:Adonis/Addons/I18n'
const i18n = I18n.locale(customerLocale)
await Mail.send((message) => {
message
.subject(i18n.formatMessage('emails.welcome_subject'))
.htmlView('emails/welcome', { i18n })
})
Reloading translations
The translations are loaded and cached within the memory on the application start. Therefore, any changes you make to the translation files are not reflected until you restart the process.
During development, the dev server will restart itself on file change. However, in production, you will have to restart the server manually (like a new deployment).
If for some reason, you want to reload the translations within the running process, then you can make use of the I18n.reloadTranslations()
method to do it.
import I18n from '@ioc:Adonis/Addons/I18n'
await I18n.reloadTranslations()
Reporting missing translations
To help you progressively add translations for new languages, we report the missing translations by emitting the i18n:missing:translation
event.
Create a new preload file start/i18n.ts
by running the following Ace command. Select all the environments.
node ace make:prldfile i18n
Open the newly created file and paste the following contents inside it. Currently, we are using the I18n.prettyPrint
method to log the message to the console. However, you can also use the logger to log the message.
import Event from '@ioc:Adonis/Core/Event'
import I18n from '@ioc:Adonis/Addons/I18n'
Event.on('i18n:missing:translation', I18n.prettyPrint)
Add custom message formatter
The message formatter defines the syntax and the capabilities of the stored translations. The package ships with an icu
formatter that uses the ICU messages syntax for writing translations.
However, you can also register a custom message formatter using the I18n.extend
method. The formatter implementation must adhere to the TranslationsFormatterContract
interface.
interface TranslationsFormatterContract {
readonly name: string
format(message: string, locale: string, data?: Record<string, any>): string
}
name
A unique name for the formatter. It will be a static string value.
format
The format method receives the following arguments and must return a formatted string.
- The first argument is the message text.
- The second argument is the
locale
for which the formatting should happen. - Finally, the data object for dynamic values.
Dummy implementation
Following is a very straightforward implementation that uses the Edge template engine for formatting translations.
Step 1. Create formatter class.
Create a new file MustacheFormatter.ts
within the providers
directory and paste the following contents inside it.
import type { ViewContract } from '@ioc:Adonis/Core/View'
import type { TranslationsFormatterContract } from '@ioc:Adonis/Addons/I18n'
export class MustacheFormatter implements TranslationsFormatterContract {
public readonly name = 'mustache'
constructor(private view: ViewContract) {}
public format(message: string, _: string, data?: Record<string, any>) {
return this.view.renderRawSync(message, data)
}
}
Step 2. Extend I18n and register the formatter
Open the providers/AppProvider.ts
file and register the formatter within the boot
method.
import { ApplicationContract } from '@ioc:Adonis/Core/Application'
import { MustacheFormatter } from './MustacheFormatter'
export default class AppProvider {
constructor(protected app: ApplicationContract) {}
public register() {
// Register your own bindings
}
public async boot() {
const I18n = this.app.container.resolveBinding('Adonis/Addons/I18n')
const View = this.app.container.resolveBinding('Adonis/Core/View')
I18n.extend('mustache', 'formatter', () => new MustacheFormatter(View))
}
public async ready() {
// App is ready
}
public async shutdown() {
// Cleanup, since app is going down
}
}
mustache
formatter
Step 3. Use the Update the config file and set the translationsFormat
to mustache.
{
translationsFormat: 'mustache'
}
Add custom messages loader
The message loader is responsible for loading the messages from a permanent source. The package ships with an fs
formatter that reads the .json
and .yaml
files from the file system.
However, you can also register custom loaders using the I18n.extend
method. The loader implementation must adhere to the LoaderContract
interface.
type Translations = {
[lang: string]: Record<string, string>
}
interface LoaderContract {
load(): Promise<Translations>
}
Loaders only need to implement a single method called load
that returns all the translations as an object.
The top-level keys of the object are the language codes, and the value is another object of messages.
{
en: {},
fr: {},
it: {}
}
Also, make sure to convert nested messages inside a language object to a flat object. For example:
{
en: {
'messages.title': '',
'messages.subtitle': ''
}
}
Dummy implementation
Following is a very straightforward implementation that reads the messages from the Database using Lucid.
Step 1. Create the loader class.
Create a new file DbLoader.ts
within the providers
directory and paste the following contents inside it.
import type { DatabaseContract } from '@ioc:Adonis/Lucid/Database'
import type {
Translations,
LoaderContract
} from '@ioc:Adonis/Addons/I18n'
export type DbLoaderConfig = {
enabled: boolean
table: string
}
export class DbLoader implements LoaderContract {
constructor(private db: DatabaseContract, private config: DbLoaderConfig) {}
public async load() {
const rows = await this.db.from(this.config.table)
return rows.reduce<Translations>((result, row) => {
result[row.locale] = result[row.locale] || {}
result[row.locale][row.key] = row.message
return result
}, {})
}
}
Step 2. Extend I18n and register the loader
Open the providers/AppProvider.ts
file and register the loader within the boot
method.
import { ApplicationContract } from '@ioc:Adonis/Core/Application'
import { DbLoader } from './DbLoader'
export default class AppProvider {
constructor(protected app: ApplicationContract) {}
public register() {
// Register your own bindings
}
public async boot() {
const I18n = this.app.container.resolveBinding('Adonis/Addons/I18n')
const Db = this.app.container.resolveBinding('Adonis/Lucid/Database')
I18n.extend('db', 'loader', (_, config) => {
return new DbLoader(Db, config)
})
}
public async ready() {
// App is ready
}
public async shutdown() {
// Cleanup, since app is going down
}
}
db
loader
Step 3. Use the Update the config file and add the db
loader key to the loaders
object.
{
loaders: {
fs: {},
db: {
enabled: true,
table: 'translations'
}
}
}
Step 4. Create translations table
Use the following migration to create the translations table.
When running the migration, you will have to disable the db
loader inside the config file. Otherwise, the loader will attempt to read the messages from a non-existing table.
import BaseSchema from '@ioc:Adonis/Lucid/Schema'
export default class Translations extends BaseSchema {
protected tableName = 'translations'
public async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('locale', 8).notNullable()
table.string('key').notNullable()
table.text('message', 'longtext').notNullable()
table.timestamp('created_at', { useTz: true })
table.timestamp('updated_at', { useTz: true })
table.unique(['locale', 'key'])
})
}
public async down() {
this.schema.dropTable(this.tableName)
}
}
Additional reading
Make sure to read the API reference guide to view all the available properties and methods.