Commit c8d33ad5 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Le serveur formulait en français dans une interface réglée en anglais

L'interface a été rendue bilingue et mesurée à cent pour cent. Restait ce que
le serveur dit, et cela se voyait : « This indicator does not grade for the sake
of grading » suivi de « Tour d'astreinte sans trou ». Deux cent une phrases —
refus des routes, accusés, libellés de l'indicateur de préparation, messages de
validation, résumés d'événements du journal.

── Un seul point de traduction pour cent trente-sept messages

Le champ « message » d'une réponse JSON est, par convention de ce serveur, une
phrase adressée au lecteur : un refus qui dit quoi corriger, ou un accusé qui
dit ce qui vient d'être fait. Un crochet de sérialisation le traduit, une fois,
plutôt qu'un appel par route.

Cette convention avait une exception, et je l'ai retirée plutôt que de bâtir un
garde-fou autour d'elle : le lien public de mobilisation renvoyait le texte de
la campagne sous « message ». C'est du contenu écrit par la cellule ; il voyage
désormais sous « texte », comme partout ailleurs dans ce dépôt. Le crochet n'a
donc plus rien à éviter.

Onze messages portent une valeur interpolée — un nom, un délai, un rôle. Ceux-là
se traduisent à la source, avec « req.traduire », la substitution ne pouvant pas
se faire après coup.

── Les nombres, généralisés

Les messages de validation portent leur seuil dans leur texte : « au moins 2
caractères », « au moins 10 caractères ». Une entrée par seuil serait absurde.
La traduction tente la clé exacte, puis la même clé dont les nombres sont
remplacés par « {n} » — « au moins {n} caractères » — et remet les nombres à
leur place. Une entrée couvre tous les seuils.

── Ce qui ne se traduit pas, et ne doit pas

Ce que la cellule a écrit. Une entrée de main courante, un message de salon, un
texte de mobilisation sont des faits datés, versés au journal, relus des mois
plus tard par un assureur ou un juge. Les traduire serait réécrire une preuve.
Seule la formulation du produit se traduit — y compris les résumés que la main
courante dérive du journal à la lecture, qui sont une formulation et non un
fait.

── Une erreur que deux garanties ont vue avant moi

J'avais donné l'identité pour traducteur par défaut : « (fr) => fr ». Elle rend
le texte sans substituer, et « Gravité portée de {de} à {vers} — {motif} »
partait tel quel vers tout appelant sans langue — la suite de vérification, le
dossier de clôture, l'export du journal. EF-1203 et EF-209 sont tombées. Le
défaut est désormais le français, substitution comprise.

── Deux dictionnaires, une convention

Celui du serveur est distinct de celui de l'interface : le navigateur n'a pas à
télécharger les messages du serveur, ni le serveur à embarquer les libellés des
écrans. Même règle de part et d'autre — la clé est le texte français, une clé
absente s'affiche en français.

La langue vient de l'en-tête « accept-language », que le client envoie à chaque
requête. C'est le choix fait dans les préférences qui part, non la langue du
navigateur : quelqu'un dont le système est en anglais peut préférer lire Kastell
en français.

Éprouvé sur l'instance : le même refus rend « Session required. » ou « Session
requise. » selon l'en-tête, « at least 10 characters » conserve son seuil, et
sans en-tête c'est le français. Une garantie neuve compte les phrases du serveur
comme l'autre compte celles de l'interface — 201 sur 201.

Co-Authored-By: Claude (RCA)
parent 3bd78b67
Loading
Loading
Loading
Loading
Loading
+305 −0
Changes for apps/api/src/langues/en.ts: 305 added lines, 0 removed lines.
Original line number Diff line number Diff line
/**
 * English dictionary for what the server says.
 *
 * Keyed by the French source text, like the interface dictionary — a missing
 * key falls back to French, which is the product's own language and always
 * readable, rather than to a technical identifier.
 *
 * These are the product's own sentences: refusals that say what to correct,
 * acknowledgements that say what was just done, and the journal summaries the
 * log derives at read time. What the cell wrote is never here: an incident log
 * entry, a room message, a call-out text are dated facts, and translating them
 * would be rewriting evidence.
 *
 * Numbers carried inside a sentence are matched by a "{n}" template, so one
 * entry covers every threshold — see traduire() in ./index.ts.
 */
