Skip to content

6. Internationalisation

6.1. Exemple 21 : une application en deux langues

L'application [RdvMedecins] existe en français et en anglais. Dans le cours [NestJS], c'est le serveur qui traduisait les pages, avant de les envoyer. Désormais, les pages sont fabriquées dans le navigateur : c'est donc le client qui les traduit, et changer de langue ne demande plus rien au serveur.

[Angular] a son propre système de traduction, [@angular/localize], qui fabrique une application compilée par langue : on ne change alors de langue qu'en chargeant une autre application. Nous utiliserons plutôt la bibliothèque [ngx-translate], la plus utilisée avec [Angular], qui traduit pendant l'exécution : un clic suffit à retraduire toute la page.

1
2
3
4
5
6
7
8
21-i18n/src/app/
├── i18n/
│   ├── fr.json          le dictionnaire français
│   ├── en.json          le dictionnaire anglais
│   ├── i18n.ts          la configuration de ngx-translate
│   └── pluriel.pipe.ts  le choix du pluriel
├── app.ts
└── app.html

La bibliothèque est déclarée dans le fichier [package.json] de l'espace de travail :

"@ngx-translate/core": "^18.0.0",

6.1.1. Les dictionnaires

{
  "APP": {
    "TITRE": "Exemple 21 - une application en deux langues",
    "LANGUE": "Langue"
  },
  "ACCUEIL": {
    "BONJOUR": "Bonjour {{nom}} !",
    "AUJOURDHUI": "Nous sommes le {{date}}.",
    "PRIX": "Une consultation coûte {{prix}}.",
    "RENDEZ_VOUS": {
      "zero": "aucun rendez-vous",
      "one": "un rendez-vous",
      "other": "{{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 21 - a two-language application",
    "LANGUE": "Language"
  },
  "ACCUEIL": {
    "BONJOUR": "Hello {{nom}}!",
    "AUJOURDHUI": "Today is {{date}}.",
    "PRIX": "A consultation costs {{prix}}.",
    "RENDEZ_VOUS": {
      "zero": "no appointment",
      "one": "one appointment",
      "other": "{{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:"
  }
}
  • chaque texte a une clé, formée des noms des niveaux successifs : [ACCUEIL.BONJOUR], [ERRORS.LOGIN_TAKEN]... Le code et les templates ne contiennent plus aucun texte, seulement des clés ;
  • ligne 7 : les paramètres s'écrivent entre doubles accolades : [{{nom}}] ;
  • lignes 10-14 : les formes du pluriel, rangées sous une même clé (voir plus loin) ;
  • lignes 18-21 : les clés [ERRORS.*] sont celles que renvoie le serveur [RdvMedecins] dans ses réponses d'erreur (chapitre suivant). Le serveur ne renvoie pas de texte, mais une clé ; le client la traduit dans la langue de l'utilisateur.

6.1.2. La configuration

// =========================================================================
// 21-i18n/src/app/i18n/i18n.ts - la configuration de ngx-translate
// -------------------------------------------------------------------------
// ngx-translate est la bibliothèque de traduction la plus utilisée avec
// Angular. On lui fournit :
//   - un DICTIONNAIRE par langue (fr.json, en.json), où chaque texte a une
//     CLÉ ("ACCUEIL.BONJOUR") ; les paramètres s'écrivent {{nom}} ;
//   - un CHARGEUR (TranslateLoader), qui renvoie le dictionnaire d'une
//     langue. Le nôtre les prend dans le code de l'application (import de
//     fichiers JSON) ; un autre pourrait les télécharger ;
//   - la langue de départ (celle mémorisée dans localStorage, sinon celle
//     du navigateur, sinon le français) et la langue de repli.
// Dans les templates, {{ 'ACCUEIL.BONJOUR' | translate: { nom: 'Marie' } }}
// affiche 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.
//
// (Angular a aussi son propre système, @angular/localize, qui fabrique UNE
// application compilée par langue : on ne peut alors changer de langue
// qu'en changeant de page. ngx-translate traduit, lui, pendant l'exécution.)
// =========================================================================

import { Provider } from '@angular/core';
import { provideTranslateLoader, provideTranslateService, TranslateLoader, TranslationObject } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import fr from './fr.json';
import en from './en.json';

export type Langue = 'fr' | 'en';
export const LANGUES: Langue[] = ['fr', 'en'];
export const CLE_LANGUE = 'exemple21.langue';

// le chargeur des dictionnaires
class DictionnairesLoader implements TranslateLoader {
  getTranslation(langue: string): Observable<TranslationObject> {
    return of(langue === 'en' ? en : fr);
  }
}

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

export function provideI18n(): Provider[] {
  return provideTranslateService({
    loader: provideTranslateLoader(DictionnairesLoader),
    fallbackLang: 'fr', // une clé absente en anglais : on prend le français
    lang: langueDeDepart(),
  });
}
  • lignes 29-30 : les dictionnaires sont importés comme des modules : ils font partie du code de l'application. C'est possible grâce à l'option [resolveJsonModule] du fichier [tsconfig.json] ;
  • ligne 34 : la clé sous laquelle la langue choisie est rangée dans le [localStorage] (exemple 20) ;
  • lignes 37-41 : le chargeur des dictionnaires : [ngx-translate] l'appelle pour obtenir le dictionnaire d'une langue, sous forme d'observable. Le nôtre les prend dans le code ; un autre pourrait les télécharger depuis le serveur ;
  • lignes 43-49 : la langue de départ : celle que l'utilisateur a choisie la dernière fois, sinon celle du navigateur, sinon le français ;
  • lignes 51-57 : [provideTranslateService] rend les services de [ngx-translate] disponibles dans toute l'application. [fallbackLang] : la langue utilisée pour une clé absente du dictionnaire courant.
// =========================================================================
// 21-i18n/src/main.ts
// -------------------------------------------------------------------------
// provideI18n() : ngx-translate (cf. app/i18n/i18n.ts).
// registerLocaleData(localeFr) : les règles françaises des pipes date,
// currency... (l'anglais est toujours disponible).
// =========================================================================

import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { provideI18n } from './app/i18n/i18n';

registerLocaleData(localeFr);

bootstrapApplication(App, { providers: [provideI18n()] }).catch((erreur) => console.error(erreur));
  • ligne 15 : les pipes [date] et [currency] (exemple 09) connaissent les règles de l'anglais ; celles du français doivent être enregistrées ;
  • ligne 17 : les services de [ngx-translate] sont ajoutés à l'application.

6.1.3. Le pluriel

[ngx-translate] ne gère pas les pluriels. Un petit pipe choisit la bonne forme :

// =========================================================================
// 21-i18n/src/app/i18n/pluriel.pipe.ts - choisir la forme du pluriel
// -------------------------------------------------------------------------
// ngx-translate ne gère pas les pluriels. Le dictionnaire en donne les
// formes sous une même clé ("RENDEZ_VOUS": { "zero", "one", "other" }) et
// ce pipe choisit la bonne, selon le nombre et la langue :
//     {{ 'ACCUEIL.RENDEZ_VOUS' | pluriel: n : langue | translate: { n } }}
// Intl.PluralRules connaît les règles de chaque langue ("one" ou "other" en
// français comme en anglais, mais d'autres formes en polonais, en arabe...).
// =========================================================================

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'pluriel' })
export class PlurielPipe implements PipeTransform {
  transform(cle: string, n: number, langue: string | null): string {
    if (n === 0) {
      return `${cle}.zero`;
    }
    return `${cle}.${new Intl.PluralRules(langue ?? 'fr').select(n)}`;
  }
}
  • lignes 16-21 : le pipe reçoit une clé ([ACCUEIL.RENDEZ_VOUS]), un nombre et une langue ; il rend la clé de la bonne forme ([ACCUEIL.RENDEZ_VOUS.one], par exemple) ;
  • ligne 18 : zéro a sa propre forme (« aucun rendez-vous ») ;
  • ligne 20 : [Intl.PluralRules], fourni par le navigateur, connaît les règles de chaque langue : [one] ou [other] en français et en anglais, mais d'autres formes en polonais ou en arabe. (Attention : en français, 1,5 est au singulier ; en anglais, au pluriel.)

