init commit for iPAL-kit plugin
This commit is contained in:
+17
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }] : []),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user