Tous les articles

Tous les articles

Servir le site en français sur ses propres chemins

Pourquoi le français vit sous /fr quand l'anglais garde les chemins nus, et comment un choix de langue suit le visiteur du site jusqu'à l'application.

Le site public est prérendu et indexé, sa langue a donc sa place dans l'URL, là où un moteur de recherche et un lien partagé peuvent tous deux la voir. L'anglais garde les chemins sans préfixe, et le français vit sous /fr : /services et /fr/services. L'application, servie depuis sa propre origine, garde au contraire sa langue hors de l'URL. Un seul cookie relie les deux moitiés.

Pourquoi /fr et pas /en#

Le schéma symétrique, avec /en/services à côté de /fr/services, aurait déplacé toutes les URL existantes pour une symétrie que personne ne lit. C'était pire à la racine. Le site est statique, donc / aurait dû devenir une page de redirection. Cette page aurait été l'URL la plus liée du site, un signal faible pour un robot d'indexation, et impossible à distinguer du point de montage vide que le build est justement là pour refuser. / porte en plus le script qui envoie un visiteur connecté vers l'application. L'anglais reste donc où il était, et l'asymétrie est voulue.

L'application a pris le chemin inverse. Ses pages ne sont pas indexées et on se les partage entre collègues par lien direct, un préfixe doublerait donc chaque route sans rien apporter. Là, la langue est une préférence enregistrée, envoyée avec chaque requête dans Accept-Language.

Un seul choix pour deux origines#

Le site et l'application sont deux origines, et le stockage du navigateur est propre à chaque origine, ce qui explique aussi qu'un choix de thème fait sur l'une reste invisible sur l'autre. Un cookie rattaché au domaine enregistrable, lui, peut être partagé : c'est donc là que vit le choix de langue.

L'ordre de lecture des sources compte. Un choix inscrit dans le cookie l'emporte, parce que c'est le seul qu'une personne a réellement fait. Les langues préférées du navigateur viennent ensuite, puisqu'elles ne disent que ce que le logiciel de quelqu'un a été configuré pour préférer. Le script d'avant affichage du site et l'API résolvent dans cet ordre. Dans l'application, une langue enregistrée sur le compte n'est adoptée qu'en l'absence de cookie, si bien que se connecter ne remplace jamais un choix fait sur l'appareil. Cette langue adoptée est rendue à la déconnexion, pour que la personne suivante qui se connecte sur la même machine n'hérite pas de la langue du compte précédent, ni de la langue des e-mails que ses requêtes déclenchent.

Écrire ce cookie s'est révélé plus délicat que le lire. Un navigateur jette en silence un cookie dont le Domain ne termine pas l'hôte courant, sans erreur et sans rien à constater ensuite. Sur une origine mal configurée, le seul symptôme serait un choix de langue qui ne survit pas à un rechargement. Chaque écriture est donc relue, et celle qui n'a pas tenu se rabat sur un cookie limité à l'hôte. Ce cookie n'atteint pas l'autre origine, mais il tient sur la page que le visiteur a sous les yeux. Lire document.cookie peut aussi lever une exception sur une origine opaque, et l'application lit le cookie pendant le chargement de ses modules : une lecture non protégée empêchait la page de jamais se monter.

Deux redirections avant le premier affichage#

Un visiteur qui arrive à la racine du site avec un navigateur en français est envoyé vers /fr avant tout affichage, plutôt que de voir l'anglais puis d'être corrigé un instant plus tard. La même racine avait déjà une redirection d'avant affichage, pour les visiteurs connectés, et l'ordre des deux ne pouvait pas se régler par leur seule position : location.replace n'arrête pas le script qui l'appelle. Sans garde-fou propre, le script de langue se serait exécuté après la redirection des visiteurs connectés et aurait envoyé un francophone connecté vers /fr, où cette redirection ne se déclenche jamais. Il serait resté bloqué sur le site marketing pour de bon.

L'autre piège était une boucle. Le script lit le cookie et ne doit jamais l'écrire, et le sélecteur de langue écrit le cookie et ne doit jamais le lire. Sans l'écriture, un navigateur en français qui cliquait sur English arrivait sur /, repartait aussitôt vers /fr, et y restait.

Un seul module pour l'arithmétique des chemins#

Chaque route existe une fois par langue, générée en croisant la table des routes avec la liste des langues, pour qu'aucune ne soit déclarée deux fois. Les pages qu'écrit le prérendu viennent de ce même croisement, si bien que les fichiers produits par le build et les routes servies par le routeur ne peuvent pas diverger.

