@dotcms/events 0.0.0-stage → 26.10.5-1-alpha.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.
Files changed (57) hide show
  1. package/README.md +516 -2
  2. package/decision.cjs.js +108 -0
  3. package/decision.esm.js +92 -0
  4. package/index.cjs.default.js +1 -0
  5. package/index.cjs.js +3674 -0
  6. package/index.cjs.mjs +2 -0
  7. package/index.d.ts +1 -0
  8. package/index.esm.js +3672 -0
  9. package/markup.cjs.default.js +1 -0
  10. package/markup.cjs.js +161 -0
  11. package/markup.cjs.mjs +2 -0
  12. package/markup.d.ts +1 -0
  13. package/markup.esm.js +159 -0
  14. package/package.json +77 -4
  15. package/react.cjs.default.js +1 -0
  16. package/react.cjs.js +39 -0
  17. package/react.cjs.mjs +2 -0
  18. package/react.d.ts +1 -0
  19. package/react.esm.js +37 -0
  20. package/src/index.d.ts +8 -0
  21. package/src/lib/clicks/constants.d.ts +14 -0
  22. package/src/lib/clicks/plugin.d.ts +35 -0
  23. package/src/lib/clicks/tracker.d.ts +42 -0
  24. package/src/lib/clicks/utils.d.ts +13 -0
  25. package/src/lib/contentlets/constants.d.ts +36 -0
  26. package/src/lib/contentlets/utils.d.ts +64 -0
  27. package/src/lib/contentlets/viewport.d.ts +23 -0
  28. package/src/lib/events.d.ts +17 -0
  29. package/src/lib/experiments/api.d.ts +9 -0
  30. package/src/lib/experiments/boot.d.ts +53 -0
  31. package/src/lib/experiments/constants.d.ts +46 -0
  32. package/src/lib/experiments/decision.d.ts +53 -0
  33. package/src/lib/experiments/dom.d.ts +62 -0
  34. package/src/lib/experiments/engine.d.ts +66 -0
  35. package/src/lib/experiments/markup.d.ts +17 -0
  36. package/src/lib/experiments/models.d.ts +62 -0
  37. package/src/lib/experiments/plugin.d.ts +27 -0
  38. package/src/lib/experiments/store.d.ts +54 -0
  39. package/src/lib/impressions/constants.d.ts +28 -0
  40. package/src/lib/impressions/plugin.d.ts +38 -0
  41. package/src/lib/impressions/tracker.d.ts +67 -0
  42. package/src/lib/impressions/utils.d.ts +10 -0
  43. package/src/lib/models.d.ts +130 -0
  44. package/src/lib/pipeline/constants.d.ts +88 -0
  45. package/src/lib/pipeline/enricher/plugin.d.ts +32 -0
  46. package/src/lib/pipeline/identity/activity.d.ts +19 -0
  47. package/src/lib/pipeline/identity/plugin.d.ts +60 -0
  48. package/src/lib/pipeline/logger.d.ts +74 -0
  49. package/src/lib/pipeline/models.d.ts +588 -0
  50. package/src/lib/pipeline/navigation.d.ts +21 -0
  51. package/src/lib/pipeline/sender/http.d.ts +9 -0
  52. package/src/lib/pipeline/sender/plugin.d.ts +47 -0
  53. package/src/lib/pipeline/sender/queue.d.ts +33 -0
  54. package/src/lib/pipeline/utils.d.ts +119 -0
  55. package/src/lib/react/DotCMSExperiment.d.ts +36 -0
  56. package/src/markup.d.ts +7 -0
  57. package/src/react.d.ts +8 -0
