/** * URL-Pattern-Matching für Modals (siehe modal.json5 `urlPattern`). * * Glob: '*' = ein Pfad-Segment (kein '/') * '**' = beliebig tief (auch leer) * '/aktion' → exakt /aktion * '/aktion/**' → /aktion und alles darunter * '/aktion/*' → direkte Kinder von /aktion (nicht /aktion selbst) * '**' oder '/*' → alles * Regex: Muster mit '~' beginnen, Rest ist echte Regex. * * Match gegen den Pfad (ohne Query), case-insensitive, trailing-Slash egal. */ /** Trailing-Slash normalisieren (Root bleibt "/"). */ function normPath(p: string): string { const noQuery = p.split('?')[0].split('#')[0]; if (noQuery.length > 1 && noQuery.endsWith('/')) return noQuery.slice(0, -1); return noQuery || '/'; } /** Glob-Pattern → RegExp. `**` matcht über Segmente, `*` innerhalb. */ function globToRegExp(glob: string): RegExp { const DOUBLE = 'D'; const SINGLE = 'S'; // 1) Platzhalter für * / ** setzen, 2) Regex-Sonderzeichen escapen, 3) zurück. let body = glob .replace(/\*\*/g, DOUBLE) .replace(/\*/g, SINGLE) .replace(/[.+?^${}()|[\]\\]/g, '\\$&') .split(DOUBLE) .join('.*') .split(SINGLE) .join('[^/]*'); // '/aktion/**' soll auch '/aktion' matchen → trailing '/.*' optional machen. body = body.replace(/\/\.\*$/, '(?:/.*)?'); return new RegExp(`^${body}$`, 'i'); } export function matchUrlPattern(pattern: string, pathname: string): boolean { if (!pattern) return false; const path = normPath(pathname); const p = pattern.trim(); if (p.startsWith('~')) { try { return new RegExp(p.slice(1), 'i').test(path); } catch { return false; // kaputte Regex → kein Match statt Crash } } return globToRegExp(normPath(p)).test(path); } /** Minimal-Shape eines aufgelösten Modal-Eintrags fürs Matching. */ export interface ModalLike { _slug?: string; slug?: string; urlPattern?: string; version?: string; timeFrom?: string | null; timeUntil?: string | null; } /** Stabiler localStorage-Key: pro Modal-Slug + version. */ export function dismissKey(m: ModalLike): string { const slug = m.slug ?? m._slug ?? 'unknown'; return `modal-dismissed:${slug}:${m.version ?? ''}`; } /** Im Zeitfenster [timeFrom, timeUntil]? Beide optional. */ export function inTimeWindow(m: ModalLike, now: number): boolean { if (m.timeFrom) { const from = Date.parse(m.timeFrom); if (!Number.isNaN(from) && now < from) return false; } if (m.timeUntil) { const until = Date.parse(m.timeUntil); if (!Number.isNaN(until) && now > until) return false; } return true; } /** * Erstes Modal wählen, das auf die Route passt + im Zeitfenster liegt + * nicht ausgeblendet ist. `isDismissed` kapselt localStorage/sessionStorage. */ export function selectModal( modals: T[], pathname: string, now: number, isDismissed: (m: T) => boolean, ): T | null { for (const m of modals) { if (!m?.urlPattern) continue; if (!matchUrlPattern(m.urlPattern, pathname)) continue; if (!inTimeWindow(m, now)) continue; if (isDismissed(m)) continue; return m; } return null; }