Skip to content

5. Internationalisation

Une application internationalisée affiche ses textes dans la langue de l'utilisateur. L'étude de cas est en français et en anglais. Le principe est simple : les vues ne contiennent plus de textes, mais des clés ([MENU.ACCUEIL]), et un dictionnaire par langue associe à chaque clé son texte. La langue choisie est mémorisée dans un cookie.

5.1. Exemple 22 : une application en deux langues

22-i18n/
├── i18n/
│   ├── en.json
│   └── fr.json
├── src/
│   ├── app.module.ts
│   ├── helpers.ts
│   ├── i18n.ts
│   ├── langue.controller.ts
│   ├── main.ts
│   └── pages.controller.ts
├── views/
│   ├── partials/
│   │   └── navbar.hbs
│   ├── accueil.hbs
│   ├── layout.hbs
│   └── produits.hbs
├── nest-cli.json
└── tsconfig.json

5.1.1. Les dictionnaires

Le fichier [i18n/fr.json] :

{
  "MENU": {
    "ACCUEIL": "Accueil",
    "PRODUITS": "Produits"
  },
  "ACCUEIL": {
    "TITRE": "Bienvenue",
    "BONJOUR": "Bonjour {nom} !",
    "DATE": "Nous sommes le {date}."
  },
  "PRODUITS": {
    "TITRE": "Nos produits",
    "NOM": "Produit",
    "PRIX": "Prix",
    "NOMBRE": "{n} produit(s) au catalogue."
  }
}

Le fichier [i18n/en.json] :

{
  "MENU": {
    "ACCUEIL": "Home",
    "PRODUITS": "Products"
  },
  "ACCUEIL": {
    "TITRE": "Welcome",
    "BONJOUR": "Hello {nom}!",
    "DATE": "Today is {date}."
  },
  "PRODUITS": {
    "TITRE": "Our products",
    "NOM": "Product",
    "PRIX": "Price",
    "NOMBRE": "{n} product(s) in the catalogue."
  }
}
  • un dictionnaire est un fichier JSON, organisé par page ou par thème : la clé [ACCUEIL.BONJOUR] désigne le texte [BONJOUR] de la section [ACCUEIL] ;
  • ligne 8 : un texte peut contenir des paramètres, entre accolades, remplacés au moment de la traduction. L'ordre des mots change d'une langue à l'autre : c'est pourquoi on ne fabrique pas un texte en concaténant des morceaux traduits.

5.1.2. Le module de traduction

Le fichier [src/i18n.ts] :

import { readFileSync } from 'fs';
import { join } from 'path';

export type Langue = 'fr' | 'en';
export const LANGUES: Langue[] = ['fr', 'en'];
export const LANGUE_PAR_DEFAUT: Langue = 'fr';
// la "locale" utilisée par Intl pour les dates et les nombres
export const LOCALES: Record<Langue, string> = { fr: 'fr-FR', en: 'en-US' };

// un dictionnaire est un objet JSON imbriqué : { "MENU": { "ACCUEIL": "Accueil" } }
type Dictionnaire = { [cle: string]: string | Dictionnaire };

// les dictionnaires sont lus UNE fois, au démarrage
const DOSSIER = join(__dirname, '..', 'i18n');
const dictionnaires: Record<Langue, Dictionnaire> = {
  fr: JSON.parse(readFileSync(join(DOSSIER, 'fr.json'), 'utf-8')),
  en: JSON.parse(readFileSync(join(DOSSIER, 'en.json'), 'utf-8')),
};

export function estUneLangue(valeur: unknown): valeur is Langue {
  return typeof valeur === 'string' && (LANGUES as string[]).includes(valeur);
}

// traduire('fr', 'ACCUEIL.BONJOUR', { nom: 'Marie' }) -> "Bonjour Marie !"
export function traduire(langue: Langue, cle: string, params: Record<string, unknown> = {}): string {
  const texte = chercher(dictionnaires[langue], cle) ?? cle;
  return texte.replace(/\{(\w+)\}/g, (tout, nom: string) => (nom in params ? String(params[nom]) : tout));
}

// 'ACCUEIL.BONJOUR' : on descend dans ACCUEIL, puis dans BONJOUR
function chercher(dictionnaire: Dictionnaire, cle: string): string | null {
  let noeud: string | Dictionnaire | undefined = dictionnaire;
  for (const morceau of cle.split('.')) {
    if (typeof noeud !== 'object') {
      return null;
    }
    noeud = noeud[morceau];
  }
  return typeof noeud === 'string' ? noeud : null;
}

Commentons ce code :

  • lignes 4-6 : les langues gérées ;
  • ligne 8 : la « locale » de chaque langue, utilisée par [Intl] pour formater les dates et les nombres ([fr-FR] : 23/09/2026, 12,50 € ; [en-US] : 9/23/2026, €12.50) ;
  • lignes 14-18 : les dictionnaires sont lus une seule fois, au chargement du module, puis gardés en mémoire ;
  • lignes 20-22 : une garde de type [TypeScript] ([valeur is Langue]) : après un test positif, le compilateur sait que la valeur est une [Langue] ;
  • lignes 25-28 : la traduction. La clé est cherchée dans le dictionnaire ; absente, c'est la clé elle-même qui est affichée : un oubli se repère immédiatement à l'écran. L'expression régulière [/\{(\w+)\}/g] trouve chaque paramètre [{nom}] et le remplace par sa valeur ;
  • lignes 31-40 : la clé [ACCUEIL.BONJOUR] est découpée en [ACCUEIL] puis [BONJOUR], et l'on descend dans l'objet JSON.

