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.
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user