Files
kayjaydee d1ac5f9ee6 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.
2026-04-22 01:09:25 +02:00

501 lines
25 KiB
Markdown

---
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[] }``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>