Aller au contenu principal

Bilingue dès la conception

· 9 minutes de lecture
Dave Rapin
Dave Rapin
Fondateur chez Curling IO
Mise à jour : 1er octobre 2026

Cet article présente notre exploration initiale de Gleam pour Curling IO v3. Nous avons finalement choisi Rust et TypeScript. Pourquoi nous avons choisi Rust pour la version 3 explique cette décision et ce que nous avons conservé de cette exploration.

À propos de cet article

Cette note d’architecture technique porte sur la localisation dans Curling IO v3. Elle s’adresse aux ingénieurs logiciels et aborde Fluent, les vérifications de traduction à la compilation, la modélisation du contenu bilingue et le routage par langue plus en détail que nos articles habituels sur le produit.

La prise en charge de l’anglais et du français doit aller de soi. Un club québécois ne devrait pas tomber sur une page d’inscription à moitié traduite, et une organisation qui organise des compétitions nationales ne devrait pas avoir à ouvrir une demande de soutien pour signaler un libellé manquant.

Curling IO est bilingue depuis la version 2. Mais après dix ans à maintenir environ 10 000 clés de traduction YAML, nous avons atteint les limites de cette approche : des clés ou des traductions manquantes passent inaperçues, et des clés inutilisées s’accumulent sans qu’on s’en aperçoive. La version 3 conserve l’expérience bilingue et empêche davantage de ces erreurs d’être mises en production.

Deux sortes de texte, deux systèmes distincts​

Toute application web bilingue contient deux sortes de texte fondamentalement différentes :

  1. Les libellés de l’application : du texte d’interface statique comme « Ligues », « Contact » et « Courriel ». Les développeurs les rédigent, et ils changent lors du déploiement.
  2. Le contenu : des données saisies par les utilisateurs, comme les noms, résumés et descriptions des ligues. Les gestionnaires de club les entrent et peuvent les modifier à tout moment.
Libellés de l’applicationContenu
Qui les rédigeLes développeursLes gestionnaires de club
Quand ils changentAu déploiementÀ tout moment
Où ils sont stockésDans le code compiléDans la base de données
PortéeGlobale (tous les clubs)Par fiche

Dans la version 2, les libellés provenaient de fichiers YAML Rails i18n chargés au démarrage. Le contenu était stocké dans des colonnes de base de données propres à chaque langue (name_en, name_fr, summary_en, summary_fr). Cela fonctionnait, mais nous voulions corriger certains irritants.

Les leçons de la version 2​

Les fichiers YAML sont devenus difficiles à gérer. Au fil des ans, nos fichiers en.yml et fr.yml ont chacun accumulé des milliers de clés. La recherche des clés inutilisées se faisait manuellement. Il fallait de la discipline pour s’assurer que chaque clé anglaise avait son équivalent français. Les oublis occasionnels produisaient des éléments d’interface non traduits en production, sous forme de chaînes vides ou de chemins de clés comme en.registrations.confirm_button.

Les colonnes par langue ne passaient pas à l’échelle. Dans la version 2, la table leagues contient name_en, name_fr, summary_en, summary_fr, description_en, description_fr, post_registration_message_en, post_registration_message_fr, required_reading_en, required_reading_fr. Dix colonnes pour le texte traduit d’une seule table. Chaque nouveau champ traduisible exige une migration qui ajoute deux colonnes. Une troisième langue ajouterait une autre colonne pour chaque champ traduisible de chaque table.

La distinction entre vide et manquant était ambiguë. Si name_fr était vide, le gestionnaire l’avait-il laissé vide volontairement ou n’avait-il simplement pas encore eu le temps de le traduire? Le système ne pouvait pas faire la différence.

Version 3 : traduire les libellés de l’application​

Les libellés sont compilés directement dans l’application. Aucune base de données, aucun fichier YAML, aucun chargement de fichier à l’exécution. Seulement du code Gleam.

Détails d’implémentation des libellés de l’application

La structure est simple :

translations/keys.gleam constants for every key
translations/en.gleam English values
translations/fr.gleam French values
translations.gleam builds the lookup, provides t()

Les constantes de clés sont des valeurs pub const dans keys.gleam. Nous avons ajouté ce code pour obtenir des garanties à la compilation. Chaque clé utilisée dans une page ou un fichier de langue fait référence à ces constantes. Une clé mal écrite cause donc une erreur de compilation plutôt qu’un bogue silencieux à l’exécution :

pub const leagues = "leagues"
pub const contact = "contact"
pub const email_label = "email_label"

Ces clés sont à plat, sans imbrication. Dans la version 2, les clés Rails i18n étaient imbriquées par espace de noms (en.registrations.confirm_button, en.leagues.index.title, etc.). En pratique, cette imbrication ajoutait de la complexité sans grand avantage : les clés étaient plus difficiles à chercher, réorganiser les espaces de noms exigeait de modifier plusieurs fichiers et le YAML profondément imbriqué favorisait les erreurs de modification. Les clés à plat sont plus faciles à chercher et à maintenir, et fonctionnent tout aussi bien par milliers.

Les fichiers de langue associent ces constantes aux valeurs traduites :

// en.gleam
#(k.leagues, "Leagues"),
#(k.contact, "Contact"),
#(k.email_label, "Email"),

// fr.gleam
#(k.leagues, "Ligues"),
#(k.contact, "Contact"),
#(k.email_label, "Courriel"),

