Files
portfolio/.planning/phases/06-blog-pages/06-02-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

609 lines
29 KiB
Markdown

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