8.6 KiB
8.6 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-seo-blog | 04 | execute | 2 |
|
|
true |
|
|
Purpose: Sans ce feed, le sitemap dynamique ne référence pas les articles → Google ne découvre pas les pages blog. Output: 1 endpoint Nitro créé.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/phases/07-seo-blog/07-CONTEXT.md @.planning/phases/07-seo-blog/07-RESEARCH.md @.planning/phases/07-seo-blog/07-PATTERNS.md @.planning/phases/07-seo-blog/07-01-SUMMARY.md @server/plugins/reading-time.ts @server/api/contact.post.ts **Critique (Pitfall 1 RESEARCH)** : Dans les routes Nitro, `queryCollection` prend `event` en PREMIER argument (contrairement au context client/SSR page). **Critique (Pitfall 2)** : Toujours strings littérales — `queryCollection(event, 'blog_fr')` puis `queryCollection(event, 'blog_en')`, JAMAIS `queryCollection(event, 'blog_' + locale)`.Import canonique : import { defineSitemapEventHandler } from '#imports' et import type { SitemapUrl } from '#sitemap/types' (fournis par @nuxtjs/sitemap v8).
Le schema blog (après 07-01) expose : path, date, updated?, draft, title, description, image?, tags?.
Convention paths @nuxt/content : /fr/blog/{slug} et /en/blog/{slug} — même slug = paire bilingue (Phase 5/6 convention).
```ts
/**
* Dynamic sitemap URL feed for @nuxtjs/sitemap.
* Referenced via nuxt.config.ts > sitemap.sources: ['/api/__sitemap__/urls'].
* Emits /fr/blog/{slug} + /en/blog/{slug} with hreflang alternates for bilingual pairs.
* Excludes drafts (D-10). lastmod = updated ?? date (D-09). See Pitfalls 1, 2, 5, 6 in RESEARCH.
*/
import { defineSitemapEventHandler } from '#imports'
import type { SitemapUrl } from '#sitemap/types'
const SITE_URL = 'https://killiandalcin.fr'
type BlogRow = {
path: string
date: string
updated?: string
}
export default defineSitemapEventHandler(async (event) => {
// Literal collection strings (Pitfall 2). Pass event first (Pitfall 1).
const [frArticles, enArticles] = await Promise.all([
queryCollection(event, 'blog_fr')
.where('draft', '=', false)
.order('date', 'DESC')
.select('path', 'date', 'updated')
.all() as unknown as Promise<BlogRow[]>,
queryCollection(event, 'blog_en')
.where('draft', '=', false)
.order('date', 'DESC')
.select('path', 'date', 'updated')
.all() as unknown as Promise<BlogRow[]>,
])
// Build slug → { fr?, en? } index for pair detection (D-11)
const extractSlug = (p: string) => p.split('/').filter(Boolean).pop()!
const index = new Map<string, { fr?: BlogRow; en?: BlogRow }>()
for (const a of frArticles) {
const s = extractSlug(a.path)
const e = index.get(s) ?? {}
e.fr = a
index.set(s, e)
}
for (const a of enArticles) {
const s = extractSlug(a.path)
const e = index.get(s) ?? {}
e.en = a
index.set(s, e)
}
const urls: SitemapUrl[] = []
for (const [slug, pair] of index) {
const bilingual = !!(pair.fr && pair.en)
const alternatives = bilingual
? [
{ hreflang: 'fr', href: `${SITE_URL}/fr/blog/${slug}` },
{ hreflang: 'en', href: `${SITE_URL}/en/blog/${slug}` },
{ hreflang: 'x-default', href: `${SITE_URL}/fr/blog/${slug}` },
]
: []
if (pair.fr) {
urls.push({
loc: `/fr/blog/${slug}`,
lastmod: pair.fr.updated ?? pair.fr.date,
alternatives,
})
}
if (pair.en) {
urls.push({
loc: `/en/blog/${slug}`,
lastmod: pair.en.updated ?? pair.en.date,
alternatives,
})
}
}
return urls
})
```
Ne PAS toucher aux autres fichiers server/. Ne PAS re-créer `public/sitemap.xml` (FIX-01 supprimé).
test -f server/api/__sitemap__/urls.ts && grep -q "defineSitemapEventHandler" server/api/__sitemap__/urls.ts && grep -q "queryCollection(event, 'blog_fr')" server/api/__sitemap__/urls.ts && grep -q "queryCollection(event, 'blog_en')" server/api/__sitemap__/urls.ts && grep -q "'x-default'" server/api/__sitemap__/urls.ts && pnpm typecheck && pnpm dev --port 3000 & sleep 12 && curl -s http://localhost:3000/sitemap.xml | tee /tmp/sitemap.xml | grep -q '/fr/blog/' && grep -q '/en/blog/' /tmp/sitemap.xml && grep -q 'hreflang="x-default"' /tmp/sitemap.xml && ! grep -q 'test-kotlin-syntax' /tmp/sitemap.xml && kill %1
curl /sitemap.xml contient : au moins une URL /fr/blog/... ET /en/blog/..., xhtml:link hreflang=fr/en/x-default pour paires bilingues, les articles draft (ex: test-kotlin-syntax) SONT ABSENTS. typecheck vert.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| client (crawler) → /sitemap.xml | Endpoint public lecture seule, agrégation d'URLs publiques |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-07-06 | Information Disclosure | Drafts (contenu non publié) | mitigate | Filtre obligatoire .where('draft', '=', false) — testé dans verify (absence test-kotlin-syntax) |
| T-07-07 | DoS | Endpoint sitemap (query SQLite à chaque hit) | accept | @nuxtjs/sitemap v8 met en cache ; volume d'articles petit (<100) |
| T-07-08 | Tampering | extractSlug parse path |
mitigate | path est trusté (généré par @nuxt/content depuis le filesystem, pas user input) |
| </threat_model> |
<success_criteria>
- SEO-12 :
curl /sitemap.xmlcontient/fr/blog/{slug}ET/en/blog/{slug}pour chaque article non-draft - D-10 respecté : drafts absents du sitemap
- D-11 respecté : paires bilingues portent les 3 alternates (fr, en, x-default); articles mono-langue pas d'alternate
- D-09 respecté :
lastmodreflèteupdated ?? date</success_criteria>