@dotcms/events 0.0.0-stage → 26.10.5-1-next.2827

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