250 lines
8.2 KiB
Markdown
250 lines
8.2 KiB
Markdown
# 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 '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 '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 '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 '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 '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 '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. |