init commit for iPAL-kit plugin

This commit is contained in:
2026-07-28 00:21:22 +02:00
parent 5733a9c8bf
commit 24a1fe0d60
12 changed files with 443 additions and 202 deletions
+141 -37
View File
@@ -1,8 +1,8 @@
# 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.
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ść
@@ -12,6 +12,9 @@ Dodaj do `dependencies` (pin do wersji payload):
"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
@@ -27,60 +30,137 @@ ipalKit({
})
```
Dodaje tab **SEO** (title, description, image) do wskazanych kolekcji.
Auto-fill: hook `beforeChange` wypełnia puste `meta.title` z pola dokumentu
(domyślnie `title`). Nigdy nie nadpisuje tego, co edytor wpisał ręcznie.
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.
## Front — generateMetadata (factory)
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.
Najprościej: factory redukuje boilerplate. Klient podaje resolvery (bo zna
swoje kolekcje/routing), plugin składa metadata.
## 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]/[[...segments]]/page.tsx
import { createMetadataGenerator } from 'ipal-kit'
import { getSiteSettings } from 'ipal-kit'
// app/(frontend)/[locale]/[[...slug]]/page.tsx
import { createPageMetadata } from 'ipal-kit'
import { i18nConfig } from '@/i18n.config'
const gen = createMetadataGenerator({
const pageMetadata = createPageMetadata({
config: i18nConfig,
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
resolveDocument: async ({ payload, params, locale }) => {
const slug = /* z params */ ''
const res = await payload.find({
collection: 'pages',
where: { slug: { equals: slug } },
locale: 'all', depth: 1, limit: 1,
})
return res.docs[0] ?? null // musi mieć .meta i .slug (locale:'all')
},
resolveSiteName: async ({ payload, locale }) =>
(await getSiteSettings(payload, { locale })).siteName ?? null,
// collection: 'pages', // domyślne
// settingsSlug: 'site-settings', // domyślne
// siteNameField: 'siteName', // domyślne
})
export async function generateMetadata({ params }) {
const payload = await getPayload({ config })
const { locale } = await params
return gen({ payload, params: await params, locale })
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params
return pageMetadata({ payload: await getPayload({ config }), locale, slug })
}
```
## Front — niżej: buildMetadata bezpośrednio
Wrapper jest konieczny: Next woła `generateMetadata({ params })` bez payloada, a
plugin nigdy nie wywołuje `getPayload` sam.
Jeśli chcesz pełną kontrolę zamiast factory:
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'
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all', depth: 1 })
return buildMetadata({
meta: doc.meta, // z plugin-seo
siteName: settings.siteName,
imageUrl: /* url OG image */,
imageUrl: '/og.png',
locale: 'pl',
slugs: getLocalizedSlugs({ slugField: doc.slug, config }),
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 } }
```
@@ -90,6 +170,30 @@ return buildMetadata({
```ts
import { composeTitle, buildHreflangAlternates } from 'ipal-kit'
composeTitle({ pageTitle: 'O nas', siteName: 'Acme' }) // 'O nas | Acme'
buildHreflangAlternates({ slugs, config, baseUrl }) // { pl: '...', en: '...' }
```
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.