Skip to content

6. Internationalisation

6.1. Exemple 16 : une application en deux langues

Dans le cours [NestJS], le serveur traduisait les pages avant de les envoyer. Ici, la traduction se fait dans le navigateur, avec la bibliothèque [vue-i18n], et changer de langue ne demande plus rien au serveur.

6.1.1. Les dictionnaires

Un dictionnaire par langue, au format JSON. Chaque texte a une clé ([ACCUEIL.BONJOUR]) :

{
  "APP": {
    "TITRE": "Exemple 16 - une application en deux langues",
    "LANGUE": "Langue"
  },
  "ACCUEIL": {
    "BONJOUR": "Bonjour {nom} !",
    "AUJOURDHUI": "Nous sommes le {date}.",
    "PRIX": "Une consultation coûte {prix}.",
    "RENDEZ_VOUS": "aucun rendez-vous | un rendez-vous | {n} rendez-vous",
    "AJOUTER": "Ajouter un rendez-vous",
    "RETIRER": "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)."
  },
  "SERVEUR": {
    "TITRE": "Traduire une réponse du serveur",
    "EXPLICATION": "Le serveur RdvMedecins ne renvoie que des clés. C'est le client qui les traduit :"
  }
}
{
  "APP": {
    "TITRE": "Example 16 - a two-language application",
    "LANGUE": "Language"
  },
  "ACCUEIL": {
    "BONJOUR": "Hello {nom}!",
    "AUJOURDHUI": "Today is {date}.",
    "PRIX": "A consultation costs {prix}.",
    "RENDEZ_VOUS": "no appointment | one appointment | {n} appointments",
    "AJOUTER": "Add an appointment",
    "RETIRER": "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)."
  },
  "SERVEUR": {
    "TITRE": "Translating a server response",
    "EXPLICATION": "The RdvMedecins server only sends keys. The client translates them:"
  }
}
  • ligne 7 : [{nom}] est un paramètre du message ;
  • ligne 10 : un message à pluriel : ses formes sont séparées par [|] (zéro, un, plusieurs) ; [{n}] est le nombre ;
  • lignes 14-17 : ce sont les MÊMES clés que celles que renvoie le serveur [RdvMedecins] dans ses réponses d'erreur (cf. chapitre suivant).

6.1.2. La configuration

// =========================================================================
// 16-i18n/src/i18n.ts - la configuration de vue-i18n
// -------------------------------------------------------------------------
// vue-i18n est la bibliothèque de traduction de Vue.js. On lui fournit :
//   - un DICTIONNAIRE par langue (fichiers locales/fr.json, locales/en.json),
//     où chaque texte a une CLÉ ("ACCUEIL.BONJOUR") ;
//   - la langue de départ (ici : celle mémorisée dans localStorage, sinon
//     celle du navigateur, sinon le français) ;
//   - des formats de dates et de nombres propres à chaque langue.
// Dans les composants, t('ACCUEIL.BONJOUR', { nom: 'Marie' }) renvoie le
// texte de la langue COURANTE ; changer de langue met toute la page à jour.
//
// C'est, côté client, le même principe que le cours NestJS (dictionnaires
// i18n/*.json + helper {{t}}) - mais la traduction se fait maintenant dans
// le navigateur, et changer de langue ne demande plus rien au serveur.
// =========================================================================

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

export type Langue = 'fr' | 'en';
export const CLE_LANGUE = 'exemple16.langue';

function langueDeDepart(): Langue {
  const memorisee = localStorage.getItem(CLE_LANGUE);
  if (memorisee === 'fr' || memorisee === 'en') {
    return memorisee;
  }
  return navigator.language.startsWith('en') ? 'en' : 'fr';
}

// le type des messages : celui du dictionnaire français (le "modèle")
type Messages = typeof fr;

export const i18n = createI18n<[Messages], Langue>({
  legacy: false, // l'API "Composition" (useI18n), celle de <script setup>
  locale: langueDeDepart(),
  fallbackLocale: 'fr', // une clé absente en anglais : on prend le français
  messages: { fr, en },
  // d(date, 'long') et n(nombre, 'euros') : formats propres à chaque langue
  datetimeFormats: {
    fr: { long: { dateStyle: 'full' } },
    en: { long: { dateStyle: 'full' } },
  },
  numberFormats: {
    fr: { euros: { style: 'currency', currency: 'EUR' } },
    en: { euros: { style: 'currency', currency: 'EUR' } },
  },
});
  • ligne 19 : [Vite] sait importer un fichier JSON : on obtient un objet ;
  • lignes 25-31 : la langue mémorisée dans [localStorage], sinon celle du navigateur ([navigator.language]), sinon le français ;
  • lignes 36-50 : l'objet de configuration :
    • [legacy: false] : l'API « Composition » ([useI18n]), celle de [<script setup>] ;
    • [fallbackLocale: 'fr'] : une clé absente du dictionnaire anglais est prise dans le français ;
    • [datetimeFormats], [numberFormats] : des formats de dates et de nombres, propres à chaque langue.
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() deviennent disponibles dans tous les composants
createApp(App).use(i18n).mount('#app');

