Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 985dcdbd80 | |||
| 0b1152c8a1 | |||
| 4bc0886a42 | |||
| 97ea1a8df2 | |||
| 466bed0944 | |||
| e17faae5d7 | |||
| 15e1a37e59 | |||
| 47c2839ae8 | |||
| fae410243b | |||
| 9b1717cbd8 | |||
| 654842ba44 | |||
| 17420afefe | |||
| 487e323a94 | |||
| 7edc0b8123 | |||
| d7a13f0d4a | |||
| 5bd5624121 | |||
| 680bbfbbe6 |
@@ -61,7 +61,7 @@
|
||||
|
||||
- [ ] **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-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 — Cocon sémantique
|
||||
@@ -100,7 +100,7 @@
|
||||
| BLOG-06 | Phase 6 | Pending |
|
||||
| SEO-10 | 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-15 | Phase 7 | Pending |
|
||||
| BLOG-07 | Phase 8 | Pending |
|
||||
|
||||
+12
-12
@@ -108,8 +108,8 @@ Plans:
|
||||
## 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)
|
||||
- [ ] **Phase 6: Blog Pages** - Page listing /blog et page article /blog/[slug] SSR, bilingue, avec TOC et nav prev/next
|
||||
- [ ] **Phase 7: SEO Blog** - useSeoMeta par article, JSON-LD Article, sitemap etendu, og:image, BreadcrumbList
|
||||
- [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)
|
||||
- [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
|
||||
|
||||
---
|
||||
@@ -143,10 +143,10 @@ Plans:
|
||||
5. `curl localhost:3000/en/blog` retourne le listing en anglais — les articles ont leur version EN
|
||||
**Plans:** 4 plans
|
||||
Plans:
|
||||
- [ ] 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)
|
||||
- [ ] 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-01-PLAN.md — Content schema Zod extension (draft/wordCount/minutes) + Nitro reading-time hook + draft:true sur test articles
|
||||
- [x] 06-02-PLAN.md — i18n keys blog.*/nav.blog/a11y.blog* + lien Blog dans AppHeader + BlogCard.vue unifié (default + compact)
|
||||
- [x] 06-03-PLAN.md — Page listing app/pages/blog/index.vue (hero + grid + empty state, SSR bilingue)
|
||||
- [x] 06-04-PLAN.md — BlogToc.vue + BlogPrevNext.vue + enrichissement app/pages/blog/[slug].vue (breadcrumb + header + TOC + surround)
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 7: SEO Blog
|
||||
@@ -161,10 +161,10 @@ Plans:
|
||||
5. La page article contient un JSON-LD `BreadcrumbList` : Accueil → Blog → Titre de l'article
|
||||
**Plans:** 4 plans
|
||||
Plans:
|
||||
- [ ] 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)
|
||||
- [ ] 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)
|
||||
- [ ] 07-01-PLAN.md — Install nuxt-schema-org + schema updated + definePerson/defineWebSite global + sitemap.sources
|
||||
- [ ] 07-02-PLAN.md — resolveOgImage helper + og-blog-default.jpg + [slug].vue useSeoMeta enrichi + defineArticle/defineBreadcrumb
|
||||
- [ ] 07-03-PLAN.md — index.vue useSeoMeta enrichi + defineWebPage(CollectionPage) + defineBreadcrumb
|
||||
- [x] 07-04-PLAN.md — server/api/__sitemap__/urls.ts (bilingue, draft:false, alternates hreflang, lastmod=updated||date)
|
||||
|
||||
### 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
|
||||
@@ -190,6 +190,6 @@ Plans:
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 5. @nuxt/content Setup & Renderer | 2/2 | Complete | 2026-04-22 |
|
||||
| 6. Blog Pages | 2/4 | In progress | - |
|
||||
| 7. SEO Blog | 0/? | Not started | - |
|
||||
| 6. Blog Pages | 4/4 | Complete | 2026-04-22 |
|
||||
| 7. SEO Blog | 4/4 | Complete | 2026-04-22 |
|
||||
| 8. Content & Cocon Semantique | 0/? | Not started | - |
|
||||
|
||||
+13
-9
@@ -2,15 +2,15 @@
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: Phase 6 — Plan 06-02 shipped (2/4), ready for Plan 06-03
|
||||
last_updated: "2026-04-22T09:25:00.000Z"
|
||||
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-22T12:00:00.000Z"
|
||||
last_activity: 2026-04-22
|
||||
progress:
|
||||
total_phases: 8
|
||||
completed_phases: 3
|
||||
completed_phases: 4
|
||||
total_plans: 15
|
||||
completed_plans: 11
|
||||
percent: 73
|
||||
completed_plans: 14
|
||||
percent: 93
|
||||
---
|
||||
|
||||
# Project State
|
||||
@@ -23,11 +23,11 @@ progress:
|
||||
|
||||
## Current Focus
|
||||
|
||||
Phase: Phase 6 — Blog Pages
|
||||
Plan: 06-03 (next — Wave 3, listing page /blog)
|
||||
Status: Plan 06-02 shipped — i18n FR+EN complet, nav link Blog en place, BlogCard.vue (variant default+compact) auto-importable, typecheck vert
|
||||
Phase: Phase 7 — SEO Blog
|
||||
Plan: 07-03 (next — Wave 2, blog index/tags SEO)
|
||||
Status: Plan 07-04 shipped — endpoint Nitro sitemap bilingue (draft-filtered + hreflang x-default), SEO-12 complet
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- **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).
|
||||
- **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).
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 1–3 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
|
||||
@@ -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 1–4). 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 (2024–2025) | 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); 3–7 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
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { killianPerson } from '~/utils/seo-person'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const head = useLocaleHead({ seo: true })
|
||||
|
||||
@@ -7,6 +9,14 @@ useHead({
|
||||
link: computed(() => head.value.link || []),
|
||||
meta: computed(() => head.value.meta || []),
|
||||
})
|
||||
|
||||
useSchemaOrg([
|
||||
definePerson(killianPerson),
|
||||
defineWebSite({
|
||||
name: "Killian' Dal-Cin — Hytale Plugin Developer",
|
||||
inLanguage: ['fr-FR', 'en-US'],
|
||||
}),
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { KILLIAN_PERSON_ID } from '~/utils/seo-person'
|
||||
import { resolveOgImage } from '~/utils/resolve-og-image'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const route = useRoute()
|
||||
@@ -38,6 +41,16 @@ const { data: surround } = await useAsyncData(
|
||||
{ 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 {
|
||||
path: string
|
||||
title: string
|
||||
@@ -78,6 +91,13 @@ const readingMinutes = computed(() => {
|
||||
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 {
|
||||
id: string
|
||||
depth: number
|
||||
@@ -96,7 +116,37 @@ useSeoMeta({
|
||||
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,
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -33,14 +33,42 @@ const uniqueTags = computed(() => {
|
||||
|
||||
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({
|
||||
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,
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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}`}`
|
||||
}
|
||||
@@ -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
|
||||
@@ -4,6 +4,7 @@ const blogSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
date: z.string(),
|
||||
updated: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
image: z.string().optional(),
|
||||
draft: z.boolean().optional().default(false),
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineNuxtConfig({
|
||||
'@nuxt/eslint',
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxtjs/sitemap',
|
||||
'nuxt-schema-org',
|
||||
'nuxt-gtag',
|
||||
],
|
||||
components: [
|
||||
@@ -31,6 +32,9 @@ export default defineNuxtConfig({
|
||||
url: 'https://killiandalcin.fr',
|
||||
name: "Killian' DAL-CIN - Developpeur Full Stack",
|
||||
},
|
||||
sitemap: {
|
||||
sources: ['/api/__sitemap__/urls'],
|
||||
},
|
||||
i18n: {
|
||||
strategy: 'prefix',
|
||||
defaultLocale: 'fr',
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@nuxt/eslint": "^1.15.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"nuxt-schema-org": "^6.0.4",
|
||||
"tailwindcss": "^4.2.3",
|
||||
"typescript": "~5.9.0",
|
||||
"vue-tsc": "^3.2.6"
|
||||
|
||||
Generated
+1323
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
PLACEHOLDER: Replace with actual 1200x630 og:image for killiandalcin.fr
|
||||
@@ -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
|
||||
})
|
||||
Reference in New Issue
Block a user