From 2d3974ea2c51cfc4e7b17ef0b94df68cc333aadf Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Wed, 22 Apr 2026 00:51:49 +0200 Subject: [PATCH] docs(06): research phase blog pages - API @nuxt/content v3, TOC IO, surround, hook reading time --- .planning/phases/06-blog-pages/06-RESEARCH.md | 1031 +++++++++++++++++ 1 file changed, 1031 insertions(+) create mode 100644 .planning/phases/06-blog-pages/06-RESEARCH.md diff --git a/.planning/phases/06-blog-pages/06-RESEARCH.md b/.planning/phases/06-blog-pages/06-RESEARCH.md new file mode 100644 index 0000000..43b87c9 --- /dev/null +++ b/.planning/phases/06-blog-pages/06-RESEARCH.md @@ -0,0 +1,1031 @@ +# 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 │ + │ └─