ui-nyla/src/utils/slugUtils.ts

152 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Generic slug helpers for FormGenerator inputs of type `slug`.
*
* Format: lowercase ASCII (`[a-z0-9]`), single-hyphen segments, configurable
* length range (defaults 232). 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 (az), Ziffern (09) und Bindestriche. Ohne führende oder folgende Bindestriche.';
export interface SlugOptions {
minLen?: number;
maxLen?: number;
}
const _GERMAN_MAP: Record<string, string> = {
ä: '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<string>,
opts: SlugOptions = {},
): string {
const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN;
const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN;
const used = new Set<string>();
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');
}