export const EN: Record<string, string> = {
  " ; et {n} autre(s).": " ; and {n} other(s).",
  "32 Mo au maximum pour un élément du kit.": "32 MB maximum for a kit item.",
  "Accès déjà clos.": "Access already closed.",
  "Accès refermé.": "Access closed.",
  "Accès refusé.": "Access refused.",
  "Action inconnue.": "Unknown action.",
  "Action réaffectée.": "Action reassigned.",
  "Activation de {nom} consignée.": "Activation of {nom} logged.",
  "Adresse confirmée.": "Address confirmed.",
  "Appareil inconnu.": "Unknown device.",
  "Arbitrage demandé — {objet}": "Decision requested — {objet}",
  "Arbitrage demandé. Il reste visible jusqu'au tranchage.":
    "Decision requested. It stays visible until settled.",
  "Arbitrage rendu — {objet} : {retenue}": "Decision settled — {objet}: {retenue}",
  "Arbitrage rendu.": "Decision settled.",
  "Au-delà de 32 Mo, passez par un dépôt reprenable : une coupure ne doit pas coûter tout le transfert.":
    "Beyond 32 MB, use a resumable upload: a dropped connection must not cost the whole transfer.",
  "Aucun instantané n'existait à cet instant : le tableau n'avait pas encore servi.":
    "No snapshot existed at that moment: the board had not been used yet.",
  "C'est noté, {nom}.": "Noted, {nom}.",
  "Campagne inconnue.": "Unknown campaign.",
  "Canal inconnu.": "Unknown channel.",
  "Ce code est inconnu ou périmé. Demandez-en un nouveau depuis votre session web.":
    "This code is unknown or out of date. Ask for a new one from your web session.",
  "Ce document est en quarantaine : son contenu n'est pas servi.":
    "This document is quarantined: its content is not served.",
  "Ce lien a déjà servi ou a expiré. Redemandez-le depuis l'application.":
    "This link has already been used or has expired. Request it again from the application.",
  "Ce lien de mobilisation n'est pas reconnu.": "This call-out link is not recognised.",
  "Ce lien est expiré, révoqué ou invalide.":
    "This link has expired, been revoked, or is invalid.",
  "Ce lien n'est pas reconnu.": "This link is not recognised.",
  "Ce message exact est parti il y a {n} s. Rien n'a été renvoyé — vos destinataires l'ont déjà. Suivez la campagne en cours, ou modifiez le texte si vous avez quelque chose à ajouter.":
    "This exact message went out {n} s ago. Nothing was resent — your recipients already have it. Follow the campaign under way, or change the text if you have something to add.",
  "Ce passage n'ouvre que les écrans de crise, de préparation, de restitution et de veille.":
    "This pass only opens the crisis, preparedness, handover and monitoring screens.",
  "Cellule ouverte — {libelle}": "Cell opened — {libelle}",
  "Cellule « {libelle} » ouverte, avec son salon et sa salle. Son cockpit préparé s'applique s'il en existe un pour ce code.":
    "Cell “{libelle}” opened, with its room and its call. Its prepared cockpit applies if one exists for that code.",
  "Cet appareil a été révoqué. Effacez ses données et enrôlez-le à nouveau.":
    "This device has been revoked. Wipe its data and enrol it again.",
  "Cet appareil n'est pas enrôlé sur cette instance.":
    "This device is not enrolled on this instance.",
  "Cet envoi partira à {n} destinataire(s). Recopiez ce nombre pour confirmer.":
    "This send will go to {n} recipient(s). Type that number again to confirm.",
  "Cet exercice est terminé.": "This exercise is over.",
  "Cette action demande le rôle {attendu}. Le vôtre est « {role} ».":
    "This action requires the {attendu} role. Yours is “{role}”.",
  "Cette clé ne porte pas « {portee} ». Portées accordées : {accordees}.":
    "This key does not carry “{portee}”. Scopes granted: {accordees}.",
  "Cette notification ne correspond à rien que cet appareil puisse voir.":
    "This notification matches nothing this device can see.",
  "Cette réponse n'était pas proposée pour ce recensement.":
    "That response was not offered for this headcount.",
  "Cette réponse n'était pas proposée pour cette mobilisation.":
    "That response was not offered for this call-out.",
  "Clé déjà révoquée.": "Key already revoked.",
  "Clé inconnue ou révoquée.": "Unknown or revoked key.",
  "Clé révoquée.": "Key revoked.",
  "Compte créé. Vérifiez votre adresse pour l'activer.":
    "Account created. Check your address to activate it.",
  "Compte désactivé et sessions révoquées.": "Account deactivated and sessions revoked.",
  "Compte réactivé.": "Account reactivated.",
  "Contenu absent.": "Content missing.",
  "Crise close": "Crisis closed",
  "Crise inconnue.": "Unknown crisis.",
  "Crise introuvable.": "Crisis not found.",
  "Crise ouverte — {intitule} ({gravite})": "Crisis opened — {intitule} ({gravite})",
  "Destinataire retiré.": "Recipient removed.",
  "Document inconnu.": "Unknown document.",
  "Document retiré du dossier en cours. Son historique demeure.":
    "Document removed from the current file. Its history remains.",
  "Droit root accordé.": "Root right granted.",
  "Droit root retiré.": "Root right revoked.",
  "Décision — {objet} : {retenue}": "Decision — {objet}: {retenue}",
  "Déconnecté.": "Signed out.",
  "Dépôt inconnu.": "Unknown upload.",
  "Enregistrement de réunion démarré": "Meeting recording started",
  "Enregistrement déjà supprimé.": "Recording already deleted.",
  "Entrée en cellule au titre de {role}": "Joined the cell as {role}",
  "Envoyez la partie en application/octet-stream.": "Send the part as application/octet-stream.",
  "Erreur interne.": "Internal error.",
  "Escalade vers {vers} après {n} min": "Escalated to {vers} after {n} min",
  "Exercice clos.": "Exercise closed.",
  "Exercice inconnu.": "Unknown exercise.",
  "Fiche mise à jour.": "Record updated.",
  "Fiche modifiée.": "Record changed.",
  "Fiche retirée.": "Record removed.",
  "Fiche révisée.": "Record revised.",
  "Fichier supprimé. Le journal conserve qu'il a existé, par qui et pourquoi.":
    "File deleted. The log keeps that it existed, by whom and why.",
  "Gravité portée de {de} à {vers} — {motif}": "Severity raised from {de} to {vers} — {motif}",
  "Identifiant d'organisation invalide : vérifiez l'adresse.":
    "Invalid organisation identifier: check the address.",
  "Identifiant de crise invalide : vérifiez l'adresse.":
    "Invalid crisis identifier: check the address.",
  "Identité d'appareil illisible.": "Unreadable device identity.",
  "Intervenant externe admis en cellule": "External participant admitted to the cell",
  "La fin précède le début.": "The end precedes the start.",
  "Le SMS demande un numéro : renseignez-le, ou décochez ce canal.":
    "SMS needs a number: enter one, or untick this channel.",
  "Le tableau a changé entre-temps : voici l'état courant.":
    "The board changed in the meantime: here is the current state.",
  "Les codes précédents sont désormais invalides.": "The previous codes are now invalid.",
  "Lien invalide ou expiré.": "Invalid or expired link.",
  "Liste supprimée.": "List deleted.",
  "Merci {nom}, votre présence est enregistrée.": "Thank you {nom}, your presence is recorded.",
  "Message consigné en main courante.": "Message added to the incident log.",
  "Message inconnu.": "Unknown message.",
  "Mobilisation close : relances et escalades s'arrêtent.":
    "Call-out closed: reminders and escalations stop.",
  "Mobilisation — {n} destinataire(s)": "Call-out — {n} recipient(s)",
  "Modèle modifié.": "Template changed.",
  "Modèle retiré du dossier en vigueur.": "Template removed from the dossier in force.",
  "Mot de passe remplacé.": "Password replaced.",
  "Objectif de {objectif} présents pour {cibles} personne(s) sollicitée(s) : il ne pourrait jamais être atteint.":
    "A target of {objectif} present for {cibles} person(s) contacted: it could never be reached.",
  "Organisation absente.": "Organisation missing.",
  "Organisation introuvable.": "Organisation not found.",
  "Organisation renommée.": "Organisation renamed.",
  "Organisation réactivée.": "Organisation reactivated.",
  "Organisation supprimée.": "Organisation deleted.",
  "Organisation suspendue.": "Organisation suspended.",
  "Ouvrir une cellule revient de la direction de crise.":
    "Opening a cell is for the crisis management.",
  "Partage déjà clos.": "Share already closed.",
  "Partage révoqué.": "Share revoked.",
  "Passage en phase « {vers} »": "Moved to phase “{vers}”",
  "Personne inconnue.": "Unknown person.",
  "Personne ne correspond. Un recensement sans destinataire ne recense rien.":
    "Nobody matches. A headcount with no recipient counts nobody.",
  "Personne retirée du dossier en vigueur.": "Person removed from the dossier in force.",
  "Point de situation publié": "Situation report published",
  "Point introuvable.": "Report not found.",
  "Présentez la clé d'intégration en en-tête Authorization: Bearer.":
    "Present the integration key in an Authorization: Bearer header.",
  "Présentez votre second facteur.": "Present your second factor.",
  "Présentez à nouveau votre second facteur pour accéder à la console.":
    "Present your second factor again to reach the console.",
  "Présentez à nouveau votre second facteur pour autoriser un appareil.":
    "Present your second factor again to authorise a device.",
  "Période inconnue.": "Unknown period.",
  "Période retirée.": "Period removed.",
  "Quotas mis à jour.": "Quotas updated.",
  "Recensement inconnu.": "Unknown headcount.",
  "Requête invalide — {problemes}": "Invalid request — {problemes}",
  "Ressource inconnue.": "Unknown resource.",
  "Ressource introuvable.": "Resource not found.",
  "Réaction enregistrée.": "Reaction recorded.",
  "Réglage enregistré.": "Setting saved.",
  "SMTP_FROM est requis dès que SMTP_HOST est renseigné":
    "SMTP_FROM is required as soon as SMTP_HOST is set",
  "Salle de visioconférence fermée": "Video room closed",
  "Salle de visioconférence fermée — {motif}": "Video room closed — {motif}",
  "Salle de visioconférence ouverte — {libelle}": "Video room opened — {libelle}",
  "Salle fermée.": "Room closed.",
  "Salle inconnue.": "Unknown room.",
  "Salon fermé. Son contenu reste au journal.": "Channel closed. Its content stays in the log.",
  "Salon inconnu.": "Unknown channel.",
  "Second facteur réinitialisé, sessions révoquées.": "Second factor reset, sessions revoked.",
  "Session absente.": "Session missing.",
  "Session expirée.": "Session expired.",
  "Session introuvable.": "Session not found.",
  "Session requise.": "Session required.",
  "Session révoquée.": "Session revoked.",
  "Seul l'animateur de la cellule ou le directeur de crise enregistre.":
    "Only the cell facilitator or the crisis director can record.",
  "Seul le directeur de crise ou l'animateur de la cellule y fait entrer quelqu'un.":
    "Only the crisis director or the cell facilitator can bring someone in.",
  "Seul le directeur de crise supprime un enregistrement.":
    "Only the crisis director can delete a recording.",
  "Si un compte existe pour cette adresse, un lien vient d'être envoyé.":
    "If an account exists for this address, a link has just been sent.",
  "Source inconnue.": "Unknown source.",
  "Statut mis à jour.": "Status updated.",
  "Surcharge retirée — la valeur du fichier reprend.":
    "Override removed — the value from the file applies again.",
  "Tableau fermé, après un dernier instantané.": "Board closed, after one last snapshot.",
  "Tableau inconnu.": "Unknown board.",
  "Tiers activé — {nom}": "Third party activated — {nom}",
  "Tiers introuvable.": "Third party not found.",
  "Tiers mis à jour.": "Third party updated.",
  "Tiers retiré du dossier en vigueur.": "Third party removed from the dossier in force.",
  "Un observateur ne modifie pas la bibliothèque de l'organisation.":
    "An observer does not change the organisation's library.",
  "Un observateur ne modifie pas le dossier de préparation. C'est la même règle qu'à froid.":
    "An observer does not change the preparedness dossier. Same rule as in calm conditions.",
  "Un second facteur est déjà actif.": "A second factor is already active.",
  "Une alerte identique a été déclenchée il y a {n} s. Rien n'a été créé : la fenêtre anti-doublon est de {fenetre} s.":
    "An identical alert was triggered {n} s ago. Nothing was created: the duplicate window is {fenetre} s.",
  "Une clé d'exercice n'injecte rien dans une crise réelle.":
    "An exercise key injects nothing into a real crisis.",
  "Une clé d'intégration ne lit pas le contenu d'une crise réelle. C'est la même règle que pour le compte root : administrer n'est pas lire.":
    "An integration key does not read the content of a real crisis. Same rule as for the root account: administering is not reading.",
  "{nom} sans réponse — {suite}": "{nom} no answer — {suite}",
  "Échéance modifiée.": "Deadline changed.",
  "Élément inconnu.": "Unknown item.",
  "Élément mis à jour.": "Item updated.",
  "Élément retiré du kit en vigueur ; les versions antérieures le conservent.":
    "Item removed from the kit in force; earlier versions keep it.",
  "Étiquette retirée du vocabulaire et des fiches en vigueur.":
    "Tag removed from the vocabulary and from the records in force.",

  // ── Validation : ce que zod refuse, dit en français puis traduit ───────
  "champ obligatoire": "required field",
  "ne peut pas être vide": "cannot be empty",
  "au moins {n} caractères": "at least {n} characters",
  "au moins {n} élément(s)": "at least {n} item(s)",
  "valeur minimale : {n}": "minimum value: {n}",
  "au plus {n} caractères": "at most {n} characters",
  "valeur maximale : {n}": "maximum value: {n}",
  "adresse électronique invalide": "invalid email address",
  "adresse web invalide": "invalid web address",
  "identifiant invalide": "invalid identifier",
  "format invalide": "invalid format",

  // ── Indicateur de préparation : chaque manque nomme son geste ─────────
  //
  // « fr-FR » est la clé du format de date : la date suit la langue, « lundi 8
  // septembre » devenant « Monday 8 September ».
  " et {a} autre(s).": " and {a} other(s).",
  "Annuaire à jour": "Directory up to date",
  "Aucun des {a} tiers n'a d'échéance renseignée — rien n'est surveillé.":
    "None of the {a} third parties has an expiry recorded — nothing is being watched.",
  "Aucun tiers au registre : il n'y a pas encore de contrat à surveiller.":
    "No third party in the register: there is no contract to watch yet.",
  "Aucun tiers enregistré.": "No third party recorded.",
  "Aucune astreinte déclarée sur les {a} prochains jours.":
    "No on-call period declared for the next {a} days.",
  "Aucune des {a} fiches ne porte de rôle de crise.":
    "None of the {a} records carries a crisis role.",
  "Aucune des {a} échéances connues ne tombe dans les trois mois.":
    "None of the {a} known expiry dates falls within three months.",
  "Aucune fiche réflexe.": "No response sheet.",
  "Aucune personne dans l'annuaire : aucun rôle de crise n'est encore attribué.":
    "Nobody in the directory: no crisis role is assigned yet.",
  "Aucune personne dans l'annuaire de crise.": "Nobody in the crisis directory.",
  "Comblez ces créneaux : une crise qui arrive dans un trou d'astreinte n'appelle personne.":
    "Fill these windows: a crisis that arrives in an on-call gap calls nobody.",
  "Contrats en cours de validité": "Contracts in force",
  "Fiches réflexes revues": "Response sheets reviewed",
  "Joignables hors du système d'information": "Reachable outside the information systems",
  "Lancez une campagne de vérification : chacun confirme ses coordonnées en un clic.":
    "Start a verification campaign: everyone confirms their details in one click.",
  "Les {a} prochains jours sont couverts sans interruption.":
    "The next {a} days are covered without interruption.",
  "Les {a} rôles de crise ont chacun leur suppléant.": "All {a} crisis roles have their deputy.",
  "Manquant : {a}.": "Missing: {a}.",
  "Membres ayant déjà ouvert l'outil": "Members who have opened the tool",
  "Procédures d'activation complètes": "Activation procedures complete",
  "Relisez les fiches les plus anciennes.": "Reread the oldest sheets.",
  "Renseignez ces tiers : sans eux, personne pour investiguer, payer ni dire ce qui est permis.":
    "Record these third parties: without them, nobody to investigate, nobody to pay, nobody to say what is allowed.",
  "Renseignez « Rôle en cellule de crise » sur les fiches concernées, puis désignez leur suppléant.":
    "Fill in “Role in the crisis cell” on the records concerned, then name their deputy.",
  "Renseignez « Échéance du contrat » sur chaque tiers : un contrat expiré se découvre au moment de l'appeler.":
    "Fill in “Contract expiry” on every third party: an expired contract is discovered at the moment you call.",
  "Rédigez au moins la fiche de première heure : que fait-on dans les soixante premières minutes.":
    "Write at least the first-hour sheet: what to do in the first sixty minutes.",
  "Réponse à incident, assurance et conseil juridique sont au registre.":
    "Incident response, insurance and legal counsel are in the register.",
  "Sans coordonnées personnelles, ces personnes seront injoignables le jour où la messagerie d'entreprise sera hors service — c'est-à-dire le jour J.":
    "Without personal contact details, these people will be unreachable the day the company mail system is down — that is to say, on the day.",
  "Suppléants désignés": "Deputies named",
  "Tiers indispensables enregistrés": "Essential third parties recorded",
  "Tour d'astreinte sans trou": "On-call rota with no gap",
  "Un outil découvert le jour de la crise n'est pas utilisé. Faites-les se connecter une fois.":
    "A tool discovered on the day of the crisis is not used. Get them to sign in once.",
  "Une crise dure plusieurs jours : personne ne tient un rôle sans relève.":
    "A crisis lasts several days: nobody holds a role without relief.",
  "Vérifiez le renouvellement avant l'échéance.": "Check the renewal before the expiry date.",
  "fr-FR": "en-GB",
  "{a} contrat(s) arrivent à échéance : {b}.": "{a} contract(s) reaching expiry: {b}.",
  "{a} fiche(s), dont {b} non revue(s) depuis un an.":
    "{a} sheet(s), of which {b} not reviewed for a year.",
  "{a} membre(s) sur {b} se sont déjà connectés.":
    "{a} member(s) out of {b} have already signed in.",
  "{a} rôle(s) sur {b} sans suppléant désigné.": "{a} role(s) out of {b} with no deputy named.",
  "{faites} fiche(s) sur {total} vérifiées depuis moins de {mois} mois.":
    "{faites} record(s) out of {total} verified within the last {mois} months.",
  "{faites} personne(s) sur {total} ont une adresse ou un numéro indépendants de l'entreprise.":
    "{faites} person(s) out of {total} have an address or a number independent of the company.",
  "{faits} tiers sur {total} ont numéro d'urgence, personne habilitée à appeler et conduite à tenir sans réponse.":
    "{faits} third parties out of {total} have an emergency number, someone authorised to call, and a course of action if nobody answers.",
  "{trous} trou(s), {heures} h au total sur {jours} jours — le premier le {date}.":
    "{trous} gap(s), {heures} h in total over {jours} days — the first on {date}.",
  "À compléter : {a}": "To complete: {a}",
};
+93 −0
Changes for apps/api/src/langues/index.ts: 93 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { EN } from "./en.js";

