Files
portfolio/.planning/phases/06-blog-pages/06-RESEARCH.md
T

63 KiB
Raw Blame History

Phase 6: Blog Pages - Research

Researched: 2026-04-22 Domain: Nuxt 4 SSR + @nuxt/content v3 listing/article pages, TOC avec active state, prev/next via surroundings, i18n prefix strategy Confidence: HIGH (APIs officielles + docs Nuxt UI/Content v3 + pattern éprouvés)

<user_constraints>

User Constraints (from 06-CONTEXT.md)

Locked Decisions (21 décisions D-01..D-21)

Layout listing /blog

  • D-01: Grille 1/2/3 cols responsive (mobile/tablet/desktop), même pattern visuel que /projects (ProjectCard).
  • D-02: Chaque card = titre (h2) + description tronquée + date formatée i18n + tags (UBadge non-cliquables) + image cover (si frontmatter image) + reading time.
  • D-03: Aucun fallback image cover (cards homogènes sans placeholder branded).
  • D-04: Hero section en haut de /blog = slogan // blog + H1 gradient + subtitle + stats (articles, tags uniques, langues).

Chrome article /blog/[slug]

  • D-05: TOC = sidebar sticky à droite sur desktop (≥lg), drawer mobile sur <lg. Génération depuis page.body.toc. Pas de TOC inline au-dessus du body.
  • D-06: Highlight TOC actif via IntersectionObserver. Implémentation client-only, hydrate proprement après SSR.
  • D-07: Header article complet = titre H1 + date i18n + tags UBadge + cover hero (aspect 21/9 ou 16/9 pleine largeur) + reading time + breadcrumb UBreadcrumb (Accueil → Blog → Titre). Visuel uniquement — JSON-LD BreadcrumbList = Phase 7.
  • D-08: Largeur max body markdown = max-w-3xl (~768px). Wrapper prose dark:prose-invert de Phase 5 conservé.

Nav prev/next en bas d'article

  • D-09: Style = cards riches côte à côte (titre + date + icon flèche + label "Article précédent/suivant"). Fond subtil, hover bg-brand.
  • D-10: Pas d'image cover dans ces cards.
  • D-11: Helper utilisé = surround() de @nuxt/content (en pratique queryCollectionItemSurroundings). Zero logique de tri custom.
  • D-12: Ordre = date frontmatter descendant. Plus récent en haut du listing, "Article précédent" = plus ancien.
  • D-13: Edge cases (pas de voisin) = afficher seul le lien existant. Pas de fallback vers /blog.

Visibilité blog & article de test

  • D-14: content/{fr,en}/blog/test-kotlin-syntax.md = ajouter draft: true. Toutes les queries filtrent draft: false. Article reste accessible par URL directe.
  • D-15: Ajouter un lien "Blog" dans AppHeader.vue entre "Hytale" et "Projects". Ordre final nav : Home / Hytale / Blog / Projects / About / Contact / Fiverr.
  • D-16: Empty state listing = message "Bientôt des articles Hytale" + icône i-lucide-book-open + CTA UButton vers /contact.
  • D-17: URLs finales : /fr/blog, /en/blog, /fr/blog/[slug], /en/blog/[slug]. /blog sans préfixe → 302 via detectBrowserLanguage.

Additions techniques

  • D-18: Étendre schema Zod dans content.config.ts : ajouter draft: z.boolean().optional().default(false).
  • D-19: Créer un composable useReadingTime(content): number (200 mots/min) OU équivalent hook content:file:afterParse.
  • D-20: Composant unique BlogCard.vue avec variant prop (default / compact) réutilisé par listing ET prev/next.
  • D-21: Ajouter clés i18n blog.*, nav.blog, a11y.blogTocToggle/blogPrev/blogNext dans fr.json/en.json.

Claude's Discretion

  • Nom exact du composable reading time (useReadingTime recommandé).
  • Structure interne du composant TOC (sticky container, drawer composition).
  • Format exact de la date i18n (Intl.DateTimeFormat recommandé).
  • Classes Tailwind exactes du hero cover image (aspect-[21/9] retenu dans UI-SPEC).
  • Emplacement exact du breadcrumb (UI-SPEC impose : AU-DESSUS du H1).

Deferred Ideas (OUT OF SCOPE Phase 6)

  • Filtrage par tag cliquable — backlog post-M1.1.
  • Recherche full-text blog — backlog.
  • Pagination / infinite scroll — backlog (<20 articles).
  • JSON-LD Article + BreadcrumbList — Phase 7.
  • useSeoMeta enrichi par article (og:image, canonical, dateModified) — Phase 7.
  • Sitemap étendu avec URLs blog — Phase 7.
  • OG image generator dynamique — backlog SEO-06.
  • Articles Hytale réels (2+ seed) — Phase 8.
  • Section "Articles récents" sur /hytale — Phase 8.
  • Alias /articles, tags pages, RSS — scope creep / backlog. </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
BLOG-02 Page listing /blog — liste articles SSR bilingue (titre, description, date, tags) §API queryCollection + §BlogCard pattern + §Hero listing
BLOG-03 Page article /blog/[slug] — rendu SSR + TOC + prev/next §queryCollectionItemSurroundings + §page.body.toc + §IntersectionObserver + §BlogToc
BLOG-06 Articles bilingues FR/EN via i18n content §Littéraux queryCollection + §strategy prefix + §Gotchas Phase 5
</phase_requirements>

Summary

Phase 6 est une phase de composition de composants : aucune nouvelle dépendance, aucun nouveau module. Tout le stack est déjà installé (Phase 5) : @nuxt/content@^3.13, @nuxt/ui@^3.3, @nuxt/image@^2, @nuxtjs/i18n@^10.2, @tailwindcss/typography@^0.5, zod@^4.3. Le travail consiste à (1) étendre le schema blog_fr/blog_en avec draft, (2) créer un listing /blog/index.vue, (3) enrichir /blog/[slug].vue avec TOC + header + breadcrumb + prev/next, (4) créer 3 composants (BlogCard, BlogToc, BlogPrevNext) + 1 composable (useReadingTime), (5) câbler i18n et nav.

Trois pièges connus dominent la planification :

  1. Le Vite extractor de @nuxt/content refuse queryCollection(variable) → littéraux uniquement, donc branches if/else isFr à chaque query (listing, surround, [slug]).
  2. La catch-all route [...slug].vue casse avec i18n strategy prefix → rester sur [slug].vue single-segment (déjà conforme Phase 5).
  3. Le body de page en v3 est type: 'minimal' (tuples [tag, attrs, ...children]), PAS l'AST unist v2 → traversal récursif custom ou utiliser un hook content:file:afterParse (recommandé).

