Added protecting hooks

This commit is contained in:
2026-09-25 12:42:38 +02:00
parent 9a18434f4a
commit b82ef82f50
33 changed files with 733 additions and 6 deletions
+101
View File
@@ -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 }
}
+10
View File
@@ -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,
)
}
}
}
}
+20
View File
@@ -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
}
+38
View File
@@ -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
}
+35
View File
@@ -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
}
}