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'],
page: 1, // z ?page=
content: contentConfig,
withEntries: false, // true → dociąga wpisy do archiwum (patrz niżej)
})
// route.type: 'home' | 'page' | 'archive' | 'entry' | (null gdy 404)
```
@@ -96,7 +95,7 @@ import { createContentHelpers } from 'ipal-kit'
import config from '@/payload.config'
import { contentConfig } from '@/content.config'
export const { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute } =
export const { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute, getEntries } =
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
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
// 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)
// komponent strony — z wpisami
const route = await resolveRoute(locale, slug, page, true)
// route.entries: { docs, page, totalPages, hasPrevPage, hasNextPage, ... }
// 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, ... }
```
Metadane i strona wołają z różnymi argumentami, więc `cache()` traktuje je jako
osobne wywołania — i słusznie, bo metadane wpisów nie potrzebują.
`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
+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.
+140 -36
View File
@@ -1,8 +1,8 @@
# 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.
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ść
@@ -12,6 +12,9 @@ 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)
```ts
@@ -27,60 +30,137 @@ ipalKit({
})
```
Dodaje tab **SEO** (title, description, image) do wskazanych kolekcji.
Auto-fill: hook `beforeChange` wypełnia puste `meta.title` z pola dokumentu
(domyślnie `title`). Nigdy nie nadpisuje tego, co edytor wpisał ręcznie.
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.
## 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
swoje kolekcje/routing), plugin składa metadata.
## 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ć:
```ts
// app/(frontend)/[locale]/[[...segments]]/page.tsx
import { createMetadataGenerator } from 'ipal-kit'
import { getSiteSettings } from 'ipal-kit'
// app/(frontend)/[locale]/[[...slug]]/page.tsx
import { createPageMetadata } from 'ipal-kit'
import { i18nConfig } from '@/i18n.config'
const gen = createMetadataGenerator({
const pageMetadata = createPageMetadata({
config: i18nConfig,
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
resolveDocument: async ({ payload, params, locale }) => {
const slug = /* z params */ ''
const res = await payload.find({
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,
// collection: 'pages', // domyślne
// settingsSlug: 'site-settings', // domyślne
// siteNameField: 'siteName', // domyślne
})
export async function generateMetadata({ params }) {
const payload = await getPayload({ config })
const { locale } = await params
return gen({ payload, params: await params, locale })
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params
return pageMetadata({ payload: await getPayload({ config }), locale, slug })
}
```
## 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
import { buildMetadata, getLocalizedSlugs } from 'ipal-kit'
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all', depth: 1 })
return buildMetadata({
meta: doc.meta, // z plugin-seo
siteName: settings.siteName,
imageUrl: /* url OG image */,
imageUrl: '/og.png',
locale: 'pl',
slugs: getLocalizedSlugs({ slugField: doc.slug, config }),
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 } }
```
@@ -90,6 +170,30 @@ return buildMetadata({
```ts
import { composeTitle, buildHreflangAlternates } from 'ipal-kit'
composeTitle({ pageTitle: 'O nas', siteName: 'Acme' }) // 'O nas | Acme'
buildHreflangAlternates({ slugs, config, baseUrl }) // { pl: '...', en: '...' }
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:
```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",
"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",
"type": "module",
"exports": {
@@ -37,6 +37,8 @@
"dist"
],
"scripts": {
"prepare": "pnpm build",
"prepublishOnly": "pnpm clean && pnpm build",
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --outDir dist --rootDir ./src",
@@ -55,19 +57,26 @@
"test:int": "vitest"
},
"dependencies": {
"@payloadcms/plugin-form-builder": "3.84.1",
"@payloadcms/plugin-seo": "3.84.1",
"lucide-react": "^0.400.0",
"nodemailer": "^8.0.1",
"server-only": "^0.0.1",
"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": {
"@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-postgres": "3.84.1",
"@payloadcms/db-sqlite": "3.84.1",
"@payloadcms/eslint-config": "3.28.0",
"@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/ui": "3.84.1",
"@playwright/test": "1.58.2",
@@ -97,15 +106,12 @@
"vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18"
},
"peerDependencies": {
"payload": "^3.84.1",
"react": "^19.0.0"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",
"pnpm": "^9 || ^10 || ^11"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com",
"exports": {
".": {
"import": "./dist/index.js",
@@ -142,6 +148,5 @@
"esbuild",
"unrs-resolver"
]
},
"registry": "https://registry.npmjs.org/"
}
}
+19 -20
View File
@@ -1,40 +1,37 @@
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 { themeFields } from './fields/theme.js'
type BuildSiteSettingsArgs = {
/** Extra fields injected by the client project */
additionalFields?: Field[]
/** Archive-page assignments for content collections — joins the same tab */
content?: ContentOption
/** System-page assignments — adds a "System Pages" tab when provided */
pages?: PagesOption
/**
* Fields for the "System Pages" tab — page assignments by role (homepage,
* privacy policy, collection archives). Assembled by the caller, which is the
* only place that knows which modules are enabled. The tab is omitted when
* this is empty.
*/
systemPageFields?: Field[]
}
/**
* 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
* siteSettings.general.siteName). Client-provided fields land in their
* own "Custom" tab so core data paths never shift.
* siteSettings.general.siteName). Client-provided fields land in their own
* "Custom" tab so core data paths never shift.
*/
export function buildSiteSettings({
additionalFields,
content,
pages,
systemPageFields,
}: 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 {
slug: 'site-settings',
access: {
@@ -55,7 +52,9 @@ export function buildSiteSettings({
fields: themeFields,
label: 'Theme',
},
...(systemPageFields.length ? [{ fields: systemPageFields, label: 'System Pages' }] : []),
...(systemPageFields?.length
? [{ fields: systemPageFields, label: 'System Pages' }]
: []),
...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),
],
},
+8 -24
View File
@@ -1,9 +1,7 @@
import type { BasePayload } from 'payload'
import type { ArchiveEntries } from './getArchiveEntries.js'
import type { ContentOption } from './types.js'
import { getArchiveEntries } from './getArchiveEntries.js'
import { archiveFieldName } from './types.js'
type ArchivePage = { id: number | string; slug?: unknown }
@@ -21,8 +19,6 @@ export type ResolvedRoute =
| {
collection: string
doc: Record<string, unknown>
/** Present only when resolved with `withEntries` — metadata doesn't need them. */
entries?: ArchiveEntries
/** 1-based, from ?page=. */
page: number
perPage: number
@@ -45,17 +41,18 @@ type ResolveRouteArgs = {
segments?: string[]
/** SiteSettings global slug. Defaults to 'site-settings'. */
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
* 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
* 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
@@ -77,7 +74,6 @@ export async function resolveRoute({
payload,
segments,
settingsSlug = 'site-settings',
withEntries = false,
}: ResolveRouteArgs): Promise<null | ResolvedRoute> {
const settings = (await payload.findGlobal({
slug: settingsSlug,
@@ -94,9 +90,9 @@ export async function resolveRoute({
return null
}
// Which archive (if any) does the first segment name?
const [first, ...rest] = segments
// Does the first segment name an archive page?
for (const collection of content?.collections ?? []) {
const archive = settings[archiveFieldName(collection.slug)]
if (!archive || typeof archive !== 'object') {continue}
@@ -106,24 +102,12 @@ export async function resolveRoute({
// /pl/artykuly → the archive page itself.
if (rest.length === 0) {
const perPage = collection.perPage ?? 10
return {
type: 'archive',
collection: collection.slug,
doc: archiveDoc as Record<string, unknown>,
page,
perPage,
...(withEntries
? {
entries: await getArchiveEntries({
collection: collection.slug,
locale,
page,
payload,
perPage,
}),
}
: {}),
perPage: collection.perPage ?? 10,
}
}
+24 -21
View File
@@ -3,9 +3,9 @@ import type { BasePayload, SanitizedConfig } from 'payload'
import { getPayload } from 'payload'
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 = {
/**
@@ -24,7 +24,7 @@ type CreateContentHelpersArgs = {
/**
* Bundles the per-request data helpers a frontend needs — the same cached
* 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
* 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 { contentConfig } from '@/content.config'
*
* export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute } =
* export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries } =
* 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({
config,
@@ -63,31 +67,30 @@ export function createContentHelpers({
return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })
})
/**
* 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).
*/
/** What does this URL point at? Routing only — no listing data. */
const resolveRoute = cache(
async (
locale: string,
segments: string[] | undefined,
page: number,
withEntries = false,
): Promise<null | ResolvedRoute> => {
const payload = await getCachedPayload()
return resolveRouteRaw({
content,
locale,
page,
pagesSlug,
payload,
segments,
settingsSlug,
withEntries,
})
return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug })
},
)
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 { I18nConfig } from '../i18n/index.js'
import type { PageMetadata } from './buildMetadata.js'
import type { TitleOrder } from './composeTitle.js'
import type { SeoMeta } from './types.js'
import { resolveRoute } from '../content/index.js'
import { getLocalizedSlugs } from '../i18n/index.js'
import { buildMetadata } from './buildMetadata.js'
import { readSiteMetaConfig } from './readSiteMetaConfig.js'
import { slugsAcrossLocales } from './slugsAcrossLocales.js'
type CreatePageMetadataArgs = {
/** Absolute site origin, e.g. 'https://example.com'. */
@@ -40,37 +40,18 @@ type PageMetadataContext = {
slug?: string[]
}
type SettingsShape = {
[key: string]: unknown
homepage?: { id: number | string; slug?: unknown } | null | number | string
titleOrder?: null | TitleOrder
titleSeparator?: null | string
}
type DocShape = {
id: number | string
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. */
async function slugsAcrossLocales(
payload: BasePayload,
collection: string,
id: number | string,
config: I18nConfig,
) {
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> })
: {}
/** plugin-seo stores the OG image as an upload relationship. */
function resolveOgImage(doc: DocShape): null | string {
const image = (doc.meta as { image?: unknown } | null | undefined)?.image
if (image && typeof image === 'object' && 'url' in image) {
return (image as { url: string }).url ?? null
}
return null
}
/**
@@ -114,35 +95,17 @@ export function createPageMetadata(args: CreatePageMetadataArgs) {
page,
payload,
}: PageMetadataContext): Promise<PageMetadata> {
const settings = (await payload.findGlobal({
slug: settingsSlug,
depth: 1,
locale: locale as never,
})) as SettingsShape
const site = await readSiteMetaConfig({ locale, payload, settingsSlug, siteNameField })
const siteName = (settings[siteNameField] as string | undefined) ?? null
// The panel stores the bare character ('|'); titles need it padded.
const separator = settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined
const order = settings.titleOrder ?? undefined
const homepage =
settings.homepage && typeof settings.homepage === 'object' ? settings.homepage : null
// 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 base = {
baseUrl,
config,
homeSlug: site.homeSlug,
locale,
order: site.order,
separator: site.separator,
siteName: site.siteName,
}
const route = await resolveRoute({
content,
@@ -156,7 +119,9 @@ export function createPageMetadata(args: CreatePageMetadataArgs) {
// Unknown route (the page component will 404) — still return something
// 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
@@ -164,34 +129,30 @@ export function createPageMetadata(args: CreatePageMetadataArgs) {
// segment differs per locale, since it's the archive page's own slug.
const prefix =
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
const docCollection = route.type === 'entry' ? route.collection : collection
const slugs = await slugsAcrossLocales(payload, docCollection, doc.id, config)
const slugs = await slugsAcrossLocales({
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.
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({
baseUrl,
config,
homeSlug,
imageUrl,
locale,
...base,
imageUrl: resolveOgImage(doc),
meta: doc.meta,
order,
prefix,
query,
separator,
siteName,
slugs,
})
}
+3
View File
@@ -9,5 +9,8 @@ export { createPageMetadata } from './createPageMetadata.js'
export { buildHreflangAlternates } from './hreflang.js'
export { injectAutoFillMeta } from './injectAutoFillMeta.js'
export { injectSeoTabs } from './injectSeoTabs.js'
export { readSiteMetaConfig } from './readSiteMetaConfig.js'
export type { SiteMetaConfig } from './readSiteMetaConfig.js'
export { buildSeoPlugin } from './seoPluginConfig.js'
export { slugsAcrossLocales } from './slugsAcrossLocales.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 { buildSiteSettings } from './globals/SiteSettings/index.js'
import { injectRoles } from './modules/access/index.js'
import { buildArchiveFields } from './modules/content/index.js'
import { buildFormsPlugin } from './modules/forms/formsPluginConfig.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'
/**
@@ -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
validateI18nConfig(options.i18n)
@@ -74,12 +76,21 @@ const ipalKit = (options: IpalOptions): Plugin => {
}
// --- 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 ?? []),
buildSiteSettings({
additionalFields: options.siteSettingsFields,
content: options.content,
pages: options.pages,
systemPageFields,
}),
buildSiteIntegrations({ additionalFields: options.integrationsFields }),
buildCookieSettings(),
@@ -95,4 +106,3 @@ const ipalKit = (options: IpalOptions): Plugin => {
return config
}
}
export default ipalKit