Primary recommendation: Utiliser queryCollectionItemSurroundings('blog_fr', path, { fields: [...] }) pour prev/next (API officielle v3, retourne [prev | null, next | null]). Calculer le reading time dans un hook Nitro content:file:afterParse avec injection de minutes + wordCount dans le content object, et étendre le schema Zod avec ces champs pour les rendre queryables. Implémenter le TOC highlight avec un IntersectionObserver manuel (pas de @vueuse/core installé) dans onMounted + cleanup onBeforeUnmount.

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Listing articles SSR Frontend Server (SSR) useAsyncData + queryCollection résolvent côté serveur au render, HTML complet envoyé au browser (SEO)
Rendu markdown article Frontend Server (SSR) Browser (hydration) ContentRenderer SSR puis hydrate MDC components côté client
TOC sticky highlight Browser (client-only) IntersectionObserver ne peut fonctionner qu'après mount; initial state = premier heading côté SSR
TOC drawer mobile Browser UDrawer Nuxt UI = composant client-interactif
Prev/next articles Frontend Server (SSR) queryCollectionItemSurroundings dans useAsyncData, rendu SSR
Reading time computation Build time (Nitro hook) Calculé dans content:file:afterParse à l'ingestion, stocké dans la DB SQLite, requêté côté SSR
Filtrage draft:false Frontend Server (SSR) .where('draft', '=', false) au niveau query, jamais côté client
i18n routing (/fr/blog vs /en/blog) Frontend Server (SSR) useLocalePath résout côté serveur, strategy prefix impose la langue dans l'URL
Nav link Blog dans AppHeader Frontend Server (SSR) Rendu dans le layout SSR, hydraté côté client

Standard Stack

Core (tout déjà installé — NE RIEN AJOUTER)

Library Version installée Purpose Why Standard
@nuxt/content ^3.13.0 [VERIFIED: package.json] queryCollection + ContentRenderer + Shiki intégré Officiel Nuxt, v3 remplace v2 findSurround par queryCollectionItemSurroundings [CITED: content.nuxt.com/docs/utils/query-collection-item-surroundings]
@nuxt/ui ^3.3.2 [VERIFIED: package.json] UBreadcrumb, UDrawer, UBadge, UButton, UIcon Officiel Nuxt, déjà consommé partout dans le projet
@nuxt/image ^2.0.0 [VERIFIED: package.json] NuxtImg cover card + article hero Officiel, déjà utilisé (AppHeader, ProjectCard)
@nuxtjs/i18n ^10.2.4 [VERIFIED: package.json] useI18n, useLocalePath, switchLocalePath Officiel Nuxt SEO stack, strategy prefix déjà configurée
@tailwindcss/typography ^0.5.19 [VERIFIED: package.json] prose + dark:prose-invert (hérité Phase 5) Ecosystem Tailwind officiel
zod ^4.3.6 [VERIFIED: package.json] Schema frontmatter collections Requis par @nuxt/content v3

Supporting (également installé)

Library Version Purpose When to Use
@iconify-json/lucide ^1.2.102 [VERIFIED: package.json] Icônes i-lucide-* (list, book-open, mail, arrow-left/right, clock, calendar) Toutes les UIcon de cette phase

Alternatives Considered / NON RETENUES

Instead of Could Use Tradeoff
Custom BlogToc.vue UContentToc officiel Nuxt UI v3 UContentToc gère highlight auto + links shape compatible avec page.body.toc.links [CITED: ui.nuxt.com/docs/components/content-toc]. MAIS : design imposé, customization limitée au ui slot prop, et UI-SPEC §BlogToc contract impose une mise en page custom (aside sticky desktop + UDrawer mobile unifié). Décision : rester sur custom pour contrôle total de la composition sticky/drawer. À mentionner au planner comme fallback rapide si budget serré.
Custom BlogPrevNext.vue UContentSurround officiel Nuxt UI v3 UContentSurround prend directement :surround="[prev,next]" et rend un grid 2-cols avec prevIcon/nextIcon [CITED: ui.nuxt.com/docs/components/content-surround]. MAIS : D-20 exige composant unique BlogCard.vue réutilisé via variant compact. Décision : custom (aligné D-20). ContentSurround mentionné pour info.
@vueuse/core useIntersectionObserver IntersectionObserver natif manuel @vueuse/core PAS INSTALLÉ [VERIFIED: package.json ne contient aucune entry @vueuse]. Installation = nouvelle dépendance refusée par contrainte "Zéro dépendance payante + stack minimale". Pattern natif dans onMounted + cleanup onBeforeUnmount suffit largement — code ~25 lignes.
Composable useReadingTime(text) client-side Nitro hook content:file:afterParse Hook calcule à l'ingestion et stocke minutes + wordCount sur le document → queryable côté SSR, zero compute runtime. Pattern officiel [CITED: content.nuxt.com/docs/advanced/hooks]. Retenu.

Installation requise : AUCUNE. Phase 6 est 100% composition sur stack existant.

Version verification (pinned) :

  • @nuxt/content@^3.13.0 — confirmé current stable (2025 era) [VERIFIED: package.json]
  • @nuxt/ui@^3.3.2 — confirmé current stable [VERIFIED: package.json]
  • @nuxtjs/i18n@^10.2.4 — confirmé current stable [VERIFIED: package.json]

Architecture Patterns

System Architecture Diagram

                                 ┌──────────────────────────────────────┐
                                 │ Browser request /fr/blog             │
                                 └──────────────────┬───────────────────┘
                                                    ▼
                 ┌──────────────────────────────────────────────────────────┐
                 │ Nuxt 4 SSR → @nuxtjs/i18n strategy prefix résout locale │
                 │   locale.value === 'fr' → path /fr/blog                 │
                 └──────────────────┬───────────────────────────────────────┘
                                    ▼
          ┌─────────────────────────────────────────────────────────────────┐
          │ app/pages/blog/index.vue                                        │
          │   useAsyncData('blog-list-fr', () =>                            │
          │     queryCollection('blog_fr')   ← littéral !                   │
          │       .where('draft','=', false)                                │
          │       .order('date','DESC')                                     │
          │       .all()                                                    │
          │   )                                                             │
          └─────────────────┬───────────────────────────────────────────────┘
                            ▼
          ┌──────────────────────────────────────────────────────────────────┐
          │ @nuxt/content SQLite (Nitro runtime)                             │
          │   Returns [article, article, ...] avec frontmatter + path +      │
          │   minutes (hook afterParse) + draft                              │
          └─────────────────┬────────────────────────────────────────────────┘
                            ▼
          ┌──────────────────────────────────────────────────────────────────┐
          │ Render SSR:                                                       │
          │   Hero section (stats = articles.length, tags uniques, 2)        │
          │   Grid (BlogCard variant="default" ×N)                           │
          │   OR empty state (UButton CTA /contact)                          │
          └─────────────────┬────────────────────────────────────────────────┘
                            ▼
                 ┌──────────────────────────────────────┐
                 │ HTML SSR complet → Browser           │
                 │ Hydrate NuxtLink + UButton interactifs│
                 └──────────────────────────────────────┘

