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
+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> })
: {}
}