package/index.esm.js ADDED
@@ -0,0 +1,3672 @@
1
+ import { getUVEState } from '@dotcms/uve';
2
+ import { E as EXPERIMENT_ATTRIBUTE, V as VARIANT_ATTRIBUTE, D as DEFAULT_VARIANT, R as REVEAL_STYLE_ID, a as DECISION_TIMEOUT_MS, b as VARIANT_QUERY_PARAM, Q as QUIET_MS, I as IS_USER_INCLUDED_REQUEST_TIMEOUT_MS, c as IS_USER_INCLUDED_PATH, S as STORAGE_KEYS, O as ONE_DAY_MS, d as decideVariant, B as BOOT_STATE_KEY, e as DEFAULT_ASSIGNMENT_TIMEOUT_MS } from './decision.esm.js';
3
+ import onRouteChange from '@analytics/router-utils';
4
+ import smartQueue from '@analytics/queue-utils';
5
+
6
+ /*! *****************************************************************************
7
+ Copyright (c) Microsoft Corporation.
8
+
9
+ Permission to use, copy, modify, and/or distribute this software for any
10
+ purpose with or without fee is hereby granted.
11
+
12
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
13
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
14
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
15
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
16
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
17
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
18
+ PERFORMANCE OF THIS SOFTWARE.
19
+ ***************************************************************************** */
20
+ /* global Reflect, Promise */
21
+
22
+
23
+ function __classPrivateFieldGet(receiver, state, kind, f) {
24
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
25
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
26
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
27
+ }
28
+
29
+ function __classPrivateFieldSet(receiver, state, value, kind, f) {
30
+ if (kind === "m") throw new TypeError("Private method is not writable");
31
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
32
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
33
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
34
+ }
35
+
36
+ /*
37
+ * Constants of the click tracker.
38
+ */
39
+ /**
40
+ * Event type for content clicks
41
+ * Must match DotCMSPredefinedEventType.CONTENT_CLICK
42
+ */
43
+ const CLICK_EVENT_TYPE = 'content_click';
44
+ /**
45
+ * Default debounce time in milliseconds for clicks
46
+ */
47
+ const DEFAULT_CLICK_THROTTLE_MS = 300;
48
+ /**
49
+ * CSS selector for clickable elements to track
50
+ * Only clicks on <a> and <button> elements are tracked
51
+ */
52
+ const CLICKABLE_ELEMENTS_SELECTOR = 'a, button';
53
+
54
+ /*
55
+ * Constants the content trackers share: how contentlets are found, and the rescan event.
56
+ */
57
+ /**
58
+ * Default debounce time in milliseconds for MutationObserver
59
+ */
60
+ const CONTENTLET_OBSERVER_DEBOUNCE_MS = 250;
61
+ /**
62
+ * CSS class selector for contentlet elements
63
+ *
64
+ * @important This constant is intentionally duplicated in @dotcms/react SDK
65
+ * (core-web/libs/sdk/react/src/lib/next/components/Contentlet/Contentlet.tsx), whose renderers
66
+ * print the class, and dotCMS prints it on traditional pages too. Both constants MUST have the
67
+ * same value ('dotcms-contentlet') for content tracking to find React-rendered contentlets.
68
+ *
69
+ * This duplication is intentional to keep the SDKs independent:
70
+ * - @dotcms/events works without React
71
+ * - @dotcms/react works without events
72
+ * - When both are used together, they must share the same class name
73
+ *
74
+ * If you need to change this value, you MUST update it in both locations:
75
+ * 1. This file (@dotcms/events)
76
+ * 2. Contentlet.tsx in @dotcms/react
77
+ *
78
+ * @see core-web/libs/sdk/react/src/lib/next/components/Contentlet/Contentlet.tsx
79
+ */
80
+ const CONTENTLET_CLASS = 'dotcms-contentlet';
81
+ /**
82
+ * Attribute that carries a contentlet's identifier (`dataset['dotIdentifier']`). The page
83
+ * renderers print it only once analytics is active, after hydration, so it can land after
84
+ * the contentlet itself.
85
+ */
86
+ const CONTENTLET_IDENTIFIER_ATTRIBUTE = 'data-dot-identifier';
87
+ /**
88
+ * Window event that asks the content trackers to scan for contentlets again. Sent when
89
+ * contentlets that were hidden become visible, which changes no DOM node the trackers watch
90
+ * (an experiment's rows are revealed through a style rule).
91
+ */
92
+ const CONTENTLET_RESCAN_EVENT = 'dotcms:events:rescan';
93
+
94
+ /** The window property that traditional pages and plain scripts reach `dotEvents` through. */
95
+ const EVENTS_WINDOW_KEY = 'dotEvents';
96
+ /**
97
+ * The Analytics.js name of the sender plugin. The enricher keys its hooks with it
98
+ * (`page:<name>`, `track:<name>`), so they run for the sender's calls only.
99
+ */
100
+ const SENDER_PLUGIN_NAME = 'dot-events-sender';
101
+ // Analytics endpoint for sending events to server
102
+ const ANALYTICS_ENDPOINT = '/api/v1/analytics/content/event';
103
+ /**
104
+ * The event types dotCMS accepts, each with its own schema. It rejects any other type, so the
105
+ * SDK sends no custom events.
106
+ */
107
+ const DotCMSPredefinedEventType = {
108
+ PAGEVIEW: 'pageview',
109
+ CONTENT_IMPRESSION: 'content_impression',
110
+ CONTENT_CLICK: 'content_click',
111
+ CONVERSION: 'conversion'
112
+ };
113
+ /**
114
+ * Expected UTM parameter keys for campaign tracking
115
+ */
116
+ const EXPECTED_UTM_KEYS = [
117
+ 'utm_source',
118
+ 'utm_medium',
119
+ 'utm_campaign',
120
+ 'utm_term',
121
+ 'utm_content'
122
+ ];
123
+ /**
124
+ * Session configuration constants
125
+ */
126
+ const DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
127
+ /**
128
+ * Session storage key for session ID
129
+ */
130
+ const SESSION_STORAGE_KEY = 'dot_events_session_id';
131
+ /**
132
+ * User ID configuration constants
133
+ */
134
+ const USER_ID_KEY = 'dot_events_user_id';
135
+ /**
136
+ * Default queue configuration batch size
137
+ */
138
+ const DEFAULT_QUEUE_CONFIG_BATCH_SIZE = 15;
139
+ /**
140
+ * Default queue configuration flush interval
141
+ */
142
+ const DEFAULT_QUEUE_CONFIG_FLUSH_INTERVAL = 5000;
143
+ /**
144
+ * Activity tracking configuration
145
+ * Events used to detect user activity for session management
146
+ * - click: Detects real user interaction with minimal performance impact
147
+ * - visibilitychange: Handled separately to detect tab changes
148
+ */
149
+ const ACTIVITY_EVENTS = ['click'];
150
+ /**
151
+ * Default queue configuration for event batching
152
+ */
153
+ const DEFAULT_QUEUE_CONFIG = {
154
+ eventBatchSize: DEFAULT_QUEUE_CONFIG_BATCH_SIZE, // Max events per batch - auto-sends when reached
155
+ flushInterval: DEFAULT_QUEUE_CONFIG_FLUSH_INTERVAL // Time between flushes - sends whatever is queued
156
+ };
157
+ /**
158
+ * Default properties that Analytics.js adds automatically
159
+ * These should be filtered out to only keep user-provided properties
160
+ */
161
+ const ANALYTICS_JS_DEFAULT_PROPERTIES = [
162
+ 'title',
163
+ 'url',
164
+ 'path',
165
+ 'hash',
166
+ 'search',
167
+ 'width',
168
+ 'height',
169
+ 'referrer'
170
+ ];
171
+ /**
172
+ * Window flag that says the SDK is active on the page. Not this package's name to change:
173
+ * `@dotcms/uve/internal` declares it too (`ANALYTICS_ACTIVE_WINDOW_KEY`), and the React, Vue
174
+ * and Angular SDKs read it to print the contentlet attributes impressions and clicks need.
175
+ */
176
+ const ANALYTICS_WINDOWS_ACTIVE_KEY = '__dotAnalyticsActive__';
177
+ /**
178
+ * Window event sent once the SDK is active, so the renderers that read
179
+ * `ANALYTICS_WINDOWS_ACTIVE_KEY` render again. Shared with `@dotcms/uve/internal`
180
+ * (`ANALYTICS_READY_EVENT`) like that flag.
181
+ */
182
+ const ANALYTICS_READY_EVENT = 'dotcms:analytics:ready';
183
+ /**
184
+ * Queue persistence configuration constants
185
+ * Used for storing events in sessionStorage for traditional page navigations
186
+ */
187
+ /**
188
+ * Session storage key for persistent tab ID
189
+ * This ID remains constant across page navigations within the same browser tab
190
+ */
191
+ const TAB_ID_STORAGE_KEY = 'dot_events_tab_id';
192
+ /**
193
+ * Prefix for queue storage key in sessionStorage
194
+ * Full key format: dot_events_queue_{tabId}
195
+ */
196
+ const QUEUE_STORAGE_KEY_PREFIX = 'dot_events_queue';
197
+ /**
198
+ * Maximum age in milliseconds for persisted events
199
+ * Events older than this will be discarded (24 hours)
200
+ */
201
+ const MAX_EVENT_AGE_MS = 24 * 60 * 60 * 1000;
202
+
203
+ var _DotLogger_packageName, _DotLogger_context, _DotLogger_minLevel;
204
+ /**
205
+ * Priority map for log level comparisons
206
+ * Lower numbers = more verbose, higher numbers = less verbose
207
+ */
208
+ const LOG_LEVEL_PRIORITY = {
209
+ debug: 0,
210
+ info: 1,
211
+ warn: 2,
212
+ error: 3
213
+ };
214
+ /**
215
+ * Custom logger for DotCMS SDK
216
+ * Provides structured logging with context identification and configurable log level filtering
217
+ *
218
+ * @example
219
+ * ```typescript
220
+ * const logger = new DotLogger('events', 'Impression', 'info');
221
+ * logger.debug('This will not show'); // Below threshold
222
+ * logger.info('Tracker initialized'); // Shows
223
+ * logger.error('Failed to track'); // Shows
224
+ * ```
225
+ */
226
+ class DotLogger {
227
+ constructor(packageName, context, minLevel = 'warn') {
228
+ _DotLogger_packageName.set(this, void 0);
229
+ _DotLogger_context.set(this, void 0);
230
+ _DotLogger_minLevel.set(this, void 0);
231
+ __classPrivateFieldSet(this, _DotLogger_packageName, packageName, "f");
232
+ __classPrivateFieldSet(this, _DotLogger_context, context, "f");
233
+ __classPrivateFieldSet(this, _DotLogger_minLevel, minLevel, "f");
234
+ }
235
+ /**
236
+ * Creates the formatted prefix for log messages
237
+ * Format: [dotCMS packageName | Context] [LEVEL]
238
+ */
239
+ getPrefix(level) {
240
+ return `[dotCMS ${__classPrivateFieldGet(this, _DotLogger_packageName, "f")} | ${__classPrivateFieldGet(this, _DotLogger_context, "f")}] [${level.toUpperCase()}]`;
241
+ }
242
+ /**
243
+ * Checks if a log level should be displayed based on the minimum threshold
244
+ */
245
+ shouldLog(level) {
246
+ return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[__classPrivateFieldGet(this, _DotLogger_minLevel, "f")];
247
+ }
248
+ /**
249
+ * Log a DEBUG level message
250
+ * Used for detailed debugging information
251
+ */
252
+ debug(...args) {
253
+ if (this.shouldLog('debug')) {
254
+ // eslint-disable-next-line no-console
255
+ console.log(this.getPrefix('debug'), ...args);
256
+ }
257
+ }
258
+ /**
259
+ * Log an INFO level message
260
+ * Used for general informational messages
261
+ */
262
+ info(...args) {
263
+ if (this.shouldLog('info')) {
264
+ // eslint-disable-next-line no-console
265
+ console.info(this.getPrefix('info'), ...args);
266
+ }
267
+ }
268
+ /**
269
+ * Log a WARN level message
270
+ * Used for warning messages that don't prevent operation
271
+ */
272
+ warn(...args) {
273
+ if (this.shouldLog('warn')) {
274
+ console.warn(this.getPrefix('warn'), ...args);
275
+ }
276
+ }
277
+ /**
278
+ * Log an ERROR level message
279
+ * Always logs regardless of threshold for critical errors
280
+ */
281
+ error(...args) {
282
+ console.error(this.getPrefix('error'), ...args);
283
+ }
284
+ /**
285
+ * Create a console group for organizing related log messages
286
+ * Respects the minimum log level threshold
287
+ */
288
+ group(label) {
289
+ if (this.shouldLog('debug')) {
290
+ // eslint-disable-next-line no-console
291
+ console.group(`${this.getPrefix('debug')} ${label}`);
292
+ }
293
+ }
294
+ /**
295
+ * End the current console group
296
+ * Respects the minimum log level threshold
297
+ */
298
+ groupEnd() {
299
+ if (this.shouldLog('debug')) {
300
+ // eslint-disable-next-line no-console
301
+ console.groupEnd();
302
+ }
303
+ }
304
+ /**
305
+ * Start a timer with the given label
306
+ * Useful for performance measurements
307
+ */
308
+ time(label) {
309
+ if (this.shouldLog('debug')) {
310
+ // eslint-disable-next-line no-console
311
+ console.time(`${this.getPrefix('debug')} ${label}`);
312
+ }
313
+ }
314
+ /**
315
+ * End a timer and log the elapsed time
316
+ * Useful for performance measurements
317
+ */
318
+ timeEnd(label) {
319
+ if (this.shouldLog('debug')) {
320
+ // eslint-disable-next-line no-console
321
+ console.timeEnd(`${this.getPrefix('debug')} ${label}`);
322
+ }
323
+ }
324
+ /**
325
+ * Log a message (alias for info)
326
+ * Provided for backward compatibility
327
+ */
328
+ log(...args) {
329
+ this.info(...args);
330
+ }
331
+ }
332
+ _DotLogger_packageName = new WeakMap(), _DotLogger_context = new WeakMap(), _DotLogger_minLevel = new WeakMap();
333
+
334
+ var _DotCMSActivityTracker_activityListeners, _DotCMSActivityTracker_inactivityTimer, _DotCMSActivityTracker_isThrottled, _DotCMSActivityTracker_config;
335
+ /** Session activity is recorded at most once a second */
336
+ const ACTIVITY_THROTTLE_MS = 1000;
337
+ /**
338
+ * Activity tracking for the events pipeline.
339
+ * Handles user activity monitoring, session management, and inactivity detection.
340
+ * Singleton pattern since we only handle one site at a time.
341
+ */
342
+ class DotCMSActivityTracker {
343
+ constructor() {
344
+ _DotCMSActivityTracker_activityListeners.set(this, []);
345
+ _DotCMSActivityTracker_inactivityTimer.set(this, null);
346
+ _DotCMSActivityTracker_isThrottled.set(this, false);
347
+ _DotCMSActivityTracker_config.set(this, null);
348
+ }
349
+ /**
350
+ * Updates activity timestamp (throttled for performance)
351
+ */
352
+ updateActivityTime() {
353
+ // Reset inactivity timer
354
+ if (__classPrivateFieldGet(this, _DotCMSActivityTracker_inactivityTimer, "f")) {
355
+ clearTimeout(__classPrivateFieldGet(this, _DotCMSActivityTracker_inactivityTimer, "f"));
356
+ }
357
+ __classPrivateFieldSet(this, _DotCMSActivityTracker_inactivityTimer, setTimeout(() => {
358
+ // User became inactive - handle session timeout
359
+ if (__classPrivateFieldGet(this, _DotCMSActivityTracker_config, "f")?.debug) {
360
+ console.warn('[dotCMS events] User became inactive after timeout');
361
+ }
362
+ // Timer has fired, set to null to indicate no active timer
363
+ __classPrivateFieldSet(this, _DotCMSActivityTracker_inactivityTimer, null, "f");
364
+ }, DEFAULT_SESSION_TIMEOUT_MINUTES * 60 * 1000), "f");
365
+ }
366
+ /**
367
+ * Updates session activity with throttling
368
+ */
369
+ updateSessionActivity() {
370
+ if (__classPrivateFieldGet(this, _DotCMSActivityTracker_isThrottled, "f"))
371
+ return;
372
+ __classPrivateFieldSet(this, _DotCMSActivityTracker_isThrottled, true, "f");
373
+ this.updateActivityTime();
374
+ setTimeout(() => {
375
+ __classPrivateFieldSet(this, _DotCMSActivityTracker_isThrottled, false, "f");
376
+ }, ACTIVITY_THROTTLE_MS);
377
+ }
378
+ /**
379
+ * Initializes activity tracking with event listeners
380
+ */
381
+ initialize(config) {
382
+ this.cleanup();
383
+ __classPrivateFieldSet(this, _DotCMSActivityTracker_config, config, "f");
384
+ // Early return if window is not available (SSR/build time)
385
+ if (typeof window === 'undefined') {
386
+ return;
387
+ }
388
+ const throttledHandler = () => this.updateSessionActivity();
389
+ // Add activity event listeners
390
+ ACTIVITY_EVENTS.forEach((eventType) => {
391
+ window.addEventListener(eventType, throttledHandler, { passive: true });
392
+ __classPrivateFieldGet(this, _DotCMSActivityTracker_activityListeners, "f").push(() => window.removeEventListener(eventType, throttledHandler));
393
+ });
394
+ // Handle visibility changes for mobile/background scenarios
395
+ const handleVisibilityChange = () => {
396
+ if (document.visibilityState === 'visible') {
397
+ this.updateSessionActivity();
398
+ if (config.debug) {
399
+ console.warn('[dotCMS events] User returned to tab, session reactivated');
400
+ }
401
+ }
402
+ };
403
+ document.addEventListener('visibilitychange', handleVisibilityChange);
404
+ __classPrivateFieldGet(this, _DotCMSActivityTracker_activityListeners, "f").push(() => document.removeEventListener('visibilitychange', handleVisibilityChange));
405
+ // Initialize activity time
406
+ this.updateActivityTime();
407
+ if (config.debug) {
408
+ console.warn('[dotCMS events] Activity tracking initialized');
409
+ }
410
+ }
411
+ /**
412
+ * Cleans up all activity tracking listeners
413
+ */
414
+ cleanup() {
415
+ __classPrivateFieldGet(this, _DotCMSActivityTracker_activityListeners, "f").forEach((cleanup) => cleanup());
416
+ __classPrivateFieldSet(this, _DotCMSActivityTracker_activityListeners, [], "f");
417
+ if (__classPrivateFieldGet(this, _DotCMSActivityTracker_inactivityTimer, "f")) {
418
+ clearTimeout(__classPrivateFieldGet(this, _DotCMSActivityTracker_inactivityTimer, "f"));
419
+ __classPrivateFieldSet(this, _DotCMSActivityTracker_inactivityTimer, null, "f");
420
+ }
421
+ __classPrivateFieldSet(this, _DotCMSActivityTracker_config, null, "f");
422
+ }
423
+ }
424
+ _DotCMSActivityTracker_activityListeners = new WeakMap(), _DotCMSActivityTracker_inactivityTimer = new WeakMap(), _DotCMSActivityTracker_isThrottled = new WeakMap(), _DotCMSActivityTracker_config = new WeakMap();
425
+ // Create singleton instance
426
+ const activityTracker = new DotCMSActivityTracker();
427
+ /**
428
+ * Updates session activity with throttling
429
+ */
430
+ const updateSessionActivity = () => {
431
+ activityTracker.updateSessionActivity();
432
+ };
433
+ /**
434
+ * Initializes activity tracking
435
+ */
436
+ const initializeActivityTracking = (config) => {
437
+ activityTracker.initialize(config);
438
+ };
439
+ /**
440
+ * Cleans up activity tracking listeners and resets analytics state
441
+ */
442
+ const cleanupActivityTracking = () => {
443
+ activityTracker.cleanup();
444
+ // Reset analytics state and cleanup window properties
445
+ if (typeof window !== 'undefined') {
446
+ window[ANALYTICS_WINDOWS_ACTIVE_KEY] = false;
447
+ // Dispatch cleanup event to notify any subscribers
448
+ window.dispatchEvent(new CustomEvent('dotcms:events:cleanup'));
449
+ }
450
+ };
451
+
452
+ // Performance cache for static browser data that rarely changes
453
+ let staticBrowserData = null;
454
+ // UTM parameters cache to avoid repetitive URL parsing
455
+ let utmCache = null;
456
+ /**
457
+ * Generates a random ID from the current time and Math.random: unique enough for session and
458
+ * user ids, but not cryptographically secure.
459
+ * @internal This function is for internal use only and should not be used outside of the SDK.
460
+ * @param prefix - The prefix for the generated ID
461
+ * @returns A unique ID string with the given prefix
462
+ */
463
+ const generateSecureId = (prefix) => {
464
+ const timestamp = Date.now();
465
+ const randomPart = Math.random().toString(36).substr(2, 9);
466
+ const extraRandom = Math.random().toString(36).substr(2, 9);
467
+ return `${prefix}_${timestamp}_${randomPart}${extraRandom}`;
468
+ };
469
+ /**
470
+ * Safe localStorage wrapper with error handling.
471
+ * @internal This is for internal use only.
472
+ */
473
+ const safeLocalStorage = {
474
+ getItem: (key) => {
475
+ try {
476
+ return localStorage.getItem(key);
477
+ }
478
+ catch {
479
+ return null;
480
+ }
481
+ },
482
+ setItem: (key, value) => {
483
+ try {
484
+ localStorage.setItem(key, value);
485
+ }
486
+ catch (e) {
487
+ console.warn(`[dotCMS events] Could not save ${key} to localStorage`);
488
+ throw e;
489
+ }
490
+ }
491
+ };
492
+ /**
493
+ * Safe sessionStorage wrapper with error handling
494
+ */
495
+ const safeSessionStorage = {
496
+ getItem: (key) => {
497
+ try {
498
+ return sessionStorage.getItem(key);
499
+ }
500
+ catch {
501
+ return null;
502
+ }
503
+ },
504
+ setItem: (key, value) => {
505
+ try {
506
+ sessionStorage.setItem(key, value);
507
+ }
508
+ catch (e) {
509
+ console.warn(`[dotCMS events] Could not save ${key} to sessionStorage`);
510
+ throw e;
511
+ }
512
+ },
513
+ removeItem: (key) => {
514
+ try {
515
+ sessionStorage.removeItem(key);
516
+ }
517
+ catch {
518
+ console.warn(`[dotCMS events] Could not remove ${key} from sessionStorage`);
519
+ }
520
+ }
521
+ };
522
+ /**
523
+ * Gets or generates a user ID from localStorage.
524
+ * @internal This function is for internal use only.
525
+ * @returns The user ID string
526
+ */
527
+ const getUserId = () => {
528
+ let userId = safeLocalStorage.getItem(USER_ID_KEY);
529
+ if (!userId) {
530
+ userId = generateSecureId('user');
531
+ try {
532
+ safeLocalStorage.setItem(USER_ID_KEY, userId);
533
+ }
534
+ catch {
535
+ // Ignore storage errors for user ID (ephemeral user)
536
+ }
537
+ }
538
+ return userId;
539
+ };
540
+ /**
541
+ * Checks if a new day has started since session creation
542
+ */
543
+ const hasPassedMidnight = (sessionStartTime) => {
544
+ const sessionStart = new Date(sessionStartTime);
545
+ const now = new Date();
546
+ const sessionStartUTC = new Date(sessionStart.getUTCFullYear(), sessionStart.getUTCMonth(), sessionStart.getUTCDate());
547
+ const nowUTC = new Date(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
548
+ return sessionStartUTC.getTime() !== nowUTC.getTime();
549
+ };
550
+ /**
551
+ * Gets session ID with comprehensive lifecycle management.
552
+ * Returns existing valid session ID or creates a new one if needed.
553
+ * @internal This function is for internal use only.
554
+ *
555
+ * Session validation criteria:
556
+ * 1. User is still active (< 30 min inactivity)
557
+ * 2. Session hasn't passed midnight (UTC)
558
+ *
559
+ * @returns The session ID string
560
+ */
561
+ const getSessionId = () => {
562
+ const now = Date.now();
563
+ // Early return if window is not available (SSR/build time)
564
+ if (typeof window === 'undefined') {
565
+ return generateSecureId('session_fallback');
566
+ }
567
+ try {
568
+ const sessionData = sessionStorage.getItem(SESSION_STORAGE_KEY);
569
+ if (sessionData) {
570
+ const { sessionId, startTime, lastActivity } = JSON.parse(sessionData);
571
+ // Check if session is still valid
572
+ const hasNotPassedMidnight = !hasPassedMidnight(startTime);
573
+ const hasRecentActivity = now - lastActivity < DEFAULT_SESSION_TIMEOUT_MINUTES * 60 * 1000;
574
+ if (hasNotPassedMidnight && hasRecentActivity) {
575
+ // Update last activity time
576
+ sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({
577
+ sessionId,
578
+ startTime,
579
+ lastActivity: now
580
+ }));
581
+ return sessionId;
582
+ }
583
+ }
584
+ // Create new session
585
+ const newSessionId = generateSecureId('session');
586
+ const sessionDataToStore = {
587
+ sessionId: newSessionId,
588
+ startTime: now,
589
+ lastActivity: now
590
+ };
591
+ sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessionDataToStore));
592
+ return newSessionId;
593
+ }
594
+ catch {
595
+ // Fallback to simple session ID if storage fails
596
+ return generateSecureId('session_fallback');
597
+ }
598
+ };
599
+ /**
600
+ * Gets analytics context with user and session identification.
601
+ * Used by the identity plugin to inject context into analytics events.
602
+ *
603
+ * @param config - The analytics configuration object
604
+ * @returns The analytics context with site_auth, session_id, user_id and device
605
+ */
606
+ const getEventContext = (config) => {
607
+ const sessionId = getSessionId();
608
+ const userId = getUserId();
609
+ const device = getDeviceDataForContext();
610
+ return {
611
+ site_auth: config.siteAuth,
612
+ session_id: sessionId,
613
+ user_id: userId,
614
+ device
615
+ };
616
+ };
617
+ /**
618
+ * Gets static browser data (cached for performance)
619
+ */
620
+ const getStaticBrowserData = () => {
621
+ if (staticBrowserData) {
622
+ return staticBrowserData;
623
+ }
624
+ staticBrowserData = {
625
+ user_language: navigator.language,
626
+ doc_encoding: document.characterSet || document.charset,
627
+ screen_resolution: typeof screen !== 'undefined' && screen.width && screen.height
628
+ ? `${screen.width}x${screen.height}`
629
+ : ''
630
+ };
631
+ return staticBrowserData;
632
+ };
633
+ /**
634
+ * Gets current device data for analytics.
635
+ * Combines static browser data with dynamic viewport information.
636
+ * Used by the identity plugin to inject device data into context.
637
+ *
638
+ * @returns Device data with screen resolution, language, and viewport dimensions
639
+ */
640
+ const getDeviceDataForContext = () => {
641
+ const staticData = getStaticBrowserData();
642
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
643
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
644
+ return {
645
+ screen_resolution: staticData.screen_resolution ?? '',
646
+ language: staticData.user_language ?? '',
647
+ viewport_width: String(viewportWidth),
648
+ viewport_height: String(viewportHeight)
649
+ };
650
+ };
651
+ /**
652
+ * Extracts and transforms UTM parameters from the URL - cached for performance.
653
+ * Returns UTM data in DotCMS format (without 'utm_' prefix).
654
+ * @internal This function is for internal use only.
655
+ * @param location - The Location object to extract UTM parameters from
656
+ * @returns DotCMSEventUtmData object with transformed UTM parameters (source, medium, campaign, etc.)
657
+ */
658
+ const extractUTMParameters = (location) => {
659
+ const search = location.search;
660
+ // Return cached result if search hasn't changed
661
+ if (utmCache && utmCache.search === search) {
662
+ return utmCache.params;
663
+ }
664
+ const urlParams = new URLSearchParams(search);
665
+ const utmData = {};
666
+ EXPECTED_UTM_KEYS.forEach((key) => {
667
+ const value = urlParams.get(key);
668
+ if (value) {
669
+ // Transform utm_source -> source, utm_medium -> medium, etc.
670
+ const cleanKey = key.replace('utm_', '');
671
+ utmData[cleanKey] = value;
672
+ }
673
+ });
674
+ // Cache the result
675
+ utmCache = { search, params: utmData };
676
+ return utmData;
677
+ };
678
+ /**
679
+ * Gets timezone offset in the format +HH:mm or -HH:mm
680
+ */
681
+ const getTimezoneOffset = () => {
682
+ try {
683
+ const offset = new Date().getTimezoneOffset();
684
+ const sign = offset > 0 ? '-' : '+';
685
+ const absOffset = Math.abs(offset);
686
+ const hours = Math.floor(absOffset / 60);
687
+ const minutes = absOffset % 60;
688
+ return `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
689
+ }
690
+ catch {
691
+ return '+00:00';
692
+ }
693
+ };
694
+ /**
695
+ * Gets local time in ISO format without milliseconds.
696
+ * Used by enricher plugins to add local_time to events.
697
+ *
698
+ * @returns Local time string in ISO 8601 format with timezone offset (e.g., "2024-01-01T12:00:00-05:00")
699
+ */
700
+ const getLocalTime = () => {
701
+ try {
702
+ const now = new Date();
703
+ const timezoneOffset = getTimezoneOffset();
704
+ // Format: YYYY-MM-DDTHH:mm:ss+HH:mm (without milliseconds)
705
+ const year = now.getFullYear();
706
+ const month = (now.getMonth() + 1).toString().padStart(2, '0');
707
+ const day = now.getDate().toString().padStart(2, '0');
708
+ const hours = now.getHours().toString().padStart(2, '0');
709
+ const minutes = now.getMinutes().toString().padStart(2, '0');
710
+ const seconds = now.getSeconds().toString().padStart(2, '0');
711
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${timezoneOffset}`;
712
+ }
713
+ catch {
714
+ return new Date().toISOString();
715
+ }
716
+ };
717
+ /**
718
+ * Optimized payload enrichment using existing analytics.js data.
719
+ * Filters out Analytics.js default properties and only keeps user-provided properties in custom.
720
+ * Used by the enricher plugin to transform Analytics.js payload into DotCMS event format.
721
+ *
722
+ * @param payload - The Analytics.js payload with context already injected by identity plugin
723
+ * @param location - The Location object to extract page data from (defaults to window.location)
724
+ * @returns Enriched payload with page, UTM, custom data, and local_time (device is in context)
725
+ */
726
+ const enrichPagePayloadOptimized = (payload, location = typeof window !== 'undefined' ? window.location : {}) => {
727
+ const local_time = getLocalTime();
728
+ const staticData = getStaticBrowserData();
729
+ // Extract data from analytics.js payload
730
+ const { properties } = payload;
731
+ // Filter out Analytics.js default properties to get only user-provided properties
732
+ const userProvidedProperties = {};
733
+ Object.keys(properties).forEach((key) => {
734
+ if (!ANALYTICS_JS_DEFAULT_PROPERTIES.includes(key)) {
735
+ userProvidedProperties[key] = properties[key];
736
+ }
737
+ });
738
+ // TODO: Fix this when we detect the locale_id of the page in not server side
739
+ const locale_id = payload.context?.device?.language?.toLowerCase() ?? '';
740
+ const pageData = {
741
+ url: location.href,
742
+ doc_encoding: staticData.doc_encoding,
743
+ doc_hash: location.hash,
744
+ doc_protocol: location.protocol,
745
+ doc_search: location.search,
746
+ doc_host: location.hostname,
747
+ doc_path: location.pathname,
748
+ title: properties['title'] ?? document?.title,
749
+ locale_id
750
+ };
751
+ // Extract UTM parameters from the current URL (already transformed to DotCMS format)
752
+ const utmData = extractUTMParameters(location);
753
+ return {
754
+ ...payload,
755
+ page: pageData,
756
+ ...(Object.keys(utmData).length > 0 && { utm: utmData }),
757
+ // Only include custom if there are user-provided properties
758
+ ...(Object.keys(userProvidedProperties).length > 0 && { custom: userProvidedProperties }),
759
+ local_time
760
+ };
761
+ };
762
+ /**
763
+ * Checks if code is running in a browser environment
764
+ * @returns true if window and document are available
765
+ */
766
+ const isBrowser$1 = () => {
767
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
768
+ };
769
+ /**
770
+ * Runs a callback when the browser discards the page. A page it keeps in the back/forward
771
+ * cache (`pagehide` with `persisted`) comes back as it was when the visitor returns, with its
772
+ * listeners and observers, so it is left alone. `beforeunload` is not used: it fires before the
773
+ * page goes into that cache too.
774
+ *
775
+ * @param discarded - Called on the `pagehide` that discards the page
776
+ */
777
+ const onPageDiscard = (discarded) => {
778
+ window.addEventListener('pagehide', (event) => {
779
+ if (!event.persisted) {
780
+ discarded();
781
+ }
782
+ });
783
+ };
784
+ /**
785
+ * Creates a logger with plugin-specific prefix and configurable log level
786
+ * @param pluginName - Name of the plugin (e.g., 'Click', 'Impression')
787
+ * @param config - Analytics configuration with debug flag and optional logLevel
788
+ * @returns DotLogger instance with configured log level
789
+ */
790
+ const createPluginLogger = (pluginName, config) => {
791
+ // Use explicit logLevel if provided, otherwise fall back to debug flag
792
+ const level = config.logLevel ?? (config.debug ? 'debug' : 'warn');
793
+ return new DotLogger('events', pluginName, level);
794
+ };
795
+
796
+ /*
797
+ * Helpers of the content trackers: they find contentlets in the DOM, read their data, watch
798
+ * for new ones and decide which trackers run.
799
+ */
800
+ /**
801
+ * Creates a throttled version of a callback function
802
+ * Ensures the callback is executed at most once every `limitMs` milliseconds
803
+ * @param callback - The function to throttle
804
+ * @param limitMs - The time limit in milliseconds
805
+ * @param options - `trailing`: instead of dropping the calls made inside the window, run the
806
+ * last one when the window ends
807
+ * @returns A throttled function
808
+ */
809
+ function createThrottle(callback, limitMs, options = {}) {
810
+ let lastRun = 0;
811
+ let trailingTimer = null;
812
+ return (...args) => {
813
+ const now = Date.now();
814
+ const wait = limitMs - (now - lastRun);
815
+ if (wait <= 0) {
816
+ callback(...args);
817
+ lastRun = now;
818
+ return;
819
+ }
820
+ if (options.trailing && trailingTimer === null) {
821
+ trailingTimer = setTimeout(() => {
822
+ trailingTimer = null;
823
+ lastRun = Date.now();
824
+ callback(...args);
825
+ }, wait);
826
+ }
827
+ };
828
+ }
829
+ /**
830
+ * Extracts the contentlet identifier from a DOM element
831
+ * @param element - The HTML element containing data attributes
832
+ * @returns The contentlet identifier or null if not found
833
+ */
834
+ function extractContentletIdentifier(element) {
835
+ return element.dataset['dotIdentifier'] || null;
836
+ }
837
+ /**
838
+ * Extracts all contentlet data from a DOM element's data attributes
839
+ * @param element - The HTML element containing data attributes
840
+ * @returns Complete contentlet data object
841
+ */
842
+ function extractContentletData(element) {
843
+ return {
844
+ identifier: element.dataset['dotIdentifier'] || '',
845
+ inode: element.dataset['dotInode'] || '',
846
+ contentType: element.dataset['dotType'] || '',
847
+ title: element.dataset['dotTitle'] || '',
848
+ baseType: element.dataset['dotBasetype'] || ''
849
+ };
850
+ }
851
+ /**
852
+ * Initial scan delay for DOM readiness
853
+ * Allows React/Next.js to finish rendering before scanning for contentlets
854
+ */
855
+ const INITIAL_SCAN_DELAY_MS = 100;
856
+ /**
857
+ * Finds all contentlet elements in the DOM
858
+ * @returns Array of contentlet HTMLElements
859
+ */
860
+ const findContentlets = () => {
861
+ return Array.from(document.querySelectorAll(`.${CONTENTLET_CLASS}`));
862
+ };
863
+ /**
864
+ * Creates a MutationObserver that watches for contentlet changes in the DOM
865
+ * @param callback - Function to call when mutations are detected
866
+ * @param debounceMs - Debounce time in milliseconds (default: 250ms)
867
+ * @param options - `identifiers`: also call back when a contentlet receives its
868
+ * `data-dot-identifier`. The renderers print it after hydration, usually within
869
+ * milliseconds of the contentlet itself, so calls inside the window are not dropped: the
870
+ * last one runs when it ends.
871
+ * @returns Configured and active MutationObserver
872
+ */
873
+ const createContentletObserver = (callback, debounceMs = CONTENTLET_OBSERVER_DEBOUNCE_MS, options = {}) => {
874
+ const watchIdentifiers = options.identifiers === true;
875
+ const throttledCallback = createThrottle(callback, debounceMs, { trailing: watchIdentifiers });
876
+ const isContentlet = (node) => node.nodeType === Node.ELEMENT_NODE &&
877
+ node.classList?.contains(CONTENTLET_CLASS) === true;
878
+ const observer = new MutationObserver((mutations) => {
879
+ // This reduces observer callback executions by ~90% on dynamic sites
880
+ const hasRelevantChanges = mutations.some((mutation) => {
881
+ // Only the identifier attribute is observed, so any attribute change counts
882
+ // when it lands on a contentlet
883
+ if (mutation.type === 'attributes') {
884
+ return isContentlet(mutation.target);
885
+ }
886
+ // Skip if no nodes were added or removed
887
+ if (mutation.addedNodes.length === 0 && mutation.removedNodes.length === 0) {
888
+ return false;
889
+ }
890
+ // Check if any added/removed nodes are or contain contentlets
891
+ const nodes = [
892
+ ...Array.from(mutation.addedNodes),
893
+ ...Array.from(mutation.removedNodes)
894
+ ];
895
+ return nodes.some((node) => {
896
+ // Only check element nodes
897
+ if (node.nodeType !== Node.ELEMENT_NODE) {
898
+ return false;
899
+ }
900
+ const element = node;
901
+ // Check if node itself is a contentlet
902
+ if (element.classList?.contains(CONTENTLET_CLASS)) {
903
+ return true;
904
+ }
905
+ // Check if node contains contentlets
906
+ return element.querySelector?.(`.${CONTENTLET_CLASS}`) !== null;
907
+ });
908
+ });
909
+ // Only invoke callback if relevant changes detected
910
+ if (hasRelevantChanges) {
911
+ throttledCallback();
912
+ }
913
+ });
914
+ const observeOptions = {
915
+ childList: true,
916
+ subtree: true,
917
+ characterData: false,
918
+ ...(watchIdentifiers
919
+ ? { attributes: true, attributeFilter: [CONTENTLET_IDENTIFIER_ATTRIBUTE] }
920
+ : { attributes: false })
921
+ };
922
+ // Handle case where document.body might not be available yet if script is in <head>
923
+ const target = document.body || document.documentElement;
924
+ if (target) {
925
+ observer.observe(target, observeOptions);
926
+ }
927
+ else {
928
+ // Fallback: wait for DOMContentLoaded if everything is null (rare)
929
+ window.addEventListener('DOMContentLoaded', () => {
930
+ if (document.body) {
931
+ observer.observe(document.body, observeOptions);
932
+ }
933
+ });
934
+ }
935
+ return observer;
936
+ };
937
+ /**
938
+ * Cleans up a content tracker when the browser discards the page. A page kept in the
939
+ * back/forward cache keeps its tracker, which works again when the visitor goes back to it.
940
+ * @param cleanup - Function to call when the page is discarded
941
+ */
942
+ const setupPluginCleanup = (cleanup) => {
943
+ if (!isBrowser$1())
944
+ return;
945
+ onPageDiscard(cleanup);
946
+ };
947
+ /**
948
+ * Gets enhanced tracking plugins based on configuration
949
+ * Returns content impression and click tracking plugins if enabled
950
+ * @param config - Analytics configuration
951
+ * @param impressionPlugin - Impression tracking plugin factory
952
+ * @param clickPlugin - Click tracking plugin factory
953
+ * @returns Array of enabled tracking plugins
954
+ */
955
+ const getEnhancedTrackingPlugins = (config, impressionPlugin, clickPlugin) => {
956
+ return [
957
+ config.impressions && impressionPlugin(config),
958
+ config.clicks && clickPlugin(config)
959
+ ].filter(Boolean);
960
+ };
961
+
962
+ /**
963
+ * Where a contentlet sits in the viewport, which both content trackers measure.
964
+ * Pure functions without class dependencies for better testability and reusability
965
+ */
966
+ /**
967
+ * Calculates the visibility ratio of an element in the viewport
968
+ * @param element - The HTML element to check
969
+ * @returns A number between 0 and 1 representing the visible percentage
970
+ */
971
+ function calculateElementVisibilityRatio(element) {
972
+ const rect = element.getBoundingClientRect();
973
+ const viewHeight = window.innerHeight || document.documentElement.clientHeight;
974
+ const viewWidth = window.innerWidth || document.documentElement.clientWidth;
975
+ const visibleHeight = Math.min(rect.bottom, viewHeight) - Math.max(rect.top, 0);
976
+ const visibleWidth = Math.min(rect.right, viewWidth) - Math.max(rect.left, 0);
977
+ if (visibleHeight <= 0 || visibleWidth <= 0) {
978
+ return 0;
979
+ }
980
+ const visibleArea = visibleHeight * visibleWidth;
981
+ const totalArea = rect.height * rect.width;
982
+ return totalArea > 0 ? visibleArea / totalArea : 0;
983
+ }
984
+ /**
985
+ * Calculates the offset percentage of an element from the top of the viewport
986
+ * @param element - The HTML element to check
987
+ * @returns Percentage value (can be negative if above viewport)
988
+ */
989
+ function calculateViewportOffset(element) {
990
+ const rect = element.getBoundingClientRect();
991
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
992
+ const offsetPercentage = (rect.top / viewportHeight) * 100;
993
+ return Math.round(offsetPercentage * 100) / 100;
994
+ }
995
+ /**
996
+ * Gets comprehensive viewport metrics for an element
997
+ * @param element - The HTML element to analyze
998
+ * @returns Object containing offset percentage and visibility ratio
999
+ */
1000
+ function getViewportMetrics(element) {
1001
+ const visibilityRatio = calculateElementVisibilityRatio(element);
1002
+ const offsetPercentage = calculateViewportOffset(element);
1003
+ return {
1004
+ offsetPercentage,
1005
+ visibilityRatio
1006
+ };
1007
+ }
1008
+
1009
+ /**
1010
+ * Handles click events on elements within a contentlet.
1011
+ * The tracker passes the contentlet the click landed in: the innermost one around the clicked
1012
+ * link or button.
1013
+ *
1014
+ * @param event - The mouse event
1015
+ * @param contentletElement - The contentlet container element
1016
+ * @param trackCallback - Callback to execute if the click is valid
1017
+ * @param logger - Logger instance for debug messages
1018
+ */
1019
+ const handleContentletClick = (event, contentletElement, trackCallback, logger) => {
1020
+ const target = event.target;
1021
+ logger.debug('Click detected on:', target);
1022
+ // 1. Find clickable element (a or button) within the event path
1023
+ const clickableElement = target.closest(CLICKABLE_ELEMENTS_SELECTOR);
1024
+ if (!clickableElement) {
1025
+ logger.debug('No <a> or <button> found in click path');
1026
+ return;
1027
+ }
1028
+ // 2. Verify clickable is actually inside this contentlet (safety check)
1029
+ if (!contentletElement.contains(clickableElement)) {
1030
+ logger.debug('Click was outside contentlet boundary');
1031
+ return;
1032
+ }
1033
+ logger.debug('Found clickable element:', clickableElement);
1034
+ // 3. Extract and validate contentlet data
1035
+ const contentletData = extractContentletData(contentletElement);
1036
+ if (!contentletData.identifier) {
1037
+ logger.debug('Contentlet has no identifier');
1038
+ return;
1039
+ }
1040
+ logger.debug('Contentlet data:', contentletData);
1041
+ // 4. Build payload with metadata
1042
+ const viewportMetrics = getViewportMetrics(contentletElement);
1043
+ // Extract attributes from the clickable element
1044
+ // Filter out: analytics attributes, and already-captured properties (class, id, href)
1045
+ // Format as 'key:value' strings (e.g., ['data-category:val', 'data-campaign:val2'])
1046
+ const attributes = [];
1047
+ for (const attr of clickableElement.attributes) {
1048
+ if (!attr.name.startsWith('data-dot-analytics') &&
1049
+ attr.name !== 'class' &&
1050
+ attr.name !== 'id' &&
1051
+ attr.name !== 'href') {
1052
+ attributes.push(`${attr.name}:${attr.value}`);
1053
+ }
1054
+ }
1055
+ // The contentlet's position among the page's contentlets, read when it is clicked: one
1056
+ // query per click, and the position stays right as contentlets are added or moved
1057
+ const domIndex = findContentlets().indexOf(contentletElement);
1058
+ const payload = {
1059
+ content: {
1060
+ identifier: contentletData.identifier,
1061
+ inode: contentletData.inode,
1062
+ title: contentletData.title,
1063
+ content_type: contentletData.contentType
1064
+ },
1065
+ position: {
1066
+ viewport_offset_pct: viewportMetrics.offsetPercentage,
1067
+ dom_index: domIndex
1068
+ },
1069
+ element: {
1070
+ text: (clickableElement.innerText || clickableElement.textContent || '')
1071
+ .trim()
1072
+ .substring(0, 100), // Limit length
1073
+ type: clickableElement.tagName.toLowerCase(),
1074
+ id: clickableElement.id || '', // Required by backend, empty string if not present
1075
+ class: clickableElement.className || '', // Required by backend, empty string if not present
1076
+ href: clickableElement.getAttribute('href') || '', // Path as written in HTML (relative), empty string for buttons
1077
+ attributes: attributes // Additional attributes (data-*, aria-*, target, etc.)
1078
+ }
1079
+ };
1080
+ trackCallback(CLICK_EVENT_TYPE, payload);
1081
+ };
1082
+
1083
+ var _DotCMSClickTracker_lastClickAt, _DotCMSClickTracker_logger, _DotCMSClickTracker_subscribers, _DotCMSClickTracker_listening, _DotCMSClickTracker_handleClick;
1084
+ /**
1085
+ * Tracks clicks on the links and buttons inside contentlets, through one click listener on the
1086
+ * document, in the capture phase. A click finds its contentlet with `closest`, so:
1087
+ * - nothing scans or observes the page, and a contentlet added at any time counts;
1088
+ * - an app's `stopPropagation()` cannot hide a click from the tracker;
1089
+ * - a click inside nested contentlets counts once, for the innermost;
1090
+ * - clicks on the same contentlet within 300 ms count once.
1091
+ *
1092
+ * @example
1093
+ * ```typescript
1094
+ * const tracker = new DotCMSClickTracker(config);
1095
+ * const subscription = tracker.onClick((eventName, payload) => {
1096
+ * console.log('Click detected:', payload);
1097
+ * });
1098
+ * tracker.initialize();
1099
+ * // Later: subscription.unsubscribe();
1100
+ * ```
1101
+ */
1102
+ class DotCMSClickTracker {
1103
+ constructor(config) {
1104
+ // When each contentlet was last clicked: a click on one never throttles another
1105
+ _DotCMSClickTracker_lastClickAt.set(this, new WeakMap());
1106
+ _DotCMSClickTracker_logger.set(this, void 0);
1107
+ _DotCMSClickTracker_subscribers.set(this, new Set());
1108
+ _DotCMSClickTracker_listening.set(this, false);
1109
+ /** Reports a click on a link or button inside a contentlet, throttled per contentlet */
1110
+ _DotCMSClickTracker_handleClick.set(this, (event) => {
1111
+ const target = event.target;
1112
+ if (!(target instanceof Element)) {
1113
+ return;
1114
+ }
1115
+ const contentlet = target
1116
+ .closest(CLICKABLE_ELEMENTS_SELECTOR)
1117
+ ?.closest(`.${CONTENTLET_CLASS}`);
1118
+ if (!contentlet) {
1119
+ return;
1120
+ }
1121
+ handleContentletClick(event, contentlet, (eventName, payload) => {
1122
+ const now = Date.now();
1123
+ if (now - (__classPrivateFieldGet(this, _DotCMSClickTracker_lastClickAt, "f").get(contentlet) ?? 0) < DEFAULT_CLICK_THROTTLE_MS) {
1124
+ return;
1125
+ }
1126
+ __classPrivateFieldGet(this, _DotCMSClickTracker_lastClickAt, "f").set(contentlet, now);
1127
+ __classPrivateFieldGet(this, _DotCMSClickTracker_subscribers, "f").forEach((callback) => callback(eventName, payload));
1128
+ __classPrivateFieldGet(this, _DotCMSClickTracker_logger, "f").info(`Fired click event for ${payload.content.identifier}`, payload);
1129
+ }, __classPrivateFieldGet(this, _DotCMSClickTracker_logger, "f"));
1130
+ });
1131
+ __classPrivateFieldSet(this, _DotCMSClickTracker_logger, createPluginLogger('Click', config), "f");
1132
+ }
1133
+ /**
1134
+ * Subscribe to click events
1135
+ * @param callback - Function called when click is detected
1136
+ * @returns Subscription object with unsubscribe method
1137
+ */
1138
+ onClick(callback) {
1139
+ __classPrivateFieldGet(this, _DotCMSClickTracker_subscribers, "f").add(callback);
1140
+ return {
1141
+ unsubscribe: () => {
1142
+ __classPrivateFieldGet(this, _DotCMSClickTracker_subscribers, "f").delete(callback);
1143
+ }
1144
+ };
1145
+ }
1146
+ /** Starts listening for clicks; without a document, as on the server, it does nothing */
1147
+ initialize() {
1148
+ if (!isBrowser$1()) {
1149
+ __classPrivateFieldGet(this, _DotCMSClickTracker_logger, "f").warn('No document, skipping');
1150
+ return;
1151
+ }
1152
+ if (__classPrivateFieldGet(this, _DotCMSClickTracker_listening, "f")) {
1153
+ return;
1154
+ }
1155
+ document.addEventListener('click', __classPrivateFieldGet(this, _DotCMSClickTracker_handleClick, "f"), true);
1156
+ __classPrivateFieldSet(this, _DotCMSClickTracker_listening, true, "f");
1157
+ __classPrivateFieldGet(this, _DotCMSClickTracker_logger, "f").info('Plugin initialized');
1158
+ }
1159
+ /**
1160
+ * Stops listening for clicks. Should be called when the plugin is disabled or the page is
1161
+ * discarded.
1162
+ */
1163
+ cleanup() {
1164
+ if (__classPrivateFieldGet(this, _DotCMSClickTracker_listening, "f")) {
1165
+ document.removeEventListener('click', __classPrivateFieldGet(this, _DotCMSClickTracker_handleClick, "f"), true);
1166
+ __classPrivateFieldSet(this, _DotCMSClickTracker_listening, false, "f");
1167
+ }
1168
+ __classPrivateFieldGet(this, _DotCMSClickTracker_logger, "f").info('Click tracking cleaned up');
1169
+ }
1170
+ }
1171
+ _DotCMSClickTracker_lastClickAt = new WeakMap(), _DotCMSClickTracker_logger = new WeakMap(), _DotCMSClickTracker_subscribers = new WeakMap(), _DotCMSClickTracker_listening = new WeakMap(), _DotCMSClickTracker_handleClick = new WeakMap();
1172
+
1173
+ /**
1174
+ * Clicks plugin of the events pipeline.
1175
+ * Handles automatic tracking of clicks on content elements.
1176
+ *
1177
+ * This plugin initializes the click tracker which:
1178
+ * - Listens for clicks once, on the document, in the capture phase
1179
+ * - Reports clicks on <a> or <button> elements inside a contentlet, the innermost one
1180
+ * - Extracts contentlet data, its position when clicked, and element metadata
1181
+ * - Throttles clicks per contentlet to prevent duplicates
1182
+ * - Fires 'content_click' events via subscription callback
1183
+ *
1184
+ * Note: This plugin is only registered if config.clicks is enabled.
1185
+ * See getEnhancedTrackingPlugins() for conditional loading logic.
1186
+ *
1187
+ * @param {PipelineConfig} config - Configuration with clicks settings
1188
+ * @returns {Object} Plugin object with lifecycle methods
1189
+ */
1190
+ const clicksPlugin = (config) => {
1191
+ let clickTracker = null;
1192
+ let subscription = null;
1193
+ const logger = createPluginLogger('Click', config);
1194
+ return {
1195
+ name: 'dot-events-clicks',
1196
+ /**
1197
+ * Initialize click tracking
1198
+ * Called when Analytics.js initializes the plugin with instance context
1199
+ * @param instance - Analytics.js instance with track method
1200
+ */
1201
+ initialize: ({ instance }) => {
1202
+ // Create and initialize tracker
1203
+ clickTracker = new DotCMSClickTracker(config);
1204
+ // Subscribe to click events
1205
+ subscription = clickTracker.onClick((eventName, payload) => {
1206
+ instance.track(eventName, payload);
1207
+ });
1208
+ // Start tracking
1209
+ clickTracker.initialize();
1210
+ logger.info('Click tracking plugin initialized');
1211
+ return Promise.resolve();
1212
+ },
1213
+ /**
1214
+ * Setup cleanup handlers when plugin is loaded
1215
+ * Called after Analytics.js completes plugin loading
1216
+ */
1217
+ loaded: () => {
1218
+ if (isBrowser$1() && clickTracker) {
1219
+ const cleanup = () => {
1220
+ if (subscription) {
1221
+ subscription.unsubscribe();
1222
+ subscription = null;
1223
+ }
1224
+ if (clickTracker) {
1225
+ clickTracker.cleanup();
1226
+ clickTracker = null;
1227
+ logger.info('Click tracking cleaned up on page unload');
1228
+ }
1229
+ };
1230
+ // Cleanup on page unload
1231
+ setupPluginCleanup(cleanup);
1232
+ }
1233
+ return true;
1234
+ }
1235
+ };
1236
+ };
1237
+
1238
+ const MARK_SELECTOR = `[${EXPERIMENT_ATTRIBUTE}]`;
1239
+ const revealedRules = new Set();
1240
+ // Where the browser has adopted style sheets, reveals go to one: a change through the CSSOM,
1241
+ // which no Content-Security-Policy blocks, unlike a style element the script creates.
1242
+ let revealSheet;
1243
+ /**
1244
+ * Tells whether an element is on screen as far as React is concerned: not inside a route that
1245
+ * Activity keeps in the page with `display: none`, nor inside the hidden container where a
1246
+ * Suspense boundary's content waits while it streams in.
1247
+ *
1248
+ * @param element - The element
1249
+ * @returns False when an ancestor keeps it out of view
1250
+ */
1251
+ const isRendered = (element) => {
1252
+ for (let node = element; node; node = node.parentElement) {
1253
+ if (node.hidden || node.style.display === 'none') {
1254
+ return false;
1255
+ }
1256
+ }
1257
+ return true;
1258
+ };
1259
+ /**
1260
+ * Reads the experiments the current page renders, from their marks: each experiment and
1261
+ * rendered variant once, in document order.
1262
+ *
1263
+ * @returns The experiments and the variants the server rendered
1264
+ */
1265
+ const readPageExperiments = () => {
1266
+ const marks = new Map();
1267
+ document.querySelectorAll(MARK_SELECTOR).forEach((element) => {
1268
+ const experimentId = element.getAttribute(EXPERIMENT_ATTRIBUTE);
1269
+ if (!experimentId || !isRendered(element)) {
1270
+ return;
1271
+ }
1272
+ const variant = element.getAttribute(VARIANT_ATTRIBUTE) || DEFAULT_VARIANT;
1273
+ marks.set(`${experimentId}\n${variant}`, { experimentId, variant });
1274
+ });
1275
+ return Array.from(marks.values());
1276
+ };
1277
+ /**
1278
+ * Calls back when marks are inserted or rewritten, so the engine decides content that arrives
1279
+ * after its first decision: Suspense content that streams in, or a route that changes only its
1280
+ * query and keeps its elements.
1281
+ *
1282
+ * @param changed - Called once per batch of DOM changes that touches a mark
1283
+ * @returns Stops observing
1284
+ */
1285
+ const observeMarks = (changed) => {
1286
+ const touchesMark = (record) => record.type === 'attributes' ||
1287
+ Array.from(record.addedNodes).some((node) => node instanceof Element &&
1288
+ (node.matches(MARK_SELECTOR) || !!node.querySelector(MARK_SELECTOR)));
1289
+ const observer = new MutationObserver((records) => {
1290
+ if (records.some(touchesMark)) {
1291
+ changed();
1292
+ }
1293
+ });
1294
+ observer.observe(document.documentElement, {
1295
+ childList: true,
1296
+ subtree: true,
1297
+ attributeFilter: [EXPERIMENT_ATTRIBUTE, VARIANT_ATTRIBUTE]
1298
+ });
1299
+ return () => observer.disconnect();
1300
+ };
1301
+ /**
1302
+ * Adds a reveal rule: to the adopted style sheet where the browser has them, or else to a
1303
+ * style element in the head.
1304
+ *
1305
+ * @param rule - The rule
1306
+ */
1307
+ const addRevealRule = (rule) => {
1308
+ if ('adoptedStyleSheets' in document) {
1309
+ try {
1310
+ revealSheet ?? (revealSheet = new CSSStyleSheet());
1311
+ if (!document.adoptedStyleSheets.includes(revealSheet)) {
1312
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, revealSheet];
1313
+ }
1314
+ revealSheet.insertRule(rule, revealSheet.cssRules.length);
1315
+ return;
1316
+ }
1317
+ catch {
1318
+ // A style element instead
1319
+ }
1320
+ }
1321
+ let style = document.getElementById(REVEAL_STYLE_ID);
1322
+ if (!style) {
1323
+ style = document.createElement('style');
1324
+ style.id = REVEAL_STYLE_ID;
1325
+ document.head.appendChild(style);
1326
+ }
1327
+ style.appendChild(document.createTextNode(rule));
1328
+ };
1329
+ /**
1330
+ * Shows rows the renderer hid. With an id and a variant, only that experiment's rows rendered
1331
+ * with that variant, so a route kept in the page with another render stays hidden; without an
1332
+ * id, all of them (engine off, inside UVE).
1333
+ *
1334
+ * The rule beats the renderer's hiding rule through `:root` and `!important`, whatever the
1335
+ * order of the two in the document.
1336
+ *
1337
+ * @param experimentId - The experiment whose rows to show
1338
+ * @param variant - The variant they were rendered with
1339
+ */
1340
+ const revealRows = (experimentId, variant) => {
1341
+ if (typeof document === 'undefined') {
1342
+ return;
1343
+ }
1344
+ const selector = experimentId
1345
+ ? `[${EXPERIMENT_ATTRIBUTE}="${CSS.escape(experimentId)}"]` +
1346
+ (variant ? `[${VARIANT_ATTRIBUTE}="${CSS.escape(variant)}"]` : '')
1347
+ : MARK_SELECTOR;
1348
+ const rule = `:root ${selector}{visibility:visible !important;animation:none !important}`;
1349
+ if (revealedRules.has(rule)) {
1350
+ return;
1351
+ }
1352
+ revealedRules.add(rule);
1353
+ addRevealRule(rule);
1354
+ // The rows show through a style rule, which no tracker's DOM observer notices, and the
1355
+ // impression tracker skipped them while they were hidden.
1356
+ window.dispatchEvent(new CustomEvent(CONTENTLET_RESCAN_EVENT));
1357
+ };
1358
+ /** The style element that hides the contentlet wrappers while a new visitor is decided. */
1359
+ const CONTENTLET_HIDING_STYLE_ID = 'dotcms-experiment-contentlets';
1360
+ /**
1361
+ * Hides the contentlet wrappers dotCMS prints on the pages it renders: they hold what a variant
1362
+ * changes. Like the markup's rule, it shows them on its own after `DECISION_TIMEOUT_MS`.
1363
+ */
1364
+ const hideContentlets = () => {
1365
+ if (document.getElementById(CONTENTLET_HIDING_STYLE_ID)) {
1366
+ return;
1367
+ }
1368
+ const style = document.createElement('style');
1369
+ style.id = CONTENTLET_HIDING_STYLE_ID;
1370
+ style.textContent =
1371
+ `.${CONTENTLET_CLASS}{visibility:hidden;animation:dotcms-experiment-timeout 0s linear ${DECISION_TIMEOUT_MS / 1000}s forwards}` +
1372
+ '@keyframes dotcms-experiment-timeout{to{visibility:visible}}';
1373
+ document.head.appendChild(style);
1374
+ };
1375
+ /**
1376
+ * Shows the contentlet wrappers `hideContentlets` hid, and asks the impression tracker to scan
1377
+ * again for them.
1378
+ */
1379
+ const revealContentlets = () => {
1380
+ const style = document.getElementById(CONTENTLET_HIDING_STYLE_ID);
1381
+ if (!style) {
1382
+ return;
1383
+ }
1384
+ style.remove();
1385
+ window.dispatchEvent(new CustomEvent(CONTENTLET_RESCAN_EVENT));
1386
+ };
1387
+ /**
1388
+ * The variant a page dotCMS rendered shows: the one its contentlet wrappers carry in
1389
+ * `data-dot-variant`, or, when the variant changes none of them (or the wrappers are not
1390
+ * parsed yet), the one its URL asks for. dotCMS renders the variant `variantName` names.
1391
+ *
1392
+ * @param href - The page's URL
1393
+ * @returns The rendered variant
1394
+ */
1395
+ const readRenderedVariant = (href) => {
1396
+ const wrapped = Array.from(document.querySelectorAll(`.${CONTENTLET_CLASS}[${VARIANT_ATTRIBUTE}]`))
1397
+ .map((wrapper) => wrapper.getAttribute(VARIANT_ATTRIBUTE))
1398
+ .find((variant) => !!variant && variant !== DEFAULT_VARIANT);
1399
+ return wrapped ?? new URL(href).searchParams.get(VARIANT_QUERY_PARAM) ?? DEFAULT_VARIANT;
1400
+ };
1401
+ /**
1402
+ * Holds the page's new `fetch` calls, except `keepalive` ones (an analytics flush), for
1403
+ * `QUIET_MS`. A page being replaced runs until the variant's document arrives, and a hydrated
1404
+ * app goes on prefetching its links; held, they do not compete with the variant's server render.
1405
+ * A held call never settles, and after `QUIET_MS` only new calls go out.
1406
+ */
1407
+ const holdRequests = () => {
1408
+ const send = window.fetch;
1409
+ window.fetch = (input, init) => init?.keepalive ? send.call(window, input, init) : new Promise(() => undefined);
1410
+ setTimeout(() => {
1411
+ window.fetch = send;
1412
+ }, QUIET_MS);
1413
+ };
1414
+ /**
1415
+ * Replaces the page with a variant's URL: stops its loading, navigates, and holds its new
1416
+ * requests, as the boot script does when it decides from storage.
1417
+ *
1418
+ * @param url - The variant's URL
1419
+ */
1420
+ const leavePageFor = (url) => {
1421
+ window.stop();
1422
+ window.location.replace(url);
1423
+ holdRequests();
1424
+ };
1425
+
1426
+ const asStringArray = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
1427
+ const toEntity = (raw) => {
1428
+ const entity = (raw ?? {});
1429
+ return {
1430
+ experiments: Array.isArray(entity['experiments'])
1431
+ ? entity['experiments']
1432
+ : [],
1433
+ includedExperimentIds: asStringArray(entity['includedExperimentIds']),
1434
+ excludedExperimentIds: asStringArray(entity['excludedExperimentIds']),
1435
+ excludedExperimentIdsEnded: asStringArray(entity['excludedExperimentIdsEnded'])
1436
+ };
1437
+ };
1438
+ /**
1439
+ * Asks dotCMS which running experiments the visitor is in.
1440
+ *
1441
+ * @param dotcmsUrl - The dotCMS origin, without a trailing slash
1442
+ * @param exclude - Experiments already evaluated, so the server skips them
1443
+ * @returns `ok` with the entity, `disabled` on a 403 (Experiments off), or `failed`
1444
+ */
1445
+ const fetchIsUserIncluded = async (dotcmsUrl, exclude) => {
1446
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
1447
+ const timer = setTimeout(() => controller?.abort(), IS_USER_INCLUDED_REQUEST_TIMEOUT_MS);
1448
+ try {
1449
+ const response = await fetch(`${dotcmsUrl}${IS_USER_INCLUDED_PATH}`, {
1450
+ method: 'POST',
1451
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
1452
+ body: JSON.stringify({ exclude }),
1453
+ signal: controller?.signal ?? null
1454
+ });
1455
+ if (response.status === 403) {
1456
+ return { status: 'disabled' };
1457
+ }
1458
+ if (!response.ok) {
1459
+ return { status: 'failed', httpStatus: response.status };
1460
+ }
1461
+ const json = (await response.json());
1462
+ return { status: 'ok', entity: toEntity(json?.entity) };
1463
+ }
1464
+ catch {
1465
+ return { status: 'failed' };
1466
+ }
1467
+ finally {
1468
+ clearTimeout(timer);
1469
+ }
1470
+ };
1471
+
1472
+ const storageOf = (kind) => {
1473
+ try {
1474
+ return kind === 'local' ? window.localStorage : window.sessionStorage;
1475
+ }
1476
+ catch {
1477
+ return null;
1478
+ }
1479
+ };
1480
+ const read = (kind, key) => {
1481
+ try {
1482
+ const raw = storageOf(kind)?.getItem(key);
1483
+ return raw ? JSON.parse(raw) : null;
1484
+ }
1485
+ catch {
1486
+ return null;
1487
+ }
1488
+ };
1489
+ const write = (kind, key, value) => {
1490
+ try {
1491
+ storageOf(kind)?.setItem(key, JSON.stringify(value));
1492
+ }
1493
+ catch {
1494
+ // Storage can be full or blocked; the engine keeps working in memory.
1495
+ }
1496
+ };
1497
+ /**
1498
+ * Loads the stored assignments, without the ones whose look-back window has passed.
1499
+ *
1500
+ * @returns The assignments, or null when there are none
1501
+ */
1502
+ const loadAssignments = () => {
1503
+ const stored = read('local', STORAGE_KEYS.assignments);
1504
+ if (!stored || !Array.isArray(stored.experiments)) {
1505
+ return null;
1506
+ }
1507
+ const now = Date.now();
1508
+ return {
1509
+ fetchedAt: stored.fetchedAt ?? 0,
1510
+ experiments: stored.experiments.filter((experiment) => experiment.expiresAt > now),
1511
+ evaluatedIds: Array.isArray(stored.evaluatedIds) ? stored.evaluatedIds : []
1512
+ };
1513
+ };
1514
+ /**
1515
+ * Merges an `isUserIncluded` answer into the stored assignments and saves them.
1516
+ * Ended experiments drop out; every evaluated experiment is remembered, so a visitor
1517
+ * left out by traffic allocation is not evaluated again.
1518
+ *
1519
+ * @param entity - The response entity
1520
+ * @param previous - What was stored before the request
1521
+ * @returns The assignments now stored
1522
+ */
1523
+ const saveAssignments = (entity, previous) => {
1524
+ const now = Date.now();
1525
+ const ended = new Set(entity.excludedExperimentIdsEnded);
1526
+ const byId = new Map((previous?.experiments ?? [])
1527
+ .filter((experiment) => !ended.has(experiment.id) && experiment.expiresAt > now)
1528
+ .map((experiment) => [experiment.id, experiment]));
1529
+ entity.experiments.forEach((experiment) => {
1530
+ byId.set(experiment.id, {
1531
+ ...experiment,
1532
+ expiresAt: now + (experiment.lookBackWindow?.expireMillis || ONE_DAY_MS)
1533
+ });
1534
+ });
1535
+ const evaluated = new Set([
1536
+ ...(previous?.evaluatedIds ?? []),
1537
+ ...entity.includedExperimentIds,
1538
+ ...entity.excludedExperimentIds,
1539
+ ...entity.experiments.map((experiment) => experiment.id)
1540
+ ]);
1541
+ ended.forEach((id) => evaluated.delete(id));
1542
+ const next = {
1543
+ fetchedAt: now,
1544
+ experiments: [...byId.values()],
1545
+ evaluatedIds: [...evaluated]
1546
+ };
1547
+ write('local', STORAGE_KEYS.assignments, next);
1548
+ return next;
1549
+ };
1550
+ /**
1551
+ * Tells whether this tab should ask `isUserIncluded`: once per tab session, when nothing
1552
+ * is stored, or when the stored answer is older than a day.
1553
+ *
1554
+ * @param stored - The current assignments
1555
+ * @returns True when a request is due
1556
+ */
1557
+ const isCheckDue = (stored) => {
1558
+ const checkedThisTab = read('session', STORAGE_KEYS.checkedThisTab) === true;
1559
+ return !checkedThisTab || !stored || Date.now() - stored.fetchedAt > ONE_DAY_MS;
1560
+ };
1561
+ /** Records that this tab asked `isUserIncluded`. */
1562
+ const markCheckedThisTab = () => write('session', STORAGE_KEYS.checkedThisTab, true);
1563
+ /** Tells whether the site answered 403 in the last day. */
1564
+ const isDisabledForNow = () => (read('local', STORAGE_KEYS.disabledUntil) ?? 0) > Date.now();
1565
+ /** Remembers a 403 for a day, so sites without Experiments stop paying for the call. */
1566
+ const disableForADay = () => write('local', STORAGE_KEYS.disabledUntil, Date.now() + ONE_DAY_MS);
1567
+ /**
1568
+ * Returns the experiments the visitor reached in this analytics session. A new session
1569
+ * starts empty.
1570
+ *
1571
+ * @param sessionId - The analytics session id
1572
+ * @returns The cumulative `context.experiments`
1573
+ */
1574
+ const getSessionExperiments = (sessionId) => {
1575
+ const stored = read('session', STORAGE_KEYS.sessionExperiments);
1576
+ return stored && stored.sessionId === sessionId ? stored.experiments : [];
1577
+ };
1578
+ /**
1579
+ * Adds an experiment to the session's `context.experiments`, replacing an older entry
1580
+ * for the same experiment.
1581
+ *
1582
+ * @param sessionId - The analytics session id
1583
+ * @param entry - The experiment the visitor reached
1584
+ */
1585
+ const joinSessionExperiment = (sessionId, entry) => {
1586
+ const others = getSessionExperiments(sessionId).filter((experiment) => experiment.id !== entry.id);
1587
+ write('session', STORAGE_KEYS.sessionExperiments, {
1588
+ sessionId,
1589
+ experiments: [...others, entry]
1590
+ });
1591
+ };
1592
+ /**
1593
+ * Removes ended experiments from the session's `context.experiments`.
1594
+ *
1595
+ * @param endedIds - Ids `isUserIncluded` reported as ended
1596
+ */
1597
+ const leaveEndedExperiments = (endedIds) => {
1598
+ if (endedIds.length === 0) {
1599
+ return;
1600
+ }
1601
+ const stored = read('session', STORAGE_KEYS.sessionExperiments);
1602
+ if (!stored) {
1603
+ return;
1604
+ }
1605
+ const ended = new Set(endedIds);
1606
+ write('session', STORAGE_KEYS.sessionExperiments, {
1607
+ sessionId: stored.sessionId,
1608
+ experiments: stored.experiments.filter((experiment) => !ended.has(experiment.id))
1609
+ });
1610
+ };
1611
+
1612
+ /**
1613
+ * Calls back on every navigation inside the page, an SPA route change, that changes the path
1614
+ * or the query. It listens to the browser's Navigation API, which reports every History change,
1615
+ * back and forward included, without patching `history`. Where the browser has none, it falls
1616
+ * back to `@analytics/router-utils`, which reports path changes only.
1617
+ *
1618
+ * @param navigated - Called once the URL has changed
1619
+ * @returns Stops the calls. router-utils has no way to detach, so in the fallback its listener
1620
+ * stays and only stops calling back
1621
+ */
1622
+ const onNavigation = (navigated) => {
1623
+ const navigation = window.navigation;
1624
+ if (!navigation) {
1625
+ let active = true;
1626
+ onRouteChange(() => {
1627
+ if (active) {
1628
+ navigated();
1629
+ }
1630
+ });
1631
+ return () => {
1632
+ active = false;
1633
+ };
1634
+ }
1635
+ const pageOf = () => `${window.location.pathname}${window.location.search}`;
1636
+ let current = pageOf();
1637
+ const listener = () => {
1638
+ const next = pageOf();
1639
+ // Routers also save their own state on the URL they are on
1640
+ if (next !== current) {
1641
+ current = next;
1642
+ navigated();
1643
+ }
1644
+ };
1645
+ navigation.addEventListener('currententrychange', listener);
1646
+ return () => navigation.removeEventListener('currententrychange', listener);
1647
+ };
1648
+ /**
1649
+ * Calls back each time the browser restores the page from its back/forward cache (`pageshow`
1650
+ * with `persisted`). The page comes back as the visitor left it, its scripts still running, so
1651
+ * nothing that runs on a load runs again, and onNavigation reports nothing: the visitor is
1652
+ * back on the same page, not on another route.
1653
+ *
1654
+ * @param restored - Called on each restore
1655
+ * @returns Stops the calls
1656
+ */
1657
+ const onPageRestore = (restored) => {
1658
+ const listener = (event) => {
1659
+ if (event.persisted) {
1660
+ restored();
1661
+ }
1662
+ };
1663
+ window.addEventListener('pageshow', listener);
1664
+ return () => window.removeEventListener('pageshow', listener);
1665
+ };
1666
+
1667
+ /**
1668
+ * Tells whether a URL rule from `isUserIncluded` matches a URL.
1669
+ *
1670
+ * @param pattern - The rule, a regular expression in a string
1671
+ * @param href - The URL
1672
+ * @returns False when the rule is missing or is not a valid expression
1673
+ */
1674
+ const matchesRule = (pattern, href) => {
1675
+ if (!pattern) {
1676
+ return false;
1677
+ }
1678
+ // dotCMS writes the rules for a lowercase address and its own experiments script tests
1679
+ // them that way: the part before the query in lowercase, the query as it is
1680
+ const query = href.indexOf('?');
1681
+ const url = query < 0 ? href.toLowerCase() : href.slice(0, query).toLowerCase() + href.slice(query);
1682
+ try {
1683
+ return new RegExp(pattern).test(url);
1684
+ }
1685
+ catch {
1686
+ return false;
1687
+ }
1688
+ };
1689
+ /**
1690
+ * The warning for a route that renders another variant than the one its URL asks for.
1691
+ *
1692
+ * @param mark - The experiment and the variant the server rendered
1693
+ * @param variant - The variant the URL asks for
1694
+ * @returns The message
1695
+ */
1696
+ const routeIgnoresVariantMessage = (mark, variant) => `this URL asks for ${variant} of experiment ${mark.experimentId}, but the server rendered ${mark.variant}. ` +
1697
+ 'The route does not pass variantName to its page request, so the page is shown as it is and the visit is not counted in the experiment.';
1698
+ /**
1699
+ * The page a URL shows: its path and query, which the route renders. The hash changes nothing.
1700
+ *
1701
+ * @param href - The URL
1702
+ * @returns The path and the query
1703
+ */
1704
+ const pageOf = (href) => {
1705
+ const url = new URL(href);
1706
+ return url.pathname + url.search;
1707
+ };
1708
+ /**
1709
+ * The URL the page's boot script is replacing the page with, if it is.
1710
+ *
1711
+ * @returns The variant's URL, or undefined when the script did not redirect
1712
+ */
1713
+ const bootRedirect = () => {
1714
+ const state = window[BOOT_STATE_KEY];
1715
+ return state && typeof state.redirectedTo === 'string'
1716
+ ? state.redirectedTo
1717
+ : undefined;
1718
+ };
1719
+ /**
1720
+ * Creates the engine that assigns the visitor, holds and releases pageviews, redirects to
1721
+ * the assigned variant and fills `context.experiments`.
1722
+ *
1723
+ * @param options - dotCMS origin, the assignment timeout and a logger
1724
+ * @returns The engine
1725
+ */
1726
+ const createExperimentsEngine = ({ dotcmsUrl, timeoutMs, log, warn, onError, navigate = leavePageFor, pages = 'markup' }) => {
1727
+ let assignments = loadAssignments();
1728
+ let disabled = isDisabledForNow();
1729
+ let inflight = null;
1730
+ // URLs already warned about, so a page warns once however many pageviews it sends
1731
+ const warnedUrls = new Set();
1732
+ // The variant's URL, when prepare replaced the page
1733
+ let preparedRedirect;
1734
+ // One decision per page visit and rendered experiment, shared by decide and the mark observer
1735
+ const decisions = new Map();
1736
+ let stopObserving;
1737
+ // Navigations inside the page so far. A visit is the page plus this count: a visitor who
1738
+ // leaves and comes back during a wait starts a new visit, decided and counted on its own
1739
+ let navigations = 0;
1740
+ // The visit whose page runs an experiment the visitor is in: only its events carry them
1741
+ let visitInExperiment;
1742
+ // The hiding rule shows the content after DECISION_TIMEOUT_MS on its own: a longer wait
1743
+ // would show the original, then replace it with the variant
1744
+ const waitMs = Math.min(timeoutMs, DECISION_TIMEOUT_MS);
1745
+ if (timeoutMs > DECISION_TIMEOUT_MS) {
1746
+ warn(`experiments.timeout is ${timeoutMs} ms; the page waits ${DECISION_TIMEOUT_MS} ms at most, as long as the experiment's content stays hidden`);
1747
+ }
1748
+ const stopCountingNavigations = pages === 'markup'
1749
+ ? onNavigation(() => {
1750
+ navigations += 1;
1751
+ })
1752
+ : undefined;
1753
+ const findAssignment = (experimentId) => assignments?.experiments.find((experiment) => experiment.id === experimentId);
1754
+ /**
1755
+ * Warns when an experiment the visitor is in runs on the current URL, by its URL rule,
1756
+ * while nothing on the page carries experiment marks: the experiment would never show.
1757
+ */
1758
+ const warnIfUnmarked = () => {
1759
+ const href = window.location.href;
1760
+ const experiment = assignments?.experiments.find((candidate) => matchesRule(candidate.regexs?.isExperimentPage, href));
1761
+ if (!experiment || warnedUrls.has(href)) {
1762
+ return;
1763
+ }
1764
+ warnedUrls.add(href);
1765
+ warn(`experiment "${experiment.name}" (${experiment.id}) runs on this page, but nothing on it carries the experiment's marks. ` +
1766
+ 'Wrap the content the experiment varies in DotCMSExperiment (@dotcms/events/react), or print experimentMarkup (@dotcms/events/markup): ' +
1767
+ 'until then the visitor sees the original and the experiment records nothing.');
1768
+ };
1769
+ const check = (force = false) => {
1770
+ if (disabled) {
1771
+ return Promise.resolve();
1772
+ }
1773
+ if (inflight) {
1774
+ return inflight;
1775
+ }
1776
+ if (!force && !isCheckDue(assignments)) {
1777
+ return Promise.resolve();
1778
+ }
1779
+ log('asking isUserIncluded');
1780
+ inflight = fetchIsUserIncluded(dotcmsUrl, assignments?.evaluatedIds ?? [])
1781
+ .then((result) => {
1782
+ if (result.status === 'disabled') {
1783
+ disabled = true;
1784
+ disableForADay();
1785
+ log('isUserIncluded answered 403: experiments off for a day');
1786
+ onError?.({
1787
+ code: 'EXPERIMENTS',
1788
+ status: 403,
1789
+ message: 'isUserIncluded answered 403: experiments are off for the site, for a day'
1790
+ });
1791
+ return;
1792
+ }
1793
+ if (result.status === 'failed') {
1794
+ log('isUserIncluded failed; keeping the stored assignments');
1795
+ onError?.({
1796
+ code: 'EXPERIMENTS',
1797
+ ...(result.httpStatus !== undefined && { status: result.httpStatus }),
1798
+ message: 'isUserIncluded failed: the stored assignments stay'
1799
+ });
1800
+ return;
1801
+ }
1802
+ assignments = saveAssignments(result.entity, assignments);
1803
+ leaveEndedExperiments(result.entity.excludedExperimentIdsEnded);
1804
+ markCheckedThisTab();
1805
+ log('assignments', assignments.experiments.map((experiment) => `${experiment.id}: ${experiment.variant.name}`));
1806
+ })
1807
+ .finally(() => {
1808
+ inflight = null;
1809
+ });
1810
+ return inflight;
1811
+ };
1812
+ /** The stored experiment whose `isExperimentPage` rule matches the current URL. */
1813
+ const experimentOnThisPage = () => assignments?.experiments.find((experiment) => experiment.expiresAt > Date.now() &&
1814
+ matchesRule(experiment.regexs?.isExperimentPage, window.location.href));
1815
+ const decisionFor = (mark, href = window.location.href) => decideVariant({
1816
+ experimentId: mark.experimentId,
1817
+ rendered: mark.variant,
1818
+ href,
1819
+ stored: assignments,
1820
+ disabled,
1821
+ now: Date.now(),
1822
+ param: VARIANT_QUERY_PARAM,
1823
+ defaultVariant: DEFAULT_VARIANT
1824
+ });
1825
+ const waitForAnswer = () => Promise.race([
1826
+ check(true).then(() => 'answered'),
1827
+ new Promise((resolve) => setTimeout(() => resolve('timeout'), waitMs))
1828
+ ]);
1829
+ /**
1830
+ * The mark of a page dotCMS rendered: its experiment from the URL rule, and the variant its
1831
+ * contentlet wrappers carry. On a first visit nothing stored has the rule yet, so it waits
1832
+ * for `isUserIncluded`, up to the timeout, with the contentlets hidden.
1833
+ *
1834
+ * @returns The mark, or null when no experiment runs on the page for this visitor
1835
+ */
1836
+ const contentletsMark = async () => {
1837
+ if (disabled) {
1838
+ return null;
1839
+ }
1840
+ if (!assignments && (await waitForAnswer()) === 'timeout') {
1841
+ log('the assignment timed out; showing the page without the experiment');
1842
+ return null;
1843
+ }
1844
+ const experiment = experimentOnThisPage();
1845
+ return experiment
1846
+ ? { experimentId: experiment.id, variant: readRenderedVariant(window.location.href) }
1847
+ : null;
1848
+ };
1849
+ /**
1850
+ * Decides one experiment the page renders: shows it, or leaves for the assigned variant.
1851
+ *
1852
+ * @param mark - The experiment and the variant the server rendered
1853
+ * @param href - The page's URL when its decision started
1854
+ * @param reveal - Shows what the page hid while it waited
1855
+ * @returns How it ended; `left` when the visitor went to another page during the wait
1856
+ */
1857
+ const decideMark = async (mark, href, reveal) => {
1858
+ // The same decision the boot script makes while the HTML is parsed
1859
+ let decision = decisionFor(mark, href);
1860
+ const visit = navigations;
1861
+ if (decision.kind === 'unknown') {
1862
+ log(`holding the pageview for ${mark.experimentId}`);
1863
+ const outcome = await waitForAnswer();
1864
+ // A click during the wait, even one that comes back: the next page is decided on
1865
+ // its own, and this visit neither redirects to a variant of another URL nor counts
1866
+ if (navigations !== visit || pageOf(window.location.href) !== pageOf(href)) {
1867
+ log(`left ${pageOf(href)} while ${mark.experimentId} was decided`);
1868
+ return 'left';
1869
+ }
1870
+ if (outcome === 'timeout') {
1871
+ log('the assignment timed out; showing the page without the experiment');
1872
+ reveal();
1873
+ return 'shown';
1874
+ }
1875
+ decision = decisionFor(mark, href);
1876
+ }
1877
+ switch (decision.kind) {
1878
+ case 'redirect':
1879
+ log(`rendered ${mark.variant}, assigned ${decision.variant}: ${decision.url}`);
1880
+ navigate(decision.url);
1881
+ return 'redirected';
1882
+ case 'assigned': {
1883
+ const assigned = findAssignment(mark.experimentId);
1884
+ if (assigned) {
1885
+ joinSessionExperiment(getSessionId(), {
1886
+ id: assigned.id,
1887
+ running_id: assigned.runningId,
1888
+ variant: assigned.variant.name
1889
+ });
1890
+ visitInExperiment = visit;
1891
+ }
1892
+ log(`showing ${decision.variant} of ${mark.experimentId}`);
1893
+ break;
1894
+ }
1895
+ case 'ignored':
1896
+ warn(routeIgnoresVariantMessage(mark, decision.variant));
1897
+ break;
1898
+ default:
1899
+ log(`not in ${mark.experimentId}; showing the page`);
1900
+ }
1901
+ reveal();
1902
+ return 'shown';
1903
+ };
1904
+ /**
1905
+ * Decides a marked experiment once per page, whoever asks first: decide, for the pageview,
1906
+ * or the observer, for marks that arrive later.
1907
+ *
1908
+ * @param mark - The experiment and the variant the server rendered
1909
+ * @param href - The page's URL
1910
+ * @returns How the decision ended
1911
+ */
1912
+ const decideOnce = (mark, href) => {
1913
+ const key = `${navigations}\n${pageOf(href)}\n${mark.experimentId}\n${mark.variant}`;
1914
+ let outcome = decisions.get(key);
1915
+ if (!outcome) {
1916
+ outcome = decideMark(mark, href, () => revealRows(mark.experimentId, mark.variant));
1917
+ decisions.set(key, outcome);
1918
+ // A visit left mid-decision is never asked for again: its key has an older count
1919
+ void outcome.then((result) => {
1920
+ if (result === 'left') {
1921
+ decisions.delete(key);
1922
+ }
1923
+ });
1924
+ }
1925
+ return outcome;
1926
+ };
1927
+ /**
1928
+ * Decides every experiment the page renders, in document order; the first redirect wins.
1929
+ *
1930
+ * @param marks - The page's marks
1931
+ * @param href - The page's URL
1932
+ * @returns The page's decision
1933
+ */
1934
+ const decideMarks = async (marks, href) => {
1935
+ for (const mark of marks) {
1936
+ const outcome = await decideOnce(mark, href);
1937
+ if (outcome === 'redirected') {
1938
+ return { redirected: true };
1939
+ }
1940
+ if (outcome === 'left') {
1941
+ return { redirected: false, left: true };
1942
+ }
1943
+ }
1944
+ return { redirected: false };
1945
+ };
1946
+ /**
1947
+ * Waits for the marks of a page whose experiment runs on its URL, by the stored rule, while
1948
+ * the page has none yet: content behind Suspense streams in after the page loads, and after
1949
+ * a route change.
1950
+ *
1951
+ * @returns The marks, or none after the timeout
1952
+ */
1953
+ const waitForMarks = () => new Promise((resolve) => {
1954
+ let stop = () => undefined;
1955
+ const timer = setTimeout(() => {
1956
+ stop();
1957
+ resolve([]);
1958
+ }, waitMs);
1959
+ stop = observeMarks(() => {
1960
+ const marks = readPageExperiments();
1961
+ if (marks.length) {
1962
+ clearTimeout(timer);
1963
+ stop();
1964
+ resolve(marks);
1965
+ }
1966
+ });
1967
+ });
1968
+ /**
1969
+ * From the first decision on, decides the marks that arrive later, as soon as they are in
1970
+ * the page: streamed content, or a route rendered after its pageview.
1971
+ */
1972
+ const watchLateMarks = () => {
1973
+ stopObserving ?? (stopObserving = observeMarks(() => {
1974
+ const href = window.location.href;
1975
+ readPageExperiments().forEach((mark) => void decideOnce(mark, href));
1976
+ }));
1977
+ };
1978
+ return {
1979
+ prepare: () => {
1980
+ if (pages !== 'contentlets' || disabled) {
1981
+ return false;
1982
+ }
1983
+ const experiment = experimentOnThisPage();
1984
+ if (!experiment) {
1985
+ // A first visit: nothing stored says whether this page runs an experiment
1986
+ if (!assignments) {
1987
+ hideContentlets();
1988
+ }
1989
+ return false;
1990
+ }
1991
+ // The body is not parsed yet: the URL says which variant dotCMS renders
1992
+ const decision = decisionFor({
1993
+ experimentId: experiment.id,
1994
+ variant: readRenderedVariant(window.location.href)
1995
+ });
1996
+ if (decision.kind !== 'redirect') {
1997
+ return false;
1998
+ }
1999
+ log(`replacing the page with ${decision.variant} of ${experiment.id}: ${decision.url}`);
2000
+ preparedRedirect = decision.url;
2001
+ navigate(decision.url);
2002
+ return true;
2003
+ },
2004
+ start: () => {
2005
+ void check();
2006
+ },
2007
+ decide: async () => {
2008
+ // The boot script decided from storage while the HTML was parsed
2009
+ const redirectedTo = preparedRedirect ?? bootRedirect();
2010
+ if (redirectedTo) {
2011
+ log(`the page script is replacing the page: ${redirectedTo}`);
2012
+ return { redirected: true };
2013
+ }
2014
+ const href = window.location.href;
2015
+ const visit = navigations;
2016
+ let marks = readPageExperiments();
2017
+ if (pages === 'markup') {
2018
+ watchLateMarks();
2019
+ // The page runs an experiment by its URL rule, but its marks are not in yet
2020
+ if (!marks.length && !disabled && experimentOnThisPage()) {
2021
+ marks = await waitForMarks();
2022
+ if (navigations !== visit || pageOf(window.location.href) !== pageOf(href)) {
2023
+ return { redirected: false, left: true };
2024
+ }
2025
+ if (!marks.length) {
2026
+ warnIfUnmarked();
2027
+ }
2028
+ }
2029
+ return decideMarks(marks, href);
2030
+ }
2031
+ // A traditional page that prints the experiment markup is decided by it
2032
+ if (marks.length) {
2033
+ return decideMarks(marks, href);
2034
+ }
2035
+ const mark = await contentletsMark();
2036
+ if (!mark) {
2037
+ revealContentlets();
2038
+ return { redirected: false };
2039
+ }
2040
+ const outcome = await decideMark(mark, href, revealContentlets);
2041
+ if (outcome === 'left') {
2042
+ return { redirected: false, left: true };
2043
+ }
2044
+ return { redirected: outcome === 'redirected' };
2045
+ },
2046
+ contextExperiments: (sessionId) => visitInExperiment === navigations ? getSessionExperiments(sessionId) : [],
2047
+ stop: () => {
2048
+ stopObserving?.();
2049
+ stopObserving = undefined;
2050
+ stopCountingNavigations?.();
2051
+ }
2052
+ };
2053
+ };
2054
+
2055
+ /**
2056
+ * Analytics.js plugin that adds the session's `context.experiments` to every event sent from
2057
+ * a page that runs an experiment the visitor is in. The engine's `isUserIncluded` check starts
2058
+ * in `init`, before Analytics.js loads.
2059
+ *
2060
+ * It must come right after the identity plugin, which creates `context`.
2061
+ *
2062
+ * @param engine - The experiments engine
2063
+ * @returns The plugin
2064
+ */
2065
+ const experimentsPlugin = (engine) => {
2066
+ const withExperiments = ({ payload }) => {
2067
+ const context = payload?.context;
2068
+ if (!context?.session_id) {
2069
+ return payload;
2070
+ }
2071
+ const experiments = engine.contextExperiments(context.session_id);
2072
+ if (experiments.length === 0) {
2073
+ return payload;
2074
+ }
2075
+ return { ...payload, context: { ...context, experiments } };
2076
+ };
2077
+ return {
2078
+ name: 'dot-events-experiments',
2079
+ pageStart: withExperiments,
2080
+ trackStart: withExperiments,
2081
+ loaded: () => true
2082
+ };
2083
+ };
2084
+
2085
+ /*
2086
+ * Constants of the impression tracker.
2087
+ */
2088
+ /**
2089
+ * Impression tracking configuration constants
2090
+ */
2091
+ /**
2092
+ * Default minimum percentage of element that must be visible (0.0 to 1.0)
2093
+ */
2094
+ const DEFAULT_IMPRESSION_VISIBILITY_THRESHOLD = 0.5;
2095
+ /**
2096
+ * Default minimum time in milliseconds element must be visible
2097
+ */
2098
+ const DEFAULT_IMPRESSION_DWELL_MS = 750;
2099
+ /**
2100
+ * Default maximum number of elements to track (performance limit)
2101
+ */
2102
+ const DEFAULT_IMPRESSION_MAX_NODES = 100;
2103
+ /**
2104
+ * Default impression tracking configuration
2105
+ */
2106
+ const DEFAULT_IMPRESSION_CONFIG = {
2107
+ visibilityThreshold: DEFAULT_IMPRESSION_VISIBILITY_THRESHOLD,
2108
+ dwellMs: DEFAULT_IMPRESSION_DWELL_MS,
2109
+ maxNodes: DEFAULT_IMPRESSION_MAX_NODES
2110
+ };
2111
+ /**
2112
+ * Event type for content impressions
2113
+ * Must match DotCMSPredefinedEventType.CONTENT_IMPRESSION
2114
+ */
2115
+ const IMPRESSION_EVENT_TYPE = 'content_impression';
2116
+
2117
+ /**
2118
+ * Utility functions for impression tracking
2119
+ */
2120
+ /**
2121
+ * Checks if an element meets a specific visibility threshold
2122
+ * @param element - The HTML element to check
2123
+ * @param threshold - The required visibility ratio (0.0 to 1.0)
2124
+ * @returns True if the element meets or exceeds the threshold
2125
+ */
2126
+ function isElementMeetingVisibilityThreshold(element, threshold) {
2127
+ const visibilityRatio = calculateElementVisibilityRatio(element);
2128
+ return visibilityRatio >= threshold;
2129
+ }
2130
+
2131
+ var _DotCMSImpressionTracker_observer, _DotCMSImpressionTracker_mutationObserver, _DotCMSImpressionTracker_elementImpressionStates, _DotCMSImpressionTracker_sessionTrackedImpressions, _DotCMSImpressionTracker_impressionConfig, _DotCMSImpressionTracker_stopWatchingNavigations, _DotCMSImpressionTracker_stopWatchingRestores, _DotCMSImpressionTracker_subscribers, _DotCMSImpressionTracker_logger, _DotCMSImpressionTracker_handleRescan;
2132
+ /**
2133
+ * Tracks content impressions using IntersectionObserver and dwell time.
2134
+ * Emits events through subscriptions when impressions are detected.
2135
+ */
2136
+ class DotCMSImpressionTracker {
2137
+ constructor(config) {
2138
+ _DotCMSImpressionTracker_observer.set(this, null);
2139
+ _DotCMSImpressionTracker_mutationObserver.set(this, null);
2140
+ _DotCMSImpressionTracker_elementImpressionStates.set(this, new Map());
2141
+ _DotCMSImpressionTracker_sessionTrackedImpressions.set(this, new Set());
2142
+ _DotCMSImpressionTracker_impressionConfig.set(this, void 0);
2143
+ _DotCMSImpressionTracker_stopWatchingNavigations.set(this, null);
2144
+ _DotCMSImpressionTracker_stopWatchingRestores.set(this, null);
2145
+ _DotCMSImpressionTracker_subscribers.set(this, new Set());
2146
+ _DotCMSImpressionTracker_logger.set(this, void 0);
2147
+ /** Scans again on CONTENTLET_RESCAN_EVENT, sent when hidden contentlets are shown */
2148
+ _DotCMSImpressionTracker_handleRescan.set(this, () => {
2149
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").debug('Rescan requested');
2150
+ this.findAndObserveContentletElements();
2151
+ });
2152
+ __classPrivateFieldSet(this, _DotCMSImpressionTracker_logger, createPluginLogger('Impression', config), "f");
2153
+ __classPrivateFieldSet(this, _DotCMSImpressionTracker_impressionConfig, this.resolveImpressionConfig(config.impressions), "f");
2154
+ }
2155
+ /**
2156
+ * Subscribe to impression events
2157
+ * @param callback - Function called when impression is detected
2158
+ * @returns Subscription object with unsubscribe method
2159
+ */
2160
+ onImpression(callback) {
2161
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_subscribers, "f").add(callback);
2162
+ return {
2163
+ unsubscribe: () => {
2164
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_subscribers, "f").delete(callback);
2165
+ }
2166
+ };
2167
+ }
2168
+ /** Notifies all subscribers of an impression */
2169
+ notifySubscribers(eventName, payload) {
2170
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_subscribers, "f").forEach((callback) => {
2171
+ try {
2172
+ callback(eventName, payload);
2173
+ }
2174
+ catch (error) {
2175
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").error('Error in impression subscriber:', error);
2176
+ }
2177
+ });
2178
+ }
2179
+ /**
2180
+ * Merges user config with defaults. IntersectionObserver throws on a visibility threshold
2181
+ * outside 0 to 1, so one outside that range is clamped to it, with a warning
2182
+ */
2183
+ resolveImpressionConfig(userConfig) {
2184
+ // If boolean/undefined, use defaults
2185
+ if (typeof userConfig !== 'object' || userConfig === null) {
2186
+ return { ...DEFAULT_IMPRESSION_CONFIG };
2187
+ }
2188
+ // Merge user config with defaults
2189
+ const merged = { ...DEFAULT_IMPRESSION_CONFIG, ...userConfig };
2190
+ const requested = merged.visibilityThreshold;
2191
+ const visibilityThreshold = Number.isFinite(requested)
2192
+ ? Math.min(1, Math.max(0, requested))
2193
+ : DEFAULT_IMPRESSION_CONFIG.visibilityThreshold;
2194
+ if (visibilityThreshold !== requested) {
2195
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn(`visibilityThreshold must be between 0 and 1, got ${requested}: using ${visibilityThreshold}`);
2196
+ }
2197
+ return { ...merged, visibilityThreshold };
2198
+ }
2199
+ /** Initializes tracking: sets up observers, finds contentlets, handles visibility/navigation */
2200
+ initialize() {
2201
+ // Early return if SSR
2202
+ if (!isBrowser$1()) {
2203
+ return;
2204
+ }
2205
+ // Setup IntersectionObserver
2206
+ this.initializeIntersectionObserver();
2207
+ // Wait for DOM to be ready before scanning
2208
+ // The delay allows React/Next.js to finish initial rendering
2209
+ // before searching for contentlet elements
2210
+ if (typeof window !== 'undefined') {
2211
+ setTimeout(() => {
2212
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").debug('Running initial scan after timeout...');
2213
+ // Find and observe contentlet elements
2214
+ this.findAndObserveContentletElements();
2215
+ }, INITIAL_SCAN_DELAY_MS);
2216
+ }
2217
+ // Setup mutation observer for dynamic content
2218
+ this.initializeDynamicContentDetector();
2219
+ // Scan again when hidden contentlets are shown without a DOM mutation
2220
+ window.addEventListener(CONTENTLET_RESCAN_EVENT, __classPrivateFieldGet(this, _DotCMSImpressionTracker_handleRescan, "f"));
2221
+ // Listen for visibility changes to pause/resume tracking
2222
+ this.initializePageVisibilityHandler();
2223
+ // Listen for page navigation to reset tracking
2224
+ this.initializePageNavigationHandler();
2225
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info('Impression tracking initialized with config:', __classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f"));
2226
+ }
2227
+ /** Sets up IntersectionObserver with configured visibility threshold */
2228
+ initializeIntersectionObserver() {
2229
+ const options = {
2230
+ root: null, // Use viewport as root
2231
+ rootMargin: '0px',
2232
+ threshold: __classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").visibilityThreshold
2233
+ };
2234
+ __classPrivateFieldSet(this, _DotCMSImpressionTracker_observer, new IntersectionObserver((entries) => {
2235
+ this.processIntersectionChanges(entries);
2236
+ }, options), "f");
2237
+ }
2238
+ /** Finds contentlets in DOM, validates them, and starts observing (respects maxNodes limit) */
2239
+ findAndObserveContentletElements() {
2240
+ if (!__classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f"))
2241
+ return;
2242
+ const contentlets = findContentlets();
2243
+ if (contentlets.length === 0) {
2244
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn('No contentlets found to track');
2245
+ return;
2246
+ }
2247
+ // Limit to maxNodes for performance
2248
+ const maxNodesToTrack = Math.min(contentlets.length, __classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").maxNodes);
2249
+ let observedCount = 0;
2250
+ for (let i = 0; i < maxNodesToTrack; i++) {
2251
+ const element = contentlets[i];
2252
+ if (!element) {
2253
+ continue;
2254
+ }
2255
+ const identifier = extractContentletIdentifier(element);
2256
+ if (identifier) {
2257
+ // Skip elements that shouldn't be tracked
2258
+ const skipReason = this.shouldSkipElement(element);
2259
+ if (skipReason) {
2260
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").debug(`Skipping element ${identifier} (${skipReason})`);
2261
+ continue;
2262
+ }
2263
+ // Only observe and initialize if this is a new element
2264
+ if (!__classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").has(identifier)) {
2265
+ if (!element.dataset['dotDomIndex']) {
2266
+ element.dataset['dotDomIndex'] = String(i);
2267
+ }
2268
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f").observe(element);
2269
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").set(identifier, {
2270
+ timer: null,
2271
+ visibleSince: null,
2272
+ tracked: this.hasBeenTrackedInSession(identifier),
2273
+ element
2274
+ });
2275
+ observedCount++;
2276
+ }
2277
+ }
2278
+ }
2279
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info(`Observing ${observedCount} contentlets`);
2280
+ if (contentlets.length > maxNodesToTrack) {
2281
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn(`${contentlets.length - maxNodesToTrack} contentlets not tracked (maxNodes limit: ${__classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").maxNodes})`);
2282
+ }
2283
+ }
2284
+ /**
2285
+ * Watches for contentlets added to the DOM, and for contentlets receiving their
2286
+ * identifier, which the renderers print after hydration (throttled for performance)
2287
+ */
2288
+ initializeDynamicContentDetector() {
2289
+ if (!isBrowser$1()) {
2290
+ return;
2291
+ }
2292
+ __classPrivateFieldSet(this, _DotCMSImpressionTracker_mutationObserver, createContentletObserver(() => {
2293
+ this.findAndObserveContentletElements();
2294
+ }, CONTENTLET_OBSERVER_DEBOUNCE_MS, { identifiers: true }), "f");
2295
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info('MutationObserver enabled for dynamic content detection');
2296
+ }
2297
+ /** Cancels all timers when page is hidden (prevents false impressions) */
2298
+ initializePageVisibilityHandler() {
2299
+ document.addEventListener('visibilitychange', () => {
2300
+ if (document.visibilityState === 'hidden') {
2301
+ // Page is hidden, cancel all timers
2302
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").forEach((state) => {
2303
+ if (state.timer !== null) {
2304
+ window.clearTimeout(state.timer);
2305
+ state.timer = null;
2306
+ state.visibleSince = null;
2307
+ }
2308
+ });
2309
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn('Page hidden, all impression timers cancelled');
2310
+ }
2311
+ });
2312
+ }
2313
+ /**
2314
+ * Resets tracking when the visitor goes to another page, or comes back to this one from the
2315
+ * back/forward cache. It listens through onNavigation: the browser's Navigation API where
2316
+ * there is one, which needs no patch of `history`
2317
+ */
2318
+ initializePageNavigationHandler() {
2319
+ __classPrivateFieldSet(this, _DotCMSImpressionTracker_stopWatchingNavigations, onNavigation(() => {
2320
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn(`Navigation detected (${window.location.pathname}), resetting impression tracking`);
2321
+ this.forgetPageImpressions();
2322
+ }), "f");
2323
+ // A restored page is seen again, and counts again, as after a load. Its DOM is the one
2324
+ // the visitor left, so nothing else scans it: observed again from scratch, the observer
2325
+ // reports what is in view
2326
+ __classPrivateFieldSet(this, _DotCMSImpressionTracker_stopWatchingRestores, onPageRestore(() => {
2327
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info('Page restored from the back/forward cache, counting impressions again');
2328
+ this.forgetPageImpressions();
2329
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f")?.disconnect();
2330
+ this.findAndObserveContentletElements();
2331
+ }), "f");
2332
+ }
2333
+ /** Forgets the page's impressions, timers and elements, so the next view counts its own */
2334
+ forgetPageImpressions() {
2335
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_sessionTrackedImpressions, "f").clear();
2336
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").forEach((state) => {
2337
+ if (state.timer !== null) {
2338
+ window.clearTimeout(state.timer);
2339
+ state.timer = null;
2340
+ state.visibleSince = null;
2341
+ }
2342
+ });
2343
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").clear();
2344
+ }
2345
+ /** Handles visibility changes: starts timer on enter, cancels on exit */
2346
+ processIntersectionChanges(entries) {
2347
+ // Ignore if page is not visible
2348
+ if (document.visibilityState !== 'visible') {
2349
+ return;
2350
+ }
2351
+ entries.forEach((entry) => {
2352
+ const element = entry.target;
2353
+ const identifier = extractContentletIdentifier(element);
2354
+ if (!identifier)
2355
+ return;
2356
+ if (entry.isIntersecting) {
2357
+ // Element is visible, start tracking
2358
+ this.startImpressionDwellTimer(identifier, element);
2359
+ }
2360
+ else {
2361
+ // Element is not visible, cancel tracking
2362
+ this.cancelImpressionDwellTimer(identifier);
2363
+ }
2364
+ });
2365
+ }
2366
+ /** Starts dwell timer; fires impression if element still visible when timer expires */
2367
+ startImpressionDwellTimer(identifier, element) {
2368
+ const state = __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").get(identifier);
2369
+ if (!state)
2370
+ return;
2371
+ // Already tracked, skip
2372
+ if (state.tracked || this.hasBeenTrackedInSession(identifier)) {
2373
+ return;
2374
+ }
2375
+ // Timer already running, skip
2376
+ if (state.timer !== null) {
2377
+ return;
2378
+ }
2379
+ // Page not visible, skip
2380
+ if (document.visibilityState !== 'visible') {
2381
+ return;
2382
+ }
2383
+ // Start timer
2384
+ state.visibleSince = Date.now();
2385
+ state.element = element;
2386
+ state.timer = window.setTimeout(() => {
2387
+ // Verify element is still visible before firing (post-dwell check)
2388
+ if (this.isElementStillVisible(element)) {
2389
+ this.trackAndSendImpression(identifier, element);
2390
+ }
2391
+ else {
2392
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").warn(`Dwell timer expired for ${identifier} but element no longer visible, skipping impression`);
2393
+ // Clear state since we're not tracking
2394
+ state.timer = null;
2395
+ state.visibleSince = null;
2396
+ }
2397
+ }, __classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").dwellMs);
2398
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").debug(`Started dwell timer for ${identifier} (${__classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").dwellMs}ms)`);
2399
+ }
2400
+ /** Cancels active dwell timer (element left viewport before dwell time) */
2401
+ cancelImpressionDwellTimer(identifier) {
2402
+ const state = __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").get(identifier);
2403
+ if (!state || state.timer === null)
2404
+ return;
2405
+ window.clearTimeout(state.timer);
2406
+ state.timer = null;
2407
+ state.visibleSince = null;
2408
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").debug(`Cancelled dwell timer for ${identifier}`);
2409
+ }
2410
+ /** Fires impression event with content & position data (page data added by enricher plugin) */
2411
+ trackAndSendImpression(identifier, element) {
2412
+ const state = __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").get(identifier);
2413
+ if (!state)
2414
+ return;
2415
+ // Calculate actual dwell time
2416
+ const dwellTime = state.visibleSince ? Date.now() - state.visibleSince : 0;
2417
+ // Extract contentlet data using utility
2418
+ const contentletData = extractContentletData(element);
2419
+ // Calculate viewport metrics using utility
2420
+ const viewportMetrics = getViewportMetrics(element);
2421
+ // Read cached DOM index instead of expensive query
2422
+ // Falls back to -1 if not cached (should never happen in normal flow)
2423
+ const domIndex = parseInt(element.dataset['dotDomIndex'] || '-1', 10);
2424
+ // Build impression payload (enricher plugin adds page data automatically)
2425
+ const impressionPayload = {
2426
+ content: {
2427
+ identifier: contentletData.identifier,
2428
+ inode: contentletData.inode,
2429
+ title: contentletData.title,
2430
+ content_type: contentletData.contentType
2431
+ },
2432
+ position: {
2433
+ viewport_offset_pct: viewportMetrics.offsetPercentage,
2434
+ dom_index: domIndex
2435
+ }
2436
+ };
2437
+ // Notify subscribers (plugin will call analytics.track)
2438
+ this.notifySubscribers(IMPRESSION_EVENT_TYPE, impressionPayload);
2439
+ // Mark as tracked in session
2440
+ this.markImpressionAsTracked(identifier);
2441
+ // Clear state
2442
+ state.timer = null;
2443
+ state.visibleSince = null;
2444
+ state.tracked = true;
2445
+ // Stop observing this element (no longer needed)
2446
+ if (__classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f")) {
2447
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f").unobserve(element);
2448
+ }
2449
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info(`Fired impression for ${identifier} (dwell: ${dwellTime}ms) - element unobserved`, contentletData);
2450
+ }
2451
+ /** Returns skip reason if element is hidden/too small, null if trackable */
2452
+ shouldSkipElement(element) {
2453
+ const rect = element.getBoundingClientRect();
2454
+ const styles = window.getComputedStyle(element);
2455
+ // Check for zero dimensions (empty/collapsed elements)
2456
+ if (rect.height === 0 || rect.width === 0) {
2457
+ return `zero dimensions: ${rect.width}x${rect.height}`;
2458
+ }
2459
+ // Check for very small elements (likely tracking pixels or artifacts)
2460
+ const MIN_SIZE = 10; // pixels
2461
+ if (rect.height < MIN_SIZE || rect.width < MIN_SIZE) {
2462
+ return `too small: ${rect.width}x${rect.height} (minimum: ${MIN_SIZE}px)`;
2463
+ }
2464
+ // Check for hidden elements (visibility: hidden)
2465
+ if (styles.visibility === 'hidden') {
2466
+ return 'visibility: hidden';
2467
+ }
2468
+ // Check for transparent elements (opacity: 0)
2469
+ if (parseFloat(styles.opacity) === 0) {
2470
+ return 'opacity: 0';
2471
+ }
2472
+ // Check for elements with display: none
2473
+ if (styles.display === 'none') {
2474
+ return 'display: none';
2475
+ }
2476
+ // Element passes all checks
2477
+ return null;
2478
+ }
2479
+ /** Post-dwell check: verifies element still meets visibility threshold */
2480
+ isElementStillVisible(element) {
2481
+ // Check page visibility first
2482
+ if (document.visibilityState !== 'visible') {
2483
+ return false;
2484
+ }
2485
+ // Check if element meets visibility threshold using utility
2486
+ return isElementMeetingVisibilityThreshold(element, __classPrivateFieldGet(this, _DotCMSImpressionTracker_impressionConfig, "f").visibilityThreshold);
2487
+ }
2488
+ /** Checks if impression already fired in current page session */
2489
+ hasBeenTrackedInSession(identifier) {
2490
+ return __classPrivateFieldGet(this, _DotCMSImpressionTracker_sessionTrackedImpressions, "f").has(identifier);
2491
+ }
2492
+ /** Marks impression as tracked (prevents duplicates in same page session) */
2493
+ markImpressionAsTracked(identifier) {
2494
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_sessionTrackedImpressions, "f").add(identifier);
2495
+ }
2496
+ /** Cleanup: disconnects observers, clears timers and state */
2497
+ cleanup() {
2498
+ // Disconnect intersection observer
2499
+ if (__classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f")) {
2500
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_observer, "f").disconnect();
2501
+ __classPrivateFieldSet(this, _DotCMSImpressionTracker_observer, null, "f");
2502
+ }
2503
+ // Disconnect mutation observer
2504
+ if (__classPrivateFieldGet(this, _DotCMSImpressionTracker_mutationObserver, "f")) {
2505
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_mutationObserver, "f").disconnect();
2506
+ __classPrivateFieldSet(this, _DotCMSImpressionTracker_mutationObserver, null, "f");
2507
+ }
2508
+ window.removeEventListener(CONTENTLET_RESCAN_EVENT, __classPrivateFieldGet(this, _DotCMSImpressionTracker_handleRescan, "f"));
2509
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_stopWatchingNavigations, "f")?.call(this);
2510
+ __classPrivateFieldSet(this, _DotCMSImpressionTracker_stopWatchingNavigations, null, "f");
2511
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_stopWatchingRestores, "f")?.call(this);
2512
+ __classPrivateFieldSet(this, _DotCMSImpressionTracker_stopWatchingRestores, null, "f");
2513
+ // Clear all active dwell timers
2514
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").forEach((state) => {
2515
+ if (state.timer !== null) {
2516
+ window.clearTimeout(state.timer);
2517
+ }
2518
+ });
2519
+ // Clear all state
2520
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_elementImpressionStates, "f").clear();
2521
+ // Clear all subscribers
2522
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_subscribers, "f").clear();
2523
+ __classPrivateFieldGet(this, _DotCMSImpressionTracker_logger, "f").info('Impression tracking cleaned up');
2524
+ }
2525
+ }
2526
+ _DotCMSImpressionTracker_observer = new WeakMap(), _DotCMSImpressionTracker_mutationObserver = new WeakMap(), _DotCMSImpressionTracker_elementImpressionStates = new WeakMap(), _DotCMSImpressionTracker_sessionTrackedImpressions = new WeakMap(), _DotCMSImpressionTracker_impressionConfig = new WeakMap(), _DotCMSImpressionTracker_stopWatchingNavigations = new WeakMap(), _DotCMSImpressionTracker_stopWatchingRestores = new WeakMap(), _DotCMSImpressionTracker_subscribers = new WeakMap(), _DotCMSImpressionTracker_logger = new WeakMap(), _DotCMSImpressionTracker_handleRescan = new WeakMap();
2527
+
2528
+ /**
2529
+ * Impressions plugin of the events pipeline.
2530
+ * Handles automatic tracking of content visibility and impressions.
2531
+ *
2532
+ * This plugin initializes the impression tracker which:
2533
+ * - Uses IntersectionObserver to detect when contentlets are visible
2534
+ * - Tracks dwell time (how long elements are visible)
2535
+ * - Fires 'content-impression' events via instance.track()
2536
+ * - Deduplicates impressions per session
2537
+ *
2538
+ * Analytics.js runs the plugins in the order events.ts registers them: identity, experiments,
2539
+ * impressions, clicks, enricher, sender. This one hooks into no event: its tracker fires
2540
+ * events through instance.track(), which runs them through the other plugins like any event.
2541
+ *
2542
+ * Note: This plugin is only registered if config.impressions is enabled.
2543
+ * See getEnhancedTrackingPlugins() for conditional loading logic.
2544
+ *
2545
+ * @param {PipelineConfig} config - Configuration with impressions settings
2546
+ * @returns {Object} Plugin object with lifecycle methods
2547
+ */
2548
+ const impressionsPlugin = (config) => {
2549
+ let impressionTracker = null;
2550
+ let subscription = null;
2551
+ const logger = createPluginLogger('Impression', config);
2552
+ return {
2553
+ name: 'dot-events-impressions',
2554
+ /**
2555
+ * Initialize impression tracking
2556
+ * Called when Analytics.js initializes the plugin with instance context
2557
+ * @param instance - Analytics.js instance with track method
2558
+ */
2559
+ initialize: ({ instance }) => {
2560
+ // Only initialize if impressions are enabled
2561
+ if (!config.impressions) {
2562
+ logger.info('Impression tracking disabled (config.impressions not set)');
2563
+ return Promise.resolve();
2564
+ }
2565
+ // Create and initialize tracker
2566
+ impressionTracker = new DotCMSImpressionTracker(config);
2567
+ impressionTracker.initialize();
2568
+ // Subscribe to impression events and call analytics track
2569
+ subscription = impressionTracker.onImpression((eventName, payload) => {
2570
+ instance.track(eventName, payload);
2571
+ });
2572
+ logger.info('Impression tracking plugin initialized');
2573
+ return Promise.resolve();
2574
+ },
2575
+ /**
2576
+ * Setup cleanup handlers when plugin is loaded
2577
+ * Called after Analytics.js completes plugin loading
2578
+ */
2579
+ loaded: () => {
2580
+ // Only setup cleanup if tracker was initialized
2581
+ if (isBrowser$1() && impressionTracker) {
2582
+ const cleanup = () => {
2583
+ // Unsubscribe before cleanup
2584
+ if (subscription) {
2585
+ subscription.unsubscribe();
2586
+ subscription = null;
2587
+ }
2588
+ if (impressionTracker) {
2589
+ impressionTracker.cleanup();
2590
+ impressionTracker = null;
2591
+ logger.info('Impression tracking cleaned up on page unload');
2592
+ }
2593
+ };
2594
+ // Cleanup on page unload
2595
+ setupPluginCleanup(cleanup);
2596
+ }
2597
+ return true;
2598
+ }
2599
+ };
2600
+ };
2601
+
2602
+ /**
2603
+ * Plugin that enriches the analytics payload data with page, UTM, and custom data.
2604
+ * Uses Analytics.js lifecycle events to inject enriched data before the main plugin processes it.
2605
+ *
2606
+ * The identity plugin, registered first, injects context: { session_id, site_auth, user_id, device }.
2607
+ * This plugin, registered after the experiments, impressions and clicks plugins, adds page, UTM
2608
+ * and custom data. The sender, registered last, structures the events and sends them.
2609
+ *
2610
+ * This plugin is ONLY responsible for data enrichment - NOT for event structuring or business logic.
2611
+ */
2612
+ const enricherPlugin = () => {
2613
+ return {
2614
+ name: 'dot-events-enricher',
2615
+ /**
2616
+ * PAGE VIEW ENRICHMENT - Runs after identity context injection
2617
+ * Returns enriched payload with page, utm, and custom data added
2618
+ * @returns {EnrichedAnalyticsPayload} Enriched payload ready for event creation
2619
+ */
2620
+ [`page:${SENDER_PLUGIN_NAME}`]: ({ payload }) => {
2621
+ const enrichedData = enrichPagePayloadOptimized(payload);
2622
+ if (!enrichedData.page) {
2623
+ throw new Error('[dotCMS events] Missing required page data');
2624
+ }
2625
+ return enrichedData;
2626
+ },
2627
+ /**
2628
+ * TRACK EVENT ENRICHMENT - Runs after identity context injection
2629
+ * Adds page data and timestamp for predefined content events.
2630
+ * For custom events, only adds timestamp.
2631
+ *
2632
+ * @returns {EnrichedTrackPayload} Enriched payload ready for event structuring
2633
+ */
2634
+ [`track:${SENDER_PLUGIN_NAME}`]: ({ payload }) => {
2635
+ const { event } = payload;
2636
+ const local_time = getLocalTime();
2637
+ // For content_impression, content_click, and conversion events, add page data
2638
+ if (event === DotCMSPredefinedEventType.CONTENT_IMPRESSION ||
2639
+ event === DotCMSPredefinedEventType.CONTENT_CLICK ||
2640
+ event === DotCMSPredefinedEventType.CONVERSION) {
2641
+ return {
2642
+ ...payload,
2643
+ page: {
2644
+ title: document.title,
2645
+ url: window.location.href
2646
+ },
2647
+ local_time
2648
+ };
2649
+ }
2650
+ // For custom events, just add local_time
2651
+ return {
2652
+ ...payload,
2653
+ local_time
2654
+ };
2655
+ }
2656
+ };
2657
+ };
2658
+
2659
+ /**
2660
+ * Identity plugin of the events pipeline.
2661
+ * Handles user ID generation, session management, and activity tracking.
2662
+ * This plugin provides consistent identity context across all analytics events.
2663
+ *
2664
+ * It runs first: events.ts registers identity, experiments, impressions, clicks, enricher and
2665
+ * sender, in that order, and each plugin reads what the ones before it wrote.
2666
+ *
2667
+ * @param {PipelineConfig} config - Configuration object containing server URL, site key, and debug settings
2668
+ * @returns {Object} Plugin object with methods for initialization and event processing
2669
+ */
2670
+ const identityPlugin = (config) => {
2671
+ return {
2672
+ name: 'dot-events-identity',
2673
+ /**
2674
+ * Initialize the identity plugin
2675
+ * Sets up activity tracking for session management
2676
+ */
2677
+ initialize: () => {
2678
+ initializeActivityTracking(config);
2679
+ return Promise.resolve();
2680
+ },
2681
+ /**
2682
+ * Inject identity context into page events and updates session activity for session management
2683
+ * This runs BEFORE the enricher plugin
2684
+ */
2685
+ pageStart: ({ payload }) => {
2686
+ updateSessionActivity();
2687
+ const context = getEventContext(config);
2688
+ return {
2689
+ ...payload,
2690
+ context
2691
+ };
2692
+ },
2693
+ /**
2694
+ * Inject identity context into track events and updates session activity for session management
2695
+ * This runs BEFORE the enricher plugin
2696
+ */
2697
+ trackStart: ({ payload }) => {
2698
+ updateSessionActivity();
2699
+ const context = getEventContext(config);
2700
+ return {
2701
+ ...payload,
2702
+ context
2703
+ };
2704
+ },
2705
+ /**
2706
+ * Cleans up activity tracking when the browser discards the page. A page kept in the
2707
+ * back/forward cache keeps it, and keeps `__dotAnalyticsActive__` set, so the
2708
+ * renderers still print the contentlet attributes once the visitor goes back to it.
2709
+ */
2710
+ loaded: () => {
2711
+ if (typeof window !== 'undefined') {
2712
+ onPageDiscard(cleanupActivityTracking);
2713
+ }
2714
+ return true;
2715
+ }
2716
+ };
2717
+ };
2718
+
2719
+ /**
2720
+ * Hands a failure to the configured handler. A handler that throws must not break the page.
2721
+ *
2722
+ * @param config - The analytics configuration
2723
+ * @param error - The failure
2724
+ */
2725
+ const notify = (config, error) => {
2726
+ try {
2727
+ config.onError?.(error);
2728
+ }
2729
+ catch {
2730
+ // The app's handler failed; the events pipeline carries on
2731
+ }
2732
+ };
2733
+ /**
2734
+ * Reads the response body, when it is JSON.
2735
+ *
2736
+ * @param response - The events response
2737
+ * @returns The body, or null
2738
+ */
2739
+ const readBody = async (response) => {
2740
+ try {
2741
+ return (await response.json());
2742
+ }
2743
+ catch {
2744
+ return null;
2745
+ }
2746
+ };
2747
+ /**
2748
+ * Reports what dotCMS answered to an events request: an error status, or an accepted request
2749
+ * whose body says some events failed.
2750
+ *
2751
+ * @param response - The events response
2752
+ * @param body - Its body, when JSON
2753
+ * @param config - The analytics configuration
2754
+ * @param events - How many events the request carried
2755
+ */
2756
+ const reportResponse = (response, body, config, events) => {
2757
+ const errors = body?.entity?.errors;
2758
+ const failed = body?.entity?.failed ?? 0;
2759
+ if (response.ok && failed === 0) {
2760
+ return;
2761
+ }
2762
+ const reason = errors?.[0]?.message ?? body?.message ?? (response.statusText || 'Unknown Error');
2763
+ notify(config, {
2764
+ code: 'REJECTED',
2765
+ message: response.ok
2766
+ ? `${failed} of ${events} event(s) failed: ${reason}`
2767
+ : `HTTP ${response.status}: ${reason}`,
2768
+ status: response.status,
2769
+ detail: errors ?? body,
2770
+ events
2771
+ });
2772
+ };
2773
+ /**
2774
+ * Send analytics events to the server using fetch API
2775
+ * @param payload - The event payload data
2776
+ * @param config - The analytics configuration
2777
+ * @param keepalive - Use keepalive mode for page unload scenarios (default: false)
2778
+ * @returns A promise that resolves when the request is complete
2779
+ */
2780
+ const sendEvents = async (payload, config, keepalive = false) => {
2781
+ const logger = createPluginLogger('HTTP', config);
2782
+ const endpoint = `${config.server}${ANALYTICS_ENDPOINT}`;
2783
+ const body = JSON.stringify(payload);
2784
+ logger.info(`Sending ${payload.events.length} event(s)${keepalive ? ' (keepalive)' : ''}`, {
2785
+ payload
2786
+ });
2787
+ try {
2788
+ const fetchOptions = {
2789
+ method: 'POST',
2790
+ headers: { 'Content-Type': 'application/json' },
2791
+ body
2792
+ };
2793
+ // Only add keepalive-specific options when keepalive is true
2794
+ if (keepalive) {
2795
+ fetchOptions.keepalive = true;
2796
+ fetchOptions.credentials = 'omit'; // Required for keepalive requests
2797
+ // Fire and forget - don't await response with keepalive
2798
+ // The browser will send the request even if the page unloads
2799
+ fetch(endpoint, fetchOptions)
2800
+ .then(async (response) => {
2801
+ reportResponse(response, await readBody(response), config, payload.events.length);
2802
+ })
2803
+ .catch((err) => {
2804
+ logger.error('Keepalive request failed (browser may have ignored it):', err);
2805
+ notify(config, {
2806
+ code: 'NETWORK',
2807
+ message: String(err),
2808
+ events: payload.events.length
2809
+ });
2810
+ });
2811
+ // We can't know if it succeeded, but for keepalive=true contexts we usually assume "sent"
2812
+ // or we return false since we can't confirm.
2813
+ // However, the caller usually ignores the return value for keepalive.
2814
+ return true;
2815
+ }
2816
+ // Normal request - await and check response
2817
+ const response = await fetch(endpoint, fetchOptions);
2818
+ if (!response.ok) {
2819
+ // Always log the HTTP status code
2820
+ const statusText = response.statusText || 'Unknown Error';
2821
+ const baseErrorMessage = `HTTP ${response.status}: ${statusText}`;
2822
+ const errorData = await readBody(response);
2823
+ if (errorData?.message) {
2824
+ logger.warn(`${errorData.message} (${baseErrorMessage})`);
2825
+ }
2826
+ else if (errorData) {
2827
+ // JSON parsed successfully but no message property
2828
+ logger.warn(`${baseErrorMessage} - No error message in response`);
2829
+ }
2830
+ else {
2831
+ logger.warn(`${baseErrorMessage} - Failed to parse error response`);
2832
+ }
2833
+ reportResponse(response, errorData, config, payload.events.length);
2834
+ return false;
2835
+ }
2836
+ // Accepted: dotCMS may still have failed some of the events
2837
+ reportResponse(response, await readBody(response), config, payload.events.length);
2838
+ return true;
2839
+ }
2840
+ catch (error) {
2841
+ logger.error('Error sending event:', error);
2842
+ notify(config, { code: 'NETWORK', message: String(error), events: payload.events.length });
2843
+ return false;
2844
+ }
2845
+ };
2846
+
2847
+ /**
2848
+ * What sets two events' contexts apart: the site, the session, the visitor and the
2849
+ * experiments. The device alone does not, since the viewport changes between events, as it
2850
+ * does when a phone's address bar hides on scroll.
2851
+ *
2852
+ * @param context - An event's context
2853
+ * @returns A key that is equal for contexts that can share a request
2854
+ */
2855
+ const contextKey = ({ site_auth, session_id, user_id, experiments }) => JSON.stringify([site_auth, session_id, user_id, experiments ?? []]);
2856
+ /**
2857
+ * Groups events into request bodies, in order: consecutive events whose contexts share a key
2858
+ * go out together, with the latest of their contexts. Events of a page that runs an
2859
+ * experiment and of one that does not never share a context.
2860
+ *
2861
+ * @param items - Events with the context each was created with
2862
+ * @returns One request body per run of events
2863
+ */
2864
+ const toRequestBodies = (items) => {
2865
+ const bodies = [];
2866
+ let lastKey;
2867
+ items.forEach(({ event, context }) => {
2868
+ const key = contextKey(context);
2869
+ const last = bodies[bodies.length - 1];
2870
+ if (last && key === lastKey) {
2871
+ last.events.push(event);
2872
+ last.context = context;
2873
+ }
2874
+ else {
2875
+ bodies.push({ context, events: [event] });
2876
+ lastKey = key;
2877
+ }
2878
+ });
2879
+ return bodies;
2880
+ };
2881
+ /**
2882
+ * Creates a queue manager for batching analytics events.
2883
+ * Uses factory function pattern consistent with the plugin architecture.
2884
+ */
2885
+ const createEventQueue = (config) => {
2886
+ const logger = createPluginLogger('Queue', config);
2887
+ let eventQueue = null;
2888
+ // The context each queued event was created with
2889
+ let contextOf = new WeakMap();
2890
+ /**
2891
+ * Whether to use keepalive mode for sending events
2892
+ * true for page unload (visibilitychange, pagehide), false for normal sends
2893
+ */
2894
+ let keepalive = false;
2895
+ /**
2896
+ * Track if we're in a SPA navigation to avoid unnecessary flushes
2897
+ */
2898
+ let isSPANavigation = false;
2899
+ let currentPath = typeof window !== 'undefined' ? window.location.pathname : '';
2900
+ /**
2901
+ * Unique identifier for this browser tab
2902
+ * Used to prevent race conditions when multiple tabs write to sessionStorage
2903
+ */
2904
+ let tabId = '';
2905
+ /**
2906
+ * Parallel array to track events, with their contexts, for persistence
2907
+ * smartQueue doesn't expose its internal array, so we maintain a copy
2908
+ */
2909
+ let eventsForPersistence = [];
2910
+ // Merge user config with defaults (allows partial configuration)
2911
+ // After merge, queueConfig always has all required values
2912
+ const queueConfig = {
2913
+ ...DEFAULT_QUEUE_CONFIG,
2914
+ ...(typeof config.queue === 'object' ? config.queue : {})
2915
+ };
2916
+ /**
2917
+ * Get storage key for this tab's queue
2918
+ */
2919
+ const getStorageKey = () => {
2920
+ return `${QUEUE_STORAGE_KEY_PREFIX}_${tabId}`;
2921
+ };
2922
+ /**
2923
+ * Validate persisted queue structure
2924
+ * @param data - Data from sessionStorage
2925
+ * @returns Validated queue or null if invalid
2926
+ */
2927
+ const validatePersistedQueue = (data) => {
2928
+ const q = data;
2929
+ if (!q || typeof q !== 'object' || Array.isArray(q)) {
2930
+ return null;
2931
+ }
2932
+ // Validate minimal structural requirements
2933
+ if (typeof q.tabId !== 'string' ||
2934
+ typeof q.timestamp !== 'number' ||
2935
+ !Number.isFinite(q.timestamp) ||
2936
+ !Array.isArray(q.events)) {
2937
+ logger.warn('Invalid persisted queue: structural mismatch');
2938
+ return null;
2939
+ }
2940
+ // Validate minimal event structure: each event with its context
2941
+ const validEvents = q.events.filter((item) => item &&
2942
+ typeof item === 'object' &&
2943
+ item.event &&
2944
+ typeof item.event === 'object' &&
2945
+ 'event_type' in item.event &&
2946
+ item.context &&
2947
+ typeof item.context === 'object');
2948
+ return {
2949
+ tabId: q.tabId,
2950
+ timestamp: q.timestamp,
2951
+ events: validEvents
2952
+ };
2953
+ };
2954
+ /**
2955
+ * Load events from sessionStorage
2956
+ * @returns Persisted queue or null if not found/invalid
2957
+ */
2958
+ const loadFromStorage = () => {
2959
+ try {
2960
+ const key = getStorageKey();
2961
+ const stored = safeSessionStorage.getItem(key);
2962
+ if (!stored) {
2963
+ logger.debug('No persisted queue found');
2964
+ return null;
2965
+ }
2966
+ const parsed = JSON.parse(stored);
2967
+ const validated = validatePersistedQueue(parsed);
2968
+ if (!validated) {
2969
+ // Clear invalid storage
2970
+ safeSessionStorage.removeItem(key);
2971
+ return null;
2972
+ }
2973
+ // Check if events are too old (24 hours)
2974
+ const age = Date.now() - validated.timestamp;
2975
+ if (age > MAX_EVENT_AGE_MS) {
2976
+ logger.warn(`Persisted events too old (${Math.round(age / 1000 / 60 / 60)}h), discarding`);
2977
+ safeSessionStorage.removeItem(key);
2978
+ return null;
2979
+ }
2980
+ logger.info(`Loaded ${validated.events.length} persisted event(s) from storage (age: ${Math.round(age / 1000)}s)`);
2981
+ return validated;
2982
+ }
2983
+ catch (error) {
2984
+ logger.error('Failed to load persisted queue', error);
2985
+ // Clear corrupted storage
2986
+ safeSessionStorage.removeItem(getStorageKey());
2987
+ return null;
2988
+ }
2989
+ };
2990
+ /**
2991
+ * Persist current queue to sessionStorage
2992
+ */
2993
+ const persistToStorage = () => {
2994
+ if (eventsForPersistence.length === 0) {
2995
+ return;
2996
+ }
2997
+ try {
2998
+ const key = getStorageKey();
2999
+ const queueData = {
3000
+ tabId,
3001
+ timestamp: Date.now(),
3002
+ events: eventsForPersistence
3003
+ };
3004
+ safeSessionStorage.setItem(key, JSON.stringify(queueData));
3005
+ logger.debug(`Persisted ${eventsForPersistence.length} event(s) to storage`);
3006
+ }
3007
+ catch (error) {
3008
+ if (error instanceof Error && error.name === 'QuotaExceededError') {
3009
+ logger.warn('sessionStorage quota exceeded, continuing without persistence');
3010
+ }
3011
+ else {
3012
+ logger.error('Failed to persist queue', error);
3013
+ }
3014
+ }
3015
+ };
3016
+ /**
3017
+ * Clear persisted queue from sessionStorage
3018
+ */
3019
+ const clearStorage = () => {
3020
+ try {
3021
+ const key = getStorageKey();
3022
+ safeSessionStorage.removeItem(key);
3023
+ logger.debug('Persisted queue cleared from storage');
3024
+ }
3025
+ catch (error) {
3026
+ logger.error('Failed to clear persisted queue', error);
3027
+ }
3028
+ };
3029
+ /**
3030
+ * Sends events right away, outside the batches. Used for the events an earlier page
3031
+ * persisted, which go out when this page loads (that caller passes useKeepalive false),
3032
+ * each with the context it was created with.
3033
+ * @param events - Events to send, with their contexts
3034
+ * @param useKeepalive - Whether to send with keepalive
3035
+ * @returns Promise<boolean> - true if every request succeeded
3036
+ */
3037
+ const sendImmediately = async (events, useKeepalive = true) => {
3038
+ if (events.length === 0) {
3039
+ return true;
3040
+ }
3041
+ logger.info(`Sending ${events.length} persisted event(s) immediately`);
3042
+ const sent = await Promise.all(toRequestBodies(events).map((payload) => sendEvents(payload, config, useKeepalive)));
3043
+ return sent.every(Boolean);
3044
+ };
3045
+ /**
3046
+ * Send batch of events to server
3047
+ * Called by smartQueue - uses keepalive mode when flushing on page unload
3048
+ * @param events - The batch of events to send
3049
+ * @param _rest - Remaining events in queue (unused, required by smartQueue API)
3050
+ */
3051
+ const sendBatch = (events, _rest) => {
3052
+ const batch = events.flatMap((event) => {
3053
+ const context = contextOf.get(event);
3054
+ return context ? [{ event, context }] : [];
3055
+ });
3056
+ if (batch.length === 0)
3057
+ return;
3058
+ logger.debug(`Sending batch of ${events.length} event(s)`, {
3059
+ events,
3060
+ keepalive
3061
+ });
3062
+ // Send events (fire-and-forget with keepalive): one request per context in the batch
3063
+ toRequestBodies(batch).forEach((payload) => sendEvents(payload, config, keepalive));
3064
+ // Remove sent events from parallel tracking array
3065
+ eventsForPersistence = eventsForPersistence.filter((queued) => !events.some((sent) => sent === queued.event));
3066
+ // Always update storage after dispatching the send — even for keepalive flushes.
3067
+ // Note: sendEvents is fire-and-forget (the returned promise is not awaited),
3068
+ // so this runs regardless of whether the HTTP request succeeds.
3069
+ // sessionStorage writes are synchronous and complete before page unload,
3070
+ // so the persistence state stays consistent. Previously, keepalive flushes
3071
+ // skipped the storage update, which caused the next page load to re-send
3072
+ // the same events from persistence, producing duplicates.
3073
+ if (eventsForPersistence.length === 0) {
3074
+ clearStorage();
3075
+ }
3076
+ else {
3077
+ persistToStorage();
3078
+ }
3079
+ };
3080
+ /**
3081
+ * Flush remaining events when page becomes hidden or unloads
3082
+ * Enables keepalive mode and triggers smartQueue to flush ALL events
3083
+ */
3084
+ const flushRemaining = () => {
3085
+ if (!eventQueue || eventQueue.size() === 0 || eventsForPersistence.length === 0)
3086
+ return;
3087
+ logger.info(`Flushing ${eventQueue.size()} events (page hidden/unload)`);
3088
+ // Use keepalive mode for reliable delivery during page unload
3089
+ keepalive = true;
3090
+ // Flush all events - flush(true) makes smartQueue recursively batch until empty
3091
+ eventQueue.flush(true);
3092
+ };
3093
+ /**
3094
+ * Handle visibility change - flush when page becomes hidden
3095
+ * This is more reliable than beforeunload/unload, especially on mobile
3096
+ *
3097
+ * IMPORTANT: We skip flush if this is a SPA navigation (client-side routing)
3098
+ * Only flush on real tab changes or browser close
3099
+ */
3100
+ const handleVisibilityChange = () => {
3101
+ logger.debug('handleVisibilityChange', document.visibilityState);
3102
+ if (document.visibilityState === 'hidden') {
3103
+ // Don't flush if this is just a SPA navigation
3104
+ if (isSPANavigation) {
3105
+ logger.debug('Skipping flush (SPA navigation detected), persisting to storage');
3106
+ // For SPA navigation, persist events without flushing
3107
+ // Events stay in memory and are preserved across client-side routing
3108
+ persistToStorage();
3109
+ return;
3110
+ }
3111
+ // Real visibility change (tab switch, browser close, etc.)
3112
+ flushRemaining();
3113
+ }
3114
+ else if (document.visibilityState === 'visible') {
3115
+ // Reset SPA navigation flag when page becomes visible again
3116
+ isSPANavigation = false;
3117
+ }
3118
+ };
3119
+ return {
3120
+ /**
3121
+ * Initialize the queue with smart batching
3122
+ */
3123
+ initialize: () => {
3124
+ // 1. Load or generate persistent tab ID
3125
+ // Tab ID must remain constant across page navigations within the same browser tab
3126
+ if (typeof window !== 'undefined') {
3127
+ // Try to load existing tab ID from sessionStorage
3128
+ const storedTabId = safeSessionStorage.getItem(TAB_ID_STORAGE_KEY);
3129
+ if (storedTabId) {
3130
+ // Reuse existing tab ID from previous page
3131
+ tabId = storedTabId;
3132
+ logger.debug(`Reusing Tab ID: ${tabId}`);
3133
+ }
3134
+ else {
3135
+ // Generate new tab ID (Robust check for crypto.randomUUID)
3136
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
3137
+ tabId = crypto.randomUUID();
3138
+ }
3139
+ else {
3140
+ // Fallback for non-secure contexts or older browsers/environments
3141
+ tabId = generateSecureId('tab');
3142
+ logger.debug('crypto.randomUUID not available, using fallback generator');
3143
+ }
3144
+ try {
3145
+ safeSessionStorage.setItem(TAB_ID_STORAGE_KEY, tabId);
3146
+ }
3147
+ catch {
3148
+ // Ignore storage error for tab ID, will just generate a new one on next load
3149
+ }
3150
+ logger.debug(`Generated new Tab ID: ${tabId}`);
3151
+ }
3152
+ }
3153
+ // 2. Load persisted events from previous page (if any)
3154
+ const persisted = loadFromStorage();
3155
+ if (persisted && persisted.events.length > 0) {
3156
+ // Send persisted events immediately WITHOUT keepalive (to confirm success)
3157
+ // This ensures events from previous page navigation are sent reliably
3158
+ sendImmediately(persisted.events, false).then((success) => {
3159
+ if (success) {
3160
+ // Clear storage ONLY after successful transmission
3161
+ clearStorage();
3162
+ }
3163
+ else {
3164
+ logger.warn('Failed to send persisted events, keeping in storage for next retry');
3165
+ }
3166
+ });
3167
+ }
3168
+ // 3. Initialize smartQueue for new events
3169
+ eventQueue = smartQueue((items, rest) => {
3170
+ sendBatch(items);
3171
+ }, {
3172
+ max: queueConfig.eventBatchSize,
3173
+ interval: queueConfig.flushInterval,
3174
+ throttle: false // Always false - enables both batch size and interval triggers
3175
+ });
3176
+ // 4. Setup page visibility and unload listeners
3177
+ // visibilitychange is more reliable than beforeunload, especially on mobile
3178
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
3179
+ document.addEventListener('visibilitychange', handleVisibilityChange);
3180
+ // pagehide as fallback for browsers without bfcache or older browsers
3181
+ window.addEventListener('pagehide', flushRemaining);
3182
+ // Detect SPA navigations using @analytics/router-utils
3183
+ // Handles Next.js, React Router, Vue Router, etc.
3184
+ onRouteChange((newPath) => {
3185
+ isSPANavigation = true;
3186
+ currentPath = newPath;
3187
+ logger.debug(`SPA navigation detected (${currentPath})`);
3188
+ // Reset flag after a short delay (SPA navigation is done)
3189
+ setTimeout(() => {
3190
+ isSPANavigation = false;
3191
+ }, 100);
3192
+ });
3193
+ }
3194
+ },
3195
+ /**
3196
+ * Add event to queue
3197
+ * smartQueue handles all batching logic automatically:
3198
+ * - Sends immediately when eventBatchSize reached (with throttle: false)
3199
+ * - Sends pending events every flushInterval
3200
+ */
3201
+ enqueue: (event, context) => {
3202
+ if (!eventQueue)
3203
+ return;
3204
+ // Each event goes out with the context it was created with
3205
+ contextOf.set(event, context);
3206
+ // Add to parallel tracking array for persistence
3207
+ eventsForPersistence.push({ event, context });
3208
+ // Calculate predicted size before push to show correct order in logs
3209
+ const predictedSize = eventQueue.size() + 1;
3210
+ const maxSize = queueConfig.eventBatchSize;
3211
+ const willBeFull = predictedSize >= maxSize;
3212
+ logger.debug(`Event added. Queue size: ${predictedSize}/${maxSize}${willBeFull ? ' (full, sending...)' : ''}`, { eventType: event.event_type, event });
3213
+ // Push triggers sendBatch callback if queue is full (throttle: false)
3214
+ eventQueue.push(event);
3215
+ // Persist to sessionStorage after enqueue
3216
+ persistToStorage();
3217
+ },
3218
+ /**
3219
+ * Get queue size for debugging
3220
+ * Returns the number of events in smartQueue
3221
+ */
3222
+ size: () => eventQueue?.size() ?? 0,
3223
+ /**
3224
+ * Clean up queue resources
3225
+ * Flushes remaining events and cleans up listeners
3226
+ *
3227
+ * IMPORTANT: Does NOT clear sessionStorage
3228
+ * - Storage is updated when sendBatch dispatches a batch, whether or not its request
3229
+ * succeeds, and in initialize()
3230
+ * - This allows events to persist across traditional page navigations
3231
+ */
3232
+ cleanup: () => {
3233
+ // Flush remaining events with keepalive
3234
+ flushRemaining();
3235
+ // Remove event listeners
3236
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
3237
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
3238
+ window.removeEventListener('pagehide', flushRemaining);
3239
+ }
3240
+ // Clean up memory only (not sessionStorage)
3241
+ eventQueue = null;
3242
+ contextOf = new WeakMap();
3243
+ keepalive = false;
3244
+ eventsForPersistence = [];
3245
+ }
3246
+ };
3247
+ };
3248
+
3249
+ /**
3250
+ * Analytics plugin for tracking page views and custom events in DotCMS applications.
3251
+ * This plugin handles:
3252
+ * 1. Event structuring (deciding between predefined and custom events)
3253
+ * 2. Building complete request bodies
3254
+ * 3. Sending analytics data to the DotCMS server
3255
+ * 4. Managing initialization and queue management
3256
+ *
3257
+ * The enricher plugin runs BEFORE this plugin and adds page/utm/custom data.
3258
+ * This plugin receives enriched payloads and structures them into proper events.
3259
+ *
3260
+ * @param {PipelineConfig} config - Configuration object containing API key, server URL,
3261
+ * debug mode, auto page view settings, and queue config
3262
+ * @returns {Object} Plugin object with methods for initialization and event tracking
3263
+ */
3264
+ const senderPlugin = (config) => {
3265
+ let isInitialized = false;
3266
+ // Queue is enabled if queue is not explicitly false
3267
+ const enableQueue = config.queue !== false;
3268
+ let queue = null;
3269
+ /**
3270
+ * Dispatches event to queue or directly to server
3271
+ */
3272
+ const dispatchEvent = (requestBody) => {
3273
+ const event = requestBody.events[0];
3274
+ const context = requestBody.context;
3275
+ // Use queue or send directly
3276
+ if (enableQueue && queue) {
3277
+ if (event) {
3278
+ queue.enqueue(event, context);
3279
+ }
3280
+ }
3281
+ else {
3282
+ // Direct send without queue (when queue === false)
3283
+ sendEvents(requestBody, config);
3284
+ }
3285
+ };
3286
+ return {
3287
+ name: SENDER_PLUGIN_NAME,
3288
+ config,
3289
+ /**
3290
+ * Initialize the plugin with optional queue management
3291
+ */
3292
+ initialize: () => {
3293
+ isInitialized = true;
3294
+ // Initialize queue if enabled (queue is undefined or an object)
3295
+ if (enableQueue) {
3296
+ queue = createEventQueue(config);
3297
+ queue.initialize();
3298
+ }
3299
+ return Promise.resolve();
3300
+ },
3301
+ /**
3302
+ * Track a page view event
3303
+ * Receives enriched payload from the enricher plugin and structures it into a pageview event
3304
+ */
3305
+ page: ({ payload }) => {
3306
+ if (!isInitialized) {
3307
+ throw new Error('[dotCMS events] Plugin not initialized');
3308
+ }
3309
+ const { context, page, utm, custom, local_time } = payload;
3310
+ if (!page) {
3311
+ throw new Error('[dotCMS events] Missing required page data');
3312
+ }
3313
+ const requestBody = {
3314
+ context,
3315
+ events: [
3316
+ {
3317
+ event_type: DotCMSPredefinedEventType.PAGEVIEW,
3318
+ local_time,
3319
+ data: {
3320
+ page,
3321
+ ...(utm && { utm }),
3322
+ ...(custom && { custom })
3323
+ }
3324
+ }
3325
+ ]
3326
+ };
3327
+ dispatchEvent(requestBody);
3328
+ },
3329
+ /**
3330
+ * Builds a content impression, content click or conversion from the enriched payload.
3331
+ *
3332
+ * - content_impression → extracts from properties, combines with enriched page data
3333
+ * - content_click → extracts from properties, combines with enriched page data
3334
+ * - conversion → its name and the page
3335
+ *
3336
+ * Those, with pageviews, are the only event types dotCMS accepts.
3337
+ */
3338
+ track: ({ payload }) => {
3339
+ if (!isInitialized) {
3340
+ throw new Error('[dotCMS events] Plugin not initialized');
3341
+ }
3342
+ const { event, properties, context, local_time } = payload;
3343
+ let analyticsEvent;
3344
+ // One case per event type dotCMS accepts
3345
+ switch (event) {
3346
+ case DotCMSPredefinedEventType.CONTENT_IMPRESSION: {
3347
+ // Extract impression data from properties (sent by tracker)
3348
+ const impressionPayload = properties;
3349
+ const { content, position } = impressionPayload;
3350
+ const { page } = payload; // Added by enricher
3351
+ if (!content || !position || !page) {
3352
+ throw new Error('[dotCMS events] Missing required impression data');
3353
+ }
3354
+ analyticsEvent = {
3355
+ event_type: DotCMSPredefinedEventType.CONTENT_IMPRESSION,
3356
+ local_time,
3357
+ data: {
3358
+ content,
3359
+ position,
3360
+ page
3361
+ }
3362
+ };
3363
+ break;
3364
+ }
3365
+ case DotCMSPredefinedEventType.CONTENT_CLICK: {
3366
+ // Extract click data from properties (sent by click plugin)
3367
+ const clickPayload = properties;
3368
+ const { content, position, element } = clickPayload;
3369
+ const { page } = payload; // Added by enricher
3370
+ if (!content || !position || !element || !page) {
3371
+ throw new Error('[dotCMS events] Missing required click data');
3372
+ }
3373
+ analyticsEvent = {
3374
+ event_type: DotCMSPredefinedEventType.CONTENT_CLICK,
3375
+ local_time,
3376
+ data: {
3377
+ content,
3378
+ position,
3379
+ element,
3380
+ page
3381
+ }
3382
+ };
3383
+ break;
3384
+ }
3385
+ case DotCMSPredefinedEventType.CONVERSION: {
3386
+ // Extract conversion data from properties (sent by user)
3387
+ const { name } = properties;
3388
+ const { page } = payload; // Added by enricher
3389
+ if (!name || !page) {
3390
+ throw new Error('[dotCMS events] Missing required conversion data');
3391
+ }
3392
+ analyticsEvent = {
3393
+ event_type: DotCMSPredefinedEventType.CONVERSION,
3394
+ local_time,
3395
+ data: {
3396
+ conversion: { name },
3397
+ page
3398
+ }
3399
+ };
3400
+ break;
3401
+ }
3402
+ default:
3403
+ // Only the SDK calls track, with the types above
3404
+ throw new Error(`[dotCMS events] ${event} is not an event type dotCMS accepts`);
3405
+ }
3406
+ const requestBody = {
3407
+ context,
3408
+ events: [analyticsEvent]
3409
+ };
3410
+ dispatchEvent(requestBody);
3411
+ },
3412
+ /**
3413
+ * Check if the plugin is loaded
3414
+ */
3415
+ loaded: () => isInitialized
3416
+ };
3417
+ };
3418
+
3419
+ /** Calls made before `init`, replayed once it runs. Capped so a missing `init` can't grow it. */
3420
+ const MAX_PENDING_CALLS = 50;
3421
+ /**
3422
+ * A storage that lives as long as the page. Analytics.js persists an anonymous id that nothing
3423
+ * here reads, and its default storage falls back to a cookie when localStorage is
3424
+ * unavailable: with this one, nothing it keeps reaches localStorage, sessionStorage or a
3425
+ * cookie.
3426
+ *
3427
+ * @returns The storage
3428
+ */
3429
+ const createMemoryStorage = () => {
3430
+ const values = new Map();
3431
+ return {
3432
+ getItem: (key) => values.get(key),
3433
+ setItem: (key, value) => void values.set(key, value),
3434
+ removeItem: (key) => void values.delete(key)
3435
+ };
3436
+ };
3437
+ let instance = null;
3438
+ let engine = null;
3439
+ let activeConfigKey = null;
3440
+ let lastPageKey = null;
3441
+ let debug = false;
3442
+ const pendingCalls = [];
3443
+ const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
3444
+ const log = (...args) => {
3445
+ if (debug) {
3446
+ // eslint-disable-next-line no-console
3447
+ console.info('[dotCMS events]', ...args);
3448
+ }
3449
+ };
3450
+ let onErrorHandler;
3451
+ /**
3452
+ * Hands a failure to the app's `onError`. A handler that throws must not break the page.
3453
+ *
3454
+ * @param error - The failure
3455
+ */
3456
+ const reportError = (error) => {
3457
+ try {
3458
+ onErrorHandler?.(error);
3459
+ }
3460
+ catch {
3461
+ // The app's handler failed; events carry on
3462
+ }
3463
+ };
3464
+ /** Reports a setup mistake in the page, only in debug like `log`. */
3465
+ const warn = (message) => {
3466
+ if (debug) {
3467
+ console.warn(`[dotCMS events] ${message}`);
3468
+ }
3469
+ };
3470
+ const defer = (call) => {
3471
+ if (pendingCalls.length < MAX_PENDING_CALLS) {
3472
+ pendingCalls.push(call);
3473
+ }
3474
+ };
3475
+ // Optional settings pass through only when set, so the core applies its own defaults.
3476
+ const toAnalyticsConfig = (config) => ({
3477
+ server: config.dotcmsUrl.replace(/\/+$/, ''),
3478
+ siteAuth: config.siteAuth,
3479
+ debug: Boolean(config.debug),
3480
+ autoPageView: config.autoPageView !== false,
3481
+ ...(config.logLevel !== undefined && { logLevel: config.logLevel }),
3482
+ ...(config.queue !== undefined && { queue: config.queue }),
3483
+ ...(config.impressions !== undefined && { impressions: config.impressions }),
3484
+ ...(config.clicks !== undefined && { clicks: config.clicks }),
3485
+ onError: reportError
3486
+ });
3487
+ const assignmentTimeout = (config) => typeof config.experiments === 'object' && typeof config.experiments.timeout === 'number'
3488
+ ? config.experiments.timeout
3489
+ : DEFAULT_ASSIGNMENT_TIMEOUT_MS;
3490
+ const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
3491
+ /**
3492
+ * Sends a pageview, after the experiments engine decides the page. When the engine
3493
+ * redirects, the pageview is dropped: the variant's page sends its own.
3494
+ */
3495
+ const sendPageView = async (data) => {
3496
+ if (!instance) {
3497
+ return;
3498
+ }
3499
+ if (engine) {
3500
+ const { redirected, left } = await engine.decide();
3501
+ if (redirected) {
3502
+ log('pageview dropped: redirecting to the assigned variant');
3503
+ return;
3504
+ }
3505
+ // Sent now, it would carry the URL of the page the visitor went to
3506
+ if (left) {
3507
+ log('pageview dropped: the visitor left the page while it was decided');
3508
+ return;
3509
+ }
3510
+ }
3511
+ instance.page(data);
3512
+ };
3513
+ const trackAutomaticPageView = async () => {
3514
+ const pageKey = `${window.location.pathname}${window.location.search}`;
3515
+ if (pageKey === lastPageKey) {
3516
+ return;
3517
+ }
3518
+ lastPageKey = pageKey;
3519
+ await sendPageView({});
3520
+ };
3521
+ const startAutomaticPageViews = () => {
3522
+ const first = () => void trackAutomaticPageView();
3523
+ if (document.readyState === 'loading') {
3524
+ document.addEventListener('DOMContentLoaded', first, { once: true });
3525
+ }
3526
+ else {
3527
+ first();
3528
+ }
3529
+ // Wait two frames after a navigation, so the new route's rows are in the DOM.
3530
+ onNavigation(() => {
3531
+ void nextFrame()
3532
+ .then(nextFrame)
3533
+ .then(() => trackAutomaticPageView());
3534
+ });
3535
+ // A page restored from the back/forward cache keeps the URL already counted, but the
3536
+ // visitor sees it again: it counts, as it does when the browser loads it again instead
3537
+ onPageRestore(() => void sendPageView({}));
3538
+ };
3539
+ /**
3540
+ * Starts the SDK: what `dotEvents.init` does, with how the pages mark their experiment. Apps call
3541
+ * `dotEvents.init`, for pages that print the experiment markup; the script dotCMS injects into
3542
+ * traditional pages calls this with `contentlets`, since those pages carry dotCMS's contentlet
3543
+ * wrappers instead. Not exported by the package.
3544
+ *
3545
+ * @param config - The app's config
3546
+ * @param pages - How the pages mark what their experiment varies
3547
+ */
3548
+ const initEvents = (config, pages) => {
3549
+ if (!isBrowser()) {
3550
+ return;
3551
+ }
3552
+ const configKey = `${config?.dotcmsUrl}|${config?.siteAuth}`;
3553
+ // Claimed as soon as init starts: Analytics.js loads after this call returns
3554
+ if (activeConfigKey) {
3555
+ if (configKey !== activeConfigKey) {
3556
+ console.warn('[dotCMS events] init was called again with another config; ignored');
3557
+ }
3558
+ return;
3559
+ }
3560
+ debug = Boolean(config?.debug);
3561
+ onErrorHandler = config?.onError;
3562
+ if (!config?.dotcmsUrl || !config?.siteAuth) {
3563
+ console.error('[dotCMS events] init needs dotcmsUrl and siteAuth');
3564
+ return;
3565
+ }
3566
+ if (getUVEState()) {
3567
+ // Inside the editor nothing is tracked, and nothing may stay hidden.
3568
+ revealRows();
3569
+ window[ANALYTICS_WINDOWS_ACTIVE_KEY] = false;
3570
+ log('inside UVE: events off');
3571
+ return;
3572
+ }
3573
+ activeConfigKey = configKey;
3574
+ const analyticsConfig = toAnalyticsConfig(config);
3575
+ const experimentsOn = config.experiments !== false;
3576
+ engine = experimentsOn
3577
+ ? createExperimentsEngine({
3578
+ dotcmsUrl: analyticsConfig.server,
3579
+ timeoutMs: assignmentTimeout(config),
3580
+ log,
3581
+ warn,
3582
+ onError: reportError,
3583
+ pages
3584
+ })
3585
+ : null;
3586
+ if (engine) {
3587
+ // On pages dotCMS renders, a returning visitor is decided now, while the head is parsed.
3588
+ // Otherwise a new visitor's isUserIncluded leaves now, while Analytics.js loads.
3589
+ if (!engine.prepare()) {
3590
+ engine.start();
3591
+ }
3592
+ }
3593
+ else {
3594
+ revealRows();
3595
+ }
3596
+ // The page renderers print contentlet attributes while analytics is active, so they
3597
+ // learn it now, before they hydrate, and never render twice.
3598
+ window[ANALYTICS_WINDOWS_ACTIVE_KEY] = true;
3599
+ window.dispatchEvent(new CustomEvent(ANALYTICS_READY_EVENT));
3600
+ // Traditional pages and plain scripts reach the same object as window.dotEvents.
3601
+ const globalScope = window;
3602
+ if (!globalScope[EVENTS_WINDOW_KEY]) {
3603
+ globalScope[EVENTS_WINDOW_KEY] = dotEvents;
3604
+ }
3605
+ // Analytics.js reads the time zone when its module is evaluated: loading it here keeps
3606
+ // that work, and the module's, out of the task that starts the app. Calls wait in
3607
+ // pendingCalls meanwhile.
3608
+ void import('analytics')
3609
+ .then(({ Analytics }) => {
3610
+ const plugins = [
3611
+ identityPlugin(analyticsConfig),
3612
+ ...(engine ? [experimentsPlugin(engine)] : []),
3613
+ ...getEnhancedTrackingPlugins(analyticsConfig, impressionsPlugin, clicksPlugin),
3614
+ enricherPlugin(),
3615
+ senderPlugin(analyticsConfig)
3616
+ ];
3617
+ // Analytics.js reads `storage` from its config, though its types leave it out
3618
+ const analyticsJsConfig = {
3619
+ app: 'dotEvents',
3620
+ debug: analyticsConfig.debug,
3621
+ plugins,
3622
+ storage: createMemoryStorage()
3623
+ };
3624
+ instance = Analytics(analyticsJsConfig);
3625
+ log('ready', {
3626
+ experiments: experimentsOn,
3627
+ autoPageView: analyticsConfig.autoPageView
3628
+ });
3629
+ pendingCalls.splice(0).forEach((call) => call());
3630
+ if (analyticsConfig.autoPageView) {
3631
+ startAutomaticPageViews();
3632
+ }
3633
+ })
3634
+ .catch((error) => {
3635
+ console.error('[dotCMS events] Analytics.js failed to load; no events are sent', error);
3636
+ });
3637
+ };
3638
+ /**
3639
+ * The events SDK. There is one object per page: every import gets the same instance,
3640
+ * configured by the one `init` call.
3641
+ */
3642
+ const dotEvents = {
3643
+ init(config) {
3644
+ initEvents(config, 'markup');
3645
+ },
3646
+ conversion(name) {
3647
+ if (!isBrowser()) {
3648
+ return;
3649
+ }
3650
+ if (!name || name.trim() === '') {
3651
+ console.warn('[dotCMS events] a conversion needs a name');
3652
+ return;
3653
+ }
3654
+ if (!instance) {
3655
+ defer(() => dotEvents.conversion(name));
3656
+ return;
3657
+ }
3658
+ instance.track(DotCMSPredefinedEventType.CONVERSION, { name });
3659
+ },
3660
+ pageView(data = {}) {
3661
+ if (!isBrowser()) {
3662
+ return;
3663
+ }
3664
+ if (!instance) {
3665
+ defer(() => dotEvents.pageView(data));
3666
+ return;
3667
+ }
3668
+ void sendPageView(data);
3669
+ }
3670
+ };
3671
+
3672
+ export { dotEvents };