─────────────── Article path /fr/blog/[slug] ────────────────────────

                 ┌──────────────────────────────────────┐
                 │ Browser /fr/blog/ma-premier-article  │
                 └──────────────────┬───────────────────┘
                                    ▼
          ┌──────────────────────────────────────────────────────────────────┐
          │ app/pages/blog/[slug].vue                                        │
          │   1. useAsyncData('blog-fr-slug', () =>                          │
          │        queryCollection('blog_fr').path(path).first())            │
          │   2. useAsyncData('blog-fr-slug-surround', () =>                 │
          │        queryCollectionItemSurroundings('blog_fr', path, {        │
          │          fields: ['title','description','date','image'] })      │
          │          .where('draft','=', false)                              │
          │          .order('date','DESC'))                                  │
          │                                                                  │
          │   Renders sequentially:                                          │
          │     UBreadcrumb [Accueil → Blog → titre]                         │
          │     H1 + meta row (date i18n + · + minutes + UBtn TOC mobile)    │
          │     Tags UBadge row                                              │
          │     NuxtImg cover (aspect-21/9) si image                         │
          │     grid grid-cols-[1fr_16rem] desktop :                         │
          │       ├─ <article class="prose"> ContentRenderer                 │
          │       └─ <aside sticky> BlogToc (desktop)                        │
          │     BlogPrevNext (surround[0], surround[1])                      │
          └─────────────────┬────────────────────────────────────────────────┘
                            ▼
          ┌──────────────────────────────────────────────────────────────────┐
          │ Client hydrate                                                    │
          │   onMounted → IntersectionObserver sur h2[id], h3[id]            │
          │   Update activeId ref → BlogToc classe text-brand-500 conditionnel│
          │   UDrawer mobile ouvre au clic UButton trigger                   │
          └──────────────────────────────────────────────────────────────────┘

─────────────── Build-time Nitro hook ──────────────────────────────

┌─────────────────────────────────────────────────────────────┐
│ server/plugins/reading-time.ts (NEW)                        │
│   nitroApp.hooks.hook('content:file:afterParse', (ctx) => { │
│     // ctx.content = parsed doc                              │
│     const wordCount = countWordsMinimal(ctx.content.body)   │
│     ctx.content.wordCount = wordCount                       │
│     ctx.content.minutes = Math.ceil(wordCount / 200)        │
│   })                                                         │
│                                                              │
│ content.config.ts : ajouter wordCount + minutes au schema   │
│ pour exposer via queryCollection                            │
└─────────────────────────────────────────────────────────────┘
app/
├── pages/
│   └── blog/
│       ├── index.vue           # NEW — listing SSR
│       └── [slug].vue          # ENRICH — phase 5 minimal → header + TOC + prev/next
├── components/
│   ├── BlogCard.vue            # NEW — variants default + compact
│   ├── BlogToc.vue             # NEW — sticky aside + UDrawer mobile + IntersectionObserver
│   ├── BlogPrevNext.vue        # NEW — 2× BlogCard variant=compact + icons
│   └── layout/
│       └── AppHeader.vue       # MODIFY — ajouter { key:'blog', path:'/blog' } dans navLinks
├── composables/
│   └── useReadingTime.ts       # NEW — helper client si besoin; source of truth = hook Nitro
├── utils/                      # CREATE folder
│   └── countWords.ts           # NEW — traversal minimal body pour fallback client
server/
└── plugins/
    └── reading-time.ts         # NEW — Nitro hook content:file:afterParse
content.config.ts               # MODIFY — ajouter draft + wordCount + minutes au schema
i18n/locales/
├── fr.json                     # MODIFY — ajouter blog.* + nav.blog + a11y.*
└── en.json                     # MODIFY — idem

Pattern 1: queryCollection littéral avec branches if/else

What: Le Vite extractor de @nuxt/content v3 scan le code source à build pour extraire les queries et pré-compiler les collections. Il ne peut pas analyser des variables dynamiques. Conséquence : queryCollection(locale.value === 'fr' ? 'blog_fr' : 'blog_en') retourne un objet vide. [VERIFIED: 05-STATE.md Gotchas + 05-02-SUMMARY]

When to use: Toutes les queries @nuxt/content de Phase 6.

Example:

// ✅ CORRECT — littéral séparé par branche
const { locale } = useI18n()
const isFr = computed(() => locale.value === 'fr')
const route = useRoute()

const { data: articles } = await useAsyncData(
  `blog-list-${locale.value}`,
  () => isFr.value
    ? queryCollection('blog_fr').where('draft', '=', false).order('date', 'DESC').all()
    : queryCollection('blog_en').where('draft', '=', false).order('date', 'DESC').all(),
  { watch: [locale] }  // re-fetch au switch de langue
)

// ❌ INCORRECT — extractor casse
const col = isFr.value ? 'blog_fr' : 'blog_en'
const { data } = await useAsyncData(() => queryCollection(col).all())  // ← returns empty

Pattern 2: queryCollectionItemSurroundings (prev/next)

What: API v3 officielle qui remplace findSurround() de v2 [CITED: masteringnuxt.com/blog/upgrading-from-nuxt-content-v2-to-v3]. Signature [CITED: content.nuxt.com/docs/utils/query-collection-item-surroundings]:

function queryCollectionItemSurroundings<T extends keyof PageCollections>(
  collection: T,
  path: string,
  opts?: { before?: number, after?: number, fields?: Array<keyof Item> }
): ChainablePromise<T, ContentNavigationItem[]>

Retour : Array [previousItem, nextItem] (longueur 2 par défaut). Si currentItem est le premier article : [null, nextItem]. Si dernier : [previousItem, null]. Si un seul article dans la collection : [null, null]. [CITED: content.nuxt.com/docs/utils/query-collection-item-surroundings]

Chaînable avec : .where(), .andWhere(), .orWhere(), .order(). IMPORTANT : les filtres où/order s'appliquent à la collection AVANT calcul des voisins — donc filtrer draft = false ici exclut correctement les brouillons des surroundings.

Example canonique :

const { data: surround } = await useAsyncData(
  `blog-surround-${locale.value}-${slug}`,
  () => isFr.value
    ? queryCollectionItemSurroundings('blog_fr', path, {
        fields: ['title', 'description', 'date', 'image', 'path']
      })
        .where('draft', '=', false)
        .order('date', 'DESC')
    : queryCollectionItemSurroundings('blog_en', path, {
        fields: ['title', 'description', 'date', 'image', 'path']
      })
        .where('draft', '=', false)
        .order('date', 'DESC')
)

const prev = computed(() => surround.value?.[0] ?? null)
const next = computed(() => surround.value?.[1] ?? null)

Avec D-12 order DESC : articles plus récents en haut de listing. Dans un DESC feed, le "previous article" au sens temporel (plus ancien) est logiquement à droite (suivant dans la liste). Le nommage UI est un choix éditorial : UI-SPEC i18n blog.prevArticle = "Article précédent" pointe par convention vers l'article plus ancien. Vérifier avec le planner/UX que surround[0] correspond bien à "plus ancien" dans l'ordre DESC. Semantique queryCollectionItemSurroundings par défaut : [0] = élément AVANT dans la liste ordonnée, [1] = élément APRÈS. En DESC, [0] = plus récent, [1] = plus ancien. → inverser l'affectation UI : UI "précédent" (ancien) = surround[1], UI "suivant" (nouveau) = surround[0]. [ASSUMED — À VALIDER EN IMPLÉMENTATION]

Pattern 3: page.body.toc structure (flat + nested)

What: @nuxt/content v3 expose automatiquement page.body.toc.links comme array d'objets typés. [CITED: github.com/nuxt/content discussions + damieng.com]

Structure exacte :

interface TocLink {
  id: string         // anchor id auto-généré depuis le texte du heading (kebab-case)
  depth: number      // 2 pour h2, 3 pour h3, etc.
  text: string       // texte du heading sans markdown
  children?: TocLink[]  // optionnel, uniquement si heading plus profond suit
}

