speedster-js Guide

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.