Files
portfolio/.planning/phases/06-blog-pages/06-01-PLAN.md
T
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

25 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 01 execute 1
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
true
BLOG-02
BLOG-03
BLOG-06
blog
content-schema
reading-time
nitro-plugin
truths artifacts key_links
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é
path provides contains
content.config.ts Schema blogSchema étendu avec draft/wordCount/minutes draft: z.boolean().optional().default(false)
path provides contains
server/plugins/reading-time.ts Nitro plugin hook content:file:afterParse injectant wordCount + minutes content:file:afterParse
path provides exports
app/utils/countWords.ts Fonction pure `countWordsInMinimalBody(body)` ignorant code/pre tags
countWordsInMinimalBody
path provides exports
app/composables/useReadingTime.ts Helper client fallback (200 wpm) quand hook indisponible
useReadingTime
path provides contains
content/fr/blog/test-kotlin-syntax.md Article de test marqué draft: true (filtré des listings) draft: true
path provides contains
content/en/blog/test-kotlin-syntax.md Version EN marquée draft: true draft: true
from to via pattern
server/plugins/reading-time.ts app/utils/countWords.ts import { countWordsInMinimalBody } from '~/utils/countWords' countWordsInMinimalBody
from to via pattern
server/plugins/reading-time.ts content.config.ts schema content.wordCount / content.minutes injectés → exposés via Zod optional fields content.minutes\s*=
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

<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/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 ```typescript const blogSchema = z.object({ title: z.string(), description: z.string(), date: z.string(), tags: z.array(z.string()).optional(), image: z.string().optional(), }) ```
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,
}),
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('request', (event) => { /* ... */ })
})
body = { type: 'minimal', value: MinimalNode[] }
MinimalNode = string | [tag: string, attrs: object, ...children: MinimalNode[]]
Task 1.1 : Étendre le schema Zod de content.config.ts (draft + wordCount + minutes) content.config.ts - 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) 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 :

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) :

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). grep -c "draft: z.boolean().optional().default(false)" content.config.ts <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> 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.

Task 1.2 : Créer app/utils/countWords.ts (pure AST traversal ignorant code/pre) app/utils/countWords.ts - .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 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 :

/**
 * 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. test -f app/utils/countWords.ts && grep -c "export function countWordsInMinimalBody" app/utils/countWords.ts <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> 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.

Task 1.3 : Créer server/plugins/reading-time.ts (hook content:file:afterParse) server/plugins/reading-time.ts - 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) 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 :

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 test -f server/plugins/reading-time.ts && grep -c "content:file:afterParse" server/plugins/reading-time.ts <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> 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.
Task 1.4 : Créer app/composables/useReadingTime.ts (fallback client 200 wpm) app/composables/useReadingTime.ts - .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) 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 :

/**
 * 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) :

<!-- BlogCard.vue template -->
<span>{{ t('blog.readingTime', { minutes: article.minutes ?? useReadingTime(article.description ?? '') }) }}</span>
test -f app/composables/useReadingTime.ts && grep -c "export function useReadingTime" app/composables/useReadingTime.ts - `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 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*`). Task 1.5 : Marquer les articles test-kotlin-syntax.md (FR + EN) comme `draft: true` - content/fr/blog/test-kotlin-syntax.md - content/en/blog/test-kotlin-syntax.md - 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) 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 :

---
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 :

---
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. grep -c "^draft: true$" content/fr/blog/test-kotlin-syntax.md content/en/blog/test-kotlin-syntax.md <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> 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.

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)

<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>
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