SvelteKit, seelf et localisation

Publié dans développement
le

La version 1.2.0 de seelf marque l'arrivée de la localisation de l'application en anglais et en français. Petit retour sur le choix technique que j'ai retenu.

Le cahier des charges

Première chose à faire, définir le besoin. Pour la localisation de seelf, mon cahier des charges ressemblait donc à ceci :

  • Pouvoir localiser des messages, des dates, des durées et des nombres
  • Pouvoir ajouter facilement une nouvelle langue
  • Ne pas pouvoir utiliser une clé de traduction non définie
  • Pouvoir paramétrer les traductions lorsque nécessaire

Un besoin au final assez classique quand on parle d'internationalisation. Cela dit, pour ma part, l'un des points essentiels était que le process de build échoue dans le cas de l'utilisation d'une traduction manquante ou mal paramétrée.

Les librairies existantes et leurs limites

La partie frontend de seelf est réalisée avec l'aide du framework SvelteKit en utilisant le langage Typescript. La suite logique était donc de regarder les librairies existantes en terme de localisation avec ce framework.

Un petit tour sur un moteur de recherche me permet de mettre en avant un certain nombre de librairies :

Seulement, aucune ne parvient à me convaincre suffisamment. Pour la majorité de ces librairies, l'interpolation (donc le paramétrage des traductions) me semble bien trop complexe. Par exemple, avec sveltekit-i18n, vous pouvez avoir des chaînes de traduction de ce style :

{
  "placeholder": "Title with {{placeholder}}.",
  "placeholder_with_default_value": "{{placeholder; default:Default value;}}.",
  "modifier": "{{gender; female:She; male:He;}} has a dog.",
  "combined": "You have {{number:gt; 0:{{number}} new {{number; 1:message; default:messages;}}!; default:no messages.;}}"
}

Et même si je comprends l'utilité de cette syntaxe dans le cadre d'un fichier de traduction en JSON, cela restait trop compliqué pour mes besoins. Notons aussi qu'aucun typage n'est disponible avec ce genre de système et que donc, on peut se retrouver à oublier un argument nécessaire à la traduction ou à fournir une valeur du mauvais type.

Autre point intéressant à mentionner, chacune de ces solutions retourne la valeur de traduction sous forme de store svelte ce qui permet de changer à la volée et de manière réactive la langue de l'application. Même si je ne remet pas en cause la performance des stores tels qu'implémentés par Svelte, je trouvais dommage d'en payer le prix sachant que la langue n'est que très rarement changée au sein de l'application. Rafraîchir la page lorsque cela arrive me semblait tout à fait acceptable.

La solution retenue

Vous l'aurez sans doute compris, au final, j'ai préféré partir sur une solution sur-mesure adaptée parfaitement à mes besoins.

J'ai simplifié volontairement les exemples ci-dessous mais vous pouvez retrouver le code complet dans le dépôt Github de seelf.

Le typage des traductions

Comme je le disais au début, le point essentiel était le typage des traductions. Si je traduis (Haha !) ce besoin en Typescript, cela signifie qu'un dictionnaire de traductions est défini comme suit :

/**
 * Les traductions de l'application sont soit des chaînes
 * brutes, soit des fonctions prenant un nombre variable
 * de paramètres et retournant une chaîne.
 */
type Translations = Record<string, string | ((...args: any[]) => string)>;

Et oui, comme l'application utilise déjà Typescript, autant en profiter pour s'appuyer dessus et définir les traductions de l'application dans des fichiers .ts !

Le formatage des dates et nombres

Autre point important, pouvoir localiser des dates et des nombres. Pour se faire, notre service de localisation devra implémenter une petite interface :

type DateValue = string | number | Date;

interface FormatProvider {
  date(value: DateValue): string;
  datetime(value: DateValue): string;
  duration(start: DateValue, end: DateValue): string;
}

Et comme parfois une traduction paramétrée nécessite de formater le même genre de données, on peut venir compléter notre définition de Translations pour que le this de la fonction soit au final un FormatProvider :

/**
 * Désormais si je défini une traduction comme nécessitant un ou plusieurs
 * paramètres, je pourrai utiliser `this.date` ou `this.datetime` pour localiser
 * des données.
 */
type TranslationFunc = (this: FormatProvider, ...args: any[]) => string;
type Translations = Record<string, string | TranslationFunc>;

Les langues (ou locales)

Dernier point du cahier des charges, pouvoir ajouter facilement une nouvelle langue. De la même manière que pour les traductions, les langues sont donc définies par un type :

type Locale<T extends Translations> = {
  code: string;
  displayName: string;
  translations: T;
};

