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

271 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 7: SEO Blog — Pattern Map
**Mapped:** 2026-04-22
**Files analyzed:** 8 (4 new, 4 modified)
**Analogs found:** 8 / 8
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `app/utils/seo-person.ts` (new) | utility / const | static export | `app/data/site.ts` | role-match |
| `app/utils/resolve-og-image.ts` (new) | utility / pure fn | transform | `app/utils/countWords.ts` | exact |
| `server/api/__sitemap__/urls.ts` (new) | nitro route | request-response (dynamic feed) | `server/plugins/reading-time.ts` (nitro ctx) + `server/api/contact.post.ts` (route shape) | role-match |
| `public/og-blog-default.jpg` (new) | static asset | file-I/O | n/a (asset) | — |
| `content.config.ts` (modify) | config | schema extension | itself (existing `blogSchema`) | exact |
| `nuxt.config.ts` (modify) | config | module registration + sitemap sources | itself | exact |
| `app/app.vue` (modify) | root component | global schema-org identity | itself (existing `useHead` + `useLocaleHead`) | exact |
| `app/pages/blog/[slug].vue` (modify) | page | request-response (SSR SEO + JSON-LD) | itself (existing `useSeoMeta`) + `app/pages/blog/index.vue` | exact |
| `app/pages/blog/index.vue` (modify) | page | request-response (SSR SEO + JSON-LD listing) | itself | exact |
## Pattern Assignments
### `app/utils/seo-person.ts` (new, utility/const)
**Analog:** `app/data/site.ts` (lines 1-12) — pattern for exported typed constants sourced from shared types.
**Convention to copy:**
```ts
// Named export of a typed const object, imported via `~/` alias elsewhere.
export const siteConfig: SiteConfig = {
name: 'Killian',
url: 'https://killiandalcin.fr',
...
}
```
**Apply:** Export `KILLIAN_PERSON_ID = '#killian'` string const + `killianPerson` object. Reuse `siteConfig.url`, `siteConfig.social[]` (LinkedIn, Gitea URLs at lines 20-36) as source of truth for `sameAs[]`. No new identity drift.
---
### `app/utils/resolve-og-image.ts` (new, utility/pure fn)
**Analog:** `app/utils/countWords.ts` (lines 1-34)
**Imports / JSDoc / export pattern** (lines 1-10):
```ts
/**
* <one-line purpose>
* <detail lines>
*
* Used by <consumer files>.
*/
export function countWordsInMinimalBody(body: unknown): number {
```
**Apply:** Same shape — top-level JSDoc naming consumers (`useSeoMeta` on `[slug].vue` + `index.vue`, `defineArticle` on `[slug].vue`), single named export, explicit param/return types, no external imports. Hard-code `SITE_URL` + `FALLBACK` constants at module top (mirrors `countWords.ts` self-contained style).
---
### `server/api/__sitemap__/urls.ts` (new, nitro route)
**Analogs:**
- `server/plugins/reading-time.ts` (lines 12-23) — nitro plugin pattern with `defineNitroPlugin`, hook-based, shows how nitro files wire into the app.
- `server/api/contact.post.ts` (lines 22-28) — route handler pattern with `defineEventHandler(async (event) => {...})`, Zod validation, typed responses.
**Route handler shape to copy** (contact.post.ts lines 22-28):
```ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const parsed = contactSchema.safeParse(body)
if (!parsed.success) {
throw createError({ statusCode: 400, message: 'Invalid payload' })
}
...
})
```
**Apply:** Replace `defineEventHandler` with `defineSitemapEventHandler` (from `#imports`, per RESEARCH Pattern 3). Use `event` as first arg for `queryCollection(event, 'blog_fr')` / `queryCollection(event, 'blog_en')` (Pitfall 1+2 RESEARCH). Return typed `SitemapUrl[]` from `#sitemap/types`. No Zod validation needed (no input body). No try/catch — let Nitro bubble.
**Content query pattern to copy** from `app/pages/blog/index.vue` lines 10-20:
```ts
isFr.value
? queryCollection('blog_fr').where('draft', '=', false).order('date', 'DESC').all()
: queryCollection('blog_en').where('draft', '=', false).order('date', 'DESC').all()
```
**Apply:** Run BOTH branches in `Promise.all` (server context aggregates both locales, no i18n conditional). Literal collection strings mandatory.
---
### `content.config.ts` (modify)
**Analog:** itself (lines 3-12, existing `blogSchema`)
**Extension pattern** (current file):
```ts
const blogSchema = z.object({
title: z.string(),
description: z.string(),
date: z.string(),
tags: z.array(z.string()).optional(),
image: z.string().optional(), // already present — D-14 #2 is a no-op
draft: z.boolean().optional().default(false),
wordCount: z.number().optional(),
minutes: z.number().optional(),
})
```
**Apply:** Add ONE line: `updated: z.string().optional(),` (D-13/D-14). `image` already declared — verify only. Mirrors Phase 6 precedent (`wordCount` / `minutes` `.optional()`). Document cache invalidation: `rm -rf node_modules/.cache/content .nuxt` after schema edit (Pitfall 8 RESEARCH).
---
### `nuxt.config.ts` (modify)
**Analog:** itself
**Modules array pattern** (lines 5-13):
```ts
modules: [
'@nuxt/ui',
'@nuxt/image',
'@nuxt/content',
'@nuxt/eslint',
'@nuxtjs/i18n',
'@nuxtjs/sitemap',
'nuxt-gtag',
],
```
**Apply:** Add `'nuxt-schema-org'` to the array (order indifferent per D-01; place next to `@nuxtjs/sitemap` for cohesion). Add top-level `sitemap: { sources: ['/api/__sitemap__/urls'] }` block (no existing `sitemap` block — new top-level key, same indent as `site`, `i18n`, `content`). Do NOT touch existing `site`, `i18n`, `content` blocks.
---
### `app/app.vue` (modify, global schema-org)
**Analog:** itself (entire file, 10 lines)
**Current script setup pattern** (lines 1-10):
```ts
const { locale } = useI18n()
const head = useLocaleHead({ seo: true })
useHead({
htmlAttrs: { lang: locale },
link: computed(() => head.value.link || []),
meta: computed(() => head.value.meta || []),
})
```
**Apply:** APPEND (do not replace) after `useHead(...)`:
```ts
import { killianPerson } from '~/utils/seo-person'
useSchemaOrg([
definePerson(killianPerson),
defineWebSite({ name: '...', inLanguage: ['fr-FR', 'en-US'] }),
])
```
`definePerson` / `defineWebSite` / `useSchemaOrg` are auto-imports from `nuxt-schema-org`. Do NOT duplicate `useLocaleHead` hreflang logic (already shipped).
---
### `app/pages/blog/[slug].vue` (modify, article page)
**Analog:** itself (lines 93-99 — existing `useSeoMeta`)
**Current useSeoMeta pattern to EXTEND** (lines 93-99):
```ts
useSeoMeta({
title: () => page.value?.title,
description: () => page.value?.description,
ogTitle: () => page.value?.title,
ogDescription: () => page.value?.description,
ogType: 'article',
})
```
**Locale/localePath pattern already in file** (lines 2-7):
```ts
const { t, locale } = useI18n()
const localePath = useLocalePath()
const route = useRoute()
const isFr = computed(() => locale.value === 'fr')
const slug = route.params.slug as string
```
**Breadcrumb items already in file** (lines 57-61) — **re-use labels (`t('blog.breadcrumb.home')`, `t('blog.breadcrumb.blog')`) for `defineBreadcrumb`:**
```ts
const breadcrumbItems = computed(() => [
{ label: t('blog.breadcrumb.home'), to: localePath('/'), icon: 'i-lucide-home' },
{ label: t('blog.breadcrumb.blog'), to: localePath('/blog') },
{ label: page.value?.title ?? '' },
])
```
**useAsyncData bilingual branch pattern already in file** (lines 10-17) — copy shape for the new "bilingual pair detector" async data (D-15 `ogLocaleAlternate`):
```ts
const { data: page } = await useAsyncData(
`blog-${locale.value}-${slug}`,
() => isFr.value
? queryCollection('blog_fr').path(path.value).first()
: queryCollection('blog_en').path(path.value).first(),
{ watch: [locale] },
)
```
**Apply:**
1. Add helper imports: `import { KILLIAN_PERSON_ID } from '~/utils/seo-person'` and `import { resolveOgImage } from '~/utils/resolve-og-image'`.
2. Add `altExists` `useAsyncData` block (opposite locale, same slug) — mirror lines 10-17 exactly, swap collection.
3. EXTEND (not replace) the `useSeoMeta({...})` call with D-15 keys: `ogImage`, `ogUrl`, `ogLocale`, `ogLocaleAlternate`, `twitterCard: 'summary_large_image'`, `twitterImage`, `articlePublishedTime`, `articleModifiedTime`, `articleAuthor`. Wrap all dynamic values in `() => ...` arrow fns (reactive pattern, mirrors existing `title: () => page.value?.title`).
4. ADD `useSchemaOrg([defineArticle({...}), defineBreadcrumb({...})])` after `useSeoMeta` — use `{ '@id': KILLIAN_PERSON_ID }` for `author`/`publisher` (Pitfall 4).
---
### `app/pages/blog/index.vue` (modify, listing page)
**Analog:** itself (lines 37-43 — existing `useSeoMeta`)
**Apply:**
1. EXTEND the existing `useSeoMeta` (lines 37-43) with D-16 keys: `ogImage` (= absolute `/og-blog-default.jpg`), `ogLocale`, `ogLocaleAlternate`, `twitterCard`, `twitterImage`. Keep `ogType: 'website'`.
2. ADD `useSchemaOrg([defineWebPage({ '@type': 'CollectionPage', ... }), defineBreadcrumb({ itemListElement: [home, blog] })])` after `useSeoMeta`.
3. Re-use `resolveOgImage(null)` to emit the fallback consistently (D-06).
---
## Shared Patterns
### Bilingual `queryCollection` branching (literal strings mandatory)
**Source:** `app/pages/blog/index.vue` lines 10-20 and `[slug].vue` lines 10-17.
**Apply to:** `server/api/__sitemap__/urls.ts` (both branches via `Promise.all`), `[slug].vue` alt-exists detection.
```ts
isFr.value
? queryCollection('blog_fr').where('draft', '=', false).order('date', 'DESC').all()
: queryCollection('blog_en').where('draft', '=', false).order('date', 'DESC').all()
```
**Rule:** Never `queryCollection('blog_' + locale)` — Vite extractor breaks in build (Phase 5 gotcha, Pitfall 2 RESEARCH).
### Reactive arrow-fn values in `useSeoMeta`
**Source:** `[slug].vue` lines 94-98 (`title: () => page.value?.title`).
**Apply to:** All new `useSeoMeta` keys in `[slug].vue` and `index.vue`. Static strings are fine; anything reading from `page.value` / `locale.value` / `altExists.value` MUST be wrapped `() => ...`.
### `localePath()` for canonical URLs (never concat slug)
**Source:** `[slug].vue` line 3 + breadcrumb lines 58-59.
**Apply to:** `ogUrl`, `mainEntityOfPage`, `defineBreadcrumb` items in both pages. Canonical form: `` `${siteConfig.url}${localePath('/blog/' + slug)}` `` (Pitfall 6).
### Single source of truth for identity (Killian)
**Source:** `app/data/site.ts` lines 5-43 (`siteConfig`).
**Apply to:** `app/utils/seo-person.ts` must re-import (or re-derive from) `siteConfig.url`, `siteConfig.social[]` URLs. No duplicated LinkedIn/Gitea strings.
### Content schema extension via `.optional()`
**Source:** `content.config.ts` lines 3-12 — precedent set by Phase 6 `wordCount`/`minutes`.
**Apply to:** new `updated: z.string().optional()` field.
### Nitro ctx + `queryCollection(event, ...)` first-arg rule
**Source:** `server/plugins/reading-time.ts` lines 12-23 (nitro ctx patterns in this repo).
**Apply to:** `server/api/__sitemap__/urls.ts` — pass `event` as first arg (Pitfall 1).
---
## No Analog Found
| File | Role | Reason |
|---|---|---|
| `public/og-blog-default.jpg` | static asset | Binary asset; no code analog. Planner task: ship 1200×630 branded JPG (placeholder acceptable per Open Question #2 RESEARCH). |
| `useSchemaOrg` / `defineArticle` / `defineBreadcrumb` / `definePerson` / `defineWebSite` / `defineSitemapEventHandler` calls | schema-org / sitemap APIs | No prior usage in the codebase; follow RESEARCH Patterns 13 verbatim. |
## Metadata
**Analog search scope:** `app/`, `server/`, `content.config.ts`, `nuxt.config.ts`
**Files scanned:** 9 read in full (all ≤ 160 lines; no large-file targeted reads needed)
**Pattern extraction date:** 2026-04-22