Aller au contenu

Six décisions pour garder une couche i18n Electron simple à maintenir

Une conception i18n pour Electron couvrant les préférences de langue, les catalogues typés, le comportement de repli, le formatage avec Intl et la synchronisation du renderer vers le processus principal.

13 min de lectureGuidesPar Temposhot Team

Une application de bureau avec cinq à dix langues n'a généralement pas besoin d'un grand framework i18n. Elle a besoin de quelques règles claires, car une application Electron comporte deux processus qui affichent tous deux du texte : le renderer dessine l'interface, et le processus principal gère les menus, les boîtes de dialogue et les notifications. Les six décisions ci-dessous permettent de conserver une couche i18n réduite et écrite à la main, correcte à mesure que l'application et ses catalogues grandissent.

1. Stocker une préférence, pas seulement une locale

Enregistrer "de" lorsque l'utilisateur choisit l'allemand semble suffisant, jusqu'au moment où l'on souhaite que l'application suive la langue du système d'exploitation. Il faut alors stocker l'intention de l'utilisateur et résoudre la locale effective à l'exécution :

export const SUPPORTED = ["en", "de", "it", "ja", "ko", "zh-Hans"] as const;
export type Locale = (typeof SUPPORTED)[number];
export type LanguagePreference = "system" | Locale;

const ALIASES: Record<string, Locale> = { "zh-cn": "zh-Hans", "zh-sg": "zh-Hans" };

export function resolveLocale(pref: LanguagePreference, systemLanguages: string[]): Locale {
  if (pref !== "system") return pref;
  for (const tag of systemLanguages) {
    const lower = tag.toLowerCase();
    const exact = SUPPORTED.find((l) => lower === l.toLowerCase() || lower.startsWith(l.toLowerCase() + "-"));
    if (exact) return exact;
    if (ALIASES[lower]) return ALIASES[lower];
  }
  return "en";
}

Dans le processus principal, app.getPreferredSystemLanguages() renvoie la liste ordonnée des langues du système d'exploitation. Si "system" est stocké, un utilisateur qui modifie la langue de son OS obtient la nouvelle langue au lancement suivant, sans toucher aux réglages de l'application.

La fonction parcourt toute la liste de préférences, et pas seulement sa première entrée. Un utilisateur dont la liste est « portugais, puis anglais » obtient l'anglais parce que c'est son second choix, et non parce que c'est la valeur par défaut. La correspondance par préfixe associe de-CH à l'allemand et zh-Hans-CN au chinois simplifié. zh-TW ne correspond à rien : la fonction passe à la préférence suivante au lieu de devenir silencieusement du chinois simplifié.

Ordre de résolution

La fonction renvoie le premier résultat trouvé. L'ordre des contrôles compte, car une locale stockée ne doit jamais être remplacée par la liste du système.

Cinq étapes numérotées pour résoudre une locale : renvoyer directement la locale stockée, lire la liste des langues du système dans l'ordre, accepter une correspondance exacte ou par préfixe, vérifier la table ALIASES, puis revenir à l'anglais.

Lorsque deux entrées de la liste système sont toutes deux prises en charge, la première l'emporte. macOS conserve la liste dans l'ordre défini par l'utilisateur dans Language & Region : la première entrée prise en charge correspond donc au premier choix de l'utilisateur parmi les langues disponibles.

Trois préférences stockées en pratique

Le tableau ci-dessous montre comment la même fonction traite trois utilisateurs. Chaque ligne correspond à un appel de resolveLocale avec une valeur stockée et une liste de langues du système d'exploitation.

Comparaison de trois utilisateurs : une préférence système avec fr-FR, de-DE et en-US donne l'allemand, une préférence japonaise stockée reste en japonais, et une préférence système avec zh-TW et en-GB donne l'anglais.

L'utilisateur 1 parle français en premier, mais l'application n'embarque pas le français. La boucle ignore fr-FR, trouve de-DE en deuxième position et renvoie l'allemand. L'utilisateur 3 obtient l'anglais pour une autre raison : zh-TW n'est pas un alias et ne correspond pas par préfixe au chinois simplifié. La boucle atteint donc en-GB et renvoie l'anglais. L'utilisateur 2 ne lit jamais la liste système, car la valeur stockée est une locale et non "system".

