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,213 @@
|
||||
---
|
||||
phase: 07-seo-blog
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- nuxt.config.ts
|
||||
- content.config.ts
|
||||
- app/app.vue
|
||||
- app/utils/seo-person.ts
|
||||
autonomous: true
|
||||
requirements: [SEO-11, SEO-12]
|
||||
must_haves:
|
||||
truths:
|
||||
- "nuxt-schema-org est installé et chargé comme module Nuxt"
|
||||
- "Schema Zod blog_fr/blog_en accepte `updated` (ISO string) en plus de `image`"
|
||||
- "Une identité Person Killian globale (definePerson) + defineWebSite est émise dans chaque page SSR"
|
||||
- "nuxt.config.ts référence /api/__sitemap__/urls dans sitemap.sources"
|
||||
artifacts:
|
||||
- path: "app/utils/seo-person.ts"
|
||||
provides: "KILLIAN_PERSON_ID const + killianPerson object (dérivé de siteConfig)"
|
||||
contains: "export const KILLIAN_PERSON_ID"
|
||||
- path: "content.config.ts"
|
||||
provides: "blogSchema étendu avec updated.optional()"
|
||||
contains: "updated: z.string().optional()"
|
||||
- path: "nuxt.config.ts"
|
||||
provides: "module nuxt-schema-org + sitemap.sources"
|
||||
contains: "nuxt-schema-org"
|
||||
- path: "app/app.vue"
|
||||
provides: "useSchemaOrg global (definePerson + defineWebSite)"
|
||||
contains: "useSchemaOrg"
|
||||
key_links:
|
||||
- from: "app/app.vue"
|
||||
to: "app/utils/seo-person.ts"
|
||||
via: "import killianPerson"
|
||||
pattern: "killianPerson"
|
||||
- from: "nuxt.config.ts"
|
||||
to: "/api/__sitemap__/urls"
|
||||
via: "sitemap.sources"
|
||||
pattern: "sitemap.*sources.*__sitemap__/urls"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fondation SEO Blog : installer `nuxt-schema-org`, étendre le schema Zod `blog_fr`/`blog_en` avec `updated`, déclarer l'identité Killian globale (Person + WebSite) dans `app.vue`, et brancher le sitemap dynamique sur un endpoint Nitro (déclaration uniquement — l'endpoint est créé plan 07-04).
|
||||
|
||||
Purpose: Aucun des plans Wave 2 ne peut fonctionner sans (a) le module `nuxt-schema-org` présent dans `modules[]`, (b) le champ `updated` queryable, (c) l'identité Person disponible par `@id` global, (d) `sitemap.sources` wiré.
|
||||
Output: package installé, 1 fichier utilitaire créé, 3 fichiers config/racine modifiés.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/07-seo-blog/07-CONTEXT.md
|
||||
@.planning/phases/07-seo-blog/07-RESEARCH.md
|
||||
@.planning/phases/07-seo-blog/07-PATTERNS.md
|
||||
@nuxt.config.ts
|
||||
@content.config.ts
|
||||
@app/app.vue
|
||||
@app/data/site.ts
|
||||
|
||||
<interfaces>
|
||||
Depuis app/data/site.ts :
|
||||
- `siteConfig.url` = 'https://killiandalcin.fr'
|
||||
- `siteConfig.social` = tableau avec entrées Gitea, LinkedIn, Discord, Email (reprendre `url` pour `sameAs`)
|
||||
|
||||
Depuis content.config.ts (existant) :
|
||||
```ts
|
||||
const blogSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
date: z.string(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
image: z.string().optional(), // DÉJÀ présent (D-14 #2 = no-op)
|
||||
draft: z.boolean().optional().default(false),
|
||||
wordCount: z.number().optional(),
|
||||
minutes: z.number().optional(),
|
||||
})
|
||||
```
|
||||
|
||||
Depuis app/app.vue (existant) : `useHead` + `useLocaleHead({ seo: true })` — NE PAS remplacer, APPEND.
|
||||
|
||||
Auto-imports nuxt-schema-org (une fois module ajouté) : `useSchemaOrg`, `definePerson`, `defineWebSite`, `defineArticle`, `defineBreadcrumb`, `defineWebPage`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Installer nuxt-schema-org + étendre content.config.ts (schema updated)</name>
|
||||
<files>package.json, pnpm-lock.yaml, content.config.ts</files>
|
||||
<read_first>
|
||||
- package.json (vérifier absence de nuxt-schema-org)
|
||||
- content.config.ts (schéma actuel, ligne 3-12)
|
||||
- .planning/phases/07-seo-blog/07-RESEARCH.md §Standard Stack (version cible ^6.0.4)
|
||||
- .planning/phases/07-seo-blog/07-RESEARCH.md Pitfall 8 (cache invalidation)
|
||||
- .planning/phases/07-seo-blog/07-PATTERNS.md §content.config.ts (modify)
|
||||
</read_first>
|
||||
<action>
|
||||
1. Installer : `pnpm add -D nuxt-schema-org@^6.0.4` (D-01, D-04 — NE PAS installer `@nuxtjs/seo` umbrella).
|
||||
2. Dans `content.config.ts`, modifier `blogSchema` : ajouter exactement la ligne `updated: z.string().optional(),` entre `date: z.string(),` et `tags: z.array(z.string()).optional(),` (D-13, D-14). Ne PAS toucher aux autres champs (`image` déjà présent).
|
||||
3. Vider les caches pour forcer la re-ingestion : `rm -rf node_modules/.cache/content .nuxt` (Pitfall 8 RESEARCH).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q '"nuxt-schema-org"' package.json && grep -q 'updated: z.string().optional()' content.config.ts && pnpm typecheck</automated>
|
||||
</verify>
|
||||
<done>nuxt-schema-org^6.0.4 dans devDependencies, `updated: z.string().optional()` présent dans blogSchema, caches vidés, typecheck exit 0.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Enregistrer module + sitemap.sources dans nuxt.config.ts, créer app/utils/seo-person.ts, brancher useSchemaOrg global dans app/app.vue</name>
|
||||
<files>nuxt.config.ts, app/utils/seo-person.ts, app/app.vue</files>
|
||||
<read_first>
|
||||
- nuxt.config.ts (lignes 1-82 entier, surtout modules[] 5-13)
|
||||
- app/app.vue (10 lignes entier)
|
||||
- app/data/site.ts (lignes 5-43 — source url + social)
|
||||
- .planning/phases/07-seo-blog/07-PATTERNS.md §seo-person.ts, §nuxt.config.ts, §app.vue
|
||||
- .planning/phases/07-seo-blog/07-RESEARCH.md §Pattern 1 (Global Schema Identity)
|
||||
</read_first>
|
||||
<action>
|
||||
1. **nuxt.config.ts** :
|
||||
- Ajouter `'nuxt-schema-org'` dans `modules[]` après `'@nuxtjs/sitemap'` (ligne ~12).
|
||||
- Ajouter, au même niveau d'indentation que `site:` et `i18n:`, le bloc :
|
||||
```ts
|
||||
sitemap: {
|
||||
sources: ['/api/__sitemap__/urls'],
|
||||
},
|
||||
```
|
||||
- Ne PAS modifier `site`, `i18n`, `content`, `runtimeConfig`, `gtag`, `vite`.
|
||||
2. **Créer `app/utils/seo-person.ts`** avec le contenu exact (pattern `app/utils/countWords.ts` : JSDoc top + export nommé + const typé) :
|
||||
```ts
|
||||
/**
|
||||
* Global Person identity for schema.org (Killian Dal-Cin).
|
||||
* Consumed by: app/app.vue (definePerson global) and app/pages/blog/[slug].vue (author/publisher @id ref).
|
||||
* Derives URLs from siteConfig — single source of truth.
|
||||
*/
|
||||
import { siteConfig } from '~/data/site'
|
||||
|
||||
export const KILLIAN_PERSON_ID = '#killian'
|
||||
|
||||
export const killianPerson = {
|
||||
'@id': KILLIAN_PERSON_ID,
|
||||
name: "Killian' Dal-Cin",
|
||||
url: siteConfig.url,
|
||||
jobTitle: siteConfig.jobTitle,
|
||||
sameAs: siteConfig.social
|
||||
.filter((s) => s.name !== 'Email')
|
||||
.map((s) => s.url),
|
||||
} as const
|
||||
```
|
||||
3. **app/app.vue** : APPEND (ne pas remplacer) après le bloc `useHead({...})` existant, AVANT la fermeture `</script>` :
|
||||
```ts
|
||||
import { killianPerson } from '~/utils/seo-person'
|
||||
|
||||
useSchemaOrg([
|
||||
definePerson(killianPerson),
|
||||
defineWebSite({
|
||||
name: "Killian' Dal-Cin — Hytale Plugin Developer",
|
||||
inLanguage: ['fr-FR', 'en-US'],
|
||||
}),
|
||||
])
|
||||
```
|
||||
Ne pas toucher au `<template>` ni au `useLocaleHead`/`useHead` existants.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "'nuxt-schema-org'" nuxt.config.ts && grep -q "/api/__sitemap__/urls" nuxt.config.ts && grep -q "KILLIAN_PERSON_ID" app/utils/seo-person.ts && grep -q "definePerson(killianPerson)" app/app.vue && pnpm typecheck && pnpm dev --port 3000 & sleep 10 && curl -s http://localhost:3000/ | grep -q '"@type":"Person"' && kill %1</automated>
|
||||
</verify>
|
||||
<done>Module chargé sans erreur ; `curl /` contient un `<script type="application/ld+json">` avec `"@type":"Person"` et `"@id":"#killian"` émis en SSR ; typecheck vert.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| build → runtime | Dépendance npm (`nuxt-schema-org`) introduite dans le supply chain — version figée `^6.0.4` |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07-01 | Tampering | package.json (nouveau module) | mitigate | Version explicite `^6.0.4` + pnpm-lock.yaml committé, intégrité pnpm |
|
||||
| T-07-02 | Information Disclosure | schema.org Person (exposition URLs publiques) | accept | URLs déjà publiques (portfolio freelance), email exclu de `sameAs` |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Module présent : `grep "'nuxt-schema-org'" nuxt.config.ts`
|
||||
- Sitemap source : `grep "sources.*__sitemap__/urls" nuxt.config.ts`
|
||||
- Schema étendu : `grep "updated: z.string().optional()" content.config.ts`
|
||||
- Person global en HTML SSR : `curl http://localhost:3000/ | grep '"@id":"#killian"'`
|
||||
- TypeScript : `pnpm typecheck` exit 0
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `nuxt-schema-org` installé (^6.0.4), lockfile à jour
|
||||
2. `updated` queryable (Zod) — un article avec `updated:` frontmatter sera exposé par `queryCollection(...).select('updated')`
|
||||
3. `curl /` émet JSON-LD global avec Person (@id=#killian) + WebSite, en SSR pur
|
||||
4. `nuxt.config.ts > sitemap.sources` déclaré (l'endpoint sera créé 07-04)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Après complétion, créer `.planning/phases/07-seo-blog/07-01-SUMMARY.md`.
|
||||
</output>
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
phase: 07-seo-blog
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [07-01]
|
||||
files_modified:
|
||||
- app/utils/resolve-og-image.ts
|
||||
- public/og-blog-default.jpg
|
||||
- app/pages/blog/[slug].vue
|
||||
autonomous: true
|
||||
requirements: [SEO-10, SEO-11, SEO-13, SEO-15]
|
||||
must_haves:
|
||||
truths:
|
||||
- "curl /fr/blog/{slug} retourne og:title, og:description, og:image UNIQUES (par article)"
|
||||
- "og:image est absolute (https://...) et = frontmatter image || /og-blog-default.jpg (jamais og-image.png générique)"
|
||||
- "Le HTML contient un JSON-LD `@type: Article` avec headline, description, datePublished, dateModified, author (@id=#killian), publisher (@id=#killian), inLanguage, mainEntityOfPage"
|
||||
- "Le HTML contient un JSON-LD `@type: BreadcrumbList` Accueil → Blog → Titre"
|
||||
- "article:published_time et article:modified_time présents (ISO 8601)"
|
||||
- "og:locale:alternate émis uniquement si l'article existe dans les 2 langues"
|
||||
artifacts:
|
||||
- path: "app/utils/resolve-og-image.ts"
|
||||
provides: "resolveOgImage(article) → URL absolue"
|
||||
contains: "export function resolveOgImage"
|
||||
- path: "public/og-blog-default.jpg"
|
||||
provides: "fallback branded 1200x630"
|
||||
- path: "app/pages/blog/[slug].vue"
|
||||
provides: "useSeoMeta enrichi + useSchemaOrg([defineArticle, defineBreadcrumb])"
|
||||
contains: "defineArticle"
|
||||
key_links:
|
||||
- from: "app/pages/blog/[slug].vue"
|
||||
to: "app/utils/resolve-og-image.ts"
|
||||
via: "import resolveOgImage"
|
||||
pattern: "resolveOgImage"
|
||||
- from: "app/pages/blog/[slug].vue (defineArticle.author)"
|
||||
to: "app/app.vue (definePerson global)"
|
||||
via: "@id reference"
|
||||
pattern: "'@id': KILLIAN_PERSON_ID"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Enrichir la page article `/blog/[slug]` avec (a) `useSeoMeta` étendu (D-15), (b) `useSchemaOrg([defineArticle, defineBreadcrumb])` (D-02, SEO-11, SEO-15), et (c) helper partagé `resolveOgImage` + asset fallback `/og-blog-default.jpg` (D-05, D-06, SEO-13).
|
||||
|
||||
Purpose: SEO-10/11/13/15 — satisfaire les 4 success criteria curl de la phase sur `/blog/[slug]`.
|
||||
Output: 1 util créé, 1 asset déposé, 1 page enrichie.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.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
|
||||
@app/pages/blog/[slug].vue
|
||||
@app/utils/countWords.ts
|
||||
@app/utils/seo-person.ts
|
||||
|
||||
<interfaces>
|
||||
Depuis `app/utils/seo-person.ts` (créé 07-01) :
|
||||
- `KILLIAN_PERSON_ID = '#killian'`
|
||||
- `killianPerson` (pour référence)
|
||||
|
||||
Depuis `app/pages/blog/[slug].vue` (existant, à étendre — ne PAS remplacer) :
|
||||
- `const { t, locale } = useI18n()` (ligne 2)
|
||||
- `const localePath = useLocalePath()` (ligne 3)
|
||||
- `const isFr = computed(() => locale.value === 'fr')` (ligne 5)
|
||||
- `const slug = route.params.slug as string` (ligne 6)
|
||||
- `const path = computed(() => ...)` (ligne 7)
|
||||
- `const { data: page } = await useAsyncData(...)` (lignes 10-17) — carry `title, description, date, updated?, image?, tags?`
|
||||
- `useSeoMeta({ title, description, ogTitle, ogDescription, ogType: 'article' })` (lignes 93-99) — à ÉTENDRE
|
||||
|
||||
Auto-imports nuxt-schema-org disponibles : `useSchemaOrg`, `defineArticle`, `defineBreadcrumb`.
|
||||
|
||||
`resolveOgImage(article?: { image?: string } | null): string` — retourne URL absolue préfixée par `https://killiandalcin.fr`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Créer app/utils/resolve-og-image.ts + déposer public/og-blog-default.jpg</name>
|
||||
<files>app/utils/resolve-og-image.ts, public/og-blog-default.jpg</files>
|
||||
<read_first>
|
||||
- app/utils/countWords.ts (pattern JSDoc + export nommé)
|
||||
- .planning/phases/07-seo-blog/07-RESEARCH.md §Pattern 4 (resolveOgImage helper)
|
||||
- .planning/phases/07-seo-blog/07-PATTERNS.md §resolve-og-image.ts
|
||||
</read_first>
|
||||
<action>
|
||||
1. Créer `app/utils/resolve-og-image.ts` avec contenu exact :
|
||||
```ts
|
||||
/**
|
||||
* Resolves an article's og:image to an absolute URL.
|
||||
* Strategy (D-05): frontmatter `image` if present, else branded fallback `/og-blog-default.jpg`.
|
||||
* Consumed by: app/pages/blog/[slug].vue (useSeoMeta.ogImage + defineArticle.image)
|
||||
* app/pages/blog/index.vue (useSeoMeta.ogImage fallback only).
|
||||
*/
|
||||
const SITE_URL = 'https://killiandalcin.fr'
|
||||
const FALLBACK = '/og-blog-default.jpg'
|
||||
|
||||
export function resolveOgImage(article?: { image?: string } | null): string {
|
||||
const raw = article?.image?.trim() || FALLBACK
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw
|
||||
return `${SITE_URL}${raw.startsWith('/') ? raw : `/${raw}`}`
|
||||
}
|
||||
```
|
||||
2. Déposer un asset `public/og-blog-default.jpg` (1200×630). Placeholder acceptable (RESEARCH Open Question #2) : générer un JPG simple via ImageMagick (si disponible) ou utiliser un existant cropé. Commande minimale si `magick` disponible :
|
||||
```sh
|
||||
magick -size 1200x630 gradient:'#0f172a'-'#1e293b' -gravity center -fill white -pointsize 64 -annotate 0 "Blog · killiandalcin.fr" public/og-blog-default.jpg
|
||||
```
|
||||
Si `magick` absent, copier `public/og-image.png` en `public/og-blog-default.jpg` via `cp public/og-image.png public/og-blog-default.jpg` COMME DERNIER RECOURS et noter dans le SUMMARY qu'un design définitif reste à produire (checkpoint design report en backlog). L'important est que le fichier existe et soit servable.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f app/utils/resolve-og-image.ts && grep -q "export function resolveOgImage" app/utils/resolve-og-image.ts && test -f public/og-blog-default.jpg && pnpm typecheck</automated>
|
||||
</verify>
|
||||
<done>Helper exporté type-check OK, asset JPG servable à `/og-blog-default.jpg`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Enrichir app/pages/blog/[slug].vue — useSeoMeta D-15 + useSchemaOrg defineArticle + defineBreadcrumb</name>
|
||||
<files>app/pages/blog/[slug].vue</files>
|
||||
<read_first>
|
||||
- app/pages/blog/[slug].vue (fichier entier 1-157)
|
||||
- .planning/phases/07-seo-blog/07-RESEARCH.md §Pattern 2 (Article Page JSON-LD + Meta), §useSeoMeta Enrichment table
|
||||
- .planning/phases/07-seo-blog/07-PATTERNS.md §[slug].vue (modify)
|
||||
- .planning/phases/07-seo-blog/07-CONTEXT.md D-13, D-15
|
||||
</read_first>
|
||||
<action>
|
||||
Dans `app/pages/blog/[slug].vue`, zone `<script setup lang="ts">` uniquement (ne PAS toucher au template) :
|
||||
|
||||
1. **Imports** — ajouter au tout début du script (après la ligne 1 `<script setup lang="ts">`) :
|
||||
```ts
|
||||
import { KILLIAN_PERSON_ID } from '~/utils/seo-person'
|
||||
import { resolveOgImage } from '~/utils/resolve-og-image'
|
||||
```
|
||||
|
||||
2. **Détection pair bilingue** — après le bloc `surround` (après ligne 39), avant `interface SurroundArticle` :
|
||||
```ts
|
||||
// Détecter la version dans l'autre langue (pour og:locale:alternate, D-15, Pitfall 7)
|
||||
const { data: altExists } = await useAsyncData(
|
||||
`blog-alt-${locale.value}-${slug}`,
|
||||
() =>
|
||||
isFr.value
|
||||
? queryCollection('blog_en').path(`/en/blog/${slug}`).first()
|
||||
: queryCollection('blog_fr').path(`/fr/blog/${slug}`).first(),
|
||||
{ watch: [locale] },
|
||||
)
|
||||
```
|
||||
|
||||
3. **Computeds SEO** — après `readingMinutes` computed (ligne 79), AVANT `interface TocLink` :
|
||||
```ts
|
||||
const SITE_URL = 'https://killiandalcin.fr'
|
||||
const ogImage = computed(() => resolveOgImage(page.value as { image?: string } | null))
|
||||
const canonicalUrl = computed(() => `${SITE_URL}${localePath('/blog/' + slug)}`)
|
||||
const publishedIso = computed(() => page.value?.date)
|
||||
const modifiedIso = computed(() => page.value?.updated ?? page.value?.date) // D-13
|
||||
```
|
||||
|
||||
4. **Remplacer** le `useSeoMeta({...})` existant (lignes 93-99) par la version enrichie D-15 (arrow-fns pour tout ce qui lit `.value` — Pattern "Reactive arrow-fn values") :
|
||||
```ts
|
||||
useSeoMeta({
|
||||
title: () => page.value?.title,
|
||||
description: () => page.value?.description,
|
||||
ogTitle: () => page.value?.title,
|
||||
ogDescription: () => page.value?.description,
|
||||
ogType: 'article',
|
||||
ogImage,
|
||||
ogUrl: canonicalUrl,
|
||||
ogLocale: () => (isFr.value ? 'fr_FR' : 'en_US'),
|
||||
ogLocaleAlternate: () => (altExists.value ? [isFr.value ? 'en_US' : 'fr_FR'] : []),
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterImage: ogImage,
|
||||
articlePublishedTime: publishedIso,
|
||||
articleModifiedTime: modifiedIso,
|
||||
articleAuthor: () => "Killian' Dal-Cin",
|
||||
})
|
||||
```
|
||||
|
||||
5. **Ajouter** après `useSeoMeta(...)` un bloc `useSchemaOrg` :
|
||||
```ts
|
||||
useSchemaOrg([
|
||||
defineArticle({
|
||||
headline: () => page.value?.title,
|
||||
description: () => page.value?.description,
|
||||
image: ogImage,
|
||||
datePublished: publishedIso,
|
||||
dateModified: modifiedIso,
|
||||
inLanguage: () => (isFr.value ? 'fr-FR' : 'en-US'),
|
||||
author: { '@id': KILLIAN_PERSON_ID },
|
||||
publisher: { '@id': KILLIAN_PERSON_ID },
|
||||
mainEntityOfPage: canonicalUrl,
|
||||
}),
|
||||
defineBreadcrumb({
|
||||
itemListElement: [
|
||||
{ name: () => t('blog.breadcrumb.home'), item: () => localePath('/') },
|
||||
{ name: () => t('blog.breadcrumb.blog'), item: () => localePath('/blog') },
|
||||
{ name: () => page.value?.title ?? '' },
|
||||
],
|
||||
}),
|
||||
])
|
||||
```
|
||||
Ne PAS toucher aux computeds `breadcrumbItems`, `formattedDate`, `readingMinutes`, `tocLinks`, ni au template.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "defineArticle" app/pages/blog/[slug].vue && grep -q "defineBreadcrumb" app/pages/blog/[slug].vue && grep -q "articlePublishedTime" app/pages/blog/[slug].vue && grep -q "resolveOgImage" app/pages/blog/[slug].vue && grep -q "KILLIAN_PERSON_ID" app/pages/blog/[slug].vue && pnpm typecheck && pnpm dev --port 3000 & sleep 12 && SLUG=$(ls content/fr/blog | head -1 | sed 's/\.md$//') && curl -s "http://localhost:3000/fr/blog/$SLUG" | tee /tmp/slug.html | grep -q 'property="og:image".*https://killiandalcin.fr' && grep -q '"@type":"Article"' /tmp/slug.html && grep -q '"@type":"BreadcrumbList"' /tmp/slug.html && grep -q 'property="article:published_time"' /tmp/slug.html && kill %1</automated>
|
||||
</verify>
|
||||
<done>curl /fr/blog/{slug} HTML contient : og:image absolu, article:published_time, JSON-LD Article (avec author @id=#killian), JSON-LD BreadcrumbList 3 items. typecheck vert.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| frontmatter → HTML | `image:` du markdown injecté dans meta tags / JSON-LD (auteur = soi-même, confiance élevée) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07-03 | Tampering | `resolveOgImage` (URL depuis frontmatter) | mitigate | Helper construit URL en préfixant SITE_URL ; frontmatter écrit par l'auteur unique (pas de user input externe) |
|
||||
| T-07-04 | Information Disclosure | JSON-LD article (author) | accept | Identité Killian publique par design |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Helper vérifiable : `grep "export function resolveOgImage" app/utils/resolve-og-image.ts`
|
||||
- og:image absolu : `curl /fr/blog/{slug} | grep 'property="og:image"' | grep 'https://'`
|
||||
- JSON-LD Article : `curl /fr/blog/{slug} | grep '"@type":"Article"'`
|
||||
- JSON-LD BreadcrumbList : `curl /fr/blog/{slug} | grep '"@type":"BreadcrumbList"'`
|
||||
- article:published_time : `curl /fr/blog/{slug} | grep 'property="article:published_time"'`
|
||||
- Pas de client-only : tout doit être dans le HTML initial SSR (pas de diff après hydratation)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. SEO-10 : `curl /fr/blog/{slug}` contient og:title, og:description, og:image uniques (dépendent de `page.title`/`description`/`image`)
|
||||
2. SEO-11 : JSON-LD Article valide avec author, datePublished, dateModified, headline
|
||||
3. SEO-13 : og:image = frontmatter absolutisé OR `https://killiandalcin.fr/og-blog-default.jpg`, jamais `og-image.png`
|
||||
4. SEO-15 : JSON-LD BreadcrumbList Accueil → Blog → {title}
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Après complétion, créer `.planning/phases/07-seo-blog/07-02-SUMMARY.md`.
|
||||
</output>
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
phase: 07-seo-blog
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [07-01]
|
||||
files_modified:
|
||||
- app/pages/blog/index.vue
|
||||
autonomous: true
|
||||
requirements: [SEO-10, SEO-13, SEO-15]
|
||||
must_haves:
|
||||
truths:
|
||||
- "curl /fr/blog et /en/blog retournent og:image absolu = https://killiandalcin.fr/og-blog-default.jpg"
|
||||
- "og:locale = fr_FR (ou en_US) et og:locale:alternate = en_US (ou fr_FR) — le listing existe toujours dans les 2 langues"
|
||||
- "Le HTML contient un JSON-LD @type: CollectionPage (via defineWebPage) pour le listing"
|
||||
- "Le HTML contient un JSON-LD BreadcrumbList Accueil → Blog"
|
||||
artifacts:
|
||||
- path: "app/pages/blog/index.vue"
|
||||
provides: "useSeoMeta enrichi (D-16) + useSchemaOrg CollectionPage + Breadcrumb"
|
||||
contains: "defineWebPage"
|
||||
key_links:
|
||||
- from: "app/pages/blog/index.vue"
|
||||
to: "app/utils/resolve-og-image.ts"
|
||||
via: "import resolveOgImage"
|
||||
pattern: "resolveOgImage"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Enrichir la page listing `/blog` avec (a) `useSeoMeta` étendu (D-16 — og:image fallback, og:locale, og:locale:alternate, twitter), et (b) `useSchemaOrg([defineWebPage({ '@type': 'CollectionPage' }), defineBreadcrumb])` (D-03, SEO-15).
|
||||
|
||||
Purpose: Le listing doit être partageable socialement (card OG branded) et porter un breadcrumb JSON-LD cohérent avec les articles.
|
||||
Output: 1 page enrichie.
|
||||
</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
|
||||
@app/pages/blog/index.vue
|
||||
|
||||
<interfaces>
|
||||
Depuis `app/pages/blog/index.vue` (existant, à étendre — ne PAS remplacer) :
|
||||
- `const { t, locale } = useI18n()` (ligne 2)
|
||||
- `const localePath = useLocalePath()` (ligne 3)
|
||||
- `const isFr = computed(() => locale.value === 'fr')` (ligne 4)
|
||||
- `useSeoMeta({ title, description, ogTitle, ogDescription, ogType: 'website' })` (lignes 37-43) — à ÉTENDRE
|
||||
|
||||
Auto-imports : `useSchemaOrg`, `defineWebPage`, `defineBreadcrumb`.
|
||||
|
||||
`resolveOgImage(null)` retourne `https://killiandalcin.fr/og-blog-default.jpg` (fallback, D-06).
|
||||
|
||||
**Note**: `app/utils/resolve-og-image.ts` est créé dans 07-02 (Wave 2, parallèle). Plan 07-03 a DÉJÀ une dépendance implicite (runtime) sur ce fichier : si 07-03 exécute avant 07-02, `import { resolveOgImage }` échouera. L'exécuteur DOIT lancer 07-02 d'abord OU créer provisoirement le helper ici. **Recommandation** : exécuteur vérifie `test -f app/utils/resolve-og-image.ts` et, si absent, utilise la constante littérale `const OG_FALLBACK = 'https://killiandalcin.fr/og-blog-default.jpg'` en dur dans ce fichier (évite le couplage). Plan 07-02 n'écrit QUE `[slug].vue` + utils, donc pas de conflit de fichier.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Enrichir app/pages/blog/index.vue — useSeoMeta D-16 + useSchemaOrg CollectionPage + Breadcrumb</name>
|
||||
<files>app/pages/blog/index.vue</files>
|
||||
<read_first>
|
||||
- app/pages/blog/index.vue (fichier entier 1-151)
|
||||
- .planning/phases/07-seo-blog/07-RESEARCH.md §Open Question #1 (CollectionPage via defineWebPage), §useSeoMeta Enrichment
|
||||
- .planning/phases/07-seo-blog/07-PATTERNS.md §index.vue (modify)
|
||||
- .planning/phases/07-seo-blog/07-CONTEXT.md D-03, D-16
|
||||
</read_first>
|
||||
<action>
|
||||
Dans `app/pages/blog/index.vue`, zone `<script setup lang="ts">` uniquement.
|
||||
|
||||
1. **Import** — tout en haut du script :
|
||||
```ts
|
||||
import { resolveOgImage } from '~/utils/resolve-og-image'
|
||||
```
|
||||
|
||||
2. **Computeds SEO** — après la constante `totalLanguages = 2` (ligne 34), avant `useSeoMeta` :
|
||||
```ts
|
||||
const SITE_URL = 'https://killiandalcin.fr'
|
||||
const ogImage = resolveOgImage(null) // fallback absolute URL (D-16)
|
||||
const canonicalUrl = computed(() => `${SITE_URL}${localePath('/blog')}`)
|
||||
```
|
||||
|
||||
3. **Remplacer** le `useSeoMeta({...})` existant (lignes 37-43) par la version enrichie D-16 :
|
||||
```ts
|
||||
useSeoMeta({
|
||||
title: () => t('blog.title'),
|
||||
description: () => t('blog.subtitle'),
|
||||
ogTitle: () => t('blog.title'),
|
||||
ogDescription: () => t('blog.subtitle'),
|
||||
ogType: 'website',
|
||||
ogImage,
|
||||
ogUrl: canonicalUrl,
|
||||
ogLocale: () => (isFr.value ? 'fr_FR' : 'en_US'),
|
||||
ogLocaleAlternate: () => [isFr.value ? 'en_US' : 'fr_FR'],
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterImage: ogImage,
|
||||
})
|
||||
```
|
||||
|
||||
4. **Ajouter** après `useSeoMeta(...)` :
|
||||
```ts
|
||||
useSchemaOrg([
|
||||
defineWebPage({
|
||||
'@type': 'CollectionPage',
|
||||
name: () => t('blog.title'),
|
||||
description: () => t('blog.subtitle'),
|
||||
inLanguage: () => (isFr.value ? 'fr-FR' : 'en-US'),
|
||||
url: canonicalUrl,
|
||||
}),
|
||||
defineBreadcrumb({
|
||||
itemListElement: [
|
||||
{ name: () => t('blog.breadcrumb.home'), item: () => localePath('/') },
|
||||
{ name: () => t('blog.breadcrumb.blog'), item: () => localePath('/blog') },
|
||||
],
|
||||
}),
|
||||
])
|
||||
```
|
||||
Ne PAS toucher aux computeds `totalArticles`, `uniqueTags`, `totalLanguages`, au `useAsyncData`, ni au `<template>`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "defineWebPage" app/pages/blog/index.vue && grep -q "defineBreadcrumb" app/pages/blog/index.vue && grep -q "resolveOgImage" app/pages/blog/index.vue && grep -q "ogLocaleAlternate" app/pages/blog/index.vue && pnpm typecheck && pnpm dev --port 3000 & sleep 12 && curl -s http://localhost:3000/fr/blog | tee /tmp/blog.html | grep -q 'property="og:image".*og-blog-default.jpg' && grep -q '"@type":"CollectionPage"' /tmp/blog.html && grep -q '"@type":"BreadcrumbList"' /tmp/blog.html && curl -s http://localhost:3000/en/blog | grep -q 'property="og:locale" content="en_US"' && kill %1</automated>
|
||||
</verify>
|
||||
<done>curl /fr/blog et /en/blog retournent og:image pointant vers og-blog-default.jpg absolu, og:locale correct, JSON-LD CollectionPage + BreadcrumbList. typecheck vert.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| (aucune nouvelle) | Rien de user-input ; i18n strings déjà trustées |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-07-05 | Information Disclosure | JSON-LD listing (URLs publiques) | accept | Par design — le listing doit être crawlable |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- og:image listing : `curl /fr/blog | grep 'og-blog-default.jpg'`
|
||||
- og:locale correct : `curl /en/blog | grep 'content="en_US"'`
|
||||
- JSON-LD CollectionPage : `curl /fr/blog | grep '"@type":"CollectionPage"'`
|
||||
- JSON-LD Breadcrumb : `curl /fr/blog | grep '"@type":"BreadcrumbList"'`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. SEO-10 étendu : og:title, og:description, og:image distincts du site par défaut
|
||||
2. SEO-13 : og:image = `/og-blog-default.jpg` absolu (jamais `og-image.png`)
|
||||
3. SEO-15 : BreadcrumbList Accueil → Blog présent sur le listing
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Après complétion, créer `.planning/phases/07-seo-blog/07-03-SUMMARY.md`.
|
||||
</output>
|
||||
@@ -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