6.1.4. La page traduite

// =========================================================================
// 21-i18n/src/app/app.ts - une page traduite
// -------------------------------------------------------------------------
//   - 'CLE' | translate                    un texte ;
//   - 'CLE' | translate: { nom: ... }      un texte à paramètres ;
//   - date / currency avec la langue       une date, un montant, au format
//                                          de la langue courante ;
//   - translate.use('en')                  change la langue : toute la
//                                          page se retraduit.
// TranslateService.currentLang est un SIGNAL : la langue courante.
// =========================================================================

import { Component, effect, inject, signal } from '@angular/core';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CLE_LANGUE, Langue, LANGUES } from './i18n/i18n';
import { PlurielPipe } from './i18n/pluriel.pipe';

@Component({
  selector: 'app-root',
  imports: [CurrencyPipe, DatePipe, TranslatePipe, PlurielPipe],
  templateUrl: './app.html',
})
export class App {
  private readonly translate = inject(TranslateService);
  protected readonly langue = this.translate.currentLang;
  protected readonly langues = LANGUES;
  protected readonly nombre = signal(2);
  protected readonly aujourdhui = new Date();

  // ce que renverrait le serveur RdvMedecins (cf. chapitre sur la boîte noire)
  protected readonly reponsesServeur = [
    { statusCode: 409, cle: 'ERRORS.LOGIN_TAKEN', params: {} },
    { statusCode: 429, cle: 'ERRORS.TOO_MANY_ATTEMPTS', params: { minutes: '15' } },
  ];