interface Toc {
  title: string
  searchDepth: number
  depth: number          // max depth inclus
  links: TocLink[]
}

Exemple réel :

{
  "depth": 5,
  "searchDepth": 5,
  "links": [
    { "id": "my-first-blog-post", "depth": 2, "text": "My first blog post" },
    {
      "id": "tailwindcss", "depth": 2, "text": "TailwindCSS",
      "children": [
        { "id": "tailwindcss-typography", "depth": 3, "text": "TailwindCSS Typography" }
      ]
    }
  ]
}

When to use: Rendu sidebar TOC + drawer mobile (BlogToc.vue).

Example :

<script setup lang="ts">
const props = defineProps<{ links: TocLink[], activeId: string | null }>()
</script>

<template>
  <ol class="space-y-2 text-sm">
    <li v-for="link in links" :key="link.id">
      <a
        :href="`#${link.id}`"
        :class="[
          activeId === link.id
            ? 'text-brand-500 dark:text-brand-400 font-medium'
            : 'text-gray-500 hover:text-gray-900 dark:hover:text-white'
        ]"
      >{{ link.text }}</a>
      <ol v-if="link.children" class="mt-1 ml-4 space-y-1">
        <li v-for="child in link.children" :key="child.id">
          <a :href="`#${child.id}`" :class="activeId === child.id ? 'text-brand-500' : 'text-gray-500'">
            {{ child.text }}
          </a>
        </li>
      </ol>
    </li>
  </ol>
</template>

Pattern 4: IntersectionObserver client-only pour TOC highlight

What: Observer les headings h2/h3 dans l'article, surligner le premier heading actuellement visible. SSR-safe car l'init se fait en onMounted. [CITED: mokkapps.de/blog/create-a-table-of-contents-with-active-states-in-nuxt-3]

rootMargin recommandé : '-20% 0px -70% 0px' — le heading devient actif quand il entre dans les 20%30% supérieurs du viewport (sweet spot lecture). UI-SPEC impose cette valeur.

Example complet (à mettre dans BlogToc.vue) :

<script setup lang="ts">
const props = defineProps<{ links: TocLink[] }>()

const activeId = ref<string | null>(null)
let observer: IntersectionObserver | null = null

onMounted(() => {
  if (typeof window === 'undefined') return

  // Flatten TOC pour inclure les children
  const allIds: string[] = []
  const collect = (links: TocLink[]) => {
    for (const link of links) {
      allIds.push(link.id)
      if (link.children) collect(link.children)
    }
  }
  collect(props.links)

  // Initialiser activeId au premier heading pour SSR-hydration cohérence
  activeId.value = allIds[0] ?? null

  observer = new IntersectionObserver(
    (entries) => {
      // Prendre le premier heading visible dans le "zone active"
      const visible = entries
        .filter((e) => e.isIntersecting)
        .sort((a, b) => a.target.getBoundingClientRect().top - b.target.getBoundingClientRect().top)
      if (visible.length > 0) {
        activeId.value = visible[0].target.id
      }
    },
    { rootMargin: '-20% 0px -70% 0px', threshold: 0 }
  )

  for (const id of allIds) {
    const el = document.getElementById(id)
    if (el) observer.observe(el)
  }
})

onBeforeUnmount(() => {
  observer?.disconnect()
  observer = null
})
</script>

Note hydration : Si on initialise activeId.value = null côté SSR, puis qu'on le met à allIds[0] côté client dans onMounted, il n'y a PAS de mismatch (l'HTML initial avec null est juste pas de highlight, puis au mount on update — update reactive après hydration = comportement normal). Alternative : initial state = allIds[0] déjà côté SSR, mais document.getElementById n'existe pas côté serveur. Solution simple : laisser activeId = ref(null) init, onMounted set le premier.

Pattern 5: Reading time via Nitro hook (recommandé) + fallback composable

What: Calcul WORD COUNT au parse de chaque fichier markdown, injecté comme propriété du content object, persisté dans la DB SQLite, queryable via queryCollection. [CITED: content.nuxt.com/docs/advanced/hooks]

Pourquoi hook > composable client : (1) zero compute runtime par requête, (2) cohérent listing ↔ article (même valeur affichée partout), (3) queryable (.where('minutes', '>', 5) possible).

Structure body v3 type: 'minimal' : [tagName, attributes, ...children] où children peut être string (noeud texte) ou un autre tuple [tag, attrs, ...]. [CITED: github.com/nuxt/content/issues/3072]

server/plugins/reading-time.ts :

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('content:file:afterParse', (ctx) => {
    const { file, content } = ctx
    if (!file.id?.endsWith('.md')) return

    const wordCount = countWordsInMinimalBody(content.body)
    content.wordCount = wordCount
    content.minutes = Math.max(1, Math.ceil(wordCount / 200))  // 200 wpm per D-19
  })
})

function countWordsInMinimalBody(body: unknown): number {
  let count = 0
  // body = { type: 'minimal', value: MinimalNode[] }
  // MinimalNode = string | [tag, attrs, ...children]
  const visit = (node: unknown) => {
    if (typeof node === 'string') {
      const trimmed = node.trim()
      if (trimmed) count += trimmed.split(/\s+/).length
      return
    }
    if (Array.isArray(node)) {
      const tag = node[0]
      // Ignorer les blocs de code (le texte des snippets n'est pas "lisible")
      if (tag === 'code' || tag === 'pre') return
      // children = node[2..]
      for (let i = 2; i < node.length; i++) visit(node[i])
    }
  }
  const body_ = body as { type: string, value: unknown[] } | undefined
  if (body_?.value && Array.isArray(body_.value)) {
    for (const n of body_.value) visit(n)
  }
  return count
}

content.config.ts updated :

const blogSchema = z.object({
  title: z.string(),
  description: z.string(),
  date: z.string(),
  tags: z.array(z.string()).optional(),
  image: z.string().optional(),
  draft: z.boolean().optional().default(false),  // D-18
  // Ajoutés par hook — exposés via schema pour queryability
  wordCount: z.number().optional(),
  minutes: z.number().optional(),
})

IMPORTANT : Les propriétés injectées par hook DOIVENT être déclarées dans le schema Zod pour être visibles via queryCollection [CITED: content.nuxt.com/docs/advanced/hooks]. Sinon elles existent en DB mais sont strippées au return.

Composable fallback app/composables/useReadingTime.ts :

export function useReadingTime(wordCountOrText: number | string): number {
  if (typeof wordCountOrText === 'number') {
    return Math.max(1, Math.ceil(wordCountOrText / 200))
  }
  const count = wordCountOrText.trim().split(/\s+/).filter(Boolean).length
  return Math.max(1, Math.ceil(count / 200))
}

→ Usage template : {{ t('blog.readingTime', { minutes: article.minutes ?? useReadingTime(article.description) }) }} (fallback description si hook pas exécuté, ex: dev mode).

Pattern 6: BlogCard unifié avec variant prop (D-20)

Pattern : Un seul composant, deux rendus visuels. Approche déclarative via prop :

