Files
windwiderstand/src/lib/modal-match.ts
T
Peter Meier 84c41f80b1
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 2m10s
feat(modal): route-targeted content modals on navigation
Add a global modal/popup system driven entirely from the CMS. A new
`modal` config collection self-targets routes via a glob/regex urlPattern
(optional time window), and its `content` array of block references renders
through the existing BlockRenderer — so markdown, HTML and YouTube blocks
work exactly like a page row. A `modals` list collection holds the active
set behind an enable flag (analogous to `campaigns`).

- modal-match.ts: glob (`*`/`**`) + `~regex` pathname matcher, time window
  and modal selection helpers (+ unit tests)
- ContentModal.svelte: immediate/delay/scroll/exit_intent triggers, session
  vs. permanent dismiss (sessionStorage + localStorage, keyed by slug+version)
- +layout.server.ts: load `modals-global` in the bootstrap batch, resolve
  block images server-side
- +layout.svelte: mount on non-embed routes, match against the current path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:47:49 +02:00

102 lines
3.1 KiB
TypeScript

/**
* 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<T extends ModalLike>(
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;
}