Added protecting hooks
This commit is contained in:
Vendored
+2
-2
@@ -6,11 +6,11 @@ import { notificationsFields } from './fields.js';
|
|||||||
*/ export function buildNotifications() {
|
*/ export function buildNotifications() {
|
||||||
return {
|
return {
|
||||||
slug: 'notifications',
|
slug: 'notifications',
|
||||||
label: 'Notifications',
|
|
||||||
access: {
|
access: {
|
||||||
read: ()=>true
|
read: ()=>true
|
||||||
},
|
},
|
||||||
fields: notificationsFields
|
fields: notificationsFields,
|
||||||
|
label: 'Notifications'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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"}
|
{"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"}
|
||||||
Vendored
+1
@@ -16,6 +16,7 @@ export type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js';
|
|||||||
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
||||||
export type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption, } from './modules/forms/types.js';
|
export type { FormsCollectionOverrides, FormsFieldsOverride, FormsOption, } from './modules/forms/types.js';
|
||||||
export { createContentHelpers } from './modules/frontend/index.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 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 { 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 type { LocaleMiddlewareResult } from './modules/i18n/index.js';
|
||||||
|
|||||||
Vendored
+1
@@ -10,6 +10,7 @@ export { mailAdapter } from './modules/email/mailAdapter.js';
|
|||||||
export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js';
|
export { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js';
|
||||||
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
||||||
export { createContentHelpers } from './modules/frontend/index.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 { 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 { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
||||||
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+35
@@ -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, string> | 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 {};
|
||||||
+71
@@ -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
|
||||||
File diff suppressed because one or more lines are too long
Vendored
+7
@@ -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';
|
||||||
Vendored
+11
@@ -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
|
||||||
Vendored
+1
@@ -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"}
|
||||||
+15
@@ -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;
|
||||||
+37
@@ -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
|
||||||
@@ -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<string, unknown>\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"}
|
||||||
+13
@@ -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;
|
||||||
Vendored
+19
@@ -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
|
||||||
+1
@@ -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"}
|
||||||
+17
@@ -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;
|
||||||
+33
@@ -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
|
||||||
+1
@@ -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"}
|
||||||
+16
@@ -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;
|
||||||
+31
@@ -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
|
||||||
@@ -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<string, unknown>)?.[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"}
|
||||||
+8
-1
@@ -103,11 +103,17 @@ export default buildConfig({
|
|||||||
|
|
||||||
| Moduł | Opis | Dok |
|
| 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) |
|
| pages | System pages (homepage/privacy/cookies) → ścieżki | [pages.md](./pages.md) |
|
||||||
| access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
|
| access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
|
||||||
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
|
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
|
||||||
| seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.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) |
|
| 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) |
|
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
|
||||||
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.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) |
|
| analytics | GA4 / GTM spięte z Consent Mode | [analytics.md](./analytics.md) |
|
||||||
| slug | Auto-slug z tytułu, per locale | [slug.md](./slug.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) |
|
| 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) |
|
| 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) |
|
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
|
||||||
|
|
||||||
|
|||||||
+150
@@ -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).
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GlobalConfig } from 'payload'
|
import type { GlobalConfig } from 'payload'
|
||||||
|
|
||||||
import { notificationsFields } from './fields.js'
|
import { notificationsFields } from './fields.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,10 +10,10 @@ import { notificationsFields } from './fields.js'
|
|||||||
export function buildNotifications(): GlobalConfig {
|
export function buildNotifications(): GlobalConfig {
|
||||||
return {
|
return {
|
||||||
slug: 'notifications',
|
slug: 'notifications',
|
||||||
label: 'Notifications',
|
|
||||||
access: {
|
access: {
|
||||||
read: () => true, // texts are public-facing (shown to end users)
|
read: () => true, // texts are public-facing (shown to end users)
|
||||||
},
|
},
|
||||||
fields: notificationsFields,
|
fields: notificationsFields,
|
||||||
|
label: 'Notifications',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ export type {
|
|||||||
FormsOption,
|
FormsOption,
|
||||||
} from './modules/forms/types.js'
|
} from './modules/forms/types.js'
|
||||||
export { createContentHelpers } from './modules/frontend/index.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 type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'
|
||||||
export {
|
export {
|
||||||
buildLocalizedPath,
|
buildLocalizedPath,
|
||||||
|
|||||||
@@ -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, string> | 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, string> | string,
|
||||||
|
): null | string {
|
||||||
|
const slugField = doc.slug
|
||||||
|
const slug =
|
||||||
|
typeof slugField === 'string'
|
||||||
|
? slugField
|
||||||
|
: slugField && typeof slugField === 'object'
|
||||||
|
? ((slugField as Record<string, unknown>)[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<string>()
|
||||||
|
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<string>()
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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<string, unknown>
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<string, unknown>)?.[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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user