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.