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)=>{ // Validate eagerly — fail fast before Payload boots validateI18nConfig(options.i18n); return async (incomingConfig)=>{ // Early return when disabled — schema stays, behavior off if (options.enabled === false) { return incomingConfig; } let config = { ...incomingConfig }; // --- custom admin route (e.g. '/its' instead of '/admin') --- // Sets config.routes.admin; the project must move its app/(payload)// // folder to match (plugin can't create files in the project's app/). if (options.adminRoute) { config.routes = { ...config.routes ?? {}, admin: options.adminRoute }; } // --- enforced 2FA (TOTP) via @clocklimited/payload-2fa --- // Enforced by default (forceSetup) unless twoFactor is explicitly false. The // plugin is a PEER dependency imported dynamically — ipal-kit doesn't bundle // it, so projects that opt out (twoFactor: false) needn't install it, and the // import never runs under generate:importmap when 2FA is off. Wrapping access // control (not just admin UI) means TOTP gates data access — no API bypass. if (options.twoFactor !== false) { const tf = options.twoFactor; if (!tf?.issuer) { throw new Error('[ipal] twoFactor.issuer is required (name shown in the authenticator ' + 'app). Pass twoFactor: { issuer: "Your Site" }, or twoFactor: false to ' + 'opt out (discouraged).'); } try { // Dynamic specifier via a variable so TS doesn't try to resolve this // optional peer dependency at build time (it isn't in the plugin's own // node_modules). Avoids TS2307 without @ts-expect-error; the module // exists at runtime in projects that installed it. // @ts-ignore const mod = await import('@clocklimited/payload-2fa'); // The package exports `payloadTotp`; older/other builds may use // `totpPlugin`. Accept either so a rename doesn't break us. const totp = mod.payloadTotp ?? mod.totpPlugin; if (typeof totp !== 'function') { throw new Error('expected export payloadTotp (or totpPlugin) to be a function — ' + 'check the installed @clocklimited/payload-2fa version'); } config = await totp({ collection: tf.collectionSlug ?? 'users', forceSetup: true, totp: { issuer: tf.issuer } })(config); } catch (err) { throw new Error('[ipal] 2FA is enforced but @clocklimited/payload-2fa is not installed. ' + 'Run: pnpm add @clocklimited/payload-2fa — or set twoFactor: false to ' + `opt out (discouraged). Original error: ${String(err)}`); } } // --- 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() ]; 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; }; }; //# sourceMappingURL=plugin.js.map