import type { Config, Plugin } from 'payload' import type { IpalOptions } from './types.js' import { buildCookieSettings } from './globals/CookieSettings/index.js' import { buildNotifications } from './globals/Notifications/index.js' import { buildSiteIntegrations } from './globals/SiteIntegrations/index.js' import { buildSiteSettings } from './globals/SiteSettings/index.js' import { injectRoles } from './modules/access/index.js' import { buildArchiveFields } from './modules/content/index.js' import { testEmailEndpoint } from './modules/email/test/testEmailEndpoint.js' import { buildFormsPlugin } from './modules/forms/formsPluginConfig.js' import { buildLocalizationConfig, validateI18nConfig } from './modules/i18n/index.js' import { buildSystemPagesFields } from './modules/pages/index.js' import { buildSeoPlugin, injectAutoFillMeta, injectSeoTabs } from './modules/seo/index.js' /** * 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' }, * }), * ], * }) * ``` */ export const ipalKit = (options: IpalOptions): Plugin => { // Validate eagerly — fail fast before Payload boots validateI18nConfig(options.i18n) return async (incomingConfig: Config): Promise => { // 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 --- // The System Pages tab collects every "which page plays this role" // assignment. Composing it here keeps SiteSettings unaware of which modules // are enabled — it just renders the fields it's given. const systemPageFields = [ ...(options.pages ? buildSystemPagesFields(options.pages) : []), ...(options.content && options.pages ? buildArchiveFields(options.content, options.pages.slug) : []), ] config.globals = [ ...(config.globals ?? []), buildSiteSettings({ additionalFields: options.siteSettingsFields, systemPageFields, }), buildSiteIntegrations({ additionalFields: options.integrationsFields }), buildCookieSettings(), buildNotifications(), ] // --- endpoints --- // Test-email endpoint (admin-only): POST /api/ipal/test-email sends a probe // message through the currently selected transport, so the panel's "send // test" button can confirm delivery without leaving the admin UI. config.endpoints = [...(config.endpoints ?? []), testEmailEndpoint] // --- hooks: onInit --- const incomingOnInit = config.onInit config.onInit = async (payload) => { if (incomingOnInit) { await incomingOnInit(payload) } payload.logger.info('[ipal] Plugin initialized.') } return config } }