6.1.3. La page

<!--
  =========================================================================
  16-i18n/src/App.vue - une page traduite
  -------------------------------------------------------------------------
    - t('CLE')                  un texte ;
    - t('CLE', { nom: ... })    un texte à paramètres ({nom} dans le dictionnaire) ;
    - t('CLE', n)               un pluriel : "aucun | un | {n}" selon n ;
    - d(date, 'long')           une date, au format de la langue ;
    - n(nombre, 'euros')        un nombre, au format de la langue ;
    - locale                    la langue courante : la modifier retraduit tout.
  =========================================================================
-->
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { CLE_LANGUE, type Langue } from './i18n';

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

// à chaque changement de langue : on la mémorise, et on met à jour
// l'attribut lang de la page (utile aux lecteurs d'écran, aux correcteurs)
watch(
  locale,
  (langue) => {
    localStorage.setItem(CLE_LANGUE, langue);
    document.documentElement.lang = langue;
    document.title = t('APP.TITRE');
  },
  { immediate: true },
);

function choisir(langue: Langue): void {
  locale.value = langue;
}

// ce que renverrait le serveur RdvMedecins (cf. chapitre sur la boîte noire)
const reponsesServeur = [
  { statusCode: 409, cle: 'ERRORS.LOGIN_TAKEN' },
  { statusCode: 429, cle: '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.TITRE') }}</h1>
      <div class="btn-group btn-group-sm" role="group" :aria-label="t('APP.LANGUE')">
        <button class="btn" :class="locale === 'fr' ? 'btn-primary' : 'btn-outline-primary'" @click="choisir('fr')">FR</button>
        <button class="btn" :class="locale === 'en' ? 'btn-primary' : 'btn-outline-primary'" @click="choisir('en')">EN</button>
      </div>
    </div>

    <div class="card p-3 mb-3">
      <p>{{ t('ACCUEIL.BONJOUR', { nom: 'Marie' }) }}</p>
      <p>{{ t('ACCUEIL.AUJOURDHUI', { date: d(new Date(), 'long') }) }}</p>
      <p>{{ t('ACCUEIL.PRIX', { prix: n(1234.5, 'euros') }) }}</p>
      <!-- t(clé, nombre) choisit la bonne forme du pluriel -->
      <p class="fw-bold">{{ t('ACCUEIL.RENDEZ_VOUS', nombre) }}</p>
      <div class="d-flex gap-2">
        <button class="btn btn-sm btn-outline-success" @click="nombre++">{{ t('ACCUEIL.AJOUTER') }}</button>
        <button class="btn btn-sm btn-outline-secondary" :disabled="nombre === 0" @click="nombre--">{{ t('ACCUEIL.RETIRER') }}</button>
      </div>
    </div>

    <div class="card p-3">
      <h2 class="h6">{{ t('SERVEUR.TITRE') }}</h2>
      <p>{{ t('SERVEUR.EXPLICATION') }}</p>
      <ul class="mb-0">
        <li v-for="r in reponsesServeur" :key="r.cle">
          <code>{{ JSON.stringify(r) }}</code><br>
          <!-- la clé du serveur EST une clé du dictionnaire ; params fournit ses paramètres -->
          &rarr; {{ t(r.cle, r.params ?? {}) }}
        </li>
      </ul>
    </div>
  </div>
</template>
  • ligne 18 : les outils de traduction :
    • [t(clé, paramètres)] : un texte ;
    • [t(clé, nombre)] : un pluriel ;
    • [d(date, 'long')] : une date, au format de la langue ;
    • [n(nombre, 'euros')] : un nombre, au format de la langue ;
    • [locale] : la langue courante, une [ref]. La modifier retraduit toute la page ;
  • lignes 23-30 : à chaque changement de langue, on mémorise le choix et l'on met à jour l'attribut [lang] de la page (utile aux lecteurs d'écran et aux correcteurs orthographiques) ;
  • lignes 38-41 : deux réponses d'erreur, telles que les renvoie le serveur [RdvMedecins] ;
  • ligne 73 : la clé envoyée par le serveur est une clé du dictionnaire ; ses paramètres, ceux du message.

En français :

Image

En anglais, après un clic sur « Add an appointment » :

Image