Files

251 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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>