docs(08): create phase plan — content & cocon sémantique (3 plans, 2 waves)

- 08-01 (W1): HytaleRecentArticles.vue scaffold + injection hytale.vue + i18n
- 08-02 (W2): article tutorial how-to-build-your-first-hytale-plugin FR+EN
- 08-03 (W2): article positionnement hytale-plugin-development-2026 FR+EN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 18:38:13 +02:00
parent 0d92729654
commit abb7964214
4 changed files with 909 additions and 5 deletions
@@ -0,0 +1,290 @@
---
phase: 08-content-cocon-semantique
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- app/components/HytaleRecentArticles.vue
- app/pages/hytale.vue
- i18n/locales/fr.json
- i18n/locales/en.json
autonomous: true
requirements: [SEO-14]
tags: [nuxt-content, i18n, hytale, cocon-semantique]
must_haves:
truths:
- "Visiter /hytale affiche une section 'Articles récents' (uniquement si ≥1 article tagué hytale avec draft:false existe en base content)"
- "La section réutilise BlogCard variant compact en grille 2 colonnes desktop / 1 colonne mobile"
- "Switch FR/EN met à jour la section (useAsyncData key inclut la locale + watch)"
- "Si 0 article hytale publié, la section est entièrement masquée (pas d'empty state)"
- "Un lien 'Voir tous les articles' pointe vers /blog (FR) ou /en/blog (EN) via localePath"
artifacts:
- path: "app/components/HytaleRecentArticles.vue"
provides: "Section composant auto-importé, queryCollection branches littérales + filtre tag hytale + limit 2"
contains: "queryCollection('blog_fr')"
- path: "app/pages/hytale.vue"
provides: "Insertion <HytaleRecentArticles /> avant fermeture du root div"
contains: "<HytaleRecentArticles"
- path: "i18n/locales/fr.json"
provides: "Clés hytale.recentArticles.title/subtitle/viewAll en FR accentué"
contains: "recentArticles"
- path: "i18n/locales/en.json"
provides: "Clés hytale.recentArticles.title/subtitle/viewAll en EN"
contains: "recentArticles"
key_links:
- from: "app/components/HytaleRecentArticles.vue"
to: "BlogCard.vue (variant compact)"
via: "auto-import Nuxt + props article+variant"
pattern: "variant=\"compact\""
- from: "app/components/HytaleRecentArticles.vue"
to: "@nuxt/content collections blog_fr / blog_en"
via: "queryCollection(literal).where('tags', ...).limit(2)"
pattern: "queryCollection\\('blog_(fr|en)'\\)"
- from: "app/pages/hytale.vue"
to: "app/components/HytaleRecentArticles.vue"
via: "auto-import + template insertion"
pattern: "<HytaleRecentArticles"
---
<objective>
Scaffolder l'infrastructure technique du cocon sémantique côté /hytale : composant `HytaleRecentArticles.vue` (queryCollection bilingue, filtre tag=hytale, limit 2, masqué si vide), injection dans `app/pages/hytale.vue`, et clés i18n associées en FR/EN.
Purpose: Préparer le conteneur qui affichera les 2 articles seed publiés en Wave 2. Le composant doit dégrader gracieusement (v-if=length) tant que les articles ne sont pas encore publiés, ce qui permet de shipper cette Wave 1 sans casser /hytale.
Output: 1 nouveau composant + 1 page modifiée + 2 fichiers i18n mis à jour. Aucun article créé à ce stade.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-content-cocon-semantique/08-CONTEXT.md
@.planning/phases/08-content-cocon-semantique/08-PATTERNS.md
@app/pages/blog/index.vue
@app/components/BlogCard.vue
@app/pages/hytale.vue
<interfaces>
<!-- BlogCard.vue props (from app/components/BlogCard.vue lines 2-21) -->
```typescript
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' // default 'next'
}
```
<!-- queryCollection bilingual pattern (from app/pages/blog/index.vue lines 1-21) -->
```typescript
const { t, locale } = useI18n()
const localePath = useLocalePath()
const isFr = computed(() => locale.value === 'fr')
const { data: articles } = await useAsyncData(
`hytale-recent-${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] },
)
```
<!-- i18n insertion point: existing hytale.* block in i18n/locales/fr.json starts ~line 471 (per PATTERNS.md). Add recentArticles sibling to hero/services/pricing. -->
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Créer composant HytaleRecentArticles.vue (query + filtre tag + render)</name>
<files>app/components/HytaleRecentArticles.vue</files>
<read_first>
- app/pages/blog/index.vue (pattern queryCollection bilingue branches littérales, lignes 1-21)
- app/components/BlogCard.vue (variant compact, props interface lignes 2-21)
- .planning/phases/08-content-cocon-semantique/08-PATTERNS.md §"HytaleRecentArticles.vue"
- .planning/phases/08-content-cocon-semantique/08-CONTEXT.md §D-10, D-11, D-12, D-13
</read_first>
<action>
Créer `app/components/HytaleRecentArticles.vue` (~70-90 lignes).
**Script setup (TypeScript strict) :**
- `const { t, locale } = useI18n()` + `const localePath = useLocalePath()`
- `const isFr = computed(() => locale.value === 'fr')`
- `useAsyncData` avec key littérale interpolée `` `hytale-recent-${locale.value}` ``, ternaire `isFr.value` → `queryCollection('blog_fr')` / `queryCollection('blog_en')` (branches littérales obligatoires — Pitfall Phase 5 D-03, voir STATE.md gotcha).
- Chaîne de la query : `.where('draft', '=', false).order('date', 'DESC').all()`**SANS `.limit(2)` au SQL ni `.where('tags', 'LIKE', ...)`** (l'opérateur LIKE sur champ JSON array SQLite n'est pas fiable selon D-11). À la place, **filtre JS post-query** :
```ts
const articles = computed(() => {
const all = data.value ?? []
return all.filter((a) => Array.isArray(a.tags) && a.tags.includes('hytale')).slice(0, 2)
})
```
(renomme la destructuration `useAsyncData` en `{ data }` et expose `articles` computed — documente en commentaire `// Filtre JS car LIKE SQLite unreliable sur tags[] — D-11`).
- Option `{ watch: [locale] }` sur useAsyncData (re-fetch au switch langue).
**Template :**
- `<section v-if="articles.length" class="py-16 md:py-20 px-4 sm:px-6 lg:px-8">`
- Wrapper intérieur `max-w-7xl mx-auto` pour cohérence /blog.
- Header section : petit `<span>// recent-articles</span>` style mono brand + `<h2>{{ t('hytale.recentArticles.title') }}</h2>` (réutiliser tailwind styles de /blog hero h1, taille h2 plus sobre : `text-3xl sm:text-4xl font-bold`) + `<p>{{ t('hytale.recentArticles.subtitle') }}</p>` si clé présente.
- Grille : `<div class="grid grid-cols-1 md:grid-cols-2 gap-5 lg:gap-6 mt-8">` avec `<BlogCard v-for="article in articles" :key="article.path" :article="article" variant="compact" />` (pas de `direction` → default 'next' accepté, D-13 ne spécifie pas prev/next sémantique, acceptable).
- Footer : `<NuxtLink :to="localePath('/blog')" class="inline-flex items-center gap-2 mt-8 text-brand-500 hover:text-brand-600 font-medium">{{ t('hytale.recentArticles.viewAll') }} <UIcon name="i-lucide-arrow-right" /></NuxtLink>`.
**Règles strictes (D-09, D-10, D-11, D-12, D-13) :**
- BlogCard **auto-importé** — pas d'import explicite.
- Pas de fallback empty state (D-12 : masquer section complète).
- Pas d'usage de `queryCollection(variableName)` — littéraux uniquement.
</action>
<verify>
<automated>pnpm typecheck</automated>
</verify>
<done>
- Fichier `app/components/HytaleRecentArticles.vue` existe
- `grep -E "queryCollection\\('blog_(fr|en)'\\)" app/components/HytaleRecentArticles.vue` retourne les 2 branches
- `grep "v-if=\"articles.length\"" app/components/HytaleRecentArticles.vue` passe
- `grep "variant=\"compact\"" app/components/HytaleRecentArticles.vue` passe
- `grep "tags.*includes.*'hytale'" app/components/HytaleRecentArticles.vue` passe (filtre JS)
- `pnpm typecheck` exit 0
</done>
</task>
<task type="auto">
<name>Task 2: Injecter HytaleRecentArticles dans app/pages/hytale.vue + ajouter clés i18n FR/EN</name>
<files>app/pages/hytale.vue, i18n/locales/fr.json, i18n/locales/en.json</files>
<read_first>
- app/pages/hytale.vue (39 lignes, template section actuelle)
- .planning/phases/08-content-cocon-semantique/08-PATTERNS.md §"app/pages/hytale.vue" + §"i18n/locales"
- i18n/locales/fr.json (bloc hytale.* ~ligne 471, blog.* ~ligne 557 pour le style accentué 2026)
- i18n/locales/en.json (bloc hytale.* miroir)
- .planning/phases/08-content-cocon-semantique/08-CONTEXT.md §D-14
</read_first>
<action>
**Étape 1 — `app/pages/hytale.vue` :**
Template actuel (lignes 30-39) :
```vue
<template>
<div>
<HytaleHeroSection />
<HytaleServicesSection />
<HytalePricingSection />
<div class="relative bg-gray-50/50 dark:bg-gray-900/20">
<TestimonialsSection />
</div>
</div>
</template>
```
Modification exacte : insérer `<HytaleRecentArticles />` **après** le `</div>` qui ferme le wrapper testimonials, **avant** le `</div>` final du root. Résultat attendu :
```vue
<template>
<div>
<HytaleHeroSection />
<HytaleServicesSection />
<HytalePricingSection />
<div class="relative bg-gray-50/50 dark:bg-gray-900/20">
<TestimonialsSection />
</div>
<HytaleRecentArticles />
</div>
</template>
```
Aucun changement dans le `<script setup>`. Aucun import (auto-import Nuxt).
**Étape 2 — `i18n/locales/fr.json` :**
Localiser le bloc `"hytale": { ... }` (début ~ligne 471). Ajouter un sous-objet `recentArticles` en sibling de `hero`/`services`/`pricing` (ordre libre, mais placer à la fin du bloc hytale pour minimiser le diff). Style **accentué** (cohérent avec `blog.*` ajouté Phase 6-02, voir PATTERNS.md §i18n) :
```json
"recentArticles": {
"title": "Articles récents",
"subtitle": "Les dernières publications sur le développement de plugins Hytale",
"viewAll": "Voir tous les articles"
}
```
**Étape 3 — `i18n/locales/en.json` :**
Miroir exact dans le bloc `"hytale": { ... }` :
```json
"recentArticles": {
"title": "Recent articles",
"subtitle": "Latest writing on Hytale plugin development",
"viewAll": "View all articles"
}
```
**Règles :**
- JSON valide : pas de trailing comma, double quotes, virgule correcte entre la clé sibling précédente et `recentArticles`.
- Conserver l'indentation existante du fichier.
- Ne PAS modifier d'autres clés.
</action>
<verify>
<automated>pnpm typecheck &amp;&amp; node -e "JSON.parse(require('fs').readFileSync('i18n/locales/fr.json','utf8')); JSON.parse(require('fs').readFileSync('i18n/locales/en.json','utf8'))"</automated>
</verify>
<done>
- `grep "<HytaleRecentArticles" app/pages/hytale.vue` passe
- `grep -A 3 "\"recentArticles\"" i18n/locales/fr.json` affiche title/subtitle/viewAll
- `grep -A 3 "\"recentArticles\"" i18n/locales/en.json` affiche title/subtitle/viewAll
- `pnpm typecheck` exit 0
- JSON parse FR + EN sans erreur
- Run `pnpm dev` puis `curl http://localhost:3000/hytale` → HTML rendu sans erreur 500 (section absente tant que 0 article hytale, conforme D-12)
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| content DB → SSR render | Données lues par queryCollection ; Zod-validées Phase 5, pas d'user input |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-01 | T (Tampering) | filtre JS tags.includes('hytale') | mitigate | `Array.isArray(a.tags)` guard avant `.includes()` pour éviter TypeError si frontmatter cassé passe le schema |
| T-08-02 | I (Info Disclosure) | queryCollection where draft=false | mitigate | Filtre draft=false SQL obligatoire (déjà pattern éprouvé /blog) — pas de leak d'articles draft:true |
| T-08-03 | D (DoS) | limit 2 post-filter | accept | Limite post-filter sur JS, volume d'articles < 100 attendu, négligeable |
</threat_model>
<verification>
- `pnpm typecheck` exit 0
- `curl http://localhost:3000/hytale` et `curl http://localhost:3000/en/hytale` retournent 200 SSR sans erreur
- Tant qu'aucun article hytale:true n'existe, section invisible dans HTML (grep `recentArticles` absent de la sortie curl) — conforme D-12
- Après Wave 2, re-curl : section visible avec 2 slugs
</verification>
<success_criteria>
- Composant HytaleRecentArticles.vue livré, auto-importé, TypeScript strict, pattern Phase 5 Pitfall-safe
- app/pages/hytale.vue injecte `<HytaleRecentArticles />` en dernière position du root
- Clés i18n FR+EN présentes sous `hytale.recentArticles.*`
- Zéro erreur typecheck, zéro warning console SSR sur /hytale
</success_criteria>
<output>
Après complétion, créer `.planning/phases/08-content-cocon-semantique/08-01-SUMMARY.md` (template summary).
</output>