<script setup lang="ts">
interface BlogCardProps {
  article: {
    path: string
    title: string
    description?: string
    date: string
    tags?: string[]
    image?: string
    minutes?: number
  }
  variant?: 'default' | 'compact'
  direction?: 'prev' | 'next'  // uniquement si variant=compact
}
const props = withDefaults(defineProps<BlogCardProps>(), { variant: 'default' })
const { t, locale } = useI18n()
const localePath = useLocalePath()

const formattedDate = computed(() => {
  try {
    return new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-FR' : 'en-US', {
      year: 'numeric', month: 'long', day: 'numeric'
    }).format(new Date(props.article.date))
  } catch {
    return props.article.date
  }
})
</script>

Templates branchés via <template v-if="variant === 'default'"> et <template v-else> selon UI-SPEC §BlogCard variant contract.

Anti-Patterns to Avoid

  • queryCollection(variableName) → retourne {} silencieusement. Utiliser toujours littéraux avec if/else.
  • [...slug].vue catch-all route + i18n prefix strategy → page resolve {} + Vue warn. Toujours single-segment [slug].vue.
  • Retirer i18n.baseUrl dans nuxt.config.ts → casse useLocaleHead (donc les meta tags SEO Phase 7). NE PAS TOUCHER.
  • Ajouter routeRules: { '/blog/**': { redirect: '/fr/blog' } } → casse detectBrowserLanguage ET bloque la résolution des slugs. La redirection langue-less se fait EXCLUSIVEMENT via detectBrowserLanguage.redirectOn: 'no prefix' (déjà configuré).
  • Utiliser useState pour activeId TOC → state partagé global, casse au changement de route. Utiliser ref local dans BlogToc.vue.
  • Appeler document.querySelector dans setup() top-level → crash SSR. Toujours dans onMounted.
  • Filtrer draft === false côté client après .all() → gaspille du bandwidth + expose les brouillons dans le payload initial. Filter côté query avec .where('draft', '=', false).
  • Appeler useIntersectionObserver de @vueuse/core → pas installé. Soit natif, soit installer @vueuse/core comme nouvelle dépendance (refusée).
  • Ne pas unobserve dans onBeforeUnmount → memory leak au navigate entre articles. Toujours observer.disconnect().

Don't Hand-Roll

Problem Don't Build Use Instead Why
Prev/next articles calculation Tri manuel par date + index find queryCollectionItemSurroundings('blog_fr', path) Gère edge cases (null), chaînable avec where/order, type-safe [CITED: content.nuxt.com]
Date formatting i18n const m = ['janvier','février',...] manuel new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(new Date(date)) Native, 100% des locales du monde, zero bug
Word count pour reading time Regex /\w+/g sur rawbody string Hook content:file:afterParse + traversal minimal AST ignorant <code>/<pre> Consistance + queryability + cohérence listing/article
Breadcrumb UI Flexbox custom avec séparateurs chevron <UBreadcrumb :items="[...]" /> Nuxt UI v3 officiel, items supporte { label, to, icon } + NuxtLink auto [CITED: ui.nuxt.com/components/breadcrumb]
Drawer mobile TOC Custom overlay + transition + focus trap <UDrawer v-model:open="open" direction="right"> Nuxt UI v3 gère escape, click-outside, transitions, a11y [CITED: ui.nuxt.com/components/drawer]
TOC extraction depuis markdown Regex sur # lines page.body.toc.links (auto-généré par @nuxt/content) Flat + nested depth handled natively
Cover image optimization <img src="/cover.jpg"> raw <NuxtImg :src format="webp" :width :height loading="lazy"> @nuxt/image déjà installé, WebP auto + lazy + responsive sizes
Locale routing String concat /\${locale.value}/blog useLocalePath() Respecte strategy + fallback + defaultLocale
Active route detection route.path === '/fr/blog' string aria-current="page" + isActive() pattern AppHeader existant Pattern du projet déjà établi (AppHeader.vue ligne 25-27)
Syntax highlighting blocs code Custom highlighter Shiki via @nuxt/content (déjà Phase 5, single theme github-dark) Aucun travail requis — hérité

Key insight : Phase 6 est presque 100% composition. Tout ce qui semble "custom" (TOC, prev/next cards, breadcrumb, cover) a une solution officielle ou un helper auto-importé. Le seul vrai code custom justifié = BlogCard.vue (D-20 variant) et BlogToc.vue (layout sticky+drawer unifié custom hors du design Nuxt UI par D-05).

Runtime State Inventory

Phase 6 ≠ rename/refactor/migration. Section non applicable. Les seuls side-effects runtime sont :

  • SQLite @nuxt/content : schema étendu (ajout colonnes draft, wordCount, minutes) → auto-rebuild de la DB par @nuxt/content à la prochaine nuxt dev / nuxt build. Aucune migration manuelle.
  • Build artifacts : node_modules/.cache/content/ contient la DB SQLite buildée — supprimer en cas d'incohérence post-schema-change (rm -rf node_modules/.cache).

Common Pitfalls

Pitfall 1: queryCollection variable → collection vide silencieusement

What goes wrong: Aucune erreur au runtime, les articles n'apparaissent simplement pas (array vide). Impossible à diagnostiquer si on ne connaît pas le piège. Why it happens: Le plugin Vite de @nuxt/content parse le code source statiquement pour extraire les noms de collections et les pré-compiler dans la DB. Il ne fait pas de type-flow analysis runtime. How to avoid: TOUJOURS brancher en if/else isFr avec deux littéraux string. Jamais de ternaire ou variable. Warning signs: Dev tools Network tab montre un fetch content réussi mais vide. Le dev mode log peut afficher un warn Could not resolve collection variable.

Pitfall 2: Hydration mismatch si activeId initialisé différemment SSR vs client

What goes wrong: Vue warn Hydration text mismatch ou Hydration class mismatch sur le premier heading de la TOC au chargement article. Why it happens: Si on met activeId.value = 'first-heading-id' en synchrone (même dans ref()), ça fait partie du render SSR, mais côté client il n'y a pas encore de heading dans le DOM à observer, donc re-render = mismatch. How to avoid: activeId = ref<string | null>(null) initial. onMounted set la valeur après IntersectionObserver setup. Le premier heading devient visuellement actif après ~1 frame. Warning signs: Console warnings au premier mount d'un article, TOC highlight flash.

Pitfall 3: Breadcrumb avec label dynamique côté serveur vs hydration

What goes wrong: UBreadcrumb items avec { label: page.title } fonctionne SSR, mais au changement de langue sans reload → l'item reste stale si pas watché. Why it happens: useAsyncData ne re-fetch pas automatiquement au changement de locale si watch non déclaré. How to avoid: Ajouter { watch: [locale] } dans les useAsyncData pour listing ET article ET surround. Invalider la key avec locale : useAsyncData(\blog-${locale.value}-${slug}`, …)`. Warning signs: Switch FR/EN recharge partiellement la page mais titre/breadcrumb anciens.

Pitfall 4: surround[0] vs surround[1] sémantique en order DESC

