Files
ipal-kit/docs/seo.md
T
2026-08-04 19:37:23 +02:00

250 lines
8.3 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.
# seo
Wpina `@payloadcms/plugin-seo` (pola meta w kolekcjach) i dodaje warstwę logiki:
składanie tytułów, budowanie `Metadata` dla Next.js z canonical i hreflang,
auto-fill pustych meta z treści dokumentu.
## Zależność
Dodaj do `dependencies` (pin do wersji payload):
```json
"dependencies": { "@payloadcms/plugin-seo": "3.84.1" }
```
Po wpięciu uruchom `pnpm payload generate:importmap` — pola SEO to komponenty
admina i bez importMap się nie wyrenderują.
## Config (payload.config.ts)
```ts
ipalKit({
seo: {
collections: ['pages', 'posts'], // które kolekcje dostają meta
// generateTitle: ({ doc }) => `${doc.title}`, // opcjonalne
// generateDescription: ({ doc }) => doc.excerpt ?? '',
// fields: [...], // extra pola w grupie SEO
// autoFill: { title: 'title', description: 'excerpt' }, // mapowanie auto-fill
// autoFill: false, // wyłącz auto-fill
},
})
```
Dodaje tab **SEO** do wskazanych kolekcji: title, description, image,
titleOverride. Auto-fill: hook `beforeChange` wypełnia puste `meta.title` z pola
dokumentu (domyślnie `title`). Nigdy nie nadpisuje tego, co edytor wpisał
ręcznie.
Tab budowany jest przez `injectSeoTabs`, nie przez `tabbedUI` plugin-seo —
tamten scala taby patrząc na pierwsze pole kolekcji i psuje się, gdy są tam już
inne pola (slug, role), zostawiając pusty tab SEO.
## Tytuły — konfiguracja z panelu
Bez kodu, per witryna (Site Settings → General):
- **Title Order** — `Page first` (O nas | Acme) albo `Site first` (Acme | O nas)
- **Title Separator** — `|` `–` `-` `·` `/`
Per strona (tab SEO):
- **Title Override** — wpisany tekst trafia do karty dosłownie, ignorując oba
powyższe. Do strony głównej, gdzie składanie dałoby "Acme | Acme".
Domyślnie: `Tytuł | Nazwa witryny`.
## Front — createPageMetadata
Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System
Pages, grupa meta), więc nie trzeba mu tego opisywać:
```ts
// app/(frontend)/[locale]/[[...slug]]/page.tsx
import { createPageMetadata } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config'
const pageMetadata = createPageMetadata({
config: i18nConfig,
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
// collection: 'pages', // domyślne
// settingsSlug: 'site-settings', // domyślne
// siteNameField: 'siteName', // domyślne
})
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params
return pageMetadata({ payload: await getPayload({ config }), locale, slug })
}
```
Wrapper jest konieczny: Next woła `generateMetadata({ params })` bez payloada, a
plugin nigdy nie wywołuje `getPayload` sam.
Ogarnia: tytuł (order/separator/override z panelu), description, canonical,
hreflang dla wszystkich locale, OG, home zwinięty do `/pl` (slug home czytany z
System Pages).
Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang będą względne (`/pl` zamiast
`https://…/pl`).
## Front — createMetadataGenerator
Dla tras spoza konwencji: inna kolekcja (blog), własna logika obrazka, inny
global. Klient dostarcza resolvery.
```ts
import { createMetadataGenerator } from '@intecion/ipal-kit'
const generate = createMetadataGenerator({
config: i18nConfig,
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
homeSlug: 'homepage',
resolveDocument: async ({ payload, params, locale }) => {
const slug = (params.slug as string[])?.join('/')
// meta MUSI przyjść w konkretnym locale (stringi), a slug jako mapa
// locale→wartość (hreflang) — to dwa różne odczyty.
const found = await payload.find({
collection: 'posts',
where: { slug: { equals: slug } },
locale,
depth: 1,
limit: 1,
})
const doc = found.docs[0]
if (!doc) return null
const allLocales = await payload.findByID({
collection: 'posts',
id: doc.id,
locale: 'all',
depth: 0,
})
return { ...doc, slug: allLocales.slug }
},
resolveSiteName: async ({ payload, locale }) =>
(await getSiteSettings(payload, { locale })).siteName ?? null,
resolveImageUrl: async ({ doc }) => doc.meta?.image?.url ?? null,
})
export async function generateMetadata({ params }) {
const { locale, slug } = await params
return generate({ payload: await getPayload({ config }), params: { slug }, locale })
}
```
**Pułapka:** pojedynczy odczyt z `locale: 'all'` wygląda kusząco, ale wtedy
**każde** zlokalizowane pole jest mapą — `meta.title` też. Składanie tytułu
dostaje obiekt zamiast stringa i leci `pageTitle?.trim is not a function`.
Next uruchamia `generateMetadata` i komponent strony niezależnie — bez React
`cache()` wokół tych odczytów każde żądanie pyta bazę dwa razy.
## Front — buildMetadata bezpośrednio
Gdy chcesz pełną kontrolę:
```ts
import { buildMetadata, getLocalizedSlugs } from '@intecion/ipal-kit'
return buildMetadata({
meta: doc.meta, // z plugin-seo
siteName: settings.siteName,
imageUrl: '/og.png',
locale: 'pl',
slugs: getLocalizedSlugs({ slugField: docAllLocales.slug, config }),
config,
baseUrl: 'https://example.com',
separator: ' – ', // opcjonalne
order: 'site-first', // opcjonalne
homeSlug: 'homepage',
})
// → { title, description, openGraph, alternates: { canonical, languages } }
```
## Pomocnicze
```ts
import { composeTitle, buildHreflangAlternates } from '@intecion/ipal-kit'
composeTitle({ pageTitle: 'O nas', siteName: 'Acme' })
// 'O nas | Acme'
composeTitle({ pageTitle: 'O nas', siteName: 'Acme', separator: ' – ', order: 'site-first' })
// 'Acme – O nas'
buildHreflangAlternates({ slugs, config, baseUrl }) // { pl: '...', en: '...' }
```
## Pomocnicze niższego poziomu
`createPageMetadata` składa się z mniejszych, testowalnych kawałków —
eksportowanych, gdybyś budował własny generator metadanych:
```ts
import { readSiteMetaConfig, slugsAcrossLocales } from '@intecion/ipal-kit'
// nazwa witryny, separator (dopełniony), kolejność, homeSlug — z SiteSettings
const site = await readSiteMetaConfig({ payload, locale })
// slug dokumentu we wszystkich locale — mapa dla hreflang
const slugs = await slugsAcrossLocales({ payload, collection: 'pages', id, config })
```
`slugsAcrossLocales` robi osobne zapytanie z `locale: 'all'` — bo ten tryb
zamienia KAŻDE zlokalizowane pole w mapę, co jest dobre dla sluga i złe dla
reszty (mapa w title rozłożyłaby składanie tytułu). Dlatego czyta tylko slug,
depth 0.
## Sitemapa i robots.txt
Plugin składa sitemapę z hreflangiem per URL — te same buildery co metadane, więc
nie rozjedzie się z tym, co strony serwują. Handlery są gotowe w
`createContentHelpers`; klient re-eksportuje je jedną linią.
```ts
// src/lib/content.ts
export const { /* ... */, sitemap, robots } = createContentHelpers({
config,
content: contentConfig,
i18n: i18nConfig, // wymagane dla sitemap (hreflang)
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
})
```
```ts
// app/sitemap.ts
export { sitemap as default } from '@/lib/content'
// app/robots.ts
export { robots as default } from '@/lib/content'
```
Next generuje z tego `/sitemap.xml` i `/robots.txt`. Nie da się tego schować
całkiem w pluginie — Next tworzy te trasy wyłącznie z plików w `app/`, skanuje
katalog projektu, nie node_modules. Ale re-eksport to maksimum redukcji: cała
logika jest w pluginie.
Co zawiera sitemapa:
- każdą stronę i wpis bloga, URL w domyślnym locale
- `alternates.languages` → Next renderuje `<xhtml:link rel="alternate" hreflang>`
per URL (to czyta Google przy wielu językach; zwykła sitemapa tego nie ma)
- `lastModified` z realnego `updatedAt` dokumentu
- wpisy bloga z prefiksem archiwum (`/pl/artykuly/moj-post`)
Pomija: drafty (`_status !== 'published'`) i dokumenty z `meta.noindex`.
Niskopoziomowo (własna trasa zamiast handlera z fabryki):
```ts
import { buildSitemapEntries, buildRobots } from '@intecion/ipal-kit'
const entries = await buildSitemapEntries({
payload, config: i18nConfig, baseUrl, content: contentConfig,
})
```
Przy dziesiątkach tysięcy URL-i Next ma `generateSitemaps` do dzielenia na
części — dodasz, gdyby realnie było ich tyle. Jedna sitemapa wystarcza do ~50k.