/** Longest slug generated, so that URLs stay readable. */ export const MAX_SLUG_LENGTH = 80; /** Valid slug: lowercase ASCII words separated by single hyphens. */ export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; /** Letters that Unicode normalization does not split into a base letter and an accent. */ const SPECIAL_LETTERS: Record = { æ: 'ae', œ: 'oe', ø: 'o', ß: 'ss', đ: 'd', ł: 'l', þ: 'th' }; /** * Turns a name into a slug: accents removed, lowercase, every run of other characters replaced by one hyphen, * cut at a word boundary to {@link MAX_SLUG_LENGTH}. "&" becomes "and" ("&TEAM" -> "and-team", not "team"). * @returns an empty string when nothing is left (name without any latin letter or digit, e.g. in hangul). */ export function slugify(name: string, maxLength = MAX_SLUG_LENGTH): string { const slug = name .normalize('NFKD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .replace(/[æœøßđłþ]/g, (letter) => SPECIAL_LETTERS[letter]) .replace(/&/g, ' and ') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return truncateSlug(slug, maxLength); } /** Cuts a slug to `maxLength`, at the last hyphen when there is one, never leaving a trailing hyphen. */ function truncateSlug(slug: string, maxLength: number): string { if (slug.length <= maxLength) return slug; const cut = slug.slice(0, maxLength); const lastHyphen = cut.lastIndexOf('-'); return (lastHyphen > 0 ? cut.slice(0, lastHyphen) : cut).replace(/-+$/, ''); } /** Appends `-suffix` to a slug, shortening the slug so that the result fits in {@link MAX_SLUG_LENGTH}. */ export function withSlugSuffix(slug: string, suffix: string | number): string { const end = `-${suffix}`; return truncateSlug(slug, MAX_SLUG_LENGTH - end.length) + end; } /** * Picks the first candidate not in `taken`. When they are all taken, numbers the last one (`-2`, `-3`...) * until a free slug is found. * @param candidates slugs by order of preference, at least one, none empty. */ export function pickAvailableSlug(candidates: string[], taken: ReadonlySet): string { const free = candidates.find((candidate) => !taken.has(candidate)); if (free) return free; const last = candidates[candidates.length - 1]; for (let n = 2; ; n++) { const numbered = withSlugSuffix(last, n); if (!taken.has(numbered)) return numbered; } }