init commit for iPAL-kit plugin

This commit is contained in:
2026-07-18 20:30:23 +02:00
parent 10638127a9
commit 5733a9c8bf
119 changed files with 6892 additions and 411 deletions
-35
View File
@@ -1,35 +0,0 @@
'use client'
import { useConfig } from '@payloadcms/ui'
import { formatAdminURL } from 'payload/shared'
import { useEffect, useState } from 'react'
export const BeforeDashboardClient = () => {
const { config } = useConfig()
const [message, setMessage] = useState('')
useEffect(() => {
const fetchMessage = async () => {
const response = await fetch(
formatAdminURL({
apiRoute: config.routes.api,
path: '/my-plugin-endpoint',
}),
)
const result = await response.json()
setMessage(result.message)
}
void fetchMessage()
}, [config.serverURL, config.routes.api])
return (
<div>
<h1>Added by the plugin: Before Dashboard Client</h1>
<div>
Message from the endpoint:
<div>{message || 'Loading...'}</div>
</div>
</div>
)
}
@@ -1,5 +0,0 @@
.wrapper {
display: flex;
gap: 5px;
flex-direction: column;
}
-19
View File
@@ -1,19 +0,0 @@
import type { ServerComponentProps } from 'payload'
import styles from './BeforeDashboardServer.module.css'
export const BeforeDashboardServer = async (props: ServerComponentProps) => {
const { payload } = props
const { docs } = await payload.find({ collection: 'plugin-collection' })
return (
<div className={styles.wrapper}>
<h1>Added by the plugin: Before Dashboard Server</h1>
Docs from Local API:
{docs.map((doc) => (
<div key={doc.id}>{doc.id}</div>
))}
</div>
)
}
-5
View File
@@ -1,5 +0,0 @@
import type { PayloadHandler } from 'payload'
export const customEndpointHandler: PayloadHandler = () => {
return Response.json({ message: 'Hello from custom endpoint' })
}
+19 -1
View File
@@ -1 +1,19 @@
export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'
'use client'
export { Analytics } from '../modules/analytics/client.js'
/**
* Entry point: ipal-kit/client
*
* Client-side ('use client') exports — React hooks, providers, and UI
* components. Kept separate from the main entry so server bundles don't pull in
* client-only code.
*/
export {
ConsentProvider,
CookieBanner,
CookieButton,
useConsent,
useConsentContext,
} from '../modules/consent/client.js'
export type { CookieBannerClassNames } from '../modules/consent/client.js'
export { Turnstile } from '../modules/turnstile/client.js'
export type { TurnstileProps } from '../modules/turnstile/client.js'
+9
View File
@@ -0,0 +1,9 @@
/**
* Entry point: ipal-kit/next/middleware
*
* Re-exports the locale middleware factory and default matcher for use in a
* client project's next-middleware.ts. Kept as a thin re-export so the Next-facing
* surface is separate from the main package export.
*/
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from '../modules/i18n/index.js'
export type { LocaleMiddlewareResult } from '../modules/i18n/index.js'
+14 -1
View File
@@ -1 +1,14 @@
export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js'
/**
* Entry point: ipal-kit/rsc
*
* Server-component exports. RenderBlocks is a React Server Component, so it
* lives here rather than in the main package entry to keep React out of the
* server-config bundle.
*/
export { RenderBlocks } from '../modules/blocks/index.js'
export type {
BlockComponentMap,
BlockData,
EnhanceProps,
RenderBlocksProps,
} from '../modules/blocks/index.js'
+17
View File
@@ -0,0 +1,17 @@
/**
* Entry point: ipal-kit/server
*
* Server-only runtime functions — these import 'server-only' (Turnstile secret,
* SMTP password, nodemailer). Kept OUT of the main entry so that loading the
* Payload config or running `payload generate:importmap` (both Node scripts,
* no bundler) never pulls in 'server-only', which throws outside a bundled
* server context.
*
* Import these only from Server Actions, route handlers, or server components —
* never from the Payload config or client code.
*/
export { verifyTurnstile } from '../modules/turnstile/index.js'
export { sendEmail } from '../modules/email/index.js'
export type { SendEmailArgs, SendEmailResult } from '../modules/email/index.js'
export { submitForm } from '../modules/forms/index.js'
export type { SubmitFormArgs, SubmitFormResult } from '../modules/forms/index.js'
+58
View File
@@ -0,0 +1,58 @@
import type { Field } from 'payload'
import { CONSENT_CATEGORIES } from '../../modules/consent/index.js'
/**
* Fields for the CookieSettings global — GDPR consent banner copy, editable
* per locale. The privacy-policy link is NOT stored here: it comes from the
* pages module (system page role 'privacyPolicy'), keeping one source of truth.
*/
export const cookieSettingsFields: Field[] = [
{
name: 'message',
type: 'textarea',
admin: {
description: 'Main consent message shown in the banner.',
},
localized: true,
},
{
name: 'settingsTitle',
type: 'text',
admin: {
description: 'Heading for the detailed settings panel.',
},
localized: true,
},
{
name: 'buttons',
type: 'group',
admin: {
description: 'Button labels.',
},
fields: [
{ name: 'acceptAll', type: 'text', localized: true },
{ name: 'reject', type: 'text', localized: true },
{ name: 'settings', type: 'text', localized: true },
{ name: 'save', type: 'text', localized: true },
{ name: 'back', type: 'text', localized: true },
],
},
{
name: 'categories',
type: 'array',
admin: {
description: 'Per-category titles and descriptions.',
},
fields: [
{
name: 'key',
type: 'select',
options: CONSENT_CATEGORIES.map((c) => ({ label: c, value: c })),
required: true,
},
{ name: 'title', type: 'text', localized: true },
{ name: 'description', type: 'textarea', localized: true },
],
},
]
+21
View File
@@ -0,0 +1,21 @@
import type { GlobalConfig } from 'payload'
import { cookieSettingsFields } from './fields.js'
/**
* Builds the CookieSettings global — consent banner copy managed by editors.
* Public read (the banner needs it on the frontend for anonymous visitors).
*/
export function buildCookieSettings(): GlobalConfig {
return {
slug: 'cookie-settings',
access: {
read: () => true,
},
admin: {
group: 'Settings',
},
fields: cookieSettingsFields,
label: 'Cookie Settings',
}
}
@@ -0,0 +1,24 @@
import type { Field } from 'payload'
/**
* Analytics configuration — GA4 measurement ID and GTM container ID.
* IDs are public (exposed client-side), so no read restriction needed.
*/
export const analyticsFields: Field[] = [
{
name: 'ga4MeasurementId',
type: 'text',
admin: {
description: 'Google Analytics 4 Measurement ID.',
placeholder: 'G-XXXXXXXXXX',
},
},
{
name: 'gtmContainerId',
type: 'text',
admin: {
description: 'Google Tag Manager container ID.',
placeholder: 'GTM-XXXXXXX',
},
},
]
@@ -0,0 +1,55 @@
import type { Field } from 'payload'
/**
* SMTP transport settings for outbound email.
*
* Protected at the global level (SiteIntegrations requires an authenticated
* user), so all fields — including the password — stay editable in the admin
* panel while remaining inaccessible to anonymous API requests.
*/
export const smtpFields: Field[] = [
{
type: 'row',
fields: [
{
name: 'smtpHost',
type: 'text',
admin: { placeholder: 'smtp.example.com', width: '70%' },
},
{
name: 'smtpPort',
type: 'number',
admin: { width: '30%' },
defaultValue: 587,
},
],
},
{
name: 'smtpUser',
type: 'text',
admin: {
description: 'SMTP account username.',
},
},
{
name: 'smtpPassword',
type: 'text',
admin: {
description: 'SMTP account password.',
},
},
{
name: 'smtpFromAddress',
type: 'email',
admin: {
description: 'Default "from" address for outgoing mail.',
},
},
{
name: 'smtpFromName',
type: 'text',
admin: {
description: 'Default "from" display name.',
},
},
]
@@ -0,0 +1,40 @@
import type { Field } from 'payload'
/**
* Cloudflare R2 storage credentials.
* Reserved for future use — media offloading to R2.
*
* Protected at the global level (SiteIntegrations requires an authenticated
* user), so the access keys stay editable in the admin panel while remaining
* inaccessible to anonymous API requests.
*/
export const storageFields: Field[] = [
{
name: 'r2Bucket',
type: 'text',
admin: {
description: 'R2 bucket name.',
},
},
{
name: 'r2Endpoint',
type: 'text',
admin: {
description: 'R2 S3-compatible endpoint URL.',
},
},
{
name: 'r2AccessKeyId',
type: 'text',
admin: {
description: 'R2 access key ID.',
},
},
{
name: 'r2SecretAccessKey',
type: 'text',
admin: {
description: 'R2 secret access key.',
},
},
]
@@ -0,0 +1,26 @@
import type { Field } from 'payload'
/**
* Cloudflare Turnstile credentials.
*
* siteKey is public (rendered in the widget); secretKey is used for
* server-side verification. Both are protected at the global level
* (SiteIntegrations requires an authenticated user) rather than per-field,
* so they remain editable in the admin panel.
*/
export const turnstileFields: Field[] = [
{
name: 'turnstileSiteKey',
type: 'text',
admin: {
description: 'Public site key rendered in the Turnstile widget.',
},
},
{
name: 'turnstileSecretKey',
type: 'text',
admin: {
description: 'Secret key used for server-side verification.',
},
},
]
+54
View File
@@ -0,0 +1,54 @@
import type { Field, GlobalConfig } from 'payload'
import { isAdmin } from '../../modules/access/index.js'
import { analyticsFields } from './fields/analytics.js'
import { smtpFields } from './fields/smtp.js'
import { storageFields } from './fields/storage.js'
import { turnstileFields } from './fields/turnstile.js'
type BuildSiteIntegrationsArgs = {
/** Extra fields injected by the client project */
additionalFields?: Field[]
}
/**
* Builds the SiteIntegrations global.
*
* Holds third-party service credentials. Access is enforced at the global
* level — the whole global requires an authenticated user — so secrets stay
* out of anonymous API responses while remaining editable in the admin panel
* and readable via the server-side Local API. (Field-level read:false was
* avoided because it also hides fields from the admin UI, making them
* impossible to enter.)
*
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).
*/
export function buildSiteIntegrations({
additionalFields,
}: BuildSiteIntegrationsArgs = {}): GlobalConfig {
return {
slug: 'site-integrations',
access: {
// Admin-only — secrets live here. Anonymous and non-admin users get
// nothing through the API; admins read/edit in the panel and via Local API.
read: ({ req: { user } }) => isAdmin(user),
update: ({ req: { user } }) => isAdmin(user),
},
admin: {
group: 'Settings',
},
fields: [
{
type: 'tabs',
tabs: [
{ fields: analyticsFields, label: 'Analytics' },
{ fields: turnstileFields, label: 'Turnstile' },
{ fields: smtpFields, label: 'SMTP' },
{ fields: storageFields, label: 'Storage' },
...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),
],
},
],
label: 'Site Integrations',
}
}
@@ -0,0 +1,68 @@
import type { Field } from 'payload'
/**
* General site identity fields.
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
*/
export const generalFields: Field[] = [
{
name: 'siteName',
type: 'text',
admin: {
description: 'Used in page titles and Open Graph metadata.',
},
localized: true,
required: true,
},
{
name: 'titleOrder',
type: 'select',
admin: {
description: 'Which comes first in browser tabs.',
},
defaultValue: 'page-first',
options: [
{ label: 'Page first — About Us | Acme', value: 'page-first' },
{ label: 'Site first — Acme | About Us', value: 'site-first' },
],
},
{
name: 'titleSeparator',
type: 'select',
admin: {
description: 'Separates the page title from the site name in browser tabs.',
},
defaultValue: '|',
options: [
{ label: 'Pipe — Page | Site', value: '|' },
{ label: 'Dash — Page – Site', value: '–' },
{ label: 'Hyphen — Page - Site', value: '-' },
{ label: 'Bullet — Page · Site', value: '·' },
{ label: 'Slash — Page / Site', value: '/' },
],
},
{
name: 'logo',
type: 'upload',
admin: {
description: 'Primary site logo.',
},
relationTo: 'media',
},
{
name: 'defaultShareImage',
type: 'upload',
admin: {
description: 'Fallback Open Graph image when a page has none.',
},
relationTo: 'media',
},
{
name: 'favicon',
type: 'upload',
admin: {
description: 'Square source icon (PNG or SVG) for the browser tab. Rendered by the frontend.',
},
relationTo: 'media',
},
]
+32
View File
@@ -0,0 +1,32 @@
import type { Field } from 'payload'
/**
* Theme behavior configuration.
*
* These are decisions, not visuals — the plugin stores them, the client
* template reads them and decides whether to render a toggle and which
* mode to start in. Appearance itself stays in the template.
*/
export const themeFields: Field[] = [
{
name: 'defaultTheme',
type: 'select',
admin: {
description: 'Theme applied on a visitor’s first visit.',
},
defaultValue: 'light',
options: [
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
],
required: true,
},
{
name: 'allowThemeToggle',
type: 'checkbox',
admin: {
description: 'Show a light/dark switch on the site.',
},
defaultValue: true,
},
]
+65
View File
@@ -0,0 +1,65 @@
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
}
/**
* Builds the SiteSettings global.
*
* 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.
*/
export function buildSiteSettings({
additionalFields,
content,
pages,
}: 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: {
read: () => true,
},
admin: {
group: 'Settings',
},
fields: [
{
type: 'tabs',
tabs: [
{
fields: generalFields,
label: 'General',
},
{
fields: themeFields,
label: 'Theme',
},
...(systemPageFields.length ? [{ fields: systemPageFields, label: 'System Pages' }] : []),
...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),
],
},
],
label: 'Site Settings',
}
}
+95 -113
View File
@@ -1,113 +1,95 @@
import type { CollectionSlug, Config } from 'payload'
import { customEndpointHandler } from './endpoints/customEndpointHandler.js'
export type IpalKitConfig = {
/**
* List of collections to add a custom field
*/
collections?: Partial<Record<CollectionSlug, true>>
disabled?: boolean
}
export const ipalKit =
(pluginOptions: IpalKitConfig) =>
(config: Config): Config => {
if (!config.collections) {
config.collections = []
}
config.collections.push({
slug: 'plugin-collection',
fields: [
{
name: 'id',
type: 'text',
},
],
})
if (pluginOptions.collections) {
for (const collectionSlug in pluginOptions.collections) {
const collection = config.collections.find(
(collection) => collection.slug === collectionSlug,
)
if (collection) {
collection.fields.push({
name: 'addedByPlugin',
type: 'text',
admin: {
position: 'sidebar',
},
})
}
}
}
/**
* If the plugin is disabled, we still want to keep added collections/fields so the database schema is consistent which is important for migrations.
* If your plugin heavily modifies the database schema, you may want to remove this property.
*/
if (pluginOptions.disabled) {
return config
}
if (!config.endpoints) {
config.endpoints = []
}
if (!config.admin) {
config.admin = {}
}
if (!config.admin.components) {
config.admin.components = {}
}
if (!config.admin.components.beforeDashboard) {
config.admin.components.beforeDashboard = []
}
config.admin.components.beforeDashboard.push(
`ipal-kit/client#BeforeDashboardClient`,
)
config.admin.components.beforeDashboard.push(
`ipal-kit/rsc#BeforeDashboardServer`,
)
config.endpoints.push({
handler: customEndpointHandler,
method: 'get',
path: '/my-plugin-endpoint',
})
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
// Ensure we are executing any existing onInit functions before running our own.
if (incomingOnInit) {
await incomingOnInit(payload)
}
const { totalDocs } = await payload.count({
collection: 'plugin-collection',
where: {
id: {
equals: 'seeded-by-plugin',
},
},
})
if (totalDocs === 0) {
await payload.create({
collection: 'plugin-collection',
data: {
id: 'seeded-by-plugin',
},
})
}
}
return config
}
export type { AccessOption, Role } from './modules/access/index.js'
export {
adminOnly,
adminOnlyField,
adminOrEditor,
adminOrEditorField,
adminOrSelf,
authenticated,
hasMinimumRole,
isAdmin,
isEditor,
requireRole,
requireRoleField,
ROLE_HIERARCHY,
} from './modules/access/index.js'
export type { AnalyticsConfig } from './modules/analytics/index.js'
export { getAnalyticsConfig } from './modules/analytics/index.js'
export {
ACCEPT_ALL_CONSENT,
CONSENT_CATEGORIES,
CONSENT_COOKIE,
CONSENT_MAX_AGE,
CONSENT_VERSION,
DEFAULT_CONSENT,
getConsentTexts,
parseConsent,
REJECT_ALL_CONSENT,
serializeConsent,
setDefaultConsent,
updateConsent,
} from './modules/consent/index.js'
export type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'
export type {
ContentCollectionOption,
ContentOption,
ResolvedRoute,
} from './modules/content/index.js'
export {
archiveFieldName,
buildArchivePath,
buildEntryPath,
getArchiveEntries,
parsePageParam,
resolveRoute,
} from './modules/content/index.js'
export type { ArchiveEntries } from './modules/content/index.js'
// Imported straight from the file, NOT from ./modules/email/index.js — that
// barrel re-exports sendEmail, which imports 'server-only' and would crash when
// Payload loads the config (or runs generate:importmap) as a plain Node script.
export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'
export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'
export type {
FormsCollectionOverrides,
FormsFieldsOverride,
FormsOption,
} from './modules/forms/types.js'
export { createContentHelpers } from './modules/frontend/index.js'
export type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'
export {
buildLocalizedPath,
getDefaultLocale,
getLocaleCodes,
getLocaleDefinition,
getLocalizedSlugs,
isValidLocale,
LOCALE_COOKIE_NAME,
matchAcceptLanguage,
negotiateLocale,
switchLocalePath,
} from './modules/i18n/index.js'
export type { LocaleMiddlewareResult } from './modules/i18n/index.js'
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'
export type { PagesOption, SystemPageRole } from './modules/pages/index.js'
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'
export type { GlobalQueryOptions } from './modules/payload/index.js'
export {
getGlobal,
getSiteIntegrations,
getSiteSettings,
SITE_INTEGRATIONS_SLUG,
SITE_SETTINGS_SLUG,
} from './modules/payload/index.js'
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
export type { AutoFillMapping } from './modules/seo/index.js'
export {
buildAutoFillMetaHook,
createMetadataGenerator,
createPageMetadata,
injectAutoFillMeta,
} from './modules/seo/index.js'
export { buildSlugField, toSlug } from './modules/slug/index.js'
export { default as ipalKit } from './plugin.js'
export type { IpalOptions } from './types.js'
+42
View File
@@ -0,0 +1,42 @@
import type { Access, FieldAccess } from 'payload'
import type { Role } from './types.js'
import { hasMinimumRole, isAdmin } from './predicates.js'
/**
* Collection-level access (returns boolean | Where).
* Use in collection `access.read/create/update/delete`.
*/
export const adminOnly: Access = ({ req: { user } }) => isAdmin(user)
export const adminOrEditor: Access = ({ req: { user } }) => hasMinimumRole(user, 'editor')
export const authenticated: Access = ({ req: { user } }) => Boolean(user)
/** Requires at least the given role. */
export const requireRole =
(minimum: Role): Access =>
({ req: { user } }) =>
hasMinimumRole(user, minimum)
/** Admins see all; others are constrained to their own document. */
export const adminOrSelf: Access = ({ req: { user } }) => {
if (isAdmin(user)) {return true}
if (!user) {return false}
return { id: { equals: user.id } }
}
/**
* Field-level access (returns boolean only — no Where support).
* Use in field `access.read/update`.
*/
export const adminOnlyField: FieldAccess = ({ req: { user } }) => isAdmin(user)
export const adminOrEditorField: FieldAccess = ({ req: { user } }) => hasMinimumRole(user, 'editor')
/** Requires at least the given role, for field-level access. */
export const requireRoleField =
(minimum: Role): FieldAccess =>
({ req: { user } }) =>
hasMinimumRole(user, minimum)
+15
View File
@@ -0,0 +1,15 @@
export {
adminOnly,
adminOnlyField,
adminOrEditor,
adminOrEditorField,
adminOrSelf,
authenticated,
requireRole,
requireRoleField,
} from './access.js'
export { injectRoles } from './injectRoles.js'
export { hasMinimumRole, isAdmin, isEditor } from './predicates.js'
export { buildRolesField } from './rolesField.js'
export type { AccessOption, Role } from './types.js'
export { ROLE_HIERARCHY } from './types.js'
+29
View File
@@ -0,0 +1,29 @@
import type { Config } from 'payload'
import type { AccessOption } from './types.js'
import { buildRolesField } from './rolesField.js'
/**
* Injects the fixed `roles` field into the client's auth collection.
*
* The plugin owns the role definition; the client owns the collection. This
* finds the collection by slug and appends the field. We control the whole
* stack, so no conflict handling is needed — the field is simply added.
*/
export function injectRoles(config: Config, access: AccessOption): Config {
const rolesField = buildRolesField(access.defaultRole)
return {
...config,
collections: (config.collections ?? []).map((collection) => {
if (collection.slug !== access.authCollection) {
return collection
}
return {
...collection,
fields: [...collection.fields, rolesField],
}
}),
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { Role } from './types.js'
import { ROLE_HIERARCHY } from './types.js'
/**
* The plugin can't know the client's generated User type, and Payload types
* `req.user` loosely (UntypedUser | null). Predicates therefore accept an
* unknown-ish user and read `roles` defensively — no assumptions about shape
* beyond an optional roles array.
*/
type MaybeUser = { roles?: null | Role[] } | null | Record<string, unknown> | undefined
/** Safely extracts the roles array from a loosely-typed user. */
function getRoles(user: MaybeUser): Role[] {
if (!user || typeof user !== 'object') {return []}
const roles = (user as { roles?: unknown }).roles
if (!Array.isArray(roles)) {return []}
return roles.filter((role): role is Role => ROLE_HIERARCHY.includes(role as Role))
}
/** Highest-privilege role index the user holds, or -1 if none. */
function highestRoleIndex(user: MaybeUser): number {
const roles = getRoles(user)
if (!roles.length) {return -1}
return Math.max(...roles.map((role) => ROLE_HIERARCHY.indexOf(role)))
}
/**
* True if the user holds at least the given role in the hierarchy.
* admin satisfies 'editor' and 'user'; editor satisfies 'user'.
*/
export function hasMinimumRole(user: MaybeUser, minimum: Role): boolean {
return highestRoleIndex(user) >= ROLE_HIERARCHY.indexOf(minimum)
}
/** True if the user is an admin. */
export function isAdmin(user: MaybeUser): boolean {
return hasMinimumRole(user, 'admin')
}
/** True if the user is an editor or higher (editor, admin). */
export function isEditor(user: MaybeUser): boolean {
return hasMinimumRole(user, 'editor')
}
+34
View File
@@ -0,0 +1,34 @@
import type { Field } from 'payload'
import type { Role } from './types.js'
import { isAdmin } from './predicates.js'
import { ROLE_HIERARCHY } from './types.js'
/**
* Builds the fixed `roles` field the plugin injects into the auth collection.
*
* Saved to the JWT so role checks avoid a database lookup. Only admins can
* change roles, preventing privilege escalation by lower-privilege users.
*/
export function buildRolesField(defaultRole: Role = 'user'): Field {
return {
name: 'roles',
type: 'select',
access: {
// Only admins may assign or change roles
update: ({ req: { user } }) => isAdmin(user),
},
admin: {
description: 'Role hierarchy: admin > editor > user.',
},
defaultValue: [defaultRole],
hasMany: true,
options: ROLE_HIERARCHY.map((role) => ({
label: role.charAt(0).toUpperCase() + role.slice(1),
value: role,
})),
required: true,
saveToJWT: true,
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Role hierarchy, lowest to highest privilege.
* A higher role satisfies any requirement met by a lower one.
*/
export const ROLE_HIERARCHY = ['user', 'editor', 'admin'] as const
export type Role = (typeof ROLE_HIERARCHY)[number]
/**
* Access-control options.
*
* The plugin injects a fixed `roles` field into the client's auth collection
* — the collection itself belongs to the client (create-payload-app), the
* role definition belongs to the plugin.
*/
export type AccessOption = {
/** Slug of the client's auth collection, e.g. 'users'. */
authCollection: string
/** Role assigned to new users. Defaults to 'user'. */
defaultRole?: Role
}
+70
View File
@@ -0,0 +1,70 @@
'use client'
import { useEffect } from 'react'
import type { AnalyticsConfig } from './types.js'
import { gtag } from '../consent/googleConsent.js'
import { CONSENT_COOKIE, DEFAULT_CONSENT, parseConsent, setDefaultConsent } from '../consent/index.js'
declare global {
interface Window {
dataLayer?: unknown[]
}
}
const GTM_SCRIPT = (id: string) => `https://www.googletagmanager.com/gtm.js?id=${id}`
const GA4_SCRIPT = (id: string) => `https://www.googletagmanager.com/gtag/js?id=${id}`
/** Reads the current consent state from cookie, or defaults if none. */
function readConsent() {
if (typeof document === 'undefined') {return DEFAULT_CONSENT}
const raw = document.cookie
.split('; ')
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`))
?.split('=')[1]
return parseConsent(raw) ?? DEFAULT_CONSENT
}
function injectScript(src: string) {
if (document.querySelector(`script[src="${src}"]`)) {return}
const s = document.createElement('script')
s.src = src
s.async = true
document.head.appendChild(s)
}
/**
* Loads Google Analytics — GTM if a container ID is set, otherwise GA4 gtag.
*
* Consent Mode is initialised to the visitor's stored consent BEFORE the tag
* loads (via setDefaultConsent from the consent module), so tags respect the
* choice from the first hit; useConsent sends an update the moment the visitor
* decides. IDs are public and come from SiteIntegrations, passed in as props
* (the client project reads them server-side).
*
* Renders nothing — it only injects the scripts. Mount once, high in the tree
* (e.g. root layout), inside ConsentProvider.
*/
export function Analytics({ ga4MeasurementId, gtmContainerId }: AnalyticsConfig) {
useEffect(() => {
if (!ga4MeasurementId && !gtmContainerId) {return}
// 1. Consent Mode default = the visitor's stored choice, before tags load.
setDefaultConsent(readConsent())
// 2. Load the tag.
if (gtmContainerId) {
window.dataLayer = window.dataLayer || []
window.dataLayer.push({ event: 'gtm.js', 'gtm.start': Date.now() })
injectScript(GTM_SCRIPT(gtmContainerId))
} else if (ga4MeasurementId) {
injectScript(GA4_SCRIPT(ga4MeasurementId))
// Uses the shared gtag (pushes `arguments`, as Google's tags expect);
// real gtag.js wires itself up once the script loads.
gtag('js', new Date())
gtag('config', ga4MeasurementId)
}
}, [ga4MeasurementId, gtmContainerId])
return null
}
+2
View File
@@ -0,0 +1,2 @@
'use client'
export { Analytics } from './Analytics.js'
@@ -0,0 +1,19 @@
import type { BasePayload } from 'payload'
import type { AnalyticsConfig } from './types.js'
import { getSiteIntegrations } from '../payload/index.js'
/**
* Reads the public analytics IDs from SiteIntegrations, server-side.
*
* Returns only the two public IDs (GA4 / GTM) — safe to pass to the client
* <Analytics> component. Does NOT expose any secret from SiteIntegrations.
*/
export async function getAnalyticsConfig(payload: BasePayload): Promise<AnalyticsConfig> {
const integrations = await getSiteIntegrations<AnalyticsConfig>(payload)
return {
ga4MeasurementId: integrations.ga4MeasurementId ?? null,
gtmContainerId: integrations.gtmContainerId ?? null,
}
}
+4
View File
@@ -0,0 +1,4 @@
// Server-safe: config type + the helper that reads IDs from SiteIntegrations.
// The <Analytics> component is client-side — exported via ./client.
export type { AnalyticsConfig } from './types.js'
export { getAnalyticsConfig } from './getAnalyticsConfig.js'
+9
View File
@@ -0,0 +1,9 @@
/**
* Analytics IDs, read from SiteIntegrations (public — safe on the client).
*/
export type AnalyticsConfig = {
/** GA4 Measurement ID, e.g. 'G-XXXXXXXXXX'. */
ga4MeasurementId?: null | string
/** GTM Container ID, e.g. 'GTM-XXXXXXX'. */
gtmContainerId?: null | string
}
+53
View File
@@ -0,0 +1,53 @@
import { Fragment } from 'react'
import type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'
export type RenderBlocksProps = {
/** Block data array from a Payload document (e.g. page.layout). */
blocks: BlockData[] | null | undefined
/** Client-provided map of blockType → component. */
components: BlockComponentMap
/**
* Optional client hook to inject block-specific props (nav anchors, etc.).
* Keeps the engine generic — the plugin knows no concrete block types.
*/
enhanceProps?: EnhanceProps
}
/**
* Generic, server-side block renderer.
*
* Iterates a document's blocks and renders each via the client's component
* map. Unknown blocks are skipped (null), never thrown. Block-specific logic is
* delegated to the client's optional `enhanceProps` — the plugin itself is
* block-agnostic.
*
* The client owns components and block schemas (they pass `components` and
* define blocks in their config); the plugin owns only the iteration and
* wiring. No wrapper markup is added — spacing/layout belong to the client's
* components.
*
* Nested blocks: a block that needs to render child blocks should render its
* own <RenderBlocks> and import the registry itself, rather than receiving the
* component map as a prop. Passing the map as a prop breaks React Server
* Components — functions (client components) can't cross the server→client
* boundary via props.
*/
export function RenderBlocks({ blocks, components, enhanceProps }: RenderBlocksProps) {
if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {
return null
}
return (
<Fragment>
{blocks.map((block, index) => {
const Component = components[block.blockType]
if (!Component) {return null}
const extra = enhanceProps ? enhanceProps({ allBlocks: blocks, block, index }) : {}
return <Component key={index} {...block} {...extra} />
})}
</Fragment>
)
}
+3
View File
@@ -0,0 +1,3 @@
export { RenderBlocks } from './RenderBlocks.js'
export type { RenderBlocksProps } from './RenderBlocks.js'
export type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentType } from 'react'
/**
* A single block's data as stored by Payload — always has a blockType, plus
* arbitrary block-specific fields. The plugin stays generic over the shape.
*/
export type BlockData = {
[key: string]: unknown
blockType: string
}
/**
* Maps a blockType to the client's component for it.
* The client owns the components; the plugin only receives this map.
*/
export type BlockComponentMap = Record<string, ComponentType<any>>
/**
* Optional per-block prop enhancer supplied by the client.
*
* Lets the client inject block-specific logic (e.g. collect anchors for a nav
* block) without the plugin knowing any concrete block types. Returns extra
* props merged into the rendered block.
*/
export type EnhanceProps = (args: {
allBlocks: BlockData[]
block: BlockData
index: number
}) => Record<string, unknown>
+21
View File
@@ -0,0 +1,21 @@
'use client'
import { createContext, type ReactNode, use } from 'react'
import type { ConsentTexts } from './texts.js'
import { useConsent } from './useConsent.js'
type ConsentContextValue = { texts: ConsentTexts } & ReturnType<typeof useConsent>
const ConsentContext = createContext<ConsentContextValue | null>(null)
export function ConsentProvider({ children, texts }: { children: ReactNode; texts: ConsentTexts }) {
const consent = useConsent()
return <ConsentContext value={{ ...consent, texts }}>{children}</ConsentContext>
}
export function useConsentContext(): ConsentContextValue {
const ctx = use(ConsentContext)
if (!ctx) {throw new Error('useConsentContext must be used within ConsentProvider')}
return ctx
}
+147
View File
@@ -0,0 +1,147 @@
'use client'
import { Cookie } from 'lucide-react'
import { useEffect, useState } from 'react'
import { CONSENT_CATEGORIES, type ConsentCategory } from './categories.js'
import { useConsentContext } from './ConsentContext.js'
/**
* Optional class overrides — for when tokens aren't enough, e.g. turning the
* bottom bar into a centred modal. Replaces the slot's classes outright.
*/
export type CookieBannerClassNames = {
primaryButton?: string
root?: string
secondaryButton?: string
}
/**
* Colours and radius come from CSS custom properties with built-in fallbacks,
* so the banner looks right with no setup, and re-skinning per client means
* declaring a few variables — no imports, no props, no specificity fights:
*
* ```css
* :root {
* --ipal-primary: #16a34a;
* --ipal-radius: 1rem;
* }
* ```
*
* Available: --ipal-surface, --ipal-border, --ipal-text, --ipal-text-strong,
* --ipal-text-muted, --ipal-primary, --ipal-primary-hover, --ipal-primary-text,
* --ipal-hover, --ipal-radius. Each has a `-dark` counterpart used under
* Tailwind's `dark:` variant (e.g. --ipal-surface-dark).
*
* Layout stays in Tailwind: spacing and flow aren't things a brand changes, and
* exposing them as tokens would mean re-inventing CSS one variable at a time.
*/
const surface = 'bg-[var(--ipal-surface,#fff)] dark:bg-[var(--ipal-surface-dark,#171717)]'
const border = 'border-[var(--ipal-border,#e5e5e5)] dark:border-[var(--ipal-border-dark,#262626)]'
const radius = 'rounded-[var(--ipal-radius,0.375rem)]'
const accent = 'text-[var(--ipal-primary,#2563eb)]'
const defaults = {
primaryButton: `${radius} px-3 py-1.5 text-sm font-medium bg-[var(--ipal-primary,#2563eb)] text-[var(--ipal-primary-text,#fff)] hover:bg-[var(--ipal-primary-hover,#1d4ed8)]`,
root: `fixed inset-x-0 bottom-0 z-50 border-t p-4 shadow-lg ${surface} ${border}`,
secondaryButton: `${radius} px-3 py-1.5 text-sm font-medium text-[var(--ipal-text,#404040)] hover:bg-[var(--ipal-hover,#f5f5f5)] dark:text-[var(--ipal-text-dark,#e5e5e5)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]`,
}
const bodyText = 'text-[var(--ipal-text,#404040)] dark:text-[var(--ipal-text-dark,#d4d4d4)]'
const strongText =
'text-[var(--ipal-text-strong,#171717)] dark:text-[var(--ipal-text-strong-dark,#f5f5f5)]'
const mutedText =
'text-[var(--ipal-text-muted,#737373)] dark:text-[var(--ipal-text-muted-dark,#a3a3a3)]'
export function CookieBanner({ classNames }: { classNames?: CookieBannerClassNames } = {}) {
const { acceptAll, decided, rejectAll, savePreferences, state, texts } = useConsentContext()
const [showSettings, setShowSettings] = useState(false)
const [choices, setChoices] = useState<Record<ConsentCategory, boolean>>(state)
useEffect(() => {
setChoices(state)
}, [state])
if (decided) {return null}
const toggle = (cat: ConsentCategory) => {
if (cat === 'necessary') {return}
setChoices((prev) => ({ ...prev, [cat]: !prev[cat] }))
}
const rootClass = classNames?.root ?? defaults.root
const primaryClass = classNames?.primaryButton ?? defaults.primaryButton
const secondaryClass = classNames?.secondaryButton ?? defaults.secondaryButton
return (
<div aria-label="Cookie consent" className={rootClass} data-ipal="banner" role="dialog">
<div className="mx-auto max-w-4xl">
{!showSettings ? (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<Cookie aria-hidden="true" className={`h-6 w-6 shrink-0 ${accent}`} />
<p className={`text-justify text-sm ${bodyText}`}>
{texts.message}
{texts.privacyLink && (
<>
{' '}
<a
className={`${accent} underline hover:no-underline`}
href={texts.privacyLink.href}
>
{texts.privacyLink.label}
</a>
</>
)}
</p>
</div>
<div className="flex shrink-0 gap-2">
<button className={secondaryClass} onClick={() => setShowSettings(true)}>
{texts.buttons.settings}
</button>
<button className={secondaryClass} onClick={rejectAll}>
{texts.buttons.reject}
</button>
<button className={primaryClass} onClick={acceptAll}>
{texts.buttons.acceptAll}
</button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<h2 className={`flex items-center gap-2 text-base font-semibold ${strongText}`}>
<Cookie aria-hidden="true" className={`h-5 w-5 ${accent}`} />
{texts.settingsTitle}
</h2>
<ul className="flex flex-col gap-3">
{CONSENT_CATEGORIES.map((cat) => (
<li className="flex items-start justify-between gap-4" key={cat}>
<div>
<p className={`text-sm font-medium ${strongText}`}>
{texts.categories[cat].title}
</p>
<p className={`text-xs ${mutedText}`}>{texts.categories[cat].description}</p>
</div>
<input
checked={cat === 'necessary' ? true : choices[cat]}
className="mt-1 h-4 w-4 shrink-0 accent-[var(--ipal-primary,#2563eb)]"
disabled={cat === 'necessary'}
onChange={() => toggle(cat)}
type="checkbox"
/>
</li>
))}
</ul>
<div className="flex justify-end gap-2">
<button className={secondaryClass} onClick={() => setShowSettings(false)}>
{texts.buttons.back}
</button>
<button className={primaryClass} onClick={() => savePreferences(choices)}>
{texts.buttons.save}
</button>
</div>
</div>
)}
</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
'use client'
import { Cookie } from 'lucide-react'
import { useConsentContext } from './ConsentContext.js'
/**
* Floating "cookie settings" button — lets visitors reopen the consent panel
* after deciding (GDPR: withdrawing consent must be as easy as giving it).
* Hidden while the banner is showing.
*
* Colours follow the same CSS custom properties as CookieBanner
* (--ipal-surface, --ipal-border, --ipal-hover, --ipal-text), so a client's
* palette applies to both without touching either component. Pass className to
* replace the styling outright.
*/
export function CookieButton({ className }: { className?: string } = {}) {
const { decided, reopen } = useConsentContext()
if (!decided) {return null}
const cls =
className ??
'fixed bottom-4 left-4 z-40 flex h-11 w-11 items-center justify-center rounded-full border shadow-md transition-colors ' +
'bg-[var(--ipal-surface,#fff)] border-[var(--ipal-border,#e5e5e5)] hover:bg-[var(--ipal-hover,#f5f5f5)] ' +
'dark:bg-[var(--ipal-surface-dark,#171717)] dark:border-[var(--ipal-border-dark,#262626)] dark:hover:bg-[var(--ipal-hover-dark,#262626)]'
return (
<button aria-label="Cookie settings" className={cls} data-ipal="cookie-button" onClick={reopen}>
<Cookie
aria-hidden="true"
className="h-5 w-5 text-[var(--ipal-text,#525252)] dark:text-[var(--ipal-text-dark,#d4d4d4)]"
/>
</button>
)
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Cookie consent categories (GDPR). 'necessary' is always granted and cannot be
* disabled. The rest default to false until the user opts in.
*/
export const CONSENT_CATEGORIES = ['necessary', 'functional', 'analytics', 'marketing'] as const
export type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]
export type ConsentState = Record<ConsentCategory, boolean>
export const DEFAULT_CONSENT: ConsentState = {
necessary: true,
functional: false,
analytics: false,
marketing: false,
}
export const ACCEPT_ALL_CONSENT: ConsentState = {
necessary: true,
functional: true,
analytics: true,
marketing: true,
}
export const REJECT_ALL_CONSENT: ConsentState = { ...DEFAULT_CONSENT }
+7
View File
@@ -0,0 +1,7 @@
'use client'
export { ConsentProvider, useConsentContext } from './ConsentContext.js'
export { CookieBanner } from './CookieBanner.js'
export type { CookieBannerClassNames } from './CookieBanner.js'
export { CookieButton } from './CookieButton.js'
// Client-only consent exports — hook, provider, and UI components.
export { useConsent } from './useConsent.js'
+99
View File
@@ -0,0 +1,99 @@
import type { BasePayload } from 'payload'
import type { I18nConfig } from '../i18n/index.js'
import type { ConsentTexts } from './texts.js'
import { getSystemPagePath } from '../pages/index.js'
import { getGlobal } from '../payload/index.js'
import { CONSENT_CATEGORIES } from './categories.js'
/** English fallback used when the global has no value for a field. */
const FALLBACK: ConsentTexts = {
buttons: {
acceptAll: 'Accept all',
back: 'Back',
reject: 'Reject',
save: 'Save preferences',
settings: 'Settings',
},
categories: {
analytics: { description: 'Help us understand usage.', title: 'Analytics' },
functional: { description: 'Remember preferences.', title: 'Functional' },
marketing: { description: 'Used to show relevant ads.', title: 'Marketing' },
necessary: {
description:
'Required for the site to work, including your language and theme preferences. Always on.',
title: 'Necessary',
},
},
message:
'We use cookies to run the site, analyze traffic, and improve your experience. You can accept all, reject non-essential, or choose which to allow.',
privacyLink: null,
settingsTitle: 'Cookie settings',
}
type CookieSettingsData = {
buttons?: null | Partial<ConsentTexts['buttons']>
categories?: Array<{ description?: null | string; key: string; title?: null | string }> | null
message?: null | string
settingsTitle?: null | string
}
type GetConsentTextsArgs = {
config: I18nConfig
locale?: string
payload: BasePayload
/**
* Privacy-policy label + the system page (from SiteSettings.privacyPolicy,
* read with locale:'all') to link to. Optional — omit for no link.
*/
privacyPolicy?: {
label: string
page: { slug?: null | Record<string, unknown> } | null
}
}
/**
* Resolves consent banner texts from the CookieSettings global, falling back to
* English defaults per field. The privacy-policy link is built from the pages
* module (system page role), not stored in CookieSettings — one source of truth.
*/
export async function getConsentTexts({
config,
locale,
payload,
privacyPolicy,
}: GetConsentTextsArgs): Promise<ConsentTexts> {
const g = await getGlobal<CookieSettingsData>(payload, 'cookie-settings', { locale })
const categories = {} as ConsentTexts['categories']
for (const cat of CONSENT_CATEGORIES) {
const entry = g.categories?.find((c) => c.key === cat)
categories[cat] = {
description: entry?.description || FALLBACK.categories[cat].description,
title: entry?.title || FALLBACK.categories[cat].title,
}
}
let privacyLink: ConsentTexts['privacyLink'] = null
if (privacyPolicy?.page && locale) {
const href = getSystemPagePath({ config, locale, page: privacyPolicy.page })
if (href) {
privacyLink = { href, label: privacyPolicy.label }
}
}
return {
buttons: {
acceptAll: g.buttons?.acceptAll || FALLBACK.buttons.acceptAll,
back: g.buttons?.back || FALLBACK.buttons.back,
reject: g.buttons?.reject || FALLBACK.buttons.reject,
save: g.buttons?.save || FALLBACK.buttons.save,
settings: g.buttons?.settings || FALLBACK.buttons.settings,
},
categories,
message: g.message || FALLBACK.message,
privacyLink,
settingsTitle: g.settingsTitle || FALLBACK.settingsTitle,
}
}
+55
View File
@@ -0,0 +1,55 @@
import type { ConsentState } from './categories.js'
type ConsentValue = 'denied' | 'granted'
type GoogleConsentSignals = Record<string, ConsentValue>
function toSignals(state: ConsentState): GoogleConsentSignals {
const g = (b: boolean): ConsentValue => (b ? 'granted' : 'denied')
return {
ad_personalization: g(state.marketing),
ad_storage: g(state.marketing),
ad_user_data: g(state.marketing),
analytics_storage: g(state.analytics),
functionality_storage: g(state.functional),
personalization_storage: g(state.functional),
security_storage: 'granted', // necessary — always on
}
}
declare global {
interface Window {
dataLayer?: unknown[]
}
}
/**
* Mirrors Google's canonical snippet: `function gtag(){dataLayer.push(arguments)}`.
*
* Pushing `arguments` rather than a plain array is not a stylistic detail.
* Google's tags recognise a consent command by the Arguments object it arrives
* in; a real array lands in the dataLayer as ordinary data and the command is
* silently ignored — the tag loads, but consent never updates and analytics
* stays denied. The rest parameter exists only to type the call sites.
*
* Exported for the analytics module (which needs `js`/`config` commands) —
* intentionally not re-exported from the package's public API.
*/
export function gtag(..._args: unknown[]) {
if (typeof window === 'undefined') {return}
window.dataLayer = window.dataLayer || []
// eslint-disable-next-line prefer-rest-params
window.dataLayer.push(arguments)
}
/** Sets the default consent state (call before Google tags load). */
export function setDefaultConsent(state: ConsentState) {
gtag('consent', 'default', {
...toSignals(state),
wait_for_update: 500, // ms to wait for an update before firing
})
}
/** Updates consent after the user decides. */
export function updateConsent(state: ConsentState) {
gtag('consent', 'update', toSignals(state))
}
+20
View File
@@ -0,0 +1,20 @@
// Server-safe exports (logic, types, helper). Client components (banner,
// provider, hook) are exported separately via ./client to keep the RSC/client
// boundary clean.
export {
ACCEPT_ALL_CONSENT,
CONSENT_CATEGORIES,
DEFAULT_CONSENT,
REJECT_ALL_CONSENT,
} from './categories.js'
export type { ConsentCategory, ConsentState } from './categories.js'
export { getConsentTexts } from './getConsentTexts.js'
export { setDefaultConsent, updateConsent } from './googleConsent.js'
export {
CONSENT_COOKIE,
CONSENT_MAX_AGE,
CONSENT_VERSION,
parseConsent,
serializeConsent,
} from './storage.js'
export type { ConsentTexts } from './texts.js'
+43
View File
@@ -0,0 +1,43 @@
import type { ConsentState } from './categories.js'
import { CONSENT_CATEGORIES, DEFAULT_CONSENT } from './categories.js'
/**
* Consent persistence in a cookie. Stored with a version so we can invalidate
* old consents when the policy changes, and a timestamp (GDPR: consent must be
* dated). Readable both client-side (banner) and server-side (script gating).
*/
export const CONSENT_COOKIE = 'cookie-consent'
export const CONSENT_VERSION = 1
export const CONSENT_MAX_AGE = 60 * 60 * 24 * 180 // 180 days
type StoredConsent = {
state: ConsentState
timestamp: string
version: number
}
export function serializeConsent(state: ConsentState): string {
const payload: StoredConsent = {
state,
timestamp: new Date().toISOString(),
version: CONSENT_VERSION,
}
return encodeURIComponent(JSON.stringify(payload))
}
export function parseConsent(raw: string | undefined): ConsentState | null {
if (!raw) {return null}
try {
const parsed = JSON.parse(decodeURIComponent(raw)) as StoredConsent
if (parsed.version !== CONSENT_VERSION) {return null}
const state = { ...DEFAULT_CONSENT }
for (const cat of CONSENT_CATEGORIES) {
if (typeof parsed.state?.[cat] === 'boolean') {state[cat] = parsed.state[cat]}
}
state.necessary = true
return state
} catch {
return null
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { ConsentCategory } from './categories.js'
export type ConsentTexts = {
buttons: {
acceptAll: string
back: string
reject: string
save: string
settings: string
}
categories: Record<ConsentCategory, { description: string; title: string }>
message: string
/** null = no privacy-policy link shown */
privacyLink: { href: string; label: string } | null
settingsTitle: string
}
+47
View File
@@ -0,0 +1,47 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { ConsentCategory, ConsentState } from './categories.js'
import { ACCEPT_ALL_CONSENT, DEFAULT_CONSENT, REJECT_ALL_CONSENT } from './categories.js'
import { updateConsent } from './googleConsent.js'
import { CONSENT_COOKIE, CONSENT_MAX_AGE, parseConsent, serializeConsent } from './storage.js'
export function useConsent() {
const [state, setState] = useState<ConsentState>(DEFAULT_CONSENT)
const [decided, setDecided] = useState(false)
useEffect(() => {
const raw = document.cookie
.split('; ')
.find((c) => c.startsWith(`${CONSENT_COOKIE}=`))
?.split('=')[1]
const parsed = parseConsent(raw)
if (parsed) {
setState(parsed)
setDecided(true)
}
}, [])
const persist = useCallback((next: ConsentState) => {
document.cookie = `${CONSENT_COOKIE}=${serializeConsent(next)};path=/;max-age=${CONSENT_MAX_AGE};samesite=lax`
setState(next)
setDecided(true)
// Tell Google right away. Tags are already on the page holding whatever
// defaults Analytics set at load; without this update they keep them for
// the rest of the session and never write their cookies — the banner would
// look like it worked while nothing changed.
updateConsent(next)
}, [])
const acceptAll = useCallback(() => persist(ACCEPT_ALL_CONSENT), [persist])
const rejectAll = useCallback(() => persist(REJECT_ALL_CONSENT), [persist])
const savePreferences = useCallback(
(choices: Partial<Record<ConsentCategory, boolean>>) =>
persist({ ...DEFAULT_CONSENT, ...choices, necessary: true }),
[persist],
)
const reopen = useCallback(() => setDecided(false), [])
return { acceptAll, decided, rejectAll, reopen, savePreferences, state }
}
+32
View File
@@ -0,0 +1,32 @@
import type { Field, RelationshipField } from 'payload'
import type { ContentOption } from './types.js'
import { archiveFieldName } from './types.js'
/**
* Builds one relationship field per content collection, pointing at the
* client's Pages collection.
*
* Sits alongside the system-page roles (homepage, privacy policy) because it's
* the same idea: a page referenced by function rather than by slug. The
* assignment is locale-agnostic — one page document — while the resulting path
* is locale-aware, since the page's slug is localized. Assign the "Artykuły"
* page once and you get /pl/artykuly and /en/articles from its own slugs.
*/
export function buildArchiveFields(content: ContentOption, pagesSlug: string): Field[] {
return content.collections.map((collection): RelationshipField => {
const label = collection.label ?? collection.slug
return {
name: archiveFieldName(collection.slug),
type: 'relationship',
admin: {
description: `Page listing ${label}. Its slug becomes the URL segment for entries (e.g. /pl/artykuly/moj-wpis).`,
},
label: `${label} — archive page`,
maxDepth: 1,
relationTo: pagesSlug,
}
})
}
+69
View File
@@ -0,0 +1,69 @@
import type { BasePayload } from 'payload'
export type ArchiveEntries<T = Record<string, unknown>> = {
docs: T[]
hasNextPage: boolean
hasPrevPage: boolean
/** 1-based. */
page: number
totalDocs: number
totalPages: number
}
type GetArchiveEntriesArgs = {
/** Collection to list, e.g. 'posts'. */
collection: string
/** Relationship depth. Defaults to 1 — enough for a cover image. */
depth?: number
locale: string
/** 1-based. Values below 1 are clamped. */
page?: number
payload: BasePayload
/** Defaults to 10. */
perPage?: number
/** Payload sort string. Defaults to newest first. */
sort?: string
/** Extra constraints merged into the query, e.g. { category: { equals: id } }. */
where?: Record<string, unknown>
}
/**
* Fetches one page of a collection's entries for an archive listing.
*
* Only published documents are returned when the collection has drafts enabled;
* Payload's `where` on `_status` is left to the caller, since a collection
* without drafts has no such field.
*
* Returns pagination facts rather than markup — the listing itself belongs to
* the client (as a block on the archive page, most likely), because how a list
* of posts should look isn't something a plugin can decide.
*/
export async function getArchiveEntries<T = Record<string, unknown>>({
collection,
depth = 1,
locale,
page = 1,
payload,
perPage = 10,
sort = '-createdAt',
where,
}: GetArchiveEntriesArgs): Promise<ArchiveEntries<T>> {
const result = await payload.find({
collection: collection as never,
depth,
limit: perPage,
locale: locale as never,
page: Math.max(1, page),
sort,
...(where ? { where: where as never } : {}),
})
return {
docs: result.docs as T[],
hasNextPage: result.hasNextPage,
hasPrevPage: result.hasPrevPage,
page: result.page ?? 1,
totalDocs: result.totalDocs,
totalPages: result.totalPages,
}
}
+8
View File
@@ -0,0 +1,8 @@
export { buildArchiveFields } from './archiveFields.js'
export { getArchiveEntries } from './getArchiveEntries.js'
export type { ArchiveEntries } from './getArchiveEntries.js'
export { buildArchivePath, buildEntryPath, parsePageParam } from './paths.js'
export { resolveRoute } from './resolveRoute.js'
export type { ResolvedRoute } from './resolveRoute.js'
export type { ContentCollectionOption, ContentOption } from './types.js'
export { archiveFieldName } from './types.js'
+47
View File
@@ -0,0 +1,47 @@
type ArchivePathArgs = {
/** Archive page's slug in this locale, e.g. 'artykuly'. */
archiveSlug: string
locale: string
/** 1-based. Page 1 is omitted from the URL. */
page?: number
}
/**
* Path to an archive listing: /pl/artykuly, /pl/artykuly?page=2.
*
* Pagination goes in the query string rather than the path. A path segment
* (/pl/artykuly/2) would be ambiguous with an entry slugged "2", and the
* alternative (/pl/artykuly/strona/2) drags in yet another localized segment to
* configure and translate. Google has understood ?page= for years, and
* rel=next/prev — the reason people used to prefer path segments — was retired.
*/
export function buildArchivePath({ archiveSlug, locale, page = 1 }: ArchivePathArgs): string {
const base = `/${locale}/${archiveSlug}`
return page > 1 ? `${base}?page=${page}` : base
}
type EntryPathArgs = {
/** Archive page's slug in this locale, e.g. 'artykuly'. */
archiveSlug: string
/** Entry's slug in this locale, e.g. 'moj-post'. */
entrySlug: string
locale: string
}
/** Path to a single entry: /pl/artykuly/moj-post. */
export function buildEntryPath({ archiveSlug, entrySlug, locale }: EntryPathArgs): string {
return `/${locale}/${archiveSlug}/${entrySlug}`
}
/**
* Reads a page number out of a query parameter.
*
* Anything that isn't a positive integer is page 1 — `?page=abc`, `?page=-5`,
* `?page=1.5` and a repeated `?page=1&page=2` all have to resolve to something,
* and silently showing the first page beats a 500 on a URL a crawler invented.
*/
export function parsePageParam(value: string | string[] | undefined): number {
const raw = Array.isArray(value) ? value[0] : value
const n = Number(raw)
return Number.isInteger(n) && n > 0 ? n : 1
}
+166
View File
@@ -0,0 +1,166 @@
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 }
export type ResolvedRoute =
/** Locale root — the page assigned as Homepage in System Pages. */
| {
/** The archive page this entry lives under — its slug is the URL prefix. */
archive: Record<string, unknown>
collection: string
doc: Record<string, unknown>
type: 'entry'
}
/** An ordinary page. */
| {
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
type: 'archive'
}
/** A collection's archive page, e.g. /pl/artykuly. */
| { doc: Record<string, unknown>; type: 'home' }
/** A single entry, e.g. /pl/artykuly/moj-post. */
| { doc: Record<string, unknown>; type: 'page' }
type ResolveRouteArgs = {
content?: ContentOption
locale: string
/** Page number from the query string (?page=2). Defaults to 1. */
page?: number
/** Client's Pages collection slug. Defaults to 'pages'. */
pagesSlug?: string
payload: BasePayload
/** Route segments after the locale, e.g. ['artykuly', 'moj-post']. */
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.
*
* 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
* moves the whole section.
*
* Resolution order matters: a first segment matching an archive's slug wins
* over an ordinary page of the same name, because an archive *is* a page and
* would otherwise shadow its own entries.
*
* Depth beyond {archive}/{entry} isn't supported — categories in the path would
* make canonical and hreflang ambiguous (the same entry reachable under several
* URLs).
*/
export async function resolveRoute({
content,
locale,
page = 1,
pagesSlug = 'pages',
payload,
segments,
settingsSlug = 'site-settings',
withEntries = false,
}: ResolveRouteArgs): Promise<null | ResolvedRoute> {
const settings = (await payload.findGlobal({
slug: settingsSlug,
depth: 1,
locale: locale as never,
})) as Record<string, unknown>
// Locale root → Homepage from System Pages.
if (!segments?.length) {
const homepage = settings.homepage
if (homepage && typeof homepage === 'object') {
return { type: 'home', doc: homepage as Record<string, unknown> }
}
return null
}
// Which archive (if any) does the first segment name?
const [first, ...rest] = segments
for (const collection of content?.collections ?? []) {
const archive = settings[archiveFieldName(collection.slug)]
if (!archive || typeof archive !== 'object') {continue}
const archiveDoc = archive as ArchivePage
if (archiveDoc.slug !== first) {continue}
// /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,
}),
}
: {}),
}
}
// /pl/artykuly/moj-post → an entry.
if (rest.length === 1) {
const found = await payload.find({
collection: collection.slug as never,
depth: 2,
limit: 1,
locale: locale as never,
where: { slug: { equals: rest[0] } },
})
const entry = found.docs[0]
if (!entry) {return null}
return {
type: 'entry',
archive: archiveDoc as Record<string, unknown>,
collection: collection.slug,
doc: entry as Record<string, unknown>,
}
}
// Deeper than {archive}/{entry}.
return null
}
// An ordinary page. Joined, so nested slugs ('a/b') keep working.
const found = await payload.find({
collection: pagesSlug as never,
depth: 2,
limit: 1,
locale: locale as never,
where: { slug: { equals: segments.join('/') } },
})
const doc = found.docs[0]
if (!doc) {return null}
return { type: 'page', doc: doc as Record<string, unknown> }
}
+33
View File
@@ -0,0 +1,33 @@
/**
* A collection whose entries live under an archive page, e.g. blog posts at
* /pl/artykuly/moj-post.
*
* The collection itself belongs to the client — Payload keeps its schema in
* code, so adding one is always a code change. What this option adds is the
* routing: the plugin exposes an "archive page" assignment in SiteSettings, and
* whichever page an editor picks becomes the URL segment. Rename that page from
* "Artykuły" to "Wpisy" and the path follows, per locale, with no deploy.
*/
export type ContentCollectionOption = {
/** Slug of the client's collection, e.g. 'posts'. */
slug: string
/** Admin label for the archive assignment. Defaults to the slug. */
label?: string
/** Entries per page in listings. Defaults to 10. */
perPage?: number
}
/**
* Plugin option wiring archive-backed collections into routing.
*/
export type ContentOption = {
collections: ContentCollectionOption[]
}
/**
* Field name holding a collection's archive page in SiteSettings.
* `posts` → `postsArchive`.
*/
export function archiveFieldName(collectionSlug: string): string {
return `${collectionSlug}Archive`
}
+4
View File
@@ -0,0 +1,4 @@
// Server-only exports. sendEmail imports 'server-only' (SMTP password, nodemailer)
// so this must never be imported from a client component.
export { sendEmail } from './sendEmail.js'
export type { SendEmailArgs, SendEmailResult } from './sendEmail.js'
+108
View File
@@ -0,0 +1,108 @@
import type { PayloadEmailAdapter, SendEmailOptions } from 'payload'
import { getSiteIntegrations } from '../payload/index.js'
/** SMTP fields the adapter reads from SiteIntegrations. */
type SmtpIntegrations = {
smtpFromAddress?: null | string
smtpFromName?: null | string
smtpHost?: null | string
smtpPassword?: null | string
smtpPort?: null | number
smtpUser?: null | string
}
export type PanelSmtpAdapterArgs = {
/**
* Used only until the panel is filled in — Payload requires a from-address
* synchronously at boot, before any global can be read. Once SiteIntegrations
* has an address, it wins.
*/
fallbackFromAddress?: string
fallbackFromName?: string
}
/**
* Payload email adapter backed by the SMTP settings in the SiteIntegrations
* global.
*
* Why this exists: Payload builds its email adapter once, at boot, from the
* config — which would normally mean SMTP credentials living in env vars and a
* redeploy to change them. This adapter instead resolves SMTP on every send, so
* an editor can change the mailbox in the admin panel and the next email uses
* it.
*
* Wiring it into the config means `payload.sendEmail` works everywhere — which
* includes the form-builder's own submission emails (the ones an editor
* configures per form under "Emails"). Those go out over the panel's SMTP with
* no extra code.
*
* Boot-time constraints shape two details:
* - `defaultFromAddress` / `defaultFromName` must be returned synchronously, so
* they're placeholders; the real from-address is applied per message below.
* - nodemailer is imported dynamically inside sendEmail, so merely loading the
* Payload config (or running `generate:importmap`) doesn't pull it in.
*/
export const panelSmtpAdapter =
(args: PanelSmtpAdapterArgs = {}): PayloadEmailAdapter =>
({ payload }) => ({
name: 'ipal-panel-smtp',
defaultFromAddress: args.fallbackFromAddress ?? 'noreply@localhost',
defaultFromName: args.fallbackFromName ?? 'Website',
sendEmail: async (message: SendEmailOptions) => {
const smtp = await getSiteIntegrations<SmtpIntegrations>(payload)
const host = smtp.smtpHost
const port = smtp.smtpPort ?? 587
const user = smtp.smtpUser
const pass = smtp.smtpPassword
if (!host || !user || !pass) {
payload.logger.error('[ipal] Email not sent: SMTP is not configured in Site Integrations.')
return { error: 'SMTP is not configured in Site Integrations.', sent: false }
}
// The panel is the source of truth for the sender; fall back to whatever
// the caller set (Payload fills in defaultFromAddress when unset).
const fromAddress = smtp.smtpFromAddress
const fromName = smtp.smtpFromName
const from = fromAddress
? fromName
? `${fromName} <${fromAddress}>`
: fromAddress
: message.from
// Defence in depth against header injection. Modern nodemailer already
// normalises CRLF in standard headers, but the form-builder interpolates
// user data into the subject via {{field}} placeholders, so strip any
// newlines from header-bound values before they reach the transport.
const stripCRLF = (v: unknown): string =>
String(v ?? '')
.replace(/[\r\n]+/g, ' ')
.trim()
const { default: nodemailer } = await import('nodemailer')
const transporter = nodemailer.createTransport({
auth: { pass, user },
host,
port,
secure: port === 465, // implicit TLS on 465, STARTTLS otherwise
})
try {
const info = await transporter.sendMail({
...message,
from,
...(message.subject ? { subject: stripCRLF(message.subject) } : {}),
})
return { messageId: info.messageId, sent: true }
} catch (err) {
// Never surface SMTP internals to the caller — a form submission
// shouldn't fail loudly because the mailbox is misconfigured.
payload.logger.error(`[ipal] Email send failed: ${(err as Error).message}`)
return { error: 'Failed to send email.', sent: false }
}
},
})
+95
View File
@@ -0,0 +1,95 @@
import 'server-only'
import type { BasePayload } from 'payload'
import nodemailer from 'nodemailer'
import { getSiteIntegrations } from '../payload/index.js'
/** SMTP fields the sender reads from SiteIntegrations. */
type SmtpIntegrations = {
smtpFromAddress?: null | string
smtpFromName?: null | string
smtpHost?: null | string
smtpPassword?: null | string
smtpPort?: null | number
smtpUser?: null | string
}
export type SendEmailArgs = {
/** Override the configured from-address for this message. */
from?: string
/** HTML body. */
html: string
/** Payload instance — used to read SMTP config from SiteIntegrations. */
payload: BasePayload
replyTo?: string
subject: string
/** Optional plain-text fallback. */
text?: string
to: string | string[]
}
export type SendEmailResult = { error: string; sent: false } | { messageId: string; sent: true }
/**
* Sends an email using SMTP settings from the SiteIntegrations global.
*
* Reads config at call time (not at boot) so editors can change SMTP in the
* admin panel without a restart — consistent with the plugin's panel-managed
* model. `server-only` keeps the SMTP password out of any client bundle.
*
* Returns a result object rather than throwing, so callers (e.g. form
* submission) can handle failure without a try/catch and never leak SMTP
* details to the client.
*/
export async function sendEmail({
from,
html,
payload,
replyTo,
subject,
text,
to,
}: SendEmailArgs): Promise<SendEmailResult> {
const smtp = await getSiteIntegrations<SmtpIntegrations>(payload)
const host = smtp.smtpHost
const port = smtp.smtpPort ?? 587
const user = smtp.smtpUser
const pass = smtp.smtpPassword
if (!host || !user || !pass) {
return { error: 'SMTP is not configured in Site Integrations.', sent: false }
}
const fromAddress = from ?? smtp.smtpFromAddress
if (!fromAddress) {
return { error: 'No from-address configured.', sent: false }
}
const fromName = smtp.smtpFromName
const fromHeader = fromName ? `${fromName} <${fromAddress}>` : fromAddress
const transporter = nodemailer.createTransport({
auth: { pass, user },
host,
port,
secure: port === 465, // implicit TLS on 465, STARTTLS otherwise
})
try {
const info = await transporter.sendMail({
from: fromHeader,
html,
subject,
to,
...(text ? { text } : {}),
...(replyTo ? { replyTo } : {}),
})
return { messageId: info.messageId, sent: true }
} catch (err) {
payload.logger.error(`[ipal] Email send failed: ${(err as Error).message}`)
return { error: 'Failed to send email.', sent: false }
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { Plugin } from 'payload'
import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'
import type { FormsOption } from './types.js'
/**
* Configures @payloadcms/plugin-form-builder from IPAL's FormsOption.
*
* Provides the form/form-submissions collections and field types. Email
* delivery is intentionally NOT handled here — the plugin's own SMTP-from-panel
* sender (submitForm) does that, so the form-builder's built-in email (which
* needs a Payload email adapter) is left unused.
*/
export function buildFormsPlugin(forms: FormsOption): Plugin {
return formBuilderPlugin({
fields: {
checkbox: true,
email: true,
message: true,
number: true,
payment: false,
select: true,
text: true,
textarea: true,
...forms.fields,
},
...(forms.redirectRelationships ? { redirectRelationships: forms.redirectRelationships } : {}),
// Client-supplied collection overrides (e.g. a per-form notification
// address). The plugin provides the hook, not the opinion about which
// extra fields a form should carry.
...(forms.formOverrides ? { formOverrides: forms.formOverrides } : {}),
...(forms.formSubmissionOverrides
? { formSubmissionOverrides: forms.formSubmissionOverrides }
: {}),
})
}
+10
View File
@@ -0,0 +1,10 @@
// buildFormsPlugin is config-time (safe anywhere). submitForm is server-only
// (Turnstile secret) — never import it from a client component.
export { buildFormsPlugin } from './formsPluginConfig.js'
export { checkRateLimit } from './rateLimit.js'
export type { RateLimitArgs } from './rateLimit.js'
export { submitForm } from './submitForm.js'
export type { SubmitFormArgs, SubmitFormResult } from './submitForm.js'
export type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption } from './types.js'
export { validateSubmission } from './validateSubmission.js'
export type { FormValidationResult } from './validateSubmission.js'
+62
View File
@@ -0,0 +1,62 @@
type Bucket = { count: number; resetAt: number }
/**
* Per-IP sliding window, in memory.
*
* Deliberately simple: no Redis, no dependency. The trade-off is that the
* counter lives in one process — with several instances behind a load balancer
* each keeps its own, so the effective limit is per-instance, not global. For a
* contact form that's fine (it raises the cost of flooding without pretending
* to be airtight); a high-security form should put a real limiter in front.
*
* State is module-level, so it survives between requests but resets on redeploy
* — acceptable for abuse throttling.
*/
const buckets = new Map<string, Bucket>()
/** Sweep expired buckets occasionally so the map doesn't grow unbounded. */
let lastSweep = Date.now()
const SWEEP_INTERVAL = 60_000
function sweep(now: number) {
if (now - lastSweep < SWEEP_INTERVAL) {return}
lastSweep = now
for (const [key, bucket] of buckets) {
if (bucket.resetAt <= now) {buckets.delete(key)}
}
}
export type RateLimitArgs = {
/** Identifier to limit on — typically the client IP. */
key: string
/** Max submissions allowed per window. Defaults to 5. */
max?: number
/** Window length in ms. Defaults to 60_000 (one minute). */
windowMs?: number
}
/**
* Returns true when the request is within the limit, false when it should be
* rejected. A missing key (no IP) is allowed through — better to accept a
* submission than to block everyone behind a proxy that strips the header.
*/
export function checkRateLimit({ key, max = 5, windowMs = 60_000 }: RateLimitArgs): boolean {
if (!key) {return true}
const now = Date.now()
sweep(now)
const bucket = buckets.get(key)
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs })
return true
}
if (bucket.count >= max) {
return false
}
bucket.count += 1
return true
}
+127
View File
@@ -0,0 +1,127 @@
import 'server-only'
import type { BasePayload } from 'payload'
import { verifyTurnstile } from '../turnstile/index.js'
import { checkRateLimit } from './rateLimit.js'
import { validateSubmission } from './validateSubmission.js'
export type SubmitFormArgs = {
/** Submitted field data — shape matches the form's fields. */
data: Record<string, unknown>
/** Form-builder form ID this submission belongs to. */
formId: string
/** Client IP — used for Turnstile and rate limiting. */
ip?: string
/**
* Rate limit: max submissions per IP per minute. Defaults to 5.
* Set to 0 to disable (e.g. when a real limiter sits in front).
*/
maxPerMinute?: number
payload: BasePayload
/** Turnstile token; when present it is verified, when absent it is skipped. */
turnstileToken?: string
}
/**
* Why a submission failed — a code, never a user-facing string.
*
* The plugin knows what went wrong; it deliberately doesn't decide how to say
* it. The frontend maps these to its own copy, in its own language, and renders
* whatever component fits — a field error, a toast, a full message. The plugin
* has no business choosing the wording or the locale.
*
* - `rate_limited` — too many submissions from this IP
* - `turnstile` — bot check failed
* - `validation` — a field is missing/too long, or unknown keys were sent;
* `field` and `kind` narrow it down when a specific field is
* at fault (absent for whole-payload problems like unknown keys)
* - `not_found` — no form with this id
* - `error` — persistence failed unexpectedly
*/
export type SubmitFailure =
| {
/** The offending field's name, when one field is at fault. */
field?: string
/** What was wrong with it. */
kind?: 'required' | 'too_long' | 'unknown_fields'
reason: 'validation'
success: false
}
| { reason: 'error'; success: false }
| { reason: 'not_found'; success: false }
| { reason: 'rate_limited'; success: false }
| { reason: 'turnstile'; success: false }
export type SubmitFormResult = { submissionId: number | string; success: true } | SubmitFailure
/**
* Handles a form submission end to end: rate limit, verify Turnstile, validate
* against the form's own schema, then store.
*
* The order is cost-ascending on purpose — the cheapest checks reject first, so
* a flood never reaches Turnstile's network call or the database.
*
* Emails aren't sent here. The form-builder sends whatever an editor configured
* under the form's "Emails" tab (form-submissions hook → payload.sendEmail),
* which goes out over panelSmtpAdapter. Storing the submission is enough.
*
* server-only: touches the Turnstile secret.
*/
export async function submitForm({
data,
formId,
ip,
maxPerMinute = 5,
payload,
turnstileToken,
}: SubmitFormArgs): Promise<SubmitFormResult> {
// 1. Rate limit — cheapest gate, drops a flood before any real work.
if (maxPerMinute > 0 && ip) {
if (!checkRateLimit({ key: ip, max: maxPerMinute })) {
return { reason: 'rate_limited', success: false }
}
}
// 2. Turnstile — verify when a token is supplied; reject on failure.
if (turnstileToken !== undefined) {
const ok = await verifyTurnstile({ ip, payload, token: turnstileToken })
if (!ok) {
return { reason: 'turnstile', success: false }
}
}
// 3. Validate against the form's schema. A public endpoint can't trust the
// shape of `data` — drop unknown keys, enforce required, cap length.
const validation = await validateSubmission(payload, formId, data)
if (!validation.ok) {
if (validation.reason === 'not_found') {
return { reason: 'not_found', success: false }
}
return {
reason: 'validation',
success: false,
...(validation.field ? { field: validation.field } : {}),
...(validation.kind ? { kind: validation.kind } : {}),
}
}
// 4. Persist (form-builder shape: submissionData array). Triggers the email
// hook. Only validated, known fields are stored.
try {
const submission = await payload.create({
collection: 'form-submissions',
data: {
form: formId,
submissionData: Object.entries(validation.cleaned).map(([field, value]) => ({
field,
value: value == null ? '' : String(value),
})),
} as never,
})
return { submissionId: submission.id, success: true }
} catch (err) {
payload.logger.error(`[ipal] Form submission failed: ${(err as Error).message}`)
return { reason: 'error', success: false }
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { CollectionConfig, Field } from 'payload'
/**
* Receives the collection's default fields and returns the final list — add,
* remove, or reorder. Same shape the form-builder uses.
*/
export type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]
/**
* Overrides for a forms-related collection: replace the fields and/or any
* other collection setting (admin, access, hooks…).
*/
export type FormsCollectionOverrides = {
fields?: FormsFieldsOverride
} & Partial<Omit<CollectionConfig, 'fields'>>
/**
* Forms configuration — mirrors the fields a client enables in the
* form-builder plugin. Kept minimal; the plugin passes these through.
*/
export type FormsOption = {
/** Field types available in the form builder. Sensible defaults applied. */
fields?: {
checkbox?: boolean
email?: boolean
message?: boolean
number?: boolean
payment?: boolean
select?: boolean
text?: boolean
textarea?: boolean
}
/**
* Override the forms collection. The plugin stays opinion-free about what a
* form needs beyond its fields — a client that wants, say, a per-form
* notification address adds it here:
*
* formOverrides: {
* fields: ({ defaultFields }) => [
* ...defaultFields,
* { name: 'notificationEmail', type: 'email' },
* ],
* }
*/
formOverrides?: FormsCollectionOverrides
/** Override the form-submissions collection (same shape). */
formSubmissionOverrides?: FormsCollectionOverrides
/** Collections a form can redirect to (e.g. ['pages']). */
redirectRelationships?: string[]
}
+105
View File
@@ -0,0 +1,105 @@
import type { BasePayload } from 'payload'
/** A form-builder field, trimmed to what validation needs. */
type FormField = {
blockType?: string
label?: string
name?: string
required?: boolean | null
}
type FormDoc = {
fields?: FormField[]
id: number | string
/** Per-form notification address, when the client added the field. */
notificationEmail?: string
title?: string
}
/**
* Validation outcome — codes, not user-facing strings. The frontend turns these
* into its own copy (see SubmitFailure in submitForm).
*/
export type FormValidationResult =
| {
/** Offending field, when a single field is at fault. */
field?: string
kind: 'required' | 'too_long' | 'unknown_fields'
ok: false
reason: 'invalid'
}
| { cleaned: Record<string, unknown>; form: FormDoc; ok: true }
| { ok: false; reason: 'not_found' }
/** Field block types that don't carry a submittable value. */
const NON_DATA_BLOCKS = new Set(['message'])
/** Hard ceiling on a single field's length, independent of the form config. */
const MAX_FIELD_LENGTH = 5000
/**
* Checks submitted data against the form's own definition, rather than trusting
* whatever arrived.
*
* The server action is a public endpoint: a caller can skip the rendered form
* and post arbitrary keys. Without this, unknown fields would be stored,
* required fields could be missing, and an oversized value could sail through.
* So we load the form, keep only keys that are real fields, reject when a
* required one is blank, and cap length.
*
* Returns the loaded form on success so the caller doesn't fetch it twice, and
* a code + offending field on failure so the frontend can point at it.
*/
export async function validateSubmission(
payload: BasePayload,
formId: string,
data: Record<string, unknown>,
): Promise<FormValidationResult> {
let form: FormDoc
try {
form = (await payload.findByID({
id: formId,
collection: 'forms',
depth: 0,
})) as FormDoc
} catch {
return { ok: false, reason: 'not_found' }
}
const fields = (form.fields ?? []).filter(
(f): f is { name: string } & FormField =>
typeof f.name === 'string' && !NON_DATA_BLOCKS.has(f.blockType ?? ''),
)
const known = new Map(fields.map((f) => [f.name, f]))
const cleaned: Record<string, unknown> = {}
for (const field of fields) {
const value = data[field.name]
const isBlank =
value == null || (typeof value === 'string' && value.trim() === '') || value === false
if (field.required && isBlank) {
return { field: field.name, kind: 'required', ok: false, reason: 'invalid' }
}
if (typeof value === 'string' && value.length > MAX_FIELD_LENGTH) {
return { field: field.name, kind: 'too_long', ok: false, reason: 'invalid' }
}
// Only carry through keys that belong to the form — unknown keys from a
// hand-crafted request are dropped, not stored.
if (value !== undefined) {
cleaned[field.name] = value
}
}
// Reject outright if the payload carried keys the form doesn't define — a
// sign the request wasn't produced by the rendered form.
const unknownKeys = Object.keys(data).filter((k) => !known.has(k))
if (unknownKeys.length > 0) {
return { kind: 'unknown_fields', ok: false, reason: 'invalid' }
}
return { cleaned, form, ok: true }
}
@@ -0,0 +1,93 @@
import type { BasePayload, SanitizedConfig } from 'payload'
import { getPayload } from 'payload'
import { cache } from 'react'
import type { ContentOption, ResolvedRoute } from '../content/index.js'
import { resolveRoute as resolveRouteRaw } from '../content/index.js'
type CreateContentHelpersArgs = {
/**
* The client's payload config promise (the default export of payload.config).
* Passed in because the plugin never imports the client's config directly.
*/
config: Promise<SanitizedConfig> | SanitizedConfig
/** Archive-backed collections, same value as the plugin option. */
content?: ContentOption
/** Pages collection slug. Defaults to 'pages'. */
pagesSlug?: string
/** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string
}
/**
* 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).
*
* 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
* Next runs generateMetadata and the page component separately, and both hit
* these. Crucially the Payload instance is cached *here*, once, so every helper
* shares it; that's why this is a factory and not loose functions importing a
* shared module.
*
* ```ts
* // src/lib/content.ts
* import { createContentHelpers } from 'ipal-kit'
* import config from '@/payload.config'
* import { contentConfig } from '@/content.config'
*
* export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute } =
* createContentHelpers({ config, content: contentConfig })
* ```
*/
export function createContentHelpers({
config,
content,
pagesSlug = 'pages',
settingsSlug = 'site-settings',
}: CreateContentHelpersArgs) {
const getCachedPayload = cache(async (): Promise<BasePayload> =>
getPayload({ config: await config }),
)
const getConfiguredLocales = cache(async (): Promise<string[]> => {
const c = await config
return c.localization ? c.localization.locales.map((l) => l.code) : []
})
const getSettings = cache(async (locale: string) => {
const payload = await getCachedPayload()
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).
*/
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 { getCachedPayload, getConfiguredLocales, getSettings, resolveRoute }
}
+1
View File
@@ -0,0 +1 @@
export { createContentHelpers } from './createContentHelpers.js'
+56
View File
@@ -0,0 +1,56 @@
import type { LocalizedSlugs } from './localizedPath.js'
import type { I18nConfig } from './types.js'
import { isValidLocale } from './helpers.js'
/**
* A localized field as Payload returns it when queried with `locale: 'all'`:
* a map of locale code → value.
*/
type LocalizedField = Record<string, unknown>
type GetLocalizedSlugsArgs = {
config: I18nConfig
/**
* The document's localized slug field, as returned by Payload with
* `locale: 'all'` — e.g. { pl: 'strona-glowna', en: 'home' }.
*/
slugField: LocalizedField | null | undefined
}
/**
* Normalizes a document's localized slug field into the LocalizedSlugs
* contract consumed by buildLocalizedPath / switchLocalePath.
*
* Pure function — the template fetches the document (payload.findByID with
* `locale: 'all'`, where the slug field must be `localized: true`) and passes
* the raw field in. The plugin never touches the database.
*
* Keeps only entries whose locale is configured and whose slug is a
* non-empty string, so callers get a clean, trustworthy map.
*
* @example
* const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
* const slugs = getLocalizedSlugs({ slugField: doc.slug, config })
* // → { pl: 'strona-glowna', en: 'home' }
* switchLocalePath({ slugs, targetLocale: 'en', config }) // → '/en'
*/
export function getLocalizedSlugs({ config, slugField }: GetLocalizedSlugsArgs): LocalizedSlugs {
const result: LocalizedSlugs = {}
if (!slugField || typeof slugField !== 'object') {
return result
}
for (const [locale, value] of Object.entries(slugField)) {
if (!isValidLocale(locale, config)) {continue}
if (typeof value !== 'string') {continue}
const slug = value.trim()
if (!slug) {continue}
result[locale] = slug
}
return result
}
+32
View File
@@ -0,0 +1,32 @@
import type { I18nConfig, LocaleDefinition } from './types.js'
/**
* Returns all configured locale codes.
*/
export function getLocaleCodes(config: I18nConfig): string[] {
return config.locales.map((locale) => locale.code)
}
/**
* Returns the default locale code.
*/
export function getDefaultLocale(config: I18nConfig): string {
return config.defaultLocale
}
/**
* Checks if a string is a valid configured locale code.
*/
export function isValidLocale(code: string, config: I18nConfig): boolean {
return config.locales.some((locale) => locale.code === code)
}
/**
* Returns the full locale definition for a given code, or undefined if not found.
*/
export function getLocaleDefinition(
code: string,
config: I18nConfig,
): LocaleDefinition | undefined {
return config.locales.find((locale) => locale.code === code)
}
+10
View File
@@ -0,0 +1,10 @@
export { getLocalizedSlugs } from './getLocalizedSlugs.js'
export { getDefaultLocale, getLocaleCodes, getLocaleDefinition, isValidLocale } from './helpers.js'
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './localeMiddleware.js'
export type { LocaleMiddlewareResult } from './localeMiddleware.js'
export { buildLocalizationConfig } from './localizationConfig.js'
export { buildLocalizedPath, switchLocalePath } from './localizedPath.js'
export type { LocalizedSlugs } from './localizedPath.js'
export { LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale } from './negotiateLocale.js'
export type { I18nConfig, LocaleDefinition } from './types.js'
export { validateI18nConfig } from './validation.js'
+98
View File
@@ -0,0 +1,98 @@
import type { I18nConfig } from './types.js'
import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js'
/**
* Minimal request shape the middleware reads. Kept structural so the plugin
* doesn't hard-depend on next/server types; a Next.js `NextRequest` satisfies it.
*/
type MiddlewareRequest = {
cookies: { get: (name: string) => { value: string } | undefined }
headers: { get: (name: string) => null | string }
nextUrl: { clone: () => URL; pathname: string; search: string }
url: string
}
/**
* What the factory returns — the caller (in Next next-middleware.ts) decides how to
* act: `redirect` means send a 307 to `location` and set the locale cookie;
* `next` means let the request pass through untouched.
*/
export type LocaleMiddlewareResult =
{ cookie: { name: string; value: string }; location: string; type: 'redirect' } | { type: 'next' }
type CreateLocaleMiddlewareArgs = {
config: I18nConfig
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
cookieName?: string
}
/**
* First path segment of a URL pathname, or '' for root.
* '/pl/o-nas' → 'pl', '/o-nas' → 'o-nas', '/' → ''.
*/
function firstSegment(pathname: string): string {
return pathname.split('/').filter(Boolean)[0] ?? ''
}
/**
* Builds locale-routing logic for Next.js middleware.
*
* Behavior:
* - path already starts with a valid locale (/pl/...) → pass through
* - any other path (/, /o-nas) → redirect to /{locale}{path}, where locale
* comes from negotiateLocale (cookie → Accept-Language → default)
* - the chosen locale is written to a cookie so the next visit is stable
*
* The plugin returns a decision; the thin next-middleware.ts in the client project
* turns it into a NextResponse. This keeps all logic in the plugin while
* respecting that next-middleware.ts must physically live in the client app.
*
* @example
* // next-middleware.ts (client project) — one wiring file, no logic:
* import { NextResponse } from 'next/server'
* import { localeMiddleware } from './ipal.middleware' // created from this factory
* export function middleware(req) {
* const r = localeMiddleware(req)
* if (r.type === 'next') return NextResponse.next()
* const res = NextResponse.redirect(r.location)
* res.cookies.set(r.cookie.name, r.cookie.value)
* return res
* }
*/
export function createLocaleMiddleware({
config,
cookieName = LOCALE_COOKIE_NAME,
}: CreateLocaleMiddlewareArgs) {
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
const { pathname } = request.nextUrl
// Already locale-prefixed → nothing to do
if (isValidLocale(firstSegment(pathname), config)) {
return { type: 'next' }
}
// Resolve the locale to use
const locale = negotiateLocale({
acceptLanguage: request.headers.get('accept-language'),
config,
cookieLocale: request.cookies.get(cookieName)?.value ?? null,
})
// Redirect to the locale-prefixed path, preserving the rest
const url = request.nextUrl.clone()
url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`
return {
type: 'redirect',
cookie: { name: cookieName, value: locale },
location: url.toString(),
}
}
}
/**
* Default Next.js middleware matcher: run on everything except API routes, the
* admin panel, Next internals, and files with an extension (static assets).
*/
export const DEFAULT_MIDDLEWARE_MATCHER = ['/((?!api|admin|_next|.*\\..*).*)']
+23
View File
@@ -0,0 +1,23 @@
import type { Config } from 'payload'
import type { I18nConfig } from './types.js'
type PayloadLocalizationConfig = NonNullable<Config['localization']>
/**
* Transforms validated i18n config into Payload's localization object.
* Pure function — no side effects, no validation (caller validates first).
*/
export function buildLocalizationConfig(config: I18nConfig): PayloadLocalizationConfig {
const { defaultLocale, fallback = true, locales } = config
return {
defaultLocale,
fallback,
locales: locales.map((locale) => ({
code: locale.code,
label: locale.label,
...(locale.rtl && { rtl: true }),
})),
}
}
+122
View File
@@ -0,0 +1,122 @@
import type { I18nConfig } from './types.js'
import { isValidLocale } from './helpers.js'
/**
* Minimal shape the path builder needs from a document.
*
* The plugin doesn't know the client's Pages type, so it depends only on
* this contract: a map of locale code → slug for that locale. The template
* supplies it (e.g. by reading the localized slug field across locales).
*/
export type LocalizedSlugs = Record<string, string>
type BuildPathArgs = {
config: I18nConfig
/**
* Slug that represents the site root (served at /{locale} with no trailing
* segment). Defaults to 'home'. Matched against the slug in the target locale.
*/
homeSlug?: string
/** Target locale to build the path for */
locale: string
/**
* Localized segment the document lives under, e.g.
* `{ pl: 'artykuly', en: 'articles' }` → /pl/artykuly/moj-post.
*
* These are the slugs of the collection's archive page, so the prefix is
* whatever an editor named that page — and it differs per locale for free.
* A document under a prefix is never the home page, so homeSlug is ignored.
*/
prefix?: LocalizedSlugs
/** slug per locale, e.g. { pl: 'strona-glowna', en: 'home' } */
slugs: LocalizedSlugs
}
/**
* Builds a locale-prefixed path for a document in a target locale.
*
* Always prefixes the locale: /{locale} or /{locale}/{slug}. The home slug
* collapses to the locale root. Returns undefined if the document has no slug
* in the target locale (caller decides fallback behavior).
*
* @example
* buildLocalizedPath({ slugs: { pl: 'strona-glowna', en: 'home' }, locale: 'en', config })
* // → '/en' (home slug collapses to root)
*
* buildLocalizedPath({ slugs: { pl: 'o-nas', en: 'about' }, locale: 'en', config })
* // → '/en/about'
*
* buildLocalizedPath({
* slugs: { pl: 'moj-post', en: 'my-post' },
* prefix: { pl: 'artykuly', en: 'articles' },
* locale: 'en',
* config,
* })
* // → '/en/articles/my-post'
*/
export function buildLocalizedPath({
config,
homeSlug = 'home',
locale,
prefix,
slugs,
}: BuildPathArgs): string | undefined {
if (!isValidLocale(locale, config)) {
return undefined
}
const slug = slugs[locale]
if (!slug) {
return undefined
}
if (prefix) {
const segment = prefix[locale]
// No archive slug in this locale means the entry is unreachable there —
// there's no path to point at, so hreflang should omit it rather than
// invent /en/moj-post.
if (!segment) {
return undefined
}
return `/${locale}/${segment}/${slug}`
}
if (slug === homeSlug) {
return `/${locale}`
}
return `/${locale}/${slug}`
}
type SwitchLocaleArgs = {
config: I18nConfig
homeSlug?: string
prefix?: LocalizedSlugs
slugs: LocalizedSlugs
targetLocale: string
}
/**
* Resolves the equivalent path for the same document in a different locale —
* the language-switcher use case (/pl/strona-glowna → /en/home).
*
* Never dead-ends on a 404. When the document has no slug in the target locale,
* falls back to the archive it belongs to (/en/articles) if there is one, and
* to the locale root otherwise — the closest place the visitor would want.
*/
export function switchLocalePath({
config,
homeSlug = 'home',
prefix,
slugs,
targetLocale,
}: SwitchLocaleArgs): string {
const path = buildLocalizedPath({ config, homeSlug, locale: targetLocale, prefix, slugs })
if (path) {return path}
const archiveSegment = prefix?.[targetLocale]
if (archiveSegment) {return `/${targetLocale}/${archiveSegment}`}
return `/${targetLocale}`
}
+93
View File
@@ -0,0 +1,93 @@
import type { I18nConfig } from './types.js'
import { getLocaleCodes, isValidLocale } from './helpers.js'
/** Cookie name the template uses to persist a visitor's locale choice. */
export const LOCALE_COOKIE_NAME = 'ipal-locale'
type NegotiateLocaleArgs = {
/** Raw Accept-Language header value */
acceptLanguage?: null | string
config: I18nConfig
/** Value of the locale cookie, if present (from LOCALE_COOKIE_NAME) */
cookieLocale?: null | string
}
/**
* Resolves which locale to serve, in priority order:
* 1. Cookie (explicit prior choice)
* 2. Accept-Language header (best match against configured locales)
* 3. Configured default locale
*
* Pure function — the template feeds it request data and acts on the result
* (redirect, cookie set). No Next.js or request objects here.
*/
export function negotiateLocale({
acceptLanguage,
config,
cookieLocale,
}: NegotiateLocaleArgs): string {
// 1. Explicit prior choice wins
if (cookieLocale && isValidLocale(cookieLocale, config)) {
return cookieLocale
}
// 2. Best match from Accept-Language
const fromHeader = matchAcceptLanguage(acceptLanguage, config)
if (fromHeader) {
return fromHeader
}
// 3. Fall back to configured default
return config.defaultLocale
}
/**
* Parses an Accept-Language header and returns the best-matching configured
* locale, or undefined if none match.
*
* Matches on the primary subtag (e.g. "en-US" matches configured "en"),
* respecting the header's quality-value ordering.
*/
export function matchAcceptLanguage(
acceptLanguage: null | string | undefined,
config: I18nConfig,
): string | undefined {
if (!acceptLanguage) {return undefined}
const available = getLocaleCodes(config)
const ranked = parseAcceptLanguage(acceptLanguage)
for (const tag of ranked) {
// Exact match (e.g. "pt-BR" === "pt-BR")
const exact = available.find((code) => code.toLowerCase() === tag)
if (exact) {return exact}
// Primary-subtag match (e.g. "en-us" → "en")
const primary = tag.split('-')[0]
const partial = available.find((code) => code.toLowerCase().split('-')[0] === primary)
if (partial) {return partial}
}
return undefined
}
/**
* Parses an Accept-Language header into locale tags ordered by descending
* quality value. Tags are lowercased for comparison.
*
* "en-US,en;q=0.9,pl;q=0.8" → ["en-us", "en", "pl"]
*/
function parseAcceptLanguage(header: string): string[] {
return header
.split(',')
.map((part) => {
const [tag, ...params] = part.trim().split(';')
const qParam = params.find((p) => p.trim().startsWith('q='))
const quality = qParam ? parseFloat(qParam.split('=')[1]) : 1
return { quality: Number.isNaN(quality) ? 0 : quality, tag: tag.trim().toLowerCase() }
})
.filter((entry) => entry.tag && entry.tag !== '*')
.sort((a, b) => b.quality - a.quality)
.map((entry) => entry.tag)
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Single locale definition provided by the client project.
*/
export type LocaleDefinition = {
/** BCP-47 language code, e.g. 'pl', 'en', 'de' */
code: string
/** Human-readable label, e.g. 'Polski', 'English' */
label: string
/** Right-to-left script (defaults to false) */
rtl?: boolean
}
/**
* Full i18n configuration passed through plugin options.
*/
export type I18nConfig = {
defaultLocale: string
/** Enable locale fallback when content is missing (defaults to true) */
fallback?: boolean
locales: [LocaleDefinition, ...LocaleDefinition[]]
}
+43
View File
@@ -0,0 +1,43 @@
import type { I18nConfig } from './types.js'
const LOCALE_CODE_PATTERN = /^[a-z]{2,3}(-[A-Z]{2})?$/
/**
* Validates i18n config at plugin initialization.
* Throws descriptive errors — fail fast, no silent defaults.
*/
export function validateI18nConfig(config: I18nConfig): void {
const { defaultLocale, locales } = config
if (!locales?.length) {
throw new Error('[ipal] i18n: "locales" must contain at least one locale.')
}
const codes = new Set<string>()
for (const locale of locales) {
if (!locale.code || !locale.label) {
throw new Error(
`[ipal] i18n: Every locale must have "code" and "label". Received: ${JSON.stringify(locale)}`,
)
}
if (!LOCALE_CODE_PATTERN.test(locale.code)) {
throw new Error(
`[ipal] i18n: Invalid locale code "${locale.code}". Expected format: "pl", "en", "pt-BR".`,
)
}
if (codes.has(locale.code)) {
throw new Error(`[ipal] i18n: Duplicate locale code "${locale.code}".`)
}
codes.add(locale.code)
}
if (!codes.has(defaultLocale)) {
throw new Error(
`[ipal] i18n: defaultLocale "${defaultLocale}" not found in locales [${[...codes].join(', ')}].`,
)
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { I18nConfig } from '../i18n/index.js'
import type { SystemPageRole } from './types.js'
import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'
/**
* The shape we need from an assigned system-page document: its localized slug
* field, as returned by Payload when the parent is read with `locale: 'all'`.
*
* The plugin doesn't know the client's Pages type, so it depends only on this
* minimal contract.
*/
type AssignedPage = {
slug?: null | Record<string, unknown>
}
type GetSystemPagePathArgs = {
config: I18nConfig
/**
* Slug that represents the site root. Defaults to 'home'. When the assigned
* page's slug in the target locale equals this, the path collapses to the
* locale root (/pl, /en).
*/
homeSlug?: string
/** Target locale to build the path for. */
locale: string
/**
* The resolved system-page assignment from SiteSettings — the related
* document object (not just an ID), read with `locale: 'all'` so its slug
* is a locale→value map. Pass null/undefined if the role is unassigned.
*/
page: AssignedPage | null | undefined
}
/**
* Resolves a system-page assignment to a locale-aware path.
*
* Bridges the Pages module (which document plays a role) and the i18n module
* (how that document's localized slug becomes a URL). This is what powers
* "visit /pl → serve the homepage": read SiteSettings.homepage, pass the
* related document here, get /pl/strona-glowna (or /pl if it's the home slug).
*
* Returns undefined when the role is unassigned or the assigned page has no
* slug in the target locale — the caller decides the fallback (e.g. 404,
* redirect to default locale).
*
* @example
* const settings = await payload.findGlobal({ slug: 'site-settings', locale: 'all', depth: 1 })
* getSystemPagePath({ page: settings.homepage, locale: 'pl', config })
* // → '/pl' (home slug collapses to root)
* getSystemPagePath({ page: settings.privacyPolicy, locale: 'en', config })
* // → '/en/privacy-policy'
*/
export function getSystemPagePath({
config,
homeSlug = 'home',
locale,
page,
}: GetSystemPagePathArgs): string | undefined {
if (!page?.slug) {
return undefined
}
const slugs = getLocalizedSlugs({ config, slugField: page.slug })
return buildLocalizedPath({ config, homeSlug, locale, slugs })
}
+4
View File
@@ -0,0 +1,4 @@
export { getSystemPagePath } from './getSystemPagePath.js'
export { buildSystemPagesFields } from './systemPagesFields.js'
export type { PagesOption, SystemPageRole } from './types.js'
export { ALL_SYSTEM_PAGE_ROLES } from './types.js'
+43
View File
@@ -0,0 +1,43 @@
import type { Field, RelationshipField } from 'payload'
import type { PagesOption, SystemPageRole } from './types.js'
import { ALL_SYSTEM_PAGE_ROLES } from './types.js'
/** Admin-facing labels per role. */
const ROLE_LABELS: Record<SystemPageRole, string> = {
cookiePolicy: 'Cookie Policy',
homepage: 'Homepage',
privacyPolicy: 'Privacy Policy',
}
/** Admin descriptions per role. */
const ROLE_DESCRIPTIONS: Record<SystemPageRole, string> = {
cookiePolicy: 'Page linked from the cookie consent banner.',
homepage: 'Page served at the locale root (e.g. /pl, /en).',
privacyPolicy: 'Page linked as the privacy policy.',
}
/**
* Builds one relationship field per configured system-page role.
*
* Each field points at the client's Pages collection (via the provided slug)
* and holds a single document reference. The referenced document's slug is
* localized, so the same assignment resolves to /pl/strona-glowna and
* /en/home at runtime — role assignment is locale-agnostic, path resolution
* is locale-aware (see getSystemPagePath).
*/
export function buildSystemPagesFields(pages: PagesOption): Field[] {
const roles = pages.roles?.length ? pages.roles : [...ALL_SYSTEM_PAGE_ROLES]
return roles.map((role): RelationshipField => ({
name: role,
type: 'relationship',
admin: {
description: ROLE_DESCRIPTIONS[role],
},
label: ROLE_LABELS[role],
maxDepth: 1,
relationTo: pages.slug,
}))
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Built-in "system page" roles — documents from the client's Pages
* collection that the site references by function rather than by slug.
*/
export type SystemPageRole = 'cookiePolicy' | 'homepage' | 'privacyPolicy'
/**
* All system roles in a stable order (used when no subset is configured).
*/
export const ALL_SYSTEM_PAGE_ROLES: readonly SystemPageRole[] = [
'homepage',
'privacyPolicy',
'cookiePolicy',
]
/**
* Plugin option enabling system-page assignments in SiteSettings.
*
* The plugin doesn't own the Pages collection — the client creates it in
* their own project. This option hands the plugin the collection's slug so
* it can build relationship fields pointing at it. No hardcoding: the slug
* always comes from the client.
*/
export type PagesOption = {
/**
* Which system roles to expose as assignable fields.
* Defaults to all roles when omitted.
*/
roles?: SystemPageRole[]
/** Slug of the client's Pages collection, e.g. 'pages'. */
slug: string
}
+29
View File
@@ -0,0 +1,29 @@
import type { BasePayload } from 'payload'
/** Locale argument accepted by the global helpers. */
export type GlobalQueryOptions = {
/** Relationship population depth. Defaults to Payload's config default. */
depth?: number
/** Locale to fetch, or 'all' for every locale's values. Defaults to Payload's default. */
locale?: string
}
/**
* Thin wrapper over payload.findGlobal that keeps locale/depth handling in one
* place. The plugin never calls getPayload itself — the caller passes the
* instance (from getPayload in their app, or req.payload in a hook), matching
* the plugin's data-access rule: logic here, instance from outside.
*/
export async function getGlobal<T = Record<string, unknown>>(
payload: BasePayload,
slug: string,
options: GlobalQueryOptions = {},
): Promise<T> {
const result = await payload.findGlobal({
slug,
...(options.locale ? { locale: options.locale as never } : {}),
...(typeof options.depth === 'number' ? { depth: options.depth } : {}),
})
return result as T
}
@@ -0,0 +1,31 @@
import type { BasePayload } from 'payload'
import type { GlobalQueryOptions } from './getGlobal.js'
import { getGlobal } from './getGlobal.js'
/** Slug of the SiteIntegrations global defined by the plugin. */
export const SITE_INTEGRATIONS_SLUG = 'site-integrations'
/**
* Fetches the SiteIntegrations global.
*
* This global is admin-only through access control, but the Local API bypasses
* access control by default (overrideAccess: true), so server-side callers get
* the secrets they need (SMTP password, Turnstile secret, R2 keys). Never
* expose the raw result to the client — read the specific values you need
* server-side and pass only what's safe to the browser.
*
* Generic over the return type so the client can pass their generated
* `SiteIntegration` type.
*
* @example
* import type { SiteIntegration } from '@/payload-types'
* const integrations = await getSiteIntegrations<SiteIntegration>(payload)
*/
export function getSiteIntegrations<T = Record<string, unknown>>(
payload: BasePayload,
options?: GlobalQueryOptions,
): Promise<T> {
return getGlobal<T>(payload, SITE_INTEGRATIONS_SLUG, options)
}
+25
View File
@@ -0,0 +1,25 @@
import type { BasePayload } from 'payload'
import type { GlobalQueryOptions } from './getGlobal.js'
import { getGlobal } from './getGlobal.js'
/** Slug of the SiteSettings global defined by the plugin. */
export const SITE_SETTINGS_SLUG = 'site-settings'
/**
* Fetches the SiteSettings global.
*
* Generic over the return type so the client can pass their generated
* `SiteSetting` type for full type safety, while still working without it.
*
* @example
* import type { SiteSetting } from '@/payload-types'
* const settings = await getSiteSettings<SiteSetting>(payload, { locale: 'pl' })
*/
export function getSiteSettings<T = Record<string, unknown>>(
payload: BasePayload,
options?: GlobalQueryOptions,
): Promise<T> {
return getGlobal<T>(payload, SITE_SETTINGS_SLUG, options)
}
+4
View File
@@ -0,0 +1,4 @@
export type { GlobalQueryOptions } from './getGlobal.js'
export { getGlobal } from './getGlobal.js'
export { getSiteIntegrations, SITE_INTEGRATIONS_SLUG } from './getSiteIntegrations.js'
export { getSiteSettings, SITE_SETTINGS_SLUG } from './getSiteSettings.js'
+58
View File
@@ -0,0 +1,58 @@
import type { CollectionBeforeChangeHook } from 'payload'
/**
* Maps document fields to SEO meta fields for auto-fill.
* Defaults match the Payload website template (title → meta.title).
*/
export type AutoFillMapping = {
/** Document field used to fill meta.description when empty. */
description?: string
/** Document field used to fill meta.title when empty. Default: 'title'. */
title?: string
}
/**
* Reads a possibly-localized field value into a plain string.
* With localized fields at this hook stage the value is the active-locale
* string, so we just coerce defensively.
*/
function readString(value: unknown): string | undefined {
if (typeof value === 'string' && value.trim()) {return value.trim()}
return undefined
}
/**
* Builds a beforeChange hook that fills empty SEO meta fields from document
* content. Only fills when the meta field is blank — never overwrites what an
* editor typed. This runs server-side on every create/update, so editors get
* sensible meta without clicking "auto-generate".
*
* The plugin doesn't know the client's collection shape, so the field mapping
* is configurable; defaults follow the website template.
*/
export function buildAutoFillMetaHook(mapping: AutoFillMapping = {}): CollectionBeforeChangeHook {
const titleField = mapping.title ?? 'title'
const descriptionField = mapping.description
return ({ data }) => {
if (!data) {return data}
// plugin-seo stores meta as a group under `meta`
const meta = (data.meta as Record<string, unknown> | undefined) ?? {}
// Fill meta.title from the document title when empty
if (!readString(meta.title)) {
const sourceTitle = readString(data[titleField])
if (sourceTitle) {meta.title = sourceTitle}
}
// Fill meta.description from a configured field when empty
if (descriptionField && !readString(meta.description)) {
const sourceDescription = readString(data[descriptionField])
if (sourceDescription) {meta.description = sourceDescription}
}
data.meta = meta
return data
}
}
+122
View File
@@ -0,0 +1,122 @@
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
import type { TitleOrder } from './composeTitle.js'
import type { SeoMeta } from './types.js'
import { buildLocalizedPath } from '../i18n/index.js'
import { composeTitle } from './composeTitle.js'
import { buildHreflangAlternates } from './hreflang.js'
/**
* Subset of Next.js `Metadata` this helper produces. Kept local so the plugin
* doesn't depend on `next` types; the shape is assignable to Next's Metadata.
*/
export type PageMetadata = {
alternates?: {
canonical?: string
languages?: Record<string, string>
}
description?: string
openGraph?: {
description?: string
images?: { url: string }[]
locale?: string
title: string
}
title: string
}
type BuildMetadataArgs = {
/** Absolute site origin, e.g. 'https://example.com'. */
baseUrl?: string
config: I18nConfig
/** Home slug that collapses to the locale root. Defaults to 'home'. */
homeSlug?: string
/** Resolved OG image URL (page image or site defaultShareImage). */
imageUrl?: null | string
/** Current locale being rendered. */
locale: string
/** SEO meta from the document (plugin-seo group). */
meta?: null | SeoMeta
/** Page title or site name first. Defaults to 'page-first'. */
order?: TitleOrder
/**
* Localized segment the document lives under (an archive page's slugs).
* Feeds both canonical and hreflang, so /pl/artykuly/moj-post and
* /en/articles/my-post point at each other correctly.
*/
prefix?: LocalizedSlugs
/**
* Query string appended to canonical and every hreflang, e.g. '?page=2'.
*
* A paginated listing must be canonical to itself — pointing page 2 at page 1
* tells Google the entries on it don't exist. Alternates carry the same page,
* since /pl/artykuly?page=2 corresponds to /en/articles?page=2.
*/
query?: string
/** Separator between page title and site name. Defaults to ' | '. */
separator?: string
/** Site name for title composition and OG. */
siteName?: null | string
/** slug per locale for this document — drives canonical + hreflang. */
slugs: LocalizedSlugs
}
/**
* Assembles a Next.js-compatible Metadata object from document SEO fields and
* site-level data. Locale-aware: canonical points at the current locale's
* path, and hreflang alternates cover every locale the document exists in.
*
* Designed for use inside Next.js `generateMetadata`. The caller resolves the
* pieces (meta group, site name, image URL, localized slugs) and passes them
* in — the plugin composes, it doesn't fetch.
*/
export function buildMetadata({
baseUrl,
config,
homeSlug = 'home',
imageUrl,
locale,
meta,
order,
prefix,
query,
separator,
siteName,
slugs,
}: BuildMetadataArgs): PageMetadata {
// titleOverride wins outright: an editor who filled it in wants that exact
// string in the tab, not a composition.
const override = meta?.titleOverride?.trim()
const title = override || composeTitle({ order, pageTitle: meta?.title, separator, siteName })
const description = meta?.description?.trim() || undefined
const origin = baseUrl?.replace(/\/$/, '') ?? ''
const suffix = query ?? ''
const currentPath = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
const canonical = currentPath ? `${origin}${currentPath}${suffix}` : undefined
const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })
if (suffix) {
for (const code of Object.keys(languages)) {
languages[code] = `${languages[code]}${suffix}`
}
}
const images = imageUrl ? [{ url: imageUrl }] : undefined
return {
title,
...(description && { description }),
alternates: {
...(canonical && { canonical }),
...(Object.keys(languages).length > 0 && { languages }),
},
openGraph: {
title,
...(description && { description }),
...(images && { images }),
locale,
},
}
}
+39
View File
@@ -0,0 +1,39 @@
/** Which part comes first in a composed title. */
export type TitleOrder = 'page-first' | 'site-first'
type ComposeTitleArgs = {
/** Defaults to 'page-first' — the page title is what a visitor scans for. */
order?: TitleOrder
/** Page-specific title, e.g. 'About Us'. */
pageTitle?: null | string
/** Separator between page title and site name. Defaults to ' | '. */
separator?: string
/** Site name, e.g. 'Acme Inc'. */
siteName?: null | string
}
/**
* Composes a full document title from a page title and the site name.
*
* - both, page-first: "About Us | Acme Inc"
* - both, site-first: "Acme Inc | About Us"
* - page only: "About Us"
* - site only: "Acme Inc"
* - neither: ""
*
* Pure function — no dependency on Payload or request state.
*/
export function composeTitle({
order = 'page-first',
pageTitle,
separator = ' | ',
siteName,
}: ComposeTitleArgs): string {
const page = pageTitle?.trim()
const site = siteName?.trim()
if (page && site) {
return order === 'site-first' ? `${site}${separator}${page}` : `${page}${separator}${site}`
}
return page || site || ''
}
+105
View File
@@ -0,0 +1,105 @@
import type { BasePayload } from 'payload'
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
import type { PageMetadata } from './buildMetadata.js'
import type { SeoMeta } from './types.js'
import { getLocalizedSlugs } from '../i18n/index.js'
import { buildMetadata } from './buildMetadata.js'
/**
* A document as needed for metadata: its SEO meta group and localized slug.
*/
type MetadataDocument = {
meta?: null | SeoMeta
slug?: null | Record<string, unknown>
}
type CreateMetadataGeneratorArgs = {
/** Absolute site origin, e.g. 'https://example.com'. */
baseUrl?: string
config: I18nConfig
/** Home slug that collapses to the locale root. Defaults to 'home'. */
homeSlug?: string
/**
* Resolves the document to build metadata for, given route params.
* The client supplies this (they own the collections and routing); it should
* fetch with `locale: 'all'` so the slug field is a locale→value map.
*/
resolveDocument: (args: {
locale: string
params: Record<string, string | string[]>
payload: BasePayload
}) => Promise<MetadataDocument | null>
/** Resolves the OG image URL for the document, if any. */
resolveImageUrl?: (args: {
doc: MetadataDocument
locale: string
payload: BasePayload
}) => Promise<null | string>
/** Resolves the site name (e.g. from SiteSettings). */
resolveSiteName?: (args: { locale: string; payload: BasePayload }) => Promise<null | string>
}
/**
* Builds a metadata generator, collapsing the usual generateMetadata
* boilerplate into a single wired-up function.
*
* The client provides resolvers (they own collections/routing); the plugin
* owns the assembly (title composition, canonical, hreflang, OG).
*
* The returned function takes `{ payload, params, locale }` — Next calls
* `generateMetadata({ params })` without those, and the plugin never calls
* getPayload itself, so the client wraps it in their route file:
*
* ```ts
* // app/(frontend)/[locale]/[[...slug]]/page.tsx
* const generate = createMetadataGenerator({
* config: i18nConfig,
* baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
* resolveDocument: async ({ payload, params, locale }) => { ... },
* })
*
* export async function generateMetadata({ params }) {
* const { locale, slug } = await params
* const payload = await getPayload({ config: await config })
* return generate({ payload, params: { slug: slug ?? [] }, locale })
* }
* ```
*
* `resolveDocument` should fetch with `locale: 'all'` so the slug field comes
* back as a locale→value map — that's what hreflang alternates are built from.
*/
export function createMetadataGenerator(args: CreateMetadataGeneratorArgs) {
const { baseUrl, config, homeSlug, resolveDocument, resolveImageUrl, resolveSiteName } = args
return async function generateMetadata(context: {
locale: string
params: Record<string, string | string[]>
payload: BasePayload
}): Promise<PageMetadata> {
const { locale, params, payload } = context
const doc = await resolveDocument({ locale, params, payload })
const slugs: LocalizedSlugs = doc?.slug
? getLocalizedSlugs({ config, slugField: doc.slug })
: {}
const [siteName, imageUrl] = await Promise.all([
resolveSiteName?.({ locale, payload }) ?? Promise.resolve(null),
doc && resolveImageUrl ? resolveImageUrl({ doc, locale, payload }) : Promise.resolve(null),
])
return buildMetadata({
baseUrl,
config,
homeSlug,
imageUrl,
locale,
meta: doc?.meta,
siteName,
slugs,
})
}
}
+198
View File
@@ -0,0 +1,198 @@
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'
type CreatePageMetadataArgs = {
/** Absolute site origin, e.g. 'https://example.com'. */
baseUrl?: string
/** Collection holding pages. Defaults to 'pages'. */
collection?: string
config: I18nConfig
/**
* Archive-backed collections, same value as the plugin option. Pass it and
* entry URLs (/pl/artykuly/moj-post) get correct canonical and hreflang;
* omit it and only pages are handled.
*/
content?: ContentOption
/** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string
/** Field on SiteSettings holding the site name. Defaults to 'siteName'. */
siteNameField?: string
}
type PageMetadataContext = {
locale: string
/**
* Page number from ?page= on an archive listing. Pass it and page 2 gets a
* canonical to itself; leave it out and every page claims to be page 1.
*/
page?: number
payload: BasePayload
/** Route slug segments; empty/undefined means the locale root. */
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> })
: {}
}
/**
* Metadata for the page route, with every resolver already wired.
*
* `createMetadataGenerator` asks the client for resolvers because it can't know
* their collections. But for the plugin's own conventions it does know: pages
* live in one collection, the site name sits on SiteSettings, the OG image sits
* on the plugin-seo `meta` group, the home page is whatever System Pages points
* at, and — with `content` — entries live under their collection's archive page.
* Re-declaring all that in every project is copy-paste, so this resolves it.
*
* Reach for `createMetadataGenerator` instead when a route doesn't follow those
* conventions — custom image logic, a different global, hand-rolled paths.
*
* The plugin never calls getPayload, and Next calls generateMetadata without a
* payload, so the client keeps a small wrapper:
*
* ```ts
* const pageMetadata = createPageMetadata({ config: i18nConfig, baseUrl, content })
*
* export async function generateMetadata({ params }) {
* const { locale, slug } = await params
* return pageMetadata({ payload: await getPayload({ config }), locale, slug })
* }
* ```
*/
export function createPageMetadata(args: CreatePageMetadataArgs) {
const {
baseUrl,
collection = 'pages',
config,
content,
settingsSlug = 'site-settings',
siteNameField = 'siteName',
} = args
return async function pageMetadata({
slug,
locale,
page,
payload,
}: PageMetadataContext): Promise<PageMetadata> {
const settings = (await payload.findGlobal({
slug: settingsSlug,
depth: 1,
locale: locale as never,
})) as SettingsShape
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 route = await resolveRoute({
content,
locale,
page,
pagesSlug: collection,
payload,
segments: slug,
settingsSlug,
})
// Unknown route (the page component will 404) — still return something
// coherent rather than throwing during metadata generation.
if (!route) {return empty()}
const doc = route.doc as DocShape
// An entry sits under its archive, so its URLs need that segment — and the
// 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)
: undefined
const docCollection = route.type === 'entry' ? route.collection : collection
const slugs = await slugsAcrossLocales(payload, docCollection, doc.id, config)
// 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,
meta: doc.meta,
order,
prefix,
query,
separator,
siteName,
slugs,
})
}
}
+55
View File
@@ -0,0 +1,55 @@
import type { I18nConfig, LocalizedSlugs } from '../i18n/index.js'
import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'
type BuildHreflangArgs = {
/** Absolute site origin, e.g. 'https://example.com'. Omit for relative paths. */
baseUrl?: string
config: I18nConfig
/** Home slug that collapses to the locale root. Defaults to 'home'. */
homeSlug?: string
/**
* Localized segment the document lives under (an archive page's slugs),
* e.g. { pl: 'artykuly', en: 'articles' }. Locales missing from the prefix
* are omitted — an entry with no archive in that language has no URL there.
*/
prefix?: LocalizedSlugs
/** slug per locale for the current document, e.g. { pl: 'o-nas', en: 'about' } */
slugs: LocalizedSlugs
}
/**
* Builds a map of locale → URL for hreflang alternate links, suitable for
* Next.js Metadata `alternates.languages`.
*
* Bridges SEO and i18n: for each configured locale that the document has a
* slug in, it produces the locale-aware path (via buildLocalizedPath),
* optionally prefixed with an absolute origin.
*
* @example
* buildHreflangAlternates({
* slugs: { pl: 'o-nas', en: 'about' },
* config,
* baseUrl: 'https://example.com',
* })
* // → { pl: 'https://example.com/pl/o-nas', en: 'https://example.com/en/about' }
*/
export function buildHreflangAlternates({
baseUrl,
config,
homeSlug = 'home',
prefix,
slugs,
}: BuildHreflangArgs): Record<string, string> {
const origin = baseUrl?.replace(/\/$/, '') ?? ''
const alternates: Record<string, string> = {}
for (const locale of getLocaleCodes(config)) {
const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
if (path) {
alternates[locale] = `${origin}${path}`
}
}
return alternates
}
+13
View File
@@ -0,0 +1,13 @@
export { buildAutoFillMetaHook } from './autoFillMeta.js'
export type { AutoFillMapping } from './autoFillMeta.js'
export { buildMetadata } from './buildMetadata.js'
export type { PageMetadata } from './buildMetadata.js'
export { composeTitle } from './composeTitle.js'
export type { TitleOrder } from './composeTitle.js'
export { createMetadataGenerator } from './createMetadataGenerator.js'
export { createPageMetadata } from './createPageMetadata.js'
export { buildHreflangAlternates } from './hreflang.js'
export { injectAutoFillMeta } from './injectAutoFillMeta.js'
export { injectSeoTabs } from './injectSeoTabs.js'
export { buildSeoPlugin } from './seoPluginConfig.js'
export type { SeoMeta, SeoOption } from './types.js'
+38
View File
@@ -0,0 +1,38 @@
import type { Config } from 'payload'
import type { SeoOption } from './types.js'
import { buildAutoFillMetaHook } from './autoFillMeta.js'
/**
* Injects the auto-fill meta hook into every SEO-enabled collection.
*
* Runs after @payloadcms/plugin-seo has added the meta group, appending a
* beforeChange hook that fills empty meta from document content. Existing
* hooks are preserved (plugin hook runs last, so editor input and other hooks
* win first).
*/
export function injectAutoFillMeta(config: Config, seo: SeoOption): Config {
// autoFill disabled explicitly — leave collections untouched
if (seo.autoFill === false) {
return config
}
const hook = buildAutoFillMetaHook(seo.autoFill)
return {
...config,
collections: (config.collections ?? []).map((collection) => {
if (!seo.collections.includes(collection.slug)) {
return collection
}
return {
...collection,
hooks: {
...collection.hooks,
beforeChange: [...(collection.hooks?.beforeChange ?? []), hook],
},
}
}),
}
}
+69
View File
@@ -0,0 +1,69 @@
import type { Config, Field, TabsField } from 'payload'
import type { SeoOption } from './types.js'
/**
* Wraps each SEO-enabled collection's fields into a tabbed UI: a "Content" tab
* holding the collection's own fields, and an "SEO" tab holding the `meta`
* group that @payloadcms/plugin-seo added.
*
* This replaces plugin-seo's own `tabbedUI`, which merges tabs by inspecting
* the first field and breaks when other plugins/fields (roles, slug) already
* sit in the collection — producing an empty SEO tab. Running this AFTER
* plugin-seo (so `meta` already exists) and after other field injections lets
* us build the tabs deterministically.
*
* If a collection's first field is already a tabs field, the SEO tab is
* appended to it instead of creating a new wrapper.
*/
export function injectSeoTabs(config: Config, seo: SeoOption): Config {
return {
...config,
collections: (config.collections ?? []).map((collection) => {
if (!seo.collections.includes(collection.slug)) {
return collection
}
const fields = collection.fields ?? []
// Separate the meta group (added by plugin-seo) from the rest.
const metaField = fields.find(
(f): f is { name: string } & Field => 'name' in f && f.name === 'meta',
)
const otherFields = fields.filter(
(f) => !('name' in f && f.name === 'meta'),
)
// Nothing to do if plugin-seo hasn't added meta (shouldn't happen).
if (!metaField) {return collection}
// If the collection already leads with a tabs field, append an SEO tab.
const firstField = otherFields[0]
if (firstField && firstField.type === 'tabs') {
const existingTabs = firstField
const withSeoTab: TabsField = {
...existingTabs,
tabs: [...existingTabs.tabs, { fields: [metaField], label: 'SEO' }],
}
return {
...collection,
fields: [withSeoTab, ...otherFields.slice(1)],
}
}
// Otherwise wrap everything: Content tab + SEO tab.
const tabs: TabsField = {
type: 'tabs',
tabs: [
{ fields: otherFields, label: collection.labels?.singular?.toString() || 'Content' },
{ fields: [metaField], label: 'SEO' },
],
}
return {
...collection,
fields: [tabs],
}
}),
}
}
+45
View File
@@ -0,0 +1,45 @@
import type { Field, Plugin } from 'payload'
import { seoPlugin } from '@payloadcms/plugin-seo'
import type { SeoOption } from './types.js'
type BuildSeoPluginArgs = {
seo: SeoOption
/** Upload collection slug for the meta image (typically 'media'). */
uploadsCollection?: string
}
/**
* Escape hatch from automatic title composition: whatever is typed here becomes
* the entire title — no site name, no separator. Useful on a home page, where
* composing would produce "Intecion Software | Intecion Software".
*/
const titleOverrideField: Field = {
name: 'titleOverride',
type: 'text',
admin: {
description:
'Use this exact text as the browser-tab title — no site name, no separator. Leave empty to compose the title automatically.',
},
localized: true,
}
/**
* Configures @payloadcms/plugin-seo from IPAL's SeoOption.
*
* Adds the `meta` field group (title, description, image) to the client's
* chosen collections. tabbedUI is intentionally NOT used — it merges tabs by
* inspecting the first field, which breaks when other plugins/fields (roles,
* slug) already sit in the collection, leaving an empty SEO tab. Instead the
* SEO UI fields are injected into a controlled tab by injectSeoTabs.
*/
export function buildSeoPlugin({ seo, uploadsCollection = 'media' }: BuildSeoPluginArgs): Plugin {
return seoPlugin({
collections: seo.collections,
fields: ({ defaultFields }) => [...defaultFields, titleOverrideField, ...(seo.fields ?? [])],
uploadsCollection,
...(seo.generateTitle && { generateTitle: seo.generateTitle }),
...(seo.generateDescription && { generateDescription: seo.generateDescription }),
})
}
+39
View File
@@ -0,0 +1,39 @@
import type { Field } from 'payload'
import type { AutoFillMapping } from './autoFillMeta.js'
/**
* SEO configuration.
*
* The plugin wires @payloadcms/plugin-seo into the client's config and adds
* locale-aware metadata helpers on top. Collections come from options because
* the plugin doesn't own the client's content collections (e.g. Pages).
*/
export type SeoOption = {
/**
* Auto-fill empty meta from document fields on save. Defaults to the website
* template mapping (title → meta.title). Set to false to disable.
*/
autoFill?: AutoFillMapping | false
/** Collection slugs that receive SEO meta fields, e.g. ['pages', 'posts']. */
collections: string[]
/** Extra fields appended to the SEO group in the admin. */
fields?: Field[]
/** Optional: customize how meta descriptions are generated. */
generateDescription?: (args: { doc: Record<string, unknown> }) => string
/** Optional: customize how meta titles are generated in the admin preview. */
generateTitle?: (args: { doc: Record<string, unknown> }) => string
}
/**
* Minimal shape of the SEO meta group as stored on a document by
* @payloadcms/plugin-seo. The client's generated types are richer; helpers
* depend only on this.
*/
export type SeoMeta = {
description?: null | string
image?: unknown
title?: null | string
/** When set, used as the whole title — no site name, no separator. */
titleOverride?: null | string
}
+82
View File
@@ -0,0 +1,82 @@
import type { Field, FieldHook, TextField } from 'payload'
import slugify from 'slugify'
/**
* Normalizes a string into a URL slug. slugify handles diacritics out of the
* box (Polish included: "Strona główna" → "strona-glowna").
*/
export function toSlug(input: string): string {
return slugify(input, {
lower: true,
strict: true, // drop characters that aren't url-safe
trim: true,
})
}
type BuildSlugFieldOptions = {
/** Source field to derive the slug from. Default: 'title'. */
from?: string
/** Whether the slug is localized (per-locale). Default: true. */
localized?: boolean
/** Field name for the slug. Default: 'slug'. */
name?: string
/** Override or extend the generated field config. */
overrides?: Partial<TextField>
/** Whether the slug is required. Default: true. */
required?: boolean
}
/**
* Builds a slug field that auto-generates from a source field (default 'title')
* only when left empty — an editor's manual slug is never overwritten (mode 4a).
*
* Localized-safe: on a localized field Payload runs the hook per locale, so
* `value` is the slug for the active locale and `siblingData[from]` is the
* source in that same locale. Editing the doc in 'pl' fills slug.pl from
* title.pl; editing in 'en' fills slug.en from title.en — giving genuinely
* per-locale slugs (strona-glowna / homepage) that the language switcher needs.
*/
export function buildSlugField(options: BuildSlugFieldOptions = {}): Field {
const {
name = 'slug',
from = 'title',
localized = true,
overrides = {},
required = true,
} = options
const autoGenerate: FieldHook = ({ originalDoc, siblingData, value }) => {
// Respect a manually entered slug — only generate when empty (mode 4a).
if (typeof value === 'string' && value.trim()) {
return toSlug(value) // still normalize what the editor typed
}
// Derive from the source field in the current locale.
const source =
(siblingData as Record<string, unknown>)?.[from] ??
(originalDoc as Record<string, unknown>)?.[from]
if (typeof source === 'string' && source.trim()) {
return toSlug(source)
}
return value
}
return {
name,
type: 'text',
admin: {
description: 'Auto-generated from the title when left empty. You can override it.',
...overrides.admin,
},
hooks: {
beforeValidate: [autoGenerate],
},
index: true,
localized,
required,
...overrides,
} as TextField
}
+1
View File
@@ -0,0 +1 @@
export { buildSlugField, toSlug } from './buildSlugField.js'
+77
View File
@@ -0,0 +1,77 @@
'use client'
import { useEffect, useRef } from 'react'
declare global {
interface Window {
turnstile?: {
render: (el: HTMLElement, opts: Record<string, unknown>) => string
reset: (id?: string) => void
}
}
}
const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
/** Loads the Turnstile script once, shared across all widget instances. */
function ensureScript(): void {
if (typeof document === 'undefined') {return}
if (document.querySelector(`script[src="${SCRIPT_SRC}"]`)) {return}
const script = document.createElement('script')
script.src = SCRIPT_SRC
script.async = true
script.defer = true
document.head.appendChild(script)
}
export type TurnstileProps = {
/** Called with the token once solved, or null on expiry/error. */
onToken: (token: null | string) => void
/**
* Public Turnstile site key. The client project reads it server-side from
* SiteIntegrations (turnstileSiteKey) and passes it in — the widget is a pure
* client component and can't read Payload itself.
*/
siteKey: string
theme?: 'auto' | 'dark' | 'light'
}
/**
* Cloudflare Turnstile widget — reusable across any form. Renders the challenge
* and reports the token via onToken. The token must then be verified
* server-side (see verifyTurnstile) before a submission is trusted.
*
* siteKey is a prop rather than an env var so all Turnstile config lives in one
* place (SiteIntegrations), consistent with the plugin's panel-managed model.
* The script is injected directly (no next/script dependency) so the widget
* stays framework-agnostic.
*/
export function Turnstile({ onToken, siteKey, theme = 'auto' }: TurnstileProps) {
const ref = useRef<HTMLDivElement>(null)
const widgetId = useRef<null | string>(null)
useEffect(() => {
if (!siteKey) {return}
ensureScript()
function render() {
if (!ref.current || !window.turnstile || widgetId.current) {return}
widgetId.current = window.turnstile.render(ref.current, {
callback: (token: string) => onToken(token),
'error-callback': () => onToken(null),
'expired-callback': () => onToken(null),
sitekey: siteKey,
theme,
})
}
render()
// Script may load after mount — retry briefly until ready.
const interval = setInterval(render, 300)
return () => clearInterval(interval)
}, [siteKey, theme, onToken])
if (!siteKey) {return null}
return <div ref={ref} />
}
+5
View File
@@ -0,0 +1,5 @@
'use client'
// Client-only exports — the Turnstile widget. Kept separate from index.ts so
// the server-only verify never leaks into a browser bundle.
export { Turnstile } from './Turnstile.js'
export type { TurnstileProps } from './Turnstile.js'
+3
View File
@@ -0,0 +1,3 @@
// Server-only exports. verify.ts imports 'server-only', so this must never be
// imported from a client component — use ./client for the widget instead.
export { verifyTurnstile } from './verify.js'
+62
View File
@@ -0,0 +1,62 @@
import 'server-only'
import type { BasePayload } from 'payload'
import { getSiteIntegrations } from '../payload/index.js'
type SiteverifyResponse = {
challenge_ts?: string
'error-codes'?: string[]
hostname?: string
success: boolean
}
type IntegrationsWithTurnstile = {
turnstileSecretKey?: null | string
}
type VerifyTurnstileArgs = {
/** Optional client IP for stricter verification. */
ip?: string
/** Payload instance — used to read the secret from SiteIntegrations. */
payload: BasePayload
/** Token produced by the client-side widget. */
token: string
}
/**
* Verifies a Turnstile token with Cloudflare, server-side only.
*
* The secret comes from the SiteIntegrations global (editor-managed, per the
* plugin's "secrets in the panel" model), read via the Local API which bypasses
* access control. `server-only` guarantees this never reaches the browser
* bundle, keeping the secret off the client.
*
* Returns false on any failure (missing secret/token, network error, rejected
* challenge) — callers treat false as "do not trust this submission".
*/
export async function verifyTurnstile({
ip,
payload,
token,
}: VerifyTurnstileArgs): Promise<boolean> {
if (!token) {return false}
const integrations = await getSiteIntegrations<IntegrationsWithTurnstile>(payload)
const secret = integrations.turnstileSecretKey
if (!secret) {return false}
const body = new URLSearchParams({ response: token, secret })
if (ip) {body.append('remoteip', ip)}
try {
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
body,
method: 'POST',
})
const data = (await res.json()) as SiteverifyResponse
return data.success
} catch {
return false
}
}
+98
View File
@@ -0,0 +1,98 @@
import type { Config, Plugin } from 'payload'
import type { IpalOptions } from './types.js'
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 { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'
import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js'
import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js'
/**
* IPAL (Intecion Payload Advanced Library) plugin for Payload CMS 3.
*
* @example
* ```ts
* import { ipalKit } from 'ipal-kit'
*
* export default buildConfig({
* plugins: [
* ipalKit({
* i18n: {
* locales: [
* { code: 'pl', label: 'Polski' },
* { code: 'en', label: 'English' },
* ],
* defaultLocale: 'pl',
* },
* access: { authCollection: 'users' },
* }),
* ],
* })
* ```
*/
const ipalKit = (options: IpalOptions): Plugin => {
// Validate eagerly — fail fast before Payload boots
validateI18nConfig(options.i18n)
return async (incomingConfig: Config): Promise<Config> => {
// Early return when disabled — schema stays, behavior off
if (options.enabled === false) {
return incomingConfig
}
let config = { ...incomingConfig }
// --- i18n ---
config.localization = buildLocalizationConfig(options.i18n)
// --- access: inject roles into the client's auth collection ---
if (options.access) {
config = injectRoles(config, options.access)
}
// --- seo: apply @payloadcms/plugin-seo directly ---
// NOTE: apply the plugin function to the config immediately rather than
// pushing it onto config.plugins. Payload has already iterated the plugins
// array by the time IPAL runs, so nested plugins added to that list are
// never executed. Calling the plugin as (config) => config applies its
// transform now.
if (options.seo) {
config = await buildSeoPlugin({ seo: options.seo })(config)
// Auto-fill empty meta from document content on save
config = injectAutoFillMeta(config, options.seo)
// Wrap fields into Content + SEO tabs (replaces plugin-seo's tabbedUI,
// which breaks when other fields already exist in the collection)
config = injectSeoTabs(config, options.seo)
}
// --- forms: apply @payloadcms/plugin-form-builder directly ---
if (options.forms) {
config = await buildFormsPlugin(options.forms)(config)
}
// --- globals ---
config.globals = [
...(config.globals ?? []),
buildSiteSettings({
additionalFields: options.siteSettingsFields,
content: options.content,
pages: options.pages,
}),
buildSiteIntegrations({ additionalFields: options.integrationsFields }),
buildCookieSettings(),
]
// --- hooks: onInit ---
const incomingOnInit = config.onInit
config.onInit = async (payload) => {
if (incomingOnInit) {await incomingOnInit(payload)}
payload.logger.info('[ipal] Plugin initialized.')
}
return config
}
}
export default ipalKit
+58
View File
@@ -0,0 +1,58 @@
import type { Field } from 'payload'
import type { AccessOption } from './modules/access/types.js'
import type { ContentOption } from './modules/content/types.js'
import type { FormsOption } from './modules/forms/types.js'
import type { I18nConfig } from './modules/i18n/types.js'
import type { PagesOption } from './modules/pages/types.js'
import type { SeoOption } from './modules/seo/types.js'
/**
* Configuration options for the IPAL plugin.
* Passed by the client project in payload.config.ts.
*/
export type IpalOptions = {
/**
* Role-based access control. Injects a fixed `roles` field
* (admin > editor > user) into the client's auth collection.
*/
access?: AccessOption
/**
* Collections whose entries live under an archive page — blog posts, case
* studies, anything with a listing. Adds an "archive page" assignment per
* collection in SiteSettings; the assigned page's localized slug becomes the
* URL segment (/pl/artykuly/moj-post, /en/articles/my-post). Requires `pages`.
*/
content?: ContentOption
/** Disable the plugin without uninstalling (keeps DB schema intact) */
enabled?: boolean
/**
* Forms — form-builder collections (forms, form-submissions) plus the
* callable submitForm (Turnstile + persistence + SMTP-from-panel email).
*/
forms?: FormsOption
/** Internationalization — locales, default locale, fallback behavior */
i18n: I18nConfig
/** Additional fields injected into SiteIntegrations global */
integrationsFields?: Field[]
/**
* System-page assignments (homepage, privacy, cookies) in SiteSettings.
* Provide the slug of the client's Pages collection to enable.
*/
pages?: PagesOption
/**
* SEO — adds meta fields to chosen collections (via @payloadcms/plugin-seo)
* and enables locale-aware metadata helpers.
*/
seo?: SeoOption
/** Additional fields injected into SiteSettings global */
siteSettingsFields?: Field[]
}