---
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"
---
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
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.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
```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)
}
```
```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'
}
```
```
```
```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.
Task 2.1 : Ajouter le bloc complet `blog.*` + `nav.blog` + `a11y.blog*` dans fr.json et en.json
- i18n/locales/fr.json
- i18n/locales/en.json
- 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")
**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.
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)"
- `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`
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.
Task 2.2 : Ajouter le lien Blog dans AppHeader.vue navLinks (entre hytale et projects)app/components/layout/AppHeader.vue
- 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)
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.
grep -c "{ key: 'blog', path: '/blog' }" app/components/layout/AppHeader.vue
- `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)
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).
Task 2.3 : Créer app/components/BlogCard.vue (variants default + compact, D-20)app/components/BlogCard.vue
- 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)
Créer `app/components/BlogCard.vue` avec `
```
**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`)
- `
` (pas `
`) 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
```
**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).
test -f app/components/BlogCard.vue && grep -c "variant === 'default'" app/components/BlogCard.vue
- `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()" 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
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.
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.
- 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