docs(07): plan SEO blog — 4 plans (schema-org, useSeoMeta enrich, sitemap Nitro) .planning/phases/07-seo-blog/07-01-PLAN.md .planning/phases/07-seo-blog/07-02-PLAN.md .planning/phases/07-seo-blog/07-03-PLAN.md .planning/phases/07-seo-blog/07-04-PLAN.md .planning/ROADMAP.md
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
---
|
||||
phase: 07-seo-blog
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [07-01]
|
||||
files_modified:
|
||||
- server/api/__sitemap__/urls.ts
|
||||
autonomous: true
|
||||
requirements: [SEO-12]
|
||||
must_haves:
|
||||
truths:
|
||||
- "curl /sitemap.xml contient les URLs /fr/blog/{slug} ET /en/blog/{slug} pour chaque article non-draft"
|
||||
- "Chaque entrée d'un article bilingue contient xhtml:link alternate hreflang=fr, hreflang=en, et hreflang=x-default pointant vers la version FR"
|
||||
- "Articles draft:true sont ABSENTS du sitemap"
|
||||
- "lastmod = updated frontmatter si présent, sinon date"
|
||||
artifacts:
|
||||
- path: "server/api/__sitemap__/urls.ts"
|
||||
provides: "defineSitemapEventHandler retournant SitemapUrl[] bilingue"
|
||||
contains: "defineSitemapEventHandler"
|
||||
key_links:
|
||||
- from: "nuxt.config.ts > sitemap.sources"
|
||||
to: "server/api/__sitemap__/urls.ts"
|
||||
via: "/api/__sitemap__/urls HTTP route"
|
||||
pattern: "__sitemap__/urls"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Créer l'endpoint Nitro `server/api/__sitemap__/urls.ts` qui alimente `@nuxtjs/sitemap` avec les URLs /blog/{slug} bilingues + alternates hreflang, filtrées sur `draft=false`, avec `lastmod` dérivé de `updated ?? date` (D-08, D-09, D-10, D-11, SEO-12).
|
||||
|
||||
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éé.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
**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).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Créer server/api/__sitemap__/urls.ts — feed sitemap bilingue avec alternates hreflang</name>
|
||||
<files>server/api/__sitemap__/urls.ts</files>
|
||||
<read_first>
|
||||
- server/plugins/reading-time.ts (pattern Nitro ctx repo)
|
||||
- server/api/contact.post.ts (pattern defineEventHandler)
|
||||
- .planning/phases/07-seo-blog/07-RESEARCH.md §Pattern 3 (Nitro Sitemap Source Endpoint), Pitfalls 1, 2, 5, 6
|
||||
- .planning/phases/07-seo-blog/07-PATTERNS.md §server/api/__sitemap__/urls.ts (new)
|
||||
- .planning/phases/07-seo-blog/07-CONTEXT.md D-08, D-09, D-10, D-11
|
||||
</read_first>
|
||||
<action>
|
||||
Créer le dossier `server/api/__sitemap__/` (s'il n'existe pas) puis le fichier `server/api/__sitemap__/urls.ts` avec le contenu exact ci-dessous :
|
||||
|
||||
```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é).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
- Endpoint en place : `test -f server/api/__sitemap__/urls.ts`
|
||||
- event first-arg (Pitfall 1) : `grep "queryCollection(event, 'blog_" server/api/__sitemap__/urls.ts` (2 matchs attendus)
|
||||
- Drafts exclus (Pitfall 5) : `grep "draft.*false" server/api/__sitemap__/urls.ts`
|
||||
- Sitemap HTTP : `curl /sitemap.xml | grep '/fr/blog/'` et `/en/blog/`
|
||||
- hreflang : `curl /sitemap.xml | grep 'hreflang="x-default"'`
|
||||
- Drafts filtrés en runtime : `curl /sitemap.xml | grep test-kotlin-syntax` DOIT retourner exit 1
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. SEO-12 : `curl /sitemap.xml` contient `/fr/blog/{slug}` ET `/en/blog/{slug}` pour chaque article non-draft
|
||||
2. D-10 respecté : drafts absents du sitemap
|
||||
3. D-11 respecté : paires bilingues portent les 3 alternates (fr, en, x-default); articles mono-langue pas d'alternate
|
||||
4. D-09 respecté : `lastmod` reflète `updated ?? date`
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Après complétion, créer `.planning/phases/07-seo-blog/07-04-SUMMARY.md`.
|
||||
</output>
|
||||
Reference in New Issue
Block a user