5.1.3. Les helpers

Le fichier [src/helpers.ts] :

import { HelperOptions } from 'handlebars';
import { Langue, LANGUE_PAR_DEFAUT, LOCALES, estUneLangue, traduire } from './i18n';

interface Hbs {
  registerHelper(nom: string, fonction: (...args: any[]) => unknown): void;
}

// la langue de la page : res.locals.lang, fusionné dans le modèle racine
function langueDe(options: HelperOptions): Langue {
  const lang = options.data?.root?.lang;
  return estUneLangue(lang) ? lang : LANGUE_PAR_DEFAUT;
}

export function enregistrerHelpers(hbs: Hbs): void {
  // {{t 'CLE'}} ou {{t 'CLE' nom=...}} : traduction, avec paramètres nommés
  hbs.registerHelper('t', (cle: string, options: HelperOptions) => traduire(langueDe(options), cle, options.hash));

  // {{dateLongue quand}} : "mercredi 23 septembre 2026" / "Wednesday, September 23, 2026"
  hbs.registerHelper('dateLongue', (quand: string, options: HelperOptions) =>
    new Intl.DateTimeFormat(LOCALES[langueDe(options)], { dateStyle: 'full' }).format(new Date(quand)),
  );

  // {{monnaie prix}} : "12,50 €" / "€12.50"
  hbs.registerHelper('monnaie', (montant: number, options: HelperOptions) =>
    new Intl.NumberFormat(LOCALES[langueDe(options)], { style: 'currency', currency: 'EUR' }).format(montant),
  );

  hbs.registerHelper('eq', (a: unknown, b: unknown) => a === b);
}
  • lignes 9-12 : la langue de la page est dans le modèle racine ([options.data.root.lang]), où le middleware de [main.ts] l'a placée via [res.locals] ;
  • ligne 16 : le helper [t] traduit une clé. Les paramètres nommés du helper ([options.hash]) sont les paramètres du texte : [{{t 'ACCUEIL.BONJOUR' nom=nom}}] ;
  • lignes 19-21 et 24-26 : dates et montants sont formatés selon la langue.

5.1.4. Le choix de la langue

Le fichier [src/main.ts] :

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import * as cookieParser from 'cookie-parser';
import { NextFunction, Request, Response } from 'express';
import * as hbs from 'hbs';
import { dirname, join } from 'path';
import { AppModule } from './app.module';
import { enregistrerHelpers } from './helpers';
import { estUneLangue, LANGUE_PAR_DEFAUT } from './i18n';

const RACINE = join(__dirname, '..');

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.useStaticAssets(join(dirname(require.resolve('bootstrap/package.json')), 'dist'), { prefix: '/bootstrap/' });
  app.setBaseViewsDir(join(RACINE, 'views'));
  app.setViewEngine('hbs');
  enregistrerHelpers(hbs);
  await new Promise<void>((fini) => hbs.registerPartials(join(RACINE, 'views', 'partials'), () => fini()));
  app.use(cookieParser());
  // la langue de chaque page : celle du cookie [langue], le français sinon
  app.use((req: Request, res: Response, next: NextFunction) => {
    const cookie = req.cookies?.langue;
    res.locals.lang = estUneLangue(cookie) ? cookie : LANGUE_PAR_DEFAUT;
    next();
  });
  await app.listen(3000);
  console.log('Serveur démarré sur http://localhost:3000');
}

bootstrap();

lignes 22-26 : à chaque requête, la langue est lue dans le cookie [langue] (le français par défaut) et placée dans [res.locals.lang], donc dans le modèle de chaque vue.

Le fichier [src/langue.controller.ts] :

