Skip to content

6. Internationalization

6.1. Example 16: A Bilingual Application

In the [NestJS] course, the server translated the pages before sending them. Here, translation takes place in the browser using the [vue-i18n] library, and switching languages no longer requires any interaction with the server.

6.1.1. Dictionaries

One dictionary per language, in the JSON format. Each text has a key ([HOME.HELLO]):

{
  "APP": {
    "TITLE": "Exemple 16 - une application en deux langues",
    "LANGUAGE": "Langue"
  },
  "HOME": {
    "HELLO": "Bonjour {name} !",
    "TODAY": "Nous sommes le {date}.",
    "PRICE": "Une consultation coûte {price}.",
    "APPOINTMENTS": "aucun rendez-vous | un rendez-vous | {n} rendez-vous",
    "ADD": "Ajouter un rendez-vous",
    "REMOVE": "En retirer un"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
    "TOO_MANY_ATTEMPTS": "Trop de tentatives infructueuses. Réessayez dans {minutes} minute(s)."
  },
  "SERVER": {
    "TITLE": "Traduire une réponse du serveur",
    "EXPLANATION": "Le serveur RdvMedecins ne renvoie que des clés. C'est le client qui les traduit :"
  }
}
{
  "APP": {
    "TITLE": "Example 16 - a two-language application",
    "LANGUAGE": "Language"
  },
  "HOME": {
    "HELLO": "Hello {name}!",
    "TODAY": "Today is {date}.",
    "PRICE": "A consultation costs {price}.",
    "APPOINTMENTS": "no appointment | one appointment | {n} appointments",
    "ADD": "Add an appointment",
    "REMOVE": "Remove one"
  },
  "ERRORS": {
    "LOGIN_TAKEN": "This login is already used by another account.",
    "TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {minutes} minute(s)."
  },
  "SERVER": {
    "TITLE": "Translating a server response",
    "EXPLANATION": "The RdvMedecins server only sends keys. The client translates them:"
  }
}
  • line 7: [{name}] is a message parameter;
  • line 10: a plural message: its forms are separated by [|] (zero, one, multiple); [{n}] is the number;
  • Lines 14–17: These are the SAME keys that the server returns in its error responses (see the following chapter).

6.1.2. The configuration

// =========================================================================
// 16-i18n/src/i18n.ts - the configuration of vue-i18n
// -------------------------------------------------------------------------
// vue-i18n is the translation library of Vue.js. We provide it with:
//   - a DICTIONARY per language (files locales/fr.json, locales/en.json),
//     where each text has a KEY ("HOME.HELLO");
//   - the starting language (here: the one stored in localStorage, otherwise
//     the browser's one, otherwise English);
//   - date and number formats specific to each language.
// In the components, t('HOME.HELLO', { name: 'Marie' }) returns the
// text of the CURRENT language; changing the language updates the whole page.
//
// It is, on the client side, the same principle as the NestJS course (dictionaries
// i18n/*.json + helper {{t}}) - but the translation is now done in
// the browser, and changing the language no longer asks anything of the server.
// =========================================================================

import { createI18n } from 'vue-i18n';
import fr from './locales/fr.json';
import en from './locales/en.json';

export type Language = 'fr' | 'en';
export const LANGUAGE_KEY = 'example16.language';

function initialLanguage(): Language {
  const stored = localStorage.getItem(LANGUAGE_KEY);
  if (stored === 'fr' || stored === 'en') {
    return stored;
  }
  return navigator.language.startsWith('fr') ? 'fr' : 'en';
}

// the type of the messages: that of the English dictionary (the "model")
type Messages = typeof en;

export const i18n = createI18n<[Messages], Language>({
  legacy: false, // the "Composition" API (useI18n), that of <script setup>
  locale: initialLanguage(),
  fallbackLocale: 'en', // a key missing in French: the English one is used
  messages: { fr, en },
  // d(date, 'long') and n(number, 'euros'): formats specific to each language
  datetimeFormats: {
    fr: { long: { dateStyle: 'full' } },
    en: { long: { dateStyle: 'full' } },
  },
  numberFormats: {
    fr: { euros: { style: 'currency', currency: 'EUR' } },
    en: { euros: { style: 'currency', currency: 'EUR' } },
  },
});
  • line 19: [Vite] can import a JSON file: this returns an object;
  • lines 25–31: the language stored in [localStorage], otherwise the browser’s language ([navigator.language]), otherwise English;
  • lines 36–50: the configuration object:
    • [legacy: false]: the Composition API ([useI18n]), that of [<script setup>];
    • [fallbackLocale: 'en']: a key missing from the French dictionary is taken from the English dictionary;
    • [datetimeFormats], [numberFormats]: date and number formats specific to each language.
