/** * Generic slug helpers for FormGenerator inputs of type `slug`. * * Format: lowercase ASCII (`[a-z0-9]`), single-hyphen segments, configurable * length range (defaults 2–32). German umlauts are transliterated * (ä→ae, ö→oe, ü→ue, ß→ss) before slugging. * * Domain-specific helpers (e.g. `mandateNameUtils.ts`) MUST delegate here so * that all slug inputs stay in sync. */ export const DEFAULT_SLUG_MIN_LEN = 2; export const DEFAULT_SLUG_MAX_LEN = 32; export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; export const SLUG_HINT = 'Nur Kleinbuchstaben (a–z), Ziffern (0–9) und Bindestriche. Ohne führende oder folgende Bindestriche.'; export interface SlugOptions { minLen?: number; maxLen?: number; } const _GERMAN_MAP: Record = { ä: 'ae', Ä: 'ae', ö: 'oe', Ö: 'oe', ü: 'ue', Ü: 'ue', ß: 'ss', }; function _transliterateGerman(text: string): string { if (!text) return ''; let out = ''; for (const ch of text) { out += _GERMAN_MAP[ch] ?? ch; } return out; } function _collapseHyphensAndTrim(raw: string): string { const lowered = raw.toLowerCase(); const replaced = lowered.replace(/[^a-z0-9]+/g, '-'); return replaced.replace(/-+/g, '-').replace(/^-+|-+$/g, ''); } function _ensureMinSlugLength(slug: string, minLen: number): string { if (slug.length >= minLen) return slug; if (slug.length === 1) return slug + slug; return slug + 'x'.repeat(minLen - slug.length); } function _truncateSlugToMaxLen(slug: string, minLen: number, maxLen: number): string { if (slug.length <= maxLen) return slug; let cut = slug.slice(0, maxLen).replace(/-+$/g, ''); const lastHyphen = cut.lastIndexOf('-'); if (lastHyphen > 0) { cut = cut.slice(0, lastHyphen); } cut = cut.replace(/^-+|-+$/g, ''); if (cut.length < minLen) { return cut + 'x'.repeat(minLen - cut.length); } return cut; } export function transliterateGerman(text: string): string { return _transliterateGerman(text); } /** * Build a slug from a free-text source. Falls back to "x".repeat(minLen) * when no valid slug can be derived (callers can override the fallback). */ export function slugify(source: string | null | undefined, opts: SlugOptions = {}): string { const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN; const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN; const fallback = 'x'.repeat(Math.max(minLen, 2)); const src = (source ?? '').toString().trim(); if (!src) return fallback; const step1 = _transliterateGerman(src); const step2 = _collapseHyphensAndTrim(step1); if (!step2) return fallback; const ensured = _ensureMinSlugLength(step2, minLen); const truncated = _truncateSlugToMaxLen(ensured, minLen, maxLen); return isValidSlug(truncated, opts) ? truncated : fallback; } /** * Live-mask user input for a slug field. Does NOT enforce min length so users * can keep typing; the format check happens on submit. */ export function maskSlugInput(raw: string | null | undefined, opts: SlugOptions = {}): string { if (!raw) return ''; const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN; const transliterated = _transliterateGerman(String(raw)).toLowerCase(); const cleaned = transliterated.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-'); return cleaned.slice(0, maxLen); } export function isValidSlug(value: unknown, opts: SlugOptions = {}): value is string { const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN; const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN; if (typeof value !== 'string') return false; if (value.length < minLen || value.length > maxLen) return false; return SLUG_PATTERN.test(value); } /** Returns a localized error message for an invalid slug, or null when valid. */ export function validateSlug(value: unknown, opts: SlugOptions = {}): string | null { const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN; const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN; if (typeof value !== 'string' || value.length === 0) { return 'Wert ist erforderlich.'; } if (value.length < minLen) { return `Wert muss mindestens ${minLen} Zeichen lang sein.`; } if (value.length > maxLen) { return `Wert darf maximal ${maxLen} Zeichen lang sein.`; } if (!SLUG_PATTERN.test(value)) { return SLUG_HINT; } return null; } /** * Allocate a slug not already present in *taken*, by appending -2, -3, ... * Mirrors `allocateUniqueMandateSlug` in the gateway. */ export function allocateUniqueSlug( base: string, taken: Iterable, opts: SlugOptions = {}, ): string { const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN; const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN; const used = new Set(); for (const x of taken) { if (x) used.add(x); } if (!used.has(base)) return base; for (let n = 2; n <= 100000; n += 1) { const suffix = `-${n}`; const room = Math.max(maxLen - suffix.length, minLen); let root = base.slice(0, room).replace(/-+$/g, ''); if (root.length < minLen) root = 'x'.repeat(minLen); const cand = (root + suffix).slice(0, maxLen).replace(/-+$/g, ''); if (isValidSlug(cand, opts) && !used.has(cand)) return cand; } throw new Error('allocateUniqueSlug: could not allocate a unique slug'); }