@momentumcms/plugins-analytics 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,523 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // libs/plugins/analytics/src/lib/client/block-tracker.ts
12
+ var block_tracker_exports = {};
13
+ __export(block_tracker_exports, {
14
+ attachBlockTracking: () => attachBlockTracking
15
+ });
16
+ function attachBlockTracking(tracker, container) {
17
+ if (typeof document === "undefined" || !document.body) {
18
+ return () => {
19
+ };
20
+ }
21
+ const root = container ?? document.body;
22
+ const impressionsSeen = /* @__PURE__ */ new Set();
23
+ const hoverCooldowns = /* @__PURE__ */ new Map();
24
+ const HOVER_COOLDOWN_MS = 5e3;
25
+ let observer = null;
26
+ function handleIntersection(entries) {
27
+ for (const entry of entries) {
28
+ if (!entry.isIntersecting)
29
+ continue;
30
+ if (!(entry.target instanceof HTMLElement))
31
+ continue;
32
+ const el = entry.target;
33
+ const blockType = el.dataset["blockType"];
34
+ const blockIndex = el.dataset["blockIndex"];
35
+ if (!blockType)
36
+ continue;
37
+ const key = `${blockType}:${blockIndex ?? "?"}`;
38
+ if (impressionsSeen.has(key))
39
+ continue;
40
+ impressionsSeen.add(key);
41
+ tracker.track("block_impression", {
42
+ blockType,
43
+ blockIndex: blockIndex ? Number(blockIndex) : void 0
44
+ });
45
+ }
46
+ }
47
+ observer = new IntersectionObserver(handleIntersection, {
48
+ threshold: 0.5
49
+ });
50
+ const tracked = Array.from(root.querySelectorAll("[data-block-track]"));
51
+ for (const el of tracked) {
52
+ const trackValue = el.dataset["blockTrack"] ?? "";
53
+ if (trackValue.includes("impressions")) {
54
+ observer.observe(el);
55
+ }
56
+ }
57
+ function handleHover(event) {
58
+ const target = event.target;
59
+ if (!(target instanceof HTMLElement))
60
+ return;
61
+ const blockEl = target.closest("[data-block-track]");
62
+ if (!blockEl)
63
+ return;
64
+ const trackValue = blockEl.dataset["blockTrack"] ?? "";
65
+ if (!trackValue.includes("hover"))
66
+ return;
67
+ const blockType = blockEl.dataset["blockType"];
68
+ const blockIndex = blockEl.dataset["blockIndex"];
69
+ if (!blockType)
70
+ return;
71
+ const key = `${blockType}:${blockIndex ?? "?"}`;
72
+ const now = Date.now();
73
+ const lastFired = hoverCooldowns.get(key);
74
+ if (lastFired !== void 0 && now - lastFired < HOVER_COOLDOWN_MS)
75
+ return;
76
+ hoverCooldowns.set(key, now);
77
+ tracker.track("block_hover", {
78
+ blockType,
79
+ blockIndex: blockIndex ? Number(blockIndex) : void 0
80
+ });
81
+ }
82
+ root.addEventListener("mouseenter", handleHover, true);
83
+ return () => {
84
+ observer?.disconnect();
85
+ observer = null;
86
+ root.removeEventListener("mouseenter", handleHover, true);
87
+ impressionsSeen.clear();
88
+ hoverCooldowns.clear();
89
+ };
90
+ }
91
+ var init_block_tracker = __esm({
92
+ "libs/plugins/analytics/src/lib/client/block-tracker.ts"() {
93
+ "use strict";
94
+ }
95
+ });
96
+
97
+ // libs/plugins/analytics/src/lib/utils/type-guards.ts
98
+ function isRecord(val) {
99
+ return val != null && typeof val === "object" && !Array.isArray(val);
100
+ }
101
+ var init_type_guards = __esm({
102
+ "libs/plugins/analytics/src/lib/utils/type-guards.ts"() {
103
+ "use strict";
104
+ }
105
+ });
106
+
107
+ // libs/plugins/analytics/src/lib/utils/selector-security.ts
108
+ function normalizeCssEscapes(selector) {
109
+ return selector.replace(
110
+ /\\([0-9a-fA-F]{1,6})\s?/g,
111
+ (_, hex) => String.fromCodePoint(parseInt(hex, 16))
112
+ ).replace(/\\([^0-9a-fA-F\n])/g, "$1");
113
+ }
114
+ function stripPseudoWrappers(selector) {
115
+ return selector.replace(/:(is|where|not|has|matches)\s*\(/gi, "(");
116
+ }
117
+ function isSelectorBlocked(selector) {
118
+ const normalized = stripPseudoWrappers(normalizeCssEscapes(selector));
119
+ return BLOCKED_SELECTOR_PATTERNS.some((pattern) => pattern.test(normalized));
120
+ }
121
+ var BLOCKED_SELECTOR_PATTERNS;
122
+ var init_selector_security = __esm({
123
+ "libs/plugins/analytics/src/lib/utils/selector-security.ts"() {
124
+ "use strict";
125
+ BLOCKED_SELECTOR_PATTERNS = [
126
+ /type\s*=\s*["']?password/i,
127
+ /type\s*=\s*["']?hidden/i,
128
+ /autocomplete\s*=\s*["']?cc-/i,
129
+ /autocomplete\s*=\s*["']?current-password/i,
130
+ /autocomplete\s*=\s*["']?new-password/i
131
+ ];
132
+ }
133
+ });
134
+
135
+ // libs/plugins/analytics/src/lib/client/rule-engine.ts
136
+ var rule_engine_exports = {};
137
+ __export(rule_engine_exports, {
138
+ createRuleEngine: () => createRuleEngine,
139
+ matchUrlPattern: () => matchUrlPattern,
140
+ parseRulesResponse: () => parseRulesResponse
141
+ });
142
+ function matchUrlPattern(pattern, pathname) {
143
+ if (pattern === "*")
144
+ return true;
145
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0GLOBSTAR\0").replace(/\*/g, "[^/]*").replace(/\0GLOBSTAR\0/g, ".*");
146
+ return new RegExp(`^${escaped}$`).test(pathname);
147
+ }
148
+ function parseRulesResponse(data) {
149
+ if (!isRecord(data) || !Array.isArray(data["rules"]))
150
+ return [];
151
+ const result = [];
152
+ for (const item of data["rules"]) {
153
+ if (!isRecord(item))
154
+ continue;
155
+ if (typeof item["selector"] !== "string" || typeof item["eventName"] !== "string")
156
+ continue;
157
+ if (isSelectorBlocked(item["selector"]))
158
+ continue;
159
+ result.push({
160
+ name: typeof item["name"] === "string" ? item["name"] : "",
161
+ selector: item["selector"],
162
+ eventType: typeof item["eventType"] === "string" ? item["eventType"] : "click",
163
+ eventName: item["eventName"],
164
+ urlPattern: typeof item["urlPattern"] === "string" ? item["urlPattern"] : "*",
165
+ properties: isRecord(item["properties"]) ? item["properties"] : {},
166
+ extractProperties: Array.isArray(item["extractProperties"]) ? item["extractProperties"] : void 0,
167
+ active: item["active"] === true,
168
+ rateLimit: typeof item["rateLimit"] === "number" ? item["rateLimit"] : void 0
169
+ });
170
+ }
171
+ return result;
172
+ }
173
+ function truncate(val, max) {
174
+ return val.length > max ? val.slice(0, max) : val;
175
+ }
176
+ function extractProperties(el, rule) {
177
+ const props = { ...rule.properties };
178
+ if (!Array.isArray(rule.extractProperties))
179
+ return props;
180
+ for (const raw of rule.extractProperties) {
181
+ if (!isRecord(raw))
182
+ continue;
183
+ const key = typeof raw["key"] === "string" ? raw["key"] : null;
184
+ const source = typeof raw["source"] === "string" ? raw["source"] : null;
185
+ if (!key || !source)
186
+ continue;
187
+ const rawMaxLen = typeof raw["maxLength"] === "number" ? raw["maxLength"] : MAX_EXTRACT_LENGTH;
188
+ const maxLen = Math.min(rawMaxLen, MAX_EXTRACT_LENGTH);
189
+ switch (source) {
190
+ case "text":
191
+ props[key] = truncate(el.textContent?.trim() ?? "", maxLen);
192
+ break;
193
+ case "attribute": {
194
+ const attr = typeof raw["attribute"] === "string" ? raw["attribute"] : "";
195
+ if (BLOCKED_ATTRIBUTES.has(attr.toLowerCase()))
196
+ break;
197
+ props[key] = truncate(el.getAttribute(attr) ?? "", maxLen);
198
+ break;
199
+ }
200
+ case "dataset": {
201
+ const dsKey = typeof raw["attribute"] === "string" ? raw["attribute"] : "";
202
+ props[key] = truncate(el.dataset[dsKey] ?? "", maxLen);
203
+ break;
204
+ }
205
+ }
206
+ }
207
+ return props;
208
+ }
209
+ function createRuleEngine(tracker, config = {}) {
210
+ const endpoint = config.endpoint ?? "/api/analytics/tracking-rules";
211
+ let rules = [];
212
+ let activeCleanups = [];
213
+ let running = false;
214
+ let navigationCleanup = null;
215
+ const rateLimits = /* @__PURE__ */ new Map();
216
+ function checkRateLimit(rule) {
217
+ if (!rule.rateLimit)
218
+ return true;
219
+ const now = Date.now();
220
+ const state = rateLimits.get(rule.eventName);
221
+ if (!state || now >= state.resetAt) {
222
+ rateLimits.set(rule.eventName, { count: 1, resetAt: now + 6e4 });
223
+ return true;
224
+ }
225
+ if (state.count >= rule.rateLimit)
226
+ return false;
227
+ state.count++;
228
+ return true;
229
+ }
230
+ function fireRule(rule, el) {
231
+ if (!checkRateLimit(rule))
232
+ return;
233
+ tracker.track(rule.eventName, extractProperties(el, rule));
234
+ }
235
+ function attachRulesForUrl(url) {
236
+ for (const cleanup of activeCleanups)
237
+ cleanup();
238
+ activeCleanups = [];
239
+ const pathname = url.startsWith("http") ? new URL(url).pathname : url;
240
+ const matching = rules.filter((r) => r.active && matchUrlPattern(r.urlPattern, pathname));
241
+ if (matching.length === 0)
242
+ return;
243
+ const delegatedTypes = [
244
+ { eventType: "click", domEvent: "click", capture: false },
245
+ { eventType: "submit", domEvent: "submit", capture: true },
246
+ { eventType: "hover", domEvent: "mouseenter", capture: true },
247
+ { eventType: "focus", domEvent: "focusin", capture: false }
248
+ ];
249
+ for (const { eventType, domEvent, capture } of delegatedTypes) {
250
+ const typeRules = matching.filter((r) => r.eventType === eventType);
251
+ if (typeRules.length === 0)
252
+ continue;
253
+ const handler = (e) => {
254
+ const target = e.target;
255
+ if (!(target instanceof HTMLElement))
256
+ return;
257
+ for (const rule of typeRules) {
258
+ try {
259
+ const matched = target.closest(rule.selector);
260
+ if (matched instanceof HTMLElement) {
261
+ fireRule(rule, matched);
262
+ }
263
+ } catch {
264
+ }
265
+ }
266
+ };
267
+ document.body.addEventListener(domEvent, handler, capture);
268
+ activeCleanups.push(() => document.body.removeEventListener(domEvent, handler, capture));
269
+ }
270
+ const scrollRules = matching.filter((r) => r.eventType === "scroll-into-view");
271
+ if (scrollRules.length > 0) {
272
+ const seen = /* @__PURE__ */ new Set();
273
+ const observer = new IntersectionObserver(
274
+ (entries) => {
275
+ for (const entry of entries) {
276
+ if (!entry.isIntersecting)
277
+ continue;
278
+ const el = entry.target;
279
+ if (!(el instanceof HTMLElement))
280
+ continue;
281
+ for (const rule of scrollRules) {
282
+ try {
283
+ if (!el.matches(rule.selector))
284
+ continue;
285
+ } catch {
286
+ continue;
287
+ }
288
+ const key = `${rule.eventName}:${rule.selector}`;
289
+ if (seen.has(key))
290
+ continue;
291
+ seen.add(key);
292
+ fireRule(rule, el);
293
+ observer.unobserve(el);
294
+ }
295
+ }
296
+ },
297
+ { threshold: 0.5 }
298
+ );
299
+ for (const rule of scrollRules) {
300
+ try {
301
+ const elements = Array.from(document.querySelectorAll(rule.selector));
302
+ for (const el of elements)
303
+ observer.observe(el);
304
+ } catch {
305
+ }
306
+ }
307
+ activeCleanups.push(() => observer.disconnect());
308
+ }
309
+ }
310
+ function setupNavigationDetection() {
311
+ const cleanups = [];
312
+ const popstateHandler = () => {
313
+ if (running)
314
+ attachRulesForUrl(location.pathname);
315
+ };
316
+ window.addEventListener("popstate", popstateHandler);
317
+ cleanups.push(() => window.removeEventListener("popstate", popstateHandler));
318
+ const originalPushState = history.pushState.bind(history);
319
+ const originalReplaceState = history.replaceState.bind(history);
320
+ history.pushState = function(...args) {
321
+ originalPushState(...args);
322
+ if (running)
323
+ attachRulesForUrl(location.pathname);
324
+ };
325
+ history.replaceState = function(...args) {
326
+ originalReplaceState(...args);
327
+ if (running)
328
+ attachRulesForUrl(location.pathname);
329
+ };
330
+ cleanups.push(() => {
331
+ history.pushState = originalPushState;
332
+ history.replaceState = originalReplaceState;
333
+ });
334
+ return () => {
335
+ for (const fn of cleanups)
336
+ fn();
337
+ };
338
+ }
339
+ return {
340
+ async start() {
341
+ try {
342
+ const res = await fetch(endpoint);
343
+ if (!res.ok)
344
+ return;
345
+ const data = await res.json();
346
+ rules = parseRulesResponse(data);
347
+ } catch {
348
+ return;
349
+ }
350
+ if (rules.length === 0)
351
+ return;
352
+ running = true;
353
+ attachRulesForUrl(location.pathname);
354
+ navigationCleanup = setupNavigationDetection();
355
+ },
356
+ stop() {
357
+ running = false;
358
+ for (const cleanup of activeCleanups)
359
+ cleanup();
360
+ activeCleanups = [];
361
+ navigationCleanup?.();
362
+ navigationCleanup = null;
363
+ rateLimits.clear();
364
+ },
365
+ onNavigate(url) {
366
+ if (running)
367
+ attachRulesForUrl(url);
368
+ }
369
+ };
370
+ }
371
+ var BLOCKED_ATTRIBUTES, MAX_EXTRACT_LENGTH;
372
+ var init_rule_engine = __esm({
373
+ "libs/plugins/analytics/src/lib/client/rule-engine.ts"() {
374
+ "use strict";
375
+ init_type_guards();
376
+ init_selector_security();
377
+ BLOCKED_ATTRIBUTES = /* @__PURE__ */ new Set(["value", "password", "autocomplete", "autofill"]);
378
+ MAX_EXTRACT_LENGTH = 200;
379
+ }
380
+ });
381
+
382
+ // libs/plugins/analytics/src/lib/client/tracker.ts
383
+ function getVisitorId() {
384
+ if (typeof localStorage === "undefined")
385
+ return "server";
386
+ let visitorId = localStorage.getItem("_m_vid");
387
+ if (!visitorId) {
388
+ visitorId = generateId();
389
+ localStorage.setItem("_m_vid", visitorId);
390
+ }
391
+ return visitorId;
392
+ }
393
+ function getSessionId() {
394
+ if (typeof sessionStorage === "undefined")
395
+ return "server";
396
+ let sessionId = sessionStorage.getItem("_m_sid");
397
+ if (!sessionId) {
398
+ sessionId = generateId();
399
+ sessionStorage.setItem("_m_sid", sessionId);
400
+ }
401
+ return sessionId;
402
+ }
403
+ function generateId() {
404
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
405
+ }
406
+ function onDomReady(fn) {
407
+ if (typeof document === "undefined")
408
+ return;
409
+ if (document.readyState === "loading") {
410
+ document.addEventListener("DOMContentLoaded", fn, { once: true });
411
+ } else {
412
+ fn();
413
+ }
414
+ }
415
+ function createTracker(config = {}) {
416
+ const endpoint = config.endpoint ?? "/api/analytics/collect";
417
+ const flushInterval = config.flushInterval ?? 5e3;
418
+ const buffer = [];
419
+ let userId;
420
+ let _timer = null;
421
+ const cleanups = [];
422
+ const visitorId = getVisitorId();
423
+ const sessionId = getSessionId();
424
+ function addEvent(event) {
425
+ buffer.push({
426
+ ...event,
427
+ sessionId,
428
+ visitorId
429
+ });
430
+ }
431
+ function flush() {
432
+ if (buffer.length === 0)
433
+ return;
434
+ const events = buffer.splice(0, buffer.length).map((e) => ({
435
+ ...e,
436
+ userId
437
+ }));
438
+ const body = JSON.stringify({ events });
439
+ if (typeof navigator !== "undefined" && navigator.sendBeacon) {
440
+ const blob = new Blob([body], { type: "application/json" });
441
+ navigator.sendBeacon(endpoint, blob);
442
+ } else if (typeof fetch !== "undefined") {
443
+ void fetch(endpoint, {
444
+ method: "POST",
445
+ headers: { "Content-Type": "application/json" },
446
+ body,
447
+ keepalive: true
448
+ });
449
+ }
450
+ }
451
+ if (typeof setInterval !== "undefined") {
452
+ _timer = setInterval(flush, flushInterval);
453
+ cleanups.push(() => {
454
+ if (_timer)
455
+ clearInterval(_timer);
456
+ _timer = null;
457
+ });
458
+ }
459
+ if (typeof addEventListener !== "undefined") {
460
+ addEventListener("beforeunload", flush);
461
+ cleanups.push(() => removeEventListener("beforeunload", flush));
462
+ }
463
+ const tracker = {
464
+ pageView(properties) {
465
+ addEvent({
466
+ name: "page_view",
467
+ category: "page",
468
+ properties,
469
+ context: {
470
+ url: typeof location !== "undefined" ? location.href : void 0,
471
+ referrer: typeof document !== "undefined" ? document.referrer : void 0
472
+ }
473
+ });
474
+ },
475
+ track(name, properties) {
476
+ addEvent({
477
+ name,
478
+ category: "action",
479
+ properties,
480
+ context: {
481
+ url: typeof location !== "undefined" ? location.href : void 0
482
+ }
483
+ });
484
+ },
485
+ identify(id, traits) {
486
+ userId = id;
487
+ addEvent({
488
+ name: "identify",
489
+ category: "custom",
490
+ properties: traits
491
+ });
492
+ },
493
+ flush,
494
+ destroy() {
495
+ flush();
496
+ for (const fn of cleanups)
497
+ fn();
498
+ cleanups.length = 0;
499
+ }
500
+ };
501
+ if (config.blockTracking) {
502
+ onDomReady(() => {
503
+ void Promise.resolve().then(() => (init_block_tracker(), block_tracker_exports)).then((m) => {
504
+ const blockCleanup = m.attachBlockTracking(tracker);
505
+ cleanups.push(blockCleanup);
506
+ });
507
+ });
508
+ }
509
+ if (config.trackingRules) {
510
+ const rulesEndpoint = typeof config.trackingRules === "object" ? config.trackingRules.endpoint : void 0;
511
+ onDomReady(() => {
512
+ void Promise.resolve().then(() => (init_rule_engine(), rule_engine_exports)).then((m) => {
513
+ const engine = m.createRuleEngine(tracker, { endpoint: rulesEndpoint });
514
+ void engine.start();
515
+ cleanups.push(() => engine.stop());
516
+ });
517
+ });
518
+ }
519
+ return tracker;
520
+ }
521
+ export {
522
+ createTracker
523
+ };