Au démarrage, translations.build() assemble le tout dans un dictionnaire imbriqué : la clé externe est le code de langue, et la clé interne est la clé de traduction. Ce dictionnaire est stocké dans le contexte de l’application et transmis à chaque requête. La recherche nécessite deux accès à des tables de hachage, ce qui est pratiquement instantané.

Dans les pages, nous appliquons partiellement la fonction de recherche à la langue actuelle :

let t = t.t(rc.translations, rc.lang, _)

// Then just call t() with key constants
element.text(t(k.leagues)) // "Leagues" or "Ligues"
element.text(t(k.contact)) // "Contact" (same in both languages!)

Cette approche est claire, à typage sûr et impossible à mal utiliser à l’exécution. Si une clé manque dans un fichier de langue, l’interface affiche "[missing: postal_code_label]". Le problème est donc immédiatement visible, ce qui vaut bien mieux qu’un espace vide sans explication.

Détecter les problèmes à la compilation et pendant les tests​

Grâce aux constantes plutôt qu’aux chaînes brutes, une faute comme t(k.legues) produit une erreur de compilation, car la constante n’existe pas. Cette seule mesure élimine toute une catégorie de bogues rencontrés dans la version 2.

Test de parité des traductions

Nous avons aussi un test qui vérifie que chaque clé anglaise a un équivalent français, et vice versa :

pub fn should_have_same_keys_in_en_and_fr_test() {
let en_keys = en.all() |> dict.keys() |> set.from_list()
let fr_keys = fr.all() |> dict.keys() |> set.from_list()
set.difference(en_keys, fr_keys) |> should.equal(set.new())
set.difference(fr_keys, en_keys) |> should.equal(set.new())
}

Ajouter une clé en anglais sans l’ajouter en français fait échouer la suite de tests. Nous ne découvrons plus les chaînes non traduites en production.

Nous avons aussi un script qui détecte les clés définies mais jamais utilisées dans une page ou un modèle. Entre le compilateur, le test de parité et la détection des clés inutilisées, les problèmes de traduction sont repérés bien avant d’atteindre les utilisateurs.

Remarquez la petite portée des tests. Le système de types de Gleam vérifie tant de choses à la compilation que nos tests se concentrent sur les décisions de conception et la logique métier plutôt que sur les mauvaises surprises à l’exécution. Avec Rails dans la version 2, beaucoup de tests cherchent des problèmes qu’un système de types empêcherait : valeurs nil, mauvais types d’arguments et appels de méthodes manquantes. Dans Gleam, le compilateur s’en charge. Nos tests de traduction portent donc seulement sur la logique qui nous intéresse : toutes les langues ont-elles les mêmes clés? Le mécanisme de repli fonctionne-t-il correctement?

Version 3 : traduire le contenu​

La traduction du contenu saisi par les utilisateurs suit une autre approche. Plutôt que des colonnes par langue, chaque table traduisible possède une seule colonne JSON translations :

Structure des traductions du contenu
{"fr": {"name": "Ligue du lundi", "summary": "Résumé en français"}}

La valeur dans la langue principale reste dans la colonne habituelle (name, summary). Seules les valeurs de remplacement des langues secondaires vont dans le JSON. Cela signifie que :

  • Lire la langue principale ne coûte rien de plus. Il suffit de lire la colonne, sans analyser de JSON.
  • Sans traduction, aucun coût supplémentaire. La colonne vaut NULL.
  • Ajouter des champs traduisibles ne demande aucun changement de schéma. Il suffit d’ajouter le nom du champ dans le JSON.
  • Ajouter une troisième langue revient à ajouter une clé dans le JSON. Aucune migration n’est nécessaire.

La résolution est simple : si l’utilisateur consulte la langue principale, on retourne la colonne habituelle. S’il consulte une langue secondaire, on cherche la valeur dans le JSON. Si elle manque, on utilise la valeur de la langue principale. Chaque champ dispose ainsi d’un repli. Une traduction française manquante affiche le texte anglais plutôt que rien.

Exemple de résolution
// Parse once per record
let parsed = i18n.parse(league.data.translations)

// Resolve each field for the current language
let name = i18n.resolve(league.data.name, parsed, lang, primary_lang, "name")

Cela résout l’ambiguïté entre vide et manquant de la version 2. Si un champ n’est pas dans le JSON translations, il n’a simplement pas encore été traduit, et la valeur de la langue principale s’affiche. Pas besoin de deviner.

Routage par langue​

Le choix de langue vient du chemin de l’URL, et non des témoins ou des en-têtes du navigateur. Le résultat reste déterministe et partageable. Une URL française affiche toujours le contenu français :

  • /leagues : langue principale, sans préfixe.
  • /fr/leagues : français, avec le préfixe de langue secondaire.
  • /en/leagues : redirection vers /leagues, car la langue principale n’a jamais de préfixe.

Le routeur extrait la langue du premier segment du chemin, la stocke dans le contexte de la requête, puis toutes les pages et tous les gestionnaires d’API en aval l’utilisent. C’est clair, prévisible et bon pour le référencement.

La suite​

Les fondations de l’internationalisation sont en place. À mesure que nous développons les interfaces d’administration, les parcours d’inscription et les notifications par courriel, nous ajouterons des clés au système de libellés et la colonne JSON translations à d’autres tables. Le système est conçu pour accueillir des milliers de clés sans changement d’architecture.


Cet article est le deuxième de la série sur les fondations de Curling IO. À suivre : l’authentification sans mot de passe et la prise en charge de plusieurs adresses courriel.