Audit i18n exhaustif — 2026-08-12
Second passage, en réponse à « vérifie qu'il ne manque réellement plus rien ». 10 dimensions auditées en parallèle, chaque constat re-vérifié par un contrôleur adverse chargé de le réfuter. 130 défauts confirmés, 3…
Second passage, en réponse à « vérifie qu'il ne manque réellement plus rien ». 10 dimensions auditées en parallèle, chaque constat re-vérifié par un contrôleur adverse chargé de le réfuter. 130 défauts confirmés, 3 réfutés. Les constats de tête ont été re-vérifiés à la main avant publication.
Réponse courte
Non. Il manque beaucoup plus que ce que le premier audit avait trouvé.
Le premier audit annonçait 2281 strings en dur. Le corpus réel est de ~8000 chaînes, soit environ 3,5× plus. Et surtout, il avait raté six défauts structurels : des choses qu'aucune traduction supplémentaire ne corrigera, parce que le chemin par lequel la langue devrait circuler n'existe pas.
Ce qui est réellement sain
Trois garanties, vérifiées et désormais tenues par pnpm i18n:audit :
- Parité du catalogue — 2213 clés dans les 6 locales, 0 manquante, 0 en trop.
- Aucune clé fantôme, les 851 sites d'appel statiques de
src/ont été résolus contre le catalogue avec une attribution scope-aware (chaquet()rattaché à sa déclarationuseTranslationsla plus proche) : 0 clé manquante. Piège confirmé au passage : une attribution naïve « dernière déclaration du fichier » produit 22 faux positifs,ad-slot-card.tsxa 3 traducteurs,composer-plus-menu.tsxen a 7. - Intégrité ICU — arguments, types et branche
othercohérents sur 10 945 comparaisons. Les 431 traductions de la première PR n'ont rien cassé.
Les six défauts structurels
Rangés par gravité. Aucun ne se répare en ajoutant des clés.
1. L'IA répond en français à tout le monde
Le plus grave, et le plus ironique sur une plateforme dont l'IA est le produit.
src/features/ai/orchestrator/runtime/handler-algo-context.ts:54code en durlocale: "fr". Cette seule ligne neutralise toute la pile de layers du prompt (base guidelines, marketplace-first law, platform context, attitude, situational, runtime, overlay L3).- Le system prompt live fait 227 lignes, 100 % en français, avec 4 formules de refus imposées au modèle en français littéral.
- La couche prompt est typée
"fr" | "en"dans 11 fichiers (18 occurrences).de,es,itetptsont donc structurellement impossibles à servir : ce n'est pas un oubli de traduction, c'est le type qui les exclut. - Le briefing quotidien IA (cron 05:21 UTC) force le français sur ses 6 personas.
- Les titres de conversation sont générés par un prompt français qui restreint explicitement la sortie à l'anglais ou au français.
2. La locale de l'utilisateur n'est persistée nulle part
prisma/schema.prisma ne contient aucune colonne locale sur User,
Organization ni StatusSubscription. Le seul locale du schéma est
ContentEntry.locale.
La langue n'existe donc que dans le cookie NEXT_LOCALE, côté client. Toute
surface asynchrone est aveugle : 24 templates email, les 50 crons de
vercel.json, les webhooks Stripe. getTranslations a 0 occurrence dans tout
src/app/api/.
C'est la cause racine du défaut 3, et elle ne se corrige que par une migration.
3. Toute la communication sortante est mono-langue
- 24/24 templates React Email sans next-intl, ~221 chaînes user-facing en dur.
Y compris
magic-linketotp, qui sont le tout premier contact produit. - 68 sujets d'email codés en dur sur 4 couches (~35 vivants et user-facing).
- Un utilisateur peut faire tout le tunnel
/auth,/statusou/feedbacken français et recevoir un email en anglais. - Le digest hebdomadaire (lundi 09:00 UTC) part en HTML brut bilingue FR/EN, sur toutes les organisations sans filtre de plan.
- 6 composants React Email sont morts, doublés par un chemin HTML brut anglais.
4. Le multilingue est invisible pour les moteurs
L'architecture « une seule URL canonique + locale par cookie » a une conséquence
que le premier audit n'avait pas relevée : 0 URL indexable pour fr, de,
es, it, pt.
marketing-metadata.ts:92-94génère unhreflangoù les 6 locales pointent vers la même URL, sansx-default. C'est auto-référent, donc inopérant.- Le sitemap (~77 URLs) n'a aucun fan-out par locale.
- Tous les JSON-LD déclarent
inLanguage: "en"en dur ;openGraph.localeest figé à"en_US". - Résultat : ~31 289 mots déjà traduits (fr 6413, es 6529, pt 6492, it 6268, de 5587) sont structurellement non-indexables, contre 849 mots en anglais.
Le travail de traduction est fait ; il ne rapporte rien en SEO.
5. Le contenu MDX est traduit à l'envers
content/blog/en/ne contient que 3 articles quandfr,de,es,itetpten ont 6. Trois articles renvoient donc un 404 dur dans la locale par défaut :connect-shopify-to-claude-or-chatgpt,shopify-chatgpt-integration,shopify-claude-mcp.content/tutorials/(6),content/handbook/(1) etcontent/docs/(1) n'existent qu'en anglais et sont servis aux 6 locales sous un<html lang>qui ment.- Le RSS
/insightsne publie que 3 articles sur 6 et déclare<language>en</language>. - 78 liens
/blog/*en dur dans les MDX (chaîne de 301).
6. Aucun formatage n'est localisé
useFormatter / getFormatter de next-intl : 0 occurrence dans tout le repo.
| Symptôme | Occurrences |
|---|---|
Dates figées en "en-US" | 131 sites (67 hors /admin) |
toLocale*() sans argument (locale du process Node) | 91 (69 hors /admin) |
toFixed() → point décimal anglais | 216 (dont /status et /status/history) |
Prix concaténés avec un $ littéral | 37 (25 user-facing, 6 sur /pricing) |
| Skeletons ICU number/date au catalogue | 0 sur 2213 clés |
Clés plural | 4 (0,18 %) |
Pire, /checkout/sponsor affiche 6 montants en EUR avec un signe $ : la
devise est relabellisée par langue sans aucune conversion.
Deux bugs réels, aujourd'hui latents
Trouvés et vérifiés à la main. Ils ne se voient pas encore, mais ils se déclencheront à la première clé manquante.
Garde-fou inversé dans le wizard marketplace.
sell-store-wizard.tsx:91 fait useTranslations("sellStore"), donc une clé
absente renvoie le chemin complet sellStore.modal.fields.x.label. Or le
garde de :301 teste !value.startsWith("modal.fields.") : sans le préfixe de
namespace. La condition est donc toujours vraie, le fallback est mort, et le
chemin de clé brut s'afficherait à l'écran. Même classe de défaut dans
ad-slot-card.tsx:44-54, où le catch est mort (t() ne lève jamais).
Aucun filet de sécurité. Ni onError ni getMessageFallback ne sont
configurés, et il n'y a aucune augmentation de type IntlMessages : une clé
manquante s'affiche en clair, n'est jamais remontée, et n'est pas détectée à la
compilation.
Dette mesurée
| Sujet | Chiffre vérifié |
|---|---|
| Strings en dur, plateforme entière | ~8000 (vs 2281 annoncées) |
Fichiers .tsx hors /admin sans nœud texte JSX mais porteurs de copie | 351 fichiers, 2699 chaînes |
Fichiers appelant useTranslations et gardant de la copie en dur | 129 fichiers, 1106 chaînes |
| Chaînes françaises en dur servies aux 6 locales | 1008 dans 94 .tsx + 684 dans 137 .ts |
| Messages toast en dur | 275 dans 77 fichiers |
| Clés orphelines (jamais référencées) | 606 / 2213 = 27,4 % |
dont namespace home seul | 498 / 625 = 79,7 % |
| Poids mort chargé à chaque requête | ~13,6 % de messages/en.json |
Les orphelines viennent de réécritures sans nettoyage, Hub, auth-canvas,
sell-store-wizard, /intelligence : 4 cas indépendants. Aucun namespace entier
n'est mort, l'orphelinat est 100 % intra-namespace.
Catalogues produit encore en anglais figé, hors catalogue i18n : plans (71
chaînes, sur /pricing + billing + onboarding), fiches agents (157 chaînes sur
7 pages publiques), marketplace JSON (45 chaînes, déclaré « locale-agnostic »),
items DEMO du hub (154 chaînes, épinglées en tête de 11 listes de la racine /).
/pricing est EN-only par décision documentée : 3 sections sur 4.
Les 3 constats réfutés
Le contrôle adverse a bien fait son travail, ces trois-là sont fausses alertes, et je les note pour qu'elles ne soient pas re-signalées :
- « Le cookie
NEXT_LOCALEposé par le proxy n'est pas visible du rendu de la requête courante » : faux. Next propage le cookie via le header internex-middleware-set-cookie, que le request store fusionne avant le rendu.src/i18n/request.tsvoit donc bien le cookie dès la première requête ; le commentaire « zero flicker » desrc/proxy.ts:143-146est exact. - «
/intelligence/lookupn'exporte aucune metadata », vrai mécaniquement, sans conséquence : tous les chemins de la page finissent enredirect(), aucun HTML n'est jamais rendu. - Un troisième constat de la dimension
non-tsx-strings, écarté pour prémisse fausse (surface annoncée publique, en réalité interne).
Corrections appliquées (même PR)
Les défauts structurels débloquants ont été corrigés dans la foulée de l'audit :
| # | Correction | Fichiers |
|---|---|---|
| 1 | User.locale au schéma Prisma — la langue survit désormais hors requête (emails, crons, webhooks, prompts). Persistée par le LocaleSwitcher (setLocale) et capturée à chaque sign-in (events.signIn). Schema guard régénéré (2346 steps). | prisma/schema.prisma, src/i18n/actions.ts, src/modules/auth/server.ts |
| 2 | IA multilingue — locale: "fr" en dur supprimé ; la locale est résolue depuis le cookie NEXT_LOCALE de la requête chat. Le mapping lang inversé (=== "en" ? "en" : "fr") est corrigé (=== "fr" ? "fr" : "en"), et une directive nomme explicitement la langue de réponse dans le system prompt — les 6 locales sont servies (scaffolding EN pour de/es/it/pt). | handler-algo-context.ts, handler.ts |
| 3 | Filet de sécurité runtime — getMessageFallback (dernier segment humanisé en prod, ⟨chemin⟩ en dev), onError (log serveur), timeZone: "UTC" (anti-mismatch d'hydratation). | src/i18n/request.ts |
| 4 | Vérification des clés à la compilation — augmentation IntlMessages dérivée de en.json : toute clé inexistante casse tsc. Les 46 sites à clés dynamiques sont marqués dynamicKey() (grep-ables). A immédiatement détecté une vraie clé manquante : marketingPages.marketplace.types.newsletter.* (le type NEWSLETTER de TYPE_ORDER n'avait aucune copy) — ajoutée aux 6 locales (catalogue 2213 → 2215). | src/types/next-intl.d.ts, src/i18n/dynamic-key.ts, 20 fichiers |
| 5 | Gardes morts corrigés — le sniff inversé du wizard (startsWith("modal.fields.") sans préfixe de namespace) et les deux try/catch morts des ad-slots (t() ne lève jamais) remplacés par t.has(). | sell-store-wizard.tsx, ad-slot-card.tsx, ad-slots-mobile-banner.tsx |
| 6 | Blog anglais complété — les 3 articles 404 en locale par défaut traduits depuis le français (structure MDX, composants et frontmatter préservés). 6/6 articles dans les 6 locales. | content/blog/en/{connect-shopify-to-claude-or-chatgpt,shopify-chatgpt-integration,shopify-claude-mcp}.mdx |
| 7 | Devise réelle affichée — LOCALE_DISPLAY_CURRENCY supprimé : un montant EUR ne se relabellise plus en $ pour un utilisateur anglophone ; seules les conventions de format suivent la locale. /checkout/sponsor lit la locale active au lieu de "en" figé. | src/services/billing/pricing.ts, checkout/sponsor/page.tsx |
| 8 | Garde ICU dans l'audit — pnpm i18n:audit vérifie désormais arguments, types et branche other de chaque message (sort en code 1 si divergence). Testé négativement sur les 3 classes de casse. | scripts/i18n-audit.mjs |
Vérifié après corrections : tsc 0 erreur, 491 tests verts, parité 2215 clés × 6
locales, ICU 0 divergence.
Chantiers restants (décision produit requise)
- Modèle d'URL multilingue (préfixe
/fr/…ou sous-domaines) : tant que la locale vit dans un cookie, les ~31 000 mots traduits restent non-indexables. Décision d'architecture à prendre avant tout travail SEO. - Externalisation des ~8000 strings : surface par surface, en commençant par le hub public (106) et les surfaces marketing trouées (~58).
- i18n des emails — le blocage structurel (
User.locale) est levé ; reste à branchercreateTranslator+ catalogues dans les 24 templates et les ~35 sujets vivants. useFormattergénéralisé — ~490 sites de formatage à migrer.- Guides tracking — trancher clés vs MDX par locale (~564 strings).
- Nettoyage des 606 clés orphelines (~13,6 % du payload par requête).
- LocaleSwitcher pour les connectés, le composant n'est monté que dans le footer marketing ; un utilisateur en dashboard ne peut pas changer de langue.
Le panel /admin reste hors périmètre : outil interne opérateur.
Méthode
10 dimensions indépendantes, chacune re-vérifiée par un contrôleur adverse instruit de réfuter en priorité et de trancher vers REFUTE en cas de doute (20 agents, 2,6 M tokens, 1296 appels d'outils). Les contrôleurs ont également corrigé à la hausse plusieurs ampleurs sous-estimées par les auditeurs, et signalé ce que ceux-ci avaient manqué.
Constats de tête re-vérifiés à la main avant publication : décompte des articles
par locale, useFormatter à 0, absence de colonne locale au schéma, montage du
LocaleSwitcher, locale: "fr" de handler-algo-context.ts:54, typage
"fr" | "en", et le garde inversé du wizard.
Limite assumée : les comptages de strings restent des ordres de grandeur issus d'heuristiques textuelles, pas d'une analyse AST. Le chiffre de ~8000 est un plancher. Les défauts structurels, eux, sont binaires et tous vérifiés au fichier près.