abb7964214
- 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>
291 lines
13 KiB
Markdown
291 lines
13 KiB
Markdown
---
|
|
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 && 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>
|