Lors de la définition de la langue par défaut (l'anglais sur seelf), j'ai donc un fichier en.ts qui ressemble grosso modo à ça :

const translations = {
  // (...)
  "auth.signin.title": "Sign in",
  "auth.signin.description":
    "Please fill the form below to access your dashboard.",
  "deployment.details_tooltip": (deployNumber: number) =>
    `View deployment #${deployNumber} details and logs`,
  // (...)
} satisfies Translations;

export default {
  code: "en",
  displayName: "English",
  translations,
} as const satisfies Locale<typeof translations>;

Le mot-clé satisfies apparu dans la version 4.9 de Typescript me permet de m'assurer que les traductions respectent les valeurs possibles (chaîne ou fonction) sans pour autant modifier le type de translations.

Quand je défini une nouvelle langue, il me suffit donc de m'assurer que les clés de traductions respectent le type défini par typeof translations dans le fichier en.ts. Par exemple pour le français, on a le fichier fr.ts :

/**
 * Représente les clés de traduction propres à l'application,
 * `en` étant l'import du fichier `en.ts`
 */
type AppTranslations = (typeof en)["translations"];

export default {
  code: "fr",
  displayName: "Français",
  translations: {
    // (...)
    "auth.signin.title": "Connexion",
    "auth.signin.description":
      "Remplissez le formulaire ci-dessous pour accéder au tableau de bord.",
    "deployment.details_tooltip": (deployNumber: number) =>
      `Voir les détails et logs du déploiement #${deployNumber}`,
    // (...)
  },
} as const satisfies Locale<AppTranslations>;

Grâce à tous ces types, je m'assure que les langues définies possèdent toutes les clés ainsi que les mêmes paramètres que la langue par défaut. Impossible donc d'oublier de traduire une clé ou de se tromper dans les types de paramètres.

Le service de localisation

Dernière étape et non des moindres, utiliser tout ce qu'on a vu précédemment et venir traduire toute l'interface. Pour se faire, j'ai donc un service de localisation respectant encore une fois une petite interface :

// Extrait toutes les clés d'un certain type
type KeysOfType<O, T> = {
  [K in keyof O]: O[K] extends T ? K : never;
}[keyof O];

// Extrait le type des arguments d'une fonction
type TranslationsArgs<T> = T extends (...args: infer P) => string ? P : never;

// Le service de localisation (simplifié pour l'article ici)
interface LocalizationService<T extends Translations> extends FormatProvider {
  translate<TKey extends KeysOfType<T, string>>(key: TKey): string;
  translate<TKey extends KeysOfType<T, TranslationFunc>>(
    key: TKey,
    args: TranslationsArgs<T[TKey]>
  ): string;
}

class LocalizationServiceImplementation<T extends Translations>
  implements LocalizationService<T> {
  /** Implémentation du service en utilisant les services fournis dans Intl ;) */
}

/**
 * Plus loin, une implémentation de LocalizationService<AppTranslations>
 * est exportée et donc disponible au sein de l'application avec quelque chose
 * dans ce genre.
 */
export default new LocalizationServiceImplementation<AppTranslations>();

Et c'est là que la magie opère. Grâce au type KeysOfType<>, je m'assure que si la traduction est une fonction (KeysOfType<T, TranslationFunc>) alors des paramètres doivent être fournis et posséder le bon type, et grâce au T extends Translations, le premier argument de la fonction translate doit être une clé valide.

Côté page ou composant, il nous suffit alors d'importer le service qui implémente cette interface et d'utiliser la fonction translate :

<script lang="ts">
  import l from '$lib/localization';
</script>

<p>{l.translate('auth.signin.title')}</p>
<p>{l.translate('deployment.details_tooltip', [42])}</p>

Petit bonus, la plupart du temps dans des applications multi-langues, il est difficile de savoir si une propriété d'un composant doit être une clé de traduction ou bien une chaîne déjà localisée. Avec l'approche exposée ici, j'ai un type AppTranslationsString qui me permet de typer certaines propriétés pour rendre le tout explicite :

<script lang="ts">
  // Où AppTranslationsString = KeysOfType<typeof en['translations'], string>
  import l, { type AppTranslationsString } from '$lib/localization';

  export let label: AppTranslationsString;
</script>

<button on:click>{l.translate(label)}</button>

Conclusion

Alors bien sûr tout n'est pas encore parfait. J'aimerai par exemple avoir un chargement des langues uniquement si elles sont utilisées par l'utilisateur pour éviter de charger des traductions inutiles.

Cela dit, je voulais partager avec vous cette petite solution qui me semble élégante et un peu à contre-courant.

Cette solution sur-mesure m'a effectivement évité d'avoir à faire des concessions sur mon cahier des charges en prenant une solution existante qui aurait été perfectible.