init commit for iPAL-kit plugin
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { PayloadHandler } from 'payload'
|
||||
|
||||
export const customEndpointHandler: PayloadHandler = () => {
|
||||
return Response.json({ message: 'Hello from custom endpoint' })
|
||||
}
|
||||
+19
-1
@@ -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'
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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],
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { RenderBlocks } from './RenderBlocks.js'
|
||||
export type { RenderBlocksProps } from './RenderBlocks.js'
|
||||
export type { BlockComponentMap, BlockData, EnhanceProps } from './types.js'
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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> }
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 }
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createContentHelpers } from './createContentHelpers.js'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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|.*\\..*).*)']
|
||||
@@ -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 }),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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[]]
|
||||
}
|
||||
@@ -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(', ')}].`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 || ''
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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],
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { buildSlugField, toSlug } from './buildSlugField.js'
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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[]
|
||||
}
|
||||
Reference in New Issue
Block a user