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.
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 |
|
true |
|
|
|
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 afterParseapp/utils/countWords.ts: traversal AST minimal body ignorant code/preapp/composables/useReadingTime.ts: fallback client (200 wpm)content/{fr,en}/blog/test-kotlin-syntax.md: ajoutdraft: truefrontmatter
<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[]]
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.
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.
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 devaprès cette tâche : les 2 articles test-kotlin-syntax.md (FR + EN) traversent le hook, reçoiventwordCount+minutesinjectés - Query
queryCollection('blog_fr').all()retourne chaque article avecminutes: numbervisible - Si la DB est stale (avant suppression
.nuxt/cache), forcerrm -rf node_modules/.cache/content .nuxtpuis 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.tsretourne 0grep -c "defineNitroPlugin" server/plugins/reading-time.tsretourne 1grep -c "content:file:afterParse" server/plugins/reading-time.tsretourne 1grep -c "countWordsInMinimalBody" server/plugins/reading-time.tsretourne 2 (import + call)grep "Math.max(1, Math.ceil(wordCount / 200))" server/plugins/reading-time.tsretourne 1 matchgrep "file.id?.endsWith('.md')" server/plugins/reading-time.tsretourne 1 matchpnpm typecheckpasse 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.
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>
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/blogaffichera 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-syntaxfonctionne 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.
<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: truedans leur frontmatter - pnpm typecheck passe, pnpm dev démarre clean </success_criteria>