import { Controller, Get, NotFoundException, Param, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { estUneLangue } from './i18n';

@Controller('langue')
export class LangueController {
  // GET /langue/en : mémorise le choix dans un cookie, puis revient à la page précédente
  @Get(':langue')
  changer(@Param('langue') langue: string, @Req() req: Request, @Res() res: Response): void {
    if (!estUneLangue(langue)) {
      throw new NotFoundException();
    }
    res.cookie('langue', langue, { maxAge: 365 * 24 * 3600 * 1000, sameSite: 'lax', httpOnly: true });
    res.redirect(pageDOrigine(req));
  }
}

// la page d'où l'on vient (entête Referer), dont on ne garde que le chemin :
// rediriger vers une adresse complète venue de la requête permettrait
// d'envoyer l'utilisateur sur un autre site (faille "open redirect")
function pageDOrigine(req: Request): string {
  const referer = req.get('referer');
  if (!referer) {
    return '/';
  }
  try {
    const url = new URL(referer);
    return url.pathname + url.search;
  } catch {
    return '/';
  }
}
  • lignes 8-15 : [GET /langue/en] mémorise le choix dans un cookie valable un an, puis redirige vers la page d'où l'on vient. Une langue inconnue provoque une 404 ;
  • lignes 21-32 : la page d'origine est donnée par l'entête [Referer], que le navigateur ajoute quand on clique sur un lien. On n'en garde que le chemin : rediriger vers une adresse complète venue de la requête permettrait à un site malveillant de fabriquer un lien qui, après être passé par notre site, envoie l'utilisateur chez lui (faille dite [open redirect]).

5.1.5. Les vues

Le fichier [views/layout.hbs] :

<!doctype html>
<html lang="{{lang}}">
<head>
  <meta charset="utf-8">
  <title>{{t titrePage}}</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
</head>
<body>
  <div class="container py-3">
    {{> navbar}}
    {{{body}}}
  </div>
</body>
</html>
  • ligne 2 : l'attribut [lang] de la page suit la langue choisie (utile aux lecteurs d'écran et aux correcteurs orthographiques) ;
  • ligne 5 : le titre de la page est une clé, fournie par l'action ([titrePage]), et traduite par la vue : [{{t titrePage}}]. Sans apostrophes, l'argument du helper est une variable du modèle, et non une chaîne littérale.

Le fichier [views/partials/navbar.hbs] :

<nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
  <div class="navbar-nav me-auto">
    <a class="nav-link" href="/">{{t 'MENU.ACCUEIL'}}</a>
    <a class="nav-link" href="/produits">{{t 'MENU.PRODUITS'}}</a>
  </div>
  {{!-- la langue courante est mise en évidence --}}
  <div class="btn-group btn-group-sm">
    <a class="btn {{#if (eq lang 'fr')}}btn-primary{{else}}btn-outline-primary{{/if}}" href="/langue/fr">FR</a>
    <a class="btn {{#if (eq lang 'en')}}btn-primary{{else}}btn-outline-primary{{/if}}" href="/langue/en">EN</a>
  </div>
</nav>

lignes 8-9 : deux liens (changer de langue ne modifie aucune donnée de l'application : un GET convient), la langue courante étant mise en évidence.

Le fichier [views/accueil.hbs] :

1
2
3
<h1>{{t 'ACCUEIL.TITRE'}}</h1>
<p>{{t 'ACCUEIL.BONJOUR' nom=nom}}</p>
<p>{{t 'ACCUEIL.DATE' date=(dateLongue aujourdhui)}}</p>

ligne 3 : une sous-expression : la date est d'abord formatée par [dateLongue], puis passée comme paramètre [date] au helper [t].

Le fichier [views/produits.hbs] :

<h1>{{t 'PRODUITS.TITRE'}}</h1>
<table class="table table-sm w-auto">
  <thead><tr><th>{{t 'PRODUITS.NOM'}}</th><th class="text-end">{{t 'PRODUITS.PRIX'}}</th></tr></thead>
  <tbody>
    {{#each produits}}
      {{!-- dans la boucle, le helper retrouve la langue dans le modèle RACINE --}}
      <tr><td>{{nom}}</td><td class="text-end">{{monnaie prix}}</td></tr>
    {{/each}}
  </tbody>
</table>
<p>{{t 'PRODUITS.NOMBRE' n=nombre}}</p>

Le fichier [src/pages.controller.ts] :

import { Controller, Get, Render } from '@nestjs/common';

@Controller()
export class PagesController {
  @Get()
  @Render('accueil')
  accueil(): object {
    // titrePage est une CLÉ : la vue la traduit
    return { titrePage: 'ACCUEIL.TITRE', nom: 'Marie', aujourdhui: '2026-09-23' };
  }

  @Get('produits')
  @Render('produits')
  produits(): object {
    const produits = [
      { nom: 'Clavier', prix: 25 },
      { nom: 'Souris', prix: 12.5 },
      { nom: 'Écran', prix: 1149 },
    ];
    return { titrePage: 'PRODUITS.TITRE', produits, nombre: produits.length };
  }
}

5.1.6. Exécution

En français (pas encore de cookie) :

Image

Image

Un clic sur EN :

curl -i -H "Referer: http://localhost:3000/produits" http://localhost:3000/langue/en
1
2
3
4
5
6
7
HTTP/1.1 302 Found
Set-Cookie: langue=en; Max-Age=31536000; Path=/; Expires=Thu, 23 Sep 2027 14:16:03 GMT; HttpOnly; SameSite=Lax
Location: /produits
Content-Type: text/plain; charset=utf-8
Content-Length: 31

Found. Redirecting to /produits

Le serveur dépose le cookie et renvoie à la page d'origine, désormais en anglais :

Image

Image

L'étude de cas procède exactement ainsi, avec des dictionnaires plus fournis ([i18n/fr.json] et [i18n/en.json]) : même helper [t], même contrôleur [/langue/:langue], même cookie. Une seule différence : les messages d'erreur produits par les couches métier et web sont eux aussi des clés, traduites au dernier moment par la vue.