--- 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*=" --- 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 @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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(), }) ``` ```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, }), ``` ```typescript 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 : ```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). grep -c "draft: z.boolean().optional().default(false)" content.config.ts - `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 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 : ```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. test -f app/utils/countWords.ts && grep -c "export function countWordsInMinimalBody" app/utils/countWords.ts - `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) 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 : ```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 test -f server/plugins/reading-time.ts && grep -c "content:file:afterParse" server/plugins/reading-time.ts - `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) 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 : ```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 {{ t('blog.readingTime', { minutes: article.minutes ?? useReadingTime(article.description ?? '') }) }} ``` 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 : ```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. grep -c "^draft: true$" content/fr/blog/test-kotlin-syntax.md content/en/blog/test-kotlin-syntax.md - `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) 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) - 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 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