docs(06): create phase plan (4 plans, 3 waves)

Phase 6 Blog Pages decomposed into:
- 06-01 (Wave 1): content schema + reading-time Nitro hook + draft flags
- 06-02 (Wave 2): i18n keys + AppHeader link + BlogCard unified
- 06-03 (Wave 3): listing page /blog SSR bilingue
- 06-04 (Wave 3): [slug] enrichment + BlogToc + BlogPrevNext

Plans 06-03 and 06-04 have zero file overlap and run in parallel.

Covers BLOG-02, BLOG-03, BLOG-06. Honors all 21 D-XX user decisions
from 06-CONTEXT.md. Phase 5 gotchas (literal queryCollection, single
[slug].vue, no routeRules /blog/**) respected in every query branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 01:09:25 +02:00
parent 7bbcd67b29
commit edf7593f4f
6 changed files with 2880 additions and 5 deletions
+30 -5
View File
@@ -59,7 +59,12 @@ Plans:
3. `curl localhost:3000/en/` retourne du HTML anglais sans hardcoded French strings 3. `curl localhost:3000/en/` retourne du HTML anglais sans hardcoded French strings
4. Aucun composant ne contient de chaine en dur (grep pour strings hors `t()` dans les templates) 4. Aucun composant ne contient de chaine en dur (grep pour strings hors `t()` dans les templates)
5. Les traductions FR sonnent naturel — pas de calque anglais 5. Les traductions FR sonnent naturel — pas de calque anglais
**Plans**: TBD **Plans:** 4 plans
Plans:
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
### Phase 4: Ship ### Phase 4: Ship
**Goal**: Le site est deployable en production via Docker et passe tous les checks **Goal**: Le site est deployable en production via Docker et passe tous les checks
@@ -70,7 +75,12 @@ Plans:
2. Le container sert le site SSR sur le port attendu 2. Le container sert le site SSR sur le port attendu
3. `pnpm typecheck` et `pnpm lint` passent avec 0 erreurs 3. `pnpm typecheck` et `pnpm lint` passent avec 0 erreurs
4. `curl` sur chaque page retourne `<title>`, `<meta description>`, `og:title` dans le HTML brut 4. `curl` sur chaque page retourne `<title>`, `<meta description>`, `og:title` dans le HTML brut
**Plans**: TBD **Plans:** 4 plans
Plans:
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
--- ---
@@ -131,7 +141,12 @@ Plans:
3. La page article affiche une table des matieres generee depuis les headings du markdown 3. La page article affiche une table des matieres generee depuis les headings du markdown
4. Des liens "Article precedent" et "Article suivant" sont presents en bas de chaque article 4. Des liens "Article precedent" et "Article suivant" sont presents en bas de chaque article
5. `curl localhost:3000/en/blog` retourne le listing en anglais — les articles ont leur version EN 5. `curl localhost:3000/en/blog` retourne le listing en anglais — les articles ont leur version EN
**Plans**: TBD **Plans:** 4 plans
Plans:
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
**UI hint**: yes **UI hint**: yes
### Phase 7: SEO Blog ### Phase 7: SEO Blog
@@ -144,7 +159,12 @@ Plans:
3. `curl localhost:3000/sitemap.xml` contient les URLs `/blog/[slug]` et `/en/blog/[slug]` 3. `curl localhost:3000/sitemap.xml` contient les URLs `/blog/[slug]` et `/en/blog/[slug]`
4. `og:image` pointe vers l'image de l'article ou vers un fallback branded — jamais vers og-image.png generique 4. `og:image` pointe vers l'image de l'article ou vers un fallback branded — jamais vers og-image.png generique
5. La page article contient un JSON-LD `BreadcrumbList` : Accueil → Blog → Titre de l'article 5. La page article contient un JSON-LD `BreadcrumbList` : Accueil → Blog → Titre de l'article
**Plans**: TBD **Plans:** 4 plans
Plans:
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
### Phase 8: Content & Cocon Semantique ### Phase 8: Content & Cocon Semantique
**Goal**: Le blog est lance avec au moins 2 articles Hytale de qualite, et un visiteur qui arrive sur /hytale decouvre les articles recents — le cocon semantique entre blog et page hytale est etabli **Goal**: Le blog est lance avec au moins 2 articles Hytale de qualite, et un visiteur qui arrive sur /hytale decouvre les articles recents — le cocon semantique entre blog et page hytale est etabli
@@ -155,7 +175,12 @@ Plans:
2. Chaque article blog contient au moins un lien interne vers `/hytale` dans le corps du texte 2. Chaque article blog contient au moins un lien interne vers `/hytale` dans le corps du texte
3. La page `/hytale` affiche une section "Articles recents" avec liens vers les 2 articles seed 3. La page `/hytale` affiche une section "Articles recents" avec liens vers les 2 articles seed
4. Les articles existent en FR et EN — `curl localhost:3000/en/blog` les liste en anglais 4. Les articles existent en FR et EN — `curl localhost:3000/en/blog` les liste en anglais
**Plans**: TBD **Plans:** 4 plans
Plans:
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
**UI hint**: yes **UI hint**: yes
--- ---
@@ -0,0 +1,500 @@
---
phase: 06-blog-pages
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- content.config.ts
- server/plugins/reading-time.ts
- app/utils/countWords.ts
- app/composables/useReadingTime.ts
- content/fr/blog/test-kotlin-syntax.md
- content/en/blog/test-kotlin-syntax.md
autonomous: true
requirements:
- BLOG-02
- BLOG-03
- BLOG-06
tags:
- blog
- content-schema
- reading-time
- nitro-plugin
must_haves:
truths:
- "Le schema Zod de blog_fr et blog_en expose les champs `draft`, `wordCount`, `minutes` aux queries @nuxt/content"
- "Tout article markdown parsé par @nuxt/content reçoit automatiquement `minutes` (>= 1) et `wordCount` (>= 0) sur son objet content"
- "L'article de test test-kotlin-syntax.md (FR + EN) a `draft: true` dans son frontmatter et sera exclu des queries `.where('draft', '=', false)`"
- "Un composable fallback `useReadingTime` permet de dériver un reading time depuis un nombre de mots ou un texte brut si le hook n'a pas encore exécuté"
artifacts:
- path: "content.config.ts"
provides: "Schema blogSchema étendu avec draft/wordCount/minutes"
contains: "draft: z.boolean().optional().default(false)"
- path: "server/plugins/reading-time.ts"
provides: "Nitro plugin hook content:file:afterParse injectant wordCount + minutes"
contains: "content:file:afterParse"
- path: "app/utils/countWords.ts"
provides: "Fonction pure `countWordsInMinimalBody(body)` ignorant code/pre tags"
exports: ["countWordsInMinimalBody"]
- path: "app/composables/useReadingTime.ts"
provides: "Helper client fallback (200 wpm) quand hook indisponible"
exports: ["useReadingTime"]
- path: "content/fr/blog/test-kotlin-syntax.md"
provides: "Article de test marqué draft: true (filtré des listings)"
contains: "draft: true"
- path: "content/en/blog/test-kotlin-syntax.md"
provides: "Version EN marquée draft: true"
contains: "draft: true"
key_links:
- from: "server/plugins/reading-time.ts"
to: "app/utils/countWords.ts"
via: "import { countWordsInMinimalBody } from '~/utils/countWords'"
pattern: "countWordsInMinimalBody"
- from: "server/plugins/reading-time.ts"
to: "content.config.ts schema"
via: "content.wordCount / content.minutes injectés → exposés via Zod optional fields"
pattern: "content\\.minutes\\s*="
---
<objective>
Mettre en place la couche données fondation de Phase 6 : étendre le schema Zod des collections blog avec `draft`, `wordCount`, `minutes`, installer un hook Nitro `content:file:afterParse` qui calcule le reading time (200 mots/min) à l'ingestion, et marquer l'article de test `draft: true` pour qu'il soit exclu des listings.
**Purpose:** Sans ces trois additions, les queries de Wave 3 (`queryCollection('blog_fr').where('draft', '=', false)`) retourneront des données incomplètes ou des champs `undefined`. Cette couche n'a AUCUN consommateur UI dans son wave — elle conditionne tout ce qui suit.
**Output:**
- `content.config.ts` : schema étendu (draft + wordCount + minutes)
- `server/plugins/reading-time.ts` : Nitro hook afterParse
- `app/utils/countWords.ts` : traversal AST minimal body ignorant code/pre
- `app/composables/useReadingTime.ts` : fallback client (200 wpm)
- `content/{fr,en}/blog/test-kotlin-syntax.md` : ajout `draft: true` frontmatter
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/ROADMAP.md
@.planning/phases/06-blog-pages/06-CONTEXT.md
@.planning/phases/06-blog-pages/06-RESEARCH.md
@.planning/phases/06-blog-pages/06-PATTERNS.md
@.planning/phases/05-nuxt-content-setup-renderer/05-02-SUMMARY.md
@content.config.ts
@server/plugins/rate-limit.ts
@content/fr/blog/test-kotlin-syntax.md
@content/en/blog/test-kotlin-syntax.md
<interfaces>
<!-- Schema Zod actuel (content.config.ts) à étendre, pas réécrire -->
```typescript
const blogSchema = z.object({
title: z.string(),
description: z.string(),
date: z.string(),
tags: z.array(z.string()).optional(),
image: z.string().optional(),
})
```
<!-- Collections existantes à préserver intactes -->
```typescript
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,
}),
```
<!-- Pattern Nitro plugin (server/plugins/rate-limit.ts) — hook 'request' différent mais structure identique -->
```typescript
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('request', (event) => { /* ... */ })
})
```
<!-- Body shape @nuxt/content v3 type 'minimal' (cité RESEARCH §Pattern 5) -->
```
body = { type: 'minimal', value: MinimalNode[] }
MinimalNode = string | [tag: string, attrs: object, ...children: MinimalNode[]]
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1.1 : Étendre le schema Zod de content.config.ts (draft + wordCount + minutes)</name>
<files>content.config.ts</files>
<read_first>
- content.config.ts (état actuel — ne JAMAIS réécrire les collections, uniquement étendre le schema)
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 5 lignes 491-506 pour le schema cible + §Pitfall 5 lignes 619-623 pour pourquoi `optional()` est critique)
- .planning/phases/06-blog-pages/06-PATTERNS.md (§`content.config.ts` lignes 398-428 pour les additions exactes)
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-18 : `draft: z.boolean().optional().default(false)` — pas de `.default(true)`, pas de non-optional)
</read_first>
<action>
Ouvrir `content.config.ts` et étendre UNIQUEMENT le `blogSchema` (lignes 3-9 actuelles). Ne pas toucher aux `defineCollection` calls (blog_fr, blog_en) — ils référencent `blogSchema` par variable, donc l'extension se propage automatiquement.
Ajouter 3 champs après `image: z.string().optional(),` dans cet ordre exact :
```typescript
draft: z.boolean().optional().default(false),
wordCount: z.number().optional(),
minutes: z.number().optional(),
```
**Pourquoi `optional()` sur wordCount/minutes (pas `.default()`) :** ces champs sont injectés par le hook Nitro au parse, pas écrits par l'auteur. Les rendre obligatoires casserait l'ingestion. `.optional()` sans `.default()` garantit qu'ils sont strippés-safe mais autorise le hook à les poser (per D-18 + Pitfall 5 RESEARCH : "Les propriétés injectées par hook DOIVENT être déclarées dans le schema Zod pour être visibles via queryCollection").
**Pourquoi `.default(false)` sur draft :** la query `.where('draft', '=', false)` doit matcher les articles dont le frontmatter n'a PAS écrit `draft: false` explicitement (la plupart des articles réels). Sans `.default(false)`, le champ serait `undefined` et le where() les exclurait à tort.
Fichier final attendu (25 lignes) :
```typescript
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),
wordCount: z.number().optional(),
minutes: z.number().optional(),
})
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,
}),
},
})
```
Après la modification, supprimer le cache @nuxt/content pour forcer la régénération de la DB SQLite au prochain dev : `rm -rf node_modules/.cache/content .nuxt` (pattern documenté RESEARCH §Runtime State Inventory).
</action>
<verify>
<automated>grep -c "draft: z.boolean().optional().default(false)" content.config.ts</automated>
</verify>
<acceptance_criteria>
- `grep -c "draft: z.boolean().optional().default(false)" content.config.ts` retourne 1
- `grep -c "wordCount: z.number().optional()" content.config.ts` retourne 1
- `grep -c "minutes: z.number().optional()" content.config.ts` retourne 1
- `grep -c "blog_fr: defineCollection" content.config.ts` retourne 1 (collection préservée)
- `grep -c "blog_en: defineCollection" content.config.ts` retourne 1 (collection préservée)
- `grep "prefix: '/fr/blog'" content.config.ts` retourne 1+ match (source config intacte)
- `pnpm typecheck` passe sans nouvelle erreur TS liée à content.config.ts
</acceptance_criteria>
<done>
Schema Zod étendu avec draft (optional default false) + wordCount (optional) + minutes (optional). Collections blog_fr et blog_en inchangées mais pointent vers le nouveau schema. Cache Nuxt content supprimé pour forcer la régénération DB.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 1.2 : Créer app/utils/countWords.ts (pure AST traversal ignorant code/pre)</name>
<files>app/utils/countWords.ts</files>
<read_first>
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 5 lignes 465-488 pour la fonction `countWordsInMinimalBody` de référence)
- .planning/phases/06-blog-pages/06-PATTERNS.md (§`app/utils/countWords.ts` lignes 361-365 qui confirme qu'il n'existe PAS encore de dossier utils et qu'il faut copier RESEARCH)
- Lister `ls app/` pour confirmer que le dossier `app/utils/` n'existe pas encore et sera créé par cette tâche
</read_first>
<action>
Créer le dossier `app/utils/` (n'existe pas encore) et le fichier `app/utils/countWords.ts` exportant une fonction pure qui traverse un AST `@nuxt/content` v3 `minimal body` (shape `{ type: 'minimal', value: MinimalNode[] }``MinimalNode = string | [tag, attrs, ...children]`) et retourne le nombre de mots en ignorant les tags `code` et `pre` (les snippets de code ne comptent PAS dans le reading time lisible).
Contenu exact du fichier :
```typescript
/**
* Count words in a @nuxt/content v3 "minimal" body AST.
* Ignores code and pre tags (code snippets are not "readable" for reading-time purposes).
*
* Body shape (v3): { type: 'minimal', value: MinimalNode[] }
* MinimalNode = string | [tag: string, attrs: object, ...children: MinimalNode[]]
*
* Used by server/plugins/reading-time.ts at content:file:afterParse.
*/
export function countWordsInMinimalBody(body: unknown): number {
let count = 0
const visit = (node: unknown): void => {
if (typeof node === 'string') {
const trimmed = node.trim()
if (trimmed) count += trimmed.split(/\s+/).length
return
}
if (Array.isArray(node)) {
const tag = node[0]
// Skip code/pre — not counted as reading content
if (tag === 'code' || tag === 'pre') return
// children start at index 2 (index 0 = tag, index 1 = attrs)
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 node of body_.value) visit(node)
}
return count
}
```
**Pourquoi `unknown` plutôt que type strict :** le type `MinimalNode` n'est pas exporté publiquement par @nuxt/content v3. Narrower via `Array.isArray` + typeof check reste type-safe et évite un type import qui pourrait casser à une mise à jour mineure.
**Pourquoi ignorer code/pre :** un bloc de code de 500 mots techniques ne se lit pas au même rythme que de la prose. Convention standard de reading-time (Medium, Dev.to) : exclure les snippets.
</action>
<verify>
<automated>test -f app/utils/countWords.ts && grep -c "export function countWordsInMinimalBody" app/utils/countWords.ts</automated>
</verify>
<acceptance_criteria>
- `test -f app/utils/countWords.ts` retourne 0 (fichier existe)
- `grep -c "export function countWordsInMinimalBody" app/utils/countWords.ts` retourne 1
- `grep "if (tag === 'code' || tag === 'pre') return" app/utils/countWords.ts` retourne 1 match (code/pre ignorés)
- `grep "split(/\\\\s+/)" app/utils/countWords.ts` retourne 1 match (split whitespace)
- `pnpm typecheck` passe sans nouvelle erreur liée à app/utils/countWords.ts
- Le fichier n'importe RIEN (`grep -c "^import" app/utils/countWords.ts` retourne 0)
</acceptance_criteria>
<done>
Fonction pure `countWordsInMinimalBody(body: unknown): number` exportée, traverse récursivement le minimal body, ignore les tags `code` et `pre`, retourne un nombre >= 0. Zero dépendance, zero import.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 1.3 : Créer server/plugins/reading-time.ts (hook content:file:afterParse)</name>
<files>server/plugins/reading-time.ts</files>
<read_first>
- server/plugins/rate-limit.ts (structure `defineNitroPlugin` + `hooks.hook` — convention du projet)
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 5 lignes 453-463 pour le hook body exact + §Pitfall 5 lignes 619-623 pour le lien avec le schema)
- .planning/phases/06-blog-pages/06-PATTERNS.md (§`server/plugins/reading-time.ts` lignes 367-394)
- app/utils/countWords.ts (créé par Task 1.2 — à importer ici)
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-19 : 200 mots/min, formule `Math.ceil(wordCount / 200)`, minimum 1)
</read_first>
<action>
Créer le fichier `server/plugins/reading-time.ts` qui :
1. Utilise `defineNitroPlugin` (auto-imported par Nitro — PAS besoin de l'importer)
2. Enregistre un hook `content:file:afterParse` sur `nitroApp.hooks`
3. Skip les fichiers dont `file.id` ne finit pas par `.md` (protection cheap)
4. Calcule `wordCount` via `countWordsInMinimalBody(content.body)`
5. Injecte `content.wordCount = wordCount` et `content.minutes = Math.max(1, Math.ceil(wordCount / 200))` (D-19 : 200 wpm, floor à 1 minute)
Contenu exact du fichier :
```typescript
import { countWordsInMinimalBody } from '~/utils/countWords'
/**
* Nitro plugin: compute reading time for every markdown content file at parse time.
*
* Injects `wordCount` (number) and `minutes` (number, min 1) on the content object.
* Values are persisted in the @nuxt/content SQLite DB and queryable via queryCollection
* thanks to the matching Zod schema fields in content.config.ts (per D-18 + D-19).
*
* Hook reference: https://content.nuxt.com/docs/advanced/hooks
*/
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('content:file:afterParse', (ctx) => {
const { file, content } = ctx
// Only process markdown files (defensive — hook fires on all sources)
if (!file.id?.endsWith('.md')) return
const wordCount = countWordsInMinimalBody(content.body)
content.wordCount = wordCount
content.minutes = Math.max(1, Math.ceil(wordCount / 200)) // D-19: 200 wpm, floor 1 min
})
})
```
**Pourquoi `~/utils/countWords` et pas relative path :** Nitro résout `~/` vers `app/` dans un plugin server (confirmé par Nuxt 4 layer config par défaut). Aligné avec les conventions des composables `~/composables/*`. Si typecheck échoue à cause d'un alias manquant, fallback : import depuis `~~/app/utils/countWords`.
**Pourquoi `nitroApp` (pas `nitro`) :** convention Nuxt Content docs officielle pour ce hook (RESEARCH §Pattern 5). `rate-limit.ts` utilise `nitro` pour le hook `request` différent — les deux fonctionnent, mais on colle à la convention de la doc du hook consommé.
**Comportement attendu au démarrage dev :**
- À `pnpm dev` après cette tâche : les 2 articles test-kotlin-syntax.md (FR + EN) traversent le hook, reçoivent `wordCount` + `minutes` injectés
- Query `queryCollection('blog_fr').all()` retourne chaque article avec `minutes: number` visible
- Si la DB est stale (avant suppression `.nuxt/cache`), forcer `rm -rf node_modules/.cache/content .nuxt` puis relancer
</action>
<verify>
<automated>test -f server/plugins/reading-time.ts && grep -c "content:file:afterParse" server/plugins/reading-time.ts</automated>
</verify>
<acceptance_criteria>
- `test -f server/plugins/reading-time.ts` retourne 0
- `grep -c "defineNitroPlugin" server/plugins/reading-time.ts` retourne 1
- `grep -c "content:file:afterParse" server/plugins/reading-time.ts` retourne 1
- `grep -c "countWordsInMinimalBody" server/plugins/reading-time.ts` retourne 2 (import + call)
- `grep "Math.max(1, Math.ceil(wordCount / 200))" server/plugins/reading-time.ts` retourne 1 match
- `grep "file.id?.endsWith('.md')" server/plugins/reading-time.ts` retourne 1 match
- `pnpm typecheck` passe sans erreur
- Après `rm -rf node_modules/.cache/content .nuxt && pnpm dev`, les logs Nitro ne montrent AUCUNE erreur hook content (vérifier manuellement le demarrage dev)
</acceptance_criteria>
<done>
Nitro plugin créé, importe countWordsInMinimalBody depuis app/utils, enregistre hook content:file:afterParse, injecte wordCount + minutes (floor 1) sur chaque content object .md. Typecheck vert.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 1.4 : Créer app/composables/useReadingTime.ts (fallback client 200 wpm)</name>
<files>app/composables/useReadingTime.ts</files>
<read_first>
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 5 lignes 509-517 pour le composable exact)
- .planning/phases/06-blog-pages/06-PATTERNS.md (§`app/composables/useReadingTime.ts` lignes 344-357 qui confirme "aucun analog" et source RESEARCH)
- app/composables/ (lister le dossier pour voir les conventions existantes — `useProjects.ts` par ex.)
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-19 : source of truth = hook Nitro, composable = fallback uniquement)
</read_first>
<action>
Créer `app/composables/useReadingTime.ts` qui exporte une fonction pure (pas une composable réactive — la convention `use*` est conservée pour l'auto-import Nuxt mais elle ne retourne pas de refs). Accepte soit un `number` (nombre de mots déjà compté) soit une `string` (texte brut à compter), retourne un nombre de minutes >= 1 avec 200 wpm.
Contenu exact :
```typescript
/**
* Fallback reading-time helper when `article.minutes` is not available
* (e.g., dev hot-reload before the Nitro hook has re-parsed).
*
* Source of truth = server/plugins/reading-time.ts + content.config.ts schema.
* This is only a client-side safety net (per D-19).
*
* @param wordCountOrText number (word count already computed) OR string (raw text to tokenize)
* @returns minutes (>= 1), rounded up, using 200 words per minute
*/
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))
}
```
**Pourquoi pas de `ref` / `computed` :** ce helper est appelé inline dans un template (`{{ article.minutes ?? useReadingTime(article.description) }}`) — un calcul synchrone pur suffit. Si plus tard on veut une version réactive, on pourra wrapper dans un `computed` au site d'appel.
**Pourquoi 200 wpm :** D-19 (CONTEXT.md) fige cette valeur. Standard industrie. Même formule que le hook Nitro — cohérence listing ↔ article garantie.
**Usage prévu (Wave 2+3) :**
```vue
<!-- BlogCard.vue template -->
<span>{{ t('blog.readingTime', { minutes: article.minutes ?? useReadingTime(article.description ?? '') }) }}</span>
```
</action>
<verify>
<automated>test -f app/composables/useReadingTime.ts && grep -c "export function useReadingTime" app/composables/useReadingTime.ts</automated>
</verify>
<acceptance_criteria>
- `test -f app/composables/useReadingTime.ts` retourne 0
- `grep -c "export function useReadingTime" app/composables/useReadingTime.ts` retourne 1
- `grep "Math.max(1, Math.ceil" app/composables/useReadingTime.ts` retourne 2 matches (branche number + branche string)
- `grep "split(/\\\\s+/).filter(Boolean)" app/composables/useReadingTime.ts` retourne 1 match
- `grep -c "wordCountOrText: number | string" app/composables/useReadingTime.ts` retourne 1
- `pnpm typecheck` passe sans erreur
</acceptance_criteria>
<done>
Composable `useReadingTime(numberOrString)` exporté, retourne un number >= 1 basé sur 200 wpm. Fonction pure synchrone, pas de refs. Auto-importée par Nuxt (convention `use*`).
</done>
</task>
<task type="auto" tdd="false">
<name>Task 1.5 : Marquer les articles test-kotlin-syntax.md (FR + EN) comme `draft: true`</name>
<files>
- content/fr/blog/test-kotlin-syntax.md
- content/en/blog/test-kotlin-syntax.md
</files>
<read_first>
- content/fr/blog/test-kotlin-syntax.md (frontmatter actuel : title/description/date/tags — PAS de draft)
- content/en/blog/test-kotlin-syntax.md (idem, version EN)
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-14 : draft: true sur TEST article pour qu'il soit exclu du listing mais reste accessible URL directe)
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pitfall 7 lignes 631-635 : confirme que le listing sera vide tant qu'aucun article non-draft n'existe — comportement attendu de l'empty state)
</read_first>
<action>
Ajouter `draft: true` dans le frontmatter YAML des deux fichiers `test-kotlin-syntax.md` (FR + EN). Le frontmatter actuel contient `title`, `description`, `date`, `tags` — ajouter `draft` sur une nouvelle ligne après `tags`, avant le `---` fermant.
Pour `content/fr/blog/test-kotlin-syntax.md`, frontmatter cible :
```yaml
---
title: "Guide du format Markdown"
description: "Référence complète de tous les éléments et composants disponibles dans les articles"
date: "2026-04-21"
tags: ["guide", "markdown", "mdc"]
draft: true
---
```
Pour `content/en/blog/test-kotlin-syntax.md`, frontmatter cible :
```yaml
---
title: "Markdown Format Guide"
description: "Complete reference of all elements and components available in articles"
date: "2026-04-21"
tags: ["guide", "markdown", "mdc"]
draft: true
---
```
**Ne PAS modifier le corps markdown** des deux fichiers — uniquement le frontmatter. Le titre + tags + date doivent rester inchangés.
**Conséquence attendue (D-14 + Pitfall 7) :**
- `queryCollection('blog_fr').where('draft', '=', false).all()` retourne `[]` (tous les articles sont draft)
- La page `/fr/blog` affichera l'empty state "Bientôt des articles Hytale" (comportement correct, voulu par le planning — les 2 articles seed Hytale viendront en Phase 8)
- URL directe `/fr/blog/test-kotlin-syntax` fonctionne toujours (pas de filtre draft sur la requête `.path(path).first()` — validation en Wave 3)
**Attention frontmatter YAML :** `draft: true` (boolean YAML). Pas `draft: "true"` (string). Sinon le schema Zod `z.boolean()` rejettera au parse.
</action>
<verify>
<automated>grep -c "^draft: true$" content/fr/blog/test-kotlin-syntax.md content/en/blog/test-kotlin-syntax.md</automated>
</verify>
<acceptance_criteria>
- `grep -c "^draft: true$" content/fr/blog/test-kotlin-syntax.md` retourne 1
- `grep -c "^draft: true$" content/en/blog/test-kotlin-syntax.md` retourne 1
- `grep -c "^title:" content/fr/blog/test-kotlin-syntax.md` retourne 1 (frontmatter original préservé)
- `grep -c "^title:" content/en/blog/test-kotlin-syntax.md` retourne 1 (frontmatter original préservé)
- `grep -c "draft:" content/fr/blog/test-kotlin-syntax.md` retourne exactement 1 (pas de doublon)
- `grep -c "draft:" content/en/blog/test-kotlin-syntax.md` retourne exactement 1
- Le corps markdown (après le `---` fermant) est intact — `wc -l` avant et après doit être identique + 1 ligne chacun
- `pnpm dev` démarre sans erreur de schema Zod au parse (i.e., `draft: true` est bien interprété comme boolean, pas string)
</acceptance_criteria>
<done>
Les deux articles de test ont `draft: true` dans leur frontmatter. Le corps markdown et les autres champs frontmatter sont préservés. Les articles seront exclus de toutes les queries `.where('draft', '=', false)` de Wave 2 et 3.
</done>
</task>
</tasks>
<verification>
1. Lancer `pnpm typecheck` — passe sans nouvelle erreur TypeScript
2. Lancer `rm -rf node_modules/.cache/content .nuxt && pnpm dev` — le serveur démarre sans erreur de schema Zod ni erreur de hook Nitro
3. Vérifier dans la console `pnpm dev` qu'aucun warning Zod-parse n'apparaît pour `test-kotlin-syntax.md` (FR + EN)
4. Tester manuellement en dev (optionnel sanity check) : `curl http://localhost:3000/fr/blog/test-kotlin-syntax` retourne 200 + HTML (article reste accessible URL directe malgré draft: true — normal, aucune query `.where('draft')` sur cette route en l'état)
</verification>
<success_criteria>
- content.config.ts contient les 3 nouveaux champs Zod (draft default false, wordCount optional, minutes optional)
- server/plugins/reading-time.ts enregistre le hook content:file:afterParse et appelle countWordsInMinimalBody
- app/utils/countWords.ts exporte une fonction pure qui ignore code/pre
- app/composables/useReadingTime.ts exporte un helper 200 wpm (fallback client)
- content/{fr,en}/blog/test-kotlin-syntax.md ont `draft: true` dans leur frontmatter
- pnpm typecheck passe, pnpm dev démarre clean
</success_criteria>
<output>
After completion, create `.planning/phases/06-blog-pages/06-01-SUMMARY.md` using the summary template. Include:
- Schema Zod diff (before/after)
- Hook behavior verified (warn / no warn)
- wordCount observé sur test-kotlin-syntax.md au parse (valeur approximative)
- Any deviation from the plan
</output>
@@ -0,0 +1,608 @@
---
phase: 06-blog-pages
plan: 02
type: execute
wave: 2
depends_on: []
files_modified:
- i18n/locales/fr.json
- i18n/locales/en.json
- app/components/layout/AppHeader.vue
- app/components/BlogCard.vue
autonomous: true
requirements:
- BLOG-02
- BLOG-03
- BLOG-06
tags:
- blog
- i18n
- nav
- blog-card
- shared-components
must_haves:
truths:
- "Les clés i18n `nav.blog`, `blog.title`, `blog.subtitle`, `blog.stats.*`, `blog.readingTime`, `blog.prevArticle`, `blog.nextArticle`, `blog.backToBlog`, `blog.toc.title`, `blog.emptyState.*`, `blog.breadcrumb.*`, `a11y.blogTocToggle`, `a11y.blogPrev`, `a11y.blogNext` existent dans fr.json ET en.json avec des valeurs traduites"
- "AppHeader.vue affiche un lien `Blog` entre Hytale et Projects dans la nav desktop ET mobile"
- "BlogCard.vue est un composant unique avec variant prop `default` (listing) et `compact` (prev/next), importable partout via auto-import"
- "BlogCard variant default rend : cover image conditionnelle (si article.image) aspect-16/9 + titre + description line-clamp-2 + date formatée i18n + premier tag UBadge + reading time"
- "BlogCard variant compact rend : pas d'image + label row 'Article précédent/suivant' + icône arrow + titre + date, utilisé exclusivement par BlogPrevNext en Wave 3"
artifacts:
- path: "i18n/locales/fr.json"
provides: "Clés blog.* + nav.blog + a11y.blog* en français"
contains: "\"blog\":"
- path: "i18n/locales/en.json"
provides: "Clés blog.* + nav.blog + a11y.blog* en anglais"
contains: "\"blog\":"
- path: "app/components/layout/AppHeader.vue"
provides: "Nav link Blog entre hytale et projects (desktop + mobile)"
contains: "{ key: 'blog', path: '/blog' }"
- path: "app/components/BlogCard.vue"
provides: "Composant unifié variant default + compact pour listing et prev/next"
exports_default: true
key_links:
- from: "app/components/BlogCard.vue"
to: "i18n blog.readingTime / blog.prevArticle / blog.nextArticle"
via: "t('blog.readingTime', { minutes }) dans le template"
pattern: "t\\('blog\\.(readingTime|prevArticle|nextArticle)'"
- from: "app/components/layout/AppHeader.vue"
to: "i18n nav.blog"
via: "t(`nav.${link.key}`) avec key 'blog' ajoutée dans navLinks"
pattern: "key: 'blog'"
- from: "app/components/BlogCard.vue"
to: "NuxtLink localePath('/blog/' + slug)"
via: "absolute inset-0 SEO link pattern de ProjectCard"
pattern: "localePath"
---
<objective>
Poser les **3 pré-requis transverses** consommés par les deux pages blog (Wave 3) :
1. Les clés i18n dans fr.json + en.json (sans elles, tout template de Wave 3 rendera des `{{ $t(...) }}` vides)
2. Le lien nav `Blog` dans AppHeader (sans lui, la nav ne mène pas au blog — rupture de découvrabilité D-15)
3. Le composant `BlogCard.vue` unifié (sans lui, ni le listing ni la section prev/next ne peuvent rendre quoi que ce soit — D-20 exige composant unique avec variant)
**Purpose:** Les 3 tâches de ce plan sont indépendantes les unes des autres (fichiers disjoints) mais nécessaires ENSEMBLE avant que Wave 3 (pages) puisse être exécutée. Elles forment la "couche composition partagée".
**Output:**
- `i18n/locales/fr.json` + `en.json` : bloc `blog.*` complet + `nav.blog` + 3 clés `a11y.blog*`
- `app/components/layout/AppHeader.vue` : entrée `{ key: 'blog', path: '/blog' }` ajoutée dans `navLinks` entre hytale et projects
- `app/components/BlogCard.vue` : composant variant default + compact, auto-importé par Nuxt
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/phases/06-blog-pages/06-CONTEXT.md
@.planning/phases/06-blog-pages/06-RESEARCH.md
@.planning/phases/06-blog-pages/06-PATTERNS.md
@.planning/phases/06-blog-pages/06-UI-SPEC.md
@app/components/ProjectCard.vue
@app/components/layout/AppHeader.vue
@i18n/locales/fr.json
@i18n/locales/en.json
<interfaces>
<!-- Shape article depuis queryCollection('blog_fr') avec schema étendu Wave 1 -->
```typescript
interface BlogArticle {
path: string // ex: '/fr/blog/my-slug'
title: string
description: string
date: string
tags?: string[]
image?: string
draft?: boolean // ajouté Wave 1
wordCount?: number // ajouté Wave 1 (via hook)
minutes?: number // ajouté Wave 1 (via hook)
}
```
<!-- Props BlogCard (contrat D-20) -->
```typescript
interface BlogCardProps {
article: BlogArticle // ou un sous-ensemble pour variant compact (fields prop de surround())
variant?: 'default' | 'compact'
direction?: 'prev' | 'next' // requis seulement si variant='compact'
}
```
<!-- Pattern ProjectCard.vue existant (lignes 18-90) à transposer pour variant default -->
```
<article class="group relative rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm overflow-hidden transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1.5">
<!-- Cover image + padding p-5 sm:p-6 + tag badge + date + title + description -->
<!-- NuxtLink absolute inset-0 pour SEO + a11y -->
</article>
```
<!-- AppHeader.vue navLinks shape actuel (lignes 8-15) -->
```typescript
const navLinks = computed(() => [
{ key: 'home', path: '/' },
{ key: 'hytale', path: '/hytale' },
{ key: 'projects', path: '/projects' },
{ key: 'about', path: '/about' },
{ key: 'contact', path: '/contact' },
{ key: 'fiverr', path: '/fiverr' },
])
```
Le template itère via `v-for="link in navLinks"` puis `{{ t(\`nav.${link.key}\`) }}` — ajouter une entrée propage automatiquement au desktop ET au mobile slideover.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 2.1 : Ajouter le bloc complet `blog.*` + `nav.blog` + `a11y.blog*` dans fr.json et en.json</name>
<files>
- i18n/locales/fr.json
- i18n/locales/en.json
</files>
<read_first>
- i18n/locales/fr.json (structure actuelle : "nav" en haut, "footer", "a11y", "seo", "projects" — pour insérer "blog" en suivant la convention de clés top-level du projet)
- i18n/locales/en.json (mêmes clés, version EN)
- .planning/phases/06-blog-pages/06-UI-SPEC.md (§Copywriting Contract lignes 115-172 pour les libellés FR/EN EXACTS + §i18n Keys à créer lignes 339-379 pour la structure JSON complète)
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-21)
- .planning/phases/06-blog-pages/06-PATTERNS.md (§i18n/locales lignes 464-482 — convention "bloc projects utilise les accents, suivre ce pattern, pas a11y/seo qui sont sans accents")
</read_first>
<action>
**Pour `i18n/locales/fr.json` :**
1. Dans le bloc existant `"nav": { ... }` (lignes 2-9), ajouter une nouvelle clé `"blog"` avec la valeur `"Blog"`. Placer logiquement avant `"projects"` pour refléter l'ordre de navigation (hytale → blog → projects), mais l'ordre dans le JSON n'impacte pas le runtime — l'important est la présence de la clé.
2. Dans le bloc existant `"a11y": { ... }` (lignes 23-34), ajouter 3 nouvelles clés à la fin du bloc :
```json
"blogTocToggle": "Afficher le sommaire",
"blogPrev": "Article précédent : {title}",
"blogNext": "Article suivant : {title}"
```
3. Ajouter un NOUVEAU bloc top-level `"blog": { ... }` (à placer après le bloc `"projects"` pour cohérence thématique, ou à la fin du fichier — l'emplacement est au jugement de l'exécutant tant que le JSON reste valide) contenant :
```json
"blog": {
"title": "Blog",
"subtitle": "Articles techniques, retours d'expérience et guides pratiques sur le développement de plugins Hytale et l'écosystème web.",
"stats": {
"articles": "Articles",
"tags": "Tags",
"languages": "Langues"
},
"readingTime": "{minutes} min de lecture",
"prevArticle": "Article précédent",
"nextArticle": "Article suivant",
"backToBlog": "Retour au blog",
"toc": {
"title": "Sommaire"
},
"emptyState": {
"title": "Bientôt des articles Hytale",
"description": "Le blog se prépare. Les premiers articles sur le développement de plugins Hytale arrivent bientôt.",
"cta": "Me contacter"
},
"breadcrumb": {
"home": "Accueil",
"blog": "Blog"
}
}
```
**Pour `i18n/locales/en.json` :**
Mêmes additions, traductions EN :
1. `nav.blog` = `"Blog"`
2. `a11y.blog*` :
```json
"blogTocToggle": "Show table of contents",
"blogPrev": "Previous article: {title}",
"blogNext": "Next article: {title}"
```
3. Bloc `blog` :
```json
"blog": {
"title": "Blog",
"subtitle": "Technical articles, experience feedback and practical guides on Hytale plugin development and the web ecosystem.",
"stats": {
"articles": "Articles",
"tags": "Tags",
"languages": "Languages"
},
"readingTime": "{minutes} min read",
"prevArticle": "Previous article",
"nextArticle": "Next article",
"backToBlog": "Back to blog",
"toc": {
"title": "Table of contents"
},
"emptyState": {
"title": "Hytale articles coming soon",
"description": "The blog is being prepared. The first articles on Hytale plugin development are coming soon.",
"cta": "Contact me"
},
"breadcrumb": {
"home": "Home",
"blog": "Blog"
}
}
```
**Conventions à respecter :**
- **Accents** : FR utilise les accents dans le bloc `blog.*` (comme le bloc `projects` existant), PAS le pattern ASCII des blocs `a11y`/`seo`. Ex: "Bientôt" avec ô, "précédent" avec é, "sommaire" — accentués. Cohérent avec PATTERNS.md §convention.
- **Interpolation** : `{minutes}` et `{title}` sont la syntaxe vue-i18n standard (pas `{{ minutes }}`, pas `%{minutes}`). Cette syntaxe est déjà utilisée dans le projet (ex: à vérifier dans les blocs existants).
- **Valid JSON** : ne PAS laisser de virgule traînante après la dernière clé d'un bloc (JSON strict).
- **Ordre des blocs** : ne pas réorganiser les blocs existants (`nav`, `footer`, `a11y`, `seo`, `projects`, `home`, `about`, etc.) — uniquement ajouter.
</action>
<verify>
<automated>node -e "const fr=require('./i18n/locales/fr.json'); const en=require('./i18n/locales/en.json'); console.log(fr.nav.blog, en.nav.blog, fr.blog.title, en.blog.title, fr.blog.readingTime, en.blog.readingTime, fr.a11y.blogTocToggle, en.a11y.blogTocToggle)"</automated>
</verify>
<acceptance_criteria>
- `node -e "console.log(require('./i18n/locales/fr.json').nav.blog)"` affiche `Blog`
- `node -e "console.log(require('./i18n/locales/en.json').nav.blog)"` affiche `Blog`
- `node -e "console.log(require('./i18n/locales/fr.json').blog.title)"` affiche `Blog`
- `node -e "console.log(require('./i18n/locales/fr.json').blog.subtitle)"` commence par `Articles techniques`
- `node -e "console.log(require('./i18n/locales/en.json').blog.subtitle)"` commence par `Technical articles`
- `node -e "console.log(require('./i18n/locales/fr.json').blog.readingTime)"` affiche `{minutes} min de lecture`
- `node -e "console.log(require('./i18n/locales/en.json').blog.readingTime)"` affiche `{minutes} min read`
- `node -e "console.log(require('./i18n/locales/fr.json').blog.emptyState.cta)"` affiche `Me contacter`
- `node -e "console.log(require('./i18n/locales/en.json').blog.emptyState.cta)"` affiche `Contact me`
- `node -e "console.log(require('./i18n/locales/fr.json').blog.toc.title)"` affiche `Sommaire`
- `node -e "console.log(require('./i18n/locales/en.json').blog.toc.title)"` affiche `Table of contents`
- `node -e "console.log(require('./i18n/locales/fr.json').a11y.blogTocToggle)"` affiche `Afficher le sommaire`
- `node -e "console.log(require('./i18n/locales/fr.json').a11y.blogPrev)"` contient `{title}`
- `node -e "console.log(require('./i18n/locales/fr.json').blog.breadcrumb.home)"` affiche `Accueil`
- `node -e "console.log(require('./i18n/locales/en.json').blog.breadcrumb.home)"` affiche `Home`
- `node -e "JSON.parse(require('fs').readFileSync('./i18n/locales/fr.json'))"` ne throw pas (JSON valide)
- `node -e "JSON.parse(require('fs').readFileSync('./i18n/locales/en.json'))"` ne throw pas
- Les clés existantes (`nav.home`, `nav.hytale`, `seo.*`, `projects.*`) sont inchangées — vérifier par `node -e "console.log(require('./i18n/locales/fr.json').nav.hytale)"` = `Hytale`
</acceptance_criteria>
<done>
Les deux fichiers i18n contiennent `nav.blog`, 3 clés `a11y.blog*`, et un bloc `blog.*` complet avec les 14 clés listées (title, subtitle, stats.articles/tags/languages, readingTime, prevArticle, nextArticle, backToBlog, toc.title, emptyState.title/description/cta, breadcrumb.home/blog). JSON valide. Aucune clé existante modifiée.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2.2 : Ajouter le lien Blog dans AppHeader.vue navLinks (entre hytale et projects)</name>
<files>app/components/layout/AppHeader.vue</files>
<read_first>
- app/components/layout/AppHeader.vue (état actuel : navLinks lignes 8-15, template desktop lignes 44-55, slideover mobile lignes 89-100 — le template itère via v-for donc UN SEUL changement suffit)
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-15 : ordre final Home / Hytale / **Blog** / Projects / About / Contact / Fiverr)
- .planning/phases/06-blog-pages/06-PATTERNS.md (§AppHeader lignes 431-460)
</read_first>
<action>
Dans `app/components/layout/AppHeader.vue`, modifier UNIQUEMENT l'array `navLinks` computed (lignes 8-15). Insérer `{ key: 'blog', path: '/blog' }` entre l'entrée `hytale` et l'entrée `projects`.
Avant :
```typescript
const navLinks = computed(() => [
{ key: 'home', path: '/' },
{ key: 'hytale', path: '/hytale' },
{ key: 'projects', path: '/projects' },
{ key: 'about', path: '/about' },
{ key: 'contact', path: '/contact' },
{ key: 'fiverr', path: '/fiverr' },
])
```
Après :
```typescript
const navLinks = computed(() => [
{ key: 'home', path: '/' },
{ key: 'hytale', path: '/hytale' },
{ key: 'blog', path: '/blog' },
{ key: 'projects', path: '/projects' },
{ key: 'about', path: '/about' },
{ key: 'contact', path: '/contact' },
{ key: 'fiverr', path: '/fiverr' },
])
```
**Ne toucher à RIEN d'autre** dans le fichier :
- Pas de modification du template (le `v-for="link in navLinks"` prend l'array updated automatiquement)
- Pas de modification du slideover mobile (même v-for sur la même source)
- Pas de modification des imports, des refs, des fonctions `isActive`/`toggleLocale`/`toggleTheme`
- Ne pas ajouter un bloc blog dédié — passer par le pattern itératif existant est intentionnel (cohérence visuelle + moins de code)
**Pourquoi `path: '/blog'` (pas `/fr/blog`) :** le template wrap `localePath(link.path)` dans le NuxtLink `:to` (ligne 46 et 91). `localePath('/blog')` résout automatiquement vers `/fr/blog` ou `/en/blog` selon la locale active — pattern i18n existant respecté.
**Pourquoi la clé `'blog'` :** le template interpole `{{ t(\`nav.${link.key}\`) }}` — la clé `nav.blog` ajoutée par Task 2.1 sera automatiquement utilisée, pas de hardcode.
</action>
<verify>
<automated>grep -c "{ key: 'blog', path: '/blog' }" app/components/layout/AppHeader.vue</automated>
</verify>
<acceptance_criteria>
- `grep -c "{ key: 'blog', path: '/blog' }" app/components/layout/AppHeader.vue` retourne 1
- `grep -n "key: 'hytale'" app/components/layout/AppHeader.vue` retourne une ligne (ex: ligne 10)
- `grep -n "key: 'blog'" app/components/layout/AppHeader.vue` retourne une ligne (ex: ligne 11)
- `grep -n "key: 'projects'" app/components/layout/AppHeader.vue` retourne une ligne (ex: ligne 12)
- Le numéro de ligne de `key: 'blog'` est STRICTEMENT entre celui de `key: 'hytale'` et `key: 'projects'` (ordre respecté D-15)
- `grep -c "key: 'home'" app/components/layout/AppHeader.vue` retourne 1 (pas de duplication/suppression)
- `grep -c "key: 'fiverr'" app/components/layout/AppHeader.vue` retourne 1
- `grep -c "v-for=\"link in navLinks\"" app/components/layout/AppHeader.vue` retourne 2 (desktop + mobile templates intacts)
- `pnpm typecheck` passe
- `pnpm dev` + visite manuelle de `/fr/` montre un lien `Blog` entre `Hytale` et `Projets` dans la nav desktop (validation visuelle optionnelle, non-bloquante)
</acceptance_criteria>
<done>
AppHeader.vue contient `{ key: 'blog', path: '/blog' }` dans navLinks, positionné entre hytale et projects. Aucune autre modification. Template v-for inchangé, le nouveau lien apparaît automatiquement en desktop et dans le slideover mobile. Le libellé `Blog` vient de `nav.blog` (Task 2.1).
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2.3 : Créer app/components/BlogCard.vue (variants default + compact, D-20)</name>
<files>app/components/BlogCard.vue</files>
<read_first>
- app/components/ProjectCard.vue (pattern COMPLET à transposer pour variant default : lignes 18-90 — article wrapper, NuxtImg cover, content section, NuxtLink absolute inset-0)
- .planning/phases/06-blog-pages/06-UI-SPEC.md (§BlogCard variant contract lignes 213-230 pour le layout EXACT des deux variants + §Typography + §Color pour les classes)
- .planning/phases/06-blog-pages/06-PATTERNS.md (§BlogCard.vue lignes 152-252 — adaptation vs ProjectCard détaillée)
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 6 lignes 520-556 pour la structure TypeScript + § Pitfall 6 lignes 625-629 pour le a11y SEO link)
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-02 : tags non-cliquables ; D-03 : pas de fallback image ; D-10 : pas d'image en variant compact)
- i18n/locales/fr.json (après Task 2.1 — confirmer que blog.readingTime / prevArticle / nextArticle sont bien présents)
</read_first>
<action>
Créer `app/components/BlogCard.vue` avec `<script setup lang="ts">`, props typées, date formattée via `Intl.DateTimeFormat`, deux templates (variant default / compact) branchés par `v-if`. Le composant est auto-importé par Nuxt (convention `app/components/*.vue`).
**Script setup complet :**
```vue
<script setup lang="ts">
interface BlogArticle {
path: string
title: string
description?: string
date: string
tags?: string[]
image?: string
minutes?: number
}
interface Props {
article: BlogArticle
variant?: 'default' | 'compact'
direction?: 'prev' | 'next' // uniquement si variant='compact'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
direction: 'next',
})
const { t, locale } = useI18n()
const localePath = useLocalePath()
// Slug extrait du path pour construire l'URL locale-agnostique
// path = '/fr/blog/my-slug' ou '/en/blog/my-slug' → slug = 'my-slug'
const slug = computed(() => {
const parts = props.article.path.split('/').filter(Boolean)
return parts[parts.length - 1] ?? ''
})
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
}
})
// Reading time avec fallback composable si minutes non injecté (ex: dev hot-reload)
const readingMinutes = computed(() => {
if (typeof props.article.minutes === 'number') return props.article.minutes
return useReadingTime(props.article.description ?? '')
})
const directionIcon = computed(() =>
props.direction === 'prev' ? 'i-lucide-arrow-left' : 'i-lucide-arrow-right'
)
const directionLabel = computed(() =>
props.direction === 'prev' ? t('blog.prevArticle') : t('blog.nextArticle')
)
</script>
```
**Template — variant default (listing) :**
Transposition directe du pattern ProjectCard.vue. Différences :
- `NuxtLink` utilise `localePath('/blog/' + slug)` (pas `/project/${id}`)
- `aspect-[16/9]` sur l'image (pas `h-52`)
- `<h2>` (pas `<h3>`) pour le titre — c'est un listing d'articles (hierarchie SEO)
- Description `line-clamp-2` (pas `line-clamp-3`)
- Footer row : reading time + tags supplémentaires (+N) à la place des technologies
- Schema.org `BlogPosting` (pas `CreativeWork`)
- **Cover image conditionnelle** : uniquement si `article.image` présent (D-03 pas de fallback)
```vue
<template>
<article
v-if="variant === 'default'"
class="group relative rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm overflow-hidden transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1.5"
itemscope
itemtype="https://schema.org/BlogPosting"
>
<!-- Cover image (D-03 : aucun fallback si absent) -->
<NuxtLink
v-if="article.image"
:to="localePath(`/blog/${slug}`)"
class="block relative overflow-hidden"
>
<NuxtImg
:src="article.image"
:alt="article.title"
loading="lazy"
format="webp"
width="400"
height="225"
class="w-full aspect-[16/9] object-cover transition-transform duration-500 group-hover:scale-105"
itemprop="image"
/>
</NuxtLink>
<!-- Content -->
<div class="p-5 sm:p-6 flex flex-col gap-3">
<!-- Tag + Date -->
<div class="flex items-center justify-between">
<UBadge v-if="article.tags?.[0]" color="primary" variant="subtle" itemprop="keywords">
{{ article.tags[0] }}
</UBadge>
<time
class="text-xs text-gray-400 dark:text-gray-500 font-mono"
:datetime="article.date"
itemprop="datePublished"
>
{{ formattedDate }}
</time>
</div>
<!-- Title -->
<h2
class="text-lg font-bold text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors"
itemprop="headline"
>
{{ article.title }}
</h2>
<!-- Description -->
<p
v-if="article.description"
class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed"
itemprop="description"
>
{{ article.description }}
</p>
<!-- Footer: reading time + extra tags -->
<div class="flex items-center justify-between pt-2">
<span class="text-xs text-gray-400 dark:text-gray-500 font-medium inline-flex items-center gap-1.5">
<UIcon name="i-lucide-clock" class="w-3.5 h-3.5" />
{{ t('blog.readingTime', { minutes: readingMinutes }) }}
</span>
<div v-if="article.tags && article.tags.length > 1" class="flex gap-1.5">
<span
v-for="tag in article.tags.slice(1, 3)"
:key="tag"
class="text-[11px] px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800/80 text-gray-600 dark:text-gray-400 font-medium border border-gray-200/50 dark:border-gray-700/30"
>
{{ tag }}
</span>
<span
v-if="article.tags.length > 3"
class="text-[11px] px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800/80 text-gray-500 font-medium border border-gray-200/50 dark:border-gray-700/30"
>
+{{ article.tags.length - 3 }}
</span>
</div>
</div>
</div>
<!-- SEO + a11y: full-card clickable link (D-02 tags non-cliquables → safe per Pitfall 6) -->
<NuxtLink
:to="localePath(`/blog/${slug}`)"
class="absolute inset-0 z-10"
:aria-label="`${article.title} - ${formattedDate}`"
itemprop="url"
/>
</article>
<!-- Variant compact (prev/next) — D-10 pas d'image, D-09 label row + icon -->
<article
v-else
class="group relative rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-5 transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1.5 flex flex-col gap-2"
:class="direction === 'next' ? 'items-end text-right' : 'items-start text-left'"
>
<div class="inline-flex items-center gap-2 text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400 font-medium">
<UIcon
v-if="direction === 'prev'"
:name="directionIcon"
class="w-4 h-4 transition-transform duration-200 group-hover:-translate-x-1 group-hover:text-brand-500"
/>
<span>{{ directionLabel }}</span>
<UIcon
v-if="direction === 'next'"
:name="directionIcon"
class="w-4 h-4 transition-transform duration-200 group-hover:translate-x-1 group-hover:text-brand-500"
/>
</div>
<h3 class="text-base font-bold text-gray-900 dark:text-white group-hover:text-brand-500 dark:group-hover:text-brand-400 transition-colors">
{{ article.title }}
</h3>
<time class="text-xs font-mono text-gray-400 dark:text-gray-500" :datetime="article.date">
{{ formattedDate }}
</time>
<NuxtLink
:to="localePath(`/blog/${slug}`)"
class="absolute inset-0 z-10"
:aria-label="t(direction === 'prev' ? 'a11y.blogPrev' : 'a11y.blogNext', { title: article.title })"
/>
</article>
</template>
```
**Décisions de conception documentées :**
- `slug` calculé depuis `article.path` : les articles @nuxt/content ont un `path` de forme `/fr/blog/my-slug` → extraire le dernier segment. Évite de réclamer un champ `slug` explicite dans le frontmatter.
- Variant compact sans image (D-10 + cohérent D-03 : pas de fallback image).
- `absolute inset-0` SEO link pattern OK tant que tags restent non-cliquables (Pitfall 6 + D-02 respectés).
- Schema.org : `BlogPosting` + `datePublished` + `headline` + `description` + `keywords` + `url` + `image` (prépare Phase 7 JSON-LD Article sans effort supplémentaire — tout est déjà structuré).
- `text-right` sur variant=next, `text-left` sur prev : UX directionnelle (la flèche et le texte suivent la direction du clic).
</action>
<verify>
<automated>test -f app/components/BlogCard.vue && grep -c "variant === 'default'" app/components/BlogCard.vue</automated>
</verify>
<acceptance_criteria>
- `test -f app/components/BlogCard.vue` retourne 0
- `grep -c "variant === 'default'" app/components/BlogCard.vue` retourne 1 (template branche)
- `grep -c "variant?: 'default' | 'compact'" app/components/BlogCard.vue` retourne 1 (type union exact)
- `grep -c "direction?: 'prev' | 'next'" app/components/BlogCard.vue` retourne 1
- `grep -c "withDefaults(defineProps<Props>()" app/components/BlogCard.vue` retourne 1
- `grep -c "Intl.DateTimeFormat" app/components/BlogCard.vue` retourne 1
- `grep -c "t('blog.readingTime'" app/components/BlogCard.vue` retourne 1
- `grep "localePath(\`/blog/\${slug}\`)" app/components/BlogCard.vue` retourne 2+ matches (au moins 2 NuxtLink)
- `grep "useReadingTime" app/components/BlogCard.vue` retourne 1+ match (fallback utilisé)
- `grep "i-lucide-arrow-left" app/components/BlogCard.vue` retourne 1 match (icône prev)
- `grep "i-lucide-arrow-right" app/components/BlogCard.vue` retourne 1 match (icône next)
- `grep "BlogPosting" app/components/BlogCard.vue` retourne 1 match (Schema.org)
- `grep "aspect-\\[16/9\\]" app/components/BlogCard.vue` retourne 1 match (ratio cover listing)
- `grep -c "a11y.blogPrev" app/components/BlogCard.vue` retourne 1 (label a11y interpolé)
- `grep -c "a11y.blogNext" app/components/BlogCard.vue` retourne 1
- `pnpm typecheck` passe sans erreur TS
- `pnpm lint` passe sans nouvelle erreur ESLint sur BlogCard.vue
</acceptance_criteria>
<done>
BlogCard.vue créé avec script setup TS, 2 variants (default + compact), date i18n via Intl.DateTimeFormat, reading time avec fallback `useReadingTime`, NuxtLink absolute inset-0 pour SEO/a11y (tags non-cliquables D-02 respecté), icônes arrow directionnelles avec translate hover. Schema.org BlogPosting markup. Auto-importé par Nuxt. Typecheck + lint verts.
</done>
</task>
</tasks>
<verification>
1. `pnpm typecheck` passe
2. `pnpm lint` passe (pas de nouvelle erreur)
3. `pnpm dev` démarre sans erreur — le lien `Blog` apparaît dans la nav desktop après Hytale, avant Projets
4. Clic sur le lien `Blog` va vers `/fr/blog` (404 attendu à ce stade — la page sera créée Wave 3)
5. Validation JSON : `node -e "JSON.parse(require('fs').readFileSync('./i18n/locales/fr.json')); JSON.parse(require('fs').readFileSync('./i18n/locales/en.json')); console.log('valid')"`
6. Le composant BlogCard n'est consommé nulle part à ce stade — c'est normal, il sera utilisé par les pages de Wave 3.
</verification>
<success_criteria>
- fr.json et en.json contiennent tous les blocs blog.*, nav.blog, a11y.blog* (14+ clés ajoutées par locale)
- AppHeader.vue a `{ key: 'blog', path: '/blog' }` à la bonne position dans navLinks (entre hytale et projects)
- BlogCard.vue existe, typecheck vert, supporte variant default et compact
- Aucune régression sur les clés i18n existantes ni sur la nav existante
</success_criteria>
<output>
After completion, create `.planning/phases/06-blog-pages/06-02-SUMMARY.md` with:
- Diff i18n (nombre de clés ajoutées FR + EN)
- Position exacte du lien blog dans navLinks (ligne du fichier)
- Décisions de conception BlogCard (aspects intéressants : slug derivation, direction icons, a11y label template)
- Any deviation (ex: convention accents, ordre des blocs JSON)
</output>
@@ -0,0 +1,378 @@
---
phase: 06-blog-pages
plan: 03
type: execute
wave: 3
depends_on:
- 06-01
- 06-02
files_modified:
- app/pages/blog/index.vue
autonomous: true
requirements:
- BLOG-02
- BLOG-06
tags:
- blog
- listing
- page
- ssr
must_haves:
truths:
- "`curl localhost:3000/fr/blog` retourne du HTML SSR avec un bloc hero (slogan `// blog`, H1 Blog, subtitle, stats) et SOIT une grille de BlogCard SOIT un empty state"
- "`curl localhost:3000/en/blog` retourne la même structure avec les textes en anglais"
- "La query utilise `queryCollection('blog_fr')` et `queryCollection('blog_en')` en littéraux séparés par branche if/else (Phase 5 gotcha respecté)"
- "La query filtre `.where('draft', '=', false)` — les articles test-kotlin-syntax (draft: true après Wave 1) sont exclus, ce qui fait apparaître l'empty state à ce stade du projet (comportement voulu Pitfall 7)"
- "La query ordonne `.order('date', 'DESC')` — article le plus récent en premier (D-12)"
- "Le switch de langue (FR → EN) recharge bien la liste via `{ watch: [locale] }` dans useAsyncData"
- "Stats affichés : nombre d'articles non-draft, nombre de tags uniques, valeur fixe `2` pour languages (FR+EN)"
- "Empty state affiche UIcon book-open + titre `Bientôt des articles Hytale` / `Hytale articles coming soon` + UButton CTA → `/contact` (via localePath)"
artifacts:
- path: "app/pages/blog/index.vue"
provides: "Page listing SSR bilingue /blog avec hero + grille + empty state"
contains: "queryCollection('blog_fr')"
contains_also: "queryCollection('blog_en')"
min_lines: 80
key_links:
- from: "app/pages/blog/index.vue"
to: "queryCollection('blog_fr') / queryCollection('blog_en')"
via: "useAsyncData avec branches if/else isFr (littéraux obligatoires)"
pattern: "queryCollection\\('blog_(fr|en)'\\)"
- from: "app/pages/blog/index.vue"
to: "app/components/BlogCard.vue"
via: "v-for sur articles + <BlogCard :article=... variant='default' />"
pattern: "<BlogCard"
- from: "app/pages/blog/index.vue"
to: "i18n blog.title / blog.subtitle / blog.stats.* / blog.emptyState.*"
via: "t('blog.title') etc. dans template"
pattern: "t\\('blog\\."
---
<objective>
Créer `app/pages/blog/index.vue` — la page listing blog SSR bilingue. Hero (pattern /projects), grille responsive 1/2/3 cols de BlogCard, empty state avec CTA contact. Query bilingue avec branches littérales, filtre draft, order date DESC, watch locale.
**Purpose:** Cette page satisfait directement les success criteria 1 + 5 de la phase (listing SSR + version EN). Elle consomme les artefacts de Wave 1 (schema étendu avec draft) et Wave 2 (BlogCard + i18n + localePath).
**Output:** `app/pages/blog/index.vue` (nouveau fichier, n'existe PAS actuellement).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/phases/06-blog-pages/06-CONTEXT.md
@.planning/phases/06-blog-pages/06-RESEARCH.md
@.planning/phases/06-blog-pages/06-PATTERNS.md
@.planning/phases/06-blog-pages/06-UI-SPEC.md
@app/pages/projects.vue
@app/pages/blog/[slug].vue
@app/pages/test.vue
<interfaces>
<!-- Article shape après Wave 1 (schema étendu) -->
```typescript
interface BlogArticle {
path: string // '/fr/blog/my-slug' ou '/en/blog/my-slug'
title: string
description: string
date: string
tags?: string[]
image?: string
draft?: boolean // via Wave 1 (filtré par .where)
wordCount?: number // via Wave 1 hook
minutes?: number // via Wave 1 hook — consommé par BlogCard
}
```
<!-- Pattern query @nuxt/content v3 OBLIGATOIRE (littéraux) -->
```typescript
// CORRECT — branches if/else littérales
const { data } = 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] }
)
// ❌ INCORRECT — retourne {} silencieusement (Pitfall 1)
const col = isFr.value ? 'blog_fr' : 'blog_en'
const { data } = await useAsyncData(() => queryCollection(col).all())
```
<!-- BlogCard créé Wave 2 (props) -->
```typescript
<BlogCard :article="article" variant="default" />
// article: BlogArticle
```
<!-- Hero pattern app/pages/projects.vue lignes 56-83 à copier -->
```
<section class="relative pt-20 pb-16 px-4 sm:px-6 lg:px-8 overflow-hidden">
<!-- 2 absolute background blurs -->
<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 ...">{{ t('blog.subtitle') }}</p>
<!-- Stats row avec 2 dividers verticaux -->
</div>
</section>
```
<!-- i18n keys disponibles après Wave 2 -->
blog.title, blog.subtitle, blog.stats.articles, blog.stats.tags, blog.stats.languages,
blog.emptyState.title, blog.emptyState.description, blog.emptyState.cta
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 3.1 : Créer app/pages/blog/index.vue (hero + query bilingue + grille + empty state)</name>
<files>app/pages/blog/index.vue</files>
<read_first>
- app/pages/projects.vue (ENTIER — source du hero pattern, stats, grid, empty state)
- app/pages/blog/[slug].vue (pattern existant de query bilingue Phase 5 — branches isFr à reproduire dans le listing)
- app/pages/test.vue (autre exemple de queryCollection littéral)
- app/components/BlogCard.vue (créé Wave 2 Task 2.3 — interface props pour le v-for)
- .planning/phases/06-blog-pages/06-UI-SPEC.md (§Hero section lignes 255-278 pour le contract hero + §Empty state lignes 143-152 pour le copywriting + §Layout lignes 295-305)
- .planning/phases/06-blog-pages/06-PATTERNS.md (§app/pages/blog/index.vue lignes 25-104 pour le code skeleton complet)
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Code Examples lignes 641-709 pour le skeleton vérifié + §Pattern 1 pour les littéraux + §Pitfall 3 pour le watch locale)
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-01 grille 1/2/3 cols, D-04 hero pattern, D-16 empty state, D-17 URLs)
- i18n/locales/fr.json (pour confirmer que blog.stats.articles/tags/languages, blog.emptyState.*, blog.title/subtitle existent — ajoutés Wave 2)
</read_first>
<action>
Créer `app/pages/blog/index.vue` (nouveau fichier — le dossier `app/pages/blog/` existe déjà et contient `[slug].vue`).
**Script setup complet :**
```vue
<script setup lang="ts">
const { t, locale } = useI18n()
const localePath = useLocalePath()
const isFr = computed(() => locale.value === 'fr')
// Query bilingue avec branches littérales (Phase 5 gotcha — Pattern 1 RESEARCH)
// { watch: [locale] } pour re-fetch au switch FR/EN (Pitfall 3)
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 (UI-SPEC §Hero contract exact — 3 items)
const totalArticles = computed(() => articles.value?.length ?? 0)
const uniqueTags = computed(() => {
const set = new Set<string>()
for (const a of articles.value ?? []) {
for (const tag of a.tags ?? []) set.add(tag)
}
return set.size
})
const totalLanguages = 2 // FR + EN — valeur fixe (UI-SPEC)
// SEO minimal Phase 6 — Phase 7 enrichira avec JSON-LD + og:image par article
useSeoMeta({
title: () => t('blog.title'),
description: () => t('blog.subtitle'),
ogTitle: () => t('blog.title'),
ogDescription: () => t('blog.subtitle'),
ogType: 'website',
})
</script>
```
**Template complet** (transposition directe de `/projects.vue` avec substitution des clés i18n) :
```vue
<template>
<div>
<!-- Hero (pattern /projects.vue lignes 56-83) -->
<section class="relative pt-20 pb-16 px-4 sm:px-6 lg:px-8 overflow-hidden">
<div class="absolute inset-0 bg-gray-50/80 dark:bg-gray-900/40" aria-hidden="true" />
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-brand-500/5 dark:bg-brand-500/8 rounded-full blur-3xl pointer-events-none" aria-hidden="true" />
<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 from-gray-900 via-gray-800 to-gray-600 dark:from-white dark:via-gray-200 dark:to-gray-500 bg-clip-text text-transparent">
{{ 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: articles / tags / languages (3 items + 2 dividers) -->
<div class="flex justify-center gap-8 sm:gap-12 mt-12">
<div class="text-center">
<p class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">
{{ totalArticles }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">
{{ t('blog.stats.articles') }}
</p>
</div>
<div class="w-px bg-gradient-to-b from-transparent via-gray-300 dark:via-gray-700 to-transparent" />
<div class="text-center">
<p class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">
{{ uniqueTags }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">
{{ t('blog.stats.tags') }}
</p>
</div>
<div class="w-px bg-gradient-to-b from-transparent via-gray-300 dark:via-gray-700 to-transparent" />
<div class="text-center">
<p class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">
{{ totalLanguages }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">
{{ t('blog.stats.languages') }}
</p>
</div>
</div>
</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">
<!-- Grille responsive 1/2/3 cols (D-01) -->
<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="article in articles"
:key="article.path"
:article="article"
variant="default"
/>
</div>
<!-- Empty state (D-16 + UI-SPEC §Empty state copywriting) -->
<div v-else class="text-center py-32">
<div class="w-16 h-16 mx-auto mb-6 rounded-2xl bg-gray-100 dark:bg-gray-800/60 border border-gray-200/80 dark:border-gray-700/30 flex items-center justify-center">
<UIcon name="i-lucide-book-open" class="text-2xl text-gray-400" />
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
{{ t('blog.emptyState.title') }}
</h3>
<p class="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto leading-relaxed">
{{ t('blog.emptyState.description') }}
</p>
<UButton
color="primary"
variant="solid"
size="md"
icon="i-lucide-mail"
:to="localePath('/contact')"
>
{{ t('blog.emptyState.cta') }}
</UButton>
</div>
</div>
</section>
</div>
</template>
```
**Points critiques à respecter :**
1. **Littéraux `'blog_fr'` / `'blog_en'`** dans deux branches séparées — JAMAIS `queryCollection(col)` avec variable. Reproduit fidèlement le pattern `app/pages/blog/[slug].vue` existant.
2. **`{ watch: [locale] }`** sur le useAsyncData — sans ça, switch FR/EN affiche l'ancienne langue (Pitfall 3).
3. **Key `blog-list-${locale.value}`** — inclut la locale pour invalider le cache correctement.
4. **`computed(() => locale.value === 'fr')`** (pas `const isFr = locale.value === 'fr'`) — sinon pas de réactivité sur le switch.
5. **`articles.value?.length ?? 0`** avec optional chaining — articles peut être `null` durant l'initial fetch avant l'arrivée du SSR payload.
6. **Empty state apparaîtra à ce stade du projet** — tous les articles ont `draft: true` (Wave 1 Task 1.5 + Pitfall 7). C'est le comportement voulu : le blog se prépare, l'empty state est professionnel et CTA contact. Phase 8 ajoutera les vrais articles seed.
7. **SEO minimal** : pas d'ogImage custom, pas de canonical, pas de JSON-LD — Phase 7 traitera ça (hors scope 06).
8. **Pas de routeRules** : ne PAS ajouter de `routeRules: { '/blog': { ... } }` dans nuxt.config — la redirection FR/EN sans préfixe passe par `detectBrowserLanguage` (Phase 5 gotcha, ne pas toucher).
9. **Pas de layout personnalisé** : la page utilise le layout par défaut (header + footer globaux). Ne pas définir `definePageMeta({ layout: ... })`.
</action>
<verify>
<automated>test -f app/pages/blog/index.vue && grep -c "queryCollection('blog_fr')" app/pages/blog/index.vue && grep -c "queryCollection('blog_en')" app/pages/blog/index.vue</automated>
</verify>
<acceptance_criteria>
- `test -f app/pages/blog/index.vue` retourne 0
- `grep -c "queryCollection('blog_fr')" app/pages/blog/index.vue` retourne au moins 1
- `grep -c "queryCollection('blog_en')" app/pages/blog/index.vue` retourne au moins 1
- `grep "queryCollection(locale" app/pages/blog/index.vue` retourne rien (aucune variable dans queryCollection — littéraux uniquement)
- `grep "queryCollection(col" app/pages/blog/index.vue` retourne rien
- `grep -c "\\.where('draft', '=', false)" app/pages/blog/index.vue` retourne 2 (une par branche)
- `grep -c "\\.order('date', 'DESC')" app/pages/blog/index.vue` retourne 2
- `grep -c "watch: \\[locale\\]" app/pages/blog/index.vue` retourne 1
- `grep -c "useAsyncData" app/pages/blog/index.vue` retourne 1
- `grep "<BlogCard" app/pages/blog/index.vue` retourne 1+ match
- `grep -c "variant=\"default\"" app/pages/blog/index.vue` retourne 1
- `grep -c "v-for=\"article in articles\"" app/pages/blog/index.vue` retourne 1
- `grep -c ":key=\"article.path\"" app/pages/blog/index.vue` retourne 1
- `grep -c "t('blog.title')" app/pages/blog/index.vue` retourne 2+ matches (hero H1 + useSeoMeta)
- `grep -c "t('blog.subtitle')" app/pages/blog/index.vue` retourne 2+ matches
- `grep -c "t('blog.stats.articles')" app/pages/blog/index.vue` retourne 1
- `grep -c "t('blog.stats.tags')" app/pages/blog/index.vue` retourne 1
- `grep -c "t('blog.stats.languages')" app/pages/blog/index.vue` retourne 1
- `grep -c "t('blog.emptyState.title')" app/pages/blog/index.vue` retourne 1
- `grep -c "t('blog.emptyState.cta')" app/pages/blog/index.vue` retourne 1
- `grep "i-lucide-book-open" app/pages/blog/index.vue` retourne 1 match
- `grep "localePath('/contact')" app/pages/blog/index.vue` retourne 1 match
- `grep "// blog" app/pages/blog/index.vue` retourne 1 match (slogan mono)
- `grep "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" app/pages/blog/index.vue` retourne 1 match (D-01 grille responsive)
- `pnpm typecheck` passe sans erreur
- `pnpm lint` passe sans nouvelle erreur
- `pnpm build` complète sans erreur (validation SSR — le build fait le prerender et casse si queryCollection mal formé)
- Tests runtime manuels (dans un shell avec `pnpm dev` lancé) :
- `curl -s http://localhost:3000/fr/blog` retourne un 200 avec `<h1>` contenant `Blog` et `// blog` dans le HTML
- `curl -s http://localhost:3000/en/blog` retourne un 200 avec `Hytale articles coming soon` ou `Blog` en H1
- Les tests curl montrent le HTML de l'empty state (pas de grille) — normal, tous les articles sont draft à ce stade
</acceptance_criteria>
<done>
app/pages/blog/index.vue créé. Query bilingue avec littéraux obligatoires respectés (Phase 5 gotcha). `.where('draft','=',false)` + `.order('date','DESC')` + `{ watch: [locale] }`. Hero pattern projets transposé. Grille responsive 1/2/3 cols. Empty state avec UIcon book-open + UButton CTA contact. SEO minimal via useSeoMeta. Typecheck + lint + build verts. Les routes /fr/blog et /en/blog répondent en SSR.
</done>
</task>
</tasks>
<verification>
1. `pnpm typecheck` passe
2. `pnpm lint` passe
3. `pnpm build` complète (validation SSR prerender inclus)
4. `pnpm dev` + curl HTML :
- `curl -s http://localhost:3000/fr/blog | grep -c "// blog"` >= 1
- `curl -s http://localhost:3000/fr/blog | grep -c "Blog"` >= 1 (H1)
- `curl -s http://localhost:3000/fr/blog | grep -ci "Bientôt des articles Hytale"` >= 1 (empty state car tous les articles sont draft:true)
- `curl -s http://localhost:3000/en/blog | grep -ci "Hytale articles coming soon"` >= 1
5. Switch de langue via le toggle AppHeader FR↔EN : le contenu change (empty state FR → EN et inversement). Pas de flash de contenu stale.
6. Navigation depuis le lien `Blog` de AppHeader (ajouté Wave 2) va bien vers `/fr/blog` ou `/en/blog` selon locale.
</verification>
<success_criteria>
- Page `app/pages/blog/index.vue` créée, 80+ lignes
- Hero section SSR avec slogan `// blog` + H1 + subtitle + 3 stats
- Grille conditionnelle (v-if articles.length > 0) avec BlogCard v-for variant=default
- Empty state (v-else) avec UIcon + UButton vers /contact
- Query @nuxt/content bilingue avec littéraux, .where('draft','=',false), .order('date','DESC'), { watch: [locale] }
- `curl /fr/blog` et `curl /en/blog` retournent HTML SSR avec les bons textes traduits
- Success criteria 1 et 5 de la phase validés à la livraison
</success_criteria>
<output>
After completion, create `.planning/phases/06-blog-pages/06-03-SUMMARY.md` with:
- Commandes curl exécutées et extraits HTML (preuve SSR)
- Comportement empty state vérifié (FR + EN)
- Switch locale : délai de re-fetch constaté
- Any deviation (ex: ajustements Tailwind fins, valeurs stats edge cases)
</output>
@@ -0,0 +1,787 @@
---
phase: 06-blog-pages
plan: 04
type: execute
wave: 3
depends_on:
- 06-01
- 06-02
files_modified:
- app/pages/blog/[slug].vue
- app/components/BlogToc.vue
- app/components/BlogPrevNext.vue
autonomous: true
requirements:
- BLOG-03
- BLOG-06
tags:
- blog
- article-chrome
- toc
- prev-next
- intersection-observer
must_haves:
truths:
- "`curl localhost:3000/fr/blog/test-kotlin-syntax` retourne HTML SSR avec (ordre vertical) : UBreadcrumb (Accueil → Blog → titre) → H1 du titre → ligne meta (date formatée + · + reading time) → tags UBadge (si présents) → cover image NuxtImg (si frontmatter.image) → body markdown dans `.prose` → BlogPrevNext (en bas)"
- "`curl localhost:3000/en/blog/test-kotlin-syntax` retourne la même structure avec textes EN (breadcrumb Home → Blog → title)"
- "La page article filtre le `draft` dans la query de SURROUND (prev/next) — les articles draft:true ne viennent pas peupler la navigation. La query `.path(path).first()` n'a PAS le filtre (URL directe accessible pour les tests, D-14)"
- "Sur desktop (>= lg), une `<aside>` sticky à droite contient la table des matières avec les headings h2/h3 de `page.body.toc.links`"
- "Sur mobile (< lg), un UButton `i-lucide-list` dans la meta row ouvre un UDrawer side='right' contenant la même TOC"
- "Le heading actif (premier visible dans la zone 20%-30% du viewport) est surligné `text-brand-500` via IntersectionObserver client-only (onMounted)"
- "BlogPrevNext.vue rend 2 BlogCard variant='compact' avec direction='prev' et 'next'. Si un voisin est null, la cellule reste vide (alignement grid préservé, D-13)"
- "Prev (article plus ancien) = `surround[1]`, Next (article plus récent) = `surround[0]` car order DESC retourne l'élément before la position courante dans l'ordre de la collection (Pitfall 4)"
- "`queryCollectionItemSurroundings` utilise les littéraux 'blog_fr'/'blog_en' avec if/else (Phase 5 gotcha)"
artifacts:
- path: "app/pages/blog/[slug].vue"
provides: "Page article enrichie : breadcrumb + header + TOC layout + surround + prev/next"
contains: "queryCollectionItemSurroundings"
contains_also: "UBreadcrumb"
min_lines: 120
- path: "app/components/BlogToc.vue"
provides: "TOC sticky desktop + UDrawer mobile + IntersectionObserver highlight"
contains: "IntersectionObserver"
contains_also: "UDrawer"
- path: "app/components/BlogPrevNext.vue"
provides: "Grid 2 cols de BlogCard variant compact (prev + next)"
contains: "variant=\"compact\""
key_links:
- from: "app/pages/blog/[slug].vue"
to: "queryCollectionItemSurroundings('blog_fr'|'blog_en', path, ...)"
via: "useAsyncData secondaire avec littéraux if/else + watch locale"
pattern: "queryCollectionItemSurroundings"
- from: "app/pages/blog/[slug].vue"
to: "app/components/BlogToc.vue"
via: "<BlogToc :links=\"page.body.toc.links\" ... />"
pattern: "<BlogToc"
- from: "app/pages/blog/[slug].vue"
to: "app/components/BlogPrevNext.vue"
via: "<BlogPrevNext :prev :next />"
pattern: "<BlogPrevNext"
- from: "app/components/BlogToc.vue"
to: "DOM headings h2/h3 rendus par ContentRenderer"
via: "IntersectionObserver sur document.getElementById(link.id) dans onMounted"
pattern: "IntersectionObserver"
- from: "app/components/BlogPrevNext.vue"
to: "app/components/BlogCard.vue"
via: "<BlogCard variant=\"compact\" direction=\"prev\"|\"next\" />"
pattern: "<BlogCard"
---
<objective>
Terminer la phase en enrichissant la page article `/blog/[slug]` avec le chrome complet (breadcrumb, header riche, TOC sticky + drawer mobile, prev/next cards). Créer 2 composants : `BlogToc.vue` (sticky desktop + UDrawer mobile + IntersectionObserver) et `BlogPrevNext.vue` (grid 2 cols de BlogCard compact).
**Purpose:** Cette page satisfait les success criteria 2, 3, 4 de la phase (rendu SSR article, TOC visible, prev/next visibles). Elle consomme tous les artefacts précédents (schema Wave 1, BlogCard + i18n + nav Wave 2) et corrige le `isFr` non-réactif de Phase 5.
**Output:**
- `app/components/BlogToc.vue` (nouveau)
- `app/components/BlogPrevNext.vue` (nouveau)
- `app/pages/blog/[slug].vue` (modification substantielle de l'existant Phase 5)
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/phases/06-blog-pages/06-CONTEXT.md
@.planning/phases/06-blog-pages/06-RESEARCH.md
@.planning/phases/06-blog-pages/06-PATTERNS.md
@.planning/phases/06-blog-pages/06-UI-SPEC.md
@app/pages/blog/[slug].vue
@app/components/layout/AppHeader.vue
@app/components/ProjectCard.vue
@app/components/content/ProseImg.vue
<interfaces>
<!-- Shape page.body.toc après ContentRenderer (RESEARCH §Pattern 3) -->
```typescript
interface TocLink {
id: string // anchor id auto-généré (kebab-case du heading text)
depth: number // 2 = h2, 3 = h3, etc.
text: string
children?: TocLink[]
}
interface PageBody {
toc?: {
title: string
searchDepth: number
depth: number
links: TocLink[]
}
// ... autre champs (type minimal, value)
}
```
<!-- Shape queryCollectionItemSurroundings return -->
```typescript
// Signature
function queryCollectionItemSurroundings(
collection: 'blog_fr' | 'blog_en',
path: string,
opts?: { before?: number, after?: number, fields?: string[] }
): ChainablePromise // chain .where().order()
// Return: array de 2 éléments [before, after]
// En .order('date', 'DESC') : before = plus récent, after = plus ancien
// PITFALL 4 : UI "précédent" (plus ancien) = surround[1], UI "suivant" (plus récent) = surround[0]
```
<!-- BlogCard variant compact (créé Wave 2) -->
```vue
<BlogCard
:article="prevArticle"
variant="compact"
direction="prev"
/>
```
<!-- Pattern UDrawer Nuxt UI v3 -->
```vue
<UDrawer v-model:open="tocDrawerOpen" side="right">
<template #header>...</template>
<template #body>...</template>
</UDrawer>
```
<!-- UBreadcrumb Nuxt UI v3 items shape -->
```typescript
items: Array<{ label: string, to?: string, icon?: string }>
```
<!-- État actuel app/pages/blog/[slug].vue (Phase 5 — minimal) -->
```vue
<script setup lang="ts">
const { locale } = useI18n()
const route = useRoute()
const slug = route.params.slug as string
const isFr = locale.value === 'fr' // ❌ NON-RÉACTIF — à convertir en computed
const path = isFr ? `/fr/blog/${slug}` : `/en/blog/${slug}`
// ... useAsyncData sans { watch: [locale] }
</script>
<template>
<div class="mx-auto max-w-3xl px-4 py-12">
<article class="prose dark:prose-invert max-w-none">
<ContentRenderer v-if="page" :value="page" />
</article>
</div>
</template>
```
<!-- IntersectionObserver pattern (RESEARCH §Pattern 4 lignes 392-442) -->
```typescript
// rootMargin imposé UI-SPEC
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 }
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 4.1 : Créer app/components/BlogToc.vue (sticky desktop + UDrawer mobile + IntersectionObserver)</name>
<files>app/components/BlogToc.vue</files>
<read_first>
- app/components/layout/AppHeader.vue (pattern USlideover v-model:open="mobileOpen" lignes 6 + 80-114 — UDrawer suit le même pattern d'API)
- app/components/content/ProseImg.vue (pattern defineProps<Props> + withDefaults typé lignes 1-38)
- .planning/phases/06-blog-pages/06-UI-SPEC.md (§BlogToc contract lignes 232-253 pour desktop + mobile + IntersectionObserver — valeurs rootMargin/threshold imposées)
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 3 pour la shape TocLink + §Pattern 4 lignes 392-442 pour l'IntersectionObserver COMPLET + §Pitfall 2 hydration pour initial state)
- .planning/phases/06-blog-pages/06-PATTERNS.md (§BlogToc.vue lignes 256-310)
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-05 sticky desktop + drawer mobile, D-06 highlight IntersectionObserver client-only)
- i18n/locales/fr.json / en.json (confirmer blog.toc.title et a11y.blogTocToggle existent après Wave 2)
</read_first>
<action>
Créer `app/components/BlogToc.vue`. Le composant reçoit `links: TocLink[]` via props, gère :
- Affichage desktop : `<aside>` sticky top-24 w-64 (hidden sur < lg)
- Affichage mobile : UButton trigger `i-lucide-list` + UDrawer side='right' (hidden sur >= lg)
- Highlight : IntersectionObserver dans `onMounted`, cleanup dans `onBeforeUnmount`
- `activeId = ref<string | null>(null)` initial (Pitfall 2 hydration mismatch)
**Fichier complet :**
```vue
<script setup lang="ts">
interface TocLink {
id: string
depth: number
text: string
children?: TocLink[]
}
interface Props {
links: TocLink[]
}
const props = defineProps<Props>()
const { t } = useI18n()
const drawerOpen = ref(false)
const activeId = ref<string | null>(null)
let observer: IntersectionObserver | null = null
// Aplatir la TOC (inclure les children h3 sous h2)
const flatIds = computed(() => {
const ids: string[] = []
const collect = (nodes: TocLink[]) => {
for (const node of nodes) {
ids.push(node.id)
if (node.children?.length) collect(node.children)
}
}
collect(props.links)
return ids
})
onMounted(() => {
if (typeof window === 'undefined') return
// Setup initial activeId au premier heading pour cohérence visuelle post-hydration
activeId.value = flatIds.value[0] ?? null
observer = new IntersectionObserver(
(entries) => {
// Prendre le premier heading visible dans la zone active (du plus haut au plus bas)
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 flatIds.value) {
const el = document.getElementById(id)
if (el) observer.observe(el)
}
})
onBeforeUnmount(() => {
observer?.disconnect()
observer = null
})
function handleItemClick() {
// Fermer le drawer mobile après clic sur un lien
drawerOpen.value = false
}
</script>
<template>
<!-- Desktop: aside sticky (hidden sur mobile) -->
<aside class="hidden lg:block sticky top-24 w-64 self-start">
<p class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-4">
{{ t('blog.toc.title') }}
</p>
<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 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
'block transition-colors',
]"
>
{{ link.text }}
</a>
<ol v-if="link.children?.length" 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 dark:text-brand-400 font-medium'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
'block transition-colors',
]"
>
{{ child.text }}
</a>
</li>
</ol>
</li>
</ol>
</aside>
<!-- Mobile: UButton trigger + UDrawer side='right' (hidden sur desktop) -->
<div class="lg:hidden inline-block">
<UButton
variant="ghost"
color="neutral"
size="sm"
icon="i-lucide-list"
:aria-label="t('a11y.blogTocToggle')"
@click="drawerOpen = true"
>
{{ t('blog.toc.title') }}
</UButton>
<UDrawer v-model:open="drawerOpen" direction="right">
<template #header>
<p class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{{ t('blog.toc.title') }}
</p>
</template>
<template #body>
<ol class="space-y-3 text-sm p-4">
<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-600 dark:text-gray-300',
'block transition-colors',
]"
@click="handleItemClick"
>
{{ link.text }}
</a>
<ol v-if="link.children?.length" class="mt-2 ml-4 space-y-2">
<li v-for="child in link.children" :key="child.id">
<a
:href="`#${child.id}`"
:class="[
activeId === child.id
? 'text-brand-500 dark:text-brand-400 font-medium'
: 'text-gray-500 dark:text-gray-400',
'block transition-colors',
]"
@click="handleItemClick"
>
{{ child.text }}
</a>
</li>
</ol>
</li>
</ol>
</template>
</UDrawer>
</div>
</template>
```
**Points critiques :**
1. **Nuxt UI v3 UDrawer prop name** : `direction` (pas `side` dans certaines versions — vérifier à l'exécution ; si erreur, replace `direction` par `side`). Le projet utilise USlideover avec `side="left"` dans AppHeader — UDrawer v3 utilise `direction`. À valider au build.
2. **`activeId = ref(null)` initial** (pas le premier heading en synchrone) — Pitfall 2. On le set à `flatIds[0]` dans `onMounted` après que le DOM soit prêt.
3. **`onBeforeUnmount` cleanup** — critique pour éviter memory leak au navigate entre articles (anti-pattern RESEARCH).
4. **`handleItemClick` ferme le drawer mobile** — UX standard quand on clique sur un lien d'ancre dans un drawer.
5. **Pas de `useState`** pour activeId — ref local (anti-pattern RESEARCH ligne 563).
6. **TOC nested rendue à 2 niveaux max** (h2 + h3 children) — hiérarchie imposée par UI-SPEC §BlogToc contract. Les h4+ ne sont pas affichés.
7. **Accent color uniquement sur actif** — UI-SPEC §Color §Accent §6 : `text-brand-500 dark:text-brand-400`. Tout le reste est gris neutre.
8. **Client-only garantit par `typeof window === 'undefined' return`** — défensif même si onMounted ne s'exécute que côté client.
</action>
<verify>
<automated>test -f app/components/BlogToc.vue && grep -c "IntersectionObserver" app/components/BlogToc.vue && grep -c "UDrawer" app/components/BlogToc.vue</automated>
</verify>
<acceptance_criteria>
- `test -f app/components/BlogToc.vue` retourne 0
- `grep -c "interface TocLink" app/components/BlogToc.vue` retourne 1
- `grep -c "IntersectionObserver" app/components/BlogToc.vue` retourne 2 (type + new)
- `grep -c "UDrawer" app/components/BlogToc.vue` retourne 1+ match
- `grep "rootMargin: '-20% 0px -70% 0px'" app/components/BlogToc.vue` retourne 1 match
- `grep "threshold: 0" app/components/BlogToc.vue` retourne 1 match
- `grep -c "onMounted" app/components/BlogToc.vue` retourne 1
- `grep -c "onBeforeUnmount" app/components/BlogToc.vue` retourne 1
- `grep "observer?.disconnect()" app/components/BlogToc.vue` retourne 1 match (cleanup)
- `grep "activeId = ref" app/components/BlogToc.vue` retourne 1 match
- `grep "hidden lg:block sticky top-24" app/components/BlogToc.vue` retourne 1 match (desktop aside)
- `grep "lg:hidden" app/components/BlogToc.vue` retourne 1+ match (mobile wrapper)
- `grep "text-brand-500 dark:text-brand-400" app/components/BlogToc.vue` retourne 2+ matches (active state desktop + mobile)
- `grep -c "t('blog.toc.title')" app/components/BlogToc.vue` retourne 2+ matches (desktop header + mobile header/button)
- `grep -c "t('a11y.blogTocToggle')" app/components/BlogToc.vue` retourne 1
- `grep "useState" app/components/BlogToc.vue` retourne rien (anti-pattern évité)
- `pnpm typecheck` passe
- `pnpm lint` passe
</acceptance_criteria>
<done>
BlogToc.vue créé. Desktop : `<aside>` sticky top-24 avec liste nested h2/h3, highlight brand-500 sur actif. Mobile : UButton trigger + UDrawer direction='right' avec même contenu. IntersectionObserver avec rootMargin/threshold UI-SPEC dans onMounted, cleanup onBeforeUnmount. activeId ref local (pas useState). Accepte `links: TocLink[]` via props.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 4.2 : Créer app/components/BlogPrevNext.vue (grid 2 cols de BlogCard compact)</name>
<files>app/components/BlogPrevNext.vue</files>
<read_first>
- app/components/BlogCard.vue (créé Wave 2 Task 2.3 — confirmer le contrat : variant="compact" + direction="prev"|"next")
- .planning/phases/06-blog-pages/06-UI-SPEC.md (§BlogCard variant contract compact lignes 222-230 + §Interaction Contract lignes 321 pour le hover)
- .planning/phases/06-blog-pages/06-PATTERNS.md (§BlogPrevNext.vue lignes 313-340 pour le composition pattern)
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-09 style cards + D-10 pas d'image + D-13 case vide si absent)
- i18n/locales/fr.json / en.json (a11y.blogPrev et a11y.blogNext avec interpolation {title} existent après Wave 2)
</read_first>
<action>
Créer `app/components/BlogPrevNext.vue`. Wrapper `<nav>` avec grid 2 cols md, affiche 2 BlogCard variant=compact. Si un voisin est null, cellule vide préservée pour alignement (D-13).
**Fichier complet :**
```vue
<script setup lang="ts">
interface SurroundArticle {
path: string
title: string
description?: string
date: string
tags?: string[]
image?: string
minutes?: number
}
interface Props {
prev: SurroundArticle | null
next: SurroundArticle | null
}
defineProps<Props>()
const { t } = useI18n()
</script>
<template>
<nav
v-if="prev || next"
class="mt-16 grid md:grid-cols-2 gap-5"
:aria-label="t('blog.prevArticle') + ' / ' + t('blog.nextArticle')"
>
<!-- Prev (article plus ancien dans order DESC) -->
<div v-if="prev">
<BlogCard :article="prev" variant="compact" direction="prev" />
</div>
<div v-else aria-hidden="true" />
<!-- Next (article plus récent dans order DESC) -->
<div v-if="next">
<BlogCard :article="next" variant="compact" direction="next" />
</div>
<div v-else aria-hidden="true" />
</nav>
</template>
```
**Points critiques :**
1. **D-13 : cellule vide préservée**`<div v-else aria-hidden="true" />` maintient la grille à 2 colonnes même si un seul voisin existe. `aria-hidden` évite que les screen readers annoncent un div vide.
2. **Pas de rendu du `<nav>` si les deux sont null**`v-if="prev || next"` garde le DOM propre quand l'article est isolé (ex: premier + seul article, edge case rare).
3. **BlogCard se charge du rendu visuel** — pas de classe hover ici, BlogCard gère son propre hover (principe DRY).
4. **Pas d'interpolation `{title}` directe dans `aria-label`** — BlogCard a déjà son propre `aria-label` interpolé via `a11y.blogPrev` / `a11y.blogNext`. Le `<nav>` wrapper a un label plus générique pour éviter la redondance.
5. **Auto-import Nuxt** : BlogCard est dans `app/components/` donc auto-importé sans ligne `import`.
6. **Type `SurroundArticle`** : sous-ensemble de BlogArticle car `queryCollectionItemSurroundings` retourne uniquement les `fields` demandés (path, title, description, date, image, minutes). Déclaré localement pour ne pas créer un shared-types file dans cette phase.
</action>
<verify>
<automated>test -f app/components/BlogPrevNext.vue && grep -c "<BlogCard" app/components/BlogPrevNext.vue</automated>
</verify>
<acceptance_criteria>
- `test -f app/components/BlogPrevNext.vue` retourne 0
- `grep -c "<BlogCard" app/components/BlogPrevNext.vue` retourne 2 (prev + next)
- `grep "variant=\"compact\"" app/components/BlogPrevNext.vue` retourne 2+ matches
- `grep "direction=\"prev\"" app/components/BlogPrevNext.vue` retourne 1 match
- `grep "direction=\"next\"" app/components/BlogPrevNext.vue` retourne 1 match
- `grep -c "prev: SurroundArticle | null" app/components/BlogPrevNext.vue` retourne 1
- `grep -c "next: SurroundArticle | null" app/components/BlogPrevNext.vue` retourne 1
- `grep "v-else aria-hidden=\"true\"" app/components/BlogPrevNext.vue` retourne 2 matches (D-13 empty cells)
- `grep "grid md:grid-cols-2 gap-5" app/components/BlogPrevNext.vue` retourne 1 match
- `grep "mt-16" app/components/BlogPrevNext.vue` retourne 1 match (spacing avant prev/next)
- `pnpm typecheck` passe
- `pnpm lint` passe
</acceptance_criteria>
<done>
BlogPrevNext.vue créé. Wrapper `<nav>` conditionnel si au moins un voisin. Grid 2 cols md. 2 BlogCard variant=compact avec direction prev/next. Cellules vides préservées pour alignement (D-13).
</done>
</task>
<task type="auto" tdd="false">
<name>Task 4.3 : Enrichir app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround + prev/next)</name>
<files>app/pages/blog/[slug].vue</files>
<read_first>
- app/pages/blog/[slug].vue (état actuel Phase 5 : 34 lignes, query + prose wrapper — à enrichir, pas réécrire entièrement la logique)
- app/components/BlogToc.vue (créé Task 4.1 — interface TocLink props)
- app/components/BlogPrevNext.vue (créé Task 4.2 — interface Props prev/next)
- app/components/BlogCard.vue (créé Wave 2 — utilisé indirectement via BlogPrevNext)
- .planning/phases/06-blog-pages/06-UI-SPEC.md (§Article header contract lignes 280-291 pour l'ordre vertical + §Layout responsive article lignes 294-305 pour la grille desktop)
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Code Examples page article lignes 711-830 pour le skeleton complet + §Pitfall 3 watch locale + §Pitfall 4 surround mapping)
- .planning/phases/06-blog-pages/06-PATTERNS.md (§app/pages/blog/[slug].vue lignes 107-148 pour les patterns et gotchas)
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-05 TOC layout, D-07 header complet, D-08 max-w-3xl prose, D-11 surround helper, D-12 order DESC, D-13 edges)
- i18n/locales/fr.json (confirmer blog.breadcrumb.home/blog, blog.readingTime, a11y.blogTocToggle existent)
</read_first>
<action>
Réécrire substantiellement `app/pages/blog/[slug].vue` pour passer du minimal (Phase 5) au chrome complet (Phase 6). Garder le squelette de query `queryCollection('blog_fr').path(path).first()` mais :
1. Convertir `isFr` en `computed` (réactivité switch locale — Pitfall 3 corrigé)
2. Ajouter `{ watch: [locale] }` sur useAsyncData
3. Ajouter une 2e useAsyncData pour `queryCollectionItemSurroundings` avec fields explicites + where draft + order date DESC
4. Construire `breadcrumbItems` computed (Accueil/Home + Blog + titre article)
5. Construire `formattedDate` computed avec `Intl.DateTimeFormat`
6. Mapper `prevArticle = surround[1]` et `nextArticle = surround[0]` (Pitfall 4)
7. Restructurer le template : UBreadcrumb + H1 + meta row + tags + cover image + grid layout (article + TOC aside) + BlogPrevNext
**Fichier complet :**
```vue
<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 (PAS de filtre draft : URL directe accessible même si draft — D-14)
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) Surroundings (prev/next) AVEC filtre draft + order DESC
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 : order DESC → surround[0] = plus récent (next UI), surround[1] = plus ancien (prev UI) — Pitfall 4
const nextArticle = computed(() => surround.value?.[0] ?? null)
const prevArticle = computed(() => surround.value?.[1] ?? null)
// Breadcrumb (D-07 Accueil → Blog → Titre)
const breadcrumbItems = computed(() => [
{ label: t('blog.breadcrumb.home'), to: localePath('/'), icon: 'i-lucide-home' },
{ label: t('blog.breadcrumb.blog'), to: localePath('/blog') },
{ label: page.value?.title ?? '' },
])
// Date formattée i18n (Intl.DateTimeFormat — style long)
const formattedDate = computed(() => {
if (!page.value?.date) return ''
try {
return new Intl.DateTimeFormat(isFr.value ? 'fr-FR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(page.value.date))
} catch {
return page.value.date
}
})
// Reading time avec fallback composable si minutes non injecté
const readingMinutes = computed(() => {
if (typeof page.value?.minutes === 'number') return page.value.minutes
return useReadingTime(page.value?.description ?? '')
})
// TOC links (safe access — page.body.toc peut être undefined pour un article sans heading)
const tocLinks = computed(() => {
// @ts-expect-error — @nuxt/content v3 body shape type 'minimal' n'expose pas toc dans les types
return (page.value?.body?.toc?.links as Array<{ id: string; depth: number; text: string; children?: unknown[] }> | undefined) ?? []
})
// SEO minimal Phase 6 — Phase 7 enrichira (JSON-LD Article, og:image, BreadcrumbList)
useSeoMeta({
title: () => page.value?.title,
description: () => page.value?.description,
ogTitle: () => page.value?.title,
ogDescription: () => page.value?.description,
ogType: 'article',
})
</script>
<template>
<div class="max-w-7xl mx-auto px-4 py-12">
<!-- Breadcrumb (D-07 au-dessus du H1) -->
<UBreadcrumb :items="breadcrumbItems" class="mb-6 text-sm" />
<!-- Layout grid desktop: article + TOC aside sticky -->
<div class="lg:grid lg:grid-cols-[1fr_16rem] lg:gap-12">
<!-- Main column -->
<div class="max-w-3xl mx-auto lg:mx-0 w-full">
<!-- Header (D-07 ordre exact) -->
<header class="mb-8">
<!-- H1 -->
<h1 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ page?.title }}
</h1>
<!-- Meta row: date + · + reading time + TOC button (mobile only) -->
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
<time :datetime="page?.date" class="font-mono inline-flex items-center gap-1.5">
<UIcon name="i-lucide-calendar" class="w-3.5 h-3.5" />
{{ formattedDate }}
</time>
<span aria-hidden="true">·</span>
<span class="inline-flex items-center gap-1.5">
<UIcon name="i-lucide-clock" class="w-3.5 h-3.5" />
{{ t('blog.readingTime', { minutes: readingMinutes }) }}
</span>
<!-- Mobile TOC trigger sur lg+, le BlogToc rend son propre trigger hidden par sa branche lg:hidden -->
</div>
<!-- Tags row -->
<div v-if="page?.tags?.length" class="flex flex-wrap gap-2 mb-6">
<UBadge
v-for="tag in page.tags"
:key="tag"
color="primary"
variant="subtle"
>
{{ tag }}
</UBadge>
</div>
<!-- Cover image hero (si frontmatter.image D-07) -->
<NuxtImg
v-if="page?.image"
:src="page.image"
:alt="page.title"
loading="eager"
format="webp"
class="w-full aspect-[21/9] object-cover rounded-2xl mt-2 mb-4"
/>
</header>
<!-- Body markdown (prose hérité Phase 5 inchangé) -->
<article class="prose dark:prose-invert max-w-none">
<ContentRenderer v-if="page" :value="page" />
</article>
<!-- Prev/Next en bas de la colonne principale -->
<BlogPrevNext :prev="prevArticle" :next="nextArticle" />
</div>
<!-- TOC aside (desktop sticky, mobile drawer par trigger interne au composant) -->
<BlogToc v-if="tocLinks.length > 0" :links="tocLinks" />
</div>
</div>
</template>
```
**Notes d'implémentation :**
1. **Le trigger TOC mobile vit dans BlogToc.vue** (sa branche `<div class="lg:hidden inline-block">` avec UButton). Il N'est PAS placé dans la meta row de [slug].vue — architecture unifiée, BlogToc gère desktop ET mobile. BlogToc se rend dans la grid desktop à droite ET contient son propre UButton mobile qui apparaît sur < lg. Au résultat : le composant BlogToc se rend côté DOM à la bonne position logique, et ses media queries gèrent la visibilité.
2. **`@ts-expect-error` sur tocLinks** : @nuxt/content v3 expose bien `body.toc` au runtime mais le type exporté est `'minimal'` (tuples) qui ne le déclare pas statiquement. L'accès est safe au runtime, on commente le contournement TS.
3. **Cover image `loading="eager"`** (pas `lazy`) : c'est le hero image above-the-fold de l'article, chargement immédiat pour LCP. Opposite de BlogCard listing (lazy).
4. **max-w-3xl conservé sur la colonne article** (D-08) : sur desktop, la colonne gauche de la grid est `1fr` mais le contenu interne est `max-w-3xl` pour la lisibilité prose. Le wrapping `lg:mx-0` évite qu'il se re-centre mal quand la TOC occupe la colonne droite.
5. **Query draft filter asymétrie** : la query article principale n'a PAS `.where('draft', '=', false)` — cela permet d'accéder aux drafts par URL directe (D-14). En revanche, la query surround A le filtre — les drafts ne peuplent jamais la navigation prev/next. Cette asymétrie est INTENTIONNELLE.
6. **Cas test actuel** : `/fr/blog/test-kotlin-syntax` (draft:true) s'ouvre, UBreadcrumb + header + body + TOC visibles. Mais BlogPrevNext sera vide (`prev=null, next=null`) car c'est le seul article et il est draft. Le `<nav v-if="prev || next">` ne rend rien — visuellement propre.
7. **`createError` 404** : conservé depuis Phase 5, pas d'UI custom — `error.vue` layout global du projet prend le relais.
8. **`ogType: 'article'`** ajouté (était `website` dans Phase 5 implicite) — Phase 7 enrichira encore avec `articleAuthor`, `articlePublishedTime`, etc.
</action>
<verify>
<automated>grep -c "queryCollectionItemSurroundings" app/pages/blog/[slug].vue && grep -c "UBreadcrumb" app/pages/blog/[slug].vue && grep -c "<BlogToc" app/pages/blog/[slug].vue && grep -c "<BlogPrevNext" app/pages/blog/[slug].vue </verify>
<acceptance_criteria>
- `grep -c "queryCollectionItemSurroundings" app/pages/blog/\[slug\].vue` retourne 2 (une par branche FR/EN)
- `grep -c "UBreadcrumb" app/pages/blog/\[slug\].vue` retourne 1+ match
- `grep -c "<BlogToc" app/pages/blog/\[slug\].vue` retourne 1 match
- `grep -c "<BlogPrevNext" app/pages/blog/\[slug\].vue` retourne 1 match
- `grep -c "queryCollection('blog_fr')" app/pages/blog/\[slug\].vue` retourne 1 (article principal)
- `grep -c "queryCollection('blog_en')" app/pages/blog/\[slug\].vue` retourne 1
- `grep -c "isFr = computed" app/pages/blog/\[slug\].vue` retourne 1 (Pitfall 3 corrigé — réactif)
- `grep -c "watch: \[locale\]" app/pages/blog/\[slug\].vue` retourne 2 (article + surround)
- `grep "\.where('draft', '=', false)" app/pages/blog/\[slug\].vue` retourne 2+ matches (surround FR + EN, PAS sur la query path().first())
- `grep "\.order('date', 'DESC')" app/pages/blog/\[slug\].vue` retourne 2 matches
- `grep -c "nextArticle = computed" app/pages/blog/\[slug\].vue` retourne 1
- `grep -c "prevArticle = computed" app/pages/blog/\[slug\].vue` retourne 1
- `grep "surround.value?\[0\]" app/pages/blog/\[slug\].vue` retourne 1 match (Next = [0] per Pitfall 4)
- `grep "surround.value?\[1\]" app/pages/blog/\[slug\].vue` retourne 1 match (Prev = [1])
- `grep -c "breadcrumbItems" app/pages/blog/\[slug\].vue` retourne 2+ matches (computed + bind)
- `grep -c "Intl.DateTimeFormat" app/pages/blog/\[slug\].vue` retourne 1
- `grep -c "t('blog.breadcrumb.home')" app/pages/blog/\[slug\].vue` retourne 1
- `grep -c "t('blog.breadcrumb.blog')" app/pages/blog/\[slug\].vue` retourne 1
- `grep -c "t('blog.readingTime'" app/pages/blog/\[slug\].vue` retourne 1
- `grep -c "ContentRenderer" app/pages/blog/\[slug\].vue` retourne 1 (body markdown préservé de Phase 5)
- `grep "prose dark:prose-invert max-w-none" app/pages/blog/\[slug\].vue` retourne 1 match (wrapper Phase 5 intact)
- `grep "aspect-\[21/9\]" app/pages/blog/\[slug\].vue` retourne 1 match (cover hero aspect)
- `grep "lg:grid-cols-\[1fr_16rem\]" app/pages/blog/\[slug\].vue` retourne 1 match (grid desktop D-08)
- `grep "max-w-3xl mx-auto lg:mx-0" app/pages/blog/\[slug\].vue` retourne 1 match (colonne article lisibilité)
- `grep -c "loading=\"eager\"" app/pages/blog/\[slug\].vue` retourne 1 (cover hero above-fold)
- `grep "createError" app/pages/blog/\[slug\].vue` retourne 1 match (404 handler Phase 5 préservé)
- `pnpm typecheck` passe (attendu : zero nouvelle erreur, `@ts-expect-error` documenté sur page.body.toc)
- `pnpm lint` passe
- `pnpm build` complète (SSR prerender OK)
- Tests runtime (`pnpm dev`) :
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -ci "Accueil"` >= 1 (breadcrumb)
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -ci "Guide du format Markdown"` >= 1 (H1 titre)
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "<article"` >= 1 (body markdown rendu SSR)
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -ci "min de lecture"` >= 1 (reading time i18n)
- `curl -s http://localhost:3000/en/blog/test-kotlin-syntax | grep -ci "Home"` >= 1 (breadcrumb EN)
- `curl -s http://localhost:3000/en/blog/test-kotlin-syntax | grep -ci "min read"` >= 1
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "Sommaire"` >= 1 (TOC title FR — présent car l'article a des headings h2)
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax` ne contient PAS "Article précédent" ni "Article suivant" en HTML (car seul article draft → BlogPrevNext ne rend pas le `<nav>`)
</acceptance_criteria>
<done>
app/pages/blog/[slug].vue enrichi. Query article principale conservée sans filtre draft (URL directe accessible D-14). 2e useAsyncData avec queryCollectionItemSurroundings + filtre draft + order DESC. isFr computed + watch locale corrigent Pitfall 3. Breadcrumb + H1 + meta row + tags + cover hero (aspect-21/9) + ContentRenderer (prose Phase 5 inchangé) + BlogPrevNext. BlogToc integré dans grid desktop (sticky aside) + trigger mobile auto. Mapping prev=[1]/next=[0] respecte Pitfall 4. Typecheck + lint + build verts.
</done>
</task>
</tasks>
<verification>
1. `pnpm typecheck` passe (zero nouvelle erreur)
2. `pnpm lint` passe
3. `pnpm build` complète (validation SSR + prerender)
4. Tests SSR `pnpm dev` :
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "<article"` >= 1 (body markdown rendu côté serveur, pas SPA shell — Success criterion 2)
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "Sommaire"` >= 1 OU (TOC title FR présent — Success criterion 3 : TOC générée depuis page.body.toc)
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "Accueil"` >= 1 (breadcrumb rendu SSR)
- `curl -s http://localhost:3000/en/blog/test-kotlin-syntax | grep -c "<article"` >= 1 (version EN)
5. Tests interactifs (navigateur) :
- Scroll dans l'article → heading TOC actif change de surlignage (brand-500) au passage dans la zone 20%-70%
- Viewport < lg (narrow) : UButton "Sommaire" dans la meta row ; clic → UDrawer s'ouvre à droite avec la TOC ; clic sur un item ferme le drawer
- Switch FR/EN via AppHeader toggle : breadcrumb, H1, date, tags, reading time se re-rendent dans la nouvelle langue
6. Success criteria Phase 6 globaux (TOUS validés à la fin de Wave 3) :
- ✓ curl /fr/blog → HTML SSR listing (Plan 03 Success criterion 1)
- ✓ curl /fr/blog/[slug] → article rendu SSR complet (Plan 04 Success criterion 2)
- ✓ TOC visible depuis headings (Success criterion 3)
- ✓ Liens prev/next présents quand voisins existent (Success criterion 4 — à valider en Phase 8 quand articles seed ajoutés)
- ✓ curl /en/blog → listing EN (Plan 03 Success criterion 5)
</verification>
<success_criteria>
- BlogToc.vue créé : sticky desktop + UDrawer mobile + IntersectionObserver (rootMargin '-20% 0px -70% 0px')
- BlogPrevNext.vue créé : grid 2 cols de BlogCard variant=compact, cellules vides préservées (D-13)
- [slug].vue enrichi : UBreadcrumb + H1 + meta row (date formatée + reading time) + tags + cover hero + body prose (Phase 5 intact) + BlogToc + BlogPrevNext
- isFr converti en computed, watch locale sur les 2 useAsyncData (Pitfall 3)
- queryCollectionItemSurroundings avec littéraux + where draft + order DESC (Pitfalls 1 + 4)
- Mapping prev=surround[1] / next=surround[0] (Pitfall 4 documenté dans commentaires code)
- Typecheck + lint + build verts
- curl /fr/blog/[slug] et /en/blog/[slug] retournent HTML SSR complet incluant breadcrumb/H1/body/TOC
</success_criteria>
<output>
After completion, create `.planning/phases/06-blog-pages/06-04-SUMMARY.md` with:
- Commandes curl exécutées + extraits HTML (preuve SSR breadcrumb + body + TOC)
- Validation manuelle TOC highlight au scroll (desktop + mobile drawer)
- Validation manuelle switch FR/EN sur l'article
- Mapping surround[0]/surround[1] validé empiriquement (ajouter un 2e article non-draft temporaire si besoin pour le test, puis le supprimer)
- Any deviation (ex: UDrawer prop name 'direction' vs 'side' — selon la version Nuxt UI installée)
- Checklist success criteria Phase 6 — cocher les 5 à la fin
</output>
@@ -0,0 +1,577 @@
# Phase 6: Blog Pages - Pattern Map
**Mapped:** 2026-04-22
**Files analyzed:** 10 (3 new components, 1 new page, 1 new composable, 1 new Nitro plugin, 4 modifications)
**Analogs found:** 10 / 10
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `app/pages/blog/index.vue` (NEW) | page (listing) | SSR request-response | `app/pages/projects.vue` | exact (hero + grid + empty state) |
| `app/pages/blog/[slug].vue` (MODIFY) | page (detail) | SSR request-response | `app/pages/blog/[slug].vue` (existing) + `app/pages/test.vue` | self + role-match |
| `app/components/BlogCard.vue` (NEW) | component (presentational) | prop-driven | `app/components/ProjectCard.vue` | exact (card pattern) |
| `app/components/BlogToc.vue` (NEW) | component (stateful client) | event-driven (IntersectionObserver) | `app/components/layout/AppHeader.vue` (USlideover) + `app/components/content/ProseImg.vue` (defineProps) | partial |
| `app/components/BlogPrevNext.vue` (NEW) | component (presentational) | prop-driven | `app/components/ProjectCard.vue` | role-match (card wrapper) |
| `app/composables/useReadingTime.ts` (NEW) | composable (utility) | pure transform | n/a (aucun composable existant hors `useProjects`) | no analog |
| `app/utils/countWords.ts` (NEW) | utility | pure transform | n/a | no analog |
| `server/plugins/reading-time.ts` (NEW) | Nitro plugin | build-time hook | `server/plugins/rate-limit.ts` | role-match (defineNitroPlugin + hooks.hook) |
| `content.config.ts` (MODIFY) | config (schema) | Zod schema | `content.config.ts` (existing) | self |
| `app/components/layout/AppHeader.vue` (MODIFY) | component (navigation) | prop-driven | `app/components/layout/AppHeader.vue` (existing) | self |
| `i18n/locales/fr.json` + `en.json` (MODIFY) | config (locale) | key-value | existing `fr.json` / `en.json` | self |
## Pattern Assignments
### `app/pages/blog/index.vue` (page listing, SSR)
**Analog:** `app/pages/projects.vue` (lines 1-132)
**Script setup pattern** (projects.vue lines 1-51):
```typescript
const { t } = useI18n()
const { projects } = useProjects()
useSeoMeta({
title: () => t('seo.projects.title'),
description: () => t('seo.projects.description'),
// ...
})
const totalProjects = computed(() => projects.value.length)
const featuredCount = computed(() => projects.value.filter((p) => p.featured).length)
```
**Adaptation Phase 6:** Remplacer `useProjects()` par `useAsyncData` + `queryCollection` littéraux isFr (voir Pitfall 1 RESEARCH §Pattern 1). Ajouter `watch: [locale]`.
**Hero section pattern** (projects.vue lines 56-83) — **à copier tel quel** avec substitution des clés i18n :
```vue
<section class="relative pt-20 pb-16 px-4 sm:px-6 lg:px-8 overflow-hidden">
<div class="absolute inset-0 bg-gray-50/80 dark:bg-gray-900/40" aria-hidden="true" />
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-brand-500/5 dark:bg-brand-500/8 rounded-full blur-3xl pointer-events-none" aria-hidden="true" />
<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 from-gray-900 via-gray-800 to-gray-600 dark:from-white dark:via-gray-200 dark:to-gray-500 bg-clip-text text-transparent">{{ 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 items + 2 dividers verticaux pattern identique -->
<div class="flex justify-center gap-8 sm:gap-12 mt-12">
<div class="text-center">
<p class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">{{ totalArticles }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">{{ t('blog.stats.articles') }}</p>
</div>
<div class="w-px bg-gradient-to-b from-transparent via-gray-300 dark:via-gray-700 to-transparent" />
<!-- etc. tags / languages -->
</div>
</div>
</section>
```
**Grid pattern** (projects.vue lines 114-116):
```vue
<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>
```
**Empty state pattern** (projects.vue lines 119-128) — **adapter texte et CTA** :
```vue
<div v-else class="text-center py-32">
<div class="w-16 h-16 mx-auto mb-6 rounded-2xl bg-gray-100 dark:bg-gray-800/60 border border-gray-200/80 dark:border-gray-700/30 flex items-center justify-center">
<UIcon name="i-lucide-book-open" class="text-2xl text-gray-400" />
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">{{ t('blog.emptyState.title') }}</h3>
<p class="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto leading-relaxed">{{ t('blog.emptyState.description') }}</p>
<UButton color="primary" variant="solid" size="md" icon="i-lucide-mail" :to="localePath('/contact')">
{{ t('blog.emptyState.cta') }}
</UButton>
</div>
```
**Query pattern** (from RESEARCH §Pattern 1 + existing `app/pages/test.vue`):
```typescript
const { t, locale } = useI18n()
const localePath = useLocalePath()
const isFr = computed(() => locale.value === 'fr')
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] }
)
```
---
### `app/pages/blog/[slug].vue` (page article, enrichment)
**Analog:** Fichier existant (`app/pages/blog/[slug].vue` lines 1-34) — base SSR Phase 5 à conserver, enrichir avec breadcrumb + TOC + prev/next.
**Current base pattern** (lines 1-25) — **à garder tel quel** :
```typescript
const { locale } = useI18n()
const route = useRoute()
const slug = route.params.slug as string
const isFr = locale.value === 'fr'
const path = isFr ? `/fr/blog/${slug}` : `/en/blog/${slug}`
const { data: page } = await useAsyncData(`blog-${locale.value}-${slug}`, () =>
isFr
? queryCollection('blog_fr').path(path).first()
: queryCollection('blog_en').path(path).first()
)
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
}
useSeoMeta({ title: page.value.title, description: page.value.description, /* ... */ })
```
**Gotcha à corriger pendant enrichment** : Ajouter `{ watch: [locale] }` dans `useAsyncData` (voir Pitfall 3 RESEARCH) et convertir `isFr` en `computed` pour que les refetches se déclenchent sur switch locale.
**Wrapper prose à conserver** (line 28-32) :
```vue
<article class="prose dark:prose-invert max-w-none">
<ContentRenderer v-if="page" :value="page" />
</article>
```
**Enrichments à ajouter** (voir RESEARCH §Code Examples lignes 713-830 pour le skeleton complet) :
1. UBreadcrumb avant le `<article>`
2. Header (H1 + meta row date/minutes + UButton trigger TOC mobile + tags UBadge row + NuxtImg cover)
3. Layout grid `lg:grid-cols-[1fr_16rem] lg:gap-12` pour intégrer `<BlogToc>` sticky desktop
4. `<BlogPrevNext :prev :next />` après `</article>`
5. Seconde `useAsyncData` pour `queryCollectionItemSurroundings` (voir RESEARCH §Pattern 2 — inverser `surround[0]` et `surround[1]` pour order DESC, voir Pitfall 4)
---
### `app/components/BlogCard.vue` (component, variant default)
**Analog:** `app/components/ProjectCard.vue` (lines 1-91) — **match exact** pour variant `default`.
**Props interface pattern** (ProjectCard.vue lines 1-9):
```typescript
<script setup lang="ts">
import type { Project } from '~~/shared/types'
interface Props {
project: Project
}
const props = defineProps<Props>()
const { t } = useI18n()
```
**Adaptation BlogCard :** Type inline (le type Article vient de `queryCollection('blog_fr').all()` — inférer ou déclarer explicitement). Ajouter variant prop :
```typescript
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' })
```
**Card wrapper pattern** (ProjectCard.vue lines 19-23) — **à copier tel quel** :
```vue
<article
class="group relative rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm overflow-hidden transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1.5"
itemscope
itemtype="https://schema.org/BlogPosting"
>
```
**Cover image pattern** (ProjectCard.vue lines 25-43):
```vue
<NuxtLink :to="localePath(`/blog/${slug}`)" class="block relative overflow-hidden">
<NuxtImg
:src="article.image"
:alt="`${article.title} - ${article.description?.slice(0, 60)}...`"
loading="lazy"
format="webp"
width="400"
height="225"
class="w-full aspect-[16/9] object-cover transition-transform duration-500 group-hover:scale-105"
/>
</NuxtLink>
```
**Content section pattern** (ProjectCard.vue lines 46-79):
```vue
<div class="p-5 sm:p-6 flex flex-col gap-3">
<div class="flex items-center justify-between">
<UBadge v-if="article.tags?.[0]" color="primary" variant="subtle">{{ article.tags[0] }}</UBadge>
<time class="text-xs text-gray-400 dark:text-gray-500 font-mono" :datetime="article.date">
{{ formattedDate }}
</time>
</div>
<h2 class="text-lg font-bold text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">
{{ article.title }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed">
{{ article.description }}
</p>
<!-- reading time + tags supplémentaires (+N) -->
</div>
```
**Absolute SEO link pattern** (ProjectCard.vue lines 83-88) — **critique a11y** :
```vue
<NuxtLink
:to="localePath(`/blog/${slug}`)"
class="absolute inset-0 z-10"
:aria-label="`${t('blog.readingTime', { minutes })} - ${article.title}`"
/>
```
**Variant compact** : Pas de NuxtImg, padding `p-5`, label row avec UIcon arrow — voir UI-SPEC §BlogCard variant contract pour le contrat exact.
**Date formatting (nouveau)** — pas d'analog dans ProjectCard (qui affiche `project.date` brut) :
```typescript
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
}
})
```
---
### `app/components/BlogToc.vue` (component, stateful client)
**Analog partiel:** `app/components/layout/AppHeader.vue` (USlideover pattern lines 80-114) + `app/components/content/ProseImg.vue` (defineProps typé lines 1-38).
**USlideover/UDrawer control pattern** (AppHeader.vue lines 6 + 80):
```typescript
const mobileOpen = ref(false)
```
```vue
<USlideover v-model:open="mobileOpen" side="left" class="md:hidden">
<template #header>...</template>
<template #body>...</template>
</USlideover>
```
**Adaptation BlogToc** : Remplacer `USlideover` par `UDrawer` (UI-SPEC D-05), `side="right"` (UI-SPEC). Ref locale `tocDrawerOpen` — ne **pas** utiliser `useState` (Pitfall 8 RESEARCH).
**Props typed pattern** (ProseImg.vue lines 3-16):
```typescript
interface Props {
src: string
alt?: string
/* ... */
}
const props = withDefaults(defineProps<Props>(), {
alt: '',
})
```
**Adaptation BlogToc** :
```typescript
interface TocLink {
id: string
depth: number
text: string
children?: TocLink[]
}
const props = defineProps<{ links: TocLink[] }>()
```
**IntersectionObserver pattern****aucun analog dans le codebase**, copier directement RESEARCH §Pattern 4 (lines 393-440). Points critiques :
- `activeId = ref<string | null>(null)` initial (Pitfall 2 hydration mismatch)
- Setup dans `onMounted`, cleanup dans `onBeforeUnmount`
- `rootMargin: '-20% 0px -70% 0px'` (imposé UI-SPEC)
**Sticky desktop pattern (nouveau)** — voir UI-SPEC §BlogToc contract Desktop :
```vue
<aside class="hidden lg:block sticky top-24 w-64 self-start">
<p class="text-sm font-bold uppercase tracking-wider text-gray-500 mb-4">{{ t('blog.toc.title') }}</p>
<ol class="space-y-2 text-sm">
<!-- liste flat + nested -->
</ol>
</aside>
```
---
### `app/components/BlogPrevNext.vue` (component, prop-driven)
**Analog:** `app/components/ProjectCard.vue` (réutilise `BlogCard variant="compact"` ×2).
**Composition pattern** (nouveau, inspiré UI-SPEC) :
```vue
<script setup lang="ts">
const props = defineProps<{
prev: BlogArticle | null
next: BlogArticle | null
}>()
</script>
<template>
<nav class="mt-16 grid md:grid-cols-2 gap-5" :aria-label="t('blog.breadcrumb.blog')">
<div v-if="prev">
<BlogCard :article="prev" variant="compact" direction="prev" />
</div>
<div v-else />
<div v-if="next">
<BlogCard :article="next" variant="compact" direction="next" />
</div>
<div v-else />
</nav>
</template>
```
Pattern "empty cell kept for alignment" imposé par D-13 RESEARCH.
---
### `app/composables/useReadingTime.ts` (composable, pure transform)
**Analog:** **Aucun composable existant n'a le même rôle** (`useProjects` manipule des stores, pas de pure compute). Utiliser directement RESEARCH §Pattern 5 ligne 509-517 :
```typescript
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))
}
```
**Role :** Fallback client si `page.minutes` absent (dev mode, hook pas encore exécuté). Source of truth = hook Nitro.
---
### `app/utils/countWords.ts` (utility, pure)
**Analog:** Aucun — dossier `app/utils/` à créer. Copier RESEARCH §Pattern 5 lignes 465-488 (fonction `countWordsInMinimalBody`). Exporté et importé par le Nitro plugin.
---
### `server/plugins/reading-time.ts` (Nitro plugin, build-time hook)
**Analog:** `server/plugins/rate-limit.ts` (lines 1-32) — **même structure** `defineNitroPlugin` + `nitro.hooks.hook(...)`.
**Plugin skeleton pattern** (rate-limit.ts lines 11-32):
```typescript
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('request', (event) => {
// ...
})
})
```
**Adaptation Phase 6** (RESEARCH §Pattern 5 lines 453-463):
```typescript
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))
})
})
```
Convention de nommage paramètre : `nitro` (rate-limit) vs `nitroApp` (RESEARCH example) — les deux valent ; préférer `nitroApp` ici pour coller à la convention Nuxt docs du hook content.
---
### `content.config.ts` (config, schema)
**Analog:** `content.config.ts` existant (lines 1-25) — **étendre**, ne pas réécrire.
**Existing schema** (lines 3-9) :
```typescript
const blogSchema = z.object({
title: z.string(),
description: z.string(),
date: z.string(),
tags: z.array(z.string()).optional(),
image: z.string().optional(),
})
```
**Additions Phase 6** (D-18 + RESEARCH §Pattern 5 + Pitfall 5) :
```typescript
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(), // injecté par hook
minutes: z.number().optional(), // injecté par hook
})
```
**Structure `collections` inchangée** (lines 11-24).
---
### `app/components/layout/AppHeader.vue` (component, navigation — MODIFY)
**Analog:** Fichier lui-même (AppHeader.vue lines 8-15) — **ajouter un item** dans `navLinks` array.
**Current pattern** (lines 8-15):
```typescript
const navLinks = computed(() => [
{ key: 'home', path: '/' },
{ key: 'hytale', path: '/hytale' },
{ key: 'projects', path: '/projects' },
{ key: 'about', path: '/about' },
{ key: 'contact', path: '/contact' },
{ key: 'fiverr', path: '/fiverr' },
])
```
**Modification D-15** (ajout entre hytale et projects) :
```typescript
const navLinks = computed(() => [
{ key: 'home', path: '/' },
{ key: 'hytale', path: '/hytale' },
{ key: 'blog', path: '/blog' }, // NEW (D-15)
{ key: 'projects', path: '/projects' },
{ key: 'about', path: '/about' },
{ key: 'contact', path: '/contact' },
{ key: 'fiverr', path: '/fiverr' },
])
```
Le template ne change pas : `{{ t(\`nav.${link.key}\`) }}` lira automatiquement `nav.blog` depuis les locales.
---
### `i18n/locales/fr.json` + `en.json` (config, locale — MODIFY)
**Analog:** fichiers existants (fr.json lines 1-9 pour `nav`, lines 23-34 pour `a11y`, lines 112-149 pour `projects` pattern).
**Existing `nav` block** (fr.json lines 2-9):
```json
"nav": {
"home": "Accueil",
"projects": "Projets",
"about": "A propos",
"contact": "Contact",
"fiverr": "Fiverr",
"hytale": "Hytale"
}
```
**Add D-21** : `"blog": "Blog"` dans `nav`, plus bloc complet `blog.*` et `a11y.blogTocToggle/blogPrev/blogNext`. Structure exacte dans UI-SPEC §i18n Keys à créer (lines 341-379).
**Convention observée** : accents encodés en ASCII (`A propos` sans accent, `Developpeur` sans accent) dans les clés existantes `a11y` et `seo`. Les nouveaux libellés `blog.*` peuvent utiliser les accents (cohérent avec bloc `projects` qui les utilise) — **suivre le pattern du bloc `projects`**, pas `a11y/seo`.
---
## Shared Patterns
### i18n access
**Source:** `app/pages/projects.vue` ligne 2, `app/components/ProjectCard.vue` ligne 9
**Apply to:** Tous les composants/pages créés en Phase 6
```typescript
const { t } = useI18n()
const { t, locale } = useI18n() // si locale réactive nécessaire
const localePath = useLocalePath() // pour les NuxtLink/:to
```
### SEO meta (minimal Phase 6, enrichi Phase 7)
**Source:** `app/pages/projects.vue` lines 5-14, `app/pages/blog/[slug].vue` lines 19-24
**Apply to:** `app/pages/blog/index.vue`, `app/pages/blog/[slug].vue`
```typescript
useSeoMeta({
title: () => t('blog.title'),
description: () => t('blog.subtitle'),
ogTitle: () => t('blog.title'),
ogDescription: () => t('blog.subtitle'),
})
```
### queryCollection littéral branching (CRITIQUE — Phase 5 gotcha hérité)
**Source:** `app/pages/blog/[slug].vue` lines 9-13 + `app/pages/test.vue` lines 2-4
**Apply to:** Toute query @nuxt/content en Phase 6 (listing, surround, article)
```typescript
const { data } = await useAsyncData(
`key-${locale.value}`,
() => isFr.value
? queryCollection('blog_fr').where(...).all()
: queryCollection('blog_en').where(...).all(),
{ watch: [locale] }
)
```
**Interdiction absolue** : `queryCollection(variable)` → retourne `{}` silencieusement (Pitfall 1 RESEARCH).
### Active route detection (AppHeader pattern)
**Source:** `app/components/layout/AppHeader.vue` lines 25-27 + 45-54
**Apply to:** Pas d'usage direct en Phase 6 — mais pattern suivi implicitement par NuxtLink `aria-current` dans BlogCard et BlogPrevNext si besoin.
```typescript
function isActive(path: string): boolean {
return route.path === localePath(path)
}
```
### Card hover effect (design system)
**Source:** `app/components/ProjectCard.vue` line 20
**Apply to:** `app/components/BlogCard.vue` (les deux variants)
```
transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1.5
```
### Nitro plugin structure
**Source:** `server/plugins/rate-limit.ts`
**Apply to:** `server/plugins/reading-time.ts`
```typescript
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('<hook-name>', (ctx) => { /* ... */ })
})
```
### Error handling SSR
**Source:** `app/pages/blog/[slug].vue` lines 15-17
**Apply to:** `app/pages/blog/[slug].vue` (conservé dans enrichment)
```typescript
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
}
```
Pas d'UI custom 404 — `error.vue` layout global du projet prend le relais (UI-SPEC §Error state).
---
## No Analog Found
Les fichiers ci-dessous ont un rôle que le codebase n'a jamais implémenté. Le planner doit utiliser les patterns **RESEARCH.md** directement (déjà cités ci-dessus par référence).
| File | Role | Reason | Source à copier |
|------|------|--------|-----------------|
| `app/composables/useReadingTime.ts` | composable pure compute | Aucun composable "transform" pur existe (`useProjects` = data store) | RESEARCH §Pattern 5 ligne 509-517 |
| `app/utils/countWords.ts` | util transform AST | Dossier `app/utils/` à créer | RESEARCH §Pattern 5 ligne 465-488 |
| `server/plugins/reading-time.ts` (hook content:file:afterParse) | Nitro hook ingestion-time | `rate-limit.ts` utilise le hook `request` (runtime), pas `content:file:afterParse` (build/ingest). Structure `defineNitroPlugin` identique mais hook différent | Analog structurel OK (rate-limit.ts) + RESEARCH §Pattern 5 ligne 453-463 pour le body du hook |
| `app/components/BlogToc.vue` IntersectionObserver | client-side DOM observer | Aucun composant existant n'observe le scroll | RESEARCH §Pattern 4 ligne 393-440 |
---
## Metadata
**Analog search scope:** `app/pages/`, `app/components/`, `app/composables/`, `server/plugins/`, `content.config.ts`, `i18n/locales/`
**Files scanned:** 10+ (projects.vue, ProjectCard.vue, blog/[slug].vue, test.vue, AppHeader.vue, ProseImg.vue, rate-limit.ts, contact.post.ts, content.config.ts, fr.json, en.json)
**Pattern extraction date:** 2026-04-22