# 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 (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 ## 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 | ## 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 : │ │ ├─
ContentRenderer │ │ └─