Skip to content

Commit 59a1607

Browse files
authored
feat: support custom slugify functions (#14117)
Continuation of #14007. Supports overriding the default slugify function of the slug field. This is necessary if the slug requires special treatment, such as character encoding, additional language support, etc. For example, if you wanted to use the [`slugify`](https://www.npmjs.com/package/slugify) package, you could do something like this: ```ts import type { CollectionConfig } from 'payload' import { slugField } from 'payload' import slugify from 'slugify'; export const MyCollection: CollectionConfig = { // ... fields: [ // ... slugField({ slugify: ({ valueToSlugify }) => slugify(valueToSlugify, { // ...additional `slugify` options here }) }) ] } ``` This PR also deprecates the old `fieldToUse` arg in favor of `useAsSlug` which is more semantically clear, following the same convention as `useAsTitle`. Example: ```ts import type { CollectionConfig } from 'payload' import { slugField } from 'payload' export const MyCollection: CollectionConfig = { // ... fields: [ // ... slugField({ useAsSlug: 'myCustomTitle' }) ] } ``` In follow-up PRs, we should also: - Improve the default slugify function to better handle special characters on its own, etc. - [Support nested slugs](#14783)
1 parent 32df641 commit 59a1607

File tree

20 files changed

+359
-78
lines changed

20 files changed

+359
-78
lines changed

docs/fields/text.mdx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,11 @@ The slug field exposes a few top-level config options for easy customization:
217217
| `name` | To be used as the slug field's name. Defaults to `slug`. |
218218
| `overrides` | A function that receives the default fields so you can override on a granular level. See example below. [More details](#slug-overrides). |
219219
| `checkboxName` | To be used as the name for the `generateSlug` checkbox field. Defaults to `generateSlug`. |
220-
| `fieldToUse` | The name of the field to use when generating the slug. This field must exist in the same collection. Defaults to `title`. |
220+
| `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`. |
221221
| `localized` | Enable localization on the `slug` and `generateSlug` fields. Defaults to `false`. |
222222
| `position` | The position of the slug field. [More details](./overview#admin-options). |
223223
| `required` | Require the slug field. Defaults to `true`. |
224+
| `slugify` | Override the default slugify function. [More details](#custom-slugify-function). |
224225

225226
### Slug Overrides
226227

@@ -245,3 +246,38 @@ export const ExampleCollection: CollectionConfig = {
245246
],
246247
}
247248
```
249+
250+
### Custom Slugify Function
251+
252+
You can also override the default slugify function of the slug field. This is necessary if the slug requires special treatment, such as character encoding, additional language support, etc.
253+
254+
This functions receives the value of the `useAsSlug` field as `valueToSlugify` and must return a string.
255+
256+
For example, if you wanted to use the [`slugify`](https://www.npmjs.com/package/slugify) package, you could do something like this:
257+
258+
```ts
259+
import type { CollectionConfig } from 'payload'
260+
import { slugField } from 'payload'
261+
import slugify from 'slugify'
262+
263+
export const MyCollection: CollectionConfig = {
264+
// ...
265+
fields: [
266+
// ...
267+
slugField({
268+
slugify: ({ valueToSlugify }) =>
269+
slugify(valueToSlugify, {
270+
// ...additional `slugify` options here
271+
}),
272+
}),
273+
],
274+
}
275+
```
276+
277+
The following args are provided to the custom `slugify` function:
278+
279+
| Argument | Type | Description |
280+
| ---------------- | ---------------- | ------------------------------------------------ |
281+
| `valueToSlugify` | `string` | The value of the field specified in `useAsSlug`. |
282+
| `data` | `object` | The full document data being saved. |
283+
| `req` | `PayloadRequest` | The Payload request object. |

packages/next/src/layouts/Root/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export const RootLayout = async ({
8888
importMap,
8989
user: req.user,
9090
})
91+
9192
await applyLocaleFiltering({ clientConfig, config, req })
9293

9394
return (

packages/next/src/utilities/handleServerFunctions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { renderDocumentHandler } from '../views/Document/handleServerFunction.js
1010
import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlots.js'
1111
import { renderListHandler } from '../views/List/handleServerFunction.js'
1212
import { initReq } from './initReq.js'
13+
import { slugifyHandler } from './slugify.js'
1314

1415
const baseServerFunctions: Record<string, ServerFunction<any, any>> = {
1516
'copy-data-from-locale': copyDataFromLocaleHandler,
@@ -20,6 +21,7 @@ const baseServerFunctions: Record<string, ServerFunction<any, any>> = {
2021
'render-field': _internal_renderFieldHandler,
2122
'render-list': renderListHandler,
2223
'schedule-publish': schedulePublishHandler,
24+
slugify: slugifyHandler,
2325
'table-state': buildTableStateHandler,
2426
}
2527

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { Slugify } from 'payload/shared'
2+
3+
import {
4+
flattenAllFields,
5+
getFieldByPath,
6+
type ServerFunction,
7+
type SlugifyServerFunctionArgs,
8+
UnauthorizedError,
9+
} from 'payload'
10+
import { slugify as defaultSlugify } from 'payload/shared'
11+
12+
/**
13+
* This server function is directly related to the {@link https://payloadcms.com/docs/fields/text#slug-field | Slug Field}.
14+
* This is a server function that is used to invoke the user's custom slugify function from the client.
15+
* This pattern is required, as there is no other way for us to pass their function across the client-server boundary.
16+
* - Not through props
17+
* - Not from a server function defined within a server component (see below)
18+
* When a server function contains non-serializable data within its closure, it gets passed through the boundary (and breaks).
19+
* The only way to pass server functions to the client (that contain non-serializable data) is if it is globally defined.
20+
* But we also cannot define this function alongside the server component, as we will not have access to their custom slugify function.
21+
* See `ServerFunctionsProvider` for more details.
22+
*/
23+
export const slugifyHandler: ServerFunction<
24+
SlugifyServerFunctionArgs,
25+
Promise<ReturnType<Slugify>>
26+
> = async (args) => {
27+
const { collectionSlug, data, globalSlug, path, req, valueToSlugify } = args
28+
29+
if (!req.user) {
30+
throw new UnauthorizedError()
31+
}
32+
33+
const docConfig = collectionSlug
34+
? req.payload.collections[collectionSlug]?.config
35+
: globalSlug
36+
? req.payload.config.globals.find((g) => g.slug === globalSlug)
37+
: null
38+
39+
if (!docConfig) {
40+
throw new Error()
41+
}
42+
43+
const { field } = getFieldByPath({
44+
config: req.payload.config,
45+
fields: flattenAllFields({ fields: docConfig.fields }),
46+
path,
47+
})
48+
49+
const customSlugify = (
50+
typeof field?.custom?.slugify === 'function' ? field.custom.slugify : undefined
51+
) as Slugify
52+
53+
const result = customSlugify
54+
? await customSlugify({ data, req, valueToSlugify })
55+
: defaultSlugify(valueToSlugify)
56+
57+
return result
58+
}

packages/payload/src/admin/functions/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import type { ImportMap } from '../../bin/generateImportMap/index.js'
22
import type { SanitizedConfig } from '../../config/types.js'
33
import type { PaginatedDocs } from '../../database/types.js'
4-
import type { CollectionSlug, ColumnPreference, FolderSortKeys } from '../../index.js'
4+
import type { Slugify } from '../../fields/baseFields/slug/index.js'
5+
import type {
6+
CollectionSlug,
7+
ColumnPreference,
8+
FieldPaths,
9+
FolderSortKeys,
10+
GlobalSlug,
11+
} from '../../index.js'
512
import type { PayloadRequest, Sort, Where } from '../../types/index.js'
613
import type { ColumnsFromURL } from '../../utilities/transformColumnPreferences.js'
714

@@ -149,3 +156,9 @@ export type GetFolderResultsComponentAndDataArgs = {
149156
*/
150157
sort: FolderSortKeys
151158
}
159+
160+
export type SlugifyServerFunctionArgs = {
161+
collectionSlug?: CollectionSlug
162+
globalSlug?: GlobalSlug
163+
path?: FieldPaths['path']
164+
} & Omit<Parameters<Slugify>[0], 'req'>

packages/payload/src/admin/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ export type {
583583
ServerFunctionClientArgs,
584584
ServerFunctionConfig,
585585
ServerFunctionHandler,
586+
SlugifyServerFunctionArgs,
586587
} from './functions/index.js'
587588

588589
export type { LanguageOptions } from './LanguageOptions.js'

packages/payload/src/exports/shared.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export { defaults as collectionDefaults } from '../collections/config/defaults.j
1313

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

16+
export { type Slugify } from '../fields/baseFields/slug/index.js'
17+
1618
export { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js'
1719

1820
export {
@@ -39,8 +41,8 @@ export {
3941
} from '../fields/config/types.js'
4042

4143
export { getFieldPaths } from '../fields/getFieldPaths.js'
42-
4344
export * from '../fields/validations.js'
45+
4446
export type {
4547
FolderBreadcrumb,
4648
FolderDocumentItemKey,
@@ -52,41 +54,41 @@ export type {
5254
} from '../folders/types.js'
5355

5456
export { buildFolderWhereConstraints } from '../folders/utils/buildFolderWhereConstraints.js'
55-
5657
export { formatFolderOrDocumentItem } from '../folders/utils/formatFolderOrDocumentItem.js'
5758
export { validOperators, validOperatorSet } from '../types/constants.js'
59+
5860
export { formatFilesize } from '../uploads/formatFilesize.js'
5961

6062
export { isImage } from '../uploads/isImage.js'
61-
6263
export { appendUploadSelectFields } from '../utilities/appendUploadSelectFields.js'
6364
export { applyLocaleFiltering } from '../utilities/applyLocaleFiltering.js'
6465
export { combineWhereConstraints } from '../utilities/combineWhereConstraints.js'
66+
6567
export {
6668
deepCopyObject,
6769
deepCopyObjectComplex,
6870
deepCopyObjectSimple,
6971
deepCopyObjectSimpleWithoutReactComponents,
7072
} from '../utilities/deepCopyObject.js'
71-
7273
export {
7374
deepMerge,
7475
deepMergeWithCombinedArrays,
7576
deepMergeWithReactComponents,
7677
deepMergeWithSourceArrays,
7778
} from '../utilities/deepMerge.js'
79+
7880
export { extractID } from '../utilities/extractID.js'
7981

8082
export { flattenAllFields } from '../utilities/flattenAllFields.js'
81-
8283
export { flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js'
8384
export { formatAdminURL } from '../utilities/formatAdminURL.js'
8485
export { formatLabels, toWords } from '../utilities/formatLabels.js'
85-
export { getBestFitFromSizes } from '../utilities/getBestFitFromSizes.js'
8686

87+
export { getBestFitFromSizes } from '../utilities/getBestFitFromSizes.js'
8788
export { getDataByPath } from '../utilities/getDataByPath.js'
8889
export { getFieldPermissions } from '../utilities/getFieldPermissions.js'
8990
export { getObjectDotNotation } from '../utilities/getObjectDotNotation.js'
91+
9092
export { getSafeRedirect } from '../utilities/getSafeRedirect.js'
9193

9294
export { getSelectMode } from '../utilities/getSelectMode.js'

packages/payload/src/fields/baseFields/slug/generateSlug.ts

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,53 @@
1+
import type { PayloadRequest } from '../../../types/index.js'
12
import type { FieldHook } from '../../config/types.js'
3+
import type { SlugFieldArgs, Slugify } from './index.js'
24

3-
import { slugify } from '../../../utilities/slugify.js'
5+
import { slugify as defaultSlugify } from '../../../utilities/slugify.js'
46
import { countVersions } from './countVersions.js'
57

68
type HookArgs = {
7-
/**
8-
* Current field name for the slug. Defaults to `slug`.
9-
*/
10-
fieldName?: string
11-
fieldToUse: string
9+
slugFieldName: NonNullable<SlugFieldArgs['name']>
10+
} & Pick<SlugFieldArgs, 'slugify'> &
11+
Required<Pick<SlugFieldArgs, 'useAsSlug'>>
12+
13+
const slugify = ({
14+
customSlugify,
15+
data,
16+
req,
17+
valueToSlugify,
18+
}: {
19+
customSlugify?: Slugify
20+
data: Record<string, unknown>
21+
req: PayloadRequest
22+
valueToSlugify?: string
23+
}) => {
24+
if (customSlugify) {
25+
return customSlugify({ data, req, valueToSlugify })
26+
}
27+
28+
return defaultSlugify(valueToSlugify)
1229
}
1330

1431
/**
1532
* This is a `BeforeChange` field hook used to auto-generate the `slug` field.
1633
* See `slugField` for more details.
1734
*/
1835
export const generateSlug =
19-
({ fieldName = 'slug', fieldToUse }: HookArgs): FieldHook =>
20-
async (args) => {
21-
const { collection, data, global, operation, originalDoc, req, value: isChecked } = args
22-
23-
// Ensure user-defined slugs are not overwritten during create
24-
// Use a generic falsy check here to include empty strings
36+
({ slugFieldName, slugify: customSlugify, useAsSlug }: HookArgs): FieldHook =>
37+
async ({ collection, data, global, operation, originalDoc, req, value: isChecked }) => {
2538
if (operation === 'create') {
2639
if (data) {
27-
data[fieldName] = slugify(data?.[fieldName] || data?.[fieldToUse])
40+
data[slugFieldName] = slugify({
41+
customSlugify,
42+
data,
43+
req,
44+
// Ensure user-defined slugs are not overwritten during create
45+
// Use a generic falsy check here to include empty strings
46+
valueToSlugify: data?.[slugFieldName] || data?.[useAsSlug],
47+
})
2848
}
2949

30-
return Boolean(!data?.[fieldName])
50+
return Boolean(!data?.[slugFieldName])
3151
}
3252

3353
if (operation === 'update') {
@@ -45,22 +65,34 @@ export const generateSlug =
4565
if (!autosaveEnabled) {
4666
// We can generate the slug at this point
4767
if (data) {
48-
data[fieldName] = slugify(data?.[fieldToUse])
68+
data[slugFieldName] = slugify({
69+
customSlugify,
70+
data,
71+
req,
72+
valueToSlugify: data?.[useAsSlug],
73+
})
4974
}
5075

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

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

5984
if (!userOverride) {
6085
if (data) {
6186
// If the fallback is an empty string, we want the slug to return to `null`
6287
// This will ensure that live preview conditions continue to run as expected
63-
data[fieldName] = data?.[fieldToUse] ? slugify(data[fieldToUse]) : null
88+
data[slugFieldName] = data?.[useAsSlug]
89+
? slugify({
90+
customSlugify,
91+
data,
92+
req,
93+
valueToSlugify: data?.[useAsSlug],
94+
})
95+
: null
6496
}
6597
}
6698

@@ -74,7 +106,7 @@ export const generateSlug =
74106
collectionSlug: collection?.slug,
75107
globalSlug: global?.slug,
76108
parentID: originalDoc?.id,
77-
req: args.req,
109+
req,
78110
})
79111

80112
if (versionCount <= 2) {

0 commit comments

Comments
 (0)