--- phase: 06-blog-pages plan: 02 type: execute wave: 2 depends_on: [] files_modified: - i18n/locales/fr.json - i18n/locales/en.json - app/components/layout/AppHeader.vue - app/components/BlogCard.vue autonomous: true requirements: - BLOG-02 - BLOG-03 - BLOG-06 tags: - blog - i18n - nav - blog-card - shared-components must_haves: truths: - "Les clés i18n `nav.blog`, `blog.title`, `blog.subtitle`, `blog.stats.*`, `blog.readingTime`, `blog.prevArticle`, `blog.nextArticle`, `blog.backToBlog`, `blog.toc.title`, `blog.emptyState.*`, `blog.breadcrumb.*`, `a11y.blogTocToggle`, `a11y.blogPrev`, `a11y.blogNext` existent dans fr.json ET en.json avec des valeurs traduites" - "AppHeader.vue affiche un lien `Blog` entre Hytale et Projects dans la nav desktop ET mobile" - "BlogCard.vue est un composant unique avec variant prop `default` (listing) et `compact` (prev/next), importable partout via auto-import" - "BlogCard variant default rend : cover image conditionnelle (si article.image) aspect-16/9 + titre + description line-clamp-2 + date formatée i18n + premier tag UBadge + reading time" - "BlogCard variant compact rend : pas d'image + label row 'Article précédent/suivant' + icône arrow + titre + date, utilisé exclusivement par BlogPrevNext en Wave 3" artifacts: - path: "i18n/locales/fr.json" provides: "Clés blog.* + nav.blog + a11y.blog* en français" contains: "\"blog\":" - path: "i18n/locales/en.json" provides: "Clés blog.* + nav.blog + a11y.blog* en anglais" contains: "\"blog\":" - path: "app/components/layout/AppHeader.vue" provides: "Nav link Blog entre hytale et projects (desktop + mobile)" contains: "{ key: 'blog', path: '/blog' }" - path: "app/components/BlogCard.vue" provides: "Composant unifié variant default + compact pour listing et prev/next" exports_default: true key_links: - from: "app/components/BlogCard.vue" to: "i18n blog.readingTime / blog.prevArticle / blog.nextArticle" via: "t('blog.readingTime', { minutes }) dans le template" pattern: "t\\('blog\\.(readingTime|prevArticle|nextArticle)'" - from: "app/components/layout/AppHeader.vue" to: "i18n nav.blog" via: "t(`nav.${link.key}`) avec key 'blog' ajoutée dans navLinks" pattern: "key: 'blog'" - from: "app/components/BlogCard.vue" to: "NuxtLink localePath('/blog/' + slug)" via: "absolute inset-0 SEO link pattern de ProjectCard" pattern: "localePath" --- Poser les **3 pré-requis transverses** consommés par les deux pages blog (Wave 3) : 1. Les clés i18n dans fr.json + en.json (sans elles, tout template de Wave 3 rendera des `{{ $t(...) }}` vides) 2. Le lien nav `Blog` dans AppHeader (sans lui, la nav ne mène pas au blog — rupture de découvrabilité D-15) 3. Le composant `BlogCard.vue` unifié (sans lui, ni le listing ni la section prev/next ne peuvent rendre quoi que ce soit — D-20 exige composant unique avec variant) **Purpose:** Les 3 tâches de ce plan sont indépendantes les unes des autres (fichiers disjoints) mais nécessaires ENSEMBLE avant que Wave 3 (pages) puisse être exécutée. Elles forment la "couche composition partagée". **Output:** - `i18n/locales/fr.json` + `en.json` : bloc `blog.*` complet + `nav.blog` + 3 clés `a11y.blog*` - `app/components/layout/AppHeader.vue` : entrée `{ key: 'blog', path: '/blog' }` ajoutée dans `navLinks` entre hytale et projects - `app/components/BlogCard.vue` : composant variant default + compact, auto-importé par Nuxt @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/STATE.md @.planning/phases/06-blog-pages/06-CONTEXT.md @.planning/phases/06-blog-pages/06-RESEARCH.md @.planning/phases/06-blog-pages/06-PATTERNS.md @.planning/phases/06-blog-pages/06-UI-SPEC.md @app/components/ProjectCard.vue @app/components/layout/AppHeader.vue @i18n/locales/fr.json @i18n/locales/en.json ```typescript interface BlogArticle { path: string // ex: '/fr/blog/my-slug' title: string description: string date: string tags?: string[] image?: string draft?: boolean // ajouté Wave 1 wordCount?: number // ajouté Wave 1 (via hook) minutes?: number // ajouté Wave 1 (via hook) } ``` ```typescript interface BlogCardProps { article: BlogArticle // ou un sous-ensemble pour variant compact (fields prop de surround()) variant?: 'default' | 'compact' direction?: 'prev' | 'next' // requis seulement si variant='compact' } ``` ```
``` ```typescript const navLinks = computed(() => [ { key: 'home', path: '/' }, { key: 'hytale', path: '/hytale' }, { key: 'projects', path: '/projects' }, { key: 'about', path: '/about' }, { key: 'contact', path: '/contact' }, { key: 'fiverr', path: '/fiverr' }, ]) ``` Le template itère via `v-for="link in navLinks"` puis `{{ t(\`nav.${link.key}\`) }}` — ajouter une entrée propage automatiquement au desktop ET au mobile slideover.
Task 2.1 : Ajouter le bloc complet `blog.*` + `nav.blog` + `a11y.blog*` dans fr.json et en.json - i18n/locales/fr.json - i18n/locales/en.json - i18n/locales/fr.json (structure actuelle : "nav" en haut, "footer", "a11y", "seo", "projects" — pour insérer "blog" en suivant la convention de clés top-level du projet) - i18n/locales/en.json (mêmes clés, version EN) - .planning/phases/06-blog-pages/06-UI-SPEC.md (§Copywriting Contract lignes 115-172 pour les libellés FR/EN EXACTS + §i18n Keys à créer lignes 339-379 pour la structure JSON complète) - .planning/phases/06-blog-pages/06-CONTEXT.md (D-21) - .planning/phases/06-blog-pages/06-PATTERNS.md (§i18n/locales lignes 464-482 — convention "bloc projects utilise les accents, suivre ce pattern, pas a11y/seo qui sont sans accents") **Pour `i18n/locales/fr.json` :** 1. Dans le bloc existant `"nav": { ... }` (lignes 2-9), ajouter une nouvelle clé `"blog"` avec la valeur `"Blog"`. Placer logiquement avant `"projects"` pour refléter l'ordre de navigation (hytale → blog → projects), mais l'ordre dans le JSON n'impacte pas le runtime — l'important est la présence de la clé. 2. Dans le bloc existant `"a11y": { ... }` (lignes 23-34), ajouter 3 nouvelles clés à la fin du bloc : ```json "blogTocToggle": "Afficher le sommaire", "blogPrev": "Article précédent : {title}", "blogNext": "Article suivant : {title}" ``` 3. Ajouter un NOUVEAU bloc top-level `"blog": { ... }` (à placer après le bloc `"projects"` pour cohérence thématique, ou à la fin du fichier — l'emplacement est au jugement de l'exécutant tant que le JSON reste valide) contenant : ```json "blog": { "title": "Blog", "subtitle": "Articles techniques, retours d'expérience et guides pratiques sur le développement de plugins Hytale et l'écosystème web.", "stats": { "articles": "Articles", "tags": "Tags", "languages": "Langues" }, "readingTime": "{minutes} min de lecture", "prevArticle": "Article précédent", "nextArticle": "Article suivant", "backToBlog": "Retour au blog", "toc": { "title": "Sommaire" }, "emptyState": { "title": "Bientôt des articles Hytale", "description": "Le blog se prépare. Les premiers articles sur le développement de plugins Hytale arrivent bientôt.", "cta": "Me contacter" }, "breadcrumb": { "home": "Accueil", "blog": "Blog" } } ``` **Pour `i18n/locales/en.json` :** Mêmes additions, traductions EN : 1. `nav.blog` = `"Blog"` 2. `a11y.blog*` : ```json "blogTocToggle": "Show table of contents", "blogPrev": "Previous article: {title}", "blogNext": "Next article: {title}" ``` 3. Bloc `blog` : ```json "blog": { "title": "Blog", "subtitle": "Technical articles, experience feedback and practical guides on Hytale plugin development and the web ecosystem.", "stats": { "articles": "Articles", "tags": "Tags", "languages": "Languages" }, "readingTime": "{minutes} min read", "prevArticle": "Previous article", "nextArticle": "Next article", "backToBlog": "Back to blog", "toc": { "title": "Table of contents" }, "emptyState": { "title": "Hytale articles coming soon", "description": "The blog is being prepared. The first articles on Hytale plugin development are coming soon.", "cta": "Contact me" }, "breadcrumb": { "home": "Home", "blog": "Blog" } } ``` **Conventions à respecter :** - **Accents** : FR utilise les accents dans le bloc `blog.*` (comme le bloc `projects` existant), PAS le pattern ASCII des blocs `a11y`/`seo`. Ex: "Bientôt" avec ô, "précédent" avec é, "sommaire" — accentués. Cohérent avec PATTERNS.md §convention. - **Interpolation** : `{minutes}` et `{title}` sont la syntaxe vue-i18n standard (pas `{{ minutes }}`, pas `%{minutes}`). Cette syntaxe est déjà utilisée dans le projet (ex: à vérifier dans les blocs existants). - **Valid JSON** : ne PAS laisser de virgule traînante après la dernière clé d'un bloc (JSON strict). - **Ordre des blocs** : ne pas réorganiser les blocs existants (`nav`, `footer`, `a11y`, `seo`, `projects`, `home`, `about`, etc.) — uniquement ajouter. node -e "const fr=require('./i18n/locales/fr.json'); const en=require('./i18n/locales/en.json'); console.log(fr.nav.blog, en.nav.blog, fr.blog.title, en.blog.title, fr.blog.readingTime, en.blog.readingTime, fr.a11y.blogTocToggle, en.a11y.blogTocToggle)" - `node -e "console.log(require('./i18n/locales/fr.json').nav.blog)"` affiche `Blog` - `node -e "console.log(require('./i18n/locales/en.json').nav.blog)"` affiche `Blog` - `node -e "console.log(require('./i18n/locales/fr.json').blog.title)"` affiche `Blog` - `node -e "console.log(require('./i18n/locales/fr.json').blog.subtitle)"` commence par `Articles techniques` - `node -e "console.log(require('./i18n/locales/en.json').blog.subtitle)"` commence par `Technical articles` - `node -e "console.log(require('./i18n/locales/fr.json').blog.readingTime)"` affiche `{minutes} min de lecture` - `node -e "console.log(require('./i18n/locales/en.json').blog.readingTime)"` affiche `{minutes} min read` - `node -e "console.log(require('./i18n/locales/fr.json').blog.emptyState.cta)"` affiche `Me contacter` - `node -e "console.log(require('./i18n/locales/en.json').blog.emptyState.cta)"` affiche `Contact me` - `node -e "console.log(require('./i18n/locales/fr.json').blog.toc.title)"` affiche `Sommaire` - `node -e "console.log(require('./i18n/locales/en.json').blog.toc.title)"` affiche `Table of contents` - `node -e "console.log(require('./i18n/locales/fr.json').a11y.blogTocToggle)"` affiche `Afficher le sommaire` - `node -e "console.log(require('./i18n/locales/fr.json').a11y.blogPrev)"` contient `{title}` - `node -e "console.log(require('./i18n/locales/fr.json').blog.breadcrumb.home)"` affiche `Accueil` - `node -e "console.log(require('./i18n/locales/en.json').blog.breadcrumb.home)"` affiche `Home` - `node -e "JSON.parse(require('fs').readFileSync('./i18n/locales/fr.json'))"` ne throw pas (JSON valide) - `node -e "JSON.parse(require('fs').readFileSync('./i18n/locales/en.json'))"` ne throw pas - Les clés existantes (`nav.home`, `nav.hytale`, `seo.*`, `projects.*`) sont inchangées — vérifier par `node -e "console.log(require('./i18n/locales/fr.json').nav.hytale)"` = `Hytale` Les deux fichiers i18n contiennent `nav.blog`, 3 clés `a11y.blog*`, et un bloc `blog.*` complet avec les 14 clés listées (title, subtitle, stats.articles/tags/languages, readingTime, prevArticle, nextArticle, backToBlog, toc.title, emptyState.title/description/cta, breadcrumb.home/blog). JSON valide. Aucune clé existante modifiée. Task 2.2 : Ajouter le lien Blog dans AppHeader.vue navLinks (entre hytale et projects) app/components/layout/AppHeader.vue - app/components/layout/AppHeader.vue (état actuel : navLinks lignes 8-15, template desktop lignes 44-55, slideover mobile lignes 89-100 — le template itère via v-for donc UN SEUL changement suffit) - .planning/phases/06-blog-pages/06-CONTEXT.md (D-15 : ordre final Home / Hytale / **Blog** / Projects / About / Contact / Fiverr) - .planning/phases/06-blog-pages/06-PATTERNS.md (§AppHeader lignes 431-460) Dans `app/components/layout/AppHeader.vue`, modifier UNIQUEMENT l'array `navLinks` computed (lignes 8-15). Insérer `{ key: 'blog', path: '/blog' }` entre l'entrée `hytale` et l'entrée `projects`. Avant : ```typescript const navLinks = computed(() => [ { key: 'home', path: '/' }, { key: 'hytale', path: '/hytale' }, { key: 'projects', path: '/projects' }, { key: 'about', path: '/about' }, { key: 'contact', path: '/contact' }, { key: 'fiverr', path: '/fiverr' }, ]) ``` Après : ```typescript const navLinks = computed(() => [ { key: 'home', path: '/' }, { key: 'hytale', path: '/hytale' }, { key: 'blog', path: '/blog' }, { key: 'projects', path: '/projects' }, { key: 'about', path: '/about' }, { key: 'contact', path: '/contact' }, { key: 'fiverr', path: '/fiverr' }, ]) ``` **Ne toucher à RIEN d'autre** dans le fichier : - Pas de modification du template (le `v-for="link in navLinks"` prend l'array updated automatiquement) - Pas de modification du slideover mobile (même v-for sur la même source) - Pas de modification des imports, des refs, des fonctions `isActive`/`toggleLocale`/`toggleTheme` - Ne pas ajouter un bloc blog dédié — passer par le pattern itératif existant est intentionnel (cohérence visuelle + moins de code) **Pourquoi `path: '/blog'` (pas `/fr/blog`) :** le template wrap `localePath(link.path)` dans le NuxtLink `:to` (ligne 46 et 91). `localePath('/blog')` résout automatiquement vers `/fr/blog` ou `/en/blog` selon la locale active — pattern i18n existant respecté. **Pourquoi la clé `'blog'` :** le template interpole `{{ t(\`nav.${link.key}\`) }}` — la clé `nav.blog` ajoutée par Task 2.1 sera automatiquement utilisée, pas de hardcode. grep -c "{ key: 'blog', path: '/blog' }" app/components/layout/AppHeader.vue - `grep -c "{ key: 'blog', path: '/blog' }" app/components/layout/AppHeader.vue` retourne 1 - `grep -n "key: 'hytale'" app/components/layout/AppHeader.vue` retourne une ligne (ex: ligne 10) - `grep -n "key: 'blog'" app/components/layout/AppHeader.vue` retourne une ligne (ex: ligne 11) - `grep -n "key: 'projects'" app/components/layout/AppHeader.vue` retourne une ligne (ex: ligne 12) - Le numéro de ligne de `key: 'blog'` est STRICTEMENT entre celui de `key: 'hytale'` et `key: 'projects'` (ordre respecté D-15) - `grep -c "key: 'home'" app/components/layout/AppHeader.vue` retourne 1 (pas de duplication/suppression) - `grep -c "key: 'fiverr'" app/components/layout/AppHeader.vue` retourne 1 - `grep -c "v-for=\"link in navLinks\"" app/components/layout/AppHeader.vue` retourne 2 (desktop + mobile templates intacts) - `pnpm typecheck` passe - `pnpm dev` + visite manuelle de `/fr/` montre un lien `Blog` entre `Hytale` et `Projets` dans la nav desktop (validation visuelle optionnelle, non-bloquante) AppHeader.vue contient `{ key: 'blog', path: '/blog' }` dans navLinks, positionné entre hytale et projects. Aucune autre modification. Template v-for inchangé, le nouveau lien apparaît automatiquement en desktop et dans le slideover mobile. Le libellé `Blog` vient de `nav.blog` (Task 2.1). Task 2.3 : Créer app/components/BlogCard.vue (variants default + compact, D-20) app/components/BlogCard.vue - app/components/ProjectCard.vue (pattern COMPLET à transposer pour variant default : lignes 18-90 — article wrapper, NuxtImg cover, content section, NuxtLink absolute inset-0) - .planning/phases/06-blog-pages/06-UI-SPEC.md (§BlogCard variant contract lignes 213-230 pour le layout EXACT des deux variants + §Typography + §Color pour les classes) - .planning/phases/06-blog-pages/06-PATTERNS.md (§BlogCard.vue lignes 152-252 — adaptation vs ProjectCard détaillée) - .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 6 lignes 520-556 pour la structure TypeScript + § Pitfall 6 lignes 625-629 pour le a11y SEO link) - .planning/phases/06-blog-pages/06-CONTEXT.md (D-02 : tags non-cliquables ; D-03 : pas de fallback image ; D-10 : pas d'image en variant compact) - i18n/locales/fr.json (après Task 2.1 — confirmer que blog.readingTime / prevArticle / nextArticle sont bien présents) Créer `app/components/BlogCard.vue` avec ` ``` **Template — variant default (listing) :** Transposition directe du pattern ProjectCard.vue. Différences : - `NuxtLink` utilise `localePath('/blog/' + slug)` (pas `/project/${id}`) - `aspect-[16/9]` sur l'image (pas `h-52`) - `

` (pas `

`) pour le titre — c'est un listing d'articles (hierarchie SEO) - Description `line-clamp-2` (pas `line-clamp-3`) - Footer row : reading time + tags supplémentaires (+N) à la place des technologies - Schema.org `BlogPosting` (pas `CreativeWork`) - **Cover image conditionnelle** : uniquement si `article.image` présent (D-03 pas de fallback) ```vue ``` **Décisions de conception documentées :** - `slug` calculé depuis `article.path` : les articles @nuxt/content ont un `path` de forme `/fr/blog/my-slug` → extraire le dernier segment. Évite de réclamer un champ `slug` explicite dans le frontmatter. - Variant compact sans image (D-10 + cohérent D-03 : pas de fallback image). - `absolute inset-0` SEO link pattern OK tant que tags restent non-cliquables (Pitfall 6 + D-02 respectés). - Schema.org : `BlogPosting` + `datePublished` + `headline` + `description` + `keywords` + `url` + `image` (prépare Phase 7 JSON-LD Article sans effort supplémentaire — tout est déjà structuré). - `text-right` sur variant=next, `text-left` sur prev : UX directionnelle (la flèche et le texte suivent la direction du clic). test -f app/components/BlogCard.vue && grep -c "variant === 'default'" app/components/BlogCard.vue - `test -f app/components/BlogCard.vue` retourne 0 - `grep -c "variant === 'default'" app/components/BlogCard.vue` retourne 1 (template branche) - `grep -c "variant?: 'default' | 'compact'" app/components/BlogCard.vue` retourne 1 (type union exact) - `grep -c "direction?: 'prev' | 'next'" app/components/BlogCard.vue` retourne 1 - `grep -c "withDefaults(defineProps()" app/components/BlogCard.vue` retourne 1 - `grep -c "Intl.DateTimeFormat" app/components/BlogCard.vue` retourne 1 - `grep -c "t('blog.readingTime'" app/components/BlogCard.vue` retourne 1 - `grep "localePath(\`/blog/\${slug}\`)" app/components/BlogCard.vue` retourne 2+ matches (au moins 2 NuxtLink) - `grep "useReadingTime" app/components/BlogCard.vue` retourne 1+ match (fallback utilisé) - `grep "i-lucide-arrow-left" app/components/BlogCard.vue` retourne 1 match (icône prev) - `grep "i-lucide-arrow-right" app/components/BlogCard.vue` retourne 1 match (icône next) - `grep "BlogPosting" app/components/BlogCard.vue` retourne 1 match (Schema.org) - `grep "aspect-\\[16/9\\]" app/components/BlogCard.vue` retourne 1 match (ratio cover listing) - `grep -c "a11y.blogPrev" app/components/BlogCard.vue` retourne 1 (label a11y interpolé) - `grep -c "a11y.blogNext" app/components/BlogCard.vue` retourne 1 - `pnpm typecheck` passe sans erreur TS - `pnpm lint` passe sans nouvelle erreur ESLint sur BlogCard.vue BlogCard.vue créé avec script setup TS, 2 variants (default + compact), date i18n via Intl.DateTimeFormat, reading time avec fallback `useReadingTime`, NuxtLink absolute inset-0 pour SEO/a11y (tags non-cliquables D-02 respecté), icônes arrow directionnelles avec translate hover. Schema.org BlogPosting markup. Auto-importé par Nuxt. Typecheck + lint verts. 1. `pnpm typecheck` passe 2. `pnpm lint` passe (pas de nouvelle erreur) 3. `pnpm dev` démarre sans erreur — le lien `Blog` apparaît dans la nav desktop après Hytale, avant Projets 4. Clic sur le lien `Blog` va vers `/fr/blog` (404 attendu à ce stade — la page sera créée Wave 3) 5. Validation JSON : `node -e "JSON.parse(require('fs').readFileSync('./i18n/locales/fr.json')); JSON.parse(require('fs').readFileSync('./i18n/locales/en.json')); console.log('valid')"` 6. Le composant BlogCard n'est consommé nulle part à ce stade — c'est normal, il sera utilisé par les pages de Wave 3. - fr.json et en.json contiennent tous les blocs blog.*, nav.blog, a11y.blog* (14+ clés ajoutées par locale) - AppHeader.vue a `{ key: 'blog', path: '/blog' }` à la bonne position dans navLinks (entre hytale et projects) - BlogCard.vue existe, typecheck vert, supporte variant default et compact - Aucune régression sur les clés i18n existantes ni sur la nav existante 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)