Un seul module convertit les chemins dans les deux sens, et l'inverse doit être exact. Le test du préfixe est ancré sur le séparateur, pour que /france reste un chemin anglais. Ce module rend le chemin neutre en langue sur lequel s'appuient la table des routes, les alternatives, le prérendu et le sélecteur. Un repli des débuts lisait plutôt le chemin brut de la route, qui sur une page française porte déjà son préfixe. Le head annonçait alors comme alternative française de /fr/services l'adresse /fr/fr/services, et le sélecteur proposait le même lien doublé.

Hreflang, là où une erreur est invisible#

Chaque page porte lang, une URL canonique et un ensemble réciproque d'alternatives, x-default compris. Dans un navigateur, un groupe cassé ressemble trait pour trait à un groupe qui fonctionne. La canonique est construite par le même appel que les alternatives, si bien que l'alternative d'une page dans sa propre langue est égale à sa canonique par construction. Quand les deux étaient dérivées séparément, elles ne concordaient que par hasard.

Le build lit les fichiers produits plutôt que la configuration. Un lang erroné, une page française qui porte du texte anglais, une alternative cassée, un chunk de langue manquant ou le préchargement du catalogue de messages supprimé le font chacun échouer. Ces contrôles renvoient une liste d'échecs et ne lèvent pas d'exception. Une lecture de fichier non protégée faisait sortir toute l'exécution, en jetant chaque échec déjà collecté et en laissant une trace ENOENT là où la vérification venait de trouver ce qui n'allait pas.

Pas de bibliothèque d'internationalisation sur le site#

Le site a besoin d'une recherche dans un objet constant, avec des paramètres, et de rien d'autre. Il a reçu un petit traducteur d'une quarantaine de lignes. vue-i18n reste sur la liste des paquets interdits au site, que le build fait respecter sur les octets produits. Le champ de mot de passe du kit l'importe, et c'est cette liste qui l'attrape s'il arrive par un composant partagé. Le traducteur est fourni par instance d'application et non conservé dans un module, parce que le prérendu rend chaque page dans les deux langues dans un même processus, et un traducteur partagé ferait fuir la langue d'une page dans la suivante. La substitution des paramètres ne lit que les clés que l'objet de messages possède lui-même. Sans ça, un message contenant {toString} aurait inséré dans la page une fonction venue du prototype.

Ce que seul le français pouvait casser#

Plusieurs bugs ne pouvaient apparaître que dans la deuxième langue. Le moteur de rendu serveur échappe l'apostrophe en ', et la vérification du build comparait les titres bruts. Tous les titres anglais étaient faits de mots simples, donc la vérification passait jusqu'au premier titre français avec une apostrophe. Le logo de l'en-tête pointait vers /, une route que la table française ne contient pas. Le routeur avertissait une fois par page française prérendue, ce qui passait pour du bruit de build, et un visiteur qui cliquait sur le logo quittait le français. Le chargement du catalogue ajoutait un aller-retour avant l'hydratation, ce qu'un test clavier a attrapé en appuyant sur Tab trop tôt. Le prérendu précharge désormais le catalogue de chaque langue. Enfin, l'en-tête et le pied de page dessinaient tous deux un repère de navigation nommé Langue, deux entrées que personne ne pouvait distinguer dans la liste d'un lecteur d'écran.

L'API, en fin de compte, était prête depuis un moment. Elle portait des catalogues traduits, négociait Accept-Language, et les e-mails partaient dans la langue de chacun. Rien de tout ça n'était atteignable, parce que le frontend n'envoyait jamais l'en-tête : il s'accordait avec l'anglais codé en dur de l'application par pure coïncidence. L'API lit maintenant les valeurs de qualité comme elles sont définies, si bien que fr;q=0 est un refus et non une demande. Elle n'enregistre aussi une langue sur le compte que quand une requête en a réellement nommé une, parce qu'un script ou un simple curl n'en nomme aucune, et y inscrire l'anglais aurait masqué la vraie préférence pour de bon. Son catalogue néerlandais est parti : personne ne le traduisait, et porter une troisième langue à travers chaque contrôle de parité coûtait plus que de le supprimer.

Cinq semaines plus tard, les deux sélecteurs de langue sont devenus le même contrôle : une puce qui montre la langue en vigueur, avec son drapeau, et qui ouvre un popover natif listant chaque langue. Il ne demande aucun script, et sur le site il fonctionne donc sur la page prérendue avant l'hydratation, pour environ 2,4 Ko sur le réseau.