1
2
3
4
5
6
7
import { createApp } from 'vue';
import 'bootstrap/dist/css/bootstrap.min.css';
import App from './App.vue';
import { i18n } from './i18n';

// app.use(i18n): t(), d(), n() become available in all the components
createApp(App).use(i18n).mount('#app');

6.1.3. The page

<!--
  =========================================================================
  16-i18n/src/App.vue - a translated page
  -------------------------------------------------------------------------
    - t('KEY')                  a text;
    - t('KEY', { name: ... })   a text with parameters ({name} in the dictionary);
    - t('KEY', n)               a plural: "none | one | {n}" depending on n;
    - d(date, 'long')           a date, in the format of the language;
    - n(number, 'euros')        a number, in the format of the language;
    - locale                    the current language: modifying it translates everything again.
  =========================================================================
-->
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { LANGUAGE_KEY, type Language } from './i18n';

const { t, d, n, locale } = useI18n();
const count = ref(2);

// on each change of language: it is stored, and the lang attribute of the
// page is updated (useful for screen readers, spell checkers)
watch(
  locale,
  (language) => {
    localStorage.setItem(LANGUAGE_KEY, language);
    document.documentElement.lang = language;
    document.title = t('APP.TITLE');
  },
  { immediate: true },
);

function choose(language: Language): void {
  locale.value = language;
}

// what the RdvMedecins server would return (see the chapter on the black box)
const serverResponses = [
  { statusCode: 409, key: 'ERRORS.LOGIN_TAKEN' },
  { statusCode: 429, key: 'ERRORS.TOO_MANY_ATTEMPTS', params: { minutes: '15' } },
];
</script>

<template>
  <div class="container py-4" style="max-width: 720px">
    <div class="d-flex justify-content-between align-items-center mb-3">
      <h1 class="h3 mb-0">{{ t('APP.TITLE') }}</h1>
      <div class="btn-group btn-group-sm" role="group" :aria-label="t('APP.LANGUAGE')">
        <button class="btn" :class="locale === 'fr' ? 'btn-primary' : 'btn-outline-primary'" @click="choose('fr')">FR</button>
        <button class="btn" :class="locale === 'en' ? 'btn-primary' : 'btn-outline-primary'" @click="choose('en')">EN</button>
      </div>
    </div>

    <div class="card p-3 mb-3">
      <p>{{ t('HOME.HELLO', { name: 'Marie' }) }}</p>
      <p>{{ t('HOME.TODAY', { date: d(new Date(), 'long') }) }}</p>
      <p>{{ t('HOME.PRICE', { price: n(1234.5, 'euros') }) }}</p>
      <!-- t(key, number) chooses the right plural form -->
      <p class="fw-bold">{{ t('HOME.APPOINTMENTS', count) }}</p>
      <div class="d-flex gap-2">
        <button class="btn btn-sm btn-outline-success" @click="count++">{{ t('HOME.ADD') }}</button>
        <button class="btn btn-sm btn-outline-secondary" :disabled="count === 0" @click="count--">{{ t('HOME.REMOVE') }}</button>
      </div>
    </div>

    <div class="card p-3">
      <h2 class="h6">{{ t('SERVER.TITLE') }}</h2>
      <p>{{ t('SERVER.EXPLANATION') }}</p>
      <ul class="mb-0">
        <li v-for="r in serverResponses" :key="r.key">
          <code>{{ JSON.stringify(r) }}</code><br>
          <!-- the server's key IS a key of the dictionary; params provides its parameters -->
          &rarr; {{ t(r.key, r.params ?? {}) }}
        </li>
      </ul>
    </div>
  </div>
</template>
  • line 18: translation tools:
    • [t(key, params)]: a text;
    • [t(key, n)]: a plural;
    • [d(date, 'long')]: a date, in the language's format;
    • [n(number, 'euros')]: a number, in the language's format;
    • [locale]: the current language, a [ref]. Changing it re-translates the entire page;
  • lines 23–30: Each time the language is changed, the selection is saved and the page’s [lang] attribute is updated (useful for screen readers and spell-checkers);
  • lines 38–41: two error responses, as returned by the server [RdvMedecins];
  • line 73: the key sent by the server is a dictionary key; its parameters are those of the message.

In French (French browser):

Image

In English, after clicking “Add an appointment”:

Image