/**
 * Le serveur parle aussi à l'écran (EX-21).
 *
 * L'interface a été rendue bilingue, et le compteur l'a mesurée à cent pour
 * cent. Restait ce que le serveur produit : les messages de refus des routes,
 * les libellés de l'indicateur de préparation, les résumés d'événements du
 * journal. Ils arrivaient en français dans une interface réglée en anglais —
 * « This indicator does not grade for the sake of grading » suivi de « Tour
 * d'astreinte sans trou ».
 *
 * Même convention que l'interface, et pour les mêmes raisons : la clé est le
 * texte français lui-même. Une clé absente s'affiche donc en français, c'est-
 * à-dire dans la langue d'origine du produit, et non sous la forme d'un
 * identifiant que personne ne comprendra à trois heures du matin.
 *
 * Dictionnaire distinct de celui de l'interface, à dessein : le navigateur n'a
 * pas à télécharger les messages du serveur, et le serveur n'a pas à embarquer
 * les libellés des écrans. Deux fichiers, une seule convention.
 *
 * Ce que ce module ne traduit pas, et ne doit pas traduire : ce que la cellule
 * a écrit. Une entrée de main courante, un message de salon, un texte de
 * mobilisation sont des faits datés, versés au journal, et relus des mois plus
 * tard par un assureur ou un juge. Les traduire serait réécrire une preuve.
 * Seule la formulation du produit se traduit.
 */

