In my previous article I covered Stimulus—a modest JavaScript framework created by Basecamp. Today I'll talk about internationalizing a Stimulus application, since the framework does not provide any I18n tools out of the box. Internationalization is an important step, especially when your app is used by people from all around the world, so a basic understanding of how to do it may really come in handy.
Of course, it is up to you to decide which internationalization solution to implement, be it jQuery.I18n, Polyglot, or some other. In this tutorial I would like to show you a popular I18n framework called I18next that has lots of cool features and provides many additional third-party plugins to simplify the development process even further. Even with all these features, I18next is not a complex tool, and you don't need to study lots of documentation to get started.
In this article, you will learn how to enable I18n support in Stimulus applications with the help of the I18next library. Specifically, we'll talk about:
- I18next configuration
- translation files and loading them asynchronously
- performing translations and translating the whole page in one go
- working with plurals and gender information
- switching between locales and persisting the chosen locale in the GET parameter
- setting locale based on the user's preferences
The source code is available in the tutorial GitHub repo.
Bootstrapping a Stimulus App
In order to get started, let's clone the Stimulus Starter project and install all the dependencies using the Yarn package manager:
git clone https://github.com/stimulusjs/stimulus-starter.git cd stimulus-starter yarn install
We're going to build a simple web application that loads information about the registered users. For each user, we'll display his/her login and the number of photos he or she has uploaded so far (it does not really matter what these photos are).
Also, we are going to present a language switcher at the top of the page. When a language is chosen, the interface should be translated right away without page reloads. Moreover, the URL should be appended with a ?locale
GET parameter specifying which locale is currently being utilized. Of course, if the page is loaded with this parameter already provided, the proper language should be set automatically.
Okay, let's proceed to rendering our users. Add the following line of code to the public/index.html file:
<div data-controller="users" data-users-url="/api/users/index.json"></div>
Here, we are using the users
controller and providing a URL from which to load our users. In a real-world application, we would probably have a server-side script that fetches users from the database and responds with JSON. For this tutorial, however, let's simply place all the necessary data into the public/api/users/index.json file:
[ { "login": "johndoe", "photos_count": "15", "gender": "male" }, { "login": "annsmith", "photos_count": "20", "gender": "female" } ]
Now create a new src/controllers/users_controller.js file:
import { Controller } from "stimulus" export default class extends Controller { connect() { this.loadUsers() } }
As soon as the controller is connected to the DOM, we are asynchronously loading our users with the help of the loadUsers()
method:
loadUsers() { fetch(this.data.get("url")) .then(response => response.text()) .then(json => { this.renderUsers(json) }) }
This method sends a fetch request to the given URL, grabs the response, and finally renders the users:
renderUsers(users) { let content = '' JSON.parse(users).forEach((user) => { content += `<div>Login: ${user.login}<br>Has uploaded ${user.photos_count} photo(s)</div><hr>` }) this.element.innerHTML = content }
renderUsers()
, in turn, parses JSON, constructs a new string with all the content, and lastly displays this content on the page (this.element
is going to return the actual DOM node that the controller is connected to, which is div
in our case).
I18next
Now we are going to proceed to integrating I18next into our app. Add two libraries to our project: I18next itself and a plugin to enable asynchronous loading of translation files from the back end:
yarn add i18next i18next-xhr-backend
We are going to store all I18next-related stuff in a separate src/i18n/config.js file, so create it now:
import i18next from 'i18next' import I18nXHR from 'i18next-xhr-backend' const i18n = i18next.use(I18nXHR).init({ fallbackLng: 'en', whitelist: ['en', 'ru'], preload: ['en', 'ru'], ns: 'users', defaultNS: 'users', fallbackNS: false, debug: true, backend: { loadPath: '/i18n//.json', } }, function(err, t) { if (err) return console.error(err) }); export { i18n as i18n }
Let's go from top to bottom to understand what's going on here:
use(I18nXHR)
enables the i18next-xhr-backend plugin.fallbackLng
tells it to use English as a fallback language.whitelist
allows only English and Russian languages to be set. Of course, you may choose any other languages.preload
instructs translation files to be preloaded from the server, rather than loading them when the corresponding language is selected.ns
means "namespace" and accepts either a string or an array. In this example we have only one namespace, but for larger applications you may introduce other namespaces, likeadmin
,cart
,profile
, etc. For each namespace, a separate translation file should be created.defaultNS
setsusers
to be the default namespace.fallbackNS
disables namespace fallback.debug
allows debugging information to be displayed in the browser's console. Specifically, it says which translation files are loaded, which language is selected, etc. You will probably want to disable this setting before deploying the application to production.backend
provides configuration for the I18nXHR plugin and specifies where to load translations from. Note that the path should contain the locale's title, whereas the file should be named after the namespace and have the .json extensionfunction(err, t)
is the callback to run when I18next is ready (or when an error was raised).
Next, let's craft translation files. Translations for the Russian language should be placed into the public/i18n/ru/users.json file:
{ "login": "Логин" }
login
here is the translation key, whereas Логин
is the value to display.
English translations, in turn, should go to the public/i18n/en/users.json file:
{ "login": "Login" }
To make sure that I18next works, you may add the following line of code to the callback inside the i18n/config.js file:
// config goes here... function(err, t) { if (err) return console.error(err) console.log(i18n.t('login')) }
Here, we are using a method called t
that means "translate". This method accepts a translation key and returns the corresponding value.
However, we may have many parts of the UI that need to be translated, and doing so by utilizing the t
method would be quite tedious. Instead, I suggest that you use another plugin called loc-i18next that allows you to translate multiple elements at once.
Translating in One Go
Install the loc-i18next plugin:
yarn add loc-i18next
Import it at the top of the src/i18n/config.js file:
import locI18next from 'loc-i18next'
Now provide the configuration for the plugin itself:
// other config const loci18n = locI18next.init(i18n, { selectorAttr: 'data-i18n', optionsAttr: 'data-i18n-options', useOptionsAttr: true }); export { loci18n as loci18n, i18n as i18n }
There are a couple of things to note here:
locI18next.init(i18n)
creates a new instance of the plugin based on the previously defined instance of I18next.selectorAttr
specifies which attribute to use to detect elements that require localization. Basically, loc-i18next is going to search for such elements and use the value of thedata-i18n
attribute as the translation key.optionsAttr
specifies which attribute contains additional translation options.useOptionsAttr
instructs the plugin to use the additional options.
Our users are being loaded asynchronously, so we have to wait until this operation is done and only perform localization after that. For now, let's simply set a timer that should wait for two seconds before calling the localize()
method—that's a temporary hack, of course.
import { loci18n } from '../i18n/config' // other code... loadUsers() { fetch(this.data.get("url")) .then(response => response.text()) .then(json => { this.renderUsers(json) setTimeout(() => { // <--- this.localize() }, '2000') }) }
Code the localize()
method itself:
localize() { loci18n('.users') }
As you see, we only need to pass a selector to the loc-i18next plugin. All elements inside (that have the data-i18n
attribute set) will be localized automatically.
Now tweak the renderUsers
method. For now, let's only translate the "Login" word:
renderUsers(users) { let content = '' JSON.parse(users).forEach((user) => { content += `<div class="users">ID: ${user.id}<br><span data-i18n="login"></span>: ${user.login}<br>Has uploaded ${user.photos_count} photo(s)</div><hr>` }) this.element.innerHTML = content }
Nice! Reload the page, wait for two seconds, and make sure that the "Login" word appears for each user.
Plurals and Gender
We have localized part of the interface, which is really cool. Still, each user has two more fields: the number of uploaded photos and gender. Since we can't predict how many photos each user is going to have, the "photo" word should be pluralized properly based on the given count. In order to do this, we'll require a data-i18n-options
attribute configured previously. To provide the count, data-i18n-options
should be assigned with the following object: { "count": YOUR_COUNT }
.
Gender information should be taken into consideration as well. The word "uploaded" in English can be applied to both male and female, but in Russian it becomes either "загрузил" or "загрузила", so we need data-i18n-options
again, which has { "context": "GENDER" }
as a value. Note, by the way, that you can employ this context to achieve other tasks, not only to provide gender information.
renderUsers(users) { let content = '' JSON.parse(users).forEach((user) => { content += `<div class="users"><span data-i18n="login"></span>: ${user.login}<br><span data-i18n="uploaded" data-i18n-options="{ 'context': '${user.gender}' }"></span> <span data-i18n="photos" data-i18n-options="{ 'count': ${user.photos_count} }"></span></div><hr>` }) this.element.innerHTML = content }
Now update the English translations:
{ "login": "Login", "uploaded": "Has uploaded", "photos": "one photo", "photos_plural": " photos" }
Nothing complex here. Since for English we don't care about the gender information (which is the context), the translation key should be simply uploaded
. To provide properly pluralized translations, we are using the photos
and photos_plural
keys. The part is interpolation and will be replaced with the actual number.
As for the Russian language, things are more complex:
{ "login": "Логин", "uploaded_male": "Загрузил уже", "uploaded_female": "Загрузила уже", "photos_0": "одну фотографию", "photos_1": " фотографии", "photos_2": " фотографий" }
First of all, note that we have both uploaded_male
and uploaded_female
keys for two possible contexts. Next, pluralization rules are also more complex in Russian than in English, so we have to provide not two, but three possible phrases. I18next supports many languages out of the box, and this small tool can help you to understand which pluralization keys should be specified for a given language.
Switching Locale
We are done with translating our application, but users should be able to switch between locales. Therefore, add a new "language switcher" component to the public/index.html file:
<ul data-controller="languages" class="language-switcher"></ul>
Craft the corresponding controller inside the src/controllers/languages_controller.js file:
import { Controller } from "stimulus" import { i18n, loci18n } from '../i18n/config' export default class extends Controller { initialize() { let languages = [ {title: 'English', code: 'en'}, {title: 'Русский', code: 'ru'} ] this.element.innerHTML = languages.map((lang) => { return `<li data-action="click->languages#switchLanguage" data-lang="${lang.code}">${lang.title}</li>` }).join('') } }
Here we are using the initialize()
callback to display a list of supported languages. Each li
has a data-action
attribute which specifies what method (switchLanguage
, in this case) should be triggered when the element is clicked on.
Now add the switchLanguage()
method:
switchLanguage(e) { this.currentLang = e.target.getAttribute("data-lang") }
It simply takes the target of the event and grabs the value of the data-lang
attribute.
I would also like to add a getter and setter for the currentLang
attribute:
get currentLang() { return this.data.get("currentLang") } set currentLang(lang) { if(i18n.language !== lang) { i18n.changeLanguage(lang) } if(this.currentLang !== lang) { this.data.set("currentLang", lang) loci18n('body') this.highlightCurrentLang() } }
The getter is very simple—we fetch the value of the currently used language and return it.
The setter is more complex. First of all, we use the changeLanguage
method if the currently set language is not equal to the selected one. Also, we are storing the newly selected locale under the data-current-lang
attribute (which is accessed in the getter), localizing the body of the HTML page using the loc-i18next plugin, and lastly highlighting the currently used locale.
Let's code the highlightCurrentLang()
:
highlightCurrentLang() { this.switcherTargets.forEach((el, i) => { el.classList.toggle("current", this.currentLang === el.getAttribute("data-lang")) }) }
Here we are iterating over an array of locale switchers and comparing the values of their data-lang
attributes to the value of the currently used locale. If the values match, the switcher is assigned with a current
CSS class, otherwise this class is removed.
To make the this.switcherTargets
construct work, we need to define Stimulus targets in the following way:
static targets = [ "switcher" ]
Also, add data-target
attributes with values of switcher
for the li
s:
initialize() { // ... this.element.innerHTML = languages.map((lang) => { return `<li data-action="click->languages#switchLanguage" data-target="languages.switcher" data-lang="${lang.code}">${lang.title}</li>` }).join('') // ... }
Another important thing to consider is that translation files may take some time to load, and we must wait for this operation to complete before allowing the locale to be switched. Therefore, let's take advantage of the loaded
callback:
initialize() { i18n.on('loaded', (loaded) => { // <--- let languages = [ {title: 'English', code: 'en'}, {title: 'Русский', code: 'ru'} ] this.element.innerHTML = languages.map((lang) => { return `<li data-action="click->languages#switchLanguage" data-target="languages.switcher" data-lang="${lang.code}">${lang.title}</li>` }).join('') this.currentLang = i18n.language }) }
Lastly, don't forget to remove setTimeout
from the loadUsers()
method:
loadUsers() { fetch(this.data.get("url")) .then(response => response.text()) .then(json => { this.renderUsers(json) this.localize() }) }
Persisting Locale in the URL
After the locale is switched, I would like to add a ?lang
GET parameter to the URL containing the code of the chosen language. Appending a GET param without reloading the page can be easily done with the help of the History API:
set currentLang(lang) { if(i18n.language !== lang) { i18n.changeLanguage(lang) window.history.pushState(null, null, `?lang=${lang}`) // <--- } if(this.currentLang !== lang) { this.data.set("currentLang", lang) loci18n('body') this.highlightCurrentLang() } }
Detecting Locale
The last thing we are going to implement today is the ability to set the locale based on the user's preferences. A plugin called LanguageDetector can help us to solve this task. Add a new Yarn package:
yarn add i18next-browser-languagedetector
Import LanguageDetector
inside the i18n/config.js file:
import LngDetector from 'i18next-browser-languagedetector'
Now tweak the configuration:
const i18n = i18next.use(I18nXHR).use(LngDetector).init({ // <--- // other options go here... detection: { order: ['querystring', 'navigator', 'htmlTag'], lookupQuerystring: 'lang', } }, function(err, t) { if (err) return console.error(err) });
The order
option lists all the techniques (sorted by their importance) that the plugin should try in order to "guess" the preferred locale:
querystring
means checking a GET param containing the locale's code.lookupQuerystring
sets the name of the GET param to use, which islang
in our case.navigator
means getting locale data from the user's request.htmlTag
involves fetching the preferred locale from thelang
attribute of thehtml
tag.
Conclusion
In this article we have taken a look at I18next—a popular solution to translate JavaScript applications with ease. You have learned how to integrate I18next with the Stimulus framework, configure it, and load translation files in an asynchronous manner. Also, you've seen how to switch between locales and set the default language based on the user's preferences.
I18next has some additional configuration options and many plugins, so be sure to browse its official documentation to learn more. Also note that Stimulus does not force you to use a specific localization solution, so you may also try using something like jQuery.I18n or Polyglot.
That's all for today! Thank you for reading along, and until the next time.
No comments:
Post a Comment