What goes wrong: Labels "Article précédent" et "Article suivant" pointent vers les mauvais articles. UX cassée. Why it happens: queryCollectionItemSurroundings retourne [before, after] dans l'ORDRE DE LA COLLECTION. En .order('date', 'DESC'), "before" = article plus récent (vient avant dans la liste descendante). D-12 impose "Article précédent = plus ancien" (sémantique blog classique). How to avoid: Mapper prev (ancien) = surround[1], next (récent) = surround[0]. OU renverser en .order('date', 'ASC') et mapper direct. À tester en implémentation sur l'article du milieu avec 3 articles seed. Warning signs: Les fleches vont "à l'envers" au navigate entre articles.

Pitfall 5: Schema properties non déclarées dans Zod → strippées

What goes wrong: Hook content:file:afterParse pose content.minutes = 5, mais queryCollection(...).first().minutes === undefined. Why it happens: @nuxt/content v3 valide le return de queryCollection contre le Zod schema. Les propriétés extra sont drop silencieusement. How to avoid: Déclarer wordCount: z.number().optional() + minutes: z.number().optional() dans blogSchema. optional() permet de ne pas casser sur les fichiers existants sans hook (ex: en dev hot-reload avant hook rerun). Warning signs: Reading time affiche undefined min de lecture.

What goes wrong: <a> imbriqués ou conflicts de focus quand la card contient d'autres <a> (ex: tags cliquables futur). Why it happens: Pattern ProjectCard.vue utilise <NuxtLink class="absolute inset-0"> par dessus tout. OK tant que rien d'autre n'est cliquable. Tags UBadge non-cliquables (D-02) → safe. MAIS si quelqu'un rend UBadge :to="..." plus tard, conflit. How to avoid: Garder tags en <span> (pas UBadge to prop). Commenter dans BlogCard.vue : "Tags non-cliquables (D-02 Phase 6) — si cliquables ajoutés plus tard, retirer absolute inset-0 NuxtLink et revoir le focus order". Warning signs: Screen readers lisent le titre deux fois, tab focus saute le lien principal.

Pitfall 7: Empty state affichage quand TOUS les articles sont draft

What goes wrong: /blog rend "Bientôt des articles Hytale" alors qu'il y a 1 article test (test-kotlin-syntax.md) mais avec draft: true. Comportement correct mais surprenant en dev. Why it happens: D-14 impose draft: true sur le test article → après filter draft = false, tableau vide → empty state. How to avoid: Rien à fixer — comportement voulu. Pour valider le listing en dev, créer un article seed sans draft (pas dans le scope Phase 6, mais noter pour Phase 8 ou dans un article "welcome" minimal). Warning signs: Dev test visuel /fr/blog sans articles réels → confusion possible.

Code Examples

Patterns complets vérifiés pour copie directe par le planner.

Page listing /blog/index.vue (skeleton)

<script setup lang="ts">
const { t, locale } = useI18n()
const localePath = useLocalePath()
const isFr = computed(() => locale.value === 'fr')

// useAsyncData avec key incluant locale pour refetch au switch
const { data: articles } = await useAsyncData(
  `blog-list-${locale.value}`,
  () => isFr.value
    ? queryCollection('blog_fr').where('draft', '=', false).order('date', 'DESC').all()
    : queryCollection('blog_en').where('draft', '=', false).order('date', 'DESC').all(),
  { watch: [locale] }
)

// Stats computed
const totalArticles = computed(() => articles.value?.length ?? 0)
const uniqueTags = computed(() => {
  const set = new Set<string>()
  for (const a of articles.value ?? []) {
    for (const t of a.tags ?? []) set.add(t)
  }
  return set.size
})
const totalLanguages = 2  // FR + EN fixe

useSeoMeta({
  title: () => t('seo.blog.title', t('blog.title')),  // fallback blog.title si seo key manquante (Phase 7 enrichira)
  description: () => t('blog.subtitle'),
})
</script>

<template>
  <div>
    <!-- Hero (pattern projects.vue L56-83) -->
    <section class="relative pt-20 pb-16 px-4 sm:px-6 lg:px-8 overflow-hidden">
      <!-- background gradient identical /projects -->
      <div class="relative z-10 max-w-7xl mx-auto text-center">
        <span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// blog</span>
        <h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold mt-3 mb-5 bg-gradient-to-r …">{{ t('blog.title') }}</h1>
        <p class="text-lg sm:text-xl text-gray-500 dark:text-gray-400 max-w-2xl mx-auto leading-relaxed">
          {{ t('blog.subtitle') }}
        </p>
        <!-- Stats 3× (articles / tags / languages) -->
      </div>
    </section>

    <!-- Grid or Empty state -->
    <section class="py-16 md:py-20 px-4 sm:px-6 lg:px-8">
      <div class="max-w-7xl mx-auto">
        <div v-if="articles && articles.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 lg:gap-6">
          <BlogCard v-for="a in articles" :key="a.path" :article="a" variant="default" />
        </div>
        <div v-else class="text-center py-32">
          <!-- UI-SPEC empty state contract -->
          <UIcon name="i-lucide-book-open" class="..." />
          <h3>{{ t('blog.emptyState.title') }}</h3>
          <p>{{ t('blog.emptyState.description') }}</p>
          <UButton color="primary" variant="solid" icon="i-lucide-mail" :to="localePath('/contact')">
            {{ t('blog.emptyState.cta') }}
          </UButton>
        </div>
      </div>
    </section>
  </div>
</template>

Page article /blog/[slug].vue (enrichment skeleton)

<script setup lang="ts">
const { t, locale } = useI18n()
const localePath = useLocalePath()
const route = useRoute()
const isFr = computed(() => locale.value === 'fr')
const slug = route.params.slug as string
const path = computed(() => isFr.value ? `/fr/blog/${slug}` : `/en/blog/${slug}`)

// 1. Article principal
const { data: page } = await useAsyncData(
  `blog-${locale.value}-${slug}`,
  () => isFr.value
    ? queryCollection('blog_fr').path(path.value).first()
    : queryCollection('blog_en').path(path.value).first(),
  { watch: [locale] }
)

if (!page.value) {
  throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
}

// 2. Surrounding prev/next (seconde query — OK, même useAsyncData)
const { data: surround } = await useAsyncData(
  `blog-surround-${locale.value}-${slug}`,
  () => isFr.value
    ? queryCollectionItemSurroundings('blog_fr', path.value, {
        fields: ['title', 'description', 'date', 'image', 'path', 'minutes']
      }).where('draft', '=', false).order('date', 'DESC')
    : queryCollectionItemSurroundings('blog_en', path.value, {
        fields: ['title', 'description', 'date', 'image', 'path', 'minutes']
      }).where('draft', '=', false).order('date', 'DESC'),
  { watch: [locale] }
)

// D-12 en order DESC : surround[0] = récent (nouveau), surround[1] = ancien (ancien)
// UI "Article précédent" (plus ancien) = surround[1], "Article suivant" (plus récent) = surround[0]
const nextArticle = computed(() => surround.value?.[0] ?? null)  // récent
const prevArticle = computed(() => surround.value?.[1] ?? null)  // ancien

// Breadcrumb items
const breadcrumbItems = computed(() => [
  { label: t('nav.home'), to: localePath('/'), icon: 'i-lucide-home' },
  { label: t('nav.blog'), to: localePath('/blog') },
  { label: page.value!.title },
])

// Minimal SEO (Phase 7 enrichira avec JSON-LD + og:image)
useSeoMeta({
  title: () => page.value?.title,
  description: () => page.value?.description,
  ogTitle: () => page.value?.title,
  ogDescription: () => page.value?.description,
})