Valeurs stockées après la suppression d'une locale

Une version ultérieure peut retirer une langue. La valeur stockée n'apparaît alors plus dans SUPPORTED, et la recherche du catalogue ne trouve aucun fichier correspondant. Au chargement des réglages, convertir les valeurs inconnues en "system" et réécrire la valeur corrigée une seule fois, afin que le fichier stocké corresponde au code. L'utilisateur conserve une langue fonctionnelle au premier lancement après la mise à jour.

2. Garder les choix de langue lisibles après une mauvaise sélection

Si un utilisateur sélectionne le japonais par erreur et que tous les noms de langue s'affichent alors en japonais, il devient difficile de retrouver « English ». Afficher chaque langue dans sa propre langue (son endonyme) permet de garder la liste lisible quelle que soit la langue de l'interface :

export const LANGUAGE_NAMES: Record<Locale, string> = {
  en: "English",
  de: "Deutsch",
  it: "Italiano",
  ja: "日本語",
  ko: "한국어",
  "zh-Hans": "简体中文",
};

Intl.DisplayNames peut générer ces noms, mais son résultat pour les variantes de script diffère selon les moteurs et les versions. Une table fixe de six chaînes est plus simple à relire. Seule l'option « System default » est traduite, car il s'agit d'une expression et non d'un nom de langue.

La même règle dans Temposhot

L'application Mac Temposhot utilise le même principe d'endonyme pour son menu Language. Ce menu se trouve dans Settings, sous l'onglet General, et comporte sept entrées. Le guide indique que le nom de chaque langue est écrit dans cette langue.

Panneau de réglages du menu Language de Temposhot : emplacement dans Settings, General, sept entrées avec System default en premier, libellés en endonymes, choix conservé après un redémarrage, et traductions de l'interface encore en cours.

La dernière ligne est importante pour le lecteur de cet article. L'application enregistre le choix et change la locale immédiatement. Une partie du texte de l'interface reste en anglais après le changement, jusqu'à la publication des traductions des six autres langues.

Ordre des entrées du menu

Le menu suit l'ordre de SUPPORTED, avec l'anglais en premier. Un ordre fixe est facile à tester et ne change pas lorsqu'un traducteur modifie un nom. Trier par endonyme donnerait un ordre qui dépend des règles de collation de chaque script, ce qui est plus difficile à relire dans une pull request.

Ajouter une locale revient à l'ajouter à SUPPORTED. Le type Record<Locale, string> de LANGUAGE_NAMES fait alors échouer la compilation tant que la nouvelle entrée n'a pas de nom. Une locale ne peut donc pas apparaître dans le résolveur sans libellé visible dans le menu.

Vérifier le menu avant une version

Un menu de langues présente des défauts discrets que les tests unitaires ne détectent pas : un nom affiché dans le mauvais script, une entrée manquante ou une entrée qui n'est pas conservée. Une courte vérification manuelle avant chaque version permet de les repérer.

Liste de contrôle de six points pour le menu de langues : endonymes dans chaque script, System default en premier, six entrées de langue, libellés mis à jour après chaque changement, choix conservé après un redémarrage, et chinois simplifié uniquement pour le chinois.

Effectuez cette vérification sur un Mac dont la langue système n'est pas l'anglais. Un test sur un système en anglais ne prouve que le fonctionnement du chemin anglais.

3. Utiliser le catalogue anglais comme contrat TypeScript

Faites de l'anglais la source de référence et dérivez le type à partir de celle-ci :

export const en = {
  "settings.language": "Language",
  "settings.language.system": "System default",
  "capture.count": "{count} screenshots",
} as const;

export type MessageKey = keyof typeof en;
export type Catalog = Record<MessageKey, string>;

Chaque appel à t("settings.language") est désormais vérifié par le compilateur. Une faute de frappe ou une clé supprimée de l'anglais fait échouer la compilation. Les traducteurs travaillent sur les autres catalogues, mais personne ne peut ajouter une clé absente de l'anglais. Le fichier anglais liste donc toujours l'ensemble des éléments utilisés par l'interface.

