Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6556001
feat: support custom slugify functions
jacobsfletch Oct 8, 2025
5c7e9a2
Merge branch 'main' into feat/slugify-override
jacobsfletch Nov 25, 2025
790c452
support client-side regen using custom slugify
jacobsfletch Nov 25, 2025
406afea
Merge branch 'main' into feat/slugify-override
jacobsfletch Nov 25, 2025
e8e1418
add test and scope gen field name
jacobsfletch Nov 25, 2025
02a89af
convert to server fn
jacobsfletch Nov 26, 2025
edf2c4a
lookup config after authorization and cleanup jsdocs
jacobsfletch Nov 26, 2025
acf984c
revert gen checkbox name change and cleanup
jacobsfletch Nov 26, 2025
4b2f677
fix build
jacobsfletch Nov 26, 2025
f5dbc6e
fix tests
jacobsfletch Nov 26, 2025
80686c8
Merge branch 'main' into feat/slugify-override
jacobsfletch Nov 26, 2025
f7d8ab1
poc fix sort test
jacobsfletch Nov 26, 2025
d3ae162
deflake sort test
jacobsfletch Nov 26, 2025
db486e2
Merge branch 'main' into feat/slugify-override
jacobsfletch Nov 26, 2025
b94c269
Merge branch 'test/deflake-sort' into feat/slugify-override
jacobsfletch Nov 26, 2025
6a13349
move custom slugify to field.custom instead of unused serverProps
jacobsfletch Nov 26, 2025
657ff8d
Merge branch 'main' into feat/slugify-override
jacobsfletch Dec 1, 2025
2a901e7
add translations
jacobsfletch Dec 1, 2025
eb19514
modify args
jacobsfletch Dec 1, 2025
15518e2
temp
jacobsfletch Dec 1, 2025
a3db160
feat: expand getDataByPath util
jacobsfletch Dec 1, 2025
adc5014
splits logic
jacobsfletch Dec 1, 2025
3e92036
Merge branch 'feat/get-data-by-path' into feat/slugify-override
jacobsfletch Dec 1, 2025
ce4064a
use getDataByPath
jacobsfletch Dec 1, 2025
197a8a2
Merge branch 'main' into feat/slugify-override
jacobsfletch Dec 2, 2025
5c7cbb8
cleanup
jacobsfletch Dec 2, 2025
4f4ab23
support only top-level fields
jacobsfletch Dec 2, 2025
dad046f
revert field lookup
jacobsfletch Dec 2, 2025
a30e902
rename to valueToSlugify
jacobsfletch Dec 2, 2025
26138a9
improve docs
jacobsfletch Dec 2, 2025
7277672
fix build
jacobsfletch Dec 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/fields/text.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ The slug field exposes a few top-level config options for easy customization:
| `localized` | Enable localization on the `slug` and `generateSlug` fields. Defaults to `false`. |
| `position` | The position of the slug field. [More details](./overview#admin-options). |
| `required` | Require the slug field. Defaults to `true`. |
| `slugify` | Override the default slugify function. Receives the value of the `fieldToUse` field and must return a string. |

### Slug Overrides

Expand Down
1 change: 1 addition & 0 deletions packages/next/src/layouts/Root/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const RootLayout = async ({
importMap,
user: req.user,
})

await applyLocaleFiltering({ clientConfig, config, req })

return (
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/utilities/handleServerFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { renderDocumentHandler } from '../views/Document/handleServerFunction.js
import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js'
import { renderListHandler } from '../views/List/handleServerFunction.js'
import { initReq } from './initReq.js'
import { slugifyHandler } from './slugify.js'

const baseServerFunctions: Record<string, ServerFunction<any, any>> = {
'copy-data-from-locale': copyDataFromLocaleHandler,
Expand All @@ -20,6 +21,7 @@ const baseServerFunctions: Record<string, ServerFunction<any, any>> = {
'render-field': _internal_renderFieldHandler,
'render-list': renderListHandler,
'schedule-publish': schedulePublishHandler,
slugify: slugifyHandler,
'table-state': buildTableStateHandler,
}

Expand Down
50 changes: 50 additions & 0 deletions packages/next/src/utilities/slugify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
flattenAllFields,
getFieldByPath,
type ServerFunction,
type SlugifyServerFunctionArgs,
UnauthorizedError,
} from 'payload'
import { slugify as defaultSlugify, type Slugify } from 'payload/shared'

/**
* This server function is directly related to the {@link https://payloadcms.com/docs/fields/text#slug-field | Slug Field}.
* This is a server function that is used to invoke the user's custom slugify function from the client.
* This pattern is required, as there is no other way for us to pass their function across the client-server boundary.
* - Not through props
* - Not from a server function defined within a server component (see below)
* When a server function contains non-serializable data within its closure, it gets passed through the boundary (and breaks).
* The only way to pass server functions to the client (that contain non-serializable data) is if it is globally defined.
* But we also cannot define this function alongside the server component, as we will not have access to their custom slugify function.
* See `ServerFunctionsProvider` for more details.
*/
export const slugifyHandler: ServerFunction<
SlugifyServerFunctionArgs,
Promise<ReturnType<Slugify>>
> = async (args) => {
const { collectionSlug, globalSlug, path, req, val } = args

if (!req.user) {
throw new UnauthorizedError()
}

const docConfig = collectionSlug
? req.payload.collections[collectionSlug]?.config
: globalSlug
? req.payload.config.globals.find((g) => g.slug === globalSlug)
: null

const { field } = getFieldByPath({
config: req.payload.config,
fields: flattenAllFields({ fields: docConfig?.fields || [] }),
path: path || '',
})

const customSlugify = field.custom.slugify as Slugify | undefined

const slugify = customSlugify || defaultSlugify

const result = await slugify(val)

return result
}
16 changes: 15 additions & 1 deletion packages/payload/src/admin/functions/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import type { ImportMap } from '../../bin/generateImportMap/index.js'
import type { SanitizedConfig } from '../../config/types.js'
import type { PaginatedDocs } from '../../database/types.js'
import type { CollectionSlug, ColumnPreference, FolderSortKeys } from '../../index.js'
import type {
CollectionSlug,
ColumnPreference,
FieldPaths,
FolderSortKeys,
GlobalSlug,
} from '../../index.js'
import type { PayloadRequest, Sort, Where } from '../../types/index.js'
import type { Slugify } from '../../utilities/slugify.js'
import type { ColumnsFromURL } from '../../utilities/transformColumnPreferences.js'

export type DefaultServerFunctionArgs = {
Expand Down Expand Up @@ -149,3 +156,10 @@ export type GetFolderResultsComponentAndDataArgs = {
*/
sort: FolderSortKeys
}

export type SlugifyServerFunctionArgs = {
collectionSlug?: CollectionSlug
globalSlug?: GlobalSlug
path?: FieldPaths['path']
val?: Parameters<Slugify>[0]
}
1 change: 1 addition & 0 deletions packages/payload/src/admin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ export type {
ServerFunctionClientArgs,
ServerFunctionConfig,
ServerFunctionHandler,
SlugifyServerFunctionArgs,
} from './functions/index.js'

export type { LanguageOptions } from './LanguageOptions.js'
Expand Down
2 changes: 1 addition & 1 deletion packages/payload/src/exports/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export { sanitizeUserDataForEmail } from '../utilities/sanitizeUserDataForEmail.

export { setsAreEqual } from '../utilities/setsAreEqual.js'

export { slugify } from '../utilities/slugify.js'
export { slugify, type Slugify } from '../utilities/slugify.js'

export { toKebabCase } from '../utilities/toKebabCase.js'

Expand Down
11 changes: 7 additions & 4 deletions packages/payload/src/fields/baseFields/slug/generateSlug.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { FieldHook } from '../../config/types.js'
import type { SlugFieldArgs } from './index.js'

import { slugify } from '../../../utilities/slugify.js'
import { slugify as defaultSlugify } from '../../../utilities/slugify.js'
import { countVersions } from './countVersions.js'

type HookArgs = {
Expand All @@ -9,16 +10,18 @@ type HookArgs = {
*/
fieldName?: string
fieldToUse: string
}
} & Pick<SlugFieldArgs, 'slugify'>

/**
* This is a `BeforeChange` field hook used to auto-generate the `slug` field.
* See `slugField` for more details.
*/
export const generateSlug =
({ fieldName = 'slug', fieldToUse }: HookArgs): FieldHook =>
({ fieldName = 'slug', fieldToUse, slugify: customSlugify }: HookArgs): FieldHook =>
async (args) => {
const { collection, data, global, operation, originalDoc, req, value: isChecked } = args
const { collection, data, global, operation, originalDoc, value: isChecked } = args

const slugify = customSlugify || defaultSlugify

// Ensure user-defined slugs are not overwritten during create
// Use a generic falsy check here to include empty strings
Expand Down
32 changes: 23 additions & 9 deletions packages/payload/src/fields/baseFields/slug/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { TextFieldClientProps } from '../../../admin/types.js'
import type { FieldAdmin, RowField, TextField } from '../../../fields/config/types.js'
import type { Slugify } from '../../../utilities/slugify.js'

import { generateSlug } from './generateSlug.js'

type SlugFieldArgs = {
export type SlugFieldArgs = {
/**
* Override for the `generateSlug` checkbox field name.
* @default 'generateSlug'
* @default "generateSlug"
*/
checkboxName?: string
/**
Expand All @@ -24,7 +25,7 @@ type SlugFieldArgs = {
*/
name?: string
/**
* A function used to override te fields at a granular level.
* A function used to override the slug field(s) at a granular level.
* Passes the row field to you to manipulate beyond the exposed options.
* @example
* ```ts
Expand All @@ -43,13 +44,19 @@ type SlugFieldArgs = {
* @default true
*/
required?: TextField['required']
/**
* Provide your own slugify function to override the default.
*/
slugify?: Slugify
}

type SlugField = (args?: SlugFieldArgs) => RowField

export type SlugFieldClientProps = {} & Pick<SlugFieldArgs, 'fieldToUse'>
export type SlugField = (args?: SlugFieldArgs) => RowField

export type SlugFieldProps = SlugFieldClientProps & TextFieldClientProps
/**
* These are the props that the `SlugField` client component accepts.
* The `SlugField` server component is responsible for passing down the `slugify` function.
*/
export type SlugFieldClientProps = Pick<SlugFieldArgs, 'fieldToUse'> & TextFieldClientProps

/**
* A slug is a unique, indexed, URL-friendly string that identifies a particular document, often used to construct the URL of a webpage.
Expand All @@ -74,6 +81,7 @@ export const slugField: SlugField = ({
overrides,
position = 'sidebar',
required = true,
slugify,
} = {}) => {
const baseField: RowField = {
type: 'row',
Expand All @@ -95,7 +103,7 @@ export const slugField: SlugField = ({
},
defaultValue: true,
hooks: {
beforeChange: [generateSlug({ fieldName, fieldToUse })],
beforeChange: [generateSlug({ fieldName, fieldToUse, slugify })],
},
localized,
},
Expand All @@ -107,12 +115,18 @@ export const slugField: SlugField = ({
Field: {
clientProps: {
fieldToUse,
} satisfies SlugFieldClientProps,
},
path: '@payloadcms/ui#SlugField',
},
},
width: '100%',
},
custom: {
/**
* This is needed so we can access it from the `slugifyHandler` server function.
*/
slugify,
},
index: true,
localized,
required,
Expand Down
22 changes: 12 additions & 10 deletions packages/payload/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1448,7 +1448,8 @@ export { baseBlockFields } from './fields/baseFields/baseBlockFields.js'

export { baseIDField } from './fields/baseFields/baseIDField.js'

export { slugField, type SlugFieldProps } from './fields/baseFields/slug/index.js'
export { slugField, type SlugFieldClientProps } from './fields/baseFields/slug/index.js'
export { type SlugField } from './fields/baseFields/slug/index.js'

export {
createClientField,
Expand All @@ -1457,6 +1458,8 @@ export {
type ServerOnlyFieldProperties,
} from './fields/config/client.js'

export { sanitizeFields } from './fields/config/sanitize.js'

export interface FieldCustom extends Record<string, any> {}

export interface CollectionCustom extends Record<string, any> {}
Expand All @@ -1467,8 +1470,6 @@ export interface GlobalCustom extends Record<string, any> {}

export interface GlobalAdminCustom extends Record<string, any> {}

export { sanitizeFields } from './fields/config/sanitize.js'

export type {
AdminClient,
ArrayField,
Expand Down Expand Up @@ -1578,16 +1579,16 @@ export type {
} from './fields/config/types.js'

export { getDefaultValue } from './fields/getDefaultValue.js'
export { traverseFields as afterChangeTraverseFields } from './fields/hooks/afterChange/traverseFields.js'

export { traverseFields as afterChangeTraverseFields } from './fields/hooks/afterChange/traverseFields.js'
export { promise as afterReadPromise } from './fields/hooks/afterRead/promise.js'

export { traverseFields as afterReadTraverseFields } from './fields/hooks/afterRead/traverseFields.js'
export { traverseFields as beforeChangeTraverseFields } from './fields/hooks/beforeChange/traverseFields.js'
export { traverseFields as beforeValidateTraverseFields } from './fields/hooks/beforeValidate/traverseFields.js'

export { sortableFieldTypes } from './fields/sortableFieldTypes.js'
export { validateBlocksFilterOptions, validations } from './fields/validations.js'

export { validateBlocksFilterOptions, validations } from './fields/validations.js'
export type {
ArrayFieldValidation,
BlocksFieldValidation,
Expand Down Expand Up @@ -1619,6 +1620,7 @@ export type {
UploadFieldValidation,
UsernameFieldValidation,
} from './fields/validations.js'

export type { FolderSortKeys } from './folders/types.js'
export { getFolderData } from './folders/utils/getFolderData.js'
export {
Expand All @@ -1642,10 +1644,10 @@ export type {
} from './globals/config/types.js'
export { docAccessOperation as docAccessOperationGlobal } from './globals/operations/docAccess.js'
export { findOneOperation } from './globals/operations/findOne.js'

export { findVersionByIDOperation as findVersionByIDOperationGlobal } from './globals/operations/findVersionByID.js'

export { findVersionsOperation as findVersionsOperationGlobal } from './globals/operations/findVersions.js'

export { restoreVersionOperation as restoreVersionOperationGlobal } from './globals/operations/restoreVersion.js'
export { updateOperation as updateOperationGlobal } from './globals/operations/update.js'
export * from './kv/adapters/DatabaseKVAdapter.js'
Expand Down Expand Up @@ -1682,7 +1684,6 @@ export type {
TaskOutput,
TaskType,
} from './queues/config/types/taskTypes.js'

export type {
BaseJob,
JobLog,
Expand All @@ -1693,20 +1694,21 @@ export type {
WorkflowHandler,
WorkflowTypes,
} from './queues/config/types/workflowTypes.js'

export { countRunnableOrActiveJobsForQueue } from './queues/operations/handleSchedules/countRunnableOrActiveJobsForQueue.js'
export { importHandlerPath } from './queues/operations/runJobs/runJob/importHandlerPath.js'

export {
_internal_jobSystemGlobals,
_internal_resetJobSystemGlobals,
getCurrentDate,
} from './queues/utilities/getCurrentDate.js'

export { getLocalI18n } from './translations/getLocalI18n.js'
export * from './types/index.js'
export { getFileByPath } from './uploads/getFileByPath.js'
export { _internal_safeFetchGlobal } from './uploads/safeFetch.js'

export type * from './uploads/types.js'

export { addDataAndFileToRequest } from './utilities/addDataAndFileToRequest.js'
export { addLocalesToRequestFromData, sanitizeLocales } from './utilities/addLocalesToRequest.js'
export { canAccessAdmin } from './utilities/canAccessAdmin.js'
Expand Down
4 changes: 3 additions & 1 deletion packages/payload/src/utilities/slugify.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export const slugify = (val?: string) =>
export const slugify: Slugify = (val) =>
val
?.replace(/ /g, '-')
.replace(/[^\w-]+/g, '')
.toLowerCase()

export type Slugify = (val?: string) => Promise<string | undefined> | string | undefined
1 change: 1 addition & 0 deletions packages/ui/src/exports/rsc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js'
export { FolderTableCell } from '../../elements/FolderView/Cell/index.server.js'
export { FolderField } from '../../elements/FolderView/FolderField/index.server.js'
export { getHTMLDiffComponents } from '../../elements/HTMLDiff/index.js'
export { SlugField } from '../../fields/Slug/index.js'
export { _internal_renderFieldHandler } from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js'
export { File } from '../../graphics/File/index.js'
export { CheckIcon } from '../../icons/Check/index.js'
Expand Down
Loading
Loading