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