From edf7593f4f9658990abc001a1739c7088f51a98a Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Wed, 22 Apr 2026 01:09:25 +0200 Subject: [PATCH] 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 --- .planning/ROADMAP.md | 35 +- .planning/phases/06-blog-pages/06-01-PLAN.md | 500 +++++++++++ .planning/phases/06-blog-pages/06-02-PLAN.md | 608 ++++++++++++++ .planning/phases/06-blog-pages/06-03-PLAN.md | 378 +++++++++ .planning/phases/06-blog-pages/06-04-PLAN.md | 787 ++++++++++++++++++ .planning/phases/06-blog-pages/06-PATTERNS.md | 577 +++++++++++++ 6 files changed, 2880 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/06-blog-pages/06-01-PLAN.md create mode 100644 .planning/phases/06-blog-pages/06-02-PLAN.md create mode 100644 .planning/phases/06-blog-pages/06-03-PLAN.md create mode 100644 .planning/phases/06-blog-pages/06-04-PLAN.md create mode 100644 .planning/phases/06-blog-pages/06-PATTERNS.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 17d8714..30145f5 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -59,7 +59,12 @@ Plans: 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) 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 **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 3. `pnpm typecheck` et `pnpm lint` passent avec 0 erreurs 4. `curl` sur chaque page retourne ``, `<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 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 -**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 ### Phase 7: SEO Blog @@ -144,7 +159,12 @@ Plans: 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 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 **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 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 -**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 --- diff --git a/.planning/phases/06-blog-pages/06-01-PLAN.md b/.planning/phases/06-blog-pages/06-01-PLAN.md new file mode 100644 index 0000000..76128f2 --- /dev/null +++ b/.planning/phases/06-blog-pages/06-01-PLAN.md @@ -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> diff --git a/.planning/phases/06-blog-pages/06-02-PLAN.md b/.planning/phases/06-blog-pages/06-02-PLAN.md new file mode 100644 index 0000000..6b01e78 --- /dev/null +++ b/.planning/phases/06-blog-pages/06-02-PLAN.md @@ -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> diff --git a/.planning/phases/06-blog-pages/06-03-PLAN.md b/.planning/phases/06-blog-pages/06-03-PLAN.md new file mode 100644 index 0000000..5261be5 --- /dev/null +++ b/.planning/phases/06-blog-pages/06-03-PLAN.md @@ -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> diff --git a/.planning/phases/06-blog-pages/06-04-PLAN.md b/.planning/phases/06-blog-pages/06-04-PLAN.md new file mode 100644 index 0000000..e22136a --- /dev/null +++ b/.planning/phases/06-blog-pages/06-04-PLAN.md @@ -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> diff --git a/.planning/phases/06-blog-pages/06-PATTERNS.md b/.planning/phases/06-blog-pages/06-PATTERNS.md new file mode 100644 index 0000000..125d3d9 --- /dev/null +++ b/.planning/phases/06-blog-pages/06-PATTERNS.md @@ -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