// Deriving a Team's URL slug from a game-written name (TEAMS.md §2.1). // // A slug is derived ONCE, at create, and then frozen for the life of the row — // like `name`, and for the same reason: the Team page URL has to stay stable, and // a rename is an archive plus a create rather than an edit. const MAX_SLUG = 180 // the column is 191; leaves room for a -NN suffix /** * Reduce a name to a URL-safe stem. * * Diacritics are folded rather than stripped so "Ünderdark" becomes "underdark" * and not "nderdark". A name made entirely of characters that do not survive — * which a guild name genuinely can be, since the game accepts far more than a URL * does — leaves an empty stem, and the caller substitutes a stable fallback * rather than minting a Team with no address. */ function slugify(name) { return String(name || '') .normalize('NFKD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, MAX_SLUG) .replace(/-+$/g, '') } /** * A slug not already taken, given the ones that are. * * `taken` must include ARCHIVED teams' slugs, not only active ones. The unique * key constrains active rows alone, so the database would allow a new Team to * take a retired Team's slug — and §2.2 promises the retired one stays readable * at that address, which is what a bookmark or an old Discord link resolves to. */ function uniqueSlug(name, taken, { fallback = 'team' } = {}) { const base = slugify(name) || fallback const used = new Set(taken) if (!used.has(base)) return base // Bounded rather than unbounded: a suffix search that cannot terminate is worse // than a slug with an id in it, and 999 same-named teams is already absurd. for (let n = 2; n <= 999; n++) { const candidate = `${base}-${n}` if (!used.has(candidate)) return candidate } return `${base}-${Date.now().toString(36)}` } module.exports = { slugify, uniqueSlug, MAX_SLUG }