// Date formattée i18n
const formattedDate = computed(() => {
  if (!page.value?.date) return ''
  return new Intl.DateTimeFormat(isFr.value ? 'fr-FR' : 'en-US', {
    year: 'numeric', month: 'long', day: 'numeric',
  }).format(new Date(page.value.date))
})

const tocDrawerOpen = ref(false)
</script>

<template>
  <div class="max-w-7xl mx-auto px-4 py-12">
    <!-- Breadcrumb -->
    <UBreadcrumb :items="breadcrumbItems" class="mb-6 text-sm" />

    <div class="lg:grid lg:grid-cols-[1fr_16rem] lg:gap-12">
      <!-- Main column -->
      <div class="max-w-3xl mx-auto lg:mx-0">
        <!-- Header -->
        <header class="mb-8">
          <h1 class="text-3xl sm:text-4xl font-bold mb-4">{{ page?.title }}</h1>
          <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
            <time :datetime="page?.date" class="font-mono">{{ formattedDate }}</time>
            <span>·</span>
            <span>{{ t('blog.readingTime', { minutes: page?.minutes ?? 1 }) }}</span>
            <!-- UButton trigger drawer mobile only -->
            <UButton
              class="lg:hidden ml-auto"
              variant="ghost" color="neutral" size="sm"
              icon="i-lucide-list"
              :aria-label="t('a11y.blogTocToggle')"
              @click="tocDrawerOpen = true"
            >{{ t('blog.toc.title') }}</UButton>
          </div>
          <div v-if="page?.tags?.length" class="flex flex-wrap gap-2 mt-4">
            <UBadge v-for="tag in page.tags" :key="tag" color="primary" variant="subtle">{{ tag }}</UBadge>
          </div>
          <NuxtImg
            v-if="page?.image"
            :src="page.image"
            :alt="page.title"
            format="webp"
            loading="eager"
            class="w-full aspect-[21/9] object-cover rounded-2xl mt-8 mb-12"
          />
        </header>

        <!-- Body -->
        <article class="prose dark:prose-invert max-w-none">
          <ContentRenderer v-if="page" :value="page" />
        </article>

        <!-- Prev/Next -->
        <BlogPrevNext :prev="prevArticle" :next="nextArticle" class="mt-16" />
      </div>

      <!-- TOC desktop -->
      <aside class="hidden lg:block">
        <BlogToc
          v-if="page?.body?.toc?.links?.length"
          :links="page.body.toc.links"
          class="sticky top-24"
        />
      </aside>
    </div>

    <!-- TOC drawer mobile -->
    <UDrawer v-model:open="tocDrawerOpen" direction="right" :title="t('blog.toc.title')">
      <template #body>
        <BlogToc
          v-if="page?.body?.toc?.links?.length"
          :links="page.body.toc.links"
          @select="tocDrawerOpen = false"
        />
      </template>
    </UDrawer>
  </div>
</template>

UBreadcrumb items shape (reference)

import type { BreadcrumbItem } from '@nuxt/ui'
// BreadcrumbItem = { label?, icon?, avatar?, to?, target?, class?, ui? }

[CITED: ui.nuxt.com/docs/components/breadcrumb]

Extension schema content.config.ts

import { defineContentConfig, defineCollection, z } from '@nuxt/content'

const blogSchema = z.object({
  title: z.string(),
  description: z.string(),
  date: z.string(),
  tags: z.array(z.string()).optional(),
  image: z.string().optional(),
  draft: z.boolean().optional().default(false),        // D-18
  wordCount: z.number().optional(),                    // hook-injected
  minutes: z.number().optional(),                      // hook-injected (reading time)
})

export default defineContentConfig({
  collections: {
    blog_fr: defineCollection({
      type: 'page',
      source: { include: 'fr/blog/**/*.md', prefix: '/fr/blog' },
      schema: blogSchema,
    }),
    blog_en: defineCollection({
      type: 'page',
      source: { include: 'en/blog/**/*.md', prefix: '/en/blog' },
      schema: blogSchema,
    }),
  },
})

State of the Art

Old Approach (v2) Current Approach (v3) When Changed Impact
queryContent('/blog').findSurround() queryCollectionItemSurroundings('blog', path) v3 release Retour [prev | null, next | null], chaînable (where/order) [CITED: masteringnuxt.com upgrade guide]
queryContent().where({ draft: { $ne: true } }) queryCollection('blog').where('draft', '=', false) v3 release Syntax SQL-like au lieu de Mongo-like [CITED: content.nuxt.com/docs/utils/query-collection]
Body = unist AST (file.body.children[].type) Body = minimal tuples ([tag, attrs, ...children]) v3 release unist-util-visit ne fonctionne plus — traversal récursif custom requis [CITED: github.com/nuxt/content/issues/3072]
_draft, _partial underscore fields draft direct field v3 release Schema Zod à jour. Les pages draft peuvent être opt-in via .where('draft','=', false) sans underscore
<NuxtContent :document="doc"> <ContentRenderer :value="page"> v3 release Pattern déjà adopté Phase 5, inchangé

Deprecated/outdated :

  • @vueuse/core useIntersectionObserver serait sur-kill pour un seul usage : natif suffit. Non-deprecated mais sous-optimal ici.
  • @nuxt/content v2 syntax — ne plus référencer dans les docs projet.

Environment Availability

