251 lines
12 KiB
Markdown
251 lines
12 KiB
Markdown
---
|
||
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>
|