11 KiB
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:
// 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):
/**
* <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 withdefineNitroPlugin, hook-based, shows how nitro files wire into the app.server/api/contact.post.ts(lines 22-28) — route handler pattern withdefineEventHandler(async (event) => {...}), Zod validation, typed responses.
Route handler shape to copy (contact.post.ts lines 22-28):
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:
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):
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):
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):
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(...):
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):
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):
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:
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):
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:
- Add helper imports:
import { KILLIAN_PERSON_ID } from '~/utils/seo-person'andimport { resolveOgImage } from '~/utils/resolve-og-image'. - Add
altExistsuseAsyncDatablock (opposite locale, same slug) — mirror lines 10-17 exactly, swap collection. - 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 existingtitle: () => page.value?.title). - ADD
useSchemaOrg([defineArticle({...}), defineBreadcrumb({...})])afteruseSeoMeta— use{ '@id': KILLIAN_PERSON_ID }forauthor/publisher(Pitfall 4).
app/pages/blog/index.vue (modify, listing page)
Analog: itself (lines 37-43 — existing useSeoMeta)
Apply:
- EXTEND the existing
useSeoMeta(lines 37-43) with D-16 keys:ogImage(= absolute/og-blog-default.jpg),ogLocale,ogLocaleAlternate,twitterCard,twitterImage. KeepogType: 'website'. - ADD
useSchemaOrg([defineWebPage({ '@type': 'CollectionPage', ... }), defineBreadcrumb({ itemListElement: [home, blog] })])afteruseSeoMeta. - 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.
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