Files
portfolio/.planning/phases/06-blog-pages/06-03-PLAN.md
T
kayjaydee d1ac5f9ee6 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.
2026-04-22 01:09:25 +02:00

18 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
06-blog-pages 03 execute 3
06-01
06-02
app/pages/blog/index.vue
true
BLOG-02
BLOG-06
blog
listing
page
ssr
truths artifacts key_links
`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)
path provides contains contains_also min_lines
app/pages/blog/index.vue Page listing SSR bilingue /blog avec hero + grille + empty state queryCollection('blog_fr') queryCollection('blog_en') 80
from to via pattern
app/pages/blog/index.vue queryCollection('blog_fr') / queryCollection('blog_en') useAsyncData avec branches if/else isFr (littéraux obligatoires) queryCollection('blog_(fr|en)')
from to via pattern
app/pages/blog/index.vue app/components/BlogCard.vue v-for sur articles + <BlogCard :article=... variant='default' /> <BlogCard
from to via pattern
app/pages/blog/index.vue i18n blog.title / blog.subtitle / blog.stats.* / blog.emptyState.* t('blog.title') etc. dans template t('blog.
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).

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 ```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 } ```
// 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 :article="article" variant="default" />
// article: BlogArticle
<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>

blog.title, blog.subtitle, blog.stats.articles, blog.stats.tags, blog.stats.languages, blog.emptyState.title, blog.emptyState.description, blog.emptyState.cta

Task 3.1 : Créer app/pages/blog/index.vue (hero + query bilingue + grille + empty state) app/pages/blog/index.vue - 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) Créer `app/pages/blog/index.vue` (nouveau fichier — le dossier `app/pages/blog/` existe déjà et contient `[slug].vue`).

Script setup complet :

<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) :

<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: ... }). 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 <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> 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.
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.

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