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

206 lines
7.2 KiB
Markdown

# 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
```ts
// 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 },
],
}
```
```ts
// 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:
```ts
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:
```ts
// 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:
```ts
// 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:
```tsx
// 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
```ts
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):
```ts
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
```ts
import {
resolveRoute,
getArchiveEntries,
buildArchivePath,
buildEntryPath,
parsePageParam,
createContentHelpers,
archiveFieldName,
} from '@intecion/ipal-kit'
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '@intecion/ipal-kit'
```