Des clés plates avec des points gardent le type simple et rendent les différences faciles à lire. Des objets imbriqués fonctionnent aussi, mais ils nécessitent des types récursifs et rendent les rapports de clés manquantes plus difficiles à suivre.

Qui modifie quel fichier

Les ingénieurs modifient en.ts. Chaque traducteur modifie un fichier de locale et ne change pas en.ts. Une pull request qui ajoute une clé à en.ts seul passe quand même la compilation, car les autres catalogues sont typés comme partiels. Le test de complétude liste ensuite les clés manquantes pour chaque locale, ce qui permet à l'équipe de voir en un seul endroit les traductions en attente pour une version.

Ajouter une clé

Une nouvelle clé modifie quatre emplacements. L'ordre ci-dessous permet de tirer parti du compilateur dès la première étape.

Cinq étapes numérotées pour ajouter une clé de message : ajouter la clé et son texte anglais, lancer le compilateur, ajouter les traductions, lancer les tests de complétude et de placeholders, puis vérifier la mise en page dans chaque locale.

La dernière étape est celle que les équipes omettent souvent. Les chaînes allemandes et finnoises sont fréquemment plus longues que l'anglais : un libellé de bouton qui tient en anglais peut donc passer à la ligne ou être tronqué. Le compilateur et les tests ne détectent pas ce problème. Une vérification visuelle des chaînes les plus longues reste nécessaire.

Structure du dossier des catalogues

Le dossier contient un fichier par locale, ainsi que les fonctions d'aide qui les lisent. Une structure à plat garde des imports courts et indique clairement le fichier que modifie un traducteur.

Arborescence du dossier i18n : un fichier de catalogue pour l'anglais, l'allemand, l'italien, le japonais, le coréen et le chinois simplifié, ainsi que les fichiers d'aide index, resolve et format.

Seul en.ts exporte l'objet as const. Les autres fichiers de catalogue importent Partial<Catalog> depuis index.ts, de sorte qu'un fichier de traduction ne peut jamais définir une clé absente de l'anglais.

Nommer les clés

Faites précéder chaque clé du nom de l'écran ou de la zone qui l'utilise. settings.language et capture.count indiquent où se trouve une chaîne sans ouvrir le code, et une recherche de settings. liste toutes les chaînes de cet écran. Évitez les clés qui sont des phrases en anglais. Une clé comme "Language" oblige à modifier la clé en même temps que le texte lorsqu'on reformule le libellé.

Liste des pratiques à suivre et à éviter pour les clés de message : nommer les clés par écran et élément, garder une phrase par clé, utiliser des placeholders pour les valeurs ; ne pas assembler les clés dans le code, et ne pas utiliser de phrases anglaises comme clés.

Une clé par signification

Une clé suit le sens d'une phrase, et non le nombre d'endroits où elle s'affiche. L'application d'exemple affiche un nombre de captures dans l'en-tête des résultats, dans la barre d'état et dans la boîte de dialogue d'export. Les trois utilisent capture.count, avec {count} renseigné par l'appelant. Un traducteur corrige donc la formulation une seule fois pour les trois emplacements. Une seconde clé avec le même texte anglais finirait par diverger.

4. Conserver le repli à l'exécution et le contrôle de complétude

Les autres catalogues arrivent souvent au format JSON depuis un outil de traduction, et le compilateur ne peut pas en garantir le contenu. Typez-les comme Partial<Catalog> et revenez à l'anglais clé par clé à l'exécution :

let current: Partial<Catalog> = {};

export function setCatalog(next: Partial<Catalog>) {
  current = next;
}

export function t(key: MessageKey, vars: Record<string, string | number> = {}): string {
  const template = current[key] ?? en[key];
  return template.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? `{${name}}`));
}

Le repli permet à une version de rester utilisable lorsqu'une chaîne arrive en retard. Il masque aussi le manque, c'est pourquoi il faut ajouter un test qui échoue lorsqu'une locale livrée est incomplète. Les deux sont nécessaires : le repli protège les utilisateurs, et le test protège l'équipe contre la livraison involontaire de replis. La section des tests ci-dessous présente ce contrôle.

