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 a 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 and configure the package using the following command :
node ace add @adonisjs/i18n
-
Installs the
@adonisjs/i18n
package using the detected package manager. -
Registers the following service provider inside the
adonisrc.ts
file.{providers: [// ...other providers() => import('@adonisjs/i18n/i18n_provider')]} -
Creates the
config/i18n.ts
file. -
Creates
detect_user_locale_middleware
inside themiddleware
directory. -
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: formatters.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: formatters.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 ofsupportedLocales
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.
{
"greeting": "Hello world"
}
{
"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.
{
"shared": {
"fields": {
"first_name": "first name"
},
"messages": {
"required": "Enter {field}",
"username.required": "Choose a username for your account",
"email": "The email must be valid"
}
}
}
{
"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.
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 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.
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 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. |
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.
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.
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. |
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.
- The value to format.
- 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.
- The value to format. It could be a Date object or a luxon DateTime object.
- 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.
- The value to format.
- The formatting unit. Along with the officially supported units, we also support an additional
auto
unit. - An optional options object.
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.
- The numeric value for which to find the plural category.
- 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.
- The value to format.
- 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.
- The code to format. The value of
code
varies depending on thetype
of formatting. - 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.
{
"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.
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 theTranslationsLoaderContract
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()
})