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
@@ -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>