À quoi ressemble une chaîne manquante

L'exemple ci-dessous utilise un catalogue allemand interrompu avant que le traducteur ait terminé l'écran des réglages. Les chiffres proviennent de l'exemple et non d'une version livrée.

Quatre statistiques pour l'exemple de repli : 3 clés absentes de de.ts au moment de la coupe de version, 3 clés affichées en anglais à l'exécution, 0 clé affichée avec son nom brut puisque l'anglais contient toujours la clé, et 1 test en échec qui bloque la version tant que de.ts n'est pas complet.

L'utilisateur voit de l'anglais sur trois libellés et de l'allemand partout ailleurs. Une clé brute, comme settings.language, affichée à l'écran serait un résultat pire. Le repli l'empêche, car l'anglais définit toujours chaque clé.

Les variables manquantes restent visibles

La fonction t laisse {name} dans le résultat lorsque vars ne contient aucune valeur pour cette variable. L'application continue de fonctionner, et un relecteur voit le placeholder non renseigné dans une capture d'écran ou dans un échec de test. Ce placeholder est plus facile à repérer qu'une chaîne vide silencieuse, et il n'empêche pas le reste de l'écran de s'afficher.

Tester le chemin de repli

Un repli jamais exécuté par un test peut cesser de fonctionner sans que personne ne le remarque. Un court test avec un catalogue vide couvre ce chemin :

it("falls back to English for a missing key", () => {
  setCatalog({});
  expect(t("settings.language")).toBe("Language");
});

Dans les autres tests, appelez setCatalog avec le catalogue réel, afin que chaque test parte d'un état connu.

Le test de complétude doit lire sa liste de locales depuis SUPPORTED, et non depuis les fichiers présents sur le disque. Un fichier présent mais absent de la liste ne peut ainsi pas être livré par erreur, et une locale listée sans fichier fait échouer le test immédiatement.

Le repli fonctionne clé par clé

Le repli s'applique à chaque clé, et non à un écran entier. Un catalogue allemand dans lequel 40 clés sur 42 des réglages sont traduites affiche l'anglais pour les deux libellés manquants et l'allemand pour les 40 autres. Un repli par écran afficherait 40 chaînes finalisées en anglais à cause de deux clés manquantes, un résultat moins bon pour l'utilisateur.

5. Formater les dates et les nombres en dehors du catalogue de messages

Les catalogues doivent contenir des phrases, et non des règles de formatage. Les dates, les nombres, les tailles de fichiers et les pluriels proviennent de Intl, avec la locale résolue :

const date = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
const number = new Intl.NumberFormat(locale);
const plural = new Intl.PluralRules(locale);

Une chaîne de catalogue contient alors un placeholder, comme {date} ou {count}, et l'appelant formate la valeur avant de la transmettre. Les traducteurs n'ont ainsi pas à gérer les formats de date, et un utilisateur anglophone américain voit « Mar 2, 2026 » tandis qu'un utilisateur allemand voit « 02.03.2026 », sans que personne n'écrive l'un ou l'autre format. Les formes du pluriel varient selon la langue : la variante de message se choisit donc avec Intl.PluralRules et non avec count === 1.

Les catégories de pluriel varient selon la langue

Intl.PluralRules renvoie l'une des six catégories possibles : zero, one, two, few, many et other. L'anglais en utilise deux, et le russe en utilise quatre. Un contrôle count === 1 convient à l'anglais mais échoue pour le russe.

Comparaison des catégories de pluriel : l'anglais utilise one pour 1 et other pour 2 et 5, tandis que le russe utilise one pour 1, few pour 2 et many pour 5.

Le catalogue contient une variante par catégorie utilisée par la locale. La fonction d'aide lit plural.select(count) et recherche la clé correspondante, comme capture.count.one ou capture.count.few. Si une catégorie est absente, le système utilise other, ce qui permet d'utiliser un pluriel partiellement traduit.

Les unités font partie de la valeur

