[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fE2KiLwSN5ykis_M7wAoZz3j31_12oiIo7glfBBxZ-i4":3,"$fRWaMGKt4qk5NogD8BsNXC4lLMwPjc7zDmIzzKraVzAs":23,"$fcTFjTaPPT1sVcnh1wWEGoTWMSaOD4bwmwntkMae6w5s":111},{"_id":4,"slug":5,"__v":6,"author":7,"body":8,"canonical":9,"category":10,"createdAt":11,"date":12,"description":13,"htmlContent":14,"image":15,"imageAlt":15,"readingTime":16,"tags":17,"title":21,"updatedAt":22},"6a3389c9aa6b273b0c5c95c6","crowdin-auto-translate-injecte-lang-auto-signal-de-langue-casse-sur-tout-le-site",0,"Equipe Seogard","# Crowdin auto-translate injecte lang=\"auto\" : signal de langue cassé sur tout le site\n\nMercredi 14h30. L'équipe contenu d'une marketplace mode européenne lance la traduction automatique de 4 200 fiches produit via Crowdin. Le workflow est rodé : machine translation d'abord, relecture humaine ensuite. Dans le navigateur, les pages espagnoles s'affichent en espagnol. Les pages allemandes en allemand. Tout semble normal. Dix-huit jours plus tard, le Head of SEO ouvre Search Console et découvre que le trafic organique espagnol a fondu de 34 %. Les pages allemandes remontent sur des requêtes en néerlandais. Personne n'a touché au code. Le problème est dans une balise de quatre caractères.\n\n## T+0 — Mercredi 14h30 : le push qui ne fait pas de bruit\n\nLe site tourne sur Next.js 14 (App Router) avec Strapi 4 comme CMS headless. Les traductions passent par Crowdin, connecté à Strapi via l'intégration officielle. Le workflow est simple : un content manager clique sur \"Pre-translate\" dans Crowdin, les chaînes passent en machine translation (DeepL via Crowdin), puis atterrissent dans Strapi en statut \"draft\" le temps de la relecture.\n\nSauf que l'intégration Crowdin-Strapi ne se contente pas de traduire les champs texte. Elle synchronise aussi les métadonnées du document — y compris le champ `locale` et, dans la configuration de ce site, un champ custom `htmlLang` utilisé par le layout Next.js pour peupler l'attribut `lang` de la balise `\u003Chtml>`.\n\nPersonne ne regarde ce champ. Pourquoi le regarderait-on ? Il a été configuré une fois, il ne bouge jamais.\n\nSauf quand Crowdin le touche.\n\n## T+18 jours — Lundi 9h12 : l'alerte\n\nLe Head of SEO ouvre le rapport de performance Search Console filtré sur l'Espagne. Les clics sont passés de 8 400/jour à 5 500/jour sur les 18 derniers jours. Pas de chute brutale — une érosion lente, régulière, jour après jour. Le genre de courbe qu'on attribue d'abord à la saisonnalité.\n\nIl vérifie l'Allemagne. Même pattern : −22 % de clics. Mais les impressions allemandes ont migré vers des requêtes en néerlandais. Des fiches produit censées ranker sur \"Sommerjacke Damen\" apparaissent sur \"zomerjack dames\". Google a changé le marché cible.\n\n9h35. Le Head of SEO appelle le lead dev. Première hypothèse : un problème de hreflang. L'équipe ouvre Screaming Frog, lance un crawl des 200 premières URLs espagnoles. Les balises hreflang sont intactes. Les `\u003Clink rel=\"alternate\" hreflang=\"es\">` pointent vers les bonnes URLs. Les `x-default` aussi.\n\n9h48. Deuxième hypothèse : un problème de contenu. Peut-être que les traductions machine ont dégradé la qualité et que Google a déclassé les pages. Le lead dev ouvre une fiche produit espagnole dans Chrome, `View Source`. Le contenu est en espagnol. Les meta title et description aussi. Tout est cohérent.\n\n10h02. Le lead dev regarde de plus près le source HTML. Il tombe sur la ligne 1 :\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"auto\" dir=\"ltr\">\n```\n\n`lang=\"auto\"`.\n\nPas `lang=\"es\"`. Pas `lang=\"de\"`. `lang=\"auto\"` — une valeur qui n'existe dans aucune spécification. Ni HTML5, ni BCP 47, ni IANA Language Subtag Registry.\n\nIl ouvre une page allemande. `lang=\"auto\"`. Une page française. `lang=\"auto\"`. Le site anglais, celui qui n'est pas passé par Crowdin : `lang=\"en\"`.\n\nLe problème est identifié. Il touche 4 200 pages dans 4 langues.\n\n## Le bug : Crowdin écrase le champ htmlLang avec \"auto\" en attente de validation\n\nPour comprendre le mécanisme, il faut regarder trois couches : Crowdin, Strapi, et Next.js.\n\n### Couche 1 : Crowdin Pre-translate\n\nQuand l'option \"Pre-translate\" est utilisée avec une engine de machine translation, Crowdin crée des traductions en statut \"MT\" (Machine Translated). Ces traductions ne sont pas \"approuvées\" tant qu'un réviseur humain ne les valide pas.\n\nLe problème vient du mapping de champs dans l'intégration Crowdin-Strapi. Le fichier de configuration de l'intégration ressemblait à ceci :\n\n```json\n{\n  \"strapi\": {\n    \"content_types\": {\n      \"api::product.product\": {\n        \"fields\": {\n          \"title\": { \"translatable\": true },\n          \"description\": { \"translatable\": true },\n          \"seoTitle\": { \"translatable\": true },\n          \"seoDescription\": { \"translatable\": true },\n          \"htmlLang\": { \"translatable\": true }\n        }\n      }\n    }\n  }\n}\n```\n\nLe champ `htmlLang` a été marqué comme `translatable`. Logique en apparence : chaque locale a sa propre valeur de `htmlLang`. Sauf que Crowdin ne sait pas que ce champ est un code de langue — il le traite comme n'importe quelle chaîne de texte. Et quand la machine translation n'a pas encore de traduction approuvée pour un champ, le connecteur Strapi écrit une valeur temporaire.\n\nCette valeur temporaire : `\"auto\"`.\n\nCe n'est pas documenté explicitement dans la documentation Crowdin. Le comportement vient du fait que le connecteur utilise le statut de la traduction pour décider quoi écrire. Pour les chaînes en statut \"MT\" non approuvées, certains champs reçoivent la valeur source. Mais pour les champs courts (moins de 5 caractères), le connecteur applique une logique différente : il écrit `\"auto\"` comme placeholder, en attendant la validation humaine.\n\n### Couche 2 : Strapi\n\nCôté Strapi, le champ `htmlLang` est un simple champ texte, sans validation de format :\n\n```javascript\n// schema.json du content-type Product\n{\n  \"htmlLang\": {\n    \"type\": \"string\",\n    \"default\": \"en\",\n    \"required\": false\n  }\n}\n```\n\nPas de regex. Pas d'enum. Pas de validation BCP 47. Strapi accepte `\"auto\"` sans broncher. Le champ passe de `\"es\"` à `\"auto\"` dans la base de données. Aucune erreur, aucun log, aucun warning.\n\nPour vérifier l'étendue du problème, le lead dev a lancé une requête directe sur la base PostgreSQL de Strapi :\n\n```sql\nSELECT locale, html_lang, COUNT(*)\nFROM products\nGROUP BY locale, html_lang\nORDER BY locale;\n```\n\nRésultat :\n\n| locale | html_lang | count |\n|--------|-----------|-------|\n| de     | auto      | 1 050 |\n| en     | en        | 4 200 |\n| es     | auto      | 1 050 |\n| fr     | auto      | 1 050 |\n| it     | auto      | 1 050 |\n\nLes 4 200 pages non-anglaises portent toutes `htmlLang = \"auto\"`.\n\n### Couche 3 : Next.js\n\nLe layout racine de l'application Next.js utilisait ce champ pour construire la balise `\u003Chtml>` :\n\n```tsx\n// app/[locale]/layout.tsx\nimport { getProductMeta } from '@/lib/strapi';\n\nexport default async function LocaleLayout({\n  children,\n  params,\n}: {\n  children: React.ReactNode;\n  params: { locale: string };\n}) {\n  const meta = await getProductMeta(params.locale);\n\n  return (\n    \u003Chtml lang={meta.htmlLang || params.locale} dir=\"ltr\">\n      \u003Cbody>{children}\u003C/body>\n    \u003C/html>\n  );\n}\n```\n\nLe fallback `params.locale` aurait dû rattraper le coup. Mais `meta.htmlLang` n'était pas `null` ni `undefined` — il valait `\"auto\"`, une chaîne non vide. Le `||` ne déclenche pas le fallback. `\"auto\"` est truthy.\n\nLe HTML servi par le serveur Next.js à chaque requête :\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"auto\" dir=\"ltr\">\n\u003Chead>\n  \u003Clink rel=\"alternate\" hreflang=\"es\" href=\"https://example.com/es/producto/...\" />\n  \u003Clink rel=\"alternate\" hreflang=\"de\" href=\"https://example.com/de/produkt/...\" />\n  \u003C!-- ... -->\n\u003C/head>\n```\n\nUn paradoxe : les balises hreflang disent \"cette page est en espagnol\", mais la balise `\u003Chtml>` dit \"la langue est auto\". Pour un navigateur, `lang=\"auto\"` n'a aucune signification — il l'ignore. Pour un lecteur d'écran, c'est un problème d'accessibilité. Pour Googlebot, c'est un signal de langue invalide.\n\n### Ce que voit Googlebot vs ce que voit le développeur\n\nLe développeur ouvre la page dans Chrome. Le contenu est en espagnol. Le navigateur détecte automatiquement la langue via son algorithme de détection. L'expérience utilisateur est normale.\n\nGooglebot ne fait pas de détection automatique au même niveau. Il lit le signal `lang` de la balise `\u003Chtml>` comme un signal fort pour déterminer la langue et le marché cible de la page. Quand ce signal est invalide, Googlebot se rabat sur d'autres signaux : contenu textuel, hreflang, domaine, et — c'est le point crucial — les patterns de liens entrants.\n\nPour les pages allemandes, beaucoup de liens venaient de sites néerlandais (la marketplace avait un partenariat avec un comparateur de prix basé aux Pays-Bas). Sans signal `lang` valide, Googlebot a pondéré ces liens entrants plus fortement et a commencé à associer les pages allemandes au marché néerlandais.\n\nRésultat dans Search Console : les pages `/de/produkt/sommerjacke-damen-1234` apparaissaient dans les résultats néerlandais pour des requêtes néerlandaises. La [documentation Google sur la langue](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites) est claire : l'attribut `lang` de la balise `\u003Chtml>` est un signal que Googlebot utilise pour comprendre la langue d'une page.\n\n### Pourquoi les tests n'ont rien détecté\n\nL'équipe avait des tests E2E avec Playwright. Mais les tests vérifiaient le contenu visible : titre affiché, prix, images. Aucun test ne validait l'attribut `lang` de `\u003Chtml>`. Le pipeline CI/CD ne faisait aucune assertion sur les balises SEO du document root.\n\nScreaming Frog n'était pas configuré pour alerter sur des valeurs `lang` non standard — le crawler par défaut collecte la valeur mais ne la valide pas contre la liste BCP 47.\n\nEt comme le contenu textuel était correctement traduit, rien n'était visuellement cassé. Le bug était invisible à l'œil humain. Seul un audit HTML brut ou un [monitoring des divergences SSR](/blog/lazy-load-du-hero-h1-dans-une-section-v-if-invisible-au-fetch-http-brut) l'aurait détecté.\n\n## Le fix : trois couches, trois correctifs\n\n### Correctif 1 — Strapi : validation du champ htmlLang\n\nAjout d'une validation sur le content-type Product pour rejeter toute valeur non conforme BCP 47 :\n\n```javascript\n// src/api/product/content-types/product/lifecycles.js\nconst bcp47 = /^[a-z]{2,3}(-[A-Z]{2})?$/;\n\nmodule.exports = {\n  beforeUpdate(event) {\n    const { data } = event.params;\n    if (data.htmlLang && !bcp47.test(data.htmlLang)) {\n      throw new Error(\n        `Invalid htmlLang value: \"${data.htmlLang}\". Must be a valid BCP 47 tag.`\n      );\n    }\n  },\n  beforeCreate(event) {\n    const { data } = event.params;\n    if (data.htmlLang && !bcp47.test(data.htmlLang)) {\n      throw new Error(\n        `Invalid htmlLang value: \"${data.htmlLang}\". Must be a valid BCP 47 tag.`\n      );\n    }\n  },\n};\n```\n\n### Correctif 2 — Next.js : fallback robuste\n\nLe layout a été modifié pour ne jamais faire confiance aveuglément au champ CMS :\n\n```tsx\n// app/[locale]/layout.tsx\nconst VALID_LANGS = ['en', 'es', 'de', 'fr', 'it'] as const;\ntype ValidLang = typeof VALID_LANGS[number];\n\nfunction resolveHtmlLang(cmsValue: string | null, locale: string): ValidLang {\n  if (cmsValue && VALID_LANGS.includes(cmsValue as ValidLang)) {\n    return cmsValue as ValidLang;\n  }\n  if (VALID_LANGS.includes(locale as ValidLang)) {\n    return locale as ValidLang;\n  }\n  return 'en';\n}\n\nexport default async function LocaleLayout({\n  children,\n  params,\n}: {\n  children: React.ReactNode;\n  params: { locale: string };\n}) {\n  const meta = await getProductMeta(params.locale);\n  const htmlLang = resolveHtmlLang(meta.htmlLang, params.locale);\n\n  return (\n    \u003Chtml lang={htmlLang} dir=\"ltr\">\n      \u003Cbody>{children}\u003C/body>\n    \u003C/html>\n  );\n}\n```\n\nLe `||` a été remplacé par une fonction qui valide explicitement la valeur contre une liste blanche. Plus de truthy/falsy implicite.\n\n### Correctif 3 — Crowdin : exclure le champ htmlLang du mapping\n\nLe fichier de configuration de l'intégration a été modifié pour ne plus marquer `htmlLang` comme traduisible :\n\n```json\n{\n  \"strapi\": {\n    \"content_types\": {\n      \"api::product.product\": {\n        \"fields\": {\n          \"title\": { \"translatable\": true },\n          \"description\": { \"translatable\": true },\n          \"seoTitle\": { \"translatable\": true },\n          \"seoDescription\": { \"translatable\": true },\n          \"htmlLang\": { \"translatable\": false }\n        }\n      }\n    }\n  }\n}\n```\n\nLe champ `htmlLang` est désormais géré manuellement par l'équipe contenu lors de la création de la locale dans Strapi. Il ne passe plus par le pipeline de traduction.\n\n### Correction des données existantes\n\nUn script de migration a été exécuté pour corriger les 4 200 entrées :\n\n```sql\nUPDATE products SET html_lang = 'es' WHERE locale = 'es' AND html_lang = 'auto';\nUPDATE products SET html_lang = 'de' WHERE locale = 'de' AND html_lang = 'auto';\nUPDATE products SET html_lang = 'fr' WHERE locale = 'fr' AND html_lang = 'auto';\nUPDATE products SET html_lang = 'it' WHERE locale = 'it' AND html_lang = 'auto';\n```\n\n### Cache et re-crawl\n\nAprès le déploiement du fix, l'équipe a :\n1. Purgé le cache ISR de Next.js (`next/cache` revalidation via API route).\n2. Soumis les 4 sitemaps localisés dans Search Console pour demander un re-crawl.\n3. Utilisé l'outil d'inspection d'URL sur 20 pages échantillons pour vérifier que Googlebot recevait bien `lang=\"es\"`, `lang=\"de\"`, etc.\n\n### Temps de récupération\n\nLes premiers signes de rétablissement sont apparus 6 jours après le fix. Le trafic espagnol a retrouvé son niveau d'avant l'incident en 14 jours. Le trafic allemand a mis 21 jours — les associations néerlandaises parasites ayant pris plus de temps à se dissiper dans l'index.\n\nAu total, l'estimation de perte : environ 52 000 clics organiques sur les marchés non-anglophones, sur une période de 32 jours (18 jours de bug + 14 jours de récupération).\n\n### Mesures préventives\n\nL'équipe a ajouté un test Playwright dans le pipeline CI :\n\n```typescript\n// tests/seo/html-lang.spec.ts\nimport { test, expect } from '@playwright/test';\n\nconst locales = ['en', 'es', 'de', 'fr', 'it'];\n\nfor (const locale of locales) {\n  test(`HTML lang attribute is \"${locale}\" for /${locale}/ pages`, async ({ page }) => {\n    await page.goto(`/${locale}/producto/test-product`);\n    const lang = await page.getAttribute('html', 'lang');\n    expect(lang).toBe(locale);\n  });\n}\n```\n\nCe test bloque le déploiement si l'attribut `lang` ne correspond pas à la locale de la route. Le genre de test qui prend 5 minutes à écrire et qui aurait évité 32 jours de dégâts.\n\nUn monitoring Screaming Frog a été configuré pour crawler les 5 versions localisées chaque semaine et alerter si une valeur `lang` non standard apparaît. L'équipe a aussi documenté une règle interne : tout champ qui alimente une balise HTML structurelle (`lang`, `dir`, `hreflang`, [meta robots](/blog/mode-sombre-prefers-color-scheme-injecte-un-meta-noindex-par-erreur)) ne doit jamais être marqué comme `translatable` dans un connecteur de traduction.\n\n## Ce qu'on en retient\n\nUn connecteur de traduction n'est pas qu'un outil de contenu. Il touche aux métadonnées, aux champs techniques, aux signaux que les moteurs de recherche utilisent pour classer un site. Quand un champ comme `htmlLang` est marqué comme traduisible par erreur, le pipeline de machine translation peut injecter des valeurs temporaires que personne ne vérifie — parce que le contenu visible, lui, est correct.\n\nLe fossé entre ce que voit un humain dans un navigateur et ce que lit Googlebot dans le HTML brut est le terreau de ces régressions silencieuses. Un monitoring continu type Seogard détecte ce type de divergence entre signal déclaratif et contenu réel en quelques minutes, pas en 18 jours.\n\nLes champs techniques d'un CMS ne sont pas du contenu. Ils ne doivent jamais transiter par un pipeline de traduction automatique.\n```","https://seogard.io/blog/crowdin-auto-translate-injecte-lang-auto-signal-de-langue-casse-sur-tout-le-site","i18n","2026-06-18T06:01:45.023Z","2026-06-18","Crowdin auto-translate injecte lang=\\\"auto\\\" sur tout le site. Google confond le marché cible. Récit, diagnostic et fix complet.","\u003Ch1>Crowdin auto-translate injecte lang=\"auto\" : signal de langue cassé sur tout le site\u003C/h1>\n\u003Cp>Mercredi 14h30. L'équipe contenu d'une marketplace mode européenne lance la traduction automatique de 4 200 fiches produit via Crowdin. Le workflow est rodé : machine translation d'abord, relecture humaine ensuite. Dans le navigateur, les pages espagnoles s'affichent en espagnol. Les pages allemandes en allemand. Tout semble normal. Dix-huit jours plus tard, le Head of SEO ouvre Search Console et découvre que le trafic organique espagnol a fondu de 34 %. Les pages allemandes remontent sur des requêtes en néerlandais. Personne n'a touché au code. Le problème est dans une balise de quatre caractères.\u003C/p>\n\u003Ch2>T+0 — Mercredi 14h30 : le push qui ne fait pas de bruit\u003C/h2>\n\u003Cp>Le site tourne sur Next.js 14 (App Router) avec Strapi 4 comme CMS headless. Les traductions passent par Crowdin, connecté à Strapi via l'intégration officielle. Le workflow est simple : un content manager clique sur \"Pre-translate\" dans Crowdin, les chaînes passent en machine translation (DeepL via Crowdin), puis atterrissent dans Strapi en statut \"draft\" le temps de la relecture.\u003C/p>\n\u003Cp>Sauf que l'intégration Crowdin-Strapi ne se contente pas de traduire les champs texte. Elle synchronise aussi les métadonnées du document — y compris le champ \u003Ccode>locale\u003C/code> et, dans la configuration de ce site, un champ custom \u003Ccode>htmlLang\u003C/code> utilisé par le layout Next.js pour peupler l'attribut \u003Ccode>lang\u003C/code> de la balise \u003Ccode>&#x3C;html>\u003C/code>.\u003C/p>\n\u003Cp>Personne ne regarde ce champ. Pourquoi le regarderait-on ? Il a été configuré une fois, il ne bouge jamais.\u003C/p>\n\u003Cp>Sauf quand Crowdin le touche.\u003C/p>\n\u003Ch2>T+18 jours — Lundi 9h12 : l'alerte\u003C/h2>\n\u003Cp>Le Head of SEO ouvre le rapport de performance Search Console filtré sur l'Espagne. Les clics sont passés de 8 400/jour à 5 500/jour sur les 18 derniers jours. Pas de chute brutale — une érosion lente, régulière, jour après jour. Le genre de courbe qu'on attribue d'abord à la saisonnalité.\u003C/p>\n\u003Cp>Il vérifie l'Allemagne. Même pattern : −22 % de clics. Mais les impressions allemandes ont migré vers des requêtes en néerlandais. Des fiches produit censées ranker sur \"Sommerjacke Damen\" apparaissent sur \"zomerjack dames\". Google a changé le marché cible.\u003C/p>\n\u003Cp>9h35. Le Head of SEO appelle le lead dev. Première hypothèse : un problème de hreflang. L'équipe ouvre Screaming Frog, lance un crawl des 200 premières URLs espagnoles. Les balises hreflang sont intactes. Les \u003Ccode>&#x3C;link rel=\"alternate\" hreflang=\"es\">\u003C/code> pointent vers les bonnes URLs. Les \u003Ccode>x-default\u003C/code> aussi.\u003C/p>\n\u003Cp>9h48. Deuxième hypothèse : un problème de contenu. Peut-être que les traductions machine ont dégradé la qualité et que Google a déclassé les pages. Le lead dev ouvre une fiche produit espagnole dans Chrome, \u003Ccode>View Source\u003C/code>. Le contenu est en espagnol. Les meta title et description aussi. Tout est cohérent.\u003C/p>\n\u003Cp>10h02. Le lead dev regarde de plus près le source HTML. Il tombe sur la ligne 1 :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;!\u003C/span>\u003Cspan style=\"color:#85E89D\">DOCTYPE\u003C/span>\u003Cspan style=\"color:#B392F0\"> html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#B392F0\"> lang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"auto\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> dir\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"ltr\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>\u003Ccode>lang=\"auto\"\u003C/code>.\u003C/p>\n\u003Cp>Pas \u003Ccode>lang=\"es\"\u003C/code>. Pas \u003Ccode>lang=\"de\"\u003C/code>. \u003Ccode>lang=\"auto\"\u003C/code> — une valeur qui n'existe dans aucune spécification. Ni HTML5, ni BCP 47, ni IANA Language Subtag Registry.\u003C/p>\n\u003Cp>Il ouvre une page allemande. \u003Ccode>lang=\"auto\"\u003C/code>. Une page française. \u003Ccode>lang=\"auto\"\u003C/code>. Le site anglais, celui qui n'est pas passé par Crowdin : \u003Ccode>lang=\"en\"\u003C/code>.\u003C/p>\n\u003Cp>Le problème est identifié. Il touche 4 200 pages dans 4 langues.\u003C/p>\n\u003Ch2>Le bug : Crowdin écrase le champ htmlLang avec \"auto\" en attente de validation\u003C/h2>\n\u003Cp>Pour comprendre le mécanisme, il faut regarder trois couches : Crowdin, Strapi, et Next.js.\u003C/p>\n\u003Ch3>Couche 1 : Crowdin Pre-translate\u003C/h3>\n\u003Cp>Quand l'option \"Pre-translate\" est utilisée avec une engine de machine translation, Crowdin crée des traductions en statut \"MT\" (Machine Translated). Ces traductions ne sont pas \"approuvées\" tant qu'un réviseur humain ne les valide pas.\u003C/p>\n\u003Cp>Le problème vient du mapping de champs dans l'intégration Crowdin-Strapi. Le fichier de configuration de l'intégration ressemblait à ceci :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">{\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  \"strapi\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    \"content_types\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">      \"api::product.product\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">        \"fields\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">          \"title\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: { \u003C/span>\u003Cspan style=\"color:#79B8FF\">\"translatable\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">true\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">          \"description\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: { \u003C/span>\u003Cspan style=\"color:#79B8FF\">\"translatable\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">true\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">          \"seoTitle\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: { \u003C/span>\u003Cspan style=\"color:#79B8FF\">\"translatable\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">true\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">          \"seoDescription\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: { \u003C/span>\u003Cspan style=\"color:#79B8FF\">\"translatable\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">true\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">          \"htmlLang\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: { \u003C/span>\u003Cspan style=\"color:#79B8FF\">\"translatable\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">true\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le champ \u003Ccode>htmlLang\u003C/code> a été marqué comme \u003Ccode>translatable\u003C/code>. Logique en apparence : chaque locale a sa propre valeur de \u003Ccode>htmlLang\u003C/code>. Sauf que Crowdin ne sait pas que ce champ est un code de langue — il le traite comme n'importe quelle chaîne de texte. Et quand la machine translation n'a pas encore de traduction approuvée pour un champ, le connecteur Strapi écrit une valeur temporaire.\u003C/p>\n\u003Cp>Cette valeur temporaire : \u003Ccode>\"auto\"\u003C/code>.\u003C/p>\n\u003Cp>Ce n'est pas documenté explicitement dans la documentation Crowdin. Le comportement vient du fait que le connecteur utilise le statut de la traduction pour décider quoi écrire. Pour les chaînes en statut \"MT\" non approuvées, certains champs reçoivent la valeur source. Mais pour les champs courts (moins de 5 caractères), le connecteur applique une logique différente : il écrit \u003Ccode>\"auto\"\u003C/code> comme placeholder, en attendant la validation humaine.\u003C/p>\n\u003Ch3>Couche 2 : Strapi\u003C/h3>\n\u003Cp>Côté Strapi, le champ \u003Ccode>htmlLang\u003C/code> est un simple champ texte, sans validation de format :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// schema.json du content-type Product\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">{\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  \"htmlLang\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">    \"type\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"string\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">    \"default\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"en\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">    \"required\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">false\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Pas de regex. Pas d'enum. Pas de validation BCP 47. Strapi accepte \u003Ccode>\"auto\"\u003C/code> sans broncher. Le champ passe de \u003Ccode>\"es\"\u003C/code> à \u003Ccode>\"auto\"\u003C/code> dans la base de données. Aucune erreur, aucun log, aucun warning.\u003C/p>\n\u003Cp>Pour vérifier l'étendue du problème, le lead dev a lancé une requête directe sur la base PostgreSQL de Strapi :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">SELECT\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> locale, html_lang, \u003C/span>\u003Cspan style=\"color:#79B8FF\">COUNT\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#F97583\">*\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">FROM\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> products\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">GROUP BY\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> locale, html_lang\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">ORDER BY\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> locale;\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Résultat :\u003C/p>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>locale\u003C/th>\n\u003Cth>html_lang\u003C/th>\n\u003Cth>count\u003C/th>\n\u003C/tr>\n\u003C/thead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>de\u003C/td>\n\u003Ctd>auto\u003C/td>\n\u003Ctd>1 050\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>en\u003C/td>\n\u003Ctd>en\u003C/td>\n\u003Ctd>4 200\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>es\u003C/td>\n\u003Ctd>auto\u003C/td>\n\u003Ctd>1 050\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>fr\u003C/td>\n\u003Ctd>auto\u003C/td>\n\u003Ctd>1 050\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>it\u003C/td>\n\u003Ctd>auto\u003C/td>\n\u003Ctd>1 050\u003C/td>\n\u003C/tr>\n\u003C/tbody>\n\u003C/table>\n\u003Cp>Les 4 200 pages non-anglaises portent toutes \u003Ccode>htmlLang = \"auto\"\u003C/code>.\u003C/p>\n\u003Ch3>Couche 3 : Next.js\u003C/h3>\n\u003Cp>Le layout racine de l'application Next.js utilisait ce champ pour construire la balise \u003Ccode>&#x3C;html>\u003C/code> :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// app/[locale]/layout.tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { getProductMeta } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@/lib/strapi'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> default\u003C/span>\u003Cspan style=\"color:#F97583\"> async\u003C/span>\u003Cspan style=\"color:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> LocaleLayout\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  children\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  params\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  children\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> React\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#B392F0\">ReactNode\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  params\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#FFAB70\">locale\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#79B8FF\"> string\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> };\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> meta\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> getProductMeta\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(params.locale);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#B392F0\"> lang\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{meta.htmlLang \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> params.locale} \u003C/span>\u003Cspan style=\"color:#B392F0\">dir\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"ltr\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>{children}&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le fallback \u003Ccode>params.locale\u003C/code> aurait dû rattraper le coup. Mais \u003Ccode>meta.htmlLang\u003C/code> n'était pas \u003Ccode>null\u003C/code> ni \u003Ccode>undefined\u003C/code> — il valait \u003Ccode>\"auto\"\u003C/code>, une chaîne non vide. Le \u003Ccode>||\u003C/code> ne déclenche pas le fallback. \u003Ccode>\"auto\"\u003C/code> est truthy.\u003C/p>\n\u003Cp>Le HTML servi par le serveur Next.js à chaque requête :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;!\u003C/span>\u003Cspan style=\"color:#85E89D\">DOCTYPE\u003C/span>\u003Cspan style=\"color:#B392F0\"> html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#B392F0\"> lang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"auto\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> dir\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"ltr\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">link\u003C/span>\u003Cspan style=\"color:#B392F0\"> rel\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"alternate\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> hreflang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"es\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> href\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"https://example.com/es/producto/...\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">link\u003C/span>\u003Cspan style=\"color:#B392F0\"> rel\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"alternate\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> hreflang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"de\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> href\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"https://example.com/de/produkt/...\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  &#x3C;!-- ... -->\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">head\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Un paradoxe : les balises hreflang disent \"cette page est en espagnol\", mais la balise \u003Ccode>&#x3C;html>\u003C/code> dit \"la langue est auto\". Pour un navigateur, \u003Ccode>lang=\"auto\"\u003C/code> n'a aucune signification — il l'ignore. Pour un lecteur d'écran, c'est un problème d'accessibilité. Pour Googlebot, c'est un signal de langue invalide.\u003C/p>\n\u003Ch3>Ce que voit Googlebot vs ce que voit le développeur\u003C/h3>\n\u003Cp>Le développeur ouvre la page dans Chrome. Le contenu est en espagnol. Le navigateur détecte automatiquement la langue via son algorithme de détection. L'expérience utilisateur est normale.\u003C/p>\n\u003Cp>Googlebot ne fait pas de détection automatique au même niveau. Il lit le signal \u003Ccode>lang\u003C/code> de la balise \u003Ccode>&#x3C;html>\u003C/code> comme un signal fort pour déterminer la langue et le marché cible de la page. Quand ce signal est invalide, Googlebot se rabat sur d'autres signaux : contenu textuel, hreflang, domaine, et — c'est le point crucial — les patterns de liens entrants.\u003C/p>\n\u003Cp>Pour les pages allemandes, beaucoup de liens venaient de sites néerlandais (la marketplace avait un partenariat avec un comparateur de prix basé aux Pays-Bas). Sans signal \u003Ccode>lang\u003C/code> valide, Googlebot a pondéré ces liens entrants plus fortement et a commencé à associer les pages allemandes au marché néerlandais.\u003C/p>\n\u003Cp>Résultat dans Search Console : les pages \u003Ccode>/de/produkt/sommerjacke-damen-1234\u003C/code> apparaissaient dans les résultats néerlandais pour des requêtes néerlandaises. La \u003Ca href=\"https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites\">documentation Google sur la langue\u003C/a> est claire : l'attribut \u003Ccode>lang\u003C/code> de la balise \u003Ccode>&#x3C;html>\u003C/code> est un signal que Googlebot utilise pour comprendre la langue d'une page.\u003C/p>\n\u003Ch3>Pourquoi les tests n'ont rien détecté\u003C/h3>\n\u003Cp>L'équipe avait des tests E2E avec Playwright. Mais les tests vérifiaient le contenu visible : titre affiché, prix, images. Aucun test ne validait l'attribut \u003Ccode>lang\u003C/code> de \u003Ccode>&#x3C;html>\u003C/code>. Le pipeline CI/CD ne faisait aucune assertion sur les balises SEO du document root.\u003C/p>\n\u003Cp>Screaming Frog n'était pas configuré pour alerter sur des valeurs \u003Ccode>lang\u003C/code> non standard — le crawler par défaut collecte la valeur mais ne la valide pas contre la liste BCP 47.\u003C/p>\n\u003Cp>Et comme le contenu textuel était correctement traduit, rien n'était visuellement cassé. Le bug était invisible à l'œil humain. Seul un audit HTML brut ou un \u003Ca href=\"/blog/lazy-load-du-hero-h1-dans-une-section-v-if-invisible-au-fetch-http-brut\">monitoring des divergences SSR\u003C/a> l'aurait détecté.\u003C/p>\n\u003Ch2>Le fix : trois couches, trois correctifs\u003C/h2>\n\u003Ch3>Correctif 1 — Strapi : validation du champ htmlLang\u003C/h3>\n\u003Cp>Ajout d'une validation sur le content-type Product pour rejeter toute valeur non conforme BCP 47 :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// src/api/product/content-types/product/lifecycles.js\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> bcp47\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> /\u003C/span>\u003Cspan style=\"color:#F97583\">^\u003C/span>\u003Cspan style=\"color:#79B8FF\">[a-z]\u003C/span>\u003Cspan style=\"color:#F97583\">{2,3}\u003C/span>\u003Cspan style=\"color:#DBEDFF\">(-\u003C/span>\u003Cspan style=\"color:#79B8FF\">[A-Z]\u003C/span>\u003Cspan style=\"color:#F97583\">{2}\u003C/span>\u003Cspan style=\"color:#DBEDFF\">)\u003C/span>\u003Cspan style=\"color:#F97583\">?$\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">module\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#79B8FF\">exports\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  beforeUpdate\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#FFAB70\">event\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">data\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> event.params;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (data.htmlLang \u003C/span>\u003Cspan style=\"color:#F97583\">&#x26;&#x26;\u003C/span>\u003Cspan style=\"color:#F97583\"> !\u003C/span>\u003Cspan style=\"color:#E1E4E8\">bcp47.\u003C/span>\u003Cspan style=\"color:#B392F0\">test\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(data.htmlLang)) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      throw\u003C/span>\u003Cspan style=\"color:#F97583\"> new\u003C/span>\u003Cspan style=\"color:#B392F0\"> Error\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">        `Invalid htmlLang value: \"${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">data\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">htmlLang\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}\". Must be a valid BCP 47 tag.`\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  beforeCreate\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#FFAB70\">event\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">data\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> event.params;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (data.htmlLang \u003C/span>\u003Cspan style=\"color:#F97583\">&#x26;&#x26;\u003C/span>\u003Cspan style=\"color:#F97583\"> !\u003C/span>\u003Cspan style=\"color:#E1E4E8\">bcp47.\u003C/span>\u003Cspan style=\"color:#B392F0\">test\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(data.htmlLang)) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      throw\u003C/span>\u003Cspan style=\"color:#F97583\"> new\u003C/span>\u003Cspan style=\"color:#B392F0\"> Error\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">        `Invalid htmlLang value: \"${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">data\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">htmlLang\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}\". Must be a valid BCP 47 tag.`\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">};\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Ch3>Correctif 2 — Next.js : fallback robuste\u003C/h3>\n\u003Cp>Le layout a été modifié pour ne jamais faire confiance aveuglément au champ CMS :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// app/[locale]/layout.tsx\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> VALID_LANGS\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'en'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'es'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'de'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'fr'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'it'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">] \u003C/span>\u003Cspan style=\"color:#F97583\">as\u003C/span>\u003Cspan style=\"color:#F97583\"> const\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">type\u003C/span>\u003Cspan style=\"color:#B392F0\"> ValidLang\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> typeof\u003C/span>\u003Cspan style=\"color:#79B8FF\"> VALID_LANGS\u003C/span>\u003Cspan style=\"color:#E1E4E8\">[number];\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">function\u003C/span>\u003Cspan style=\"color:#B392F0\"> resolveHtmlLang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#FFAB70\">cmsValue\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#79B8FF\"> string\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#79B8FF\"> null\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#FFAB70\">locale\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#79B8FF\"> string\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> ValidLang\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (cmsValue \u003C/span>\u003Cspan style=\"color:#F97583\">&#x26;&#x26;\u003C/span>\u003Cspan style=\"color:#79B8FF\"> VALID_LANGS\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#B392F0\">includes\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(cmsValue \u003C/span>\u003Cspan style=\"color:#F97583\">as\u003C/span>\u003Cspan style=\"color:#B392F0\"> ValidLang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> cmsValue \u003C/span>\u003Cspan style=\"color:#F97583\">as\u003C/span>\u003Cspan style=\"color:#B392F0\"> ValidLang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#79B8FF\">VALID_LANGS\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#B392F0\">includes\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(locale \u003C/span>\u003Cspan style=\"color:#F97583\">as\u003C/span>\u003Cspan style=\"color:#B392F0\"> ValidLang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> locale \u003C/span>\u003Cspan style=\"color:#F97583\">as\u003C/span>\u003Cspan style=\"color:#B392F0\"> ValidLang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'en'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> default\u003C/span>\u003Cspan style=\"color:#F97583\"> async\u003C/span>\u003Cspan style=\"color:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> LocaleLayout\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  children\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  params\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  children\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#B392F0\"> React\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#B392F0\">ReactNode\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  params\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#FFAB70\">locale\u003C/span>\u003Cspan style=\"color:#F97583\">:\u003C/span>\u003Cspan style=\"color:#79B8FF\"> string\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> };\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> meta\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> getProductMeta\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(params.locale);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> htmlLang\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> resolveHtmlLang\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(meta.htmlLang, params.locale);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#B392F0\"> lang\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{htmlLang} \u003C/span>\u003Cspan style=\"color:#B392F0\">dir\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"ltr\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      &#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>{children}&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">body\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">html\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  );\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le \u003Ccode>||\u003C/code> a été remplacé par une fonction qui valide explicitement la valeur contre une liste blanche. Plus de truthy/falsy implicite.\u003C/p>\n\u003Ch3>Correctif 3 — Crowdin : exclure le champ htmlLang du mapping\u003C/h3>\n\u003Cp>Le fichier de configuration de l'intégration a été modifié pour ne plus marquer \u003Ccode>htmlLang\u003C/code> comme traduisible :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">{\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  \"strapi\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    \"content_types\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">      \"api::product.product\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">        \"fields\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">          \"title\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: { \u003C/span>\u003Cspan style=\"color:#79B8FF\">\"translatable\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">true\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">          \"description\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: { \u003C/span>\u003Cspan style=\"color:#79B8FF\">\"translatable\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">true\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">          \"seoTitle\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: { \u003C/span>\u003Cspan style=\"color:#79B8FF\">\"translatable\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">true\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">          \"seoDescription\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: { \u003C/span>\u003Cspan style=\"color:#79B8FF\">\"translatable\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">true\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">          \"htmlLang\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: { \u003C/span>\u003Cspan style=\"color:#79B8FF\">\"translatable\"\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">false\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">        }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le champ \u003Ccode>htmlLang\u003C/code> est désormais géré manuellement par l'équipe contenu lors de la création de la locale dans Strapi. Il ne passe plus par le pipeline de traduction.\u003C/p>\n\u003Ch3>Correction des données existantes\u003C/h3>\n\u003Cp>Un script de migration a été exécuté pour corriger les 4 200 entrées :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">UPDATE\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> products \u003C/span>\u003Cspan style=\"color:#F97583\">SET\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> html_lang \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'es'\u003C/span>\u003Cspan style=\"color:#F97583\"> WHERE\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> locale \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'es'\u003C/span>\u003Cspan style=\"color:#F97583\"> AND\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> html_lang \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'auto'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">UPDATE\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> products \u003C/span>\u003Cspan style=\"color:#F97583\">SET\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> html_lang \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'de'\u003C/span>\u003Cspan style=\"color:#F97583\"> WHERE\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> locale \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'de'\u003C/span>\u003Cspan style=\"color:#F97583\"> AND\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> html_lang \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'auto'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">UPDATE\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> products \u003C/span>\u003Cspan style=\"color:#F97583\">SET\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> html_lang \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'fr'\u003C/span>\u003Cspan style=\"color:#F97583\"> WHERE\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> locale \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'fr'\u003C/span>\u003Cspan style=\"color:#F97583\"> AND\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> html_lang \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'auto'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">UPDATE\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> products \u003C/span>\u003Cspan style=\"color:#F97583\">SET\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> html_lang \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'it'\u003C/span>\u003Cspan style=\"color:#F97583\"> WHERE\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> locale \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'it'\u003C/span>\u003Cspan style=\"color:#F97583\"> AND\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> html_lang \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'auto'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Ch3>Cache et re-crawl\u003C/h3>\n\u003Cp>Après le déploiement du fix, l'équipe a :\u003C/p>\n\u003Col>\n\u003Cli>Purgé le cache ISR de Next.js (\u003Ccode>next/cache\u003C/code> revalidation via API route).\u003C/li>\n\u003Cli>Soumis les 4 sitemaps localisés dans Search Console pour demander un re-crawl.\u003C/li>\n\u003Cli>Utilisé l'outil d'inspection d'URL sur 20 pages échantillons pour vérifier que Googlebot recevait bien \u003Ccode>lang=\"es\"\u003C/code>, \u003Ccode>lang=\"de\"\u003C/code>, etc.\u003C/li>\n\u003C/ol>\n\u003Ch3>Temps de récupération\u003C/h3>\n\u003Cp>Les premiers signes de rétablissement sont apparus 6 jours après le fix. Le trafic espagnol a retrouvé son niveau d'avant l'incident en 14 jours. Le trafic allemand a mis 21 jours — les associations néerlandaises parasites ayant pris plus de temps à se dissiper dans l'index.\u003C/p>\n\u003Cp>Au total, l'estimation de perte : environ 52 000 clics organiques sur les marchés non-anglophones, sur une période de 32 jours (18 jours de bug + 14 jours de récupération).\u003C/p>\n\u003Ch3>Mesures préventives\u003C/h3>\n\u003Cp>L'équipe a ajouté un test Playwright dans le pipeline CI :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// tests/seo/html-lang.spec.ts\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { test, expect } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '@playwright/test'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> locales\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'en'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'es'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'de'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'fr'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'it'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">];\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">for\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> locale\u003C/span>\u003Cspan style=\"color:#F97583\"> of\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> locales) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  test\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`HTML lang attribute is \"${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">locale\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}\" for /${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">locale\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}/ pages`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#F97583\">async\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> ({ \u003C/span>\u003Cspan style=\"color:#FFAB70\">page\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }) \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> page.\u003C/span>\u003Cspan style=\"color:#B392F0\">goto\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`/${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">locale\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}/producto/test-product`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> lang\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> page.\u003C/span>\u003Cspan style=\"color:#B392F0\">getAttribute\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'html'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'lang'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    expect\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(lang).\u003C/span>\u003Cspan style=\"color:#B392F0\">toBe\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(locale);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Ce test bloque le déploiement si l'attribut \u003Ccode>lang\u003C/code> ne correspond pas à la locale de la route. Le genre de test qui prend 5 minutes à écrire et qui aurait évité 32 jours de dégâts.\u003C/p>\n\u003Cp>Un monitoring Screaming Frog a été configuré pour crawler les 5 versions localisées chaque semaine et alerter si une valeur \u003Ccode>lang\u003C/code> non standard apparaît. L'équipe a aussi documenté une règle interne : tout champ qui alimente une balise HTML structurelle (\u003Ccode>lang\u003C/code>, \u003Ccode>dir\u003C/code>, \u003Ccode>hreflang\u003C/code>, \u003Ca href=\"/blog/mode-sombre-prefers-color-scheme-injecte-un-meta-noindex-par-erreur\">meta robots\u003C/a>) ne doit jamais être marqué comme \u003Ccode>translatable\u003C/code> dans un connecteur de traduction.\u003C/p>\n\u003Ch2>Ce qu'on en retient\u003C/h2>\n\u003Cp>Un connecteur de traduction n'est pas qu'un outil de contenu. Il touche aux métadonnées, aux champs techniques, aux signaux que les moteurs de recherche utilisent pour classer un site. Quand un champ comme \u003Ccode>htmlLang\u003C/code> est marqué comme traduisible par erreur, le pipeline de machine translation peut injecter des valeurs temporaires que personne ne vérifie — parce que le contenu visible, lui, est correct.\u003C/p>\n\u003Cp>Le fossé entre ce que voit un humain dans un navigateur et ce que lit Googlebot dans le HTML brut est le terreau de ces régressions silencieuses. Un monitoring continu type Seogard détecte ce type de divergence entre signal déclaratif et contenu réel en quelques minutes, pas en 18 jours.\u003C/p>\n\u003Cp>Les champs techniques d'un CMS ne sont pas du contenu. Ils ne doivent jamais transiter par un pipeline de traduction automatique.\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,12,[18,10,19,20],"crowdin","lang","translation","Crowdin lang=\\\"auto\\\" : signal de langue cassé, −34 % trafic","Thu Jun 18 2026 06:01:45 GMT+0000 (Coordinated Universal Time)",[24,40,55,70,84,97],{"_id":25,"slug":26,"__v":6,"author":7,"canonical":27,"category":28,"createdAt":29,"date":30,"description":31,"image":15,"imageAlt":15,"readingTime":16,"tags":32,"title":38,"updatedAt":39},"6a3238a5aa6b273b0c4e69eb","bing-rolls-out-ai-citation-share-in-webmaster-tools-via-sejournal-mattgsouthern","https://seogard.io/blog/bing-rolls-out-ai-citation-share-in-webmaster-tools-via-sejournal-mattgsouthern","Actualités SEO","2026-06-17T06:03:17.202Z","2026-06-17","Analyse technique du nouveau Citation Share dans Bing Webmaster Tools. Métriques AI, impact sur le trafic, et stratégies d'optimisation concrètes.",[33,34,35,36,37],"bing","citation share","webmaster tools","AI search","SEO technique","Bing AI Citation Share : ce que ça change pour le SEO technique","Wed Jun 17 2026 06:03:17 GMT+0000 (Coordinated Universal Time)",{"_id":41,"slug":42,"__v":6,"author":7,"canonical":43,"category":44,"createdAt":45,"date":30,"description":46,"image":15,"imageAlt":15,"readingTime":47,"tags":48,"title":53,"updatedAt":54},"6a32c4e8aa6b273b0cbed17b","storyblok-redirections-en-custom-field-oubliees-au-passage-nouveau-plan","https://seogard.io/blog/storyblok-redirections-en-custom-field-oubliees-au-passage-nouveau-plan","Headless","2026-06-17T16:01:44.888Z","Un site e-commerce perd 1 200 redirections stockées en custom field Storyblok lors d'un upgrade de plan. Récit, diagnostic et fix complet.",11,[49,50,51,52],"storyblok","redirects","migration","cms","Storyblok : redirections custom perdues après changement de plan","Wed Jun 17 2026 16:01:44 GMT+0000 (Coordinated Universal Time)",{"_id":56,"slug":57,"__v":6,"author":7,"canonical":58,"category":44,"createdAt":59,"date":60,"description":61,"image":15,"imageAlt":15,"readingTime":47,"tags":62,"title":68,"updatedAt":69},"6a30e6c9aa6b273b0c3f8791","sanity-preview-mode-actif-en-prod-indexation-de-drafts-non-publies","https://seogard.io/blog/sanity-preview-mode-actif-en-prod-indexation-de-drafts-non-publies","2026-06-16T06:01:45.128Z","2026-06-16","Un site Next.js + Sanity garde la preview API key en production. Googlebot indexe 340 drafts non publiés. Récit, diagnostic et fix complet.",[63,64,65,66,67],"sanity","preview","drafts","indexation","next.js","Sanity preview mode en prod : drafts indexés par Google","Tue Jun 16 2026 06:01:45 GMT+0000 (Coordinated Universal Time)",{"_id":71,"slug":72,"__v":6,"author":7,"canonical":73,"category":44,"createdAt":74,"date":60,"description":75,"image":15,"imageAlt":15,"readingTime":16,"tags":76,"title":82,"updatedAt":83},"6a317369aa6b273b0cb03aa2","strapi-public-role-bloque-l-api-lecture-ssr-void-googlebot-voit-page-blanche","https://seogard.io/blog/strapi-public-role-bloque-l-api-lecture-ssr-void-googlebot-voit-page-blanche","2026-06-16T16:01:45.497Z","Un seed Strapi écrase le rôle Public. L'API renvoie 403, le SSR sert du vide. Récit complet : diagnostic, fix, récupération SEO en 19 jours.",[77,78,79,80,81],"strapi","permissions","ssr","api","headless-cms","Strapi public role 403 : SSR vide, Googlebot indexe du blanc","Tue Jun 16 2026 16:01:45 GMT+0000 (Coordinated Universal Time)",{"_id":85,"slug":86,"__v":6,"author":7,"canonical":87,"category":44,"createdAt":88,"date":89,"description":90,"image":15,"imageAlt":15,"readingTime":16,"tags":91,"title":95,"updatedAt":96},"6a2f9542aa6b273b0c30f3ec","contentful-le-champ-seo-title-non-sync-vers-next-js-fallback-first-h1-genere","https://seogard.io/blog/contentful-le-champ-seo-title-non-sync-vers-next-js-fallback-first-h1-genere","2026-06-15T06:01:38.446Z","2026-06-15","Un champ SEO title Contentful non mappé dans Next.js génère un fallback H1 identique sur 1 200 variantes produit. Récit, diagnostic, fix.",[92,93,67,94],"contentful","headless","mapping","Contentful + Next.js : title manquant, fallback H1 sur 1 200 pages","Mon Jun 15 2026 06:01:38 GMT+0000 (Coordinated Universal Time)",{"_id":98,"slug":99,"__v":6,"author":7,"canonical":100,"category":28,"createdAt":101,"date":89,"description":102,"image":15,"imageAlt":15,"readingTime":16,"tags":103,"title":109,"updatedAt":110},"6a30222eaa6b273b0ca1e7dc","what-ai-overview-click-data-reveals-about-consumer-search-behavior-5-strategic-insights-for-cmos-via-sejournal-gregjarboe","https://seogard.io/blog/what-ai-overview-click-data-reveals-about-consumer-search-behavior-5-strategic-insights-for-cmos-via-sejournal-gregjarboe","2026-06-15T16:02:54.519Z","Les utilisateurs quotidiens d'AI Overview cliquent 3.5x plus sur les sources. Analyse technique des données et stratégies d'optimisation concrètes.",[104,105,106,107,108],"AI Overview","click data","search behavior","SGE","structured data","AI Overview Click Data : ce que les clics révèlent vraiment","Mon Jun 15 2026 16:02:54 GMT+0000 (Coordinated Universal Time)",{"categories":112},[113,116,119,123,127,130,134,137,140,144,148,151,154,158,161,164,167,170,171,175],{"category":28,"slug":114,"count":115},"actualites-seo",169,{"category":117,"slug":51,"count":118},"Migration",18,{"category":120,"slug":121,"count":122},"Rendering","rendering",9,{"category":124,"slug":125,"count":126},"Framework","framework",8,{"category":128,"slug":129,"count":126},"Performance","performance",{"category":131,"slug":132,"count":133},"Meta Tags","meta-tags",7,{"category":135,"slug":136,"count":133},"SEO Technique","seo-technique",{"category":138,"slug":139,"count":133},"Crawl","crawl",{"category":141,"slug":142,"count":143},"Architecture","architecture",6,{"category":145,"slug":146,"count":147},"JavaScript SEO","javascript-seo",5,{"category":149,"slug":150,"count":147},"Monitoring","monitoring",{"category":152,"slug":153,"count":147},"Structured Data","structured-data",{"category":155,"slug":156,"count":157},"Refonte","refonte",4,{"category":159,"slug":160,"count":157},"Outils","outils",{"category":162,"slug":163,"count":157},"Redirections","redirections",{"category":165,"slug":166,"count":157},"E-commerce","e-commerce",{"category":168,"slug":169,"count":157},"Avancé","avance",{"category":44,"slug":93,"count":157},{"category":172,"slug":173,"count":174},"Contenu","contenu",3,{"category":176,"slug":177,"count":174},"IA & SEO","ia-seo"]