Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.

Unique slug generation

default discord avatar
Deleted User2 years ago
2

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

@456226577798135808

for the tip : )

  • default discord avatar
    brianjm2 years ago

    Can you submit a PR or issue?

  • default discord avatar
    modgy.last year

    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 on GitHub

Star

Chat on Discord

Discord

online

Can't find what you're looking for?

Get dedicated engineering support directly from the Payload team.