diff --git a/dist/globals/Notifications/index.js b/dist/globals/Notifications/index.js index 6f1dd75..5a0e68a 100644 --- a/dist/globals/Notifications/index.js +++ b/dist/globals/Notifications/index.js @@ -6,11 +6,11 @@ import { notificationsFields } from './fields.js'; */ export function buildNotifications() { return { slug: 'notifications', - label: 'Notifications', access: { read: ()=>true }, - fields: notificationsFields + fields: notificationsFields, + label: 'Notifications' }; } diff --git a/dist/globals/Notifications/index.js.map b/dist/globals/Notifications/index.js.map index e09fb6d..cc96b1a 100644 --- a/dist/globals/Notifications/index.js.map +++ b/dist/globals/Notifications/index.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/globals/Notifications/index.ts"],"sourcesContent":["import type { GlobalConfig } from 'payload'\nimport { notificationsFields } from './fields.js'\n\n/**\n * Builds the Notifications global — localized action-result texts. Readable by\n * any authenticated panel user; server-side helpers read it with overrideAccess\n * so the frontend can resolve texts without a session.\n */\nexport function buildNotifications(): GlobalConfig {\n return {\n slug: 'notifications',\n label: 'Notifications',\n access: {\n read: () => true, // texts are public-facing (shown to end users)\n },\n fields: notificationsFields,\n }\n}\n"],"names":["notificationsFields","buildNotifications","slug","label","access","read","fields"],"mappings":"AACA,SAASA,mBAAmB,QAAQ,cAAa;AAEjD;;;;CAIC,GACD,OAAO,SAASC;IACd,OAAO;QACLC,MAAM;QACNC,OAAO;QACPC,QAAQ;YACNC,MAAM,IAAM;QACd;QACAC,QAAQN;IACV;AACF"} \ No newline at end of file +{"version":3,"sources":["../../../src/globals/Notifications/index.ts"],"sourcesContent":["import type { GlobalConfig } from 'payload'\n\nimport { notificationsFields } from './fields.js'\n\n/**\n * Builds the Notifications global — localized action-result texts. Readable by\n * any authenticated panel user; server-side helpers read it with overrideAccess\n * so the frontend can resolve texts without a session.\n */\nexport function buildNotifications(): GlobalConfig {\n return {\n slug: 'notifications',\n access: {\n read: () => true, // texts are public-facing (shown to end users)\n },\n fields: notificationsFields,\n label: 'Notifications',\n }\n}\n"],"names":["notificationsFields","buildNotifications","slug","access","read","fields","label"],"mappings":"AAEA,SAASA,mBAAmB,QAAQ,cAAa;AAEjD;;;;CAIC,GACD,OAAO,SAASC;IACd,OAAO;QACLC,MAAM;QACNC,QAAQ;YACNC,MAAM,IAAM;QACd;QACAC,QAAQL;QACRM,OAAO;IACT;AACF"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index 993356c..1aa0b9c 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -16,6 +16,7 @@ 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 { buildPreventDeleteSystemPage, buildRevalidateHook, buildValidateUniqueRole, setPublishedAtHook, trackSlugHistoryHook, } from './modules/hooks/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'; diff --git a/dist/index.js b/dist/index.js index f0313f1..ffbd78d 100644 --- a/dist/index.js +++ b/dist/index.js @@ -10,6 +10,7 @@ export { mailAdapter } from './modules/email/mailAdapter.js'; export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'; export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'; export { createContentHelpers } from './modules/frontend/index.js'; +export { buildPreventDeleteSystemPage, buildRevalidateHook, buildValidateUniqueRole, setPublishedAtHook, trackSlugHistoryHook } from './modules/hooks/index.js'; export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath } from './modules/i18n/index.js'; export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'; export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'; diff --git a/dist/index.js.map b/dist/index.js.map index 40fc462..e355143 100644 --- a/dist/index.js.map +++ b/dist/index.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type { AccessOption, Role } from './modules/access/index.js'\nexport {\n adminOnly,\n adminOnlyField,\n adminOrEditor,\n adminOrEditorField,\n adminOrSelf,\n authenticated,\n hasMinimumRole,\n isAdmin,\n isEditor,\n requireRole,\n requireRoleField,\n ROLE_HIERARCHY,\n} from './modules/access/index.js'\nexport type { AnalyticsConfig } from './modules/analytics/index.js'\nexport { getAnalyticsConfig } from './modules/analytics/index.js'\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n DEFAULT_CONSENT,\n getConsentTexts,\n parseConsent,\n REJECT_ALL_CONSENT,\n serializeConsent,\n setDefaultConsent,\n updateConsent,\n} from './modules/consent/index.js'\nexport type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'\nexport type {\n ContentCollectionOption,\n ContentOption,\n ResolvedRoute,\n} from './modules/content/index.js'\nexport {\n archiveFieldName,\n buildArchivePath,\n buildEntryPath,\n getArchiveEntries,\n parsePageParam,\n resolveRoute,\n} from './modules/content/index.js'\nexport type { ArchiveEntries } from './modules/content/index.js'\nexport { graphAdapter } from './modules/email/graphAdapter.js'\nexport type { GraphAdapterArgs } from './modules/email/graphAdapter.js'\nexport { mailAdapter } from './modules/email/mailAdapter.js'\nexport type { MailAdapterArgs } from './modules/email/mailAdapter.js'\n// Imported straight from the file, NOT from ./modules/email/index.js — that\n// barrel re-exports sendEmail, which imports 'server-only' and would crash when\n// Payload loads the config (or runs generate:importmap) as a plain Node script.\nexport { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'\nexport type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'\nexport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nexport type {\n FormsCollectionOverrides,\n FormsFieldsOverride,\n FormsOption,\n} from './modules/forms/types.js'\nexport { createContentHelpers } from './modules/frontend/index.js'\nexport type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'\nexport {\n buildLocalizedPath,\n getDefaultLocale,\n getLocaleCodes,\n getLocaleDefinition,\n getLocalizedSlugs,\n isValidLocale,\n LOCALE_COOKIE_NAME,\n matchAcceptLanguage,\n negotiateLocale,\n switchLocalePath,\n} from './modules/i18n/index.js'\nexport type { LocaleMiddlewareResult } from './modules/i18n/index.js'\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'\nexport { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'\nexport {\n getNotificationTexts,\n NOTIFICATION_FALLBACK,\n resolveFormMessage,\n} from './modules/notifications/index.js'\nexport type { FormNotificationTexts, NotificationTexts } from './modules/notifications/index.js'\nexport type { PagesOption, SystemPageRole } from './modules/pages/index.js'\nexport { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'\nexport type { GlobalQueryOptions } from './modules/payload/index.js'\nexport {\n getGlobal,\n getSiteIntegrations,\n getSiteSettings,\n SITE_INTEGRATIONS_SLUG,\n SITE_SETTINGS_SLUG,\n} from './modules/payload/index.js'\nexport { buildSecurityHeaders } from './modules/security/index.js'\nexport { buildCsp } from './modules/security/index.js'\nexport type { BuildCspArgs } from './modules/security/index.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'\n\nexport type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'\nexport { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'\nexport type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'\nexport {\n buildAutoFillMetaHook,\n buildRobots,\n buildSitemapEntries,\n createMetadataGenerator,\n createPageMetadata,\n injectAutoFillMeta,\n} from './modules/seo/index.js'\nexport {\n buildIconsMetadata,\n buildOrganizationJsonLd,\n validateFaviconField,\n} from './modules/seo/index.js'\nexport {\n buildBreadcrumbJsonLd,\n buildSiteNavigationJsonLd,\n buildWebSiteJsonLd,\n} from './modules/seo/index.js'\n// Local SEO structured data — LocalBusiness (map pack), Service (offering), FAQPage.\nexport {\n buildFaqJsonLd,\n buildLocalBusinessJsonLd,\n buildServiceJsonLd,\n} from './modules/seo/index.js'\nexport { buildArticleJsonLd } from './modules/seo/index.js'\nexport { buildSitemapXml } from './modules/seo/index.js'\nexport { buildLlmsTxt } from './modules/seo/index.js'\nexport { buildSlugField, toSlug } from './modules/slug/index.js'\nexport { buildR2Storage } from './modules/storage/index.js'\nexport { ipalKit } from './plugin.js'\nexport type { IpalOptions } from './types.js'\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","hasMinimumRole","isAdmin","isEditor","requireRole","requireRoleField","ROLE_HIERARCHY","getAnalyticsConfig","ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","DEFAULT_CONSENT","getConsentTexts","parseConsent","REJECT_ALL_CONSENT","serializeConsent","setDefaultConsent","updateConsent","archiveFieldName","buildArchivePath","buildEntryPath","getArchiveEntries","parsePageParam","resolveRoute","graphAdapter","mailAdapter","panelSmtpAdapter","buildFormsPlugin","createContentHelpers","buildLocalizedPath","getDefaultLocale","getLocaleCodes","getLocaleDefinition","getLocalizedSlugs","isValidLocale","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","switchLocalePath","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","normalizeFilename","normalizeFilenameHook","getNotificationTexts","NOTIFICATION_FALLBACK","resolveFormMessage","ALL_SYSTEM_PAGE_ROLES","getSystemPagePath","getGlobal","getSiteIntegrations","getSiteSettings","SITE_INTEGRATIONS_SLUG","SITE_SETTINGS_SLUG","buildSecurityHeaders","buildCsp","buildHreflangAlternates","buildMetadata","composeTitle","buildAutoFillMetaHook","buildRobots","buildSitemapEntries","createMetadataGenerator","createPageMetadata","injectAutoFillMeta","buildIconsMetadata","buildOrganizationJsonLd","validateFaviconField","buildBreadcrumbJsonLd","buildSiteNavigationJsonLd","buildWebSiteJsonLd","buildFaqJsonLd","buildLocalBusinessJsonLd","buildServiceJsonLd","buildArticleJsonLd","buildSitemapXml","buildLlmsTxt","buildSlugField","toSlug","buildR2Storage","ipalKit"],"mappings":"AACA,SACEA,SAAS,EACTC,cAAc,EACdC,aAAa,EACbC,kBAAkB,EAClBC,WAAW,EACXC,aAAa,EACbC,cAAc,EACdC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACXC,gBAAgB,EAChBC,cAAc,QACT,4BAA2B;AAElC,SAASC,kBAAkB,QAAQ,+BAA8B;AACjE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,kBAAkB,EAClBC,gBAAgB,EAChBC,iBAAiB,EACjBC,aAAa,QACR,6BAA4B;AAOnC,SACEC,gBAAgB,EAChBC,gBAAgB,EAChBC,cAAc,EACdC,iBAAiB,EACjBC,cAAc,EACdC,YAAY,QACP,6BAA4B;AAEnC,SAASC,YAAY,QAAQ,kCAAiC;AAE9D,SAASC,WAAW,QAAQ,iCAAgC;AAE5D,4EAA4E;AAC5E,gFAAgF;AAChF,gFAAgF;AAChF,SAASC,gBAAgB,QAAQ,sCAAqC;AAEtE,SAASC,gBAAgB,QAAQ,uCAAsC;AAMvE,SAASC,oBAAoB,QAAQ,8BAA6B;AAElE,SACEC,kBAAkB,EAClBC,gBAAgB,EAChBC,cAAc,EACdC,mBAAmB,EACnBC,iBAAiB,EACjBC,aAAa,EACbC,kBAAkB,EAClBC,mBAAmB,EACnBC,eAAe,EACfC,gBAAgB,QACX,0BAAyB;AAEhC,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,0BAAyB;AAC5F,SAASC,iBAAiB,EAAEC,qBAAqB,QAAQ,2BAA0B;AACnF,SACEC,oBAAoB,EACpBC,qBAAqB,EACrBC,kBAAkB,QACb,mCAAkC;AAGzC,SAASC,qBAAqB,EAAEC,iBAAiB,QAAQ,2BAA0B;AAEnF,SACEC,SAAS,EACTC,mBAAmB,EACnBC,eAAe,EACfC,sBAAsB,EACtBC,kBAAkB,QACb,6BAA4B;AACnC,SAASC,oBAAoB,QAAQ,8BAA6B;AAClE,SAASC,QAAQ,QAAQ,8BAA6B;AAKtD,SAASC,uBAAuB,EAAEC,aAAa,EAAEC,YAAY,QAAQ,yBAAwB;AAE7F,SACEC,qBAAqB,EACrBC,WAAW,EACXC,mBAAmB,EACnBC,uBAAuB,EACvBC,kBAAkB,EAClBC,kBAAkB,QACb,yBAAwB;AAC/B,SACEC,kBAAkB,EAClBC,uBAAuB,EACvBC,oBAAoB,QACf,yBAAwB;AAC/B,SACEC,qBAAqB,EACrBC,yBAAyB,EACzBC,kBAAkB,QACb,yBAAwB;AAC/B,qFAAqF;AACrF,SACEC,cAAc,EACdC,wBAAwB,EACxBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,kBAAkB,QAAQ,yBAAwB;AAC3D,SAASC,eAAe,QAAQ,yBAAwB;AACxD,SAASC,YAAY,QAAQ,yBAAwB;AACrD,SAASC,cAAc,EAAEC,MAAM,QAAQ,0BAAyB;AAChE,SAASC,cAAc,QAAQ,6BAA4B;AAC3D,SAASC,OAAO,QAAQ,cAAa"} \ No newline at end of file +{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type { AccessOption, Role } from './modules/access/index.js'\nexport {\n adminOnly,\n adminOnlyField,\n adminOrEditor,\n adminOrEditorField,\n adminOrSelf,\n authenticated,\n hasMinimumRole,\n isAdmin,\n isEditor,\n requireRole,\n requireRoleField,\n ROLE_HIERARCHY,\n} from './modules/access/index.js'\nexport type { AnalyticsConfig } from './modules/analytics/index.js'\nexport { getAnalyticsConfig } from './modules/analytics/index.js'\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n DEFAULT_CONSENT,\n getConsentTexts,\n parseConsent,\n REJECT_ALL_CONSENT,\n serializeConsent,\n setDefaultConsent,\n updateConsent,\n} from './modules/consent/index.js'\nexport type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'\nexport type {\n ContentCollectionOption,\n ContentOption,\n ResolvedRoute,\n} from './modules/content/index.js'\nexport {\n archiveFieldName,\n buildArchivePath,\n buildEntryPath,\n getArchiveEntries,\n parsePageParam,\n resolveRoute,\n} from './modules/content/index.js'\nexport type { ArchiveEntries } from './modules/content/index.js'\nexport { graphAdapter } from './modules/email/graphAdapter.js'\nexport type { GraphAdapterArgs } from './modules/email/graphAdapter.js'\nexport { mailAdapter } from './modules/email/mailAdapter.js'\nexport type { MailAdapterArgs } from './modules/email/mailAdapter.js'\n// Imported straight from the file, NOT from ./modules/email/index.js — that\n// barrel re-exports sendEmail, which imports 'server-only' and would crash when\n// Payload loads the config (or runs generate:importmap) as a plain Node script.\nexport { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'\nexport type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'\nexport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nexport type {\n FormsCollectionOverrides,\n FormsFieldsOverride,\n FormsOption,\n} from './modules/forms/types.js'\nexport { createContentHelpers } from './modules/frontend/index.js'\nexport {\n buildPreventDeleteSystemPage,\n buildRevalidateHook,\n buildValidateUniqueRole,\n setPublishedAtHook,\n trackSlugHistoryHook,\n} from './modules/hooks/index.js'\nexport type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'\nexport {\n buildLocalizedPath,\n getDefaultLocale,\n getLocaleCodes,\n getLocaleDefinition,\n getLocalizedSlugs,\n isValidLocale,\n LOCALE_COOKIE_NAME,\n matchAcceptLanguage,\n negotiateLocale,\n switchLocalePath,\n} from './modules/i18n/index.js'\nexport type { LocaleMiddlewareResult } from './modules/i18n/index.js'\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'\nexport { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'\nexport {\n getNotificationTexts,\n NOTIFICATION_FALLBACK,\n resolveFormMessage,\n} from './modules/notifications/index.js'\nexport type { FormNotificationTexts, NotificationTexts } from './modules/notifications/index.js'\nexport type { PagesOption, SystemPageRole } from './modules/pages/index.js'\nexport { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'\nexport type { GlobalQueryOptions } from './modules/payload/index.js'\nexport {\n getGlobal,\n getSiteIntegrations,\n getSiteSettings,\n SITE_INTEGRATIONS_SLUG,\n SITE_SETTINGS_SLUG,\n} from './modules/payload/index.js'\nexport { buildSecurityHeaders } from './modules/security/index.js'\nexport { buildCsp } from './modules/security/index.js'\nexport type { BuildCspArgs } from './modules/security/index.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'\n\nexport type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'\nexport { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'\nexport type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'\nexport {\n buildAutoFillMetaHook,\n buildRobots,\n buildSitemapEntries,\n createMetadataGenerator,\n createPageMetadata,\n injectAutoFillMeta,\n} from './modules/seo/index.js'\nexport {\n buildIconsMetadata,\n buildOrganizationJsonLd,\n validateFaviconField,\n} from './modules/seo/index.js'\nexport {\n buildBreadcrumbJsonLd,\n buildSiteNavigationJsonLd,\n buildWebSiteJsonLd,\n} from './modules/seo/index.js'\n// Local SEO structured data — LocalBusiness (map pack), Service (offering), FAQPage.\nexport {\n buildFaqJsonLd,\n buildLocalBusinessJsonLd,\n buildServiceJsonLd,\n} from './modules/seo/index.js'\nexport { buildArticleJsonLd } from './modules/seo/index.js'\nexport { buildSitemapXml } from './modules/seo/index.js'\nexport { buildLlmsTxt } from './modules/seo/index.js'\nexport { buildSlugField, toSlug } from './modules/slug/index.js'\nexport { buildR2Storage } from './modules/storage/index.js'\nexport { ipalKit } from './plugin.js'\nexport type { IpalOptions } from './types.js'\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","hasMinimumRole","isAdmin","isEditor","requireRole","requireRoleField","ROLE_HIERARCHY","getAnalyticsConfig","ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","DEFAULT_CONSENT","getConsentTexts","parseConsent","REJECT_ALL_CONSENT","serializeConsent","setDefaultConsent","updateConsent","archiveFieldName","buildArchivePath","buildEntryPath","getArchiveEntries","parsePageParam","resolveRoute","graphAdapter","mailAdapter","panelSmtpAdapter","buildFormsPlugin","createContentHelpers","buildPreventDeleteSystemPage","buildRevalidateHook","buildValidateUniqueRole","setPublishedAtHook","trackSlugHistoryHook","buildLocalizedPath","getDefaultLocale","getLocaleCodes","getLocaleDefinition","getLocalizedSlugs","isValidLocale","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","switchLocalePath","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","normalizeFilename","normalizeFilenameHook","getNotificationTexts","NOTIFICATION_FALLBACK","resolveFormMessage","ALL_SYSTEM_PAGE_ROLES","getSystemPagePath","getGlobal","getSiteIntegrations","getSiteSettings","SITE_INTEGRATIONS_SLUG","SITE_SETTINGS_SLUG","buildSecurityHeaders","buildCsp","buildHreflangAlternates","buildMetadata","composeTitle","buildAutoFillMetaHook","buildRobots","buildSitemapEntries","createMetadataGenerator","createPageMetadata","injectAutoFillMeta","buildIconsMetadata","buildOrganizationJsonLd","validateFaviconField","buildBreadcrumbJsonLd","buildSiteNavigationJsonLd","buildWebSiteJsonLd","buildFaqJsonLd","buildLocalBusinessJsonLd","buildServiceJsonLd","buildArticleJsonLd","buildSitemapXml","buildLlmsTxt","buildSlugField","toSlug","buildR2Storage","ipalKit"],"mappings":"AACA,SACEA,SAAS,EACTC,cAAc,EACdC,aAAa,EACbC,kBAAkB,EAClBC,WAAW,EACXC,aAAa,EACbC,cAAc,EACdC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACXC,gBAAgB,EAChBC,cAAc,QACT,4BAA2B;AAElC,SAASC,kBAAkB,QAAQ,+BAA8B;AACjE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,kBAAkB,EAClBC,gBAAgB,EAChBC,iBAAiB,EACjBC,aAAa,QACR,6BAA4B;AAOnC,SACEC,gBAAgB,EAChBC,gBAAgB,EAChBC,cAAc,EACdC,iBAAiB,EACjBC,cAAc,EACdC,YAAY,QACP,6BAA4B;AAEnC,SAASC,YAAY,QAAQ,kCAAiC;AAE9D,SAASC,WAAW,QAAQ,iCAAgC;AAE5D,4EAA4E;AAC5E,gFAAgF;AAChF,gFAAgF;AAChF,SAASC,gBAAgB,QAAQ,sCAAqC;AAEtE,SAASC,gBAAgB,QAAQ,uCAAsC;AAMvE,SAASC,oBAAoB,QAAQ,8BAA6B;AAClE,SACEC,4BAA4B,EAC5BC,mBAAmB,EACnBC,uBAAuB,EACvBC,kBAAkB,EAClBC,oBAAoB,QACf,2BAA0B;AAEjC,SACEC,kBAAkB,EAClBC,gBAAgB,EAChBC,cAAc,EACdC,mBAAmB,EACnBC,iBAAiB,EACjBC,aAAa,EACbC,kBAAkB,EAClBC,mBAAmB,EACnBC,eAAe,EACfC,gBAAgB,QACX,0BAAyB;AAEhC,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,0BAAyB;AAC5F,SAASC,iBAAiB,EAAEC,qBAAqB,QAAQ,2BAA0B;AACnF,SACEC,oBAAoB,EACpBC,qBAAqB,EACrBC,kBAAkB,QACb,mCAAkC;AAGzC,SAASC,qBAAqB,EAAEC,iBAAiB,QAAQ,2BAA0B;AAEnF,SACEC,SAAS,EACTC,mBAAmB,EACnBC,eAAe,EACfC,sBAAsB,EACtBC,kBAAkB,QACb,6BAA4B;AACnC,SAASC,oBAAoB,QAAQ,8BAA6B;AAClE,SAASC,QAAQ,QAAQ,8BAA6B;AAKtD,SAASC,uBAAuB,EAAEC,aAAa,EAAEC,YAAY,QAAQ,yBAAwB;AAE7F,SACEC,qBAAqB,EACrBC,WAAW,EACXC,mBAAmB,EACnBC,uBAAuB,EACvBC,kBAAkB,EAClBC,kBAAkB,QACb,yBAAwB;AAC/B,SACEC,kBAAkB,EAClBC,uBAAuB,EACvBC,oBAAoB,QACf,yBAAwB;AAC/B,SACEC,qBAAqB,EACrBC,yBAAyB,EACzBC,kBAAkB,QACb,yBAAwB;AAC/B,qFAAqF;AACrF,SACEC,cAAc,EACdC,wBAAwB,EACxBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,kBAAkB,QAAQ,yBAAwB;AAC3D,SAASC,eAAe,QAAQ,yBAAwB;AACxD,SAASC,YAAY,QAAQ,yBAAwB;AACrD,SAASC,cAAc,EAAEC,MAAM,QAAQ,0BAAyB;AAChE,SAASC,cAAc,QAAQ,6BAA4B;AAC3D,SAASC,OAAO,QAAQ,cAAa"} \ No newline at end of file diff --git a/dist/modules/hooks/buildRevalidateHook.d.ts b/dist/modules/hooks/buildRevalidateHook.d.ts new file mode 100644 index 0000000..de433cb --- /dev/null +++ b/dist/modules/hooks/buildRevalidateHook.d.ts @@ -0,0 +1,35 @@ +import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'; +import type { I18nConfig } from '../i18n/index.js'; +type RevalidateFn = (path: string) => void; +type BuildRevalidateHookArgs = { + config: I18nConfig; + /** Home slug (string or per-locale map) — home revalidates the root. */ + homeSlug?: Record | string; + /** + * next/cache revalidatePath, INJECTED by the project. The plugin never imports + * next/cache itself — that would crash when Payload runs as plain Node + * (generate:importmap). The project passes it: `revalidatePath` from 'next/cache'. + */ + revalidatePath: RevalidateFn; +}; +/** + * Builds afterChange + afterDelete hooks that revalidate a page's ISR cache when + * an editor saves or deletes it — so changes appear immediately instead of + * waiting for the revalidate window. Without this, ISR means editors wait; with + * it, ISR is usable for a CMS. + * + * Handles every locale, the root (home), AND a changed slug (revalidates both the + * old and new path so neither goes stale). revalidatePath is injected — the + * plugin never imports next/cache (safe under generate:importmap / plain Node). + * + * // in your Media/Pages collection config, from a project file that CAN import next/cache: + * import { revalidatePath } from 'next/cache' + * import { buildRevalidateHook } from '@intecion/ipal-kit' + * const { afterChange, afterDelete } = buildRevalidateHook({ revalidatePath, config: i18nConfig }) + * // hooks: { afterChange: [afterChange], afterDelete: [afterDelete] } + */ +export declare function buildRevalidateHook({ config, homeSlug, revalidatePath, }: BuildRevalidateHookArgs): { + afterChange: CollectionAfterChangeHook; + afterDelete: CollectionAfterDeleteHook; +}; +export {}; diff --git a/dist/modules/hooks/buildRevalidateHook.js b/dist/modules/hooks/buildRevalidateHook.js new file mode 100644 index 0000000..a905f1e --- /dev/null +++ b/dist/modules/hooks/buildRevalidateHook.js @@ -0,0 +1,71 @@ +import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'; +/** Resolve a doc's path in one locale (root for home). Null if no slug there. */ function pathForLocale(doc, locale, config, homeSlug) { + const slugField = doc.slug; + const slug = typeof slugField === 'string' ? slugField : slugField && typeof slugField === 'object' ? slugField[locale] : undefined; + if (!slug) { + return null; + } + return buildLocalizedPath({ + config, + homeSlug, + locale, + slugs: { + [locale]: slug + } + }) ?? null; +} +/** + * Builds afterChange + afterDelete hooks that revalidate a page's ISR cache when + * an editor saves or deletes it — so changes appear immediately instead of + * waiting for the revalidate window. Without this, ISR means editors wait; with + * it, ISR is usable for a CMS. + * + * Handles every locale, the root (home), AND a changed slug (revalidates both the + * old and new path so neither goes stale). revalidatePath is injected — the + * plugin never imports next/cache (safe under generate:importmap / plain Node). + * + * // in your Media/Pages collection config, from a project file that CAN import next/cache: + * import { revalidatePath } from 'next/cache' + * import { buildRevalidateHook } from '@intecion/ipal-kit' + * const { afterChange, afterDelete } = buildRevalidateHook({ revalidatePath, config: i18nConfig }) + * // hooks: { afterChange: [afterChange], afterDelete: [afterDelete] } + */ export function buildRevalidateHook({ config, homeSlug, revalidatePath }) { + const locales = getLocaleCodes(config); + const afterChange = ({ doc, previousDoc })=>{ + const seen = new Set(); + for (const locale of locales){ + // New path. + const newPath = pathForLocale(doc, locale, config, homeSlug); + if (newPath && !seen.has(newPath)) { + revalidatePath(newPath); + seen.add(newPath); + } + // Old path, if the slug changed — so the old URL doesn't serve stale content. + if (previousDoc) { + const oldPath = pathForLocale(previousDoc, locale, config, homeSlug); + if (oldPath && oldPath !== newPath && !seen.has(oldPath)) { + revalidatePath(oldPath); + seen.add(oldPath); + } + } + } + return doc; + }; + const afterDelete = ({ doc })=>{ + const seen = new Set(); + for (const locale of locales){ + const path = pathForLocale(doc, locale, config, homeSlug); + if (path && !seen.has(path)) { + revalidatePath(path); + seen.add(path); + } + } + return doc; + }; + return { + afterChange, + afterDelete + }; +} + +//# sourceMappingURL=buildRevalidateHook.js.map \ No newline at end of file diff --git a/dist/modules/hooks/buildRevalidateHook.js.map b/dist/modules/hooks/buildRevalidateHook.js.map new file mode 100644 index 0000000..7baf6f9 --- /dev/null +++ b/dist/modules/hooks/buildRevalidateHook.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/hooks/buildRevalidateHook.ts"],"sourcesContent":["import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'\n\nimport type { I18nConfig } from '../i18n/index.js'\n\nimport { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js'\n\ntype RevalidateFn = (path: string) => void\n\ntype BuildRevalidateHookArgs = {\n config: I18nConfig\n /** Home slug (string or per-locale map) — home revalidates the root. */\n homeSlug?: Record | string\n /**\n * next/cache revalidatePath, INJECTED by the project. The plugin never imports\n * next/cache itself — that would crash when Payload runs as plain Node\n * (generate:importmap). The project passes it: `revalidatePath` from 'next/cache'.\n */\n revalidatePath: RevalidateFn\n}\n\ntype DocWithSlug = { slug?: unknown }\n\n/** Resolve a doc's path in one locale (root for home). Null if no slug there. */\nfunction pathForLocale(\n doc: DocWithSlug,\n locale: string,\n config: I18nConfig,\n homeSlug?: Record | string,\n): null | string {\n const slugField = doc.slug\n const slug =\n typeof slugField === 'string'\n ? slugField\n : slugField && typeof slugField === 'object'\n ? ((slugField as Record)[locale] as string | undefined)\n : undefined\n if (!slug) {return null}\n return buildLocalizedPath({ config, homeSlug, locale, slugs: { [locale]: slug } }) ?? null\n}\n\n/**\n * Builds afterChange + afterDelete hooks that revalidate a page's ISR cache when\n * an editor saves or deletes it — so changes appear immediately instead of\n * waiting for the revalidate window. Without this, ISR means editors wait; with\n * it, ISR is usable for a CMS.\n *\n * Handles every locale, the root (home), AND a changed slug (revalidates both the\n * old and new path so neither goes stale). revalidatePath is injected — the\n * plugin never imports next/cache (safe under generate:importmap / plain Node).\n *\n * // in your Media/Pages collection config, from a project file that CAN import next/cache:\n * import { revalidatePath } from 'next/cache'\n * import { buildRevalidateHook } from '@intecion/ipal-kit'\n * const { afterChange, afterDelete } = buildRevalidateHook({ revalidatePath, config: i18nConfig })\n * // hooks: { afterChange: [afterChange], afterDelete: [afterDelete] }\n */\nexport function buildRevalidateHook({\n config,\n homeSlug,\n revalidatePath,\n}: BuildRevalidateHookArgs): {\n afterChange: CollectionAfterChangeHook\n afterDelete: CollectionAfterDeleteHook\n} {\n const locales = getLocaleCodes(config)\n\n const afterChange: CollectionAfterChangeHook = ({ doc, previousDoc }) => {\n const seen = new Set()\n for (const locale of locales) {\n // New path.\n const newPath = pathForLocale(doc as DocWithSlug, locale, config, homeSlug)\n if (newPath && !seen.has(newPath)) {\n revalidatePath(newPath)\n seen.add(newPath)\n }\n // Old path, if the slug changed — so the old URL doesn't serve stale content.\n if (previousDoc) {\n const oldPath = pathForLocale(previousDoc as DocWithSlug, locale, config, homeSlug)\n if (oldPath && oldPath !== newPath && !seen.has(oldPath)) {\n revalidatePath(oldPath)\n seen.add(oldPath)\n }\n }\n }\n return doc\n }\n\n const afterDelete: CollectionAfterDeleteHook = ({ doc }) => {\n const seen = new Set()\n for (const locale of locales) {\n const path = pathForLocale(doc as DocWithSlug, locale, config, homeSlug)\n if (path && !seen.has(path)) {\n revalidatePath(path)\n seen.add(path)\n }\n }\n return doc\n }\n\n return { afterChange, afterDelete }\n}\n"],"names":["buildLocalizedPath","getLocaleCodes","pathForLocale","doc","locale","config","homeSlug","slugField","slug","undefined","slugs","buildRevalidateHook","revalidatePath","locales","afterChange","previousDoc","seen","Set","newPath","has","add","oldPath","afterDelete","path"],"mappings":"AAIA,SAASA,kBAAkB,EAAEC,cAAc,QAAQ,mBAAkB;AAkBrE,+EAA+E,GAC/E,SAASC,cACPC,GAAgB,EAChBC,MAAc,EACdC,MAAkB,EAClBC,QAA0C;IAE1C,MAAMC,YAAYJ,IAAIK,IAAI;IAC1B,MAAMA,OACJ,OAAOD,cAAc,WACjBA,YACAA,aAAa,OAAOA,cAAc,WAC/B,AAACA,SAAqC,CAACH,OAAO,GAC/CK;IACR,IAAI,CAACD,MAAM;QAAC,OAAO;IAAI;IACvB,OAAOR,mBAAmB;QAAEK;QAAQC;QAAUF;QAAQM,OAAO;YAAE,CAACN,OAAO,EAAEI;QAAK;IAAE,MAAM;AACxF;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASG,oBAAoB,EAClCN,MAAM,EACNC,QAAQ,EACRM,cAAc,EACU;IAIxB,MAAMC,UAAUZ,eAAeI;IAE/B,MAAMS,cAAyC,CAAC,EAAEX,GAAG,EAAEY,WAAW,EAAE;QAClE,MAAMC,OAAO,IAAIC;QACjB,KAAK,MAAMb,UAAUS,QAAS;YAC5B,YAAY;YACZ,MAAMK,UAAUhB,cAAcC,KAAoBC,QAAQC,QAAQC;YAClE,IAAIY,WAAW,CAACF,KAAKG,GAAG,CAACD,UAAU;gBACjCN,eAAeM;gBACfF,KAAKI,GAAG,CAACF;YACX;YACA,8EAA8E;YAC9E,IAAIH,aAAa;gBACf,MAAMM,UAAUnB,cAAca,aAA4BX,QAAQC,QAAQC;gBAC1E,IAAIe,WAAWA,YAAYH,WAAW,CAACF,KAAKG,GAAG,CAACE,UAAU;oBACxDT,eAAeS;oBACfL,KAAKI,GAAG,CAACC;gBACX;YACF;QACF;QACA,OAAOlB;IACT;IAEA,MAAMmB,cAAyC,CAAC,EAAEnB,GAAG,EAAE;QACrD,MAAMa,OAAO,IAAIC;QACjB,KAAK,MAAMb,UAAUS,QAAS;YAC5B,MAAMU,OAAOrB,cAAcC,KAAoBC,QAAQC,QAAQC;YAC/D,IAAIiB,QAAQ,CAACP,KAAKG,GAAG,CAACI,OAAO;gBAC3BX,eAAeW;gBACfP,KAAKI,GAAG,CAACG;YACX;QACF;QACA,OAAOpB;IACT;IAEA,OAAO;QAAEW;QAAaQ;IAAY;AACpC"} \ No newline at end of file diff --git a/dist/modules/hooks/index.d.ts b/dist/modules/hooks/index.d.ts new file mode 100644 index 0000000..9d0dcef --- /dev/null +++ b/dist/modules/hooks/index.d.ts @@ -0,0 +1,7 @@ +export { normalizeFilenameHook } from '../media/index.js'; +export { buildAutoFillMetaHook, validateFaviconField } from '../seo/index.js'; +export { buildRevalidateHook } from './buildRevalidateHook.js'; +export { buildPreventDeleteSystemPage } from './preventDeleteSystemPage.js'; +export { setPublishedAtHook } from './setPublishedAt.js'; +export { trackSlugHistoryHook } from './trackSlugHistory.js'; +export { buildValidateUniqueRole } from './validateUniqueRole.js'; diff --git a/dist/modules/hooks/index.js b/dist/modules/hooks/index.js new file mode 100644 index 0000000..ec30fc8 --- /dev/null +++ b/dist/modules/hooks/index.js @@ -0,0 +1,11 @@ +// Re-eksport hooków domenowych (mieszkają w swoich modułach, tu dla przeglądu — +// żeby był jeden katalog "wszystkie hooki pluginu"). Źródło prawdy to ich moduły. +export { normalizeFilenameHook } from '../media/index.js'; +export { buildAutoFillMetaHook, validateFaviconField } from '../seo/index.js'; +export { buildRevalidateHook } from './buildRevalidateHook.js'; +export { buildPreventDeleteSystemPage } from './preventDeleteSystemPage.js'; +export { setPublishedAtHook } from './setPublishedAt.js'; +export { trackSlugHistoryHook } from './trackSlugHistory.js'; +export { buildValidateUniqueRole } from './validateUniqueRole.js'; + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/hooks/index.js.map b/dist/modules/hooks/index.js.map new file mode 100644 index 0000000..3cdfe9e --- /dev/null +++ b/dist/modules/hooks/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/hooks/index.ts"],"sourcesContent":["// Re-eksport hooków domenowych (mieszkają w swoich modułach, tu dla przeglądu —\n// żeby był jeden katalog \"wszystkie hooki pluginu\"). Źródło prawdy to ich moduły.\nexport { normalizeFilenameHook } from '../media/index.js'\nexport { buildAutoFillMetaHook, validateFaviconField } from '../seo/index.js'\nexport { buildRevalidateHook } from './buildRevalidateHook.js'\nexport { buildPreventDeleteSystemPage } from './preventDeleteSystemPage.js'\nexport { setPublishedAtHook } from './setPublishedAt.js'\n\nexport { trackSlugHistoryHook } from './trackSlugHistory.js'\nexport { buildValidateUniqueRole } from './validateUniqueRole.js'\n"],"names":["normalizeFilenameHook","buildAutoFillMetaHook","validateFaviconField","buildRevalidateHook","buildPreventDeleteSystemPage","setPublishedAtHook","trackSlugHistoryHook","buildValidateUniqueRole"],"mappings":"AAAA,gFAAgF;AAChF,kFAAkF;AAClF,SAASA,qBAAqB,QAAQ,oBAAmB;AACzD,SAASC,qBAAqB,EAAEC,oBAAoB,QAAQ,kBAAiB;AAC7E,SAASC,mBAAmB,QAAQ,2BAA0B;AAC9D,SAASC,4BAA4B,QAAQ,+BAA8B;AAC3E,SAASC,kBAAkB,QAAQ,sBAAqB;AAExD,SAASC,oBAAoB,QAAQ,wBAAuB;AAC5D,SAASC,uBAAuB,QAAQ,0BAAyB"} \ No newline at end of file diff --git a/dist/modules/hooks/preventDeleteSystemPage.d.ts b/dist/modules/hooks/preventDeleteSystemPage.d.ts new file mode 100644 index 0000000..6404a6c --- /dev/null +++ b/dist/modules/hooks/preventDeleteSystemPage.d.ts @@ -0,0 +1,15 @@ +import type { CollectionBeforeDeleteHook } from 'payload'; +/** + * Blocks deletion of a page assigned a System Page role (homepage, + * privacyPolicy, cookiePolicy, termsOfService). An editor deleting the privacy + * policy or homepage by accident would break routing and compliance links; this + * stops it with a clear error. They must unassign the role first (deliberate). + * + * Reads the role assignments from SiteSettings (which page holds which role). + * + * hooks: { beforeDelete: [buildPreventDeleteSystemPage({ settingsSlug: 'site-settings' })] } + */ +export declare function buildPreventDeleteSystemPage(args?: { + roleFields?: string[]; + settingsSlug?: string; +}): CollectionBeforeDeleteHook; diff --git a/dist/modules/hooks/preventDeleteSystemPage.js b/dist/modules/hooks/preventDeleteSystemPage.js new file mode 100644 index 0000000..c365843 --- /dev/null +++ b/dist/modules/hooks/preventDeleteSystemPage.js @@ -0,0 +1,37 @@ +import { APIError } from 'payload'; +/** + * Blocks deletion of a page assigned a System Page role (homepage, + * privacyPolicy, cookiePolicy, termsOfService). An editor deleting the privacy + * policy or homepage by accident would break routing and compliance links; this + * stops it with a clear error. They must unassign the role first (deliberate). + * + * Reads the role assignments from SiteSettings (which page holds which role). + * + * hooks: { beforeDelete: [buildPreventDeleteSystemPage({ settingsSlug: 'site-settings' })] } + */ export function buildPreventDeleteSystemPage(args = {}) { + const settingsSlug = args.settingsSlug ?? 'site-settings'; + const roleFields = args.roleFields ?? [ + 'homepage', + 'privacyPolicy', + 'cookiePolicy', + 'termsOfService' + ]; + return async ({ id, req })=>{ + const settings = await req.payload.findGlobal({ + slug: settingsSlug, + depth: 0 + }).catch(()=>null); + if (!settings) { + return; + } + for (const field of roleFields){ + const assigned = settings[field]; + const assignedId = assigned && typeof assigned === 'object' ? assigned.id : assigned; + if (assignedId != null && String(assignedId) === String(id)) { + throw new APIError(`Nie można usunąć strony przypisanej do roli systemowej "${field}". ` + `Najpierw odłącz rolę w Site Settings.`, 400); + } + } + }; +} + +//# sourceMappingURL=preventDeleteSystemPage.js.map \ No newline at end of file diff --git a/dist/modules/hooks/preventDeleteSystemPage.js.map b/dist/modules/hooks/preventDeleteSystemPage.js.map new file mode 100644 index 0000000..5accdc8 --- /dev/null +++ b/dist/modules/hooks/preventDeleteSystemPage.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/hooks/preventDeleteSystemPage.ts"],"sourcesContent":["import type { CollectionBeforeDeleteHook } from 'payload'\n\nimport { APIError } from 'payload'\n\n/**\n * Blocks deletion of a page assigned a System Page role (homepage,\n * privacyPolicy, cookiePolicy, termsOfService). An editor deleting the privacy\n * policy or homepage by accident would break routing and compliance links; this\n * stops it with a clear error. They must unassign the role first (deliberate).\n *\n * Reads the role assignments from SiteSettings (which page holds which role).\n *\n * hooks: { beforeDelete: [buildPreventDeleteSystemPage({ settingsSlug: 'site-settings' })] }\n */\nexport function buildPreventDeleteSystemPage(\n args: { roleFields?: string[]; settingsSlug?: string } = {},\n): CollectionBeforeDeleteHook {\n const settingsSlug = args.settingsSlug ?? 'site-settings'\n const roleFields = args.roleFields ?? [\n 'homepage',\n 'privacyPolicy',\n 'cookiePolicy',\n 'termsOfService',\n ]\n\n return async ({ id, req }) => {\n const settings = (await req.payload\n .findGlobal({ slug: settingsSlug as never, depth: 0 })\n .catch(() => null)) as null | Record\n if (!settings) {return}\n\n for (const field of roleFields) {\n const assigned = settings[field]\n const assignedId =\n assigned && typeof assigned === 'object' ? (assigned as { id?: unknown }).id : assigned\n if (assignedId != null && String(assignedId) === String(id)) {\n throw new APIError(\n `Nie można usunąć strony przypisanej do roli systemowej \"${field}\". ` +\n `Najpierw odłącz rolę w Site Settings.`,\n 400,\n )\n }\n }\n }\n}\n"],"names":["APIError","buildPreventDeleteSystemPage","args","settingsSlug","roleFields","id","req","settings","payload","findGlobal","slug","depth","catch","field","assigned","assignedId","String"],"mappings":"AAEA,SAASA,QAAQ,QAAQ,UAAS;AAElC;;;;;;;;;CASC,GACD,OAAO,SAASC,6BACdC,OAAyD,CAAC,CAAC;IAE3D,MAAMC,eAAeD,KAAKC,YAAY,IAAI;IAC1C,MAAMC,aAAaF,KAAKE,UAAU,IAAI;QACpC;QACA;QACA;QACA;KACD;IAED,OAAO,OAAO,EAAEC,EAAE,EAAEC,GAAG,EAAE;QACvB,MAAMC,WAAY,MAAMD,IAAIE,OAAO,CAChCC,UAAU,CAAC;YAAEC,MAAMP;YAAuBQ,OAAO;QAAE,GACnDC,KAAK,CAAC,IAAM;QACf,IAAI,CAACL,UAAU;YAAC;QAAM;QAEtB,KAAK,MAAMM,SAAST,WAAY;YAC9B,MAAMU,WAAWP,QAAQ,CAACM,MAAM;YAChC,MAAME,aACJD,YAAY,OAAOA,aAAa,WAAW,AAACA,SAA8BT,EAAE,GAAGS;YACjF,IAAIC,cAAc,QAAQC,OAAOD,gBAAgBC,OAAOX,KAAK;gBAC3D,MAAM,IAAIL,SACR,CAAC,wDAAwD,EAAEa,MAAM,GAAG,CAAC,GACnE,CAAC,qCAAqC,CAAC,EACzC;YAEJ;QACF;IACF;AACF"} \ No newline at end of file diff --git a/dist/modules/hooks/setPublishedAt.d.ts b/dist/modules/hooks/setPublishedAt.d.ts new file mode 100644 index 0000000..a0b2446 --- /dev/null +++ b/dist/modules/hooks/setPublishedAt.d.ts @@ -0,0 +1,13 @@ +import type { CollectionBeforeChangeHook } from 'payload'; +/** + * Sets `publishedAt` to now the first time a document transitions to published, + * if it isn't already set. Saves editors from filling the date manually and + * keeps blog/article dates accurate for Article JSON-LD and sitemaps. + * + * Attach to collections with drafts enabled (blog, articles): + * hooks: { beforeChange: [setPublishedAtHook] } + * + * Only sets on the published transition; never overwrites an existing date + * (an editor can still backdate manually). + */ +export declare const setPublishedAtHook: CollectionBeforeChangeHook; diff --git a/dist/modules/hooks/setPublishedAt.js b/dist/modules/hooks/setPublishedAt.js new file mode 100644 index 0000000..7ac29e3 --- /dev/null +++ b/dist/modules/hooks/setPublishedAt.js @@ -0,0 +1,19 @@ +/** + * Sets `publishedAt` to now the first time a document transitions to published, + * if it isn't already set. Saves editors from filling the date manually and + * keeps blog/article dates accurate for Article JSON-LD and sitemaps. + * + * Attach to collections with drafts enabled (blog, articles): + * hooks: { beforeChange: [setPublishedAtHook] } + * + * Only sets on the published transition; never overwrites an existing date + * (an editor can still backdate manually). + */ export const setPublishedAtHook = ({ data, originalDoc })=>{ + const becomingPublished = data._status === 'published' && originalDoc?._status !== 'published'; + if (becomingPublished && !data.publishedAt) { + data.publishedAt = new Date().toISOString(); + } + return data; +}; + +//# sourceMappingURL=setPublishedAt.js.map \ No newline at end of file diff --git a/dist/modules/hooks/setPublishedAt.js.map b/dist/modules/hooks/setPublishedAt.js.map new file mode 100644 index 0000000..3a75ee4 --- /dev/null +++ b/dist/modules/hooks/setPublishedAt.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/hooks/setPublishedAt.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\n/**\n * Sets `publishedAt` to now the first time a document transitions to published,\n * if it isn't already set. Saves editors from filling the date manually and\n * keeps blog/article dates accurate for Article JSON-LD and sitemaps.\n *\n * Attach to collections with drafts enabled (blog, articles):\n * hooks: { beforeChange: [setPublishedAtHook] }\n *\n * Only sets on the published transition; never overwrites an existing date\n * (an editor can still backdate manually).\n */\nexport const setPublishedAtHook: CollectionBeforeChangeHook = ({ data, originalDoc }) => {\n const becomingPublished = data._status === 'published' && originalDoc?._status !== 'published'\n if (becomingPublished && !data.publishedAt) {\n data.publishedAt = new Date().toISOString()\n }\n return data\n}\n"],"names":["setPublishedAtHook","data","originalDoc","becomingPublished","_status","publishedAt","Date","toISOString"],"mappings":"AAEA;;;;;;;;;;CAUC,GACD,OAAO,MAAMA,qBAAiD,CAAC,EAAEC,IAAI,EAAEC,WAAW,EAAE;IAClF,MAAMC,oBAAoBF,KAAKG,OAAO,KAAK,eAAeF,aAAaE,YAAY;IACnF,IAAID,qBAAqB,CAACF,KAAKI,WAAW,EAAE;QAC1CJ,KAAKI,WAAW,GAAG,IAAIC,OAAOC,WAAW;IAC3C;IACA,OAAON;AACT,EAAC"} \ No newline at end of file diff --git a/dist/modules/hooks/trackSlugHistory.d.ts b/dist/modules/hooks/trackSlugHistory.d.ts new file mode 100644 index 0000000..fff4055 --- /dev/null +++ b/dist/modules/hooks/trackSlugHistory.d.ts @@ -0,0 +1,17 @@ +import type { CollectionBeforeChangeHook } from 'payload'; +/** + * When a document's slug changes, appends the OLD slug to a `slugHistory` array + * field. The project reads slugHistory to serve a 301 redirect from old URLs to + * the current one — so changing a slug doesn't 404 the old address (a real SEO + * loss / audit finding). + * + * Requires a `slugHistory` field on the collection: + * { name: 'slugHistory', type: 'array', fields: [{ name: 'slug', type: 'text' }], + * admin: { readOnly: true } } + * + * hooks: { beforeChange: [trackSlugHistoryHook] } + * + * The project then, in resolveRoute or a redirect check, looks up slugHistory and + * 301s to the current slug. See docs/hooks.md. + */ +export declare const trackSlugHistoryHook: CollectionBeforeChangeHook; diff --git a/dist/modules/hooks/trackSlugHistory.js b/dist/modules/hooks/trackSlugHistory.js new file mode 100644 index 0000000..f7e3223 --- /dev/null +++ b/dist/modules/hooks/trackSlugHistory.js @@ -0,0 +1,33 @@ +/** + * When a document's slug changes, appends the OLD slug to a `slugHistory` array + * field. The project reads slugHistory to serve a 301 redirect from old URLs to + * the current one — so changing a slug doesn't 404 the old address (a real SEO + * loss / audit finding). + * + * Requires a `slugHistory` field on the collection: + * { name: 'slugHistory', type: 'array', fields: [{ name: 'slug', type: 'text' }], + * admin: { readOnly: true } } + * + * hooks: { beforeChange: [trackSlugHistoryHook] } + * + * The project then, in resolveRoute or a redirect check, looks up slugHistory and + * 301s to the current slug. See docs/hooks.md. + */ export const trackSlugHistoryHook = ({ data, originalDoc })=>{ + const oldSlug = originalDoc?.slug; + const newSlug = data.slug; + if (typeof oldSlug === 'string' && typeof newSlug === 'string' && oldSlug !== newSlug && oldSlug.length > 0) { + const history = Array.isArray(data.slugHistory) ? data.slugHistory : Array.isArray(originalDoc?.slugHistory) ? originalDoc.slugHistory : []; + // Avoid duplicates; don't record the new slug itself. + if (!history.some((h)=>h?.slug === oldSlug)) { + data.slugHistory = [ + ...history, + { + slug: oldSlug + } + ]; + } + } + return data; +}; + +//# sourceMappingURL=trackSlugHistory.js.map \ No newline at end of file diff --git a/dist/modules/hooks/trackSlugHistory.js.map b/dist/modules/hooks/trackSlugHistory.js.map new file mode 100644 index 0000000..fc63146 --- /dev/null +++ b/dist/modules/hooks/trackSlugHistory.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/hooks/trackSlugHistory.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\n/**\n * When a document's slug changes, appends the OLD slug to a `slugHistory` array\n * field. The project reads slugHistory to serve a 301 redirect from old URLs to\n * the current one — so changing a slug doesn't 404 the old address (a real SEO\n * loss / audit finding).\n *\n * Requires a `slugHistory` field on the collection:\n * { name: 'slugHistory', type: 'array', fields: [{ name: 'slug', type: 'text' }],\n * admin: { readOnly: true } }\n *\n * hooks: { beforeChange: [trackSlugHistoryHook] }\n *\n * The project then, in resolveRoute or a redirect check, looks up slugHistory and\n * 301s to the current slug. See docs/hooks.md.\n */\nexport const trackSlugHistoryHook: CollectionBeforeChangeHook = ({ data, originalDoc }) => {\n const oldSlug = originalDoc?.slug\n const newSlug = data.slug\n if (\n typeof oldSlug === 'string' &&\n typeof newSlug === 'string' &&\n oldSlug !== newSlug &&\n oldSlug.length > 0\n ) {\n const history: Array<{ slug: string }> = Array.isArray(data.slugHistory)\n ? data.slugHistory\n : Array.isArray(originalDoc?.slugHistory)\n ? originalDoc.slugHistory\n : []\n // Avoid duplicates; don't record the new slug itself.\n if (!history.some((h) => h?.slug === oldSlug)) {\n data.slugHistory = [...history, { slug: oldSlug }]\n }\n }\n return data\n}\n"],"names":["trackSlugHistoryHook","data","originalDoc","oldSlug","slug","newSlug","length","history","Array","isArray","slugHistory","some","h"],"mappings":"AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,MAAMA,uBAAmD,CAAC,EAAEC,IAAI,EAAEC,WAAW,EAAE;IACpF,MAAMC,UAAUD,aAAaE;IAC7B,MAAMC,UAAUJ,KAAKG,IAAI;IACzB,IACE,OAAOD,YAAY,YACnB,OAAOE,YAAY,YACnBF,YAAYE,WACZF,QAAQG,MAAM,GAAG,GACjB;QACA,MAAMC,UAAmCC,MAAMC,OAAO,CAACR,KAAKS,WAAW,IACnET,KAAKS,WAAW,GAChBF,MAAMC,OAAO,CAACP,aAAaQ,eACzBR,YAAYQ,WAAW,GACvB,EAAE;QACR,sDAAsD;QACtD,IAAI,CAACH,QAAQI,IAAI,CAAC,CAACC,IAAMA,GAAGR,SAASD,UAAU;YAC7CF,KAAKS,WAAW,GAAG;mBAAIH;gBAAS;oBAAEH,MAAMD;gBAAQ;aAAE;QACpD;IACF;IACA,OAAOF;AACT,EAAC"} \ No newline at end of file diff --git a/dist/modules/hooks/validateUniqueRole.d.ts b/dist/modules/hooks/validateUniqueRole.d.ts new file mode 100644 index 0000000..784e543 --- /dev/null +++ b/dist/modules/hooks/validateUniqueRole.d.ts @@ -0,0 +1,16 @@ +import type { FieldHook } from 'payload'; +/** + * Field hook for a System Page role relationship in SiteSettings: ensures a page + * isn't assigned to two roles at once (e.g. the same page as both homepage and + * privacyPolicy), which would make routing ambiguous. + * + * Attach to each role field's beforeValidate. `siblingFields` are the OTHER role + * field names to check against. + * + * hooks: { beforeValidate: [buildValidateUniqueRole({ + * siblingFields: ['privacyPolicy', 'cookiePolicy', 'termsOfService'], + * })] } + */ +export declare function buildValidateUniqueRole(args: { + siblingFields: string[]; +}): FieldHook; diff --git a/dist/modules/hooks/validateUniqueRole.js b/dist/modules/hooks/validateUniqueRole.js new file mode 100644 index 0000000..857f975 --- /dev/null +++ b/dist/modules/hooks/validateUniqueRole.js @@ -0,0 +1,31 @@ +import { APIError } from 'payload'; +/** + * Field hook for a System Page role relationship in SiteSettings: ensures a page + * isn't assigned to two roles at once (e.g. the same page as both homepage and + * privacyPolicy), which would make routing ambiguous. + * + * Attach to each role field's beforeValidate. `siblingFields` are the OTHER role + * field names to check against. + * + * hooks: { beforeValidate: [buildValidateUniqueRole({ + * siblingFields: ['privacyPolicy', 'cookiePolicy', 'termsOfService'], + * })] } + */ export function buildValidateUniqueRole(args) { + return ({ field, siblingData, value })=>{ + if (value == null) { + return value; + } + const thisId = typeof value === 'object' ? value.id : value; + for (const sibling of args.siblingFields){ + const other = siblingData?.[sibling]; + const otherId = other && typeof other === 'object' ? other.id : other; + if (otherId != null && String(otherId) === String(thisId)) { + const name = typeof field === 'object' && 'name' in field ? field.name : 'ta rola'; + throw new APIError(`Ta sama strona jest przypisana do "${name}" i "${sibling}". ` + `Każda rola systemowa musi wskazywać inną stronę.`, 400); + } + } + return value; + }; +} + +//# sourceMappingURL=validateUniqueRole.js.map \ No newline at end of file diff --git a/dist/modules/hooks/validateUniqueRole.js.map b/dist/modules/hooks/validateUniqueRole.js.map new file mode 100644 index 0000000..909496f --- /dev/null +++ b/dist/modules/hooks/validateUniqueRole.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/modules/hooks/validateUniqueRole.ts"],"sourcesContent":["import type { FieldHook } from 'payload'\n\nimport { APIError } from 'payload'\n\n/**\n * Field hook for a System Page role relationship in SiteSettings: ensures a page\n * isn't assigned to two roles at once (e.g. the same page as both homepage and\n * privacyPolicy), which would make routing ambiguous.\n *\n * Attach to each role field's beforeValidate. `siblingFields` are the OTHER role\n * field names to check against.\n *\n * hooks: { beforeValidate: [buildValidateUniqueRole({\n * siblingFields: ['privacyPolicy', 'cookiePolicy', 'termsOfService'],\n * })] }\n */\nexport function buildValidateUniqueRole(args: { siblingFields: string[] }): FieldHook {\n return ({ field, siblingData, value }) => {\n if (value == null) {return value}\n const thisId = typeof value === 'object' ? (value as { id?: unknown }).id : value\n for (const sibling of args.siblingFields) {\n const other = (siblingData as Record)?.[sibling]\n const otherId = other && typeof other === 'object' ? (other as { id?: unknown }).id : other\n if (otherId != null && String(otherId) === String(thisId)) {\n const name = typeof field === 'object' && 'name' in field ? field.name : 'ta rola'\n throw new APIError(\n `Ta sama strona jest przypisana do \"${name}\" i \"${sibling}\". ` +\n `Każda rola systemowa musi wskazywać inną stronę.`,\n 400,\n )\n }\n }\n return value\n }\n}\n"],"names":["APIError","buildValidateUniqueRole","args","field","siblingData","value","thisId","id","sibling","siblingFields","other","otherId","String","name"],"mappings":"AAEA,SAASA,QAAQ,QAAQ,UAAS;AAElC;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,wBAAwBC,IAAiC;IACvE,OAAO,CAAC,EAAEC,KAAK,EAAEC,WAAW,EAAEC,KAAK,EAAE;QACnC,IAAIA,SAAS,MAAM;YAAC,OAAOA;QAAK;QAChC,MAAMC,SAAS,OAAOD,UAAU,WAAW,AAACA,MAA2BE,EAAE,GAAGF;QAC5E,KAAK,MAAMG,WAAWN,KAAKO,aAAa,CAAE;YACxC,MAAMC,QAASN,aAAyC,CAACI,QAAQ;YACjE,MAAMG,UAAUD,SAAS,OAAOA,UAAU,WAAW,AAACA,MAA2BH,EAAE,GAAGG;YACtF,IAAIC,WAAW,QAAQC,OAAOD,aAAaC,OAAON,SAAS;gBACzD,MAAMO,OAAO,OAAOV,UAAU,YAAY,UAAUA,QAAQA,MAAMU,IAAI,GAAG;gBACzE,MAAM,IAAIb,SACR,CAAC,mCAAmC,EAAEa,KAAK,KAAK,EAAEL,QAAQ,GAAG,CAAC,GAC5D,CAAC,gDAAgD,CAAC,EACpD;YAEJ;QACF;QACA,OAAOH;IACT;AACF"} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index bf81ff0..8d4b6ba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -103,11 +103,17 @@ export default buildConfig({ | Moduł | Opis | Dok | |---|---|---| -| i18n | Lokalizacja, negocjacja locale, ścieżki URL | [i18n.md](./i18n.md) | +| i18n | Lokalizacja, negocjacja locale, ścieżki URL, strona jednojęzyczna | [i18n.md](./i18n.md) | +| hooks | Hooki: revalidate ISR, slug history 301, ochrona stron systemowych | [hooks.md](./hooks.md) | +| kolekcje-katalog | Jakie kolekcje budować, kiedy, jak wpiąć (minimum nie maksimum) | [kolekcje-katalog.md](./kolekcje-katalog.md) | +| fundamenty-projektu | Struktura katalogów, nazewnictwo, konwencje | [fundamenty-projektu.md](./fundamenty-projektu.md) | +| deployment | Zmienne .env, ISR/SSG, force-dynamic, Coolify/Docker | [deployment.md](./deployment.md) | | pages | System pages (homepage/privacy/cookies) → ścieżki | [pages.md](./pages.md) | | access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) | | payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) | | seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) | +| wymagania-prawne | **Polityki, regulaminy, baner cookies, RODO (compliance)** | [wymagania-prawne.md](./wymagania-prawne.md) | +| standardy-kodu | **Dobre praktyki senior: typy, architektura, antywzorce** | [standardy-kodu.md](./standardy-kodu.md) | | architektura-tresci | **Jak budować, żeby klient wszystko edytował** (filozofia CMS) | [architektura-tresci.md](./architektura-tresci.md) | | blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) | | consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) | @@ -117,6 +123,7 @@ export default buildConfig({ | analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) | | slug | Auto-slug z tytułu, per locale | [slug.md](./slug.md) | | notifications | Teksty wyników akcji (formularz) per język | [notifications.md](./notifications.md) | +| storage | Media na Cloudflare R2 (offload z .env) | [storage.md](./storage.md) | | security | Nagłówki bezpieczeństwa HTTP (HSTS, X-Frame...) | [security.md](./security.md) | | content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) | diff --git a/docs/hooks.md b/docs/hooks.md new file mode 100644 index 0000000..a5313bd --- /dev/null +++ b/docs/hooks.md @@ -0,0 +1,150 @@ +# Hooki pluginu — automatyzacja tworzenia stron + +Plugin dostarcza hooki, które zdejmują z projektów powtarzalną robotę. Wpinasz je +w kolekcje; działają automatycznie. Wszystkie gotowe do użycia (import z pluginu). + +Powiązane: [pages.md](./pages.md), [seo.md](./seo.md), [wymagania-prawne.md](./wymagania-prawne.md). + +--- + +## buildRevalidateHook — ISR odświeżany po zapisie (NAJWAŻNIEJSZY) + +Bez tego ISR ma haczyk: redaktor zapisuje stronę i CZEKA na revalidate (do +godziny). Z tym — zapisuje i OD RAZU widzi zmianę. To warunek, żeby ISR był +używalny dla CMS. + +```ts +// kolekcja Pages — z pliku projektu, który MOŻE importować next/cache +import { revalidatePath } from 'next/cache' +import { buildRevalidateHook } from '@intecion/ipal-kit' +import { i18nConfig } from '@/i18n.config' + +const { afterChange, afterDelete } = buildRevalidateHook({ + revalidatePath, // wstrzykiwany — plugin NIE importuje next/cache + config: i18nConfig, +}) + +export const Pages: CollectionConfig = { + slug: 'pages', + hooks: { afterChange: [afterChange], afterDelete: [afterDelete] }, + // ... +} +``` + +**Dlaczego revalidatePath wstrzykiwany:** plugin nie importuje `next/cache` (to +by wywaliło Payload przy generate:importmap / czystym Node). Projekt podaje. + +Obsługuje: wszystkie języki, root (home), zmianę slug (rewaliduje stary I nowy +path — stary URL nie serwuje starej treści), delete. + +--- + +## setPublishedAtHook — auto-data publikacji + +Ustawia `publishedAt` na teraz przy pierwszej publikacji (jeśli puste). Redaktor +nie wpisuje daty ręcznie; data jest dokładna dla Article JSON-LD i sitemap. + +```ts +import { setPublishedAtHook } from '@intecion/ipal-kit' +// kolekcja z draftami (blog, artykuły): +hooks: { beforeChange: [setPublishedAtHook] } +``` + +Ustawia tylko przy przejściu na published; nie nadpisuje istniejącej daty +(redaktor może backdatować ręcznie). + +--- + +## buildPreventDeleteSystemPage — ochrona stron systemowych + +Blokuje usunięcie strony przypisanej do roli (homepage, privacyPolicy, +cookiePolicy, termsOfService). Redaktor nie usunie przypadkiem polityki +prywatności albo strony głównej → nie rozbije routingu i linków compliance. + +```ts +import { buildPreventDeleteSystemPage } from '@intecion/ipal-kit' +hooks: { beforeDelete: [buildPreventDeleteSystemPage({ settingsSlug: 'site-settings' })] } +``` + +Żeby usunąć — najpierw odłącz rolę w Site Settings (świadoma decyzja). + +--- + +## buildValidateUniqueRole — jedna strona = jedna rola + +Zapobiega przypisaniu tej samej strony do dwóch ról systemowych (np. homepage I +privacyPolicy naraz → niejednoznaczny routing). + +```ts +import { buildValidateUniqueRole } from '@intecion/ipal-kit' +// na polu roli w SiteSettings: +{ + name: 'privacyPolicy', + type: 'relationship', + relationTo: 'pages', + hooks: { beforeValidate: [buildValidateUniqueRole({ + siblingFields: ['homepage', 'cookiePolicy', 'termsOfService'], + })] }, +} +``` + +--- + +## trackSlugHistoryHook — auto-redirect 301 przy zmianie slug + +Gdy slug się zmienia, zapisuje STARY slug do pola `slugHistory`. Projekt czyta to +i robi 301 ze starego URL na nowy → zmiana adresu nie daje 404 (realna strata SEO +z audytu). + +```ts +import { trackSlugHistoryHook } from '@intecion/ipal-kit' + +export const Pages: CollectionConfig = { + fields: [ + // ... + { name: 'slugHistory', type: 'array', admin: { readOnly: true }, + fields: [{ name: 'slug', type: 'text' }] }, + ], + hooks: { beforeChange: [trackSlugHistoryHook] }, +} +``` + +Projekt w resolveRoute / sprawdzeniu redirectów: jeśli żądany slug jest w +slugHistory jakiejś strony → 301 na jej aktualny slug. Przykład: + +```ts +// w page.tsx, gdy resolveRoute nie znajdzie strony po slug: +const byHistory = await payload.find({ + collection: 'pages', + where: { 'slugHistory.slug': { equals: requestedSlug } }, + limit: 1, +}) +if (byHistory.docs[0]) { + redirect(`/${locale}/${byHistory.docs[0].slug}`) // 301 na aktualny +} +``` + +--- + +## KOLEJNOŚĆ hooków (ważne) + +W jednej kolekcji hooki tej samej fazy uruchamiają się po kolei. Typowa Media: +```ts +hooks: { + beforeOperation: [normalizeFilenameHook], // czyste nazwy + afterChange: [afterChange], // revalidate + afterDelete: [afterDelete], +} +``` +Typowa Pages: +```ts +hooks: { + beforeChange: [setPublishedAtHook, trackSlugHistoryHook], + beforeDelete: [buildPreventDeleteSystemPage(...)], + afterChange: [afterChange], // revalidate + afterDelete: [afterDelete], +} +``` + +Które hooki wpiąć zależy od kolekcji — nie każda potrzebuje wszystkich (blog: +setPublishedAt; wszystkie z URL: revalidate + slugHistory; Pages: + preventDelete). \ No newline at end of file diff --git a/src/globals/Notifications/index.ts b/src/globals/Notifications/index.ts index d3a1e4d..fbecd04 100644 --- a/src/globals/Notifications/index.ts +++ b/src/globals/Notifications/index.ts @@ -1,4 +1,5 @@ import type { GlobalConfig } from 'payload' + import { notificationsFields } from './fields.js' /** @@ -9,10 +10,10 @@ import { notificationsFields } from './fields.js' export function buildNotifications(): GlobalConfig { return { slug: 'notifications', - label: 'Notifications', access: { read: () => true, // texts are public-facing (shown to end users) }, fields: notificationsFields, + label: 'Notifications', } } diff --git a/src/index.ts b/src/index.ts index d12b5ff..f586ccc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,6 +60,13 @@ export type { FormsOption, } from './modules/forms/types.js' export { createContentHelpers } from './modules/frontend/index.js' +export { + buildPreventDeleteSystemPage, + buildRevalidateHook, + buildValidateUniqueRole, + setPublishedAtHook, + trackSlugHistoryHook, +} from './modules/hooks/index.js' export type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js' export { buildLocalizedPath, diff --git a/src/modules/hooks/buildRevalidateHook.ts b/src/modules/hooks/buildRevalidateHook.ts new file mode 100644 index 0000000..0286587 --- /dev/null +++ b/src/modules/hooks/buildRevalidateHook.ts @@ -0,0 +1,101 @@ +import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload' + +import type { I18nConfig } from '../i18n/index.js' + +import { buildLocalizedPath, getLocaleCodes } from '../i18n/index.js' + +type RevalidateFn = (path: string) => void + +type BuildRevalidateHookArgs = { + config: I18nConfig + /** Home slug (string or per-locale map) — home revalidates the root. */ + homeSlug?: Record | string + /** + * next/cache revalidatePath, INJECTED by the project. The plugin never imports + * next/cache itself — that would crash when Payload runs as plain Node + * (generate:importmap). The project passes it: `revalidatePath` from 'next/cache'. + */ + revalidatePath: RevalidateFn +} + +type DocWithSlug = { slug?: unknown } + +/** Resolve a doc's path in one locale (root for home). Null if no slug there. */ +function pathForLocale( + doc: DocWithSlug, + locale: string, + config: I18nConfig, + homeSlug?: Record | string, +): null | string { + const slugField = doc.slug + const slug = + typeof slugField === 'string' + ? slugField + : slugField && typeof slugField === 'object' + ? ((slugField as Record)[locale] as string | undefined) + : undefined + if (!slug) {return null} + return buildLocalizedPath({ config, homeSlug, locale, slugs: { [locale]: slug } }) ?? null +} + +/** + * Builds afterChange + afterDelete hooks that revalidate a page's ISR cache when + * an editor saves or deletes it — so changes appear immediately instead of + * waiting for the revalidate window. Without this, ISR means editors wait; with + * it, ISR is usable for a CMS. + * + * Handles every locale, the root (home), AND a changed slug (revalidates both the + * old and new path so neither goes stale). revalidatePath is injected — the + * plugin never imports next/cache (safe under generate:importmap / plain Node). + * + * // in your Media/Pages collection config, from a project file that CAN import next/cache: + * import { revalidatePath } from 'next/cache' + * import { buildRevalidateHook } from '@intecion/ipal-kit' + * const { afterChange, afterDelete } = buildRevalidateHook({ revalidatePath, config: i18nConfig }) + * // hooks: { afterChange: [afterChange], afterDelete: [afterDelete] } + */ +export function buildRevalidateHook({ + config, + homeSlug, + revalidatePath, +}: BuildRevalidateHookArgs): { + afterChange: CollectionAfterChangeHook + afterDelete: CollectionAfterDeleteHook +} { + const locales = getLocaleCodes(config) + + const afterChange: CollectionAfterChangeHook = ({ doc, previousDoc }) => { + const seen = new Set() + for (const locale of locales) { + // New path. + const newPath = pathForLocale(doc as DocWithSlug, locale, config, homeSlug) + if (newPath && !seen.has(newPath)) { + revalidatePath(newPath) + seen.add(newPath) + } + // Old path, if the slug changed — so the old URL doesn't serve stale content. + if (previousDoc) { + const oldPath = pathForLocale(previousDoc as DocWithSlug, locale, config, homeSlug) + if (oldPath && oldPath !== newPath && !seen.has(oldPath)) { + revalidatePath(oldPath) + seen.add(oldPath) + } + } + } + return doc + } + + const afterDelete: CollectionAfterDeleteHook = ({ doc }) => { + const seen = new Set() + for (const locale of locales) { + const path = pathForLocale(doc as DocWithSlug, locale, config, homeSlug) + if (path && !seen.has(path)) { + revalidatePath(path) + seen.add(path) + } + } + return doc + } + + return { afterChange, afterDelete } +} diff --git a/src/modules/hooks/index.ts b/src/modules/hooks/index.ts new file mode 100644 index 0000000..a950423 --- /dev/null +++ b/src/modules/hooks/index.ts @@ -0,0 +1,10 @@ +// Re-eksport hooków domenowych (mieszkają w swoich modułach, tu dla przeglądu — +// żeby był jeden katalog "wszystkie hooki pluginu"). Źródło prawdy to ich moduły. +export { normalizeFilenameHook } from '../media/index.js' +export { buildAutoFillMetaHook, validateFaviconField } from '../seo/index.js' +export { buildRevalidateHook } from './buildRevalidateHook.js' +export { buildPreventDeleteSystemPage } from './preventDeleteSystemPage.js' +export { setPublishedAtHook } from './setPublishedAt.js' + +export { trackSlugHistoryHook } from './trackSlugHistory.js' +export { buildValidateUniqueRole } from './validateUniqueRole.js' diff --git a/src/modules/hooks/preventDeleteSystemPage.ts b/src/modules/hooks/preventDeleteSystemPage.ts new file mode 100644 index 0000000..350ea47 --- /dev/null +++ b/src/modules/hooks/preventDeleteSystemPage.ts @@ -0,0 +1,45 @@ +import type { CollectionBeforeDeleteHook } from 'payload' + +import { APIError } from 'payload' + +/** + * Blocks deletion of a page assigned a System Page role (homepage, + * privacyPolicy, cookiePolicy, termsOfService). An editor deleting the privacy + * policy or homepage by accident would break routing and compliance links; this + * stops it with a clear error. They must unassign the role first (deliberate). + * + * Reads the role assignments from SiteSettings (which page holds which role). + * + * hooks: { beforeDelete: [buildPreventDeleteSystemPage({ settingsSlug: 'site-settings' })] } + */ +export function buildPreventDeleteSystemPage( + args: { roleFields?: string[]; settingsSlug?: string } = {}, +): CollectionBeforeDeleteHook { + const settingsSlug = args.settingsSlug ?? 'site-settings' + const roleFields = args.roleFields ?? [ + 'homepage', + 'privacyPolicy', + 'cookiePolicy', + 'termsOfService', + ] + + return async ({ id, req }) => { + const settings = (await req.payload + .findGlobal({ slug: settingsSlug as never, depth: 0 }) + .catch(() => null)) as null | Record + if (!settings) {return} + + for (const field of roleFields) { + const assigned = settings[field] + const assignedId = + assigned && typeof assigned === 'object' ? (assigned as { id?: unknown }).id : assigned + if (assignedId != null && String(assignedId) === String(id)) { + throw new APIError( + `Nie można usunąć strony przypisanej do roli systemowej "${field}". ` + + `Najpierw odłącz rolę w Site Settings.`, + 400, + ) + } + } + } +} diff --git a/src/modules/hooks/setPublishedAt.ts b/src/modules/hooks/setPublishedAt.ts new file mode 100644 index 0000000..9f59f9c --- /dev/null +++ b/src/modules/hooks/setPublishedAt.ts @@ -0,0 +1,20 @@ +import type { CollectionBeforeChangeHook } from 'payload' + +/** + * Sets `publishedAt` to now the first time a document transitions to published, + * if it isn't already set. Saves editors from filling the date manually and + * keeps blog/article dates accurate for Article JSON-LD and sitemaps. + * + * Attach to collections with drafts enabled (blog, articles): + * hooks: { beforeChange: [setPublishedAtHook] } + * + * Only sets on the published transition; never overwrites an existing date + * (an editor can still backdate manually). + */ +export const setPublishedAtHook: CollectionBeforeChangeHook = ({ data, originalDoc }) => { + const becomingPublished = data._status === 'published' && originalDoc?._status !== 'published' + if (becomingPublished && !data.publishedAt) { + data.publishedAt = new Date().toISOString() + } + return data +} diff --git a/src/modules/hooks/trackSlugHistory.ts b/src/modules/hooks/trackSlugHistory.ts new file mode 100644 index 0000000..40b7081 --- /dev/null +++ b/src/modules/hooks/trackSlugHistory.ts @@ -0,0 +1,38 @@ +import type { CollectionBeforeChangeHook } from 'payload' + +/** + * When a document's slug changes, appends the OLD slug to a `slugHistory` array + * field. The project reads slugHistory to serve a 301 redirect from old URLs to + * the current one — so changing a slug doesn't 404 the old address (a real SEO + * loss / audit finding). + * + * Requires a `slugHistory` field on the collection: + * { name: 'slugHistory', type: 'array', fields: [{ name: 'slug', type: 'text' }], + * admin: { readOnly: true } } + * + * hooks: { beforeChange: [trackSlugHistoryHook] } + * + * The project then, in resolveRoute or a redirect check, looks up slugHistory and + * 301s to the current slug. See docs/hooks.md. + */ +export const trackSlugHistoryHook: CollectionBeforeChangeHook = ({ data, originalDoc }) => { + const oldSlug = originalDoc?.slug + const newSlug = data.slug + if ( + typeof oldSlug === 'string' && + typeof newSlug === 'string' && + oldSlug !== newSlug && + oldSlug.length > 0 + ) { + const history: Array<{ slug: string }> = Array.isArray(data.slugHistory) + ? data.slugHistory + : Array.isArray(originalDoc?.slugHistory) + ? originalDoc.slugHistory + : [] + // Avoid duplicates; don't record the new slug itself. + if (!history.some((h) => h?.slug === oldSlug)) { + data.slugHistory = [...history, { slug: oldSlug }] + } + } + return data +} diff --git a/src/modules/hooks/validateUniqueRole.ts b/src/modules/hooks/validateUniqueRole.ts new file mode 100644 index 0000000..8b4796e --- /dev/null +++ b/src/modules/hooks/validateUniqueRole.ts @@ -0,0 +1,35 @@ +import type { FieldHook } from 'payload' + +import { APIError } from 'payload' + +/** + * Field hook for a System Page role relationship in SiteSettings: ensures a page + * isn't assigned to two roles at once (e.g. the same page as both homepage and + * privacyPolicy), which would make routing ambiguous. + * + * Attach to each role field's beforeValidate. `siblingFields` are the OTHER role + * field names to check against. + * + * hooks: { beforeValidate: [buildValidateUniqueRole({ + * siblingFields: ['privacyPolicy', 'cookiePolicy', 'termsOfService'], + * })] } + */ +export function buildValidateUniqueRole(args: { siblingFields: string[] }): FieldHook { + return ({ field, siblingData, value }) => { + if (value == null) {return value} + const thisId = typeof value === 'object' ? (value as { id?: unknown }).id : value + for (const sibling of args.siblingFields) { + const other = (siblingData as Record)?.[sibling] + const otherId = other && typeof other === 'object' ? (other as { id?: unknown }).id : other + if (otherId != null && String(otherId) === String(thisId)) { + const name = typeof field === 'object' && 'name' in field ? field.name : 'ta rola' + throw new APIError( + `Ta sama strona jest przypisana do "${name}" i "${sibling}". ` + + `Każda rola systemowa musi wskazywać inną stronę.`, + 400, + ) + } + } + return value + } +}