Files
ipal-kit/docs/seo.md
T

6.5 KiB
Raw Blame History

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

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

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

// 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.

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

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

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:

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.