Consider the
formatSlug()
function in the official website template:
import type { FieldHook } from 'payload/types'
const format = (val: string): string =>
val
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '')
.toLowerCase()
const formatSlug =
(fallback: string): FieldHook =>
({ operation, value, originalDoc, data }) => {
if (typeof value === 'string') {
return format(value)
}
if (operation === 'create') {
const fallbackData = data?.[fallback] || originalDoc?.[fallback]
if (fallbackData && typeof fallbackData === 'string') {
return format(fallbackData)
}
}
return value
}
export default formatSlug
This is great, but I can create two posts with the same title and two identical slugs will be created. Of course, I can use
required: true
to catch this behavior, but the function will return an error. This is also not very intuitive for the user.
Is there a way to modify the
formatslug()
function to make it able to generate a "-${number}" at the end of a non-unique slug, thus making sure that it will succeed everytime? WordPress has something like that for their slug creation.
In other words, how can I gracefully check if a title is unique inside the
formatslug()
function?
Thanks for your help.
You could do a
payload.db.collections['collection-name'].findOne({slug: "your-slug"})
then if there is already a document you could append a random string to the end.
- I added more rules to the
format()
function to cover edge cases.
-
isTitleFound()
checks if a slug is found that correspond to the slugified title.
-
getUniqueSlug()
loops check if
isTitleFound()
is true. If it is, it add a
-${number}
or increment the
{number}
if one already exists.
Here is the final code.
import type { FieldHook } from "payload/types";
import payload from "payload";
const format = (val: string): string =>
val
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9 -]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-/, "")
.replace(/-$/, "");
async function isTitleFound(title: string, collection: string) {
const titleFound = await payload.find({
collection: collection,
where: {
slug: {
equals: title,
},
},
});
if (titleFound.docs.length > 0) return true;
return false;
}
async function getUniqueSlug(slug: string, collection: string) {
let i = 2;
let isFound = await isTitleFound(slug, collection);
const regex = /^.*-\d+$/;
while (isFound) {
if (regex.test(slug)) {
const match = slug.match(regex);
if (match) {
i = parseInt(match[0].split("-").pop()) + 1;
}
slug = slug.replace(/\d+$/, "");
slug += `${i}`;
} else {
slug += `-${i}`;
}
isFound = await isTitleFound(slug, collection);
}
return slug;
}
const formatSlug =
(collection: string, fallback: string): FieldHook =>
({ operation, value, originalDoc, data }) => {
if (typeof value === "string")
return getUniqueSlug(format(value), collection);
if (operation === "create") {
const fallbackData = data?.[fallback] || originalDoc?.[fallback];
if (fallbackData && typeof fallbackData === "string") {
return getUniqueSlug(format(fallbackData), collection);
}
}
return value;
};
export default formatSlug;
Maybe Payload website template should be updated with something similar. Thanks to
@756246038414622770for the tip : )
Can you submit a PR or issue?
I found an issue with the above code and thought it worth sharing here. Every time I hit save, I get a new number appended to the slug as it's finding this document in the collection and treating it as a duplicate, so a new number is constantly being added.
I think a way to avoid this would be to pass in the document ID into the
isTitleFound()
function, something like this:
async function isTitleFound(title: string, collection: string, id: string) {
const titleFound = await payload.find({
collection,
where: {
slug: {
equals: title,
},
id: {
not_equals: id,
},
},
});
if (titleFound.docs.length > 0) return true;
return false;
}
but now I'm just trying to figure out how I can pass the document ID from the collection to this function 🙂
Found it!
It turns out the document ID is available from the
originalDoc
value:
const { id } = originalDoc;
So I can pass that to
getUniqueSlug()
and then to
isTitleFound()
to check that the
find()
function excludes documents where the ID matches the one you're updating. Complete code below:
import payload from "payload";
import { FieldHook } from "payload/types";
import slugify from "slugify";
const format = (val: string): string =>
slugify(val, {
lower: true,
strict: true,
});
async function isTitleFound(slug: string, collection: string, id: string) {
const titleFound = await payload.find({
collection,
where: {
slug: {
equals: slug,
},
id: {
not_equals: id,
},
},
});
if (titleFound.docs.length > 0) return true;
return false;
}
async function getUniqueSlug(slug: string, collection: string, id: string) {
let i = 2;
let isFound = await isTitleFound(slug, collection, id);
const regex = /^.*-\d+$/;
while (isFound) {
if (regex.test(slug)) {
const match = slug.match(regex);
if (match) {
i = parseInt(match[0].split("-").pop()) + 1;
}
slug = slug.replace(/\d+$/, "");
slug += `${i}`;
} else {
slug += `-${i}`;
}
isFound = await isTitleFound(slug, collection, id);
}
return slug;
}
const formatSlug =
(collection: string, fallback: string): FieldHook =>
({ operation, value, originalDoc, data }) => {
const { id } = originalDoc;
if (typeof value === "string") {
return getUniqueSlug(format(value), collection, id);
}
if (operation === "create") {
const fallbackData = data?.[fallback] || originalDoc?.[fallback];
if (fallbackData && typeof fallbackData === "string") {
return getUniqueSlug(format(fallbackData), collection, id);
}
}
return value;
};
export default formatSlug;
Star
Discord
online
Get dedicated engineering support directly from the Payload team.