Dependency Required By Available Version Fallback
@nuxt/content queryCollection, ContentRenderer, Shiki, page.body.toc ^3.13.0 [VERIFIED: package.json]
@nuxt/ui UBreadcrumb, UDrawer, UBadge, UButton, UIcon ^3.3.2
@nuxt/image NuxtImg cover listing + article ^2.0.0
@nuxtjs/i18n useI18n, useLocalePath, locale watching ^10.2.4
@tailwindcss/typography prose styling body article ^0.5.19
@iconify-json/lucide icônes UIcon i-lucide-* ^1.2.102
zod schema collections (draft, wordCount, minutes) ^4.3.6
Shiki (intégré) Syntax highlight blocs code ✓ (via @nuxt/content) github-dark theme unique
SQLite native @nuxt/content DB runtime ✓ (via experimental.sqliteConnector: 'native') better-sqlite3 (fallback auto par module)
@vueuse/core IntersectionObserver wrapper IntersectionObserver natif (fallback retenu, pas d'install)
Node 22 Runtime build ✓ assumed per Dockerfile 22
pnpm Package manager ✓ assumed per CI/Docker 10+

Missing dependencies with no fallback : NONE — tout le stack requis est présent.

Missing dependencies with fallback : @vueuse/core absent → IntersectionObserver natif (25 lignes dans BlogToc.vue, sans install).

Validation Architecture

workflow.nyquist_validation: false dans .planning/config.json [VERIFIED: config.json] — section SKIPPED par directive de config. Le projet a sciemment opté pour un flow manuel Phase 5 qui a bien marché (checkpoint visuel humain). Phase 6 suit la même approche.

Security Domain

security_enforcement non présent dans .planning/config.json. Par défaut enabled. Phase 6 est content read-only (aucun formulaire, aucun user input, aucune upload, aucune auth). Le security footprint est minimal.

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication no
V3 Session Management no
V4 Access Control no Routes blog publiques
V5 Input Validation yes (partiel) Zod schema blogSchema valide frontmatter à l'ingestion content [VERIFIED: content.config.ts]. Pas d'input utilisateur runtime — les seuls inputs sont les MD files au build
V6 Cryptography no
V7 Error Handling yes throw createError({ statusCode: 404 }) sur slug introuvable — déjà appliqué Phase 5 [VERIFIED: [slug].vue L15-17]
V8 Data Protection no Pas de PII
V11 Business Logic no Pas de logique métier sensible
V14 Configuration yes (partiel) i18n.baseUrl + detectBrowserLanguage déjà protégés contre redirect abuse par module officiel

Known Threat Patterns for Nuxt 4 + @nuxt/content blog

Pattern STRIDE Standard Mitigation
XSS via markdown content Tampering @nuxt/content + MDC sanitize par défaut. Le seul vecteur serait un composant MDC custom qui v-html sans escape. Vérifier que les composants content/*.vue existants (Phase 5) n'utilisent pas v-html.
Open Redirect via breadcrumb/link manipulation Spoofing Tous les liens passent par localePath() qui ne résout que les routes connues. Pas de ?redirect= pattern dans cette phase.
Arbitrary path traversal via slug Tampering Route [slug].vue single-segment interdit les / dans slug → pas d'escape vers /etc/passwd. queryCollection().path() accepte seulement paths de la DB pré-buildée.
Render DoS via markdown géant DoS Reading-time hook protège partiellement (log warning si wordCount > 10k possible à ajouter en enhancement). Cover image optim via @nuxt/image (limites width/height).
Content injection via frontmatter non validé Tampering Zod schema strict sur tous les champs. Les keys extras non déclarées sont droppées par Zod.

Aucune action security critique requise pour Phase 6. Les contrôles existent déjà ou sont inhérents au stack Nuxt officiel.

Assumptions Log

# Claim Section Risk if Wrong
A1 queryCollectionItemSurroundings avec .order('date','DESC') retourne [0]=article_avant_dans_ordre_DESC, [1]=article_après → mapping UI : prev(ancien)=surround[1], next(récent)=surround[0] Pattern 2 + Pitfall 4 UX cassée (fleches inversées). Vérifier empiriquement au premier test manuel avec 3 articles seed. Mitigation : tester en dev, inverser si besoin. Trivial à fixer.
A2 La prop direction="right" de UDrawer v3 remplace l'ancienne side="right" (suivant doc) Pattern UDrawer Si direction rejeté, fallback sur side. Warning console. Fix 1 ligne. [CITED: ui.nuxt.com/components/drawer] indique direction, confiance HIGH.
A3 Le hook Nitro content:file:afterParse se déclenche en dev ET build pour chaque MD parsé Pattern 5 Si ne se déclenche qu'au build : reading-time manquant en dev. Fallback : composable `useReadingTime(page.description
A4 La propriété page.body.toc.links est présente même si l'article n'a pas de h2/h3 (array vide [] vs undefined) Pattern 3 Si undefined, v-if="page.body.toc.links?.length" handle déjà le cas. Zero risk.
A5 L'ordre des modules (@nuxtjs/sitemap AVANT @nuxt/content) nécessaire pour Phase 7, non requis pour Phase 6 Phase 7 preview Hors scope Phase 6. Juste un heads-up pour planner Phase 7.
A6 package.json sur cette machine reflète l'état réel de node_modules (pnpm install à jour) Standard Stack Si versions divergent, risque incompatibilité API. Mitigation : pnpm install --frozen-lockfile au début de l'exec phase.
A7 Le Vite extractor de @nuxt/content bloque SEULEMENT les variables, pas les const-inlined literals Anti-Patterns Conservative : toujours if/else littéral, jamais de ternaire même avec const.

Total: 7 assumptions mineures, toutes low-risk, toutes mitigeables en implémentation (tests empiriques). Aucune ne bloque le planning.

Open Questions

  1. BlogCard unique avec variant prop : template conditionnel inline OU 2 sous-composants ?

    • What we know: D-20 impose composant unique nommé BlogCard.vue avec variants default/compact.
    • What's unclear: Template v-if="variant === 'default'" dans un seul <template> vs <component :is="variant === 'default' ? DefaultTpl : CompactTpl" /> avec 2 sous-composants privés.
    • Recommendation: Template v-if inline (simplicité, 1 fichier). Maintainable tant qu'on ne dépasse pas 2 variants. Si un 3ème variant émerge, refactor vers sous-composants.
  2. BlogToc : émettre un événement select pour fermer le drawer mobile, ou faire gérer par le parent via watch sur activeId ?

    • What we know: UDrawer doit se fermer quand l'utilisateur clique un item dans le drawer mobile.
    • What's unclear: Pattern événementiel (emit) OU pattern reactif (parent watch).
    • Recommendation: emit('select', id) au click de <a> → simple et explicite. Pattern Vue idiomatic.
  3. Stats hero : uniqueTags.length calculé côté client ou exposé par une meta collection ?

    • What we know: Stat 2 = tags uniques de la collection.
    • What's unclear: Soit computed sur articles.value côté page listing (dépend de .all() déjà fetché — OK), soit requête séparée dédiée.
    • Recommendation: Computed sur articles déjà fetchés — zero overhead, une seule source of truth.
  4. Locale watching : watch: [locale] dans chaque useAsyncData OU recomputer manuel ?

    • What we know: Au switch de langue, les articles doivent re-fetch avec la bonne collection.
    • What's unclear: Impact perf du watch sur 3 queries simultanées.
    • Recommendation: watch: [locale] — pattern Nuxt canonique, négligeable en perf (un event switch locale = rare).
  5. Empty state affiche-t-il quand même le Hero avec stats = 0 ?

    • What we know: UI-SPEC §Empty state est dans une section séparée du hero.
    • What's unclear: Le hero reste visible (stats 0/0/2) OU tout le layout passe en empty state ?
    • Recommendation: Hero TOUJOURS rendu (D-04 implique identité visuelle de la page). Empty state dans la section grid uniquement. Stats 0, 0, 2 restent cohérentes sémantiquement.

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence — à valider)

Metadata

Confidence breakdown:

  • Standard stack: HIGH — toutes versions verified dans package.json, docs officielles consultées
  • Architecture: HIGH — patterns éprouvés Phase 5 + APIs officielles documentées
  • Pitfalls: HIGH — 4/7 pitfalls viennent d'expérience Phase 5 réelle (queryCollection literal, catch-all, hydration, schema Zod). 3/7 sont cités depuis docs officielles.
  • Reading time impl: MEDIUM — hook content:file:afterParse documenté officiellement, mais la structure body minimal en v3 est "internal" — traversal custom validé empiriquement recommandé avant prod.
  • Surround mapping (A1): MEDIUM — doc officielle donne le return shape mais pas la sémantique exacte selon order(). À valider au premier test.

Research date: 2026-04-22 Valid until: 2026-05-22 (~30 jours — @nuxt/content v3.13 est stable, changements breaking peu probables). Au-delà, re-vérifier queryCollectionItemSurroundings shape et page.body.toc.links format en cas de bump majeur.