Internationalisation
t est disponible dans tout composant, sans import : le compilateur l'ajoute
lui-même, et seulement s'il sert.
<h1>{t.panier.titre}</h1>
<p>{t.panier.articles({ n: articles.length })}</p>
Un message sans variable est une propriété : t.panier.titre, sans parenthèses.
Rien ne justifie d'appeler ce qui ne prend rien, et la lecture reste réactive — elle
passe par un accesseur, pas par une valeur figée à la langue de départ. Un message
qui prend des variables reste une fonction, puisqu'il faut bien les lui donner.
Un appel resté en place n'est pas une surprise à l'exécution mais une erreur de compilation, à la bonne ligne :
src/App.spd:10:12 — erreur TS2349
This expression is not callable. Type 'String' has no call signatures.
Rien de l'internationalisation ne part dans le bundle : ni analyseur de messages, ni table, ni fonction d'aide. Les messages sont compilés en expressions pures, dans des modules de langue autonomes.
Mise en place
1. Les traductions
Un fichier JSON par langue, dans un dossier :
src/lang/
fr.json
en.json
{
"panier": {
"titre": "Votre panier",
"vide": "Votre panier est vide",
"articles": "{n, plural, =0 {aucun article} one {# article} other {# articles}}",
"ajoute": "{prenom} a ajouté {quoi}"
},
"salut": "Bonjour {prenom}"
}
La syntaxe est un sous-ensemble d'ICU MessageFormat — celle que les traducteurs connaissent et que leurs outils savent lire.
2. Compiler le catalogue
bunx speedster i18n src/lang --out src/lang --default fr
Cela produit, à côté des traductions :
src/lang/
fr.json la traduction, écrite à la main
en.json
types.ts l'interface Catalogue, déduite de la langue de référence
fr.ts les messages français, compilés
en.ts les messages anglais, compilés
index.ts `t`, `locale`, et le chargement à la demande
Un seul dossier à connaître. Les fichiers compilés portent leur propre en-tête
« Fichier généré — ne pas modifier à la main », ce qui les distingue sans qu'il faille
les ranger ailleurs. Rien n'oblige à ce choix : --out accepte n'importe quel
dossier, à condition que l'alias suive.
À relancer quand les traductions changent. speedster init écrit déjà la ligne :
{ "scripts": { "i18n": "speedster i18n src/lang --out src/lang --default fr" } }
Le build.ts et le serveur de développement d'un projet amorcé appellent la même
chose sans passer par un terminal :
import { i18n } from '@ajustor/speedster/build'
const messages = await i18n.compileProject({
source: 'src/lang',
out: 'src/lang',
defaultLocale: 'fr',
// Le module d'où le catalogue importe le signal de langue. La commande le déduit
// du `package.json` ; ici on l'écrit, puisque le projet ne déclare que la façade.
runtimeModule: '@ajustor/speedster',
})
Une clé qui s'écarte de la langue de référence n'écrit rien : un catalogue partiel serait pire que pas de catalogue, puisqu'il compilerait. Un JSON à moitié tapé lève, ce qui permet au serveur de développement de continuer à tourner là où un build s'arrête.
3. L'alias
Le catalogue est généré depuis votre projet : il ne peut pas vivre dans un paquet. Un alias est la seule cible qui fonctionne depuis un composant quelle que soit sa profondeur.
// tsconfig.json
{
"compilerOptions": {
"paths": { "$messages": ["./src/lang/index.ts"] }
}
}
// build.ts
speedster({ i18nModule: '$messages' })
$messages est le défaut du compilateur : si vous gardez ce nom, il n'y a rien à
passer au plugin. speedster check lit le tsconfig.json du projet, donc l'alias y
est résolu comme au build — et l'éditeur aussi, ce qui est ce qui donne son type à
t. Si vous changez i18nModule, dites-le également à l'extension par
speedster.i18nModule, sinon elle résoudrait un module que le build n'utilise pas.
speedster init déclare cet alias d'avance, même sans traduction : l'ajouter après
coup est l'oubli classique, et il ne se manifeste pas clairement — t perd son type
dans l'éditeur, et le build échoue sur Could not resolve: "$messages".
4. Changer de langue
<script lang="ts">
import { locale, type LocaleCode } from '$messages'
const changer = (code: LocaleCode) => { void locale.set(code) }
</script>
<button on:click={() => changer('en')} aria-current={locale.code() === 'en'}>
English
</button>
<p>{t.panier.titre}</p>
locale.set charge la langue si nécessaire puis l'active. Les deux écritures sont
groupées : l'affichage ne passe jamais par un état où la langue et le catalogue se
contredisent.
Le changement réexécute exactement les positions qui affichent un message, et rien d'autre. Aucun composant n'est remonté.
Syntaxe des messages
Variables
"salut": "Bonjour {prenom}"
{t.salut({ prenom })}
Le type de prenom est déduit de l'usage : string | number ici, number s'il
sert de sélecteur de pluriel.
Pluriel
"{n, plural, =0 {aucun article} one {# article} other {# articles}}"
# est la valeur, mise en forme selon la langue : 1 234 en français,
1,234 en anglais. Les catégories sont celles du CLDR : zero, one, two,
few, many, other, plus les correspondances exactes =0, =1…
other est obligatoire : sans elle, une langue dont les règles diffèrent de celle
du traducteur n'aurait aucune branche à afficher.
offset: retranche une valeur avant le choix de la catégorie et l'affichage de # :
"{n, plural, offset:1 =0 {personne} one {vous et une autre} other {vous et # autres}}"
Choix
"{genre, select, femme {Elle} homme {Il} other {Iel}} a répondu"
Nombres, dates et heures
| Écriture | fr |
en |
|---|---|---|
{n, number} |
1 234,5 |
1,234.5 |
{n, number, integer} |
1 235 |
1,235 |
{n, number, percent} |
42 % |
42% |
{d, date} |
15 mars 2024 |
Mar 15, 2024 |
{d, date, short} · medium · long · full |
||
{d, time, short} — mêmes styles |
14:30 |
2:30 PM |
Une variable datée est typée Date | number : les deux arrivent d'une API sans
qu'on ait le choix.
currency est volontairement absent : il exige un code de devise, qui n'appartient
ni au message ni à la langue. Passez la valeur déjà formatée, ou écrivez le format
dans votre code.
Apostrophe
Comme en ICU, l'apostrophe ouvre une section littérale :
"'{n}' reste tel quel" → {n} reste tel quel
"aujourd''hui" → aujourd'hui
Ce que le build vérifie
Une traduction incomplète est une erreur de build, pas une chaîne manquante découverte en production.
| Code | Ce qu'il signale |
|---|---|
missing-message |
Une clé de la langue de référence absente d'une autre |
missing-variable |
Un message qui n'emploie pas une variable que la référence emploie |
variable-mismatch |
Une variable dont le type diffère d'une langue à l'autre |
unknown-variable |
Une variable absente de la référence |
unknown-message |
Une clé qui n'existe pas dans la référence (avertissement) |
invalid-message |
Syntaxe ICU invalide |
Dans les composants, une clé qui n'existe pas est une erreur de type :
t.panier.titr est refusé par speedster-check, sur la ligne du composant.
Les clés sont typées
L'interface Catalogue est déduite de la langue de référence :
readonly articles: (values: { readonly n: number }) => string
readonly titre: () => string
Appeler t.panier.articles() sans argument, ou avec { n: 'trois' }, est une
erreur de compilation — signalée sur la ligne du composant.
Ce qui est réellement livré
// src/lang/fr.ts, généré
import type { Catalogue } from './types.js'
const $fmt1 = new Intl.NumberFormat('fr')
const $fmt2 = new Intl.PluralRules('fr')
const messages: Catalogue = {
panier: {
titre: () => 'Votre panier',
articles: ($v) =>
$v.n === 0 ? 'aucun article'
: $fmt2.select($v.n) === 'one' ? `${$fmt1.format($v.n)} article`
: `${$fmt1.format($v.n)} articles`,
},
}
export default messages
Le module n'importe rien du framework. Un pluriel est une chaîne de conditions,
pas une fonction immédiatement appelée : aucune allocation à chaque affichage. Les
formateurs Intl sont construits une fois, au chargement du module, et partagés par
tous ses messages.
Une langue non lue n'est pas téléchargée
La langue de référence est dans le bundle principal — le premier affichage n'attend rien. Les autres sont des imports dynamiques, donc des morceaux séparés :
const loaders = { en: () => import('./en.js') }
Mesuré : une application traduite en deux langues pèse 2,72 ko gzip à l'entrée, contre 2,54 ko sans traduction. Le catalogue anglais n'y est pas.
Côté serveur
Le catalogue fonctionne à l'identique. Le rendu étant entièrement synchrone, régler la langue juste avant de rendre est sûr, même sur un serveur qui traite plusieurs requêtes :
import { renderToString } from '@ajustor/speedster-runtime/server'
import { locale } from './messages/index.js'
import Page from './Page.spd'
await locale.set(langueDemandee)
const html = renderToString(Page, { titre })
Voir Rendu serveur.