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.
| 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 -->
→ {{ 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 :

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