docs(06): create phase plan (4 plans, 3 waves)
Phase 6 Blog Pages decomposed into: - 06-01 (Wave 1): content schema + reading-time Nitro hook + draft flags - 06-02 (Wave 2): i18n keys + AppHeader link + BlogCard unified - 06-03 (Wave 3): listing page /blog SSR bilingue - 06-04 (Wave 3): [slug] enrichment + BlogToc + BlogPrevNext Plans 06-03 and 06-04 have zero file overlap and run in parallel. Covers BLOG-02, BLOG-03, BLOG-06. Honors all 21 D-XX user decisions from 06-CONTEXT.md. Phase 5 gotchas (literal queryCollection, single [slug].vue, no routeRules /blog/**) respected in every query branch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+30
-5
@@ -59,7 +59,12 @@ Plans:
|
|||||||
3. `curl localhost:3000/en/` retourne du HTML anglais sans hardcoded French strings
|
3. `curl localhost:3000/en/` retourne du HTML anglais sans hardcoded French strings
|
||||||
4. Aucun composant ne contient de chaine en dur (grep pour strings hors `t()` dans les templates)
|
4. Aucun composant ne contient de chaine en dur (grep pour strings hors `t()` dans les templates)
|
||||||
5. Les traductions FR sonnent naturel — pas de calque anglais
|
5. Les traductions FR sonnent naturel — pas de calque anglais
|
||||||
**Plans**: TBD
|
**Plans:** 4 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
|
||||||
|
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
|
||||||
|
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
|
||||||
|
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
|
||||||
|
|
||||||
### Phase 4: Ship
|
### Phase 4: Ship
|
||||||
**Goal**: Le site est deployable en production via Docker et passe tous les checks
|
**Goal**: Le site est deployable en production via Docker et passe tous les checks
|
||||||
@@ -70,7 +75,12 @@ Plans:
|
|||||||
2. Le container sert le site SSR sur le port attendu
|
2. Le container sert le site SSR sur le port attendu
|
||||||
3. `pnpm typecheck` et `pnpm lint` passent avec 0 erreurs
|
3. `pnpm typecheck` et `pnpm lint` passent avec 0 erreurs
|
||||||
4. `curl` sur chaque page retourne `<title>`, `<meta description>`, `og:title` dans le HTML brut
|
4. `curl` sur chaque page retourne `<title>`, `<meta description>`, `og:title` dans le HTML brut
|
||||||
**Plans**: TBD
|
**Plans:** 4 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
|
||||||
|
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
|
||||||
|
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
|
||||||
|
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -131,7 +141,12 @@ Plans:
|
|||||||
3. La page article affiche une table des matieres generee depuis les headings du markdown
|
3. La page article affiche une table des matieres generee depuis les headings du markdown
|
||||||
4. Des liens "Article precedent" et "Article suivant" sont presents en bas de chaque article
|
4. Des liens "Article precedent" et "Article suivant" sont presents en bas de chaque article
|
||||||
5. `curl localhost:3000/en/blog` retourne le listing en anglais — les articles ont leur version EN
|
5. `curl localhost:3000/en/blog` retourne le listing en anglais — les articles ont leur version EN
|
||||||
**Plans**: TBD
|
**Plans:** 4 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
|
||||||
|
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
|
||||||
|
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
|
||||||
|
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 7: SEO Blog
|
### Phase 7: SEO Blog
|
||||||
@@ -144,7 +159,12 @@ Plans:
|
|||||||
3. `curl localhost:3000/sitemap.xml` contient les URLs `/blog/[slug]` et `/en/blog/[slug]`
|
3. `curl localhost:3000/sitemap.xml` contient les URLs `/blog/[slug]` et `/en/blog/[slug]`
|
||||||
4. `og:image` pointe vers l'image de l'article ou vers un fallback branded — jamais vers og-image.png generique
|
4. `og:image` pointe vers l'image de l'article ou vers un fallback branded — jamais vers og-image.png generique
|
||||||
5. La page article contient un JSON-LD `BreadcrumbList` : Accueil → Blog → Titre de l'article
|
5. La page article contient un JSON-LD `BreadcrumbList` : Accueil → Blog → Titre de l'article
|
||||||
**Plans**: TBD
|
**Plans:** 4 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
|
||||||
|
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
|
||||||
|
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
|
||||||
|
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
|
||||||
|
|
||||||
### Phase 8: Content & Cocon Semantique
|
### Phase 8: Content & Cocon Semantique
|
||||||
**Goal**: Le blog est lance avec au moins 2 articles Hytale de qualite, et un visiteur qui arrive sur /hytale decouvre les articles recents — le cocon semantique entre blog et page hytale est etabli
|
**Goal**: Le blog est lance avec au moins 2 articles Hytale de qualite, et un visiteur qui arrive sur /hytale decouvre les articles recents — le cocon semantique entre blog et page hytale est etabli
|
||||||
@@ -155,7 +175,12 @@ Plans:
|
|||||||
2. Chaque article blog contient au moins un lien interne vers `/hytale` dans le corps du texte
|
2. Chaque article blog contient au moins un lien interne vers `/hytale` dans le corps du texte
|
||||||
3. La page `/hytale` affiche une section "Articles recents" avec liens vers les 2 articles seed
|
3. La page `/hytale` affiche une section "Articles recents" avec liens vers les 2 articles seed
|
||||||
4. Les articles existent en FR et EN — `curl localhost:3000/en/blog` les liste en anglais
|
4. Les articles existent en FR et EN — `curl localhost:3000/en/blog` les liste en anglais
|
||||||
**Plans**: TBD
|
**Plans:** 4 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
|
||||||
|
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
|
||||||
|
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
|
||||||
|
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
---
|
||||||
|
phase: 06-blog-pages
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- content.config.ts
|
||||||
|
- server/plugins/reading-time.ts
|
||||||
|
- app/utils/countWords.ts
|
||||||
|
- app/composables/useReadingTime.ts
|
||||||
|
- content/fr/blog/test-kotlin-syntax.md
|
||||||
|
- content/en/blog/test-kotlin-syntax.md
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- BLOG-02
|
||||||
|
- BLOG-03
|
||||||
|
- BLOG-06
|
||||||
|
tags:
|
||||||
|
- blog
|
||||||
|
- content-schema
|
||||||
|
- reading-time
|
||||||
|
- nitro-plugin
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Le schema Zod de blog_fr et blog_en expose les champs `draft`, `wordCount`, `minutes` aux queries @nuxt/content"
|
||||||
|
- "Tout article markdown parsé par @nuxt/content reçoit automatiquement `minutes` (>= 1) et `wordCount` (>= 0) sur son objet content"
|
||||||
|
- "L'article de test test-kotlin-syntax.md (FR + EN) a `draft: true` dans son frontmatter et sera exclu des queries `.where('draft', '=', false)`"
|
||||||
|
- "Un composable fallback `useReadingTime` permet de dériver un reading time depuis un nombre de mots ou un texte brut si le hook n'a pas encore exécuté"
|
||||||
|
artifacts:
|
||||||
|
- path: "content.config.ts"
|
||||||
|
provides: "Schema blogSchema étendu avec draft/wordCount/minutes"
|
||||||
|
contains: "draft: z.boolean().optional().default(false)"
|
||||||
|
- path: "server/plugins/reading-time.ts"
|
||||||
|
provides: "Nitro plugin hook content:file:afterParse injectant wordCount + minutes"
|
||||||
|
contains: "content:file:afterParse"
|
||||||
|
- path: "app/utils/countWords.ts"
|
||||||
|
provides: "Fonction pure `countWordsInMinimalBody(body)` ignorant code/pre tags"
|
||||||
|
exports: ["countWordsInMinimalBody"]
|
||||||
|
- path: "app/composables/useReadingTime.ts"
|
||||||
|
provides: "Helper client fallback (200 wpm) quand hook indisponible"
|
||||||
|
exports: ["useReadingTime"]
|
||||||
|
- path: "content/fr/blog/test-kotlin-syntax.md"
|
||||||
|
provides: "Article de test marqué draft: true (filtré des listings)"
|
||||||
|
contains: "draft: true"
|
||||||
|
- path: "content/en/blog/test-kotlin-syntax.md"
|
||||||
|
provides: "Version EN marquée draft: true"
|
||||||
|
contains: "draft: true"
|
||||||
|
key_links:
|
||||||
|
- from: "server/plugins/reading-time.ts"
|
||||||
|
to: "app/utils/countWords.ts"
|
||||||
|
via: "import { countWordsInMinimalBody } from '~/utils/countWords'"
|
||||||
|
pattern: "countWordsInMinimalBody"
|
||||||
|
- from: "server/plugins/reading-time.ts"
|
||||||
|
to: "content.config.ts schema"
|
||||||
|
via: "content.wordCount / content.minutes injectés → exposés via Zod optional fields"
|
||||||
|
pattern: "content\\.minutes\\s*="
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Mettre en place la couche données fondation de Phase 6 : étendre le schema Zod des collections blog avec `draft`, `wordCount`, `minutes`, installer un hook Nitro `content:file:afterParse` qui calcule le reading time (200 mots/min) à l'ingestion, et marquer l'article de test `draft: true` pour qu'il soit exclu des listings.
|
||||||
|
|
||||||
|
**Purpose:** Sans ces trois additions, les queries de Wave 3 (`queryCollection('blog_fr').where('draft', '=', false)`) retourneront des données incomplètes ou des champs `undefined`. Cette couche n'a AUCUN consommateur UI dans son wave — elle conditionne tout ce qui suit.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
- `content.config.ts` : schema étendu (draft + wordCount + minutes)
|
||||||
|
- `server/plugins/reading-time.ts` : Nitro hook afterParse
|
||||||
|
- `app/utils/countWords.ts` : traversal AST minimal body ignorant code/pre
|
||||||
|
- `app/composables/useReadingTime.ts` : fallback client (200 wpm)
|
||||||
|
- `content/{fr,en}/blog/test-kotlin-syntax.md` : ajout `draft: true` frontmatter
|
||||||
|
</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/ROADMAP.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/05-nuxt-content-setup-renderer/05-02-SUMMARY.md
|
||||||
|
@content.config.ts
|
||||||
|
@server/plugins/rate-limit.ts
|
||||||
|
@content/fr/blog/test-kotlin-syntax.md
|
||||||
|
@content/en/blog/test-kotlin-syntax.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Schema Zod actuel (content.config.ts) à étendre, pas réécrire -->
|
||||||
|
```typescript
|
||||||
|
const blogSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Collections existantes à préserver intactes -->
|
||||||
|
```typescript
|
||||||
|
blog_fr: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'fr/blog/**/*.md', prefix: '/fr/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
blog_en: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'en/blog/**/*.md', prefix: '/en/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Pattern Nitro plugin (server/plugins/rate-limit.ts) — hook 'request' différent mais structure identique -->
|
||||||
|
```typescript
|
||||||
|
export default defineNitroPlugin((nitro) => {
|
||||||
|
nitro.hooks.hook('request', (event) => { /* ... */ })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Body shape @nuxt/content v3 type 'minimal' (cité RESEARCH §Pattern 5) -->
|
||||||
|
```
|
||||||
|
body = { type: 'minimal', value: MinimalNode[] }
|
||||||
|
MinimalNode = string | [tag: string, attrs: object, ...children: MinimalNode[]]
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="false">
|
||||||
|
<name>Task 1.1 : Étendre le schema Zod de content.config.ts (draft + wordCount + minutes)</name>
|
||||||
|
<files>content.config.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- content.config.ts (état actuel — ne JAMAIS réécrire les collections, uniquement étendre le schema)
|
||||||
|
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 5 lignes 491-506 pour le schema cible + §Pitfall 5 lignes 619-623 pour pourquoi `optional()` est critique)
|
||||||
|
- .planning/phases/06-blog-pages/06-PATTERNS.md (§`content.config.ts` lignes 398-428 pour les additions exactes)
|
||||||
|
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-18 : `draft: z.boolean().optional().default(false)` — pas de `.default(true)`, pas de non-optional)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Ouvrir `content.config.ts` et étendre UNIQUEMENT le `blogSchema` (lignes 3-9 actuelles). Ne pas toucher aux `defineCollection` calls (blog_fr, blog_en) — ils référencent `blogSchema` par variable, donc l'extension se propage automatiquement.
|
||||||
|
|
||||||
|
Ajouter 3 champs après `image: z.string().optional(),` dans cet ordre exact :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
draft: z.boolean().optional().default(false),
|
||||||
|
wordCount: z.number().optional(),
|
||||||
|
minutes: z.number().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi `optional()` sur wordCount/minutes (pas `.default()`) :** ces champs sont injectés par le hook Nitro au parse, pas écrits par l'auteur. Les rendre obligatoires casserait l'ingestion. `.optional()` sans `.default()` garantit qu'ils sont strippés-safe mais autorise le hook à les poser (per D-18 + Pitfall 5 RESEARCH : "Les propriétés injectées par hook DOIVENT être déclarées dans le schema Zod pour être visibles via queryCollection").
|
||||||
|
|
||||||
|
**Pourquoi `.default(false)` sur draft :** la query `.where('draft', '=', false)` doit matcher les articles dont le frontmatter n'a PAS écrit `draft: false` explicitement (la plupart des articles réels). Sans `.default(false)`, le champ serait `undefined` et le where() les exclurait à tort.
|
||||||
|
|
||||||
|
Fichier final attendu (25 lignes) :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
|
||||||
|
|
||||||
|
const blogSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
draft: z.boolean().optional().default(false),
|
||||||
|
wordCount: z.number().optional(),
|
||||||
|
minutes: z.number().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineContentConfig({
|
||||||
|
collections: {
|
||||||
|
blog_fr: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'fr/blog/**/*.md', prefix: '/fr/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
blog_en: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'en/blog/**/*.md', prefix: '/en/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Après la modification, supprimer le cache @nuxt/content pour forcer la régénération de la DB SQLite au prochain dev : `rm -rf node_modules/.cache/content .nuxt` (pattern documenté RESEARCH §Runtime State Inventory).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c "draft: z.boolean().optional().default(false)" content.config.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c "draft: z.boolean().optional().default(false)" content.config.ts` retourne 1
|
||||||
|
- `grep -c "wordCount: z.number().optional()" content.config.ts` retourne 1
|
||||||
|
- `grep -c "minutes: z.number().optional()" content.config.ts` retourne 1
|
||||||
|
- `grep -c "blog_fr: defineCollection" content.config.ts` retourne 1 (collection préservée)
|
||||||
|
- `grep -c "blog_en: defineCollection" content.config.ts` retourne 1 (collection préservée)
|
||||||
|
- `grep "prefix: '/fr/blog'" content.config.ts` retourne 1+ match (source config intacte)
|
||||||
|
- `pnpm typecheck` passe sans nouvelle erreur TS liée à content.config.ts
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>
|
||||||
|
Schema Zod étendu avec draft (optional default false) + wordCount (optional) + minutes (optional). Collections blog_fr et blog_en inchangées mais pointent vers le nouveau schema. Cache Nuxt content supprimé pour forcer la régénération DB.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="false">
|
||||||
|
<name>Task 1.2 : Créer app/utils/countWords.ts (pure AST traversal ignorant code/pre)</name>
|
||||||
|
<files>app/utils/countWords.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 5 lignes 465-488 pour la fonction `countWordsInMinimalBody` de référence)
|
||||||
|
- .planning/phases/06-blog-pages/06-PATTERNS.md (§`app/utils/countWords.ts` lignes 361-365 qui confirme qu'il n'existe PAS encore de dossier utils et qu'il faut copier RESEARCH)
|
||||||
|
- Lister `ls app/` pour confirmer que le dossier `app/utils/` n'existe pas encore et sera créé par cette tâche
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Créer le dossier `app/utils/` (n'existe pas encore) et le fichier `app/utils/countWords.ts` exportant une fonction pure qui traverse un AST `@nuxt/content` v3 `minimal body` (shape `{ type: 'minimal', value: MinimalNode[] }` où `MinimalNode = string | [tag, attrs, ...children]`) et retourne le nombre de mots en ignorant les tags `code` et `pre` (les snippets de code ne comptent PAS dans le reading time lisible).
|
||||||
|
|
||||||
|
Contenu exact du fichier :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Count words in a @nuxt/content v3 "minimal" body AST.
|
||||||
|
* Ignores code and pre tags (code snippets are not "readable" for reading-time purposes).
|
||||||
|
*
|
||||||
|
* Body shape (v3): { type: 'minimal', value: MinimalNode[] }
|
||||||
|
* MinimalNode = string | [tag: string, attrs: object, ...children: MinimalNode[]]
|
||||||
|
*
|
||||||
|
* Used by server/plugins/reading-time.ts at content:file:afterParse.
|
||||||
|
*/
|
||||||
|
export function countWordsInMinimalBody(body: unknown): number {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
const visit = (node: unknown): void => {
|
||||||
|
if (typeof node === 'string') {
|
||||||
|
const trimmed = node.trim()
|
||||||
|
if (trimmed) count += trimmed.split(/\s+/).length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
const tag = node[0]
|
||||||
|
// Skip code/pre — not counted as reading content
|
||||||
|
if (tag === 'code' || tag === 'pre') return
|
||||||
|
// children start at index 2 (index 0 = tag, index 1 = attrs)
|
||||||
|
for (let i = 2; i < node.length; i++) visit(node[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body_ = body as { type?: string; value?: unknown[] } | undefined
|
||||||
|
if (body_?.value && Array.isArray(body_.value)) {
|
||||||
|
for (const node of body_.value) visit(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi `unknown` plutôt que type strict :** le type `MinimalNode` n'est pas exporté publiquement par @nuxt/content v3. Narrower via `Array.isArray` + typeof check reste type-safe et évite un type import qui pourrait casser à une mise à jour mineure.
|
||||||
|
|
||||||
|
**Pourquoi ignorer code/pre :** un bloc de code de 500 mots techniques ne se lit pas au même rythme que de la prose. Convention standard de reading-time (Medium, Dev.to) : exclure les snippets.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f app/utils/countWords.ts && grep -c "export function countWordsInMinimalBody" app/utils/countWords.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `test -f app/utils/countWords.ts` retourne 0 (fichier existe)
|
||||||
|
- `grep -c "export function countWordsInMinimalBody" app/utils/countWords.ts` retourne 1
|
||||||
|
- `grep "if (tag === 'code' || tag === 'pre') return" app/utils/countWords.ts` retourne 1 match (code/pre ignorés)
|
||||||
|
- `grep "split(/\\\\s+/)" app/utils/countWords.ts` retourne 1 match (split whitespace)
|
||||||
|
- `pnpm typecheck` passe sans nouvelle erreur liée à app/utils/countWords.ts
|
||||||
|
- Le fichier n'importe RIEN (`grep -c "^import" app/utils/countWords.ts` retourne 0)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>
|
||||||
|
Fonction pure `countWordsInMinimalBody(body: unknown): number` exportée, traverse récursivement le minimal body, ignore les tags `code` et `pre`, retourne un nombre >= 0. Zero dépendance, zero import.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="false">
|
||||||
|
<name>Task 1.3 : Créer server/plugins/reading-time.ts (hook content:file:afterParse)</name>
|
||||||
|
<files>server/plugins/reading-time.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- server/plugins/rate-limit.ts (structure `defineNitroPlugin` + `hooks.hook` — convention du projet)
|
||||||
|
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 5 lignes 453-463 pour le hook body exact + §Pitfall 5 lignes 619-623 pour le lien avec le schema)
|
||||||
|
- .planning/phases/06-blog-pages/06-PATTERNS.md (§`server/plugins/reading-time.ts` lignes 367-394)
|
||||||
|
- app/utils/countWords.ts (créé par Task 1.2 — à importer ici)
|
||||||
|
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-19 : 200 mots/min, formule `Math.ceil(wordCount / 200)`, minimum 1)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Créer le fichier `server/plugins/reading-time.ts` qui :
|
||||||
|
1. Utilise `defineNitroPlugin` (auto-imported par Nitro — PAS besoin de l'importer)
|
||||||
|
2. Enregistre un hook `content:file:afterParse` sur `nitroApp.hooks`
|
||||||
|
3. Skip les fichiers dont `file.id` ne finit pas par `.md` (protection cheap)
|
||||||
|
4. Calcule `wordCount` via `countWordsInMinimalBody(content.body)`
|
||||||
|
5. Injecte `content.wordCount = wordCount` et `content.minutes = Math.max(1, Math.ceil(wordCount / 200))` (D-19 : 200 wpm, floor à 1 minute)
|
||||||
|
|
||||||
|
Contenu exact du fichier :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { countWordsInMinimalBody } from '~/utils/countWords'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nitro plugin: compute reading time for every markdown content file at parse time.
|
||||||
|
*
|
||||||
|
* Injects `wordCount` (number) and `minutes` (number, min 1) on the content object.
|
||||||
|
* Values are persisted in the @nuxt/content SQLite DB and queryable via queryCollection
|
||||||
|
* thanks to the matching Zod schema fields in content.config.ts (per D-18 + D-19).
|
||||||
|
*
|
||||||
|
* Hook reference: https://content.nuxt.com/docs/advanced/hooks
|
||||||
|
*/
|
||||||
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
|
nitroApp.hooks.hook('content:file:afterParse', (ctx) => {
|
||||||
|
const { file, content } = ctx
|
||||||
|
|
||||||
|
// Only process markdown files (defensive — hook fires on all sources)
|
||||||
|
if (!file.id?.endsWith('.md')) return
|
||||||
|
|
||||||
|
const wordCount = countWordsInMinimalBody(content.body)
|
||||||
|
content.wordCount = wordCount
|
||||||
|
content.minutes = Math.max(1, Math.ceil(wordCount / 200)) // D-19: 200 wpm, floor 1 min
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi `~/utils/countWords` et pas relative path :** Nitro résout `~/` vers `app/` dans un plugin server (confirmé par Nuxt 4 layer config par défaut). Aligné avec les conventions des composables `~/composables/*`. Si typecheck échoue à cause d'un alias manquant, fallback : import depuis `~~/app/utils/countWords`.
|
||||||
|
|
||||||
|
**Pourquoi `nitroApp` (pas `nitro`) :** convention Nuxt Content docs officielle pour ce hook (RESEARCH §Pattern 5). `rate-limit.ts` utilise `nitro` pour le hook `request` différent — les deux fonctionnent, mais on colle à la convention de la doc du hook consommé.
|
||||||
|
|
||||||
|
**Comportement attendu au démarrage dev :**
|
||||||
|
- À `pnpm dev` après cette tâche : les 2 articles test-kotlin-syntax.md (FR + EN) traversent le hook, reçoivent `wordCount` + `minutes` injectés
|
||||||
|
- Query `queryCollection('blog_fr').all()` retourne chaque article avec `minutes: number` visible
|
||||||
|
- Si la DB est stale (avant suppression `.nuxt/cache`), forcer `rm -rf node_modules/.cache/content .nuxt` puis relancer
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f server/plugins/reading-time.ts && grep -c "content:file:afterParse" server/plugins/reading-time.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `test -f server/plugins/reading-time.ts` retourne 0
|
||||||
|
- `grep -c "defineNitroPlugin" server/plugins/reading-time.ts` retourne 1
|
||||||
|
- `grep -c "content:file:afterParse" server/plugins/reading-time.ts` retourne 1
|
||||||
|
- `grep -c "countWordsInMinimalBody" server/plugins/reading-time.ts` retourne 2 (import + call)
|
||||||
|
- `grep "Math.max(1, Math.ceil(wordCount / 200))" server/plugins/reading-time.ts` retourne 1 match
|
||||||
|
- `grep "file.id?.endsWith('.md')" server/plugins/reading-time.ts` retourne 1 match
|
||||||
|
- `pnpm typecheck` passe sans erreur
|
||||||
|
- Après `rm -rf node_modules/.cache/content .nuxt && pnpm dev`, les logs Nitro ne montrent AUCUNE erreur hook content (vérifier manuellement le demarrage dev)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>
|
||||||
|
Nitro plugin créé, importe countWordsInMinimalBody depuis app/utils, enregistre hook content:file:afterParse, injecte wordCount + minutes (floor 1) sur chaque content object .md. Typecheck vert.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="false">
|
||||||
|
<name>Task 1.4 : Créer app/composables/useReadingTime.ts (fallback client 200 wpm)</name>
|
||||||
|
<files>app/composables/useReadingTime.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 5 lignes 509-517 pour le composable exact)
|
||||||
|
- .planning/phases/06-blog-pages/06-PATTERNS.md (§`app/composables/useReadingTime.ts` lignes 344-357 qui confirme "aucun analog" et source RESEARCH)
|
||||||
|
- app/composables/ (lister le dossier pour voir les conventions existantes — `useProjects.ts` par ex.)
|
||||||
|
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-19 : source of truth = hook Nitro, composable = fallback uniquement)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Créer `app/composables/useReadingTime.ts` qui exporte une fonction pure (pas une composable réactive — la convention `use*` est conservée pour l'auto-import Nuxt mais elle ne retourne pas de refs). Accepte soit un `number` (nombre de mots déjà compté) soit une `string` (texte brut à compter), retourne un nombre de minutes >= 1 avec 200 wpm.
|
||||||
|
|
||||||
|
Contenu exact :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Fallback reading-time helper when `article.minutes` is not available
|
||||||
|
* (e.g., dev hot-reload before the Nitro hook has re-parsed).
|
||||||
|
*
|
||||||
|
* Source of truth = server/plugins/reading-time.ts + content.config.ts schema.
|
||||||
|
* This is only a client-side safety net (per D-19).
|
||||||
|
*
|
||||||
|
* @param wordCountOrText number (word count already computed) OR string (raw text to tokenize)
|
||||||
|
* @returns minutes (>= 1), rounded up, using 200 words per minute
|
||||||
|
*/
|
||||||
|
export function useReadingTime(wordCountOrText: number | string): number {
|
||||||
|
if (typeof wordCountOrText === 'number') {
|
||||||
|
return Math.max(1, Math.ceil(wordCountOrText / 200))
|
||||||
|
}
|
||||||
|
const count = wordCountOrText.trim().split(/\s+/).filter(Boolean).length
|
||||||
|
return Math.max(1, Math.ceil(count / 200))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi pas de `ref` / `computed` :** ce helper est appelé inline dans un template (`{{ article.minutes ?? useReadingTime(article.description) }}`) — un calcul synchrone pur suffit. Si plus tard on veut une version réactive, on pourra wrapper dans un `computed` au site d'appel.
|
||||||
|
|
||||||
|
**Pourquoi 200 wpm :** D-19 (CONTEXT.md) fige cette valeur. Standard industrie. Même formule que le hook Nitro — cohérence listing ↔ article garantie.
|
||||||
|
|
||||||
|
**Usage prévu (Wave 2+3) :**
|
||||||
|
```vue
|
||||||
|
<!-- BlogCard.vue template -->
|
||||||
|
<span>{{ t('blog.readingTime', { minutes: article.minutes ?? useReadingTime(article.description ?? '') }) }}</span>
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f app/composables/useReadingTime.ts && grep -c "export function useReadingTime" app/composables/useReadingTime.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `test -f app/composables/useReadingTime.ts` retourne 0
|
||||||
|
- `grep -c "export function useReadingTime" app/composables/useReadingTime.ts` retourne 1
|
||||||
|
- `grep "Math.max(1, Math.ceil" app/composables/useReadingTime.ts` retourne 2 matches (branche number + branche string)
|
||||||
|
- `grep "split(/\\\\s+/).filter(Boolean)" app/composables/useReadingTime.ts` retourne 1 match
|
||||||
|
- `grep -c "wordCountOrText: number | string" app/composables/useReadingTime.ts` retourne 1
|
||||||
|
- `pnpm typecheck` passe sans erreur
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>
|
||||||
|
Composable `useReadingTime(numberOrString)` exporté, retourne un number >= 1 basé sur 200 wpm. Fonction pure synchrone, pas de refs. Auto-importée par Nuxt (convention `use*`).
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="false">
|
||||||
|
<name>Task 1.5 : Marquer les articles test-kotlin-syntax.md (FR + EN) comme `draft: true`</name>
|
||||||
|
<files>
|
||||||
|
- content/fr/blog/test-kotlin-syntax.md
|
||||||
|
- content/en/blog/test-kotlin-syntax.md
|
||||||
|
</files>
|
||||||
|
<read_first>
|
||||||
|
- content/fr/blog/test-kotlin-syntax.md (frontmatter actuel : title/description/date/tags — PAS de draft)
|
||||||
|
- content/en/blog/test-kotlin-syntax.md (idem, version EN)
|
||||||
|
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-14 : draft: true sur TEST article pour qu'il soit exclu du listing mais reste accessible URL directe)
|
||||||
|
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pitfall 7 lignes 631-635 : confirme que le listing sera vide tant qu'aucun article non-draft n'existe — comportement attendu de l'empty state)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Ajouter `draft: true` dans le frontmatter YAML des deux fichiers `test-kotlin-syntax.md` (FR + EN). Le frontmatter actuel contient `title`, `description`, `date`, `tags` — ajouter `draft` sur une nouvelle ligne après `tags`, avant le `---` fermant.
|
||||||
|
|
||||||
|
Pour `content/fr/blog/test-kotlin-syntax.md`, frontmatter cible :
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: "Guide du format Markdown"
|
||||||
|
description: "Référence complète de tous les éléments et composants disponibles dans les articles"
|
||||||
|
date: "2026-04-21"
|
||||||
|
tags: ["guide", "markdown", "mdc"]
|
||||||
|
draft: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour `content/en/blog/test-kotlin-syntax.md`, frontmatter cible :
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: "Markdown Format Guide"
|
||||||
|
description: "Complete reference of all elements and components available in articles"
|
||||||
|
date: "2026-04-21"
|
||||||
|
tags: ["guide", "markdown", "mdc"]
|
||||||
|
draft: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ne PAS modifier le corps markdown** des deux fichiers — uniquement le frontmatter. Le titre + tags + date doivent rester inchangés.
|
||||||
|
|
||||||
|
**Conséquence attendue (D-14 + Pitfall 7) :**
|
||||||
|
- `queryCollection('blog_fr').where('draft', '=', false).all()` retourne `[]` (tous les articles sont draft)
|
||||||
|
- La page `/fr/blog` affichera l'empty state "Bientôt des articles Hytale" (comportement correct, voulu par le planning — les 2 articles seed Hytale viendront en Phase 8)
|
||||||
|
- URL directe `/fr/blog/test-kotlin-syntax` fonctionne toujours (pas de filtre draft sur la requête `.path(path).first()` — validation en Wave 3)
|
||||||
|
|
||||||
|
**Attention frontmatter YAML :** `draft: true` (boolean YAML). Pas `draft: "true"` (string). Sinon le schema Zod `z.boolean()` rejettera au parse.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c "^draft: true$" content/fr/blog/test-kotlin-syntax.md content/en/blog/test-kotlin-syntax.md</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c "^draft: true$" content/fr/blog/test-kotlin-syntax.md` retourne 1
|
||||||
|
- `grep -c "^draft: true$" content/en/blog/test-kotlin-syntax.md` retourne 1
|
||||||
|
- `grep -c "^title:" content/fr/blog/test-kotlin-syntax.md` retourne 1 (frontmatter original préservé)
|
||||||
|
- `grep -c "^title:" content/en/blog/test-kotlin-syntax.md` retourne 1 (frontmatter original préservé)
|
||||||
|
- `grep -c "draft:" content/fr/blog/test-kotlin-syntax.md` retourne exactement 1 (pas de doublon)
|
||||||
|
- `grep -c "draft:" content/en/blog/test-kotlin-syntax.md` retourne exactement 1
|
||||||
|
- Le corps markdown (après le `---` fermant) est intact — `wc -l` avant et après doit être identique + 1 ligne chacun
|
||||||
|
- `pnpm dev` démarre sans erreur de schema Zod au parse (i.e., `draft: true` est bien interprété comme boolean, pas string)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>
|
||||||
|
Les deux articles de test ont `draft: true` dans leur frontmatter. Le corps markdown et les autres champs frontmatter sont préservés. Les articles seront exclus de toutes les queries `.where('draft', '=', false)` de Wave 2 et 3.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. Lancer `pnpm typecheck` — passe sans nouvelle erreur TypeScript
|
||||||
|
2. Lancer `rm -rf node_modules/.cache/content .nuxt && pnpm dev` — le serveur démarre sans erreur de schema Zod ni erreur de hook Nitro
|
||||||
|
3. Vérifier dans la console `pnpm dev` qu'aucun warning Zod-parse n'apparaît pour `test-kotlin-syntax.md` (FR + EN)
|
||||||
|
4. Tester manuellement en dev (optionnel sanity check) : `curl http://localhost:3000/fr/blog/test-kotlin-syntax` retourne 200 + HTML (article reste accessible URL directe malgré draft: true — normal, aucune query `.where('draft')` sur cette route en l'état)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- content.config.ts contient les 3 nouveaux champs Zod (draft default false, wordCount optional, minutes optional)
|
||||||
|
- server/plugins/reading-time.ts enregistre le hook content:file:afterParse et appelle countWordsInMinimalBody
|
||||||
|
- app/utils/countWords.ts exporte une fonction pure qui ignore code/pre
|
||||||
|
- app/composables/useReadingTime.ts exporte un helper 200 wpm (fallback client)
|
||||||
|
- content/{fr,en}/blog/test-kotlin-syntax.md ont `draft: true` dans leur frontmatter
|
||||||
|
- pnpm typecheck passe, pnpm dev démarre clean
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/06-blog-pages/06-01-SUMMARY.md` using the summary template. Include:
|
||||||
|
- Schema Zod diff (before/after)
|
||||||
|
- Hook behavior verified (warn / no warn)
|
||||||
|
- wordCount observé sur test-kotlin-syntax.md au parse (valeur approximative)
|
||||||
|
- Any deviation from the plan
|
||||||
|
</output>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
---
|
||||||
|
phase: 06-blog-pages
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on:
|
||||||
|
- 06-01
|
||||||
|
- 06-02
|
||||||
|
files_modified:
|
||||||
|
- app/pages/blog/index.vue
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- BLOG-02
|
||||||
|
- BLOG-06
|
||||||
|
tags:
|
||||||
|
- blog
|
||||||
|
- listing
|
||||||
|
- page
|
||||||
|
- ssr
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "`curl localhost:3000/fr/blog` retourne du HTML SSR avec un bloc hero (slogan `// blog`, H1 Blog, subtitle, stats) et SOIT une grille de BlogCard SOIT un empty state"
|
||||||
|
- "`curl localhost:3000/en/blog` retourne la même structure avec les textes en anglais"
|
||||||
|
- "La query utilise `queryCollection('blog_fr')` et `queryCollection('blog_en')` en littéraux séparés par branche if/else (Phase 5 gotcha respecté)"
|
||||||
|
- "La query filtre `.where('draft', '=', false)` — les articles test-kotlin-syntax (draft: true après Wave 1) sont exclus, ce qui fait apparaître l'empty state à ce stade du projet (comportement voulu Pitfall 7)"
|
||||||
|
- "La query ordonne `.order('date', 'DESC')` — article le plus récent en premier (D-12)"
|
||||||
|
- "Le switch de langue (FR → EN) recharge bien la liste via `{ watch: [locale] }` dans useAsyncData"
|
||||||
|
- "Stats affichés : nombre d'articles non-draft, nombre de tags uniques, valeur fixe `2` pour languages (FR+EN)"
|
||||||
|
- "Empty state affiche UIcon book-open + titre `Bientôt des articles Hytale` / `Hytale articles coming soon` + UButton CTA → `/contact` (via localePath)"
|
||||||
|
artifacts:
|
||||||
|
- path: "app/pages/blog/index.vue"
|
||||||
|
provides: "Page listing SSR bilingue /blog avec hero + grille + empty state"
|
||||||
|
contains: "queryCollection('blog_fr')"
|
||||||
|
contains_also: "queryCollection('blog_en')"
|
||||||
|
min_lines: 80
|
||||||
|
key_links:
|
||||||
|
- from: "app/pages/blog/index.vue"
|
||||||
|
to: "queryCollection('blog_fr') / queryCollection('blog_en')"
|
||||||
|
via: "useAsyncData avec branches if/else isFr (littéraux obligatoires)"
|
||||||
|
pattern: "queryCollection\\('blog_(fr|en)'\\)"
|
||||||
|
- from: "app/pages/blog/index.vue"
|
||||||
|
to: "app/components/BlogCard.vue"
|
||||||
|
via: "v-for sur articles + <BlogCard :article=... variant='default' />"
|
||||||
|
pattern: "<BlogCard"
|
||||||
|
- from: "app/pages/blog/index.vue"
|
||||||
|
to: "i18n blog.title / blog.subtitle / blog.stats.* / blog.emptyState.*"
|
||||||
|
via: "t('blog.title') etc. dans template"
|
||||||
|
pattern: "t\\('blog\\."
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Créer `app/pages/blog/index.vue` — la page listing blog SSR bilingue. Hero (pattern /projects), grille responsive 1/2/3 cols de BlogCard, empty state avec CTA contact. Query bilingue avec branches littérales, filtre draft, order date DESC, watch locale.
|
||||||
|
|
||||||
|
**Purpose:** Cette page satisfait directement les success criteria 1 + 5 de la phase (listing SSR + version EN). Elle consomme les artefacts de Wave 1 (schema étendu avec draft) et Wave 2 (BlogCard + i18n + localePath).
|
||||||
|
|
||||||
|
**Output:** `app/pages/blog/index.vue` (nouveau fichier, n'existe PAS actuellement).
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/06-blog-pages/06-CONTEXT.md
|
||||||
|
@.planning/phases/06-blog-pages/06-RESEARCH.md
|
||||||
|
@.planning/phases/06-blog-pages/06-PATTERNS.md
|
||||||
|
@.planning/phases/06-blog-pages/06-UI-SPEC.md
|
||||||
|
@app/pages/projects.vue
|
||||||
|
@app/pages/blog/[slug].vue
|
||||||
|
@app/pages/test.vue
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Article shape après Wave 1 (schema étendu) -->
|
||||||
|
```typescript
|
||||||
|
interface BlogArticle {
|
||||||
|
path: string // '/fr/blog/my-slug' ou '/en/blog/my-slug'
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
date: string
|
||||||
|
tags?: string[]
|
||||||
|
image?: string
|
||||||
|
draft?: boolean // via Wave 1 (filtré par .where)
|
||||||
|
wordCount?: number // via Wave 1 hook
|
||||||
|
minutes?: number // via Wave 1 hook — consommé par BlogCard
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Pattern query @nuxt/content v3 OBLIGATOIRE (littéraux) -->
|
||||||
|
```typescript
|
||||||
|
// CORRECT — branches if/else littérales
|
||||||
|
const { data } = await useAsyncData(
|
||||||
|
`blog-list-${locale.value}`,
|
||||||
|
() => isFr.value
|
||||||
|
? queryCollection('blog_fr').where('draft', '=', false).order('date', 'DESC').all()
|
||||||
|
: queryCollection('blog_en').where('draft', '=', false).order('date', 'DESC').all(),
|
||||||
|
{ watch: [locale] }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ❌ INCORRECT — retourne {} silencieusement (Pitfall 1)
|
||||||
|
const col = isFr.value ? 'blog_fr' : 'blog_en'
|
||||||
|
const { data } = await useAsyncData(() => queryCollection(col).all())
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- BlogCard créé Wave 2 (props) -->
|
||||||
|
```typescript
|
||||||
|
<BlogCard :article="article" variant="default" />
|
||||||
|
// article: BlogArticle
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Hero pattern app/pages/projects.vue lignes 56-83 à copier -->
|
||||||
|
```
|
||||||
|
<section class="relative pt-20 pb-16 px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||||
|
<!-- 2 absolute background blurs -->
|
||||||
|
<div class="relative z-10 max-w-7xl mx-auto text-center">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// blog</span>
|
||||||
|
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold mt-3 mb-5 bg-gradient-to-r ...">{{ t('blog.title') }}</h1>
|
||||||
|
<p class="text-lg sm:text-xl ...">{{ t('blog.subtitle') }}</p>
|
||||||
|
<!-- Stats row avec 2 dividers verticaux -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- i18n keys disponibles après Wave 2 -->
|
||||||
|
blog.title, blog.subtitle, blog.stats.articles, blog.stats.tags, blog.stats.languages,
|
||||||
|
blog.emptyState.title, blog.emptyState.description, blog.emptyState.cta
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="false">
|
||||||
|
<name>Task 3.1 : Créer app/pages/blog/index.vue (hero + query bilingue + grille + empty state)</name>
|
||||||
|
<files>app/pages/blog/index.vue</files>
|
||||||
|
<read_first>
|
||||||
|
- app/pages/projects.vue (ENTIER — source du hero pattern, stats, grid, empty state)
|
||||||
|
- app/pages/blog/[slug].vue (pattern existant de query bilingue Phase 5 — branches isFr à reproduire dans le listing)
|
||||||
|
- app/pages/test.vue (autre exemple de queryCollection littéral)
|
||||||
|
- app/components/BlogCard.vue (créé Wave 2 Task 2.3 — interface props pour le v-for)
|
||||||
|
- .planning/phases/06-blog-pages/06-UI-SPEC.md (§Hero section lignes 255-278 pour le contract hero + §Empty state lignes 143-152 pour le copywriting + §Layout lignes 295-305)
|
||||||
|
- .planning/phases/06-blog-pages/06-PATTERNS.md (§app/pages/blog/index.vue lignes 25-104 pour le code skeleton complet)
|
||||||
|
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Code Examples lignes 641-709 pour le skeleton vérifié + §Pattern 1 pour les littéraux + §Pitfall 3 pour le watch locale)
|
||||||
|
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-01 grille 1/2/3 cols, D-04 hero pattern, D-16 empty state, D-17 URLs)
|
||||||
|
- i18n/locales/fr.json (pour confirmer que blog.stats.articles/tags/languages, blog.emptyState.*, blog.title/subtitle existent — ajoutés Wave 2)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Créer `app/pages/blog/index.vue` (nouveau fichier — le dossier `app/pages/blog/` existe déjà et contient `[slug].vue`).
|
||||||
|
|
||||||
|
**Script setup complet :**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const isFr = computed(() => locale.value === 'fr')
|
||||||
|
|
||||||
|
// Query bilingue avec branches littérales (Phase 5 gotcha — Pattern 1 RESEARCH)
|
||||||
|
// { watch: [locale] } pour re-fetch au switch FR/EN (Pitfall 3)
|
||||||
|
const { data: articles } = await useAsyncData(
|
||||||
|
`blog-list-${locale.value}`,
|
||||||
|
() =>
|
||||||
|
isFr.value
|
||||||
|
? queryCollection('blog_fr')
|
||||||
|
.where('draft', '=', false)
|
||||||
|
.order('date', 'DESC')
|
||||||
|
.all()
|
||||||
|
: queryCollection('blog_en')
|
||||||
|
.where('draft', '=', false)
|
||||||
|
.order('date', 'DESC')
|
||||||
|
.all(),
|
||||||
|
{ watch: [locale] },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stats computed (UI-SPEC §Hero contract exact — 3 items)
|
||||||
|
const totalArticles = computed(() => articles.value?.length ?? 0)
|
||||||
|
|
||||||
|
const uniqueTags = computed(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const a of articles.value ?? []) {
|
||||||
|
for (const tag of a.tags ?? []) set.add(tag)
|
||||||
|
}
|
||||||
|
return set.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalLanguages = 2 // FR + EN — valeur fixe (UI-SPEC)
|
||||||
|
|
||||||
|
// SEO minimal Phase 6 — Phase 7 enrichira avec JSON-LD + og:image par article
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('blog.title'),
|
||||||
|
description: () => t('blog.subtitle'),
|
||||||
|
ogTitle: () => t('blog.title'),
|
||||||
|
ogDescription: () => t('blog.subtitle'),
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Template complet** (transposition directe de `/projects.vue` avec substitution des clés i18n) :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Hero (pattern /projects.vue lignes 56-83) -->
|
||||||
|
<section class="relative pt-20 pb-16 px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||||
|
<div class="absolute inset-0 bg-gray-50/80 dark:bg-gray-900/40" aria-hidden="true" />
|
||||||
|
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-brand-500/5 dark:bg-brand-500/8 rounded-full blur-3xl pointer-events-none" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-7xl mx-auto text-center">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// blog</span>
|
||||||
|
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold mt-3 mb-5 bg-gradient-to-r from-gray-900 via-gray-800 to-gray-600 dark:from-white dark:via-gray-200 dark:to-gray-500 bg-clip-text text-transparent">
|
||||||
|
{{ t('blog.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg sm:text-xl text-gray-500 dark:text-gray-400 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{{ t('blog.subtitle') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Stats: articles / tags / languages (3 items + 2 dividers) -->
|
||||||
|
<div class="flex justify-center gap-8 sm:gap-12 mt-12">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">
|
||||||
|
{{ totalArticles }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">
|
||||||
|
{{ t('blog.stats.articles') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-px bg-gradient-to-b from-transparent via-gray-300 dark:via-gray-700 to-transparent" />
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">
|
||||||
|
{{ uniqueTags }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">
|
||||||
|
{{ t('blog.stats.tags') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-px bg-gradient-to-b from-transparent via-gray-300 dark:via-gray-700 to-transparent" />
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">
|
||||||
|
{{ totalLanguages }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">
|
||||||
|
{{ t('blog.stats.languages') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Grid or Empty state -->
|
||||||
|
<section class="py-16 md:py-20 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<!-- Grille responsive 1/2/3 cols (D-01) -->
|
||||||
|
<div
|
||||||
|
v-if="articles && articles.length > 0"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 lg:gap-6"
|
||||||
|
>
|
||||||
|
<BlogCard
|
||||||
|
v-for="article in articles"
|
||||||
|
:key="article.path"
|
||||||
|
:article="article"
|
||||||
|
variant="default"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state (D-16 + UI-SPEC §Empty state copywriting) -->
|
||||||
|
<div v-else class="text-center py-32">
|
||||||
|
<div class="w-16 h-16 mx-auto mb-6 rounded-2xl bg-gray-100 dark:bg-gray-800/60 border border-gray-200/80 dark:border-gray-700/30 flex items-center justify-center">
|
||||||
|
<UIcon name="i-lucide-book-open" class="text-2xl text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||||
|
{{ t('blog.emptyState.title') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto leading-relaxed">
|
||||||
|
{{ t('blog.emptyState.description') }}
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
size="md"
|
||||||
|
icon="i-lucide-mail"
|
||||||
|
:to="localePath('/contact')"
|
||||||
|
>
|
||||||
|
{{ t('blog.emptyState.cta') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points critiques à respecter :**
|
||||||
|
|
||||||
|
1. **Littéraux `'blog_fr'` / `'blog_en'`** dans deux branches séparées — JAMAIS `queryCollection(col)` avec variable. Reproduit fidèlement le pattern `app/pages/blog/[slug].vue` existant.
|
||||||
|
2. **`{ watch: [locale] }`** sur le useAsyncData — sans ça, switch FR/EN affiche l'ancienne langue (Pitfall 3).
|
||||||
|
3. **Key `blog-list-${locale.value}`** — inclut la locale pour invalider le cache correctement.
|
||||||
|
4. **`computed(() => locale.value === 'fr')`** (pas `const isFr = locale.value === 'fr'`) — sinon pas de réactivité sur le switch.
|
||||||
|
5. **`articles.value?.length ?? 0`** avec optional chaining — articles peut être `null` durant l'initial fetch avant l'arrivée du SSR payload.
|
||||||
|
6. **Empty state apparaîtra à ce stade du projet** — tous les articles ont `draft: true` (Wave 1 Task 1.5 + Pitfall 7). C'est le comportement voulu : le blog se prépare, l'empty state est professionnel et CTA contact. Phase 8 ajoutera les vrais articles seed.
|
||||||
|
7. **SEO minimal** : pas d'ogImage custom, pas de canonical, pas de JSON-LD — Phase 7 traitera ça (hors scope 06).
|
||||||
|
8. **Pas de routeRules** : ne PAS ajouter de `routeRules: { '/blog': { ... } }` dans nuxt.config — la redirection FR/EN sans préfixe passe par `detectBrowserLanguage` (Phase 5 gotcha, ne pas toucher).
|
||||||
|
9. **Pas de layout personnalisé** : la page utilise le layout par défaut (header + footer globaux). Ne pas définir `definePageMeta({ layout: ... })`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f app/pages/blog/index.vue && grep -c "queryCollection('blog_fr')" app/pages/blog/index.vue && grep -c "queryCollection('blog_en')" app/pages/blog/index.vue</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `test -f app/pages/blog/index.vue` retourne 0
|
||||||
|
- `grep -c "queryCollection('blog_fr')" app/pages/blog/index.vue` retourne au moins 1
|
||||||
|
- `grep -c "queryCollection('blog_en')" app/pages/blog/index.vue` retourne au moins 1
|
||||||
|
- `grep "queryCollection(locale" app/pages/blog/index.vue` retourne rien (aucune variable dans queryCollection — littéraux uniquement)
|
||||||
|
- `grep "queryCollection(col" app/pages/blog/index.vue` retourne rien
|
||||||
|
- `grep -c "\\.where('draft', '=', false)" app/pages/blog/index.vue` retourne 2 (une par branche)
|
||||||
|
- `grep -c "\\.order('date', 'DESC')" app/pages/blog/index.vue` retourne 2
|
||||||
|
- `grep -c "watch: \\[locale\\]" app/pages/blog/index.vue` retourne 1
|
||||||
|
- `grep -c "useAsyncData" app/pages/blog/index.vue` retourne 1
|
||||||
|
- `grep "<BlogCard" app/pages/blog/index.vue` retourne 1+ match
|
||||||
|
- `grep -c "variant=\"default\"" app/pages/blog/index.vue` retourne 1
|
||||||
|
- `grep -c "v-for=\"article in articles\"" app/pages/blog/index.vue` retourne 1
|
||||||
|
- `grep -c ":key=\"article.path\"" app/pages/blog/index.vue` retourne 1
|
||||||
|
- `grep -c "t('blog.title')" app/pages/blog/index.vue` retourne 2+ matches (hero H1 + useSeoMeta)
|
||||||
|
- `grep -c "t('blog.subtitle')" app/pages/blog/index.vue` retourne 2+ matches
|
||||||
|
- `grep -c "t('blog.stats.articles')" app/pages/blog/index.vue` retourne 1
|
||||||
|
- `grep -c "t('blog.stats.tags')" app/pages/blog/index.vue` retourne 1
|
||||||
|
- `grep -c "t('blog.stats.languages')" app/pages/blog/index.vue` retourne 1
|
||||||
|
- `grep -c "t('blog.emptyState.title')" app/pages/blog/index.vue` retourne 1
|
||||||
|
- `grep -c "t('blog.emptyState.cta')" app/pages/blog/index.vue` retourne 1
|
||||||
|
- `grep "i-lucide-book-open" app/pages/blog/index.vue` retourne 1 match
|
||||||
|
- `grep "localePath('/contact')" app/pages/blog/index.vue` retourne 1 match
|
||||||
|
- `grep "// blog" app/pages/blog/index.vue` retourne 1 match (slogan mono)
|
||||||
|
- `grep "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" app/pages/blog/index.vue` retourne 1 match (D-01 grille responsive)
|
||||||
|
- `pnpm typecheck` passe sans erreur
|
||||||
|
- `pnpm lint` passe sans nouvelle erreur
|
||||||
|
- `pnpm build` complète sans erreur (validation SSR — le build fait le prerender et casse si queryCollection mal formé)
|
||||||
|
- Tests runtime manuels (dans un shell avec `pnpm dev` lancé) :
|
||||||
|
- `curl -s http://localhost:3000/fr/blog` retourne un 200 avec `<h1>` contenant `Blog` et `// blog` dans le HTML
|
||||||
|
- `curl -s http://localhost:3000/en/blog` retourne un 200 avec `Hytale articles coming soon` ou `Blog` en H1
|
||||||
|
- Les tests curl montrent le HTML de l'empty state (pas de grille) — normal, tous les articles sont draft à ce stade
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>
|
||||||
|
app/pages/blog/index.vue créé. Query bilingue avec littéraux obligatoires respectés (Phase 5 gotcha). `.where('draft','=',false)` + `.order('date','DESC')` + `{ watch: [locale] }`. Hero pattern projets transposé. Grille responsive 1/2/3 cols. Empty state avec UIcon book-open + UButton CTA contact. SEO minimal via useSeoMeta. Typecheck + lint + build verts. Les routes /fr/blog et /en/blog répondent en SSR.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `pnpm typecheck` passe
|
||||||
|
2. `pnpm lint` passe
|
||||||
|
3. `pnpm build` complète (validation SSR prerender inclus)
|
||||||
|
4. `pnpm dev` + curl HTML :
|
||||||
|
- `curl -s http://localhost:3000/fr/blog | grep -c "// blog"` >= 1
|
||||||
|
- `curl -s http://localhost:3000/fr/blog | grep -c "Blog"` >= 1 (H1)
|
||||||
|
- `curl -s http://localhost:3000/fr/blog | grep -ci "Bientôt des articles Hytale"` >= 1 (empty state car tous les articles sont draft:true)
|
||||||
|
- `curl -s http://localhost:3000/en/blog | grep -ci "Hytale articles coming soon"` >= 1
|
||||||
|
5. Switch de langue via le toggle AppHeader FR↔EN : le contenu change (empty state FR → EN et inversement). Pas de flash de contenu stale.
|
||||||
|
6. Navigation depuis le lien `Blog` de AppHeader (ajouté Wave 2) va bien vers `/fr/blog` ou `/en/blog` selon locale.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Page `app/pages/blog/index.vue` créée, 80+ lignes
|
||||||
|
- Hero section SSR avec slogan `// blog` + H1 + subtitle + 3 stats
|
||||||
|
- Grille conditionnelle (v-if articles.length > 0) avec BlogCard v-for variant=default
|
||||||
|
- Empty state (v-else) avec UIcon + UButton vers /contact
|
||||||
|
- Query @nuxt/content bilingue avec littéraux, .where('draft','=',false), .order('date','DESC'), { watch: [locale] }
|
||||||
|
- `curl /fr/blog` et `curl /en/blog` retournent HTML SSR avec les bons textes traduits
|
||||||
|
- Success criteria 1 et 5 de la phase validés à la livraison
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/06-blog-pages/06-03-SUMMARY.md` with:
|
||||||
|
- Commandes curl exécutées et extraits HTML (preuve SSR)
|
||||||
|
- Comportement empty state vérifié (FR + EN)
|
||||||
|
- Switch locale : délai de re-fetch constaté
|
||||||
|
- Any deviation (ex: ajustements Tailwind fins, valeurs stats edge cases)
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,787 @@
|
|||||||
|
---
|
||||||
|
phase: 06-blog-pages
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on:
|
||||||
|
- 06-01
|
||||||
|
- 06-02
|
||||||
|
files_modified:
|
||||||
|
- app/pages/blog/[slug].vue
|
||||||
|
- app/components/BlogToc.vue
|
||||||
|
- app/components/BlogPrevNext.vue
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- BLOG-03
|
||||||
|
- BLOG-06
|
||||||
|
tags:
|
||||||
|
- blog
|
||||||
|
- article-chrome
|
||||||
|
- toc
|
||||||
|
- prev-next
|
||||||
|
- intersection-observer
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "`curl localhost:3000/fr/blog/test-kotlin-syntax` retourne HTML SSR avec (ordre vertical) : UBreadcrumb (Accueil → Blog → titre) → H1 du titre → ligne meta (date formatée + · + reading time) → tags UBadge (si présents) → cover image NuxtImg (si frontmatter.image) → body markdown dans `.prose` → BlogPrevNext (en bas)"
|
||||||
|
- "`curl localhost:3000/en/blog/test-kotlin-syntax` retourne la même structure avec textes EN (breadcrumb Home → Blog → title)"
|
||||||
|
- "La page article filtre le `draft` dans la query de SURROUND (prev/next) — les articles draft:true ne viennent pas peupler la navigation. La query `.path(path).first()` n'a PAS le filtre (URL directe accessible pour les tests, D-14)"
|
||||||
|
- "Sur desktop (>= lg), une `<aside>` sticky à droite contient la table des matières avec les headings h2/h3 de `page.body.toc.links`"
|
||||||
|
- "Sur mobile (< lg), un UButton `i-lucide-list` dans la meta row ouvre un UDrawer side='right' contenant la même TOC"
|
||||||
|
- "Le heading actif (premier visible dans la zone 20%-30% du viewport) est surligné `text-brand-500` via IntersectionObserver client-only (onMounted)"
|
||||||
|
- "BlogPrevNext.vue rend 2 BlogCard variant='compact' avec direction='prev' et 'next'. Si un voisin est null, la cellule reste vide (alignement grid préservé, D-13)"
|
||||||
|
- "Prev (article plus ancien) = `surround[1]`, Next (article plus récent) = `surround[0]` car order DESC retourne l'élément before la position courante dans l'ordre de la collection (Pitfall 4)"
|
||||||
|
- "`queryCollectionItemSurroundings` utilise les littéraux 'blog_fr'/'blog_en' avec if/else (Phase 5 gotcha)"
|
||||||
|
artifacts:
|
||||||
|
- path: "app/pages/blog/[slug].vue"
|
||||||
|
provides: "Page article enrichie : breadcrumb + header + TOC layout + surround + prev/next"
|
||||||
|
contains: "queryCollectionItemSurroundings"
|
||||||
|
contains_also: "UBreadcrumb"
|
||||||
|
min_lines: 120
|
||||||
|
- path: "app/components/BlogToc.vue"
|
||||||
|
provides: "TOC sticky desktop + UDrawer mobile + IntersectionObserver highlight"
|
||||||
|
contains: "IntersectionObserver"
|
||||||
|
contains_also: "UDrawer"
|
||||||
|
- path: "app/components/BlogPrevNext.vue"
|
||||||
|
provides: "Grid 2 cols de BlogCard variant compact (prev + next)"
|
||||||
|
contains: "variant=\"compact\""
|
||||||
|
key_links:
|
||||||
|
- from: "app/pages/blog/[slug].vue"
|
||||||
|
to: "queryCollectionItemSurroundings('blog_fr'|'blog_en', path, ...)"
|
||||||
|
via: "useAsyncData secondaire avec littéraux if/else + watch locale"
|
||||||
|
pattern: "queryCollectionItemSurroundings"
|
||||||
|
- from: "app/pages/blog/[slug].vue"
|
||||||
|
to: "app/components/BlogToc.vue"
|
||||||
|
via: "<BlogToc :links=\"page.body.toc.links\" ... />"
|
||||||
|
pattern: "<BlogToc"
|
||||||
|
- from: "app/pages/blog/[slug].vue"
|
||||||
|
to: "app/components/BlogPrevNext.vue"
|
||||||
|
via: "<BlogPrevNext :prev :next />"
|
||||||
|
pattern: "<BlogPrevNext"
|
||||||
|
- from: "app/components/BlogToc.vue"
|
||||||
|
to: "DOM headings h2/h3 rendus par ContentRenderer"
|
||||||
|
via: "IntersectionObserver sur document.getElementById(link.id) dans onMounted"
|
||||||
|
pattern: "IntersectionObserver"
|
||||||
|
- from: "app/components/BlogPrevNext.vue"
|
||||||
|
to: "app/components/BlogCard.vue"
|
||||||
|
via: "<BlogCard variant=\"compact\" direction=\"prev\"|\"next\" />"
|
||||||
|
pattern: "<BlogCard"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Terminer la phase en enrichissant la page article `/blog/[slug]` avec le chrome complet (breadcrumb, header riche, TOC sticky + drawer mobile, prev/next cards). Créer 2 composants : `BlogToc.vue` (sticky desktop + UDrawer mobile + IntersectionObserver) et `BlogPrevNext.vue` (grid 2 cols de BlogCard compact).
|
||||||
|
|
||||||
|
**Purpose:** Cette page satisfait les success criteria 2, 3, 4 de la phase (rendu SSR article, TOC visible, prev/next visibles). Elle consomme tous les artefacts précédents (schema Wave 1, BlogCard + i18n + nav Wave 2) et corrige le `isFr` non-réactif de Phase 5.
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
- `app/components/BlogToc.vue` (nouveau)
|
||||||
|
- `app/components/BlogPrevNext.vue` (nouveau)
|
||||||
|
- `app/pages/blog/[slug].vue` (modification substantielle de l'existant Phase 5)
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/06-blog-pages/06-CONTEXT.md
|
||||||
|
@.planning/phases/06-blog-pages/06-RESEARCH.md
|
||||||
|
@.planning/phases/06-blog-pages/06-PATTERNS.md
|
||||||
|
@.planning/phases/06-blog-pages/06-UI-SPEC.md
|
||||||
|
@app/pages/blog/[slug].vue
|
||||||
|
@app/components/layout/AppHeader.vue
|
||||||
|
@app/components/ProjectCard.vue
|
||||||
|
@app/components/content/ProseImg.vue
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Shape page.body.toc après ContentRenderer (RESEARCH §Pattern 3) -->
|
||||||
|
```typescript
|
||||||
|
interface TocLink {
|
||||||
|
id: string // anchor id auto-généré (kebab-case du heading text)
|
||||||
|
depth: number // 2 = h2, 3 = h3, etc.
|
||||||
|
text: string
|
||||||
|
children?: TocLink[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageBody {
|
||||||
|
toc?: {
|
||||||
|
title: string
|
||||||
|
searchDepth: number
|
||||||
|
depth: number
|
||||||
|
links: TocLink[]
|
||||||
|
}
|
||||||
|
// ... autre champs (type minimal, value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Shape queryCollectionItemSurroundings return -->
|
||||||
|
```typescript
|
||||||
|
// Signature
|
||||||
|
function queryCollectionItemSurroundings(
|
||||||
|
collection: 'blog_fr' | 'blog_en',
|
||||||
|
path: string,
|
||||||
|
opts?: { before?: number, after?: number, fields?: string[] }
|
||||||
|
): ChainablePromise // chain .where().order()
|
||||||
|
|
||||||
|
// Return: array de 2 éléments [before, after]
|
||||||
|
// En .order('date', 'DESC') : before = plus récent, after = plus ancien
|
||||||
|
// PITFALL 4 : UI "précédent" (plus ancien) = surround[1], UI "suivant" (plus récent) = surround[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- BlogCard variant compact (créé Wave 2) -->
|
||||||
|
```vue
|
||||||
|
<BlogCard
|
||||||
|
:article="prevArticle"
|
||||||
|
variant="compact"
|
||||||
|
direction="prev"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Pattern UDrawer Nuxt UI v3 -->
|
||||||
|
```vue
|
||||||
|
<UDrawer v-model:open="tocDrawerOpen" side="right">
|
||||||
|
<template #header>...</template>
|
||||||
|
<template #body>...</template>
|
||||||
|
</UDrawer>
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- UBreadcrumb Nuxt UI v3 items shape -->
|
||||||
|
```typescript
|
||||||
|
items: Array<{ label: string, to?: string, icon?: string }>
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- État actuel app/pages/blog/[slug].vue (Phase 5 — minimal) -->
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
const isFr = locale.value === 'fr' // ❌ NON-RÉACTIF — à convertir en computed
|
||||||
|
const path = isFr ? `/fr/blog/${slug}` : `/en/blog/${slug}`
|
||||||
|
// ... useAsyncData sans { watch: [locale] }
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-3xl px-4 py-12">
|
||||||
|
<article class="prose dark:prose-invert max-w-none">
|
||||||
|
<ContentRenderer v-if="page" :value="page" />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- IntersectionObserver pattern (RESEARCH §Pattern 4 lignes 392-442) -->
|
||||||
|
```typescript
|
||||||
|
// rootMargin imposé UI-SPEC
|
||||||
|
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 }
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="false">
|
||||||
|
<name>Task 4.1 : Créer app/components/BlogToc.vue (sticky desktop + UDrawer mobile + IntersectionObserver)</name>
|
||||||
|
<files>app/components/BlogToc.vue</files>
|
||||||
|
<read_first>
|
||||||
|
- app/components/layout/AppHeader.vue (pattern USlideover v-model:open="mobileOpen" lignes 6 + 80-114 — UDrawer suit le même pattern d'API)
|
||||||
|
- app/components/content/ProseImg.vue (pattern defineProps<Props> + withDefaults typé lignes 1-38)
|
||||||
|
- .planning/phases/06-blog-pages/06-UI-SPEC.md (§BlogToc contract lignes 232-253 pour desktop + mobile + IntersectionObserver — valeurs rootMargin/threshold imposées)
|
||||||
|
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 3 pour la shape TocLink + §Pattern 4 lignes 392-442 pour l'IntersectionObserver COMPLET + §Pitfall 2 hydration pour initial state)
|
||||||
|
- .planning/phases/06-blog-pages/06-PATTERNS.md (§BlogToc.vue lignes 256-310)
|
||||||
|
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-05 sticky desktop + drawer mobile, D-06 highlight IntersectionObserver client-only)
|
||||||
|
- i18n/locales/fr.json / en.json (confirmer blog.toc.title et a11y.blogTocToggle existent après Wave 2)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Créer `app/components/BlogToc.vue`. Le composant reçoit `links: TocLink[]` via props, gère :
|
||||||
|
- Affichage desktop : `<aside>` sticky top-24 w-64 (hidden sur < lg)
|
||||||
|
- Affichage mobile : UButton trigger `i-lucide-list` + UDrawer side='right' (hidden sur >= lg)
|
||||||
|
- Highlight : IntersectionObserver dans `onMounted`, cleanup dans `onBeforeUnmount`
|
||||||
|
- `activeId = ref<string | null>(null)` initial (Pitfall 2 hydration mismatch)
|
||||||
|
|
||||||
|
**Fichier complet :**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface TocLink {
|
||||||
|
id: string
|
||||||
|
depth: number
|
||||||
|
text: string
|
||||||
|
children?: TocLink[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
links: TocLink[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const activeId = ref<string | null>(null)
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
// Aplatir la TOC (inclure les children h3 sous h2)
|
||||||
|
const flatIds = computed(() => {
|
||||||
|
const ids: string[] = []
|
||||||
|
const collect = (nodes: TocLink[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
ids.push(node.id)
|
||||||
|
if (node.children?.length) collect(node.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collect(props.links)
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
// Setup initial activeId au premier heading pour cohérence visuelle post-hydration
|
||||||
|
activeId.value = flatIds.value[0] ?? null
|
||||||
|
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
// Prendre le premier heading visible dans la zone active (du plus haut au plus bas)
|
||||||
|
const visible = entries
|
||||||
|
.filter((e) => e.isIntersecting)
|
||||||
|
.sort((a, b) => a.target.getBoundingClientRect().top - b.target.getBoundingClientRect().top)
|
||||||
|
if (visible.length > 0) {
|
||||||
|
activeId.value = visible[0]!.target.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const id of flatIds.value) {
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
if (el) observer.observe(el)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
observer = null
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleItemClick() {
|
||||||
|
// Fermer le drawer mobile après clic sur un lien
|
||||||
|
drawerOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Desktop: aside sticky (hidden sur mobile) -->
|
||||||
|
<aside class="hidden lg:block sticky top-24 w-64 self-start">
|
||||||
|
<p class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
{{ t('blog.toc.title') }}
|
||||||
|
</p>
|
||||||
|
<ol class="space-y-2 text-sm">
|
||||||
|
<li v-for="link in links" :key="link.id">
|
||||||
|
<a
|
||||||
|
:href="`#${link.id}`"
|
||||||
|
:class="[
|
||||||
|
activeId === link.id
|
||||||
|
? 'text-brand-500 dark:text-brand-400 font-medium'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
|
||||||
|
'block transition-colors',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ link.text }}
|
||||||
|
</a>
|
||||||
|
<ol v-if="link.children?.length" class="mt-1 ml-4 space-y-1">
|
||||||
|
<li v-for="child in link.children" :key="child.id">
|
||||||
|
<a
|
||||||
|
:href="`#${child.id}`"
|
||||||
|
:class="[
|
||||||
|
activeId === child.id
|
||||||
|
? 'text-brand-500 dark:text-brand-400 font-medium'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
|
||||||
|
'block transition-colors',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ child.text }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Mobile: UButton trigger + UDrawer side='right' (hidden sur desktop) -->
|
||||||
|
<div class="lg:hidden inline-block">
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-list"
|
||||||
|
:aria-label="t('a11y.blogTocToggle')"
|
||||||
|
@click="drawerOpen = true"
|
||||||
|
>
|
||||||
|
{{ t('blog.toc.title') }}
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UDrawer v-model:open="drawerOpen" direction="right">
|
||||||
|
<template #header>
|
||||||
|
<p class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('blog.toc.title') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<ol class="space-y-3 text-sm p-4">
|
||||||
|
<li v-for="link in links" :key="link.id">
|
||||||
|
<a
|
||||||
|
:href="`#${link.id}`"
|
||||||
|
:class="[
|
||||||
|
activeId === link.id
|
||||||
|
? 'text-brand-500 dark:text-brand-400 font-medium'
|
||||||
|
: 'text-gray-600 dark:text-gray-300',
|
||||||
|
'block transition-colors',
|
||||||
|
]"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
{{ link.text }}
|
||||||
|
</a>
|
||||||
|
<ol v-if="link.children?.length" class="mt-2 ml-4 space-y-2">
|
||||||
|
<li v-for="child in link.children" :key="child.id">
|
||||||
|
<a
|
||||||
|
:href="`#${child.id}`"
|
||||||
|
:class="[
|
||||||
|
activeId === child.id
|
||||||
|
? 'text-brand-500 dark:text-brand-400 font-medium'
|
||||||
|
: 'text-gray-500 dark:text-gray-400',
|
||||||
|
'block transition-colors',
|
||||||
|
]"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
{{ child.text }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</template>
|
||||||
|
</UDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points critiques :**
|
||||||
|
|
||||||
|
1. **Nuxt UI v3 UDrawer prop name** : `direction` (pas `side` dans certaines versions — vérifier à l'exécution ; si erreur, replace `direction` par `side`). Le projet utilise USlideover avec `side="left"` dans AppHeader — UDrawer v3 utilise `direction`. À valider au build.
|
||||||
|
2. **`activeId = ref(null)` initial** (pas le premier heading en synchrone) — Pitfall 2. On le set à `flatIds[0]` dans `onMounted` après que le DOM soit prêt.
|
||||||
|
3. **`onBeforeUnmount` cleanup** — critique pour éviter memory leak au navigate entre articles (anti-pattern RESEARCH).
|
||||||
|
4. **`handleItemClick` ferme le drawer mobile** — UX standard quand on clique sur un lien d'ancre dans un drawer.
|
||||||
|
5. **Pas de `useState`** pour activeId — ref local (anti-pattern RESEARCH ligne 563).
|
||||||
|
6. **TOC nested rendue à 2 niveaux max** (h2 + h3 children) — hiérarchie imposée par UI-SPEC §BlogToc contract. Les h4+ ne sont pas affichés.
|
||||||
|
7. **Accent color uniquement sur actif** — UI-SPEC §Color §Accent §6 : `text-brand-500 dark:text-brand-400`. Tout le reste est gris neutre.
|
||||||
|
8. **Client-only garantit par `typeof window === 'undefined' return`** — défensif même si onMounted ne s'exécute que côté client.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f app/components/BlogToc.vue && grep -c "IntersectionObserver" app/components/BlogToc.vue && grep -c "UDrawer" app/components/BlogToc.vue</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `test -f app/components/BlogToc.vue` retourne 0
|
||||||
|
- `grep -c "interface TocLink" app/components/BlogToc.vue` retourne 1
|
||||||
|
- `grep -c "IntersectionObserver" app/components/BlogToc.vue` retourne 2 (type + new)
|
||||||
|
- `grep -c "UDrawer" app/components/BlogToc.vue` retourne 1+ match
|
||||||
|
- `grep "rootMargin: '-20% 0px -70% 0px'" app/components/BlogToc.vue` retourne 1 match
|
||||||
|
- `grep "threshold: 0" app/components/BlogToc.vue` retourne 1 match
|
||||||
|
- `grep -c "onMounted" app/components/BlogToc.vue` retourne 1
|
||||||
|
- `grep -c "onBeforeUnmount" app/components/BlogToc.vue` retourne 1
|
||||||
|
- `grep "observer?.disconnect()" app/components/BlogToc.vue` retourne 1 match (cleanup)
|
||||||
|
- `grep "activeId = ref" app/components/BlogToc.vue` retourne 1 match
|
||||||
|
- `grep "hidden lg:block sticky top-24" app/components/BlogToc.vue` retourne 1 match (desktop aside)
|
||||||
|
- `grep "lg:hidden" app/components/BlogToc.vue` retourne 1+ match (mobile wrapper)
|
||||||
|
- `grep "text-brand-500 dark:text-brand-400" app/components/BlogToc.vue` retourne 2+ matches (active state desktop + mobile)
|
||||||
|
- `grep -c "t('blog.toc.title')" app/components/BlogToc.vue` retourne 2+ matches (desktop header + mobile header/button)
|
||||||
|
- `grep -c "t('a11y.blogTocToggle')" app/components/BlogToc.vue` retourne 1
|
||||||
|
- `grep "useState" app/components/BlogToc.vue` retourne rien (anti-pattern évité)
|
||||||
|
- `pnpm typecheck` passe
|
||||||
|
- `pnpm lint` passe
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>
|
||||||
|
BlogToc.vue créé. Desktop : `<aside>` sticky top-24 avec liste nested h2/h3, highlight brand-500 sur actif. Mobile : UButton trigger + UDrawer direction='right' avec même contenu. IntersectionObserver avec rootMargin/threshold UI-SPEC dans onMounted, cleanup onBeforeUnmount. activeId ref local (pas useState). Accepte `links: TocLink[]` via props.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="false">
|
||||||
|
<name>Task 4.2 : Créer app/components/BlogPrevNext.vue (grid 2 cols de BlogCard compact)</name>
|
||||||
|
<files>app/components/BlogPrevNext.vue</files>
|
||||||
|
<read_first>
|
||||||
|
- app/components/BlogCard.vue (créé Wave 2 Task 2.3 — confirmer le contrat : variant="compact" + direction="prev"|"next")
|
||||||
|
- .planning/phases/06-blog-pages/06-UI-SPEC.md (§BlogCard variant contract compact lignes 222-230 + §Interaction Contract lignes 321 pour le hover)
|
||||||
|
- .planning/phases/06-blog-pages/06-PATTERNS.md (§BlogPrevNext.vue lignes 313-340 pour le composition pattern)
|
||||||
|
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-09 style cards + D-10 pas d'image + D-13 case vide si absent)
|
||||||
|
- i18n/locales/fr.json / en.json (a11y.blogPrev et a11y.blogNext avec interpolation {title} existent après Wave 2)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Créer `app/components/BlogPrevNext.vue`. Wrapper `<nav>` avec grid 2 cols md, affiche 2 BlogCard variant=compact. Si un voisin est null, cellule vide préservée pour alignement (D-13).
|
||||||
|
|
||||||
|
**Fichier complet :**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface SurroundArticle {
|
||||||
|
path: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
date: string
|
||||||
|
tags?: string[]
|
||||||
|
image?: string
|
||||||
|
minutes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prev: SurroundArticle | null
|
||||||
|
next: SurroundArticle | null
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav
|
||||||
|
v-if="prev || next"
|
||||||
|
class="mt-16 grid md:grid-cols-2 gap-5"
|
||||||
|
:aria-label="t('blog.prevArticle') + ' / ' + t('blog.nextArticle')"
|
||||||
|
>
|
||||||
|
<!-- Prev (article plus ancien dans order DESC) -->
|
||||||
|
<div v-if="prev">
|
||||||
|
<BlogCard :article="prev" variant="compact" direction="prev" />
|
||||||
|
</div>
|
||||||
|
<div v-else aria-hidden="true" />
|
||||||
|
|
||||||
|
<!-- Next (article plus récent dans order DESC) -->
|
||||||
|
<div v-if="next">
|
||||||
|
<BlogCard :article="next" variant="compact" direction="next" />
|
||||||
|
</div>
|
||||||
|
<div v-else aria-hidden="true" />
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points critiques :**
|
||||||
|
|
||||||
|
1. **D-13 : cellule vide préservée** — `<div v-else aria-hidden="true" />` maintient la grille à 2 colonnes même si un seul voisin existe. `aria-hidden` évite que les screen readers annoncent un div vide.
|
||||||
|
2. **Pas de rendu du `<nav>` si les deux sont null** — `v-if="prev || next"` garde le DOM propre quand l'article est isolé (ex: premier + seul article, edge case rare).
|
||||||
|
3. **BlogCard se charge du rendu visuel** — pas de classe hover ici, BlogCard gère son propre hover (principe DRY).
|
||||||
|
4. **Pas d'interpolation `{title}` directe dans `aria-label`** — BlogCard a déjà son propre `aria-label` interpolé via `a11y.blogPrev` / `a11y.blogNext`. Le `<nav>` wrapper a un label plus générique pour éviter la redondance.
|
||||||
|
5. **Auto-import Nuxt** : BlogCard est dans `app/components/` donc auto-importé sans ligne `import`.
|
||||||
|
6. **Type `SurroundArticle`** : sous-ensemble de BlogArticle car `queryCollectionItemSurroundings` retourne uniquement les `fields` demandés (path, title, description, date, image, minutes). Déclaré localement pour ne pas créer un shared-types file dans cette phase.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f app/components/BlogPrevNext.vue && grep -c "<BlogCard" app/components/BlogPrevNext.vue</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `test -f app/components/BlogPrevNext.vue` retourne 0
|
||||||
|
- `grep -c "<BlogCard" app/components/BlogPrevNext.vue` retourne 2 (prev + next)
|
||||||
|
- `grep "variant=\"compact\"" app/components/BlogPrevNext.vue` retourne 2+ matches
|
||||||
|
- `grep "direction=\"prev\"" app/components/BlogPrevNext.vue` retourne 1 match
|
||||||
|
- `grep "direction=\"next\"" app/components/BlogPrevNext.vue` retourne 1 match
|
||||||
|
- `grep -c "prev: SurroundArticle | null" app/components/BlogPrevNext.vue` retourne 1
|
||||||
|
- `grep -c "next: SurroundArticle | null" app/components/BlogPrevNext.vue` retourne 1
|
||||||
|
- `grep "v-else aria-hidden=\"true\"" app/components/BlogPrevNext.vue` retourne 2 matches (D-13 empty cells)
|
||||||
|
- `grep "grid md:grid-cols-2 gap-5" app/components/BlogPrevNext.vue` retourne 1 match
|
||||||
|
- `grep "mt-16" app/components/BlogPrevNext.vue` retourne 1 match (spacing avant prev/next)
|
||||||
|
- `pnpm typecheck` passe
|
||||||
|
- `pnpm lint` passe
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>
|
||||||
|
BlogPrevNext.vue créé. Wrapper `<nav>` conditionnel si au moins un voisin. Grid 2 cols md. 2 BlogCard variant=compact avec direction prev/next. Cellules vides préservées pour alignement (D-13).
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="false">
|
||||||
|
<name>Task 4.3 : Enrichir app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround + prev/next)</name>
|
||||||
|
<files>app/pages/blog/[slug].vue</files>
|
||||||
|
<read_first>
|
||||||
|
- app/pages/blog/[slug].vue (état actuel Phase 5 : 34 lignes, query + prose wrapper — à enrichir, pas réécrire entièrement la logique)
|
||||||
|
- app/components/BlogToc.vue (créé Task 4.1 — interface TocLink props)
|
||||||
|
- app/components/BlogPrevNext.vue (créé Task 4.2 — interface Props prev/next)
|
||||||
|
- app/components/BlogCard.vue (créé Wave 2 — utilisé indirectement via BlogPrevNext)
|
||||||
|
- .planning/phases/06-blog-pages/06-UI-SPEC.md (§Article header contract lignes 280-291 pour l'ordre vertical + §Layout responsive article lignes 294-305 pour la grille desktop)
|
||||||
|
- .planning/phases/06-blog-pages/06-RESEARCH.md (§Code Examples page article lignes 711-830 pour le skeleton complet + §Pitfall 3 watch locale + §Pitfall 4 surround mapping)
|
||||||
|
- .planning/phases/06-blog-pages/06-PATTERNS.md (§app/pages/blog/[slug].vue lignes 107-148 pour les patterns et gotchas)
|
||||||
|
- .planning/phases/06-blog-pages/06-CONTEXT.md (D-05 TOC layout, D-07 header complet, D-08 max-w-3xl prose, D-11 surround helper, D-12 order DESC, D-13 edges)
|
||||||
|
- i18n/locales/fr.json (confirmer blog.breadcrumb.home/blog, blog.readingTime, a11y.blogTocToggle existent)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Réécrire substantiellement `app/pages/blog/[slug].vue` pour passer du minimal (Phase 5) au chrome complet (Phase 6). Garder le squelette de query `queryCollection('blog_fr').path(path).first()` mais :
|
||||||
|
1. Convertir `isFr` en `computed` (réactivité switch locale — Pitfall 3 corrigé)
|
||||||
|
2. Ajouter `{ watch: [locale] }` sur useAsyncData
|
||||||
|
3. Ajouter une 2e useAsyncData pour `queryCollectionItemSurroundings` avec fields explicites + where draft + order date DESC
|
||||||
|
4. Construire `breadcrumbItems` computed (Accueil/Home + Blog + titre article)
|
||||||
|
5. Construire `formattedDate` computed avec `Intl.DateTimeFormat`
|
||||||
|
6. Mapper `prevArticle = surround[1]` et `nextArticle = surround[0]` (Pitfall 4)
|
||||||
|
7. Restructurer le template : UBreadcrumb + H1 + meta row + tags + cover image + grid layout (article + TOC aside) + BlogPrevNext
|
||||||
|
|
||||||
|
**Fichier complet :**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const route = useRoute()
|
||||||
|
const isFr = computed(() => locale.value === 'fr')
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
const path = computed(() => (isFr.value ? `/fr/blog/${slug}` : `/en/blog/${slug}`))
|
||||||
|
|
||||||
|
// 1) Article principal (PAS de filtre draft : URL directe accessible même si draft — D-14)
|
||||||
|
const { data: page } = await useAsyncData(
|
||||||
|
`blog-${locale.value}-${slug}`,
|
||||||
|
() =>
|
||||||
|
isFr.value
|
||||||
|
? queryCollection('blog_fr').path(path.value).first()
|
||||||
|
: queryCollection('blog_en').path(path.value).first(),
|
||||||
|
{ watch: [locale] },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!page.value) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Surroundings (prev/next) AVEC filtre draft + order DESC
|
||||||
|
const { data: surround } = await useAsyncData(
|
||||||
|
`blog-surround-${locale.value}-${slug}`,
|
||||||
|
() =>
|
||||||
|
isFr.value
|
||||||
|
? queryCollectionItemSurroundings('blog_fr', path.value, {
|
||||||
|
fields: ['title', 'description', 'date', 'image', 'path', 'minutes'],
|
||||||
|
})
|
||||||
|
.where('draft', '=', false)
|
||||||
|
.order('date', 'DESC')
|
||||||
|
: queryCollectionItemSurroundings('blog_en', path.value, {
|
||||||
|
fields: ['title', 'description', 'date', 'image', 'path', 'minutes'],
|
||||||
|
})
|
||||||
|
.where('draft', '=', false)
|
||||||
|
.order('date', 'DESC'),
|
||||||
|
{ watch: [locale] },
|
||||||
|
)
|
||||||
|
|
||||||
|
// D-12 : order DESC → surround[0] = plus récent (next UI), surround[1] = plus ancien (prev UI) — Pitfall 4
|
||||||
|
const nextArticle = computed(() => surround.value?.[0] ?? null)
|
||||||
|
const prevArticle = computed(() => surround.value?.[1] ?? null)
|
||||||
|
|
||||||
|
// Breadcrumb (D-07 Accueil → Blog → Titre)
|
||||||
|
const breadcrumbItems = computed(() => [
|
||||||
|
{ label: t('blog.breadcrumb.home'), to: localePath('/'), icon: 'i-lucide-home' },
|
||||||
|
{ label: t('blog.breadcrumb.blog'), to: localePath('/blog') },
|
||||||
|
{ label: page.value?.title ?? '' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Date formattée i18n (Intl.DateTimeFormat — style long)
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
if (!page.value?.date) return ''
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(isFr.value ? 'fr-FR' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(new Date(page.value.date))
|
||||||
|
} catch {
|
||||||
|
return page.value.date
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reading time avec fallback composable si minutes non injecté
|
||||||
|
const readingMinutes = computed(() => {
|
||||||
|
if (typeof page.value?.minutes === 'number') return page.value.minutes
|
||||||
|
return useReadingTime(page.value?.description ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
|
// TOC links (safe access — page.body.toc peut être undefined pour un article sans heading)
|
||||||
|
const tocLinks = computed(() => {
|
||||||
|
// @ts-expect-error — @nuxt/content v3 body shape type 'minimal' n'expose pas toc dans les types
|
||||||
|
return (page.value?.body?.toc?.links as Array<{ id: string; depth: number; text: string; children?: unknown[] }> | undefined) ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
// SEO minimal Phase 6 — Phase 7 enrichira (JSON-LD Article, og:image, BreadcrumbList)
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => page.value?.title,
|
||||||
|
description: () => page.value?.description,
|
||||||
|
ogTitle: () => page.value?.title,
|
||||||
|
ogDescription: () => page.value?.description,
|
||||||
|
ogType: 'article',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-12">
|
||||||
|
<!-- Breadcrumb (D-07 au-dessus du H1) -->
|
||||||
|
<UBreadcrumb :items="breadcrumbItems" class="mb-6 text-sm" />
|
||||||
|
|
||||||
|
<!-- Layout grid desktop: article + TOC aside sticky -->
|
||||||
|
<div class="lg:grid lg:grid-cols-[1fr_16rem] lg:gap-12">
|
||||||
|
<!-- Main column -->
|
||||||
|
<div class="max-w-3xl mx-auto lg:mx-0 w-full">
|
||||||
|
<!-- Header (D-07 ordre exact) -->
|
||||||
|
<header class="mb-8">
|
||||||
|
<!-- H1 -->
|
||||||
|
<h1 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{{ page?.title }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Meta row: date + · + reading time + TOC button (mobile only) -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
<time :datetime="page?.date" class="font-mono inline-flex items-center gap-1.5">
|
||||||
|
<UIcon name="i-lucide-calendar" class="w-3.5 h-3.5" />
|
||||||
|
{{ formattedDate }}
|
||||||
|
</time>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span class="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>
|
||||||
|
<!-- Mobile TOC trigger — sur lg+, le BlogToc rend son propre trigger hidden par sa branche lg:hidden -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags row -->
|
||||||
|
<div v-if="page?.tags?.length" class="flex flex-wrap gap-2 mb-6">
|
||||||
|
<UBadge
|
||||||
|
v-for="tag in page.tags"
|
||||||
|
:key="tag"
|
||||||
|
color="primary"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cover image hero (si frontmatter.image — D-07) -->
|
||||||
|
<NuxtImg
|
||||||
|
v-if="page?.image"
|
||||||
|
:src="page.image"
|
||||||
|
:alt="page.title"
|
||||||
|
loading="eager"
|
||||||
|
format="webp"
|
||||||
|
class="w-full aspect-[21/9] object-cover rounded-2xl mt-2 mb-4"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Body markdown (prose hérité Phase 5 inchangé) -->
|
||||||
|
<article class="prose dark:prose-invert max-w-none">
|
||||||
|
<ContentRenderer v-if="page" :value="page" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Prev/Next en bas de la colonne principale -->
|
||||||
|
<BlogPrevNext :prev="prevArticle" :next="nextArticle" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOC aside (desktop sticky, mobile drawer par trigger interne au composant) -->
|
||||||
|
<BlogToc v-if="tocLinks.length > 0" :links="tocLinks" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes d'implémentation :**
|
||||||
|
|
||||||
|
1. **Le trigger TOC mobile vit dans BlogToc.vue** (sa branche `<div class="lg:hidden inline-block">` avec UButton). Il N'est PAS placé dans la meta row de [slug].vue — architecture unifiée, BlogToc gère desktop ET mobile. BlogToc se rend dans la grid desktop à droite ET contient son propre UButton mobile qui apparaît sur < lg. Au résultat : le composant BlogToc se rend côté DOM à la bonne position logique, et ses media queries gèrent la visibilité.
|
||||||
|
|
||||||
|
2. **`@ts-expect-error` sur tocLinks** : @nuxt/content v3 expose bien `body.toc` au runtime mais le type exporté est `'minimal'` (tuples) qui ne le déclare pas statiquement. L'accès est safe au runtime, on commente le contournement TS.
|
||||||
|
|
||||||
|
3. **Cover image `loading="eager"`** (pas `lazy`) : c'est le hero image above-the-fold de l'article, chargement immédiat pour LCP. Opposite de BlogCard listing (lazy).
|
||||||
|
|
||||||
|
4. **max-w-3xl conservé sur la colonne article** (D-08) : sur desktop, la colonne gauche de la grid est `1fr` mais le contenu interne est `max-w-3xl` pour la lisibilité prose. Le wrapping `lg:mx-0` évite qu'il se re-centre mal quand la TOC occupe la colonne droite.
|
||||||
|
|
||||||
|
5. **Query draft filter asymétrie** : la query article principale n'a PAS `.where('draft', '=', false)` — cela permet d'accéder aux drafts par URL directe (D-14). En revanche, la query surround A le filtre — les drafts ne peuplent jamais la navigation prev/next. Cette asymétrie est INTENTIONNELLE.
|
||||||
|
|
||||||
|
6. **Cas test actuel** : `/fr/blog/test-kotlin-syntax` (draft:true) s'ouvre, UBreadcrumb + header + body + TOC visibles. Mais BlogPrevNext sera vide (`prev=null, next=null`) car c'est le seul article et il est draft. Le `<nav v-if="prev || next">` ne rend rien — visuellement propre.
|
||||||
|
|
||||||
|
7. **`createError` 404** : conservé depuis Phase 5, pas d'UI custom — `error.vue` layout global du projet prend le relais.
|
||||||
|
|
||||||
|
8. **`ogType: 'article'`** ajouté (était `website` dans Phase 5 implicite) — Phase 7 enrichira encore avec `articleAuthor`, `articlePublishedTime`, etc.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c "queryCollectionItemSurroundings" app/pages/blog/[slug].vue && grep -c "UBreadcrumb" app/pages/blog/[slug].vue && grep -c "<BlogToc" app/pages/blog/[slug].vue && grep -c "<BlogPrevNext" app/pages/blog/[slug].vue </verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c "queryCollectionItemSurroundings" app/pages/blog/\[slug\].vue` retourne 2 (une par branche FR/EN)
|
||||||
|
- `grep -c "UBreadcrumb" app/pages/blog/\[slug\].vue` retourne 1+ match
|
||||||
|
- `grep -c "<BlogToc" app/pages/blog/\[slug\].vue` retourne 1 match
|
||||||
|
- `grep -c "<BlogPrevNext" app/pages/blog/\[slug\].vue` retourne 1 match
|
||||||
|
- `grep -c "queryCollection('blog_fr')" app/pages/blog/\[slug\].vue` retourne 1 (article principal)
|
||||||
|
- `grep -c "queryCollection('blog_en')" app/pages/blog/\[slug\].vue` retourne 1
|
||||||
|
- `grep -c "isFr = computed" app/pages/blog/\[slug\].vue` retourne 1 (Pitfall 3 corrigé — réactif)
|
||||||
|
- `grep -c "watch: \[locale\]" app/pages/blog/\[slug\].vue` retourne 2 (article + surround)
|
||||||
|
- `grep "\.where('draft', '=', false)" app/pages/blog/\[slug\].vue` retourne 2+ matches (surround FR + EN, PAS sur la query path().first())
|
||||||
|
- `grep "\.order('date', 'DESC')" app/pages/blog/\[slug\].vue` retourne 2 matches
|
||||||
|
- `grep -c "nextArticle = computed" app/pages/blog/\[slug\].vue` retourne 1
|
||||||
|
- `grep -c "prevArticle = computed" app/pages/blog/\[slug\].vue` retourne 1
|
||||||
|
- `grep "surround.value?\[0\]" app/pages/blog/\[slug\].vue` retourne 1 match (Next = [0] per Pitfall 4)
|
||||||
|
- `grep "surround.value?\[1\]" app/pages/blog/\[slug\].vue` retourne 1 match (Prev = [1])
|
||||||
|
- `grep -c "breadcrumbItems" app/pages/blog/\[slug\].vue` retourne 2+ matches (computed + bind)
|
||||||
|
- `grep -c "Intl.DateTimeFormat" app/pages/blog/\[slug\].vue` retourne 1
|
||||||
|
- `grep -c "t('blog.breadcrumb.home')" app/pages/blog/\[slug\].vue` retourne 1
|
||||||
|
- `grep -c "t('blog.breadcrumb.blog')" app/pages/blog/\[slug\].vue` retourne 1
|
||||||
|
- `grep -c "t('blog.readingTime'" app/pages/blog/\[slug\].vue` retourne 1
|
||||||
|
- `grep -c "ContentRenderer" app/pages/blog/\[slug\].vue` retourne 1 (body markdown préservé de Phase 5)
|
||||||
|
- `grep "prose dark:prose-invert max-w-none" app/pages/blog/\[slug\].vue` retourne 1 match (wrapper Phase 5 intact)
|
||||||
|
- `grep "aspect-\[21/9\]" app/pages/blog/\[slug\].vue` retourne 1 match (cover hero aspect)
|
||||||
|
- `grep "lg:grid-cols-\[1fr_16rem\]" app/pages/blog/\[slug\].vue` retourne 1 match (grid desktop D-08)
|
||||||
|
- `grep "max-w-3xl mx-auto lg:mx-0" app/pages/blog/\[slug\].vue` retourne 1 match (colonne article lisibilité)
|
||||||
|
- `grep -c "loading=\"eager\"" app/pages/blog/\[slug\].vue` retourne 1 (cover hero above-fold)
|
||||||
|
- `grep "createError" app/pages/blog/\[slug\].vue` retourne 1 match (404 handler Phase 5 préservé)
|
||||||
|
- `pnpm typecheck` passe (attendu : zero nouvelle erreur, `@ts-expect-error` documenté sur page.body.toc)
|
||||||
|
- `pnpm lint` passe
|
||||||
|
- `pnpm build` complète (SSR prerender OK)
|
||||||
|
- Tests runtime (`pnpm dev`) :
|
||||||
|
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -ci "Accueil"` >= 1 (breadcrumb)
|
||||||
|
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -ci "Guide du format Markdown"` >= 1 (H1 titre)
|
||||||
|
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "<article"` >= 1 (body markdown rendu SSR)
|
||||||
|
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -ci "min de lecture"` >= 1 (reading time i18n)
|
||||||
|
- `curl -s http://localhost:3000/en/blog/test-kotlin-syntax | grep -ci "Home"` >= 1 (breadcrumb EN)
|
||||||
|
- `curl -s http://localhost:3000/en/blog/test-kotlin-syntax | grep -ci "min read"` >= 1
|
||||||
|
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "Sommaire"` >= 1 (TOC title FR — présent car l'article a des headings h2)
|
||||||
|
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax` ne contient PAS "Article précédent" ni "Article suivant" en HTML (car seul article draft → BlogPrevNext ne rend pas le `<nav>`)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>
|
||||||
|
app/pages/blog/[slug].vue enrichi. Query article principale conservée sans filtre draft (URL directe accessible D-14). 2e useAsyncData avec queryCollectionItemSurroundings + filtre draft + order DESC. isFr computed + watch locale corrigent Pitfall 3. Breadcrumb + H1 + meta row + tags + cover hero (aspect-21/9) + ContentRenderer (prose Phase 5 inchangé) + BlogPrevNext. BlogToc integré dans grid desktop (sticky aside) + trigger mobile auto. Mapping prev=[1]/next=[0] respecte Pitfall 4. Typecheck + lint + build verts.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `pnpm typecheck` passe (zero nouvelle erreur)
|
||||||
|
2. `pnpm lint` passe
|
||||||
|
3. `pnpm build` complète (validation SSR + prerender)
|
||||||
|
4. Tests SSR `pnpm dev` :
|
||||||
|
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "<article"` >= 1 (body markdown rendu côté serveur, pas SPA shell — Success criterion 2)
|
||||||
|
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "Sommaire"` >= 1 OU (TOC title FR présent — Success criterion 3 : TOC générée depuis page.body.toc)
|
||||||
|
- `curl -s http://localhost:3000/fr/blog/test-kotlin-syntax | grep -c "Accueil"` >= 1 (breadcrumb rendu SSR)
|
||||||
|
- `curl -s http://localhost:3000/en/blog/test-kotlin-syntax | grep -c "<article"` >= 1 (version EN)
|
||||||
|
5. Tests interactifs (navigateur) :
|
||||||
|
- Scroll dans l'article → heading TOC actif change de surlignage (brand-500) au passage dans la zone 20%-70%
|
||||||
|
- Viewport < lg (narrow) : UButton "Sommaire" dans la meta row ; clic → UDrawer s'ouvre à droite avec la TOC ; clic sur un item ferme le drawer
|
||||||
|
- Switch FR/EN via AppHeader toggle : breadcrumb, H1, date, tags, reading time se re-rendent dans la nouvelle langue
|
||||||
|
6. Success criteria Phase 6 globaux (TOUS validés à la fin de Wave 3) :
|
||||||
|
- ✓ curl /fr/blog → HTML SSR listing (Plan 03 Success criterion 1)
|
||||||
|
- ✓ curl /fr/blog/[slug] → article rendu SSR complet (Plan 04 Success criterion 2)
|
||||||
|
- ✓ TOC visible depuis headings (Success criterion 3)
|
||||||
|
- ✓ Liens prev/next présents quand voisins existent (Success criterion 4 — à valider en Phase 8 quand articles seed ajoutés)
|
||||||
|
- ✓ curl /en/blog → listing EN (Plan 03 Success criterion 5)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- BlogToc.vue créé : sticky desktop + UDrawer mobile + IntersectionObserver (rootMargin '-20% 0px -70% 0px')
|
||||||
|
- BlogPrevNext.vue créé : grid 2 cols de BlogCard variant=compact, cellules vides préservées (D-13)
|
||||||
|
- [slug].vue enrichi : UBreadcrumb + H1 + meta row (date formatée + reading time) + tags + cover hero + body prose (Phase 5 intact) + BlogToc + BlogPrevNext
|
||||||
|
- isFr converti en computed, watch locale sur les 2 useAsyncData (Pitfall 3)
|
||||||
|
- queryCollectionItemSurroundings avec littéraux + where draft + order DESC (Pitfalls 1 + 4)
|
||||||
|
- Mapping prev=surround[1] / next=surround[0] (Pitfall 4 documenté dans commentaires code)
|
||||||
|
- Typecheck + lint + build verts
|
||||||
|
- curl /fr/blog/[slug] et /en/blog/[slug] retournent HTML SSR complet incluant breadcrumb/H1/body/TOC
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/06-blog-pages/06-04-SUMMARY.md` with:
|
||||||
|
- Commandes curl exécutées + extraits HTML (preuve SSR breadcrumb + body + TOC)
|
||||||
|
- Validation manuelle TOC highlight au scroll (desktop + mobile drawer)
|
||||||
|
- Validation manuelle switch FR/EN sur l'article
|
||||||
|
- Mapping surround[0]/surround[1] validé empiriquement (ajouter un 2e article non-draft temporaire si besoin pour le test, puis le supprimer)
|
||||||
|
- Any deviation (ex: UDrawer prop name 'direction' vs 'side' — selon la version Nuxt UI installée)
|
||||||
|
- Checklist success criteria Phase 6 — cocher les 5 à la fin
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
# Phase 6: Blog Pages - Pattern Map
|
||||||
|
|
||||||
|
**Mapped:** 2026-04-22
|
||||||
|
**Files analyzed:** 10 (3 new components, 1 new page, 1 new composable, 1 new Nitro plugin, 4 modifications)
|
||||||
|
**Analogs found:** 10 / 10
|
||||||
|
|
||||||
|
## File Classification
|
||||||
|
|
||||||
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||||
|
|-------------------|------|-----------|----------------|---------------|
|
||||||
|
| `app/pages/blog/index.vue` (NEW) | page (listing) | SSR request-response | `app/pages/projects.vue` | exact (hero + grid + empty state) |
|
||||||
|
| `app/pages/blog/[slug].vue` (MODIFY) | page (detail) | SSR request-response | `app/pages/blog/[slug].vue` (existing) + `app/pages/test.vue` | self + role-match |
|
||||||
|
| `app/components/BlogCard.vue` (NEW) | component (presentational) | prop-driven | `app/components/ProjectCard.vue` | exact (card pattern) |
|
||||||
|
| `app/components/BlogToc.vue` (NEW) | component (stateful client) | event-driven (IntersectionObserver) | `app/components/layout/AppHeader.vue` (USlideover) + `app/components/content/ProseImg.vue` (defineProps) | partial |
|
||||||
|
| `app/components/BlogPrevNext.vue` (NEW) | component (presentational) | prop-driven | `app/components/ProjectCard.vue` | role-match (card wrapper) |
|
||||||
|
| `app/composables/useReadingTime.ts` (NEW) | composable (utility) | pure transform | n/a (aucun composable existant hors `useProjects`) | no analog |
|
||||||
|
| `app/utils/countWords.ts` (NEW) | utility | pure transform | n/a | no analog |
|
||||||
|
| `server/plugins/reading-time.ts` (NEW) | Nitro plugin | build-time hook | `server/plugins/rate-limit.ts` | role-match (defineNitroPlugin + hooks.hook) |
|
||||||
|
| `content.config.ts` (MODIFY) | config (schema) | Zod schema | `content.config.ts` (existing) | self |
|
||||||
|
| `app/components/layout/AppHeader.vue` (MODIFY) | component (navigation) | prop-driven | `app/components/layout/AppHeader.vue` (existing) | self |
|
||||||
|
| `i18n/locales/fr.json` + `en.json` (MODIFY) | config (locale) | key-value | existing `fr.json` / `en.json` | self |
|
||||||
|
|
||||||
|
## Pattern Assignments
|
||||||
|
|
||||||
|
### `app/pages/blog/index.vue` (page listing, SSR)
|
||||||
|
|
||||||
|
**Analog:** `app/pages/projects.vue` (lines 1-132)
|
||||||
|
|
||||||
|
**Script setup pattern** (projects.vue lines 1-51):
|
||||||
|
```typescript
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { projects } = useProjects()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('seo.projects.title'),
|
||||||
|
description: () => t('seo.projects.description'),
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalProjects = computed(() => projects.value.length)
|
||||||
|
const featuredCount = computed(() => projects.value.filter((p) => p.featured).length)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adaptation Phase 6:** Remplacer `useProjects()` par `useAsyncData` + `queryCollection` littéraux isFr (voir Pitfall 1 RESEARCH §Pattern 1). Ajouter `watch: [locale]`.
|
||||||
|
|
||||||
|
**Hero section pattern** (projects.vue lines 56-83) — **à copier tel quel** avec substitution des clés i18n :
|
||||||
|
```vue
|
||||||
|
<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: 3 items + 2 dividers verticaux — pattern identique -->
|
||||||
|
<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" />
|
||||||
|
<!-- etc. tags / languages -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Grid pattern** (projects.vue lines 114-116):
|
||||||
|
```vue
|
||||||
|
<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="a in articles" :key="a.path" :article="a" variant="default" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Empty state pattern** (projects.vue lines 119-128) — **adapter texte et CTA** :
|
||||||
|
```vue
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query pattern** (from RESEARCH §Pattern 1 + existing `app/pages/test.vue`):
|
||||||
|
```typescript
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const isFr = computed(() => locale.value === 'fr')
|
||||||
|
|
||||||
|
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] }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `app/pages/blog/[slug].vue` (page article, enrichment)
|
||||||
|
|
||||||
|
**Analog:** Fichier existant (`app/pages/blog/[slug].vue` lines 1-34) — base SSR Phase 5 à conserver, enrichir avec breadcrumb + TOC + prev/next.
|
||||||
|
|
||||||
|
**Current base pattern** (lines 1-25) — **à garder tel quel** :
|
||||||
|
```typescript
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
const isFr = locale.value === 'fr'
|
||||||
|
const path = isFr ? `/fr/blog/${slug}` : `/en/blog/${slug}`
|
||||||
|
|
||||||
|
const { data: page } = await useAsyncData(`blog-${locale.value}-${slug}`, () =>
|
||||||
|
isFr
|
||||||
|
? queryCollection('blog_fr').path(path).first()
|
||||||
|
: queryCollection('blog_en').path(path).first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!page.value) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
|
||||||
|
}
|
||||||
|
|
||||||
|
useSeoMeta({ title: page.value.title, description: page.value.description, /* ... */ })
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha à corriger pendant enrichment** : Ajouter `{ watch: [locale] }` dans `useAsyncData` (voir Pitfall 3 RESEARCH) et convertir `isFr` en `computed` pour que les refetches se déclenchent sur switch locale.
|
||||||
|
|
||||||
|
**Wrapper prose à conserver** (line 28-32) :
|
||||||
|
```vue
|
||||||
|
<article class="prose dark:prose-invert max-w-none">
|
||||||
|
<ContentRenderer v-if="page" :value="page" />
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enrichments à ajouter** (voir RESEARCH §Code Examples lignes 713-830 pour le skeleton complet) :
|
||||||
|
1. UBreadcrumb avant le `<article>`
|
||||||
|
2. Header (H1 + meta row date/minutes + UButton trigger TOC mobile + tags UBadge row + NuxtImg cover)
|
||||||
|
3. Layout grid `lg:grid-cols-[1fr_16rem] lg:gap-12` pour intégrer `<BlogToc>` sticky desktop
|
||||||
|
4. `<BlogPrevNext :prev :next />` après `</article>`
|
||||||
|
5. Seconde `useAsyncData` pour `queryCollectionItemSurroundings` (voir RESEARCH §Pattern 2 — inverser `surround[0]` et `surround[1]` pour order DESC, voir Pitfall 4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `app/components/BlogCard.vue` (component, variant default)
|
||||||
|
|
||||||
|
**Analog:** `app/components/ProjectCard.vue` (lines 1-91) — **match exact** pour variant `default`.
|
||||||
|
|
||||||
|
**Props interface pattern** (ProjectCard.vue lines 1-9):
|
||||||
|
```typescript
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project } from '~~/shared/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adaptation BlogCard :** Type inline (le type Article vient de `queryCollection('blog_fr').all()` — inférer ou déclarer explicitement). Ajouter variant prop :
|
||||||
|
```typescript
|
||||||
|
interface BlogCardProps {
|
||||||
|
article: {
|
||||||
|
path: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
date: string
|
||||||
|
tags?: string[]
|
||||||
|
image?: string
|
||||||
|
minutes?: number
|
||||||
|
}
|
||||||
|
variant?: 'default' | 'compact'
|
||||||
|
direction?: 'prev' | 'next' // uniquement si variant=compact
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<BlogCardProps>(), { variant: 'default' })
|
||||||
|
```
|
||||||
|
|
||||||
|
**Card wrapper pattern** (ProjectCard.vue lines 19-23) — **à copier tel quel** :
|
||||||
|
```vue
|
||||||
|
<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"
|
||||||
|
itemscope
|
||||||
|
itemtype="https://schema.org/BlogPosting"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cover image pattern** (ProjectCard.vue lines 25-43):
|
||||||
|
```vue
|
||||||
|
<NuxtLink :to="localePath(`/blog/${slug}`)" class="block relative overflow-hidden">
|
||||||
|
<NuxtImg
|
||||||
|
:src="article.image"
|
||||||
|
:alt="`${article.title} - ${article.description?.slice(0, 60)}...`"
|
||||||
|
loading="lazy"
|
||||||
|
format="webp"
|
||||||
|
width="400"
|
||||||
|
height="225"
|
||||||
|
class="w-full aspect-[16/9] object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</NuxtLink>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Content section pattern** (ProjectCard.vue lines 46-79):
|
||||||
|
```vue
|
||||||
|
<div class="p-5 sm:p-6 flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<UBadge v-if="article.tags?.[0]" color="primary" variant="subtle">{{ article.tags[0] }}</UBadge>
|
||||||
|
<time class="text-xs text-gray-400 dark:text-gray-500 font-mono" :datetime="article.date">
|
||||||
|
{{ formattedDate }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{{ article.title }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed">
|
||||||
|
{{ article.description }}
|
||||||
|
</p>
|
||||||
|
<!-- reading time + tags supplémentaires (+N) -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Absolute SEO link pattern** (ProjectCard.vue lines 83-88) — **critique a11y** :
|
||||||
|
```vue
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath(`/blog/${slug}`)"
|
||||||
|
class="absolute inset-0 z-10"
|
||||||
|
:aria-label="`${t('blog.readingTime', { minutes })} - ${article.title}`"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variant compact** : Pas de NuxtImg, padding `p-5`, label row avec UIcon arrow — voir UI-SPEC §BlogCard variant contract pour le contrat exact.
|
||||||
|
|
||||||
|
**Date formatting (nouveau)** — pas d'analog dans ProjectCard (qui affiche `project.date` brut) :
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `app/components/BlogToc.vue` (component, stateful client)
|
||||||
|
|
||||||
|
**Analog partiel:** `app/components/layout/AppHeader.vue` (USlideover pattern lines 80-114) + `app/components/content/ProseImg.vue` (defineProps typé lines 1-38).
|
||||||
|
|
||||||
|
**USlideover/UDrawer control pattern** (AppHeader.vue lines 6 + 80):
|
||||||
|
```typescript
|
||||||
|
const mobileOpen = ref(false)
|
||||||
|
```
|
||||||
|
```vue
|
||||||
|
<USlideover v-model:open="mobileOpen" side="left" class="md:hidden">
|
||||||
|
<template #header>...</template>
|
||||||
|
<template #body>...</template>
|
||||||
|
</USlideover>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adaptation BlogToc** : Remplacer `USlideover` par `UDrawer` (UI-SPEC D-05), `side="right"` (UI-SPEC). Ref locale `tocDrawerOpen` — ne **pas** utiliser `useState` (Pitfall 8 RESEARCH).
|
||||||
|
|
||||||
|
**Props typed pattern** (ProseImg.vue lines 3-16):
|
||||||
|
```typescript
|
||||||
|
interface Props {
|
||||||
|
src: string
|
||||||
|
alt?: string
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
alt: '',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adaptation BlogToc** :
|
||||||
|
```typescript
|
||||||
|
interface TocLink {
|
||||||
|
id: string
|
||||||
|
depth: number
|
||||||
|
text: string
|
||||||
|
children?: TocLink[]
|
||||||
|
}
|
||||||
|
const props = defineProps<{ links: TocLink[] }>()
|
||||||
|
```
|
||||||
|
|
||||||
|
**IntersectionObserver pattern** — **aucun analog dans le codebase**, copier directement RESEARCH §Pattern 4 (lines 393-440). Points critiques :
|
||||||
|
- `activeId = ref<string | null>(null)` initial (Pitfall 2 hydration mismatch)
|
||||||
|
- Setup dans `onMounted`, cleanup dans `onBeforeUnmount`
|
||||||
|
- `rootMargin: '-20% 0px -70% 0px'` (imposé UI-SPEC)
|
||||||
|
|
||||||
|
**Sticky desktop pattern (nouveau)** — voir UI-SPEC §BlogToc contract Desktop :
|
||||||
|
```vue
|
||||||
|
<aside class="hidden lg:block sticky top-24 w-64 self-start">
|
||||||
|
<p class="text-sm font-bold uppercase tracking-wider text-gray-500 mb-4">{{ t('blog.toc.title') }}</p>
|
||||||
|
<ol class="space-y-2 text-sm">
|
||||||
|
<!-- liste flat + nested -->
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `app/components/BlogPrevNext.vue` (component, prop-driven)
|
||||||
|
|
||||||
|
**Analog:** `app/components/ProjectCard.vue` (réutilise `BlogCard variant="compact"` ×2).
|
||||||
|
|
||||||
|
**Composition pattern** (nouveau, inspiré UI-SPEC) :
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
prev: BlogArticle | null
|
||||||
|
next: BlogArticle | null
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="mt-16 grid md:grid-cols-2 gap-5" :aria-label="t('blog.breadcrumb.blog')">
|
||||||
|
<div v-if="prev">
|
||||||
|
<BlogCard :article="prev" variant="compact" direction="prev" />
|
||||||
|
</div>
|
||||||
|
<div v-else />
|
||||||
|
<div v-if="next">
|
||||||
|
<BlogCard :article="next" variant="compact" direction="next" />
|
||||||
|
</div>
|
||||||
|
<div v-else />
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern "empty cell kept for alignment" imposé par D-13 RESEARCH.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `app/composables/useReadingTime.ts` (composable, pure transform)
|
||||||
|
|
||||||
|
**Analog:** **Aucun composable existant n'a le même rôle** (`useProjects` manipule des stores, pas de pure compute). Utiliser directement RESEARCH §Pattern 5 ligne 509-517 :
|
||||||
|
```typescript
|
||||||
|
export function useReadingTime(wordCountOrText: number | string): number {
|
||||||
|
if (typeof wordCountOrText === 'number') {
|
||||||
|
return Math.max(1, Math.ceil(wordCountOrText / 200))
|
||||||
|
}
|
||||||
|
const count = wordCountOrText.trim().split(/\s+/).filter(Boolean).length
|
||||||
|
return Math.max(1, Math.ceil(count / 200))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Role :** Fallback client si `page.minutes` absent (dev mode, hook pas encore exécuté). Source of truth = hook Nitro.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `app/utils/countWords.ts` (utility, pure)
|
||||||
|
|
||||||
|
**Analog:** Aucun — dossier `app/utils/` à créer. Copier RESEARCH §Pattern 5 lignes 465-488 (fonction `countWordsInMinimalBody`). Exporté et importé par le Nitro plugin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `server/plugins/reading-time.ts` (Nitro plugin, build-time hook)
|
||||||
|
|
||||||
|
**Analog:** `server/plugins/rate-limit.ts` (lines 1-32) — **même structure** `defineNitroPlugin` + `nitro.hooks.hook(...)`.
|
||||||
|
|
||||||
|
**Plugin skeleton pattern** (rate-limit.ts lines 11-32):
|
||||||
|
```typescript
|
||||||
|
export default defineNitroPlugin((nitro) => {
|
||||||
|
nitro.hooks.hook('request', (event) => {
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Adaptation Phase 6** (RESEARCH §Pattern 5 lines 453-463):
|
||||||
|
```typescript
|
||||||
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
|
nitroApp.hooks.hook('content:file:afterParse', (ctx) => {
|
||||||
|
const { file, content } = ctx
|
||||||
|
if (!file.id?.endsWith('.md')) return
|
||||||
|
|
||||||
|
const wordCount = countWordsInMinimalBody(content.body)
|
||||||
|
content.wordCount = wordCount
|
||||||
|
content.minutes = Math.max(1, Math.ceil(wordCount / 200))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Convention de nommage paramètre : `nitro` (rate-limit) vs `nitroApp` (RESEARCH example) — les deux valent ; préférer `nitroApp` ici pour coller à la convention Nuxt docs du hook content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `content.config.ts` (config, schema)
|
||||||
|
|
||||||
|
**Analog:** `content.config.ts` existant (lines 1-25) — **étendre**, ne pas réécrire.
|
||||||
|
|
||||||
|
**Existing schema** (lines 3-9) :
|
||||||
|
```typescript
|
||||||
|
const blogSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Additions Phase 6** (D-18 + RESEARCH §Pattern 5 + Pitfall 5) :
|
||||||
|
```typescript
|
||||||
|
const blogSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
draft: z.boolean().optional().default(false), // D-18
|
||||||
|
wordCount: z.number().optional(), // injecté par hook
|
||||||
|
minutes: z.number().optional(), // injecté par hook
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure `collections` inchangée** (lines 11-24).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `app/components/layout/AppHeader.vue` (component, navigation — MODIFY)
|
||||||
|
|
||||||
|
**Analog:** Fichier lui-même (AppHeader.vue lines 8-15) — **ajouter un item** dans `navLinks` array.
|
||||||
|
|
||||||
|
**Current pattern** (lines 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' },
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modification D-15** (ajout entre hytale et projects) :
|
||||||
|
```typescript
|
||||||
|
const navLinks = computed(() => [
|
||||||
|
{ key: 'home', path: '/' },
|
||||||
|
{ key: 'hytale', path: '/hytale' },
|
||||||
|
{ key: 'blog', path: '/blog' }, // NEW (D-15)
|
||||||
|
{ key: 'projects', path: '/projects' },
|
||||||
|
{ key: 'about', path: '/about' },
|
||||||
|
{ key: 'contact', path: '/contact' },
|
||||||
|
{ key: 'fiverr', path: '/fiverr' },
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
Le template ne change pas : `{{ t(\`nav.${link.key}\`) }}` lira automatiquement `nav.blog` depuis les locales.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `i18n/locales/fr.json` + `en.json` (config, locale — MODIFY)
|
||||||
|
|
||||||
|
**Analog:** fichiers existants (fr.json lines 1-9 pour `nav`, lines 23-34 pour `a11y`, lines 112-149 pour `projects` pattern).
|
||||||
|
|
||||||
|
**Existing `nav` block** (fr.json lines 2-9):
|
||||||
|
```json
|
||||||
|
"nav": {
|
||||||
|
"home": "Accueil",
|
||||||
|
"projects": "Projets",
|
||||||
|
"about": "A propos",
|
||||||
|
"contact": "Contact",
|
||||||
|
"fiverr": "Fiverr",
|
||||||
|
"hytale": "Hytale"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add D-21** : `"blog": "Blog"` dans `nav`, plus bloc complet `blog.*` et `a11y.blogTocToggle/blogPrev/blogNext`. Structure exacte dans UI-SPEC §i18n Keys à créer (lines 341-379).
|
||||||
|
|
||||||
|
**Convention observée** : accents encodés en ASCII (`A propos` sans accent, `Developpeur` sans accent) dans les clés existantes `a11y` et `seo`. Les nouveaux libellés `blog.*` peuvent utiliser les accents (cohérent avec bloc `projects` qui les utilise) — **suivre le pattern du bloc `projects`**, pas `a11y/seo`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Patterns
|
||||||
|
|
||||||
|
### i18n access
|
||||||
|
**Source:** `app/pages/projects.vue` ligne 2, `app/components/ProjectCard.vue` ligne 9
|
||||||
|
**Apply to:** Tous les composants/pages créés en Phase 6
|
||||||
|
```typescript
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { t, locale } = useI18n() // si locale réactive nécessaire
|
||||||
|
const localePath = useLocalePath() // pour les NuxtLink/:to
|
||||||
|
```
|
||||||
|
|
||||||
|
### SEO meta (minimal Phase 6, enrichi Phase 7)
|
||||||
|
**Source:** `app/pages/projects.vue` lines 5-14, `app/pages/blog/[slug].vue` lines 19-24
|
||||||
|
**Apply to:** `app/pages/blog/index.vue`, `app/pages/blog/[slug].vue`
|
||||||
|
```typescript
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('blog.title'),
|
||||||
|
description: () => t('blog.subtitle'),
|
||||||
|
ogTitle: () => t('blog.title'),
|
||||||
|
ogDescription: () => t('blog.subtitle'),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### queryCollection littéral branching (CRITIQUE — Phase 5 gotcha hérité)
|
||||||
|
**Source:** `app/pages/blog/[slug].vue` lines 9-13 + `app/pages/test.vue` lines 2-4
|
||||||
|
**Apply to:** Toute query @nuxt/content en Phase 6 (listing, surround, article)
|
||||||
|
```typescript
|
||||||
|
const { data } = await useAsyncData(
|
||||||
|
`key-${locale.value}`,
|
||||||
|
() => isFr.value
|
||||||
|
? queryCollection('blog_fr').where(...).all()
|
||||||
|
: queryCollection('blog_en').where(...).all(),
|
||||||
|
{ watch: [locale] }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
**Interdiction absolue** : `queryCollection(variable)` → retourne `{}` silencieusement (Pitfall 1 RESEARCH).
|
||||||
|
|
||||||
|
### Active route detection (AppHeader pattern)
|
||||||
|
**Source:** `app/components/layout/AppHeader.vue` lines 25-27 + 45-54
|
||||||
|
**Apply to:** Pas d'usage direct en Phase 6 — mais pattern suivi implicitement par NuxtLink `aria-current` dans BlogCard et BlogPrevNext si besoin.
|
||||||
|
```typescript
|
||||||
|
function isActive(path: string): boolean {
|
||||||
|
return route.path === localePath(path)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Card hover effect (design system)
|
||||||
|
**Source:** `app/components/ProjectCard.vue` line 20
|
||||||
|
**Apply to:** `app/components/BlogCard.vue` (les deux variants)
|
||||||
|
```
|
||||||
|
transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1.5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nitro plugin structure
|
||||||
|
**Source:** `server/plugins/rate-limit.ts`
|
||||||
|
**Apply to:** `server/plugins/reading-time.ts`
|
||||||
|
```typescript
|
||||||
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
|
nitroApp.hooks.hook('<hook-name>', (ctx) => { /* ... */ })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error handling SSR
|
||||||
|
**Source:** `app/pages/blog/[slug].vue` lines 15-17
|
||||||
|
**Apply to:** `app/pages/blog/[slug].vue` (conservé dans enrichment)
|
||||||
|
```typescript
|
||||||
|
if (!page.value) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Pas d'UI custom 404 — `error.vue` layout global du projet prend le relais (UI-SPEC §Error state).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No Analog Found
|
||||||
|
|
||||||
|
Les fichiers ci-dessous ont un rôle que le codebase n'a jamais implémenté. Le planner doit utiliser les patterns **RESEARCH.md** directement (déjà cités ci-dessus par référence).
|
||||||
|
|
||||||
|
| File | Role | Reason | Source à copier |
|
||||||
|
|------|------|--------|-----------------|
|
||||||
|
| `app/composables/useReadingTime.ts` | composable pure compute | Aucun composable "transform" pur existe (`useProjects` = data store) | RESEARCH §Pattern 5 ligne 509-517 |
|
||||||
|
| `app/utils/countWords.ts` | util transform AST | Dossier `app/utils/` à créer | RESEARCH §Pattern 5 ligne 465-488 |
|
||||||
|
| `server/plugins/reading-time.ts` (hook content:file:afterParse) | Nitro hook ingestion-time | `rate-limit.ts` utilise le hook `request` (runtime), pas `content:file:afterParse` (build/ingest). Structure `defineNitroPlugin` identique mais hook différent | Analog structurel OK (rate-limit.ts) + RESEARCH §Pattern 5 ligne 453-463 pour le body du hook |
|
||||||
|
| `app/components/BlogToc.vue` IntersectionObserver | client-side DOM observer | Aucun composant existant n'observe le scroll | RESEARCH §Pattern 4 ligne 393-440 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Analog search scope:** `app/pages/`, `app/components/`, `app/composables/`, `server/plugins/`, `content.config.ts`, `i18n/locales/`
|
||||||
|
**Files scanned:** 10+ (projects.vue, ProjectCard.vue, blog/[slug].vue, test.vue, AppHeader.vue, ProseImg.vue, rate-limit.ts, contact.post.ts, content.config.ts, fr.json, en.json)
|
||||||
|
**Pattern extraction date:** 2026-04-22
|
||||||
Reference in New Issue
Block a user