Phase 6 Blog Pages decomposed into: - 06-01 (Wave 1): content schema + reading-time Nitro hook + draft flags - 06-02 (Wave 2): i18n keys + AppHeader link + BlogCard unified - 06-03 (Wave 3): listing page /blog SSR bilingue - 06-04 (Wave 3): [slug] enrichment + BlogToc + BlogPrevNext Plans 06-03 and 06-04 have zero file overlap and run in parallel. Covers BLOG-02, BLOG-03, BLOG-06. Honors all 21 D-XX user decisions from 06-CONTEXT.md. Phase 5 gotchas (literal queryCollection, single [slug].vue, no routeRules /blog/**) respected in every query branch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
29 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | tags | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-blog-pages | 02 | execute | 2 |
|
true |
|
|
|
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: blocblog.*complet +nav.blog+ 3 clésa11y.blog*app/components/layout/AppHeader.vue: entrée{ key: 'blog', path: '/blog' }ajoutée dansnavLinksentre hytale et projectsapp/components/BlogCard.vue: composant variant default + compact, auto-importé par Nuxt
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 ```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) } ```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'
}
<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>
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.
-
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é. -
Dans le bloc existant
"a11y": { ... }(lignes 23-34), ajouter 3 nouvelles clés à la fin du bloc :
"blogTocToggle": "Afficher le sommaire",
"blogPrev": "Article précédent : {title}",
"blogNext": "Article suivant : {title}"
- 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 :
"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 :
-
nav.blog="Blog" -
a11y.blog*:
"blogTocToggle": "Show table of contents",
"blogPrev": "Previous article: {title}",
"blogNext": "Next article: {title}"
- Bloc
blog:
"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 blocprojectsexistant), PAS le pattern ASCII des blocsa11y/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)" <acceptance_criteria>node -e "console.log(require('./i18n/locales/fr.json').nav.blog)"afficheBlognode -e "console.log(require('./i18n/locales/en.json').nav.blog)"afficheBlognode -e "console.log(require('./i18n/locales/fr.json').blog.title)"afficheBlognode -e "console.log(require('./i18n/locales/fr.json').blog.subtitle)"commence parArticles techniquesnode -e "console.log(require('./i18n/locales/en.json').blog.subtitle)"commence parTechnical articlesnode -e "console.log(require('./i18n/locales/fr.json').blog.readingTime)"affiche{minutes} min de lecturenode -e "console.log(require('./i18n/locales/en.json').blog.readingTime)"affiche{minutes} min readnode -e "console.log(require('./i18n/locales/fr.json').blog.emptyState.cta)"afficheMe contacternode -e "console.log(require('./i18n/locales/en.json').blog.emptyState.cta)"afficheContact menode -e "console.log(require('./i18n/locales/fr.json').blog.toc.title)"afficheSommairenode -e "console.log(require('./i18n/locales/en.json').blog.toc.title)"afficheTable of contentsnode -e "console.log(require('./i18n/locales/fr.json').a11y.blogTocToggle)"afficheAfficher le sommairenode -e "console.log(require('./i18n/locales/fr.json').a11y.blogPrev)"contient{title}node -e "console.log(require('./i18n/locales/fr.json').blog.breadcrumb.home)"afficheAccueilnode -e "console.log(require('./i18n/locales/en.json').blog.breadcrumb.home)"afficheHomenode -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 parnode -e "console.log(require('./i18n/locales/fr.json').nav.hytale)"=Hytale</acceptance_criteria> Les deux fichiers i18n contiennentnav.blog, 3 clésa11y.blog*, et un blocblog.*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.
Avant :
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 :
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.blogajouté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.vueretourne 1 -grep -n "key: 'hytale'" app/components/layout/AppHeader.vueretourne une ligne (ex: ligne 10) -grep -n "key: 'blog'" app/components/layout/AppHeader.vueretourne une ligne (ex: ligne 11) -grep -n "key: 'projects'" app/components/layout/AppHeader.vueretourne une ligne (ex: ligne 12) - Le numéro de ligne dekey: 'blog'est STRICTEMENT entre celui dekey: 'hytale'etkey: 'projects'(ordre respecté D-15) -grep -c "key: 'home'" app/components/layout/AppHeader.vueretourne 1 (pas de duplication/suppression) -grep -c "key: 'fiverr'" app/components/layout/AppHeader.vueretourne 1 -grep -c "v-for="link in navLinks"" app/components/layout/AppHeader.vueretourne 2 (desktop + mobile templates intacts) -pnpm typecheckpasse -pnpm dev+ visite manuelle de/fr/montre un lienBlogentreHytaleetProjetsdans 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éBlogvient denav.blog` (Task 2.1).
Script setup complet :
<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 :
NuxtLinkutiliselocalePath('/blog/' + slug)(pas/project/${id})aspect-[16/9]sur l'image (pash-52)<h2>(pas<h3>) pour le titre — c'est un listing d'articles (hierarchie SEO)- Description
line-clamp-2(pasline-clamp-3) - Footer row : reading time + tags supplémentaires (+N) à la place des technologies
- Schema.org
BlogPosting(pasCreativeWork) - Cover image conditionnelle : uniquement si
article.imageprésent (D-03 pas de fallback)
<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 :
slugcalculé depuisarticle.path: les articles @nuxt/content ont unpathde forme/fr/blog/my-slug→ extraire le dernier segment. Évite de réclamer un champslugexplicite dans le frontmatter.- Variant compact sans image (D-10 + cohérent D-03 : pas de fallback image).
absolute inset-0SEO 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-rightsur variant=next,text-leftsur 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 <acceptance_criteria>test -f app/components/BlogCard.vueretourne 0grep -c "variant === 'default'" app/components/BlogCard.vueretourne 1 (template branche)grep -c "variant?: 'default' | 'compact'" app/components/BlogCard.vueretourne 1 (type union exact)grep -c "direction?: 'prev' | 'next'" app/components/BlogCard.vueretourne 1grep -c "withDefaults(defineProps<Props>()" app/components/BlogCard.vueretourne 1grep -c "Intl.DateTimeFormat" app/components/BlogCard.vueretourne 1grep -c "t('blog.readingTime'" app/components/BlogCard.vueretourne 1grep "localePath(\/blog/${slug}`)" app/components/BlogCard.vue` retourne 2+ matches (au moins 2 NuxtLink)grep "useReadingTime" app/components/BlogCard.vueretourne 1+ match (fallback utilisé)grep "i-lucide-arrow-left" app/components/BlogCard.vueretourne 1 match (icône prev)grep "i-lucide-arrow-right" app/components/BlogCard.vueretourne 1 match (icône next)grep "BlogPosting" app/components/BlogCard.vueretourne 1 match (Schema.org)grep "aspect-\\[16/9\\]" app/components/BlogCard.vueretourne 1 match (ratio cover listing)grep -c "a11y.blogPrev" app/components/BlogCard.vueretourne 1 (label a11y interpolé)grep -c "a11y.blogNext" app/components/BlogCard.vueretourne 1pnpm typecheckpasse sans erreur TSpnpm lintpasse sans nouvelle erreur ESLint sur BlogCard.vue </acceptance_criteria> BlogCard.vue créé avec script setup TS, 2 variants (default + compact), date i18n via Intl.DateTimeFormat, reading time avec fallbackuseReadingTime, 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.
<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>