[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$f-S_a9LoVj9d1x9IXKg8o04gIlMMMbsvVy5azv0ByK04":3,"$faubyacRun6o8iehGDyYjufFj8JcVg4SCrD69llmC7bQ":25},{"_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":23,"updatedAt":24},"6a14645baa6b273b0cc45458","astro-v5-vers-v6-content-collections-cassent-le-mapping-frontmatter-title",0,"Equipe Seogard","# Astro v6 : quand Content Collections vident 312 balises title sans prévenir\n\nMercredi 14h20. Un éditeur tech français — 312 articles de blog, 40K sessions organiques mensuelles — pousse la migration Astro 5.7 vers 6.1. Le build passe. Lighthouse reste vert. Personne ne regarde le `\u003Ctitle>` des pages rendues. Dix-huit jours plus tard, Search Console affiche −14K clics sur la période. Le coupable : un refacto interne des Content Collections qui change la façon dont le frontmatter remonte dans les templates. Les titres sont vides. Googlebot indexe du néant.\n\n## Jeudi 9h12 — \"On a un souci de trafic, non ?\"\n\nLe responsable éditorial ouvre Search Console le jeudi matin, neuf jours après le déploiement. La courbe de clics plonge depuis cinq jours. Pas un effondrement brutal — une descente progressive, −8 % par jour, le genre de signal qu'on confond avec de la saisonnalité.\n\nPremier réflexe : vérifier si un core update Google est en cours. La réponse est oui — le [May 2026 Core Update roule depuis quelques jours](/blog/google-may-2026-core-update-rolling-out-now). L'équipe conclut trop vite : \"C'est le core update, on attend.\" Le lead SEO acquiesce. Personne ne creuse davantage.\n\nQuatre jours passent. Le trafic continue de baisser. Le lead SEO lance un crawl Screaming Frog sur l'ensemble du blog. 312 URLs. Le rapport sort en 47 secondes. Colonne `Title 1` : 289 lignes affichent un titre vide ou le fallback `\u003Ctitle>undefined\u003C/title>`. 23 pages seulement gardent un titre correct — celles qui utilisent un layout différent, hérité de l'ancien code.\n\nLe lead SEO envoie un message dans le canal Slack #seo-tech à 14h03 : \"289 pages ont un title vide ou `undefined`. Depuis quand ?\"\n\nLe développeur front vérifie le commit history. La migration Astro v5 → v6 a été mergée le mercredi précédent. Le build CI était vert. Les tests end-to-end Playwright passaient — mais aucun ne vérifiait le contenu de la balise `\u003Ctitle>`.\n\nL'hypothèse initiale du dev : \"Le layout a dû casser, je regarde.\" Il ouvre le fichier `[...slug].astro`, le layout principal, le composant `\u003CBaseHead>`. Tout semble en place. Le frontmatter YAML des fichiers `.md` contient bien un champ `title`. Le Zod schema le valide. Pourtant le HTML rendu affiche `\u003Ctitle>\u003C/title>`.\n\nÀ 15h20, le dev tape dans le terminal :\n\n```bash\nnpx astro build && cat dist/blog/mon-article/index.html | grep '\u003Ctitle>'\n```\n\nRésultat :\n\n```html\n\u003Ctitle>\u003C/title>\n```\n\nLe frontmatter existe. Le schema passe. Le build compile. Mais le titre ne remonte pas dans le template. Le dev comprend que ce n'est pas un bug mineur. C'est une régression d'API silencieuse entre Astro 5 et Astro 6.\n\n## Le bug : Content Collections v2 et le nouveau shape de `entry.data`\n\nPour comprendre la casse, il faut remonter à ce qu'Astro v6 a changé dans les Content Collections.\n\n### L'API en Astro v5\n\nEn Astro 5.x, quand on récupérait une entrée de collection, la structure ressemblait à ceci :\n\n```typescript\n// src/pages/blog/[...slug].astro — Astro 5.x\n---\nimport { getCollection } from 'astro:content';\nimport BlogLayout from '../../layouts/BlogLayout.astro';\n\nexport async function getStaticPaths() {\n  const posts = await getCollection('blog');\n  return posts.map((post) => ({\n    params: { slug: post.slug },\n    props: { post },\n  }));\n}\n\nconst { post } = Astro.props;\nconst { Content } = await post.render();\n---\n\n\u003CBlogLayout title={post.data.title} description={post.data.description}>\n  \u003CContent />\n\u003C/BlogLayout>\n```\n\n`post.data.title` fonctionnait. Le champ `data` était un objet plat contenant directement les propriétés du frontmatter, validées par le schéma Zod défini dans `src/content/config.ts`.\n\n### Ce qu'Astro v6 a changé\n\nAstro v6 a introduit un refacto majeur des Content Collections sous le nom interne \"Content Layer v2\". L'objectif : supporter des sources de contenu distantes (CMS headless, API, bases de données) au même niveau que les fichiers Markdown locaux. Pour unifier l'API, la structure de retour de `getCollection()` a été modifiée.\n\nLe changement critique : pour les collections basées sur des fichiers locaux, le frontmatter n'est plus directement sous `entry.data`. Il est désormais encapsulé sous `entry.data.frontmatter` quand la collection utilise le loader `file` ou `glob` (le défaut pour les fichiers `.md` et `.mdx`).\n\nVoici la nouvelle config de collection en Astro v6 :\n\n```typescript\n// src/content/config.ts — Astro 6.x\nimport { defineCollection, z } from 'astro:content';\nimport { glob } from 'astro/loaders';\n\nconst blog = defineCollection({\n  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),\n  schema: z.object({\n    title: z.string(),\n    description: z.string(),\n    date: z.coerce.date(),\n    tags: z.array(z.string()).optional(),\n  }),\n});\n\nexport const collections = { blog };\n```\n\nLe schéma Zod est identique. Il valide toujours les mêmes champs. Mais l'accès dans le template doit maintenant passer par `entry.data.frontmatter.title` au lieu de `entry.data.title` — dans certaines configurations du loader.\n\nLe piège : **le comportement exact dépend de la façon dont le loader `glob` résout les fichiers**. Dans le cas de cette équipe, la migration vers le nouveau loader (obligatoire en v6, l'ancien système `type: 'content'` étant déprécié puis supprimé) a changé le shape de `data` sans qu'aucune erreur TypeScript ne remonte.\n\nPourquoi pas d'erreur TypeScript ? Parce que le type inféré par Zod restait correct au niveau du schéma. Le problème se situait au runtime : `post.data.title` retournait `undefined` car la valeur vivait désormais un niveau plus bas. Mais `undefined` en TypeScript, dans un template Astro, ne produit pas d'erreur de compilation. Il produit une string vide dans le HTML.\n\n### Ce que voit le développeur vs ce que voit Googlebot\n\nLe développeur ouvre la page dans le navigateur. Le titre s'affiche dans le `\u003Ch1>` — parce que le composant `\u003CContent />` rend le Markdown, et le `# Titre` du fichier `.md` génère un `\u003Ch1>` indépendamment du frontmatter. L'illusion est parfaite.\n\nGooglebot, lui, lit le `\u003Chead>`. Voici ce qu'il trouvait :\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"fr\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\" />\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  \u003Ctitle>\u003C/title>\n  \u003Cmeta name=\"description\" content=\"\" />\n  \u003Clink rel=\"canonical\" href=\"https://example.com/blog/mon-article\" />\n  \u003Cmeta property=\"og:title\" content=\"\" />\n  \u003Cmeta property=\"og:description\" content=\"\" />\n\u003C/head>\n```\n\n`\u003Ctitle>` vide. `\u003Cmeta name=\"description\">` vide. `og:title` vide. Quatre balises critiques en SEO, toutes dépendantes de `post.data.title` et `post.data.description`, toutes résolues à `undefined`, toutes rendues comme chaînes vides.\n\nLe composant `\u003CBaseHead>` incriminé :\n\n```astro\n// src/components/BaseHead.astro\n---\nconst { title, description } = Astro.props;\n---\n\n\u003Cmeta charset=\"UTF-8\" />\n\u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n\u003Ctitle>{title}\u003C/title>\n\u003Cmeta name=\"description\" content={description} />\n\u003Cmeta property=\"og:title\" content={title} />\n\u003Cmeta property=\"og:description\" content={description} />\n```\n\nLe composant est sain. C'est l'amont qui envoie `undefined`. Et Astro, contrairement à React qui rendrait littéralement la string \"undefined\", rend une string vide quand la valeur est `undefined` dans une expression de template. Comportement logique côté framework, catastrophique côté SEO.\n\n### Pourquoi les tests n'ont rien vu\n\nL'équipe avait des tests Playwright. Mais les assertions portaient sur :\n- Le statut HTTP (200)\n- La présence du `\u003Ch1>` (qui venait du Markdown, pas du frontmatter)\n- L'absence d'erreur console\n\nAucun test ne vérifiait `document.title` ou le contenu de `\u003Cmeta name=\"description\">`. Un oubli classique. Le `\u003Ch1>` était correct, le navigateur affichait la page normalement, et le build ne crashait pas.\n\nPour confirmer le diagnostic, le dev a utilisé l'outil d'inspection d'URL de Search Console sur une page impactée. Le rendu HTML montrait le `\u003Ctitle>` vide. L'inspection confirmait aussi que Google avait re-crawlé la page trois jours après le déploiement et avait indexé la version sans titre.\n\nUne vérification complémentaire avec `curl` :\n\n```bash\ncurl -s https://example.com/blog/optimiser-images-webp | head -30\n```\n\nSortie confirmant le `\u003Ctitle>\u003C/title>` vide dans le HTML statique servi. Pas besoin de JavaScript rendering — Astro génère du HTML statique, ce qui veut dire que le bug était visible dès le premier octet servi. Aucune excuse côté rendu client.\n\n## Le fix : 47 minutes pour patcher, 19 jours pour récupérer\n\n### Patch 1 — Corriger l'accès au frontmatter\n\nLa première option, la plus propre, consistait à adapter le template à la nouvelle API :\n\n```typescript\n// src/pages/blog/[...slug].astro — Astro 6.x corrigé\n---\nimport { getCollection } from 'astro:content';\nimport BlogLayout from '../../layouts/BlogLayout.astro';\n\nexport async function getStaticPaths() {\n  const posts = await getCollection('blog');\n  return posts.map((post) => ({\n    params: { slug: post.id },\n    props: { post },\n  }));\n}\n\nconst { post } = Astro.props;\nconst { Content } = await post.render();\n\n// Astro 6 : le frontmatter peut être sous data directement\n// OU sous data.frontmatter selon le loader. On normalise.\nconst frontmatter = post.data.frontmatter ?? post.data;\nconst title = frontmatter.title;\nconst description = frontmatter.description;\n---\n\n\u003CBlogLayout title={title} description={description}>\n  \u003CContent />\n\u003C/BlogLayout>\n```\n\nLe pattern `post.data.frontmatter ?? post.data` gère les deux cas : la nouvelle structure et l'ancienne, au cas où certaines collections n'auraient pas encore migré vers le nouveau loader.\n\nNote importante : en Astro v6, `post.slug` est remplacé par `post.id` pour les chemins statiques. Autre changement silencieux qui peut casser les URLs si non anticipé. Dans le cas de cette équipe, les slugs étaient déjà basés sur les noms de fichiers, donc `post.id` produisait le même résultat. Un coup de chance.\n\n### Patch 2 — Ajouter un guard dans BaseHead\n\nPour éviter que le problème ne se reproduise avec un autre champ, l'équipe a ajouté un fallback défensif :\n\n```astro\n// src/components/BaseHead.astro — version durcie\n---\nconst { title, description } = Astro.props;\n\nconst safeTitle = title || 'Blog — Example.com';\nconst safeDescription = description || 'Découvrez nos articles techniques.';\n\nif (!title) {\n  console.warn(`[SEO] Missing title for page: ${Astro.url.pathname}`);\n}\n---\n\n\u003Cmeta charset=\"UTF-8\" />\n\u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n\u003Ctitle>{safeTitle}\u003C/title>\n\u003Cmeta name=\"description\" content={safeDescription} />\n\u003Cmeta property=\"og:title\" content={safeTitle} />\n\u003Cmeta property=\"og:description\" content={safeDescription} />\n```\n\nLe `console.warn` au build permet de détecter les pages sans titre dans les logs CI. Pas un remplacement pour un vrai test, mais un filet de sécurité supplémentaire.\n\n### Patch 3 — Ajouter un test Playwright sur les metas\n\n```typescript\n// tests/seo-meta.spec.ts\nimport { test, expect } from '@playwright/test';\n\nconst sampleUrls = [\n  '/blog/optimiser-images-webp',\n  '/blog/guide-typescript-2026',\n  '/blog/astro-vs-next-comparatif',\n];\n\nfor (const url of sampleUrls) {\n  test(`Page ${url} has a non-empty title and meta description`, async ({ page }) => {\n    await page.goto(url);\n    \n    const title = await page.title();\n    expect(title).not.toBe('');\n    expect(title).not.toBe('undefined');\n    expect(title.length).toBeGreaterThan(10);\n\n    const description = await page\n      .locator('meta[name=\"description\"]')\n      .getAttribute('content');\n    expect(description).not.toBe('');\n    expect(description).not.toBe('undefined');\n    expect(description!.length).toBeGreaterThan(20);\n  });\n}\n```\n\nCe test aurait détecté le bug avant le merge. L'équipe l'a intégré dans la CI avec une règle simple : le pipeline bloque si une seule page de l'échantillon a un titre vide.\n\n### Déploiement et invalidation\n\nLe fix a été déployé 47 minutes après le diagnostic confirmé. Le site étant hébergé sur Cloudflare Pages, le build et le déploiement ont pris 2 minutes 14 secondes. L'équipe a purgé le cache Cloudflare manuellement par précaution, même si les pages statiques d'Astro n'utilisent pas de cache edge agressif par défaut.\n\nEnsuite, soumission en masse des 289 URLs impactées via l'API d'indexation de Search Console. Pas via l'interface — 289 pages une par une, ce n'est pas viable. Un script Python avec la librairie `google-auth` et l'API Indexing a fait le travail en 12 secondes.\n\n### Timeline de récupération\n\n- **J+0** (fix déployé) : HTML corrigé, titres présents.\n- **J+2** : Search Console montre que 78 pages ont été re-crawlées.\n- **J+5** : 210 pages re-crawlées. Les impressions commencent à remonter.\n- **J+9** : 289/289 pages re-crawlées avec le bon titre.\n- **J+12** : les clics reviennent à 70 % du niveau pré-incident.\n- **J+19** : retour à la normale. 39,2K clics sur les 28 derniers jours, contre 40,1K sur la période de référence. La différence de 2 % est dans la marge du core update en cours.\n\nLe facteur aggravant : le [core update de mai 2026](/blog/google-launches-core-update-amid-i-o-ai-search-overhaul-seo-pulse-via-sejournal-mattgsouthern) tournait en parallèle. Impossible de savoir avec certitude si la récupération aurait été plus rapide sans. Mais les données Search Console montrent clairement que la chute de clics a commencé trois jours après le déploiement du code cassé — pas au début du core update. La corrélation temporelle est sans ambiguïté.\n\nL'équipe a vérifié manuellement via Screaming Frog après le fix : 312/312 pages avec un `\u003Ctitle>` non vide, longueur moyenne de 52 caractères, aucun doublon. Un crawl propre.\n\n## Ce qu'on en retient\n\nLe frontmatter était là. Le schéma Zod était valide. Le build passait. Les tests aussi. Et pourtant, 289 pages ont tourné dix-huit jours avec un `\u003Ctitle>` vide — parce que le chemin d'accès à une propriété a changé d'un niveau de profondeur entre deux versions majeures.\n\nLes migrations de framework ne cassent pas toujours ce qu'on surveille. Elles cassent ce qu'on suppose stable. Le `\u003Ctitle>` est l'élément SEO le plus basique qui existe. C'est aussi celui que personne ne teste explicitement, parce que \"ça a toujours marché.\"\n\nTrois règles à graver : tester les balises `\u003Chead>` dans la CI, pas seulement le rendu visible. Lire les changelogs des loaders de contenu, pas seulement ceux du framework. Et monitorer le HTML réellement servi après chaque déploiement — un outil comme Seogard détecte une divergence de `\u003Ctitle>` entre deux crawls en quelques minutes, pas en dix-huit jours.\n\nLes régressions SEO silencieuses ne sont jamais spectaculaires. C'est pour ça qu'elles font autant de dégâts. Le prochain champ qui passera de `data.x` à `data.nested.x` ne préviendra pas non plus.\n```","https://seogard.io/blog/astro-v5-vers-v6-content-collections-cassent-le-mapping-frontmatter-title","Migration","2026-05-25T15:01:47.013Z","2026-05-25","Après upgrade Astro v5→v6, 312 articles perdent leur balise title. Récit du bug, diagnostic frontmatter, fix et récupération SEO en 19 jours.","\u003Ch1>Astro v6 : quand Content Collections vident 312 balises title sans prévenir\u003C/h1>\n\u003Cp>Mercredi 14h20. Un éditeur tech français — 312 articles de blog, 40K sessions organiques mensuelles — pousse la migration Astro 5.7 vers 6.1. Le build passe. Lighthouse reste vert. Personne ne regarde le \u003Ccode>&#x3C;title>\u003C/code> des pages rendues. Dix-huit jours plus tard, Search Console affiche −14K clics sur la période. Le coupable : un refacto interne des Content Collections qui change la façon dont le frontmatter remonte dans les templates. Les titres sont vides. Googlebot indexe du néant.\u003C/p>\n\u003Ch2>Jeudi 9h12 — \"On a un souci de trafic, non ?\"\u003C/h2>\n\u003Cp>Le responsable éditorial ouvre Search Console le jeudi matin, neuf jours après le déploiement. La courbe de clics plonge depuis cinq jours. Pas un effondrement brutal — une descente progressive, −8 % par jour, le genre de signal qu'on confond avec de la saisonnalité.\u003C/p>\n\u003Cp>Premier réflexe : vérifier si un core update Google est en cours. La réponse est oui — le \u003Ca href=\"/blog/google-may-2026-core-update-rolling-out-now\">May 2026 Core Update roule depuis quelques jours\u003C/a>. L'équipe conclut trop vite : \"C'est le core update, on attend.\" Le lead SEO acquiesce. Personne ne creuse davantage.\u003C/p>\n\u003Cp>Quatre jours passent. Le trafic continue de baisser. Le lead SEO lance un crawl Screaming Frog sur l'ensemble du blog. 312 URLs. Le rapport sort en 47 secondes. Colonne \u003Ccode>Title 1\u003C/code> : 289 lignes affichent un titre vide ou le fallback \u003Ccode>&#x3C;title>undefined&#x3C;/title>\u003C/code>. 23 pages seulement gardent un titre correct — celles qui utilisent un layout différent, hérité de l'ancien code.\u003C/p>\n\u003Cp>Le lead SEO envoie un message dans le canal Slack #seo-tech à 14h03 : \"289 pages ont un title vide ou \u003Ccode>undefined\u003C/code>. Depuis quand ?\"\u003C/p>\n\u003Cp>Le développeur front vérifie le commit history. La migration Astro v5 → v6 a été mergée le mercredi précédent. Le build CI était vert. Les tests end-to-end Playwright passaient — mais aucun ne vérifiait le contenu de la balise \u003Ccode>&#x3C;title>\u003C/code>.\u003C/p>\n\u003Cp>L'hypothèse initiale du dev : \"Le layout a dû casser, je regarde.\" Il ouvre le fichier \u003Ccode>[...slug].astro\u003C/code>, le layout principal, le composant \u003Ccode>&#x3C;BaseHead>\u003C/code>. Tout semble en place. Le frontmatter YAML des fichiers \u003Ccode>.md\u003C/code> contient bien un champ \u003Ccode>title\u003C/code>. Le Zod schema le valide. Pourtant le HTML rendu affiche \u003Ccode>&#x3C;title>&#x3C;/title>\u003C/code>.\u003C/p>\n\u003Cp>À 15h20, le dev tape dans le terminal :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">npx\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> astro\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> build\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> &#x26;&#x26; \u003C/span>\u003Cspan style=\"color:#B392F0\">cat\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> dist/blog/mon-article/index.html\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#B392F0\"> grep\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '&#x3C;title>'\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Résultat :\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\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le frontmatter existe. Le schema passe. Le build compile. Mais le titre ne remonte pas dans le template. Le dev comprend que ce n'est pas un bug mineur. C'est une régression d'API silencieuse entre Astro 5 et Astro 6.\u003C/p>\n\u003Ch2>Le bug : Content Collections v2 et le nouveau shape de \u003Ccode>entry.data\u003C/code>\u003C/h2>\n\u003Cp>Pour comprendre la casse, il faut remonter à ce qu'Astro v6 a changé dans les Content Collections.\u003C/p>\n\u003Ch3>L'API en Astro v5\u003C/h3>\n\u003Cp>En Astro 5.x, quand on récupérait une entrée de collection, la structure 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:#6A737D\">// src/pages/blog/[...slug].astro — Astro 5.x\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">---\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { getCollection } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'astro:content'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> BlogLayout \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '../../layouts/BlogLayout.astro'\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\"> async\u003C/span>\u003Cspan style=\"color:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> getStaticPaths\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\"> posts\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> getCollection\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'blog'\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\"> posts.\u003C/span>\u003Cspan style=\"color:#B392F0\">map\u003C/span>\u003Cspan style=\"color:#E1E4E8\">((\u003C/span>\u003Cspan style=\"color:#FFAB70\">post\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:#E1E4E8\">    params: { slug: post.slug },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    props: { post },\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\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">post\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> Astro.props;\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\">Content\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> post.\u003C/span>\u003Cspan style=\"color:#B392F0\">render\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">---\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">&#x3C;\u003C/span>\u003Cspan style=\"color:#E1E4E8\">BlogLayout title\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{post.data.title} description\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{post.data.description}\u003C/span>\u003Cspan style=\"color:#F97583\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#E1E4E8\">Content \u003C/span>\u003Cspan style=\"color:#F97583\">/>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#E1E4E8\">BlogLayout\u003C/span>\u003Cspan style=\"color:#F97583\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>\u003Ccode>post.data.title\u003C/code> fonctionnait. Le champ \u003Ccode>data\u003C/code> était un objet plat contenant directement les propriétés du frontmatter, validées par le schéma Zod défini dans \u003Ccode>src/content/config.ts\u003C/code>.\u003C/p>\n\u003Ch3>Ce qu'Astro v6 a changé\u003C/h3>\n\u003Cp>Astro v6 a introduit un refacto majeur des Content Collections sous le nom interne \"Content Layer v2\". L'objectif : supporter des sources de contenu distantes (CMS headless, API, bases de données) au même niveau que les fichiers Markdown locaux. Pour unifier l'API, la structure de retour de \u003Ccode>getCollection()\u003C/code> a été modifiée.\u003C/p>\n\u003Cp>Le changement critique : pour les collections basées sur des fichiers locaux, le frontmatter n'est plus directement sous \u003Ccode>entry.data\u003C/code>. Il est désormais encapsulé sous \u003Ccode>entry.data.frontmatter\u003C/code> quand la collection utilise le loader \u003Ccode>file\u003C/code> ou \u003Ccode>glob\u003C/code> (le défaut pour les fichiers \u003Ccode>.md\u003C/code> et \u003Ccode>.mdx\u003C/code>).\u003C/p>\n\u003Cp>Voici la nouvelle config de collection en Astro v6 :\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/content/config.ts — Astro 6.x\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { defineCollection, z } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'astro:content'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { glob } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'astro/loaders'\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\"> blog\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#B392F0\"> defineCollection\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  loader: \u003C/span>\u003Cspan style=\"color:#B392F0\">glob\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({ pattern: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'**/*.md'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, base: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'./src/content/blog'\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> }),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  schema: z.\u003C/span>\u003Cspan style=\"color:#B392F0\">object\u003C/span>\u003Cspan style=\"color:#E1E4E8\">({\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    title: z.\u003C/span>\u003Cspan style=\"color:#B392F0\">string\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    description: z.\u003C/span>\u003Cspan style=\"color:#B392F0\">string\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    date: z.coerce.\u003C/span>\u003Cspan style=\"color:#B392F0\">date\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    tags: z.\u003C/span>\u003Cspan style=\"color:#B392F0\">array\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(z.\u003C/span>\u003Cspan style=\"color:#B392F0\">string\u003C/span>\u003Cspan style=\"color:#E1E4E8\">()).\u003C/span>\u003Cspan style=\"color:#B392F0\">optional\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\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C/span>\u003Cspan style=\"color:#F97583\"> const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> collections\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { blog };\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le schéma Zod est identique. Il valide toujours les mêmes champs. Mais l'accès dans le template doit maintenant passer par \u003Ccode>entry.data.frontmatter.title\u003C/code> au lieu de \u003Ccode>entry.data.title\u003C/code> — dans certaines configurations du loader.\u003C/p>\n\u003Cp>Le piège : \u003Cstrong>le comportement exact dépend de la façon dont le loader \u003Ccode>glob\u003C/code> résout les fichiers\u003C/strong>. Dans le cas de cette équipe, la migration vers le nouveau loader (obligatoire en v6, l'ancien système \u003Ccode>type: 'content'\u003C/code> étant déprécié puis supprimé) a changé le shape de \u003Ccode>data\u003C/code> sans qu'aucune erreur TypeScript ne remonte.\u003C/p>\n\u003Cp>Pourquoi pas d'erreur TypeScript ? Parce que le type inféré par Zod restait correct au niveau du schéma. Le problème se situait au runtime : \u003Ccode>post.data.title\u003C/code> retournait \u003Ccode>undefined\u003C/code> car la valeur vivait désormais un niveau plus bas. Mais \u003Ccode>undefined\u003C/code> en TypeScript, dans un template Astro, ne produit pas d'erreur de compilation. Il produit une string vide dans le HTML.\u003C/p>\n\u003Ch3>Ce que voit le développeur vs ce que voit Googlebot\u003C/h3>\n\u003Cp>Le développeur ouvre la page dans le navigateur. Le titre s'affiche dans le \u003Ccode>&#x3C;h1>\u003C/code> — parce que le composant \u003Ccode>&#x3C;Content />\u003C/code> rend le Markdown, et le \u003Ccode># Titre\u003C/code> du fichier \u003Ccode>.md\u003C/code> génère un \u003Ccode>&#x3C;h1>\u003C/code> indépendamment du frontmatter. L'illusion est parfaite.\u003C/p>\n\u003Cp>Googlebot, lui, lit le \u003Ccode>&#x3C;head>\u003C/code>. Voici ce qu'il trouvait :\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\">\"fr\"\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> charset\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"UTF-8\"\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"viewport\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"width=device-width, initial-scale=1.0\"\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\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">title\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"\"\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\">\"canonical\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> href\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"https://example.com/blog/mon-article\"\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> property\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:title\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"\"\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> property\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"\"\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>\u003C/code>\u003C/pre>\n\u003Cp>\u003Ccode>&#x3C;title>\u003C/code> vide. \u003Ccode>&#x3C;meta name=\"description\">\u003C/code> vide. \u003Ccode>og:title\u003C/code> vide. Quatre balises critiques en SEO, toutes dépendantes de \u003Ccode>post.data.title\u003C/code> et \u003Ccode>post.data.description\u003C/code>, toutes résolues à \u003Ccode>undefined\u003C/code>, toutes rendues comme chaînes vides.\u003C/p>\n\u003Cp>Le composant \u003Ccode>&#x3C;BaseHead>\u003C/code> incriminé :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">// src/components/BaseHead.astro\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">---\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">const { title, description } = Astro.props;\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:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> charset\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"UTF-8\"\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"viewport\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"width=device-width, initial-scale=1.0\"\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\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>{title}&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">title\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">={description} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> property\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:title\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">={title} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> property\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">={description} />\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le composant est sain. C'est l'amont qui envoie \u003Ccode>undefined\u003C/code>. Et Astro, contrairement à React qui rendrait littéralement la string \"undefined\", rend une string vide quand la valeur est \u003Ccode>undefined\u003C/code> dans une expression de template. Comportement logique côté framework, catastrophique côté SEO.\u003C/p>\n\u003Ch3>Pourquoi les tests n'ont rien vu\u003C/h3>\n\u003Cp>L'équipe avait des tests Playwright. Mais les assertions portaient sur :\u003C/p>\n\u003Cul>\n\u003Cli>Le statut HTTP (200)\u003C/li>\n\u003Cli>La présence du \u003Ccode>&#x3C;h1>\u003C/code> (qui venait du Markdown, pas du frontmatter)\u003C/li>\n\u003Cli>L'absence d'erreur console\u003C/li>\n\u003C/ul>\n\u003Cp>Aucun test ne vérifiait \u003Ccode>document.title\u003C/code> ou le contenu de \u003Ccode>&#x3C;meta name=\"description\">\u003C/code>. Un oubli classique. Le \u003Ccode>&#x3C;h1>\u003C/code> était correct, le navigateur affichait la page normalement, et le build ne crashait pas.\u003C/p>\n\u003Cp>Pour confirmer le diagnostic, le dev a utilisé l'outil d'inspection d'URL de Search Console sur une page impactée. Le rendu HTML montrait le \u003Ccode>&#x3C;title>\u003C/code> vide. L'inspection confirmait aussi que Google avait re-crawlé la page trois jours après le déploiement et avait indexé la version sans titre.\u003C/p>\n\u003Cp>Une vérification complémentaire avec \u003Ccode>curl\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:#B392F0\">curl\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -s\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> https://example.com/blog/optimiser-images-webp\u003C/span>\u003Cspan style=\"color:#F97583\"> |\u003C/span>\u003Cspan style=\"color:#B392F0\"> head\u003C/span>\u003Cspan style=\"color:#79B8FF\"> -30\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Sortie confirmant le \u003Ccode>&#x3C;title>&#x3C;/title>\u003C/code> vide dans le HTML statique servi. Pas besoin de JavaScript rendering — Astro génère du HTML statique, ce qui veut dire que le bug était visible dès le premier octet servi. Aucune excuse côté rendu client.\u003C/p>\n\u003Ch2>Le fix : 47 minutes pour patcher, 19 jours pour récupérer\u003C/h2>\n\u003Ch3>Patch 1 — Corriger l'accès au frontmatter\u003C/h3>\n\u003Cp>La première option, la plus propre, consistait à adapter le template à la nouvelle API :\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/pages/blog/[...slug].astro — Astro 6.x corrigé\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">---\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { getCollection } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'astro:content'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> BlogLayout \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> '../../layouts/BlogLayout.astro'\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\"> async\u003C/span>\u003Cspan style=\"color:#F97583\"> function\u003C/span>\u003Cspan style=\"color:#B392F0\"> getStaticPaths\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\"> posts\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> getCollection\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'blog'\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\"> posts.\u003C/span>\u003Cspan style=\"color:#B392F0\">map\u003C/span>\u003Cspan style=\"color:#E1E4E8\">((\u003C/span>\u003Cspan style=\"color:#FFAB70\">post\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:#E1E4E8\">    params: { slug: post.id },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    props: { post },\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\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">post\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> Astro.props;\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\">Content\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> post.\u003C/span>\u003Cspan style=\"color:#B392F0\">render\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// Astro 6 : le frontmatter peut être sous data directement\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// OU sous data.frontmatter selon le loader. On normalise.\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> frontmatter\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> post.data.frontmatter \u003C/span>\u003Cspan style=\"color:#F97583\">??\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> post.data;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> title\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> frontmatter.title;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> description\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> frontmatter.description;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">---\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">&#x3C;\u003C/span>\u003Cspan style=\"color:#E1E4E8\">BlogLayout title\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{title} description\u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#E1E4E8\">{description}\u003C/span>\u003Cspan style=\"color:#F97583\">>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  &#x3C;\u003C/span>\u003Cspan style=\"color:#E1E4E8\">Content \u003C/span>\u003Cspan style=\"color:#F97583\">/>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">&#x3C;/\u003C/span>\u003Cspan style=\"color:#E1E4E8\">BlogLayout\u003C/span>\u003Cspan style=\"color:#F97583\">>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le pattern \u003Ccode>post.data.frontmatter ?? post.data\u003C/code> gère les deux cas : la nouvelle structure et l'ancienne, au cas où certaines collections n'auraient pas encore migré vers le nouveau loader.\u003C/p>\n\u003Cp>Note importante : en Astro v6, \u003Ccode>post.slug\u003C/code> est remplacé par \u003Ccode>post.id\u003C/code> pour les chemins statiques. Autre changement silencieux qui peut casser les URLs si non anticipé. Dans le cas de cette équipe, les slugs étaient déjà basés sur les noms de fichiers, donc \u003Ccode>post.id\u003C/code> produisait le même résultat. Un coup de chance.\u003C/p>\n\u003Ch3>Patch 2 — Ajouter un guard dans BaseHead\u003C/h3>\n\u003Cp>Pour éviter que le problème ne se reproduise avec un autre champ, l'équipe a ajouté un fallback défensif :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">// src/components/BaseHead.astro — version durcie\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">---\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">const { title, description } = Astro.props;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">const safeTitle = title || 'Blog — Example.com';\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">const safeDescription = description || 'Découvrez nos articles techniques.';\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">if (!title) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  console.\u003C/span>\u003Cspan style=\"color:#B392F0\">warn\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`[SEO] Missing title for page: ${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">Astro\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">url\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">pathname\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\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\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> charset\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"UTF-8\"\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"viewport\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"width=device-width, initial-scale=1.0\"\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\">title\u003C/span>\u003Cspan style=\"color:#E1E4E8\">>{safeTitle}&#x3C;/\u003C/span>\u003Cspan style=\"color:#85E89D\">title\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\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">={safeDescription} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> property\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:title\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">={safeTitle} />\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C/span>\u003Cspan style=\"color:#85E89D\">meta\u003C/span>\u003Cspan style=\"color:#B392F0\"> property\u003C/span>\u003Cspan style=\"color:#E1E4E8\">=\u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"og:description\"\u003C/span>\u003Cspan style=\"color:#B392F0\"> content\u003C/span>\u003Cspan style=\"color:#E1E4E8\">={safeDescription} />\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le \u003Ccode>console.warn\u003C/code> au build permet de détecter les pages sans titre dans les logs CI. Pas un remplacement pour un vrai test, mais un filet de sécurité supplémentaire.\u003C/p>\n\u003Ch3>Patch 3 — Ajouter un test Playwright sur les metas\u003C/h3>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// tests/seo-meta.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\"> sampleUrls\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  '/blog/optimiser-images-webp'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  '/blog/guide-typescript-2026'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">  '/blog/astro-vs-next-comparatif'\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\">for\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> url\u003C/span>\u003Cspan style=\"color:#F97583\"> of\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> sampleUrls) {\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\">`Page ${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">url\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} has a non-empty title and meta description`\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\">(url);\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\"> title\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\">title\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\">(title).not.\u003C/span>\u003Cspan style=\"color:#B392F0\">toBe\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">''\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\">(title).not.\u003C/span>\u003Cspan style=\"color:#B392F0\">toBe\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'undefined'\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\">(title.\u003C/span>\u003Cspan style=\"color:#79B8FF\">length\u003C/span>\u003Cspan style=\"color:#E1E4E8\">).\u003C/span>\u003Cspan style=\"color:#B392F0\">toBeGreaterThan\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#79B8FF\">10\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\"> description\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> page\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      .\u003C/span>\u003Cspan style=\"color:#B392F0\">locator\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'meta[name=\"description\"]'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      .\u003C/span>\u003Cspan style=\"color:#B392F0\">getAttribute\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'content'\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\">(description).not.\u003C/span>\u003Cspan style=\"color:#B392F0\">toBe\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">''\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\">(description).not.\u003C/span>\u003Cspan style=\"color:#B392F0\">toBe\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'undefined'\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\">(description\u003C/span>\u003Cspan style=\"color:#F97583\">!\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#79B8FF\">length\u003C/span>\u003Cspan style=\"color:#E1E4E8\">).\u003C/span>\u003Cspan style=\"color:#B392F0\">toBeGreaterThan\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#79B8FF\">20\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>Ce test aurait détecté le bug avant le merge. L'équipe l'a intégré dans la CI avec une règle simple : le pipeline bloque si une seule page de l'échantillon a un titre vide.\u003C/p>\n\u003Ch3>Déploiement et invalidation\u003C/h3>\n\u003Cp>Le fix a été déployé 47 minutes après le diagnostic confirmé. Le site étant hébergé sur Cloudflare Pages, le build et le déploiement ont pris 2 minutes 14 secondes. L'équipe a purgé le cache Cloudflare manuellement par précaution, même si les pages statiques d'Astro n'utilisent pas de cache edge agressif par défaut.\u003C/p>\n\u003Cp>Ensuite, soumission en masse des 289 URLs impactées via l'API d'indexation de Search Console. Pas via l'interface — 289 pages une par une, ce n'est pas viable. Un script Python avec la librairie \u003Ccode>google-auth\u003C/code> et l'API Indexing a fait le travail en 12 secondes.\u003C/p>\n\u003Ch3>Timeline de récupération\u003C/h3>\n\u003Cul>\n\u003Cli>\u003Cstrong>J+0\u003C/strong> (fix déployé) : HTML corrigé, titres présents.\u003C/li>\n\u003Cli>\u003Cstrong>J+2\u003C/strong> : Search Console montre que 78 pages ont été re-crawlées.\u003C/li>\n\u003Cli>\u003Cstrong>J+5\u003C/strong> : 210 pages re-crawlées. Les impressions commencent à remonter.\u003C/li>\n\u003Cli>\u003Cstrong>J+9\u003C/strong> : 289/289 pages re-crawlées avec le bon titre.\u003C/li>\n\u003Cli>\u003Cstrong>J+12\u003C/strong> : les clics reviennent à 70 % du niveau pré-incident.\u003C/li>\n\u003Cli>\u003Cstrong>J+19\u003C/strong> : retour à la normale. 39,2K clics sur les 28 derniers jours, contre 40,1K sur la période de référence. La différence de 2 % est dans la marge du core update en cours.\u003C/li>\n\u003C/ul>\n\u003Cp>Le facteur aggravant : le \u003Ca href=\"/blog/google-launches-core-update-amid-i-o-ai-search-overhaul-seo-pulse-via-sejournal-mattgsouthern\">core update de mai 2026\u003C/a> tournait en parallèle. Impossible de savoir avec certitude si la récupération aurait été plus rapide sans. Mais les données Search Console montrent clairement que la chute de clics a commencé trois jours après le déploiement du code cassé — pas au début du core update. La corrélation temporelle est sans ambiguïté.\u003C/p>\n\u003Cp>L'équipe a vérifié manuellement via Screaming Frog après le fix : 312/312 pages avec un \u003Ccode>&#x3C;title>\u003C/code> non vide, longueur moyenne de 52 caractères, aucun doublon. Un crawl propre.\u003C/p>\n\u003Ch2>Ce qu'on en retient\u003C/h2>\n\u003Cp>Le frontmatter était là. Le schéma Zod était valide. Le build passait. Les tests aussi. Et pourtant, 289 pages ont tourné dix-huit jours avec un \u003Ccode>&#x3C;title>\u003C/code> vide — parce que le chemin d'accès à une propriété a changé d'un niveau de profondeur entre deux versions majeures.\u003C/p>\n\u003Cp>Les migrations de framework ne cassent pas toujours ce qu'on surveille. Elles cassent ce qu'on suppose stable. Le \u003Ccode>&#x3C;title>\u003C/code> est l'élément SEO le plus basique qui existe. C'est aussi celui que personne ne teste explicitement, parce que \"ça a toujours marché.\"\u003C/p>\n\u003Cp>Trois règles à graver : tester les balises \u003Ccode>&#x3C;head>\u003C/code> dans la CI, pas seulement le rendu visible. Lire les changelogs des loaders de contenu, pas seulement ceux du framework. Et monitorer le HTML réellement servi après chaque déploiement — un outil comme Seogard détecte une divergence de \u003Ccode>&#x3C;title>\u003C/code> entre deux crawls en quelques minutes, pas en dix-huit jours.\u003C/p>\n\u003Cp>Les régressions SEO silencieuses ne sont jamais spectaculaires. C'est pour ça qu'elles font autant de dégâts. Le prochain champ qui passera de \u003Ccode>data.x\u003C/code> à \u003Ccode>data.nested.x\u003C/code> ne préviendra pas non plus.\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,11,[18,19,20,21,22],"astro","content collections","frontmatter","title","migration","Astro v6 : Content Collections cassent les title en silence","Mon May 25 2026 15:01:47 GMT+0000 (Coordinated Universal Time)",[26,41,53],{"_id":27,"slug":28,"__v":6,"author":7,"canonical":29,"category":10,"createdAt":30,"date":12,"description":31,"image":15,"imageAlt":15,"readingTime":32,"tags":33,"title":39,"updatedAt":40},"6a141e10aa6b273b0c8a4eb7","react-17-vers-react-18-suspense-ssr-fait-crasher-next-head-en-streaming","https://seogard.io/blog/react-17-vers-react-18-suspense-ssr-fait-crasher-next-head-en-streaming","2026-05-25T10:01:52.337Z","Migration React 17→18 : le streaming SSR réordonne les chunks et supprime les meta tags. Récit d'incident, diagnostic complet et patch Next.js.",12,[34,35,36,37,38],"react 18","suspense","ssr","next/head","streaming","React 18 Suspense SSR : next/head cassé par le streaming","Mon May 25 2026 10:01:52 GMT+0000 (Coordinated Universal Time)",{"_id":42,"slug":43,"__v":6,"author":7,"canonical":44,"category":10,"createdAt":45,"date":12,"description":46,"image":15,"imageAlt":15,"readingTime":32,"tags":47,"title":51,"updatedAt":52},"6a148e95aa6b273b0ce72f4a","migration-angular-17-vers-ssr-provideserverrendering-mal-configure-et-hydration-mismatch-invisible","https://seogard.io/blog/migration-angular-17-vers-ssr-provideserverrendering-mal-configure-et-hydration-mismatch-invisible","2026-05-25T18:01:57.093Z","Migration Angular 17 vers SSR : provideServerRendering mal configuré cause un hydration mismatch invisible. Récit, diagnostic Lighthouse, fix précis.",[48,36,49,50],"angular 17","hydration","provideServerRendering","Angular 17 SSR : hydration mismatch invisible, −34 % trafic","Mon May 25 2026 18:01:57 GMT+0000 (Coordinated Universal Time)",{"_id":54,"slug":55,"__v":6,"author":7,"canonical":56,"category":10,"createdAt":57,"date":58,"description":59,"image":15,"imageAlt":15,"readingTime":16,"tags":60,"title":65,"updatedAt":66},"6a129444aa6b273b0c453fac","migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence","https://seogard.io/blog/migration-vue-2-vers-vue-3-47-pages-produit-en-panne-seo-en-silence","2026-05-24T06:01:40.987Z","2026-05-24","Récit d'une migration Vue 2 vers Vue 3 où useHead mal porté a supprimé les meta titles de 47 pages produit. Diagnostic, code du bug, et fix complet.",[61,22,62,63,64],"vue 3","usehead","composition api","seo","Migration Vue 3 : 47 pages produit sans meta titles pendant 21 jours","Sun May 24 2026 06:01:40 GMT+0000 (Coordinated Universal Time)"]