63 KiB
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). Wrapperprose dark:prose-invertde 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 pratiquequeryCollectionItemSurroundings). 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= ajouterdraft: true. Toutes les queries filtrentdraft: false. Article reste accessible par URL directe. - D-15: Ajouter un lien "Blog" dans
AppHeader.vueentre "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]./blogsans préfixe → 302 viadetectBrowserLanguage.
Additions techniques
- D-18: Étendre schema Zod dans
content.config.ts: ajouterdraft: z.boolean().optional().default(false). - D-19: Créer un composable
useReadingTime(content): number(200 mots/min) OU équivalent hookcontent:file:afterParse. - D-20: Composant unique
BlogCard.vueavec variant prop (default/compact) réutilisé par listing ET prev/next. - D-21: Ajouter clés i18n
blog.*,nav.blog,a11y.blogTocToggle/blogPrev/blogNextdans fr.json/en.json.
Claude's Discretion
- Nom exact du composable reading time (
useReadingTimerecommandé). - Structure interne du composant TOC (sticky container, drawer composition).
- Format exact de la date i18n (
Intl.DateTimeFormatrecommandé). - 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. useSeoMetaenrichi 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 :
- Le Vite extractor de @nuxt/content refuse
queryCollection(variable)→ littéraux uniquement, donc branches if/elseisFrà chaque query (listing, surround, [slug]). - La catch-all route
[...slug].vuecasse avec i18n strategyprefix→ rester sur[slug].vuesingle-segment (déjà conforme Phase 5). - Le body de
pageen v3 esttype: 'minimal'(tuples[tag, attrs, ...children]), PAS l'AST unist v2 → traversal récursif custom ou utiliser un hookcontent: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 │
└─────────────────────────────────────────────────────────────┘
Recommended Project Structure
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].vuecatch-all route + i18n prefix strategy → page resolve{}+ Vue warn. Toujours single-segment[slug].vue.- Retirer
i18n.baseUrldansnuxt.config.ts→ casseuseLocaleHead(donc les meta tags SEO Phase 7). NE PAS TOUCHER. - Ajouter
routeRules: { '/blog/**': { redirect: '/fr/blog' } }→ cassedetectBrowserLanguageET bloque la résolution des slugs. La redirection langue-less se fait EXCLUSIVEMENT viadetectBrowserLanguage.redirectOn: 'no prefix'(déjà configuré). - Utiliser
useStatepouractiveIdTOC → state partagé global, casse au changement de route. Utiliserreflocal dans BlogToc.vue. - Appeler
document.querySelectordanssetup()top-level → crash SSR. Toujours dansonMounted. - Filtrer
draft === falsecôté client après.all()→ gaspille du bandwidth + expose les brouillons dans le payload initial. Filter côté query avec.where('draft', '=', false). - Appeler
useIntersectionObserverde@vueuse/core→ pas installé. Soit natif, soit installer@vueuse/corecomme nouvelle dépendance (refusée). - Ne pas unobserve dans
onBeforeUnmount→ memory leak au navigate entre articles. Toujoursobserver.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 prochainenuxt 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.
Pitfall 6: Accessibilité cards avec NuxtLink absolute inset-0
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 useIntersectionObserverserait sur-kill pour un seul usage : natif suffit. Non-deprecated mais sous-optimal ici.@nuxt/contentv2 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: falsedans.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_enforcementnon 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
-
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.
-
BlogToc : émettre un événement
selectpour fermer le drawer mobile, ou faire gérer par le parent via watch suractiveId?- 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.
-
Stats hero :
uniqueTags.lengthcalculé 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.valuecô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.
-
Locale watching :
watch: [locale]dans chaqueuseAsyncDataOU 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).
-
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)
@nuxt/contentv3 docs — queryCollection : https://content.nuxt.com/docs/utils/query-collection@nuxt/contentv3 docs — queryCollectionItemSurroundings : https://content.nuxt.com/docs/utils/query-collection-item-surroundings@nuxt/contentv3 docs — hooks : https://content.nuxt.com/docs/advanced/hooks- Nuxt UI v3 docs — UBreadcrumb : https://ui.nuxt.com/docs/components/breadcrumb (items shape { label, icon, to, avatar, slot, class, ui })
- Nuxt UI v3 docs — UDrawer : https://ui.nuxt.com/docs/components/drawer (direction prop, v-model:open, dismissible)
- Nuxt UI v3 docs — UContentToc : https://ui.nuxt.com/docs/components/content-toc (mentionné en alternative non retenue)
- Nuxt UI v3 docs — UContentSurround : https://ui.nuxt.com/docs/components/content-surround (mentionné en alternative non retenue)
package.jsonetcontent.config.tslocaux — pinning de versions et schema actuel[VERIFIED]- Phase 5 artifacts :
05-CONTEXT.md,05-02-SUMMARY.md,STATE.mdGotchas — pièges éprouvés
Secondary (MEDIUM confidence)
- Mokkapps IntersectionObserver Nuxt 3 TOC pattern : https://mokkapps.de/blog/create-a-table-of-contents-with-active-states-in-nuxt-3
- DamienG reading time Nuxt Content : https://damieng.com/blog/2023/02/07/reading-time-with-nuxt3-content/
- Mastering Nuxt v2 → v3 upgrade guide : https://masteringnuxt.com/blog/upgrading-from-nuxt-content-v2-to-v3
- Nuxt SEO guide Content integration : https://nuxtseo.com/docs/nuxt-seo/guides/nuxt-content
Tertiary (LOW confidence — à valider)
- GitHub discussion #3072 sur
type: 'minimal'body structure : https://github.com/nuxt/content/issues/3072 (auteur souligne que les types minimal sont "purely internal" et pas exposés — API pourrait évoluer).
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:afterParsedocumenté officiellement, mais la structure bodyminimalen 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.