From 24a1fe0d60c5ca5e724c1758a9ea6e7724b33560 Mon Sep 17 00:00:00 2001 From: rasm-its Date: Tue, 28 Jul 2026 00:21:22 +0200 Subject: [PATCH] init commit for iPAL-kit plugin --- docs/content.md | 28 +-- docs/publish-checklist.md | 70 ++++++++ docs/seo.md | 178 +++++++++++++++---- package.json | 25 +-- src/globals/SiteSettings/index.ts | 39 ++-- src/modules/content/resolveRoute.ts | 32 +--- src/modules/frontend/createContentHelpers.ts | 45 ++--- src/modules/seo/createPageMetadata.ts | 111 ++++-------- src/modules/seo/index.ts | 3 + src/modules/seo/readSiteMetaConfig.ts | 58 ++++++ src/modules/seo/slugsAcrossLocales.ts | 38 ++++ src/plugin.ts | 18 +- 12 files changed, 443 insertions(+), 202 deletions(-) create mode 100644 docs/publish-checklist.md create mode 100644 src/modules/seo/readSiteMetaConfig.ts create mode 100644 src/modules/seo/slugsAcrossLocales.ts diff --git a/docs/content.md b/docs/content.md index a321d94..54cd9c3 100644 --- a/docs/content.md +++ b/docs/content.md @@ -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 diff --git a/docs/publish-checklist.md b/docs/publish-checklist.md new file mode 100644 index 0000000..c268ade --- /dev/null +++ b/docs/publish-checklist.md @@ -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/plugin-seo@3.84.1 @payloadcms/plugin-form-builder@3.84.1 \ + 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. \ No newline at end of file diff --git a/docs/seo.md b/docs/seo.md index 0ab6d28..0f0a9bc 100644 --- a/docs/seo.md +++ b/docs/seo.md @@ -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 { + 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: '...' } -``` \ No newline at end of file +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. \ No newline at end of file diff --git a/package.json b/package.json index 9ae69a6..5559dbd 100644 --- a/package.json +++ b/package.json @@ -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/" + } } diff --git a/src/globals/SiteSettings/index.ts b/src/globals/SiteSettings/index.ts index f6c098a..f692afc 100644 --- a/src/globals/SiteSettings/index.ts +++ b/src/globals/SiteSettings/index.ts @@ -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' }] : []), ], }, diff --git a/src/modules/content/resolveRoute.ts b/src/modules/content/resolveRoute.ts index d78d1fa..fc521f5 100644 --- a/src/modules/content/resolveRoute.ts +++ b/src/modules/content/resolveRoute.ts @@ -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 - /** 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 { 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, page, - perPage, - ...(withEntries - ? { - entries: await getArchiveEntries({ - collection: collection.slug, - locale, - page, - payload, - perPage, - }), - } - : {}), + perPage: collection.perPage ?? 10, } } diff --git a/src/modules/frontend/createContentHelpers.ts b/src/modules/frontend/createContentHelpers.ts index 14a4bc2..f86d292 100644 --- a/src/modules/frontend/createContentHelpers.ts +++ b/src/modules/frontend/createContentHelpers.ts @@ -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 => { 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 => { + const payload = await getCachedPayload() + return getArchiveEntries({ collection, locale, page, payload, perPage }) + }, + ) + + return { getCachedPayload, getConfiguredLocales, getEntries, getSettings, resolveRoute } } diff --git a/src/modules/seo/createPageMetadata.ts b/src/modules/seo/createPageMetadata.ts index 87b6dcb..bf6db3e 100644 --- a/src/modules/seo/createPageMetadata.ts +++ b/src/modules/seo/createPageMetadata.ts @@ -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 }) - : {} +/** 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 { - 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, }) } diff --git a/src/modules/seo/index.ts b/src/modules/seo/index.ts index 8920bac..adc1e32 100644 --- a/src/modules/seo/index.ts +++ b/src/modules/seo/index.ts @@ -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' diff --git a/src/modules/seo/readSiteMetaConfig.ts b/src/modules/seo/readSiteMetaConfig.ts new file mode 100644 index 0000000..b4a6d06 --- /dev/null +++ b/src/modules/seo/readSiteMetaConfig.ts @@ -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 { + // 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, + } +} diff --git a/src/modules/seo/slugsAcrossLocales.ts b/src/modules/seo/slugsAcrossLocales.ts new file mode 100644 index 0000000..ef243cd --- /dev/null +++ b/src/modules/seo/slugsAcrossLocales.ts @@ -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 { + 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 }) + : {} +} diff --git a/src/plugin.ts b/src/plugin.ts index 84e05cd..95f1f22 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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