152 lines
5.1 KiB
TypeScript
152 lines
5.1 KiB
TypeScript
/**
|
||
* 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<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');
|
||
}
|