Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 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
6 changes: 5 additions & 1 deletion docs/fields/text.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,11 @@ The slug field exposes a few top-level config options for easy customization:
| `name` | To be used as the slug field's name. Defaults to `slug`. |
| `overrides` | A function that receives the default fields so you can override on a granular level. See example below. [More details](#slug-overrides). |
| `checkboxName` | To be used as the name for the `generateSlug` checkbox field. Defaults to `generateSlug`. |
| `fieldToUse` | The name of the field to use when generating the slug. This field must exist in the same collection. Defaults to `title`. |
| `useAsSlug` | The name of the top-level field to use when generating the slug. This field must exist in the same collection. Defaults to `title`. |
| `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 `useAsSlug` field and must return a string. |

### Slug Overrides

Expand All @@ -240,6 +241,9 @@ export const ExampleCollection: CollectionConfig = {
defaultField.fields[1].label = 'Custom Slug Label'
return defaultField
},
// You can also provide your own slugify function to support custom formatting, if needed
slugify: ({ valueToSlugify }) =>
valueToSlugify?.toLowerCase().replace(/\s+/g, '-'),
}),
// highlight-line
],
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
56 changes: 56 additions & 0 deletions packages/next/src/utilities/slugify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Slugify } from 'payload/shared'

import {
flattenAllFields,
getFieldByPath,
type ServerFunction,
type SlugifyServerFunctionArgs,
UnauthorizedError,
} from 'payload'
import { slugify as defaultSlugify } 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, data, globalSlug, path, req, value } = 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

if (!docConfig) {
throw new Error()
}

const { field } = getFieldByPath({
config: req.payload.config,
fields: flattenAllFields({ fields: docConfig.fields }),
path,
})

const customSlugify = (
typeof field?.custom?.slugify === 'function' ? field.custom.slugify : undefined
) as Slugify

const result = customSlugify ? await customSlugify({ data, req, value }) : defaultSlugify(value)

return result
}
15 changes: 14 additions & 1 deletion packages/payload/src/admin/functions/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
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 { Slugify } from '../../fields/baseFields/slug/index.js'
import type {
CollectionSlug,
ColumnPreference,
FieldPaths,
FolderSortKeys,
GlobalSlug,
} from '../../index.js'
import type { PayloadRequest, Sort, Where } from '../../types/index.js'
import type { ColumnsFromURL } from '../../utilities/transformColumnPreferences.js'

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

export type SlugifyServerFunctionArgs = {
collectionSlug?: CollectionSlug
globalSlug?: GlobalSlug
path?: FieldPaths['path']
} & Omit<Parameters<Slugify>[0], 'req'>
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
14 changes: 8 additions & 6 deletions packages/payload/src/exports/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export { defaults as collectionDefaults } from '../collections/config/defaults.j

export { serverProps } from '../config/types.js'

export { type Slugify } from '../fields/baseFields/slug/index.js'

export { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js'

export {
Expand All @@ -39,8 +41,8 @@ export {
} from '../fields/config/types.js'

export { getFieldPaths } from '../fields/getFieldPaths.js'

export * from '../fields/validations.js'

export type {
FolderBreadcrumb,
FolderDocumentItemKey,
Expand All @@ -52,41 +54,41 @@ export type {
} from '../folders/types.js'

export { buildFolderWhereConstraints } from '../folders/utils/buildFolderWhereConstraints.js'

export { formatFolderOrDocumentItem } from '../folders/utils/formatFolderOrDocumentItem.js'
export { validOperators, validOperatorSet } from '../types/constants.js'

export { formatFilesize } from '../uploads/formatFilesize.js'

export { isImage } from '../uploads/isImage.js'

export { appendUploadSelectFields } from '../utilities/appendUploadSelectFields.js'
export { applyLocaleFiltering } from '../utilities/applyLocaleFiltering.js'
export { combineWhereConstraints } from '../utilities/combineWhereConstraints.js'

export {
deepCopyObject,
deepCopyObjectComplex,
deepCopyObjectSimple,
deepCopyObjectSimpleWithoutReactComponents,
} from '../utilities/deepCopyObject.js'

export {
deepMerge,
deepMergeWithCombinedArrays,
deepMergeWithReactComponents,
deepMergeWithSourceArrays,
} from '../utilities/deepMerge.js'

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

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

export { flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js'
export { formatAdminURL } from '../utilities/formatAdminURL.js'
export { formatLabels, toWords } from '../utilities/formatLabels.js'
export { getBestFitFromSizes } from '../utilities/getBestFitFromSizes.js'

export { getBestFitFromSizes } from '../utilities/getBestFitFromSizes.js'
export { getDataByPath } from '../utilities/getDataByPath.js'
export { getFieldPermissions } from '../utilities/getFieldPermissions.js'
export { getObjectDotNotation } from '../utilities/getObjectDotNotation.js'

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

export { getSelectMode } from '../utilities/getSelectMode.js'
Expand Down
70 changes: 51 additions & 19 deletions packages/payload/src/fields/baseFields/slug/generateSlug.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,53 @@
import type { PayloadRequest } from '../../../types/index.js'
import type { FieldHook } from '../../config/types.js'
import type { SlugFieldArgs, Slugify } from './index.js'

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

type HookArgs = {
/**
* Current field name for the slug. Defaults to `slug`.
*/
fieldName?: string
fieldToUse: string
slugFieldName: NonNullable<SlugFieldArgs['name']>
} & Pick<SlugFieldArgs, 'slugify'> &
Required<Pick<SlugFieldArgs, 'useAsSlug'>>

const slugify = ({
customSlugify,
data,
req,
valueToSlugify,
}: {
customSlugify?: Slugify
data: Record<string, unknown>
req: PayloadRequest
valueToSlugify?: string
}) => {
if (customSlugify) {
return customSlugify({ data, req, valueToSlugify })
}

return defaultSlugify(valueToSlugify)
}

/**
* 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 =>
async (args) => {
const { collection, data, global, operation, originalDoc, req, value: isChecked } = args

// Ensure user-defined slugs are not overwritten during create
// Use a generic falsy check here to include empty strings
({ slugFieldName, slugify: customSlugify, useAsSlug }: HookArgs): FieldHook =>
async ({ collection, data, global, operation, originalDoc, req, value: isChecked }) => {
if (operation === 'create') {
if (data) {
data[fieldName] = slugify(data?.[fieldName] || data?.[fieldToUse])
data[slugFieldName] = slugify({
customSlugify,
data,
req,
// Ensure user-defined slugs are not overwritten during create
// Use a generic falsy check here to include empty strings
valueToSlugify: data?.[slugFieldName] || data?.[useAsSlug],
})
}

return Boolean(!data?.[fieldName])
return Boolean(!data?.[slugFieldName])
}

if (operation === 'update') {
Expand All @@ -45,22 +65,34 @@ export const generateSlug =
if (!autosaveEnabled) {
// We can generate the slug at this point
if (data) {
data[fieldName] = slugify(data?.[fieldToUse])
data[slugFieldName] = slugify({
customSlugify,
data,
req,
valueToSlugify: data?.[useAsSlug],
})
}

return Boolean(!data?.[fieldName])
return Boolean(!data?.[slugFieldName])
} else {
// If we're publishing, we can avoid querying as we can safely assume we've exceeded the version threshold (2)
const isPublishing = data?._status === 'published'

// Ensure the user can take over the generated slug themselves without it ever being overridden back
const userOverride = data?.[fieldName] !== originalDoc?.[fieldName]
const userOverride = data?.[slugFieldName] !== originalDoc?.[slugFieldName]

if (!userOverride) {
if (data) {
// If the fallback is an empty string, we want the slug to return to `null`
// This will ensure that live preview conditions continue to run as expected
data[fieldName] = data?.[fieldToUse] ? slugify(data[fieldToUse]) : null
data[slugFieldName] = data?.[useAsSlug]
? slugify({
customSlugify,
data,
req,
valueToSlugify: data?.[useAsSlug],
})
: null
}
}

Expand All @@ -74,7 +106,7 @@ export const generateSlug =
collectionSlug: collection?.slug,
globalSlug: global?.slug,
parentID: originalDoc?.id,
req: args.req,
req,
})

if (versionCount <= 2) {
Expand Down
Loading
Loading