Files
dinggadingga-backend/src/utils/slug.ts

56 lines
2.3 KiB
TypeScript

/** 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<string, string> = { æ: '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>): 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;
}
}