Intl.NumberFormat gère le regroupement des chiffres et le séparateur décimal. Ainsi, 1.2 MB en anglais et 1,2 MB en allemand proviennent du même chemin de code. L'unité fait partie de la valeur formatée et non du catalogue. Une entrée comme "{size} saved" reçoit la taille formatée de l'appelant, et le traducteur ne modifie que la phrase autour de la valeur.

Le résultat exact des dates dépend du runtime

La sortie de Intl.DateTimeFormat suit les données ICU du runtime. La chaîne exacte peut donc changer d'une version d'Electron à l'autre. Un test qui fixe "Mar 2, 2026, 9:05 AM" en dur échoue après une mise à niveau, pour une raison sans rapport avec l'application. Comparez plutôt la sortie au résultat du même appel Intl dans le test, et fixez le fuseau horaire pour que le test passe sur toutes les machines.

6. Synchroniser le choix du renderer avec le processus principal

L'écran des réglages se trouve dans le renderer, mais les menus natifs, les boîtes de dialogue, la barre d'état système et les notifications sont créés dans le processus principal. Faites du processus principal le seul propriétaire de la préférence :

// preload.ts
contextBridge.exposeInMainWorld("i18n", {
  setPreference: (pref: LanguagePreference) => ipcRenderer.invoke("i18n:set", pref),
  onChange: (cb: (locale: Locale) => void) =>
    ipcRenderer.on("i18n:changed", (_e, locale: Locale) => cb(locale)),
});

// main.ts
ipcMain.handle("i18n:set", (_e, pref: unknown) => {
  if (!isPreference(pref)) throw new Error("invalid language preference");
  store.set("language", pref);
  const locale = resolveLocale(pref, app.getPreferredSystemLanguages());
  rebuildMenu(locale);
  for (const win of BrowserWindow.getAllWindows()) win.webContents.send("i18n:changed", locale);
  return locale;
});

Le renderer envoie la demande, le processus principal décide, enregistre la valeur et la diffuse. Chaque fenêtre, y compris celles ouvertes plus tard, lit la même locale résolue. Le gestionnaire valide la valeur reçue, car les entrées IPC ne sont pas fiables.

Enregistrer le choix dans le localStorage du renderer est un raccourci courant, car l'écran des réglages s'exécute dans le renderer. La valeur reste alors dans le stockage d'une seule fenêtre, et le processus principal ne peut pas la lire avant l'ouverture d'une fenêtre. Or le menu et la barre d'état sont construits avant la première fenêtre, et ils ont donc besoin de la valeur du stockage du processus principal.

Le chemin d'un changement de langue

Un seul changement traverse les deux processus. La séquence ci-dessous indique quel côté gère chaque étape.

Flux de cinq blocs : le sélecteur du renderer appelle setPreference, le preload transmet la valeur sur le canal i18n:set, le processus principal la valide et la stocke, le processus principal résout la locale et reconstruit le menu natif, puis envoie i18n:changed à chaque fenêtre.

Le renderer ne résout pas lui-même "system". Si chaque processus résolvait la préférence séparément, une fenêtre pourrait afficher une autre langue que le menu après un changement de langue du système. Un seul résolveur dans le processus principal garantit que les deux restent identiques.

Valider la valeur dans le gestionnaire

Le script preload type l'argument, mais un renderer peut envoyer n'importe quelle valeur avec ipcRenderer.invoke. Le processus principal vérifie donc la valeur par rapport à la même liste que celle dont provient le type :

const isPreference = (value: unknown): value is LanguagePreference =>
  value === "system" || (typeof value === "string" && (SUPPORTED as readonly string[]).includes(value));

Une valeur refusée déclenche une exception, et le stockage n'est pas écrit. Seules les valeurs issues de ces deux listes peuvent atteindre store.set.

Fenêtres ouvertes après le changement

Une fenêtre créée après le changement n'attend pas la diffusion. Elle demande la locale au processus principal au chargement :

ipcMain.handle("i18n:get", () =>
  resolveLocale(store.get("language", "system") as LanguagePreference, app.getPreferredSystemLanguages()),
);