  // translate.translate(clé) : un SIGNAL qui suit la langue courante
  private readonly titre = this.translate.translate('APP.TITRE');

  constructor() {
    // à 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)
    effect(() => {
      const langue = this.langue();
      if (langue) {
        localStorage.setItem(CLE_LANGUE, langue);
        document.documentElement.lang = langue;
      }
      document.title = String(this.titre());
    });
  }

  protected choisir(langue: Langue): void {
    this.translate.use(langue);
  }
}
  • ligne 21 : [TranslatePipe] : le pipe [translate] ;
  • ligne 25 : [TranslateService] : le service principal de [ngx-translate] ;
  • ligne 26 : [currentLang] : la langue courante, sous forme de signal ;
  • lignes 32-35 : deux réponses d'erreur, telles que les renverrait le serveur : une clé, et des paramètres ;
  • ligne 38 : [translate.translate(clé)] rend un signal qui vaut la traduction de la clé, et qui suit la langue courante ;
  • lignes 43-50 : un effet, exécuté à chaque changement de langue : la langue est mémorisée (ligne 46), l'attribut [lang] de la page est mis à jour (ligne 47 ; il sert aux lecteurs d'écran et aux correcteurs orthographiques), ainsi que le titre de l'onglet (ligne 49) ;
  • ligne 54 : [use] change la langue courante : toute la page se retraduit.
<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">{{ 'APP.TITRE' | translate }}</h1>
    <div class="btn-group btn-group-sm" role="group" [attr.aria-label]="'APP.LANGUE' | translate">
      @for (l of langues; track l) {
        <button class="btn" [class]="langue() === l ? 'btn-primary' : 'btn-outline-primary'" (click)="choisir(l)">
          {{ l.toUpperCase() }}
        </button>
      }
    </div>
  </div>

  <div class="card p-3 mb-3">
    <p>{{ 'ACCUEIL.BONJOUR' | translate: { nom: 'Marie' } }}</p>
    <!-- un paramètre calculé par un autre pipe, dans la langue courante -->
    <p>{{ 'ACCUEIL.AUJOURDHUI' | translate: { date: (aujourdhui | date: 'fullDate' : '' : langue() ?? 'fr') } }}</p>
    <p>{{ 'ACCUEIL.PRIX' | translate: { prix: (1234.5 | currency: 'EUR' : 'symbol' : '1.2-2' : langue() ?? 'fr') } }}</p>
    <!-- pluriel choisit la clé (zero, one, other), translate la traduit -->
    <p class="fw-bold">{{ 'ACCUEIL.RENDEZ_VOUS' | pluriel: nombre() : langue() | translate: { n: nombre() } }}</p>
    <div class="d-flex gap-2">
      <button class="btn btn-sm btn-outline-success" (click)="nombre.set(nombre() + 1)">{{ 'ACCUEIL.AJOUTER' | translate }}</button>
      <button class="btn btn-sm btn-outline-secondary" [disabled]="nombre() === 0" (click)="nombre.set(nombre() - 1)">
        {{ 'ACCUEIL.RETIRER' | translate }}
      </button>
    </div>
  </div>

  <div class="card p-3">
    <h2 class="h6">{{ 'SERVEUR.TITRE' | translate }}</h2>
    <p>{{ 'SERVEUR.EXPLICATION' | translate }}</p>
    <ul class="mb-0">
      @for (r of reponsesServeur; track r.cle) {
        <!-- la clé du serveur EST une clé du dictionnaire ; params fournit ses paramètres -->
        <li><code>{{ r.statusCode }} {{ r.cle }}</code><br>&rarr; {{ r.cle | translate: r.params }}</li>
      }
    </ul>
  </div>
</div>
  • ligne 3 : ['APP.TITRE' | translate] : la traduction de la clé, dans la langue courante ;
  • ligne 6 : le bouton de la langue courante est mis en valeur ;
  • ligne 14 : un texte à paramètre : le pipe reçoit un objet qui donne la valeur de chaque paramètre ;
  • lignes 16-17 : la valeur d'un paramètre peut être calculée par un autre pipe. Le dernier argument de [date] et de [currency] est la langue : la date et le montant sont écrits à la manière française, ou anglaise ;
  • ligne 19 : deux pipes enchaînés : [pluriel] choisit la clé, [translate] la traduit ;
  • ligne 34 : la clé renvoyée par le serveur est traduite, avec ses paramètres.

[npm start 21-i18n] ; en français :

Image

On clique sur [EN], puis sur « Remove one » :

Image

On recharge la page : elle reste en anglais, la langue a été mémorisée.