export type Langue = "fr" | "en";

/** Ce que l'en-tête annonce, réduit à ce que le produit sait dire. */
export function langueDe(entetes: Record<string, unknown>): Langue {
  const brut = entetes["accept-language"];
  if (typeof brut !== "string") return "fr";
  // « fr-FR,fr;q=0.9,en;q=0.8 » : la première langue nommée décide. On ne
  // pondère pas les « q » — le client de Kastell envoie son choix, pas une
  // liste de préférences, et un navigateur qui en envoie une a le français
  // pour repli.
  const premiere = brut.split(",")[0]?.trim().toLowerCase() ?? "";
  return premiere.startsWith("en") ? "en" : "fr";
}

const CHIFFRES = /\d+/g;

/**
 * Traduire, avec substitution de `{nom}`.
 *
 * Deux passes. La clé exacte d'abord. Puis, si elle manque, la même clé dont
 * les nombres sont remplacés par « {n} » — ce qui traite d'un coup les messages
 * de validation, qui portent leur seuil dans leur texte : « au moins 2
 * caractères » se traduit par la clé « au moins {n} caractères » sans qu'il
 * faille une entrée par seuil.
 */
export function traduire(
  langue: Langue, fr: string, valeurs?: Record<string, string | number>,
): string {
  let brut = fr;
  if (langue !== "fr") {
    const exact = EN[fr];
    if (exact !== undefined) {
      brut = exact;
    } else {
      const nombres: string[] = [];
      const gabarit = fr.replace(CHIFFRES, (n) => { nombres.push(n); return "{n}"; });
      const parGabarit = nombres.length > 0 ? EN[gabarit] : undefined;
      if (parGabarit !== undefined) {
        let i = 0;
        brut = parGabarit.replace(/\{n\}/g, () => nombres[i++] ?? "{n}");
      }
    }
  }
  if (!valeurs) return brut;
  return brut.replace(/\{(\w+)\}/g, (entier, nom: string) =>
    String(valeurs[nom] ?? entier));
}

/** Un traducteur figé sur une langue, à passer aux fonctions qui formulent. */
export type Traduire = (fr: string, valeurs?: Record<string, string | number>) => string;

export function traducteur(langue: Langue): Traduire {
  return (fr, valeurs) => traduire(langue, fr, valeurs);
}

/**
 * Le français, substitution comprise.
 *
 * C'est le défaut des fonctions qui formulent, et il ne peut pas être
 * l'identité : « (fr) => fr » rendrait « Gravité portée de {de} à {vers} »
 * tel quel à tout appelant qui ne passe pas de langue — la suite de
 * vérification, le dossier de clôture, l'export du journal. Deux garanties
 * l'ont dit avant moi.
 */
export const enFrancais: Traduire = (fr, valeurs) => traduire("fr", fr, valeurs);
+39 −19

File changed.

Preview size limit exceeded, changes collapsed.

+5 −1

File changed.

Preview size limit exceeded, changes collapsed.

+68 −55

File changed.

Preview size limit exceeded, changes collapsed.

Loading