Le renderer appelle i18n:get une seule fois au démarrage, puis utilise i18n:changed. Les deux chemins appellent le même résolveur : une nouvelle fenêtre et une fenêtre existante ne peuvent donc pas diverger.

Fenêtres fermées pendant un changement

Une fenêtre fermée avant la diffusion ne figure plus dans BrowserWindow.getAllWindows(), et la boucle l'ignore. Une fenêtre qui se recharge appelle de nouveau i18n:get et reçoit la valeur actuelle. La diffusion se fait au mieux, et le chemin de chargement fournit la valeur correcte.

Tests qui protègent les frontières

  • Table du résolveur. Les entrées comme ["de-CH", "en-US"] (allemand), ["zh-TW", "en"] (anglais) et [] (anglais) correspondent aux locales attendues.
  • Complétude. Pour chaque locale livrée, chaque clé de en existe et n'est pas vide.
  • Parité des placeholders. Chaque traduction utilise les mêmes placeholders que l'anglais, de sorte que {count} n'est jamais supprimé ni renommé.
  • Formatage. La sortie des dates est vérifiée avec un fuseau horaire fixe, afin que les tests passent sur toutes les machines.
  • Gestionnaire IPC. Une préférence invalide est refusée, et une préférence valide déclenche la reconstruction du menu et la diffusion.
for (const [locale, catalog] of Object.entries(catalogs)) {
  for (const key of Object.keys(en) as MessageKey[]) {
    expect(catalog[key], `${locale}: ${key}`).toBeTruthy();
  }
}

Chaque test couvre une frontière. Le tableau ci-dessous indique ce que signifie l'échec de chaque test.

TestSignification d'un échec
Table du résolveurL'ordre des contrôles ou la table des alias a changé
ComplétudeUn catalogue livré ne contient pas une clé définie par l'anglais
Parité des placeholdersUne traduction a renommé ou supprimé une variable
FormatageLa sortie des dates ou des nombres dépend de la machine et non de la locale
Gestionnaire IPCUne entrée invalide a atteint le stockage, ou une fenêtre n'a pas été notifiée

Exécutez la suite à chaque modification d'un catalogue, et pas seulement avant une version. Une traduction qui renomme un placeholder se corrige au moindre coût dans la pull request qui l'introduit, alors que la coupe de version est l'endroit le plus coûteux pour le détecter.

Une petite couche i18n a encore besoin de frontières claires

Chaque décision trace une limite : intention contre locale résolue, noms contre libellés traduits, anglais comme contrat, repli contre complétude, phrases contre formatage, et renderer contre processus principal. En gardant ces limites, une couche écrite à la main de quelques centaines de lignes reste plus facile à raisonner qu'un framework configuré pour faire la même chose.

Un framework devient rentable lorsque les catalogues nécessitent des fonctions que la couche ne fournit pas, comme l'extraction des messages depuis le code source, des outils pour les traducteurs qui suivent l'état de relecture, ou le chargement à l'exécution de catalogues livrés séparément de l'application. Tant que ce besoin n'existe pas, les six décisions ci-dessus couvrent les défaillances courantes.

Les mêmes frontières s'appliquent aux autres fenêtres qui affichent du texte, comme une vue web ou une feuille de réglages. Chacune lit la locale résolue depuis le processus principal, utilise le même type de catalogue et passe le même test de complétude. Une nouvelle surface n'exige alors aucune nouvelle règle, seulement un nouvel appel à t.

Temposhot, l'application Mac dont traite ce site, est une application macOS native développée avec Xcode, et non une application Electron. Son réglage Language propose 7 options : System default et six langues affichées dans leur propre nom.

Transformer vos clics en guide pas à pas clair

Pour documenter un changement de réglage comme celui décrit ci-dessus, enregistrez-le avec Action Capture. Temposhot est disponible en téléchargement gratuit pour les Mac Apple Silicon sous macOS 14 ou une version ultérieure.

electroni18ntypescriptengineering

Transformez vos captures d'écran répétitives en résultats prêts à l'emploi

Temposhot capture l'écran à intervalles réguliers ou à chaque clic et exporte le résultat au format PDF, image, ZIP ou MP4.

Articles connexes