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
+17 -11
View File
@@ -67,7 +67,6 @@ const route = await resolveRoute({
segments: ['artykuly', 'moj-post'], segments: ['artykuly', 'moj-post'],
page: 1, // z ?page= page: 1, // z ?page=
content: contentConfig, content: contentConfig,
withEntries: false, // true → dociąga wpisy do archiwum (patrz niżej)
}) })
// route.type: 'home' | 'page' | 'archive' | 'entry' | (null gdy 404) // route.type: 'home' | 'page' | 'archive' | 'entry' | (null gdy 404)
``` ```
@@ -96,7 +95,7 @@ import { createContentHelpers } from 'ipal-kit'
import config from '@/payload.config' import config from '@/payload.config'
import { contentConfig } from '@/content.config' import { contentConfig } from '@/content.config'
export const { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute } = export const { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute, getEntries } =
createContentHelpers({ config, content: contentConfig }) createContentHelpers({ config, content: contentConfig })
``` ```
@@ -105,23 +104,30 @@ fabryce i jest współdzielona — dlatego to fabryka, nie luźne funkcje. Bez t
`cache()` nie dedupikowałby między helperami, a Next woła generateMetadata i `cache()` nie dedupikowałby między helperami, a Next woła generateMetadata i
komponent strony osobno. komponent strony osobno.
`resolveRoute` z fabryki przyjmuje `(locale, segments, page, withEntries?)`. `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.
## withEntries — wpisy tylko gdy trzeba ## Routing i pobieranie — osobno
Archiwum potrzebuje listy wpisów; metadane nie. `withEntries` rozstrzyga: Archiwum potrzebuje listy wpisów; metadane nie. Rozstrzyganie trasy i pobieranie
wpisów to dwie funkcje, składane jawnie tam, gdzie trzeba obu:
```ts ```ts
// generateMetadata — bez wpisów (nie płaci za zapytanie) // generateMetadata — sama trasa (nie płaci za zapytanie o wpisy)
const route = await resolveRoute(locale, slug, page) const route = await resolveRoute(locale, slug, page)
// komponent strony — z wpisami // komponent strony — trasa, a potem wpisy, jeśli to archiwum
const route = await resolveRoute(locale, slug, page, true) const route = await resolveRoute(locale, slug, page)
// route.entries: { docs, page, totalPages, hasPrevPage, hasNextPage, ... } const entries = route?.type === 'archive'
? await getEntries(route.collection, locale, route.page, route.perPage)
: null
// entries: { docs, page, totalPages, hasPrevPage, hasNextPage, ... }
``` ```
Metadane i strona wołają z różnymi argumentami, więc `cache()` traktuje je jako `resolveRoute` zwraca `collection` i `perPage` — czyli CO i ILE pobrać — ale
osobne wywołania — i słusznie, bo metadane wpisów nie potrzebują. 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 ## Listing
+70
View File
@@ -0,0 +1,70 @@
# Publikacja ipal-kit — checklist
## package.json — wymagane pola
```json
{
"name": "@intecion/ipal-kit", // scoped pod organizację
"version": "1.0.0", // BUMP przy każdej publikacji (semver)
"files": ["dist"], // tylko dist trafia do pakietu (NIE src)
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { ... }, // wszystkie entry points na ./dist/*
"publishConfig": {
"registry": "https://npm.pkg.github.com",
"exports": { ... } // mirror z ./dist (masz to już zrobione)
},
"scripts": {
"build": "...",
"prepublishOnly": "pnpm clean && pnpm build" // build ZAWSZE przed publish
},
"peerDependencies": { // NIE dependencies — klient już je ma
"payload": "3.84.1",
"next": ">=15",
"react": ">=19"
}
}
```
## Krytyczne przed pierwszą publikacją
- [ ] **peerDependencies zamiast dependencies** dla payload/next/react —
inaczej pakiet zaciąga drugą kopię Payloada i wszystko się sypie.
@payloadcms/plugin-seo i plugin-form-builder też jako peer (klient pinuje).
- [ ] **`"files": ["dist"]`** — bez tego do pakietu trafia src/ (widzieliśmy to
w stack trace). Sam dist.
- [ ] **usuń self-reference** — sprawdź, że w dependencies NIE ma
"ipal-kit": "file:..." (ta zaraza z pnpm add w złym katalogu).
- [ ] **prepublishOnly** buduje przed publikacją — nigdy nie publikuj ręcznie
zbudowanego dist (łatwo o nieaktualny).
- [ ] **bump wersji** — koniec z 1.0.0 na zawsze. Każda publikacja = nowy numer.
To rozwiązuje cały cykl cache/store prune, który gryzł podczas developmentu.
## Publikacja (GitHub Packages)
```bash
# jednorazowo: token GitHuba z prawami write:packages w ~/.npmrc
echo "//npm.pkg.github.com/:_authToken=TWÓJ_TOKEN" >> ~/.npmrc
# przy każdym wydaniu
npm version patch # 1.0.0 → 1.0.1 (albo minor/major)
npm publish
```
## Instalacja u pracownika
```bash
# ~/.npmrc w projekcie albo globalnie
@intecion:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=ICH_TOKEN
# potem normalnie
pnpm add @intecion/ipal-kit
pnpm add @payloadcms/[email protected] @payloadcms/[email protected] \
nodemailer lucide-react slugify server-only
```
## README pakietu
Wskaż na docs/getting-started.md jako pierwszy krok. Pracownik z dostępem do
rejestru + getting-started postawi projekt bez pytania Ciebie o nic.
+141 -37
View File
@@ -1,8 +1,8 @@
# seo # seo
Wpina `@payloadcms/plugin-seo` (pola meta w kolekcjach) i dodaje warstwę Wpina `@payloadcms/plugin-seo` (pola meta w kolekcjach) i dodaje warstwę logiki:
logiki: składanie tytułów, budowanie `Metadata` dla Next.js z canonical i składanie tytułów, budowanie `Metadata` dla Next.js z canonical i hreflang,
hreflang, auto-fill pustych meta z treści dokumentu. auto-fill pustych meta z treści dokumentu.
## Zależność ## Zależność
@@ -12,6 +12,9 @@ Dodaj do `dependencies` (pin do wersji payload):
"dependencies": { "@payloadcms/plugin-seo": "3.84.1" } "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) ## Config (payload.config.ts)
```ts ```ts
@@ -27,60 +30,137 @@ ipalKit({
}) })
``` ```
Dodaje tab **SEO** (title, description, image) do wskazanych kolekcji. Dodaje tab **SEO** do wskazanych kolekcji: title, description, image,
Auto-fill: hook `beforeChange` wypełnia puste `meta.title` z pola dokumentu titleOverride. Auto-fill: hook `beforeChange` wypełnia puste `meta.title` z pola
(domyślnie `title`). Nigdy nie nadpisuje tego, co edytor wpisał ręcznie. 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 ## Tytuły — konfiguracja z panelu
swoje kolekcje/routing), plugin składa metadata.
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 ```ts
// app/(frontend)/[locale]/[[...segments]]/page.tsx // app/(frontend)/[locale]/[[...slug]]/page.tsx
import { createMetadataGenerator } from 'ipal-kit' import { createPageMetadata } from 'ipal-kit'
import { getSiteSettings } from 'ipal-kit' import { i18nConfig } from '@/i18n.config'
const gen = createMetadataGenerator({ const pageMetadata = createPageMetadata({
config: i18nConfig, config: i18nConfig,
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
resolveDocument: async ({ payload, params, locale }) => { // collection: 'pages', // domyślne
const slug = /* z params */ '' // settingsSlug: 'site-settings', // domyślne
const res = await payload.find({ // siteNameField: 'siteName', // domyślne
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,
}) })
export async function generateMetadata({ params }) { export async function generateMetadata({ params }): Promise<Metadata> {
const payload = await getPayload({ config }) const { locale, slug } = await params
const { locale } = await params return pageMetadata({ payload: await getPayload({ config }), locale, slug })
return gen({ payload, params: await params, locale })
} }
``` ```
## 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 ```ts
import { buildMetadata, getLocalizedSlugs } from 'ipal-kit' import { buildMetadata, getLocalizedSlugs } from 'ipal-kit'
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all', depth: 1 })
return buildMetadata({ return buildMetadata({
meta: doc.meta, // z plugin-seo meta: doc.meta, // z plugin-seo
siteName: settings.siteName, siteName: settings.siteName,
imageUrl: /* url OG image */, imageUrl: '/og.png',
locale: 'pl', locale: 'pl',
slugs: getLocalizedSlugs({ slugField: doc.slug, config }), slugs: getLocalizedSlugs({ slugField: docAllLocales.slug, config }),
config, config,
baseUrl: 'https://example.com', baseUrl: 'https://example.com',
separator: ' – ', // opcjonalne
order: 'site-first', // opcjonalne
homeSlug: 'homepage',
}) })
// → { title, description, openGraph, alternates: { canonical, languages } } // → { title, description, openGraph, alternates: { canonical, languages } }
``` ```
@@ -90,6 +170,30 @@ return buildMetadata({
```ts ```ts
import { composeTitle, buildHreflangAlternates } from 'ipal-kit' import { composeTitle, buildHreflangAlternates } from 'ipal-kit'
composeTitle({ pageTitle: 'O nas', siteName: 'Acme' }) // 'O nas | Acme' composeTitle({ pageTitle: 'O nas', siteName: 'Acme' })
buildHreflangAlternates({ slugs, config, baseUrl }) // { pl: '...', en: '...' } // '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.
+15 -10
View File
@@ -1,7 +1,7 @@
{ {
"name": "ipal-kit", "name": "@intecion/ipal-kit",
"version": "1.0.0", "version": "1.0.0",
"description": "A blank template to get started with Payload 3.0", "description": "Intecion Payload Advanced Library — plugin for Payload CMS 3: i18n, SEO, forms, consent, analytics, blogs/archives.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -37,6 +37,8 @@
"dist" "dist"
], ],
"scripts": { "scripts": {
"prepare": "pnpm build",
"prepublishOnly": "pnpm clean && pnpm build",
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc", "build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --outDir dist --rootDir ./src", "build:types": "tsc --outDir dist --rootDir ./src",
@@ -55,19 +57,26 @@
"test:int": "vitest" "test:int": "vitest"
}, },
"dependencies": { "dependencies": {
"@payloadcms/plugin-form-builder": "3.84.1",
"@payloadcms/plugin-seo": "3.84.1",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"slugify": "^1.6.6" "slugify": "^1.6.6"
}, },
"peerDependencies": {
"@payloadcms/plugin-form-builder": "^3.84.1",
"@payloadcms/plugin-seo": "^3.84.1",
"next": ">=15",
"payload": "^3.84.1",
"react": "^19.0.0"
},
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-postgres": "3.84.1", "@payloadcms/db-postgres": "3.84.1",
"@payloadcms/db-sqlite": "3.84.1", "@payloadcms/db-sqlite": "3.84.1",
"@payloadcms/eslint-config": "3.28.0", "@payloadcms/eslint-config": "3.28.0",
"@payloadcms/next": "3.84.1", "@payloadcms/next": "3.84.1",
"@payloadcms/plugin-form-builder": "3.84.1",
"@payloadcms/plugin-seo": "3.84.1",
"@payloadcms/richtext-lexical": "3.84.1", "@payloadcms/richtext-lexical": "3.84.1",
"@payloadcms/ui": "3.84.1", "@payloadcms/ui": "3.84.1",
"@playwright/test": "1.58.2", "@playwright/test": "1.58.2",
@@ -97,15 +106,12 @@
"vite-tsconfig-paths": "6.0.5", "vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18" "vitest": "4.0.18"
}, },
"peerDependencies": {
"payload": "^3.84.1",
"react": "^19.0.0"
},
"engines": { "engines": {
"node": "^18.20.2 || >=20.9.0", "node": "^18.20.2 || >=20.9.0",
"pnpm": "^9 || ^10 || ^11" "pnpm": "^9 || ^10 || ^11"
}, },
"publishConfig": { "publishConfig": {
"registry": "https://npm.pkg.github.com",
"exports": { "exports": {
".": { ".": {
"import": "./dist/index.js", "import": "./dist/index.js",
@@ -142,6 +148,5 @@
"esbuild", "esbuild",
"unrs-resolver" "unrs-resolver"
] ]
}, }
"registry": "https://registry.npmjs.org/"
} }
+19 -20
View File
@@ -1,40 +1,37 @@
import type { Field, GlobalConfig } from 'payload' import type { Field, GlobalConfig } from 'payload'
import type { ContentOption } from '../../modules/content/index.js'
import type { PagesOption } from '../../modules/pages/index.js'
import { buildArchiveFields } from '../../modules/content/index.js'
import { buildSystemPagesFields } from '../../modules/pages/index.js'
import { generalFields } from './fields/general.js' import { generalFields } from './fields/general.js'
import { themeFields } from './fields/theme.js' import { themeFields } from './fields/theme.js'
type BuildSiteSettingsArgs = { type BuildSiteSettingsArgs = {
/** Extra fields injected by the client project */ /** Extra fields injected by the client project */
additionalFields?: Field[] additionalFields?: Field[]
/** Archive-page assignments for content collections — joins the same tab */ /**
content?: ContentOption * Fields for the "System Pages" tab — page assignments by role (homepage,
/** System-page assignments — adds a "System Pages" tab when provided */ * privacy policy, collection archives). Assembled by the caller, which is the
pages?: PagesOption * only place that knows which modules are enabled. The tab is omitted when
* this is empty.
*/
systemPageFields?: Field[]
} }
/** /**
* Builds the SiteSettings global. * Builds the SiteSettings global.
* *
* Composes tabs out of field groups; it deliberately doesn't know where those
* groups come from. Earlier it imported the pages and content modules directly,
* which meant a global — a presentation-layer concern — had an opinion about
* blogs. Now the plugin's composition root passes fields in, and adding a new
* kind of page assignment needs no change here.
*
* Uses unnamed tabs — data stays flat (siteSettings.siteName, not * Uses unnamed tabs — data stays flat (siteSettings.siteName, not
* siteSettings.general.siteName). Client-provided fields land in their * siteSettings.general.siteName). Client-provided fields land in their own
* own "Custom" tab so core data paths never shift. * "Custom" tab so core data paths never shift.
*/ */
export function buildSiteSettings({ export function buildSiteSettings({
additionalFields, additionalFields,
content, systemPageFields,
pages,
}: BuildSiteSettingsArgs = {}): GlobalConfig { }: BuildSiteSettingsArgs = {}): GlobalConfig {
// Archive assignments sit with the system pages: both answer "which page
// plays this role", and both turn into URLs through the page's own slug.
const systemPageFields = [
...(pages ? buildSystemPagesFields(pages) : []),
...(content && pages ? buildArchiveFields(content, pages.slug) : []),
]
return { return {
slug: 'site-settings', slug: 'site-settings',
access: { access: {
@@ -55,7 +52,9 @@ export function buildSiteSettings({
fields: themeFields, fields: themeFields,
label: 'Theme', label: 'Theme',
}, },
...(systemPageFields.length ? [{ fields: systemPageFields, label: 'System Pages' }] : []), ...(systemPageFields?.length
? [{ fields: systemPageFields, label: 'System Pages' }]
: []),
...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []), ...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),
], ],
}, },
+8 -24
View File
@@ -1,9 +1,7 @@
import type { BasePayload } from 'payload' import type { BasePayload } from 'payload'
import type { ArchiveEntries } from './getArchiveEntries.js'
import type { ContentOption } from './types.js' import type { ContentOption } from './types.js'
import { getArchiveEntries } from './getArchiveEntries.js'
import { archiveFieldName } from './types.js' import { archiveFieldName } from './types.js'
type ArchivePage = { id: number | string; slug?: unknown } type ArchivePage = { id: number | string; slug?: unknown }
@@ -21,8 +19,6 @@ export type ResolvedRoute =
| { | {
collection: string collection: string
doc: Record<string, unknown> doc: Record<string, unknown>
/** Present only when resolved with `withEntries` — metadata doesn't need them. */
entries?: ArchiveEntries
/** 1-based, from ?page=. */ /** 1-based, from ?page=. */
page: number page: number
perPage: number perPage: number
@@ -45,17 +41,18 @@ type ResolveRouteArgs = {
segments?: string[] segments?: string[]
/** SiteSettings global slug. Defaults to 'site-settings'. */ /** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string settingsSlug?: string
/**
* Fetch the archive's entries too. The page component wants them; metadata
* generation doesn't, and would pay for a query it throws away.
*/
withEntries?: boolean
} }
/** /**
* Works out what a URL points at: the home page, an ordinary page, a * Works out what a URL points at: the home page, an ordinary page, a
* collection's archive, or a single entry. * collection's archive, or a single entry.
* *
* Routing only. An archive result says which collection to list and on which
* page, but doesn't fetch the entries — that's `getArchiveEntries`, called by
* whoever actually needs them. Keeping the two apart means listing changes
* (sorting, filtering, pagination) never touch routing rules, and metadata
* generation doesn't pay for a query it would discard.
*
* The trick is that archive prefixes aren't configured anywhere — they're the * The trick is that archive prefixes aren't configured anywhere — they're the
* slug of whichever page an editor assigned as that collection's archive. So * slug of whichever page an editor assigned as that collection's archive. So
* /pl/artykuly and /en/articles come from one assignment, and renaming the page * /pl/artykuly and /en/articles come from one assignment, and renaming the page
@@ -77,7 +74,6 @@ export async function resolveRoute({
payload, payload,
segments, segments,
settingsSlug = 'site-settings', settingsSlug = 'site-settings',
withEntries = false,
}: ResolveRouteArgs): Promise<null | ResolvedRoute> { }: ResolveRouteArgs): Promise<null | ResolvedRoute> {
const settings = (await payload.findGlobal({ const settings = (await payload.findGlobal({
slug: settingsSlug, slug: settingsSlug,
@@ -94,9 +90,9 @@ export async function resolveRoute({
return null return null
} }
// Which archive (if any) does the first segment name?
const [first, ...rest] = segments const [first, ...rest] = segments
// Does the first segment name an archive page?
for (const collection of content?.collections ?? []) { for (const collection of content?.collections ?? []) {
const archive = settings[archiveFieldName(collection.slug)] const archive = settings[archiveFieldName(collection.slug)]
if (!archive || typeof archive !== 'object') {continue} if (!archive || typeof archive !== 'object') {continue}
@@ -106,24 +102,12 @@ export async function resolveRoute({
// /pl/artykuly → the archive page itself. // /pl/artykuly → the archive page itself.
if (rest.length === 0) { if (rest.length === 0) {
const perPage = collection.perPage ?? 10
return { return {
type: 'archive', type: 'archive',
collection: collection.slug, collection: collection.slug,
doc: archiveDoc as Record<string, unknown>, doc: archiveDoc as Record<string, unknown>,
page, page,
perPage, perPage: collection.perPage ?? 10,
...(withEntries
? {
entries: await getArchiveEntries({
collection: collection.slug,
locale,
page,
payload,
perPage,
}),
}
: {}),
} }
} }
+24 -21
View File
@@ -3,9 +3,9 @@ import type { BasePayload, SanitizedConfig } from 'payload'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { cache } from 'react' import { cache } from 'react'
import type { ContentOption, ResolvedRoute } from '../content/index.js' import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'
import { resolveRoute as resolveRouteRaw } from '../content/index.js' import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'
type CreateContentHelpersArgs = { type CreateContentHelpersArgs = {
/** /**
@@ -24,7 +24,7 @@ type CreateContentHelpersArgs = {
/** /**
* Bundles the per-request data helpers a frontend needs — the same cached * Bundles the per-request data helpers a frontend needs — the same cached
* wrappers every project was writing by hand (getPayload, settings, locale * wrappers every project was writing by hand (getPayload, settings, locale
* list, route resolution). * list, route resolution, archive entries).
* *
* Everything is wrapped in React `cache()`, so within one request a value is * Everything is wrapped in React `cache()`, so within one request a value is
* fetched once no matter how many times it's asked for — which matters because * fetched once no matter how many times it's asked for — which matters because
@@ -39,9 +39,13 @@ type CreateContentHelpersArgs = {
* import config from '@/payload.config' * import config from '@/payload.config'
* import { contentConfig } from '@/content.config' * import { contentConfig } from '@/content.config'
* *
* export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute } = * export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries } =
* createContentHelpers({ config, content: contentConfig }) * createContentHelpers({ config, content: contentConfig })
* ``` * ```
*
* `resolveRoute` and `getEntries` are separate on purpose: routing decides what
* a URL is, fetching gets the listing. Metadata generation needs the first and
* not the second, and a page component composes them in two obvious lines.
*/ */
export function createContentHelpers({ export function createContentHelpers({
config, config,
@@ -63,31 +67,30 @@ export function createContentHelpers({
return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never }) return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })
}) })
/** /** What does this URL point at? Routing only — no listing data. */
* Resolves a URL to a page / archive / entry. Pass `withEntries` on the page
* component (it needs the listing); omit it for metadata (it doesn't, and the
* query would be wasted).
*/
const resolveRoute = cache( const resolveRoute = cache(
async ( async (
locale: string, locale: string,
segments: string[] | undefined, segments: string[] | undefined,
page: number, page: number,
withEntries = false,
): Promise<null | ResolvedRoute> => { ): Promise<null | ResolvedRoute> => {
const payload = await getCachedPayload() const payload = await getCachedPayload()
return resolveRouteRaw({ return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug })
content,
locale,
page,
pagesSlug,
payload,
segments,
settingsSlug,
withEntries,
})
}, },
) )
return { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute } /** One page of a collection's entries, for an archive listing. */
const getEntries = cache(
async (
collection: string,
locale: string,
page: number,
perPage: number,
): Promise<ArchiveEntries> => {
const payload = await getCachedPayload()
return getArchiveEntries({ collection, locale, page, payload, perPage })
},
)
return { getCachedPayload, getConfiguredLocales, getEntries, getSettings, resolveRoute }
} }
+36 -75
View File
@@ -3,12 +3,12 @@ import type { BasePayload } from 'payload'
import type { ContentOption } from '../content/index.js' import type { ContentOption } from '../content/index.js'
import type { I18nConfig } from '../i18n/index.js' import type { I18nConfig } from '../i18n/index.js'
import type { PageMetadata } from './buildMetadata.js' import type { PageMetadata } from './buildMetadata.js'
import type { TitleOrder } from './composeTitle.js'
import type { SeoMeta } from './types.js' import type { SeoMeta } from './types.js'
import { resolveRoute } from '../content/index.js' import { resolveRoute } from '../content/index.js'
import { getLocalizedSlugs } from '../i18n/index.js'
import { buildMetadata } from './buildMetadata.js' import { buildMetadata } from './buildMetadata.js'
import { readSiteMetaConfig } from './readSiteMetaConfig.js'
import { slugsAcrossLocales } from './slugsAcrossLocales.js'
type CreatePageMetadataArgs = { type CreatePageMetadataArgs = {
/** Absolute site origin, e.g. 'https://example.com'. */ /** Absolute site origin, e.g. 'https://example.com'. */
@@ -40,37 +40,18 @@ type PageMetadataContext = {
slug?: string[] slug?: string[]
} }
type SettingsShape = {
[key: string]: unknown
homepage?: { id: number | string; slug?: unknown } | null | number | string
titleOrder?: null | TitleOrder
titleSeparator?: null | string
}
type DocShape = { type DocShape = {
id: number | string id: number | string
meta?: null | SeoMeta meta?: null | SeoMeta
/** A string when read in one locale, a locale→value map when read with 'all'. */
slug?: unknown
} }
/** Reads a document again across locales — the slug map hreflang needs. */ /** plugin-seo stores the OG image as an upload relationship. */
async function slugsAcrossLocales( function resolveOgImage(doc: DocShape): null | string {
payload: BasePayload, const image = (doc.meta as { image?: unknown } | null | undefined)?.image
collection: string, if (image && typeof image === 'object' && 'url' in image) {
id: number | string, return (image as { url: string }).url ?? null
config: I18nConfig, }
) { return null
const doc = (await payload.findByID({
id,
collection: collection as never,
depth: 0,
locale: 'all',
})) as DocShape
return doc.slug && typeof doc.slug === 'object'
? getLocalizedSlugs({ config, slugField: doc.slug as Record<string, unknown> })
: {}
} }
/** /**
@@ -114,35 +95,17 @@ export function createPageMetadata(args: CreatePageMetadataArgs) {
page, page,
payload, payload,
}: PageMetadataContext): Promise<PageMetadata> { }: PageMetadataContext): Promise<PageMetadata> {
const settings = (await payload.findGlobal({ const site = await readSiteMetaConfig({ locale, payload, settingsSlug, siteNameField })
slug: settingsSlug,
depth: 1,
locale: locale as never,
})) as SettingsShape
const siteName = (settings[siteNameField] as string | undefined) ?? null const base = {
baseUrl,
// The panel stores the bare character ('|'); titles need it padded. config,
const separator = settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined homeSlug: site.homeSlug,
const order = settings.titleOrder ?? undefined locale,
order: site.order,
const homepage = separator: site.separator,
settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null siteName: site.siteName,
// The home page's slug collapses to the locale root (/pl, not /pl/homepage). }
const homeSlug = typeof homepage?.slug === 'string' ? homepage.slug : undefined
const empty = () =>
buildMetadata({
baseUrl,
config,
homeSlug,
locale,
meta: null,
order,
separator,
siteName,
slugs: {},
})
const route = await resolveRoute({ const route = await resolveRoute({
content, content,
@@ -156,7 +119,9 @@ export function createPageMetadata(args: CreatePageMetadataArgs) {
// Unknown route (the page component will 404) — still return something // Unknown route (the page component will 404) — still return something
// coherent rather than throwing during metadata generation. // coherent rather than throwing during metadata generation.
if (!route) {return empty()} if (!route) {
return buildMetadata({ ...base, meta: null, slugs: {} })
}
const doc = route.doc as DocShape const doc = route.doc as DocShape
@@ -164,34 +129,30 @@ export function createPageMetadata(args: CreatePageMetadataArgs) {
// segment differs per locale, since it's the archive page's own slug. // segment differs per locale, since it's the archive page's own slug.
const prefix = const prefix =
route.type === 'entry' route.type === 'entry'
? await slugsAcrossLocales(payload, collection, (route.archive as DocShape).id, config) ? await slugsAcrossLocales({
id: (route.archive as DocShape).id,
collection,
config,
payload,
})
: undefined : undefined
const docCollection = route.type === 'entry' ? route.collection : collection const slugs = await slugsAcrossLocales({
const slugs = await slugsAcrossLocales(payload, docCollection, doc.id, config) id: doc.id,
collection: route.type === 'entry' ? route.collection : collection,
config,
payload,
})
// Page 2 of a listing is its own URL, not a variant of page 1. // Page 2 of a listing is its own URL, not a variant of page 1.
const query = route.type === 'archive' && route.page > 1 ? `?page=${route.page}` : undefined const query = route.type === 'archive' && route.page > 1 ? `?page=${route.page}` : undefined
// plugin-seo stores the OG image as an upload relationship.
const image = (doc.meta as { image?: unknown } | null | undefined)?.image
const imageUrl =
image && typeof image === 'object' && 'url' in image
? ((image as { url: string }).url ?? null)
: null
return buildMetadata({ return buildMetadata({
baseUrl, ...base,
config, imageUrl: resolveOgImage(doc),
homeSlug,
imageUrl,
locale,
meta: doc.meta, meta: doc.meta,
order,
prefix, prefix,
query, query,
separator,
siteName,
slugs, slugs,
}) })
} }
+3
View File
@@ -9,5 +9,8 @@ export { createPageMetadata } from './createPageMetadata.js'
export { buildHreflangAlternates } from './hreflang.js' export { buildHreflangAlternates } from './hreflang.js'
export { injectAutoFillMeta } from './injectAutoFillMeta.js' export { injectAutoFillMeta } from './injectAutoFillMeta.js'
export { injectSeoTabs } from './injectSeoTabs.js' export { injectSeoTabs } from './injectSeoTabs.js'
export { readSiteMetaConfig } from './readSiteMetaConfig.js'
export type { SiteMetaConfig } from './readSiteMetaConfig.js'
export { buildSeoPlugin } from './seoPluginConfig.js' export { buildSeoPlugin } from './seoPluginConfig.js'
export { slugsAcrossLocales } from './slugsAcrossLocales.js'
export type { SeoMeta, SeoOption } from './types.js' export type { SeoMeta, SeoOption } from './types.js'
+58
View File
@@ -0,0 +1,58 @@
import type { BasePayload } from 'payload'
import type { TitleOrder } from './composeTitle.js'
type SettingsShape = {
[key: string]: unknown
homepage?: { id: number | string; slug?: unknown } | null | number | string
titleOrder?: null | TitleOrder
titleSeparator?: null | string
}
export type SiteMetaConfig = {
/** Slug of the page assigned as Homepage; collapses to the locale root. */
homeSlug?: string
order?: TitleOrder
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
separator?: string
siteName: null | string
}
/**
* Reads the site-wide inputs to title and URL composition from SiteSettings.
*
* Split out of metadata assembly because these are one concern with one source:
* whatever an editor set in the panel. Metadata generation shouldn't also know
* that the separator arrives unpadded, or that the home slug hides inside a
* relationship — it should receive a resolved config.
*/
export async function readSiteMetaConfig({
locale,
payload,
settingsSlug = 'site-settings',
siteNameField = 'siteName',
}: {
locale: string
payload: BasePayload
settingsSlug?: string
siteNameField?: string
}): Promise<SiteMetaConfig> {
// depth 1 populates the homepage relationship, so its slug is available
// without a second query.
const settings = (await payload.findGlobal({
slug: settingsSlug as never,
depth: 1,
locale: locale as never,
})) as SettingsShape
const homepage =
settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null
return {
siteName: (settings[siteNameField] as string | undefined) ?? null,
// The panel stores '|'; titles need it padded.
homeSlug: typeof homepage?.slug === 'string' ? homepage.slug : undefined,
order: settings.titleOrder ?? undefined,
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined,
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { BasePayload } from 'payload'
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
import { getLocalizedSlugs } from '../i18n/index.js'
/**
* Reads a document's slug in every locale — the map hreflang alternates are
* built from.
*
* Needs its own query because a document fetched in one locale returns `slug`
* as a plain string. Fetching with `locale: 'all'` turns *every* localized
* field into a locale→value map, which is right for the slug and wrong for
* everything else (a title map would break title composition), so this asks
* only for what it needs and at depth 0.
*/
export async function slugsAcrossLocales({
id,
collection,
config,
payload,
}: {
collection: string
config: I18nConfig
id: number | string
payload: BasePayload
}): Promise<LocalizedSlugs> {
const doc = (await payload.findByID({
id,
collection: collection as never,
depth: 0,
locale: 'all',
})) as { slug?: unknown }
return doc.slug && typeof doc.slug === 'object'
? getLocalizedSlugs({ config, slugField: doc.slug as Record<string, unknown> })
: {}
}
+14 -4
View File
@@ -6,8 +6,10 @@ import { buildCookieSettings } from './globals/CookieSettings/index.js'
import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js' import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js'
import { buildSiteSettings } from './globals/SiteSettings/index.js' import { buildSiteSettings } from './globals/SiteSettings/index.js'
import { injectRoles } from './modules/access/index.js' import { injectRoles } from './modules/access/index.js'
import { buildArchiveFields } from './modules/content/index.js'
import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js' import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'
import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js' import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js'
import { buildSystemPagesFields } from './modules/pages/index.js'
import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js' import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js'
/** /**
@@ -33,7 +35,7 @@ import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo
* }) * })
* ``` * ```
*/ */
const ipalKit = (options: IpalOptions): Plugin => { export const ipalKit = (options: IpalOptions): Plugin => {
// Validate eagerly — fail fast before Payload boots // Validate eagerly — fail fast before Payload boots
validateI18nConfig(options.i18n) validateI18nConfig(options.i18n)
@@ -74,12 +76,21 @@ const ipalKit = (options: IpalOptions): Plugin => {
} }
// --- globals --- // --- globals ---
// The System Pages tab collects every "which page plays this role"
// assignment. Composing it here keeps SiteSettings unaware of which modules
// are enabled — it just renders the fields it's given.
const systemPageFields = [
...(options.pages ? buildSystemPagesFields(options.pages) : []),
...(options.content && options.pages
? buildArchiveFields(options.content, options.pages.slug)
: []),
]
config.globals = [ config.globals = [
...(config.globals ?? []), ...(config.globals ?? []),
buildSiteSettings({ buildSiteSettings({
additionalFields: options.siteSettingsFields, additionalFields: options.siteSettingsFields,
content: options.content, systemPageFields,
pages: options.pages,
}), }),
buildSiteIntegrations({ additionalFields: options.integrationsFields }), buildSiteIntegrations({ additionalFields: options.integrationsFields }),
buildCookieSettings(), buildCookieSettings(),
@@ -95,4 +106,3 @@ const ipalKit = (options: IpalOptions): Plugin => {
return config return config
} }
} }
export default ipalKit