7.2 KiB
content
Kolekcje, których wpisy żyją pod stroną-archiwum — blog, realizacje, aktualności,
cokolwiek z listingiem. Wpisy trafiają pod adres w rodzaju /pl/artykuly/moj-post,
a segment artykuly nie jest osobną konfiguracją — to slug strony, którą edytor
wskazał jako archiwum tej kolekcji.
Model — kto czym jest
kolekcja posts ──(config)──► „to kolekcja archiwalna”
strona „Artykuły” ──(System Pages)──► „jestem archiwum kolekcji posts”
wpis ──► należy do posts, i tyle
Wpis niczego nie wie o swoim adresie. O adresie decyduje przypisanie strony —
jedno dla całej kolekcji. Zmiana tytułu strony „Artykuły" na „Wpisy" przenosi
całą sekcję na /pl/wpisy, per locale, bez deploya. To jak folder: pliki nie
tagują się nazwą folderu, folder ma nazwę.
Kolekcji nie da się dodać z panelu — Payload trzyma schemat w kodzie. Nowy typ treści to zawsze zmiana w kodzie (nowa kolekcja + wpis w configu). Edytor zarządza tylko przypisaniem archiwum i jego adresem.
Config
// content.config.ts — współdzielony przez payload.config i front
import type { ContentOption } from '@intecion/ipal-kit'
export const contentConfig: ContentOption = {
collections: [
{ slug: 'posts', label: 'Artykuły', perPage: 10 },
{ slug: 'projects', label: 'Realizacje', perPage: 6 },
],
}
// payload.config.ts
ipalKit({
pages: { slug: 'pages' }, // wymagane — archiwum jest stroną
content: contentConfig,
seo: { collections: ['pages', 'posts', 'projects'] }, // wpisy też chcą meta
})
Każda pozycja dodaje w Site Settings → System Pages pole relacji
„{label} — archive page". slug to kolekcja klienta, perPage steruje
paginacją listingu.
Osobny plik content.config.ts (jak i18n.config.ts) jest potrzebny, bo tę samą
deklarację czytają dwa miejsca: payload.config (żeby dodać pola archiwum) i front
(router). Jedno źródło prawdy.
Rozstrzyganie tras — resolveRoute
Serce modułu. Catch-all [[...slug]] łapie wszystko, a resolveRoute mówi, czym
dana ścieżka jest:
import { resolveRoute } from '@intecion/ipal-kit'
const route = await resolveRoute({
payload,
locale: 'pl',
segments: ['artykuly', 'moj-post'],
page: 1, // z ?page=
content: contentConfig,
})
// route.type: 'home' | 'page' | 'archive' | 'entry' | (null gdy 404)
Logika: pierwszy segment dopasowywany jest do slugów stron-archiwów z System
Pages. Trafienie → wiadomo, której kolekcji dotyczy. ['artykuly'] → listing
posts; ['artykuly','moj-post'] → wpis w posts; brak trafienia → zwykła
strona.
Archiwum ma pierwszeństwo przed stroną o tym samym slugu — inaczej strona „artykuly" przesłaniałaby własne wpisy.
Głębokość tylko {archiwum}/{wpis} — kategorie w ścieżce robiłyby canonical
niejednoznacznym (ten sam wpis pod wieloma URL-ami), więc /a/b/c → null.
Kolizja slugów: dwie kolekcje z archiwum o tym samym slugu → wygrywa pierwsza
z listy collections. Błąd konfiguracji, router nie ostrzega.
Helpery frontu — createContentHelpers
Zamiast pisać cache'owane wrappery w każdym projekcie:
// src/lib/content.ts
import { createContentHelpers } from '@intecion/ipal-kit'
import config from '@/payload.config'
import { contentConfig } from '@/content.config'
export const { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute, getEntries } =
createContentHelpers({ config, content: contentConfig })
Wszystko owinięte w React cache(), a instancja Payloada powstaje raz w
fabryce i jest współdzielona — dlatego to fabryka, nie luźne funkcje. Bez tego
cache() nie dedupikowałby między helperami, a Next woła generateMetadata i
komponent strony osobno.
resolveRoute z fabryki przyjmuje (locale, segments, page) — rozstrzyga
trasę. Pobranie wpisów to osobna funkcja getEntries (patrz niżej), bo
routing i pobieranie danych to dwie różne odpowiedzialności.
Routing i pobieranie — osobno
Archiwum potrzebuje listy wpisów; metadane nie. Rozstrzyganie trasy i pobieranie wpisów to dwie funkcje, składane jawnie tam, gdzie trzeba obu:
// generateMetadata — sama trasa (nie płaci za zapytanie o wpisy)
const route = await resolveRoute(locale, slug, page)
// komponent strony — trasa, a potem wpisy, jeśli to archiwum
const route = await resolveRoute(locale, slug, page)
const entries = route?.type === 'archive'
? await getEntries(route.collection, locale, route.page, route.perPage)
: null
// entries: { docs, page, totalPages, hasPrevPage, hasNextPage, ... }
resolveRoute zwraca collection i perPage — czyli CO i ILE pobrać — ale
pobierania nie robi. Dzięki temu zmiana sortowania czy filtrowania listingu nie
dotyka reguł routingu, a metadane nie pobierają wpisów, których i tak nie użyją.
Listing
Strona-archiwum to zwykła strona z blokami, więc listing jest blokiem (Twoim — plugin nie decyduje o wyglądzie listy). Blok dostaje wpisy przez enhanceProps:
// w page.tsx
const enhanceProps = ({ block }) => {
if (block.blockType === 'entriesList' && route.type === 'archive') {
return { entries: route.entries, locale, archiveSlug: route.doc.slug }
}
return {}
}
Blok nie wie, co listuje — wpisy wstrzykuje trasa na podstawie tego, której
kolekcji ta strona jest archiwum. Ten sam blok obsługuje /pl/artykuly i
/pl/realizacje.
Ścieżki i paginacja
import { buildEntryPath, buildArchivePath, parsePageParam } from '@intecion/ipal-kit'
buildEntryPath({ locale: 'pl', archiveSlug: 'artykuly', entrySlug: 'moj-post' })
// '/pl/artykuly/moj-post'
buildArchivePath({ locale: 'pl', archiveSlug: 'artykuly', page: 2 })
// '/pl/artykuly?page=2' (strona 1 bez query)
parsePageParam(searchParams.page) // '2' → 2; śmieci/undefined → 1
Paginacja przez ?page=, nie /2. Segment ścieżki gryzłby się z wpisem o slugu
„2", a /strona/2 dodawałby kolejny zlokalizowany segment do konfiguracji.
Google rozumie ?page= od lat, a rel=next/prev zostało wycofane.
Canonical strony 2 wskazuje na siebie (?page=2), nie na stronę 1 — inaczej
Google uznałby, że wpisów z dalszych stron nie ma. Hreflangi niosą ten sam numer
(/en/articles?page=2). To ogarnia createPageMetadata automatycznie, gdy
przekażesz mu page i content.
Metadane wpisów
createPageMetadata z opcją content sam rozpoznaje wpisy i buduje im poprawny
canonical/hreflang z prefiksem (patrz seo.md):
const pageMetadata = createPageMetadata({
config: i18nConfig,
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
content: contentConfig, // ← bez tego wpisy dostają zły adres
})
Przełącznik języka
switchLocalePath (z i18n) uwzględnia prefiks: polski wpis bez tłumaczenia EN
prowadzi do archiwum EN (/en/articles), nie na stronę główną — najbliżej tego,
czego szuka odwiedzający.
Eksport
import {
resolveRoute,
getArchiveEntries,
buildArchivePath,
buildEntryPath,
parsePageParam,
createContentHelpers,
archiveFieldName,
} from '@intecion/ipal-kit'
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '@intecion/ipal-kit'