Files
2026-08-04 19:37:23 +02:00

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'