Files
portfolio/.planning/phases/07-seo-blog/07-PATTERNS.md
T

11 KiB
Raw Blame History

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 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):

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:

  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.

isFr.value
  ? queryCollection('blog_fr').where('draft', '=', false).order('date', 'DESC').all()
  : queryCollection('blog_en').where('draft', '=', false).order('date', 'DESC').all()

Rule: Never queryCollection('blog_' + locale) — Vite extractor breaks in build (Phase 5 gotcha, Pitfall 2 RESEARCH).

Reactive arrow-fn values in useSeoMeta

Source: [slug].vue lines 94-98 (title: () => page.value?.title). Apply to: All new useSeoMeta keys in [slug].vue and index.vue. Static strings are fine; anything reading from page.value / locale.value / altExists.value MUST be wrapped () => ....

localePath() for canonical URLs (never concat slug)

Source: [slug].vue line 3 + breadcrumb lines 58-59. Apply to: ogUrl, mainEntityOfPage, defineBreadcrumb items in both pages. Canonical form: `${siteConfig.url}${localePath('/blog/' + slug)}` (Pitfall 6).

Single source of truth for identity (Killian)

Source: app/data/site.ts lines 5-43 (siteConfig). Apply to: app/utils/seo-person.ts must re-import (or re-derive from) siteConfig.url, siteConfig.social[] URLs. No duplicated LinkedIn/Gitea strings.

Content schema extension via .optional()

Source: content.config.ts lines 3-12 — precedent set by Phase 6 wordCount/minutes. Apply to: new updated: z.string().optional() field.

Nitro ctx + queryCollection(event, ...) first-arg rule

Source: server/plugins/reading-time.ts lines 12-23 (nitro ctx patterns in this repo). Apply to: server/api/__sitemap__/urls.ts — pass event as first arg (Pitfall 1).


No Analog Found

File Role Reason
public/og-blog-default.jpg static asset Binary asset; no code analog. Planner task: ship 1200×630 branded JPG (placeholder acceptable per Open Question #2 RESEARCH).
useSchemaOrg / defineArticle / defineBreadcrumb / definePerson / defineWebSite / defineSitemapEventHandler calls schema-org / sitemap APIs No prior usage in the codebase; follow RESEARCH Patterns 13 verbatim.

Metadata

Analog search scope: app/, server/, content.config.ts, nuxt.config.ts Files scanned: 9 read in full (all ≤ 160 lines; no large-file targeted reads needed) Pattern extraction date: 2026-04-22