@manototh/do11y 0.1.2 → 0.2.1

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.
package/dist/do11y.js CHANGED
@@ -1,14 +1,24 @@
1
- (function() {
2
- //#region src/do11y.ts
1
+ var Do11yBundle = (function(exports) {
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ //#region src/core/constants.ts
3
4
  /**
4
- * OTel semantic convention attribute keys.
5
+ * Do11y Documentation Observability
6
+ *
7
+ * OTel semantic convention attribute keys and event names.
8
+ *
5
9
  * Standard attrs from https://opentelemetry.io/docs/specs/semconv/.
6
10
  * Custom do11y attrs use the `browser.do11y.*` namespace.
7
11
  */
12
+ const VERSION = "0.2.1";
8
13
  const ATTR_SESSION_ID = "session.id";
9
14
  const ATTR_URL_PATH = "url.path";
10
15
  const ATTR_URL_FRAGMENT = "url.fragment";
11
- const ATTR_URL_QUERY = "url.query";
16
+ /**
17
+ * Privacy-safe query parameter indicator. The actual query string is never
18
+ * sent. Emits `'has_params'` or `null` to indicate whether the URL contained
19
+ * query parameters.
20
+ */
21
+ const ATTR_DO11Y_URL_HAS_PARAMS = "browser.do11y.url.has_params";
12
22
  const ATTR_DEVICE_TYPE = "device.type";
13
23
  const ATTR_BROWSER_FAMILY = "browser.family";
14
24
  const ATTR_BROWSER_LANGUAGE = "browser.language";
@@ -52,9 +62,6 @@
52
62
  const ATTR_DO11Y_EXPAND_SUMMARY = "browser.do11y.expand.summary";
53
63
  const ATTR_DO11Y_EXPAND_ACTION = "browser.do11y.expand.action";
54
64
  const ATTR_DO11Y_EXPAND_SECTION = "browser.do11y.expand.section";
55
- /**
56
- * OTel event names for do11y events (browser.do11y.* namespace).
57
- */
58
65
  const EVENT_PAGE_VIEW = "browser.do11y.page_view";
59
66
  const EVENT_PAGE_EXIT = "browser.do11y.page_exit";
60
67
  const EVENT_SCROLL_DEPTH = "browser.do11y.scroll_depth";
@@ -66,61 +73,53 @@
66
73
  const EVENT_TOC_CLICK = "browser.do11y.toc_click";
67
74
  const EVENT_FEEDBACK = "browser.do11y.feedback";
68
75
  const EVENT_EXPAND_COLLAPSE = "browser.do11y.expand_collapse";
69
- const VERSION = "0.1.2";
70
- const _alreadyLoaded = !!window.__do11yInitialized;
71
- window.__do11yInitialized = true;
72
- const _isInIframe = window.self !== window.top;
73
- if (_isInIframe && !_alreadyLoaded) window.__do11yInitialized = false;
74
- const config = {
75
- destination: "supabase",
76
- supabaseUrl: "",
77
- supabaseKey: "",
78
- supabaseTable: "do11y_events",
79
- endpoint: "",
80
- headers: {},
81
- bodyTransform: void 0,
82
- otelSdkEndpoint: "",
83
- otelSdkHeaders: {},
84
- otelSdkServiceName: "do11y",
85
- otelSdkResourceAttributes: {},
86
- otelSdkCdnUrl: "https://esm.sh/",
87
- debug: false,
88
- flushInterval: 5e3,
89
- maxBatchSize: 10,
90
- trackOutboundLinks: true,
91
- trackInternalLinks: true,
92
- trackScrollDepth: true,
93
- scrollThresholds: [
94
- 25,
95
- 50,
96
- 75,
97
- 90
98
- ],
99
- allowedDomains: null,
100
- respectDNT: true,
101
- maxRetries: 2,
102
- retryDelay: 1e3,
103
- rateLimitMs: 100,
104
- framework: "mintlify",
105
- trackSectionVisibility: true,
106
- sectionVisibleThreshold: 3,
107
- trackTabSwitches: true,
108
- trackTocClicks: true,
109
- trackExpandCollapse: true,
110
- trackFeedback: true,
111
- tabContainerSelector: null,
112
- tocSelector: null,
113
- feedbackSelector: null,
114
- searchSelector: null,
115
- copyButtonSelector: null,
116
- codeBlockSelector: null,
117
- navigationSelector: null,
118
- footerSelector: null,
119
- contentSelector: null,
120
- useOtelBrowserInstrumentations: false,
121
- testRunId: void 0,
122
- testFramework: void 0
123
- };
76
+ const SELECTOR_KEYS = [
77
+ "searchSelector",
78
+ "copyButtonSelector",
79
+ "codeBlockSelector",
80
+ "navigationSelector",
81
+ "footerSelector",
82
+ "contentSelector",
83
+ "tabContainerSelector",
84
+ "tocSelector",
85
+ "feedbackSelector"
86
+ ];
87
+ //#endregion
88
+ //#region src/core/rate-limit.ts
89
+ /**
90
+ * Do11y — Documentation Observability
91
+ *
92
+ * Shared event rate limiter used by both the standalone transport and the
93
+ * OTel instrumentation build.
94
+ *
95
+ * Rate-limiting prevents event spam (duplicate `page_exit` on SPA
96
+ * navigation, rapid same-type bursts). The rate-limit key is per event
97
+ * name, except for scroll depth milestones: a fast scroll can cross several
98
+ * thresholds in a single frame, so the key includes the threshold attribute
99
+ * to let each milestone through independently.
100
+ */
101
+ function createRateLimiter() {
102
+ const lastEventTime = {};
103
+ return {
104
+ allow(eventName, eventData, rateLimitMs, debug) {
105
+ const now = Date.now();
106
+ const rateKey = eventData["browser.do11y.scroll.threshold"] !== null && eventData["browser.do11y.scroll.threshold"] !== void 0 ? `${eventName}:${String(eventData[ATTR_DO11Y_SCROLL_THRESHOLD])}` : eventName;
107
+ if (rateLimitMs > 0 && lastEventTime[rateKey]) {
108
+ if (now - lastEventTime[rateKey] < rateLimitMs) {
109
+ if (debug) console.log("[Do11y] Rate limited:", eventName);
110
+ return false;
111
+ }
112
+ }
113
+ lastEventTime[rateKey] = now;
114
+ return true;
115
+ },
116
+ reset() {
117
+ for (const key of Object.keys(lastEventTime)) delete lastEventTime[key];
118
+ }
119
+ };
120
+ }
121
+ //#endregion
122
+ //#region src/core/presets.ts
124
123
  const FRAMEWORK_PRESETS = {
125
124
  mintlify: {
126
125
  searchSelector: "#search-bar-entry, #search-bar-entry-mobile, [class*=\"search\"]",
@@ -200,23 +199,12 @@
200
199
  feedbackSelector: ".feedback--answer, [class*=\"feedback\"], [class*=\"helpful\"]"
201
200
  }
202
201
  };
203
- const SELECTOR_KEYS = [
204
- "searchSelector",
205
- "copyButtonSelector",
206
- "codeBlockSelector",
207
- "navigationSelector",
208
- "footerSelector",
209
- "contentSelector",
210
- "tabContainerSelector",
211
- "tocSelector",
212
- "feedbackSelector"
213
- ];
214
202
  /**
215
203
  * Apply framework-specific selectors to the config.
216
204
  * For 'custom', uses whatever the user set in config; for named
217
205
  * frameworks, loads the preset and lets explicit config values override.
218
206
  */
219
- function applyFrameworkSelectors() {
207
+ function applyFrameworkSelectors(config) {
220
208
  const preset = FRAMEWORK_PRESETS[config.framework];
221
209
  if (preset) SELECTOR_KEYS.forEach((key) => {
222
210
  if (!config[key]) config[key] = preset[key];
@@ -230,22 +218,8 @@
230
218
  if (!config[key]) config[key] = fallback[key];
231
219
  });
232
220
  }
233
- function shouldDisableTracking() {
234
- if (config.respectDNT && (navigator.doNotTrack === "1" || navigator.doNotTrack === "yes" || window.doNotTrack === "1")) {
235
- if (config.debug) console.log("[Do11y] Disabled: Do Not Track is enabled");
236
- return true;
237
- }
238
- if (config.allowedDomains && config.allowedDomains.length > 0) {
239
- const currentDomain = window.location.hostname;
240
- if (!config.allowedDomains.some((domain) => {
241
- return currentDomain === domain || currentDomain.endsWith("." + domain);
242
- })) {
243
- if (config.debug) console.log("[Do11y] Disabled: Domain not allowed:", currentDomain);
244
- return true;
245
- }
246
- }
247
- return false;
248
- }
221
+ //#endregion
222
+ //#region src/core/privacy.ts
249
223
  /**
250
224
  * Validate a CSS selector string supplied through user configuration.
251
225
  * Returns the selector unchanged if it is syntactically valid, or null
@@ -259,10 +233,27 @@
259
233
  document.querySelector(selector);
260
234
  return selector;
261
235
  } catch {
262
- if (config.debug) console.warn("[Do11y] Invalid CSS selector rejected:", selector);
263
236
  return null;
264
237
  }
265
238
  }
239
+ function shouldDisableTracking(config) {
240
+ if (config.respectDNT && (navigator.doNotTrack === "1" || navigator.doNotTrack === "yes" || window.doNotTrack === "1")) {
241
+ if (config.debug) console.log("[Do11y] Disabled: Do Not Track is enabled");
242
+ return true;
243
+ }
244
+ if (config.allowedDomains && config.allowedDomains.length > 0) {
245
+ const currentDomain = window.location.hostname;
246
+ if (!config.allowedDomains.some((domain) => {
247
+ return currentDomain === domain || currentDomain.endsWith("." + domain);
248
+ })) {
249
+ if (config.debug) console.log("[Do11y] Disabled: Domain not allowed:", currentDomain);
250
+ return true;
251
+ }
252
+ }
253
+ return false;
254
+ }
255
+ //#endregion
256
+ //#region src/core/dom-utils.ts
266
257
  function getElementClassName(el) {
267
258
  if (typeof el.className === "string") return el.className;
268
259
  const svgClass = el.className;
@@ -311,12 +302,41 @@
311
302
  if (!pathPart || pathPart === window.location.pathname || pathPart === `${window.location.pathname}${window.location.search}`) return href.slice(hashIndex);
312
303
  return null;
313
304
  }
314
- function resolveTocContainer(link) {
315
- const selector = validateSelector(config.tocSelector) ?? ".table-of-contents, .VPDocAsideOutline, .VPLocalNavOutlineDropdown, [class*=\"toc\"], [class*=\"TableOfContents\"], [class*=\"page-outline\"], .right-sidebar-panel, starlight-toc";
316
- let container = link.closest(selector);
317
- if (!container) return null;
318
- if (container === link || container.tagName === "A") container = link.closest(".VPDocAsideOutline, .VPLocalNavOutlineDropdown, nav, aside, .right-sidebar-panel, starlight-toc") ?? container.parentElement;
319
- return container;
305
+ function resolveTocContainer(link, config) {
306
+ const userSelector = validateSelector(config.tocSelector);
307
+ if (userSelector) {
308
+ const container = link.closest(userSelector);
309
+ if (container && container !== link && container.tagName !== "A") return container;
310
+ }
311
+ for (const sel of [
312
+ ".VPDocAsideOutline",
313
+ ".VPLocalNavOutlineDropdown",
314
+ ".table-of-contents",
315
+ ".right-sidebar-panel",
316
+ "starlight-toc",
317
+ "[class*=\"TableOfContents\"]",
318
+ "[class*=\"page-outline\"]",
319
+ "[class*=\"toc\"]",
320
+ "nav[id=\"TableOfContents\"]"
321
+ ]) {
322
+ const container = link.closest(sel);
323
+ if (container && container !== link && container.tagName !== "A") return container;
324
+ }
325
+ return link.parentElement && link.parentElement !== document.body ? link.parentElement : null;
326
+ }
327
+ function getNearestHeading(element) {
328
+ let current = element;
329
+ while (current && current !== document.body) {
330
+ let sibling = current.previousElementSibling;
331
+ while (sibling) {
332
+ if (/^H[1-6]$/.test(sibling.tagName)) return sibling.textContent?.trim().substring(0, 100) ?? null;
333
+ const headings = sibling.querySelectorAll("h1, h2, h3, h4, h5, h6");
334
+ if (headings.length > 0) return headings[headings.length - 1].textContent?.trim().substring(0, 100) ?? null;
335
+ sibling = sibling.previousElementSibling;
336
+ }
337
+ current = current.parentElement;
338
+ }
339
+ return null;
320
340
  }
321
341
  function sanitizeText(text, maxLength) {
322
342
  if (!text || typeof text !== "string") return null;
@@ -331,71 +351,8 @@
331
351
  sanitized = sanitized.replace(/\b[0-9a-fA-F]{32,}\b/g, "[redacted]");
332
352
  return sanitized.trim().substring(0, limit);
333
353
  }
334
- function generateSessionId() {
335
- if (window.crypto && typeof window.crypto.randomUUID === "function") return window.crypto.randomUUID();
336
- if (window.crypto && typeof window.crypto.getRandomValues === "function") {
337
- const arr = new Uint8Array(16);
338
- window.crypto.getRandomValues(arr);
339
- arr[6] = arr[6] & 15 | 64;
340
- arr[8] = arr[8] & 63 | 128;
341
- const hex = Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
342
- return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20);
343
- }
344
- return "no-crypto-00-0000-0000-000000000000";
345
- }
346
- function isValidSessionData(value) {
347
- if (!value || typeof value !== "object") return false;
348
- const v = value;
349
- return typeof v.id === "string" && v.id.length > 0 && typeof v.startTime === "string" && Array.isArray(v.pageSequence) && typeof v.pageCount === "number";
350
- }
351
- function getSession() {
352
- let session = null;
353
- try {
354
- const stored = sessionStorage.getItem("do11y_session");
355
- if (stored) {
356
- const parsed = JSON.parse(stored);
357
- if (isValidSessionData(parsed)) session = parsed;
358
- }
359
- } catch {}
360
- if (!session) {
361
- session = {
362
- id: generateSessionId(),
363
- startTime: (/* @__PURE__ */ new Date()).toISOString(),
364
- pageSequence: [],
365
- pageCount: 0,
366
- referrerCategory: null,
367
- aiPlatform: null
368
- };
369
- saveSession(session);
370
- }
371
- return session;
372
- }
373
- function saveSession(session) {
374
- try {
375
- sessionStorage.setItem("do11y_session", JSON.stringify(session));
376
- } catch {}
377
- }
378
- function updatePageSequence(path) {
379
- const session = getSession();
380
- session.pageCount++;
381
- session.pageSequence.push({
382
- path,
383
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
384
- index: session.pageCount
385
- });
386
- if (session.pageSequence.length > 50) session.pageSequence = session.pageSequence.slice(-50);
387
- saveSession(session);
388
- return session;
389
- }
390
- function getBrowserContext() {
391
- return {
392
- [ATTR_DO11Y_VIEWPORT_CATEGORY]: categorizeViewport(),
393
- [ATTR_BROWSER_FAMILY]: getBrowserFamily(),
394
- [ATTR_DEVICE_TYPE]: getDeviceType(),
395
- [ATTR_BROWSER_LANGUAGE]: (navigator.language || "").split("-")[0] || "unknown",
396
- [ATTR_DO11Y_TIMEZONE_OFFSET]: (/* @__PURE__ */ new Date()).getTimezoneOffset() / 60
397
- };
398
- }
354
+ //#endregion
355
+ //#region src/core/context.ts
399
356
  function categorizeViewport() {
400
357
  const width = window.innerWidth;
401
358
  if (width < 640) return "mobile";
@@ -419,6 +376,15 @@
419
376
  }
420
377
  return "desktop";
421
378
  }
379
+ function getBrowserContext() {
380
+ return {
381
+ [ATTR_DO11Y_VIEWPORT_CATEGORY]: categorizeViewport(),
382
+ [ATTR_BROWSER_FAMILY]: getBrowserFamily(),
383
+ [ATTR_DEVICE_TYPE]: getDeviceType(),
384
+ [ATTR_BROWSER_LANGUAGE]: (navigator.language || "").split("-")[0] || "unknown",
385
+ [ATTR_DO11Y_TIMEZONE_OFFSET]: (/* @__PURE__ */ new Date()).getTimezoneOffset() / 60
386
+ };
387
+ }
422
388
  /**
423
389
  * Known AI platform referrer patterns.
424
390
  * Each entry maps a substring found in the referrer hostname to an AI
@@ -544,276 +510,318 @@
544
510
  return {
545
511
  [ATTR_URL_PATH]: window.location.pathname,
546
512
  [ATTR_URL_FRAGMENT]: window.location.hash || null,
547
- [ATTR_URL_QUERY]: window.location.search ? "has_params" : null,
513
+ [ATTR_DO11Y_URL_HAS_PARAMS]: window.location.search ? "has_params" : null,
548
514
  [ATTR_DO11Y_PAGE_TITLE]: sanitizeText(document.title, 150)
549
515
  };
550
516
  }
551
- let eventQueue = [];
552
- let flushTimeout = null;
553
- const lastEventTime = {};
554
- let isDisabled = false;
555
- function queueEvent(eventName, eventData) {
556
- if (isDisabled) return;
557
- const now = Date.now();
558
- if (config.rateLimitMs > 0 && lastEventTime[eventName]) {
559
- if (now - lastEventTime[eventName] < config.rateLimitMs) {
560
- if (config.debug) console.log("[Do11y] Rate limited:", eventName);
561
- return;
562
- }
563
- }
564
- lastEventTime[eventName] = now;
565
- const session = getSession();
566
- const event = {
567
- _time: (/* @__PURE__ */ new Date()).toISOString(),
568
- eventName,
569
- [ATTR_DO11Y_DO11Y_VERSION]: VERSION,
570
- [ATTR_SESSION_ID]: session.id,
571
- [ATTR_DO11Y_SESSION_PAGE_COUNT]: session.pageCount,
572
- ...getPageInfo(),
573
- ...getBrowserContext(),
574
- ...eventData
575
- };
576
- if (config.testRunId) event._testRunId = config.testRunId;
577
- if (config.testFramework) event._testFramework = config.testFramework;
578
- if (config.debug) console.log("[Do11y] Event queued:", eventName, event);
579
- if (config.destination === "otlp" && _otelLogger) {
580
- _otelLogger.emit({
581
- eventName,
582
- severityNumber: 9,
583
- attributes: event,
584
- body: ""
585
- });
586
- return;
587
- }
588
- eventQueue.push(event);
589
- if (eventQueue.length > 100) {
590
- eventQueue = eventQueue.slice(-100);
591
- if (config.debug) console.warn("[Do11y] Event queue capped at 100 events");
517
+ //#endregion
518
+ //#region src/core/session.ts
519
+ function generateSessionId() {
520
+ if (window.crypto && typeof window.crypto.randomUUID === "function") return window.crypto.randomUUID();
521
+ if (window.crypto && typeof window.crypto.getRandomValues === "function") {
522
+ const arr = new Uint8Array(16);
523
+ window.crypto.getRandomValues(arr);
524
+ arr[6] = arr[6] & 15 | 64;
525
+ arr[8] = arr[8] & 63 | 128;
526
+ const hex = Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
527
+ return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20);
592
528
  }
593
- if (eventQueue.length >= config.maxBatchSize) flush();
594
- else scheduleFlush();
529
+ return "no-crypto-00-0000-0000-000000000000";
595
530
  }
596
- function scheduleFlush() {
597
- if (flushTimeout) return;
598
- flushTimeout = setTimeout(flush, config.flushInterval);
531
+ function isValidSessionData(value) {
532
+ if (!value || typeof value !== "object") return false;
533
+ const v = value;
534
+ return typeof v.id === "string" && v.id.length > 0 && typeof v.startTime === "string" && Array.isArray(v.pageSequence) && typeof v.pageCount === "number";
599
535
  }
600
- let _otelLogger = null;
601
- function validateSupabaseUrl(url) {
602
- try {
603
- const parsed = new URL(url);
604
- if (parsed.protocol !== "https:") return false;
605
- if (!parsed.hostname.endsWith(".supabase.co")) return false;
606
- return true;
607
- } catch {
608
- return false;
536
+ function getSession() {
537
+ let session = null;
538
+ try {
539
+ const stored = sessionStorage.getItem("do11y_session");
540
+ if (stored) {
541
+ const parsed = JSON.parse(stored);
542
+ if (isValidSessionData(parsed)) session = parsed;
543
+ }
544
+ } catch {}
545
+ if (!session) {
546
+ session = {
547
+ id: generateSessionId(),
548
+ startTime: (/* @__PURE__ */ new Date()).toISOString(),
549
+ pageSequence: [],
550
+ pageCount: 0,
551
+ referrerCategory: null,
552
+ aiPlatform: null
553
+ };
554
+ saveSession(session);
609
555
  }
556
+ return session;
610
557
  }
611
- function validateEndpoint(url) {
558
+ function saveSession(session) {
612
559
  try {
613
- const parsed = new URL(url);
614
- if (parsed.protocol !== "https:") return false;
615
- const host = parsed.hostname;
616
- if (host === "localhost" || host === "127.0.0.1" || host === "::1") return false;
617
- if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host)) return false;
618
- return true;
619
- } catch {
620
- return false;
560
+ sessionStorage.setItem("do11y_session", JSON.stringify(session));
561
+ } catch {}
562
+ }
563
+ function updatePageSequence(path) {
564
+ const session = getSession();
565
+ session.pageCount++;
566
+ session.pageSequence.push({
567
+ path,
568
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
569
+ index: session.pageCount
570
+ });
571
+ if (session.pageSequence.length > 50) session.pageSequence = session.pageSequence.slice(-50);
572
+ saveSession(session);
573
+ return session;
574
+ }
575
+ //#endregion
576
+ //#region src/core/tracking/scroll.ts
577
+ let trackedScrollDepths = /* @__PURE__ */ new Set();
578
+ let scrollContainer = null;
579
+ function findScrollableAncestor(el) {
580
+ let current = el;
581
+ while (current && current !== document.body && current !== document.documentElement) {
582
+ const overflowY = window.getComputedStyle(current).overflowY;
583
+ if ((overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight) return current;
584
+ current = current.parentElement;
621
585
  }
586
+ return null;
622
587
  }
623
- function validateConfig() {
624
- if (config.destination === "supabase") {
625
- if (!config.supabaseUrl) {
626
- if (config.debug) console.warn("[Do11y] No Supabase URL configured");
627
- return false;
628
- }
629
- if (!validateSupabaseUrl(config.supabaseUrl)) {
630
- if (config.debug) console.warn("[Do11y] Invalid Supabase URL. Must be https://<project>.supabase.co");
631
- return false;
632
- }
633
- if (!config.supabaseKey || typeof config.supabaseKey !== "string" || config.supabaseKey.length < 10) {
634
- if (config.debug) console.warn("[Do11y] Invalid or missing Supabase publishable key");
635
- return false;
636
- }
637
- if (!/^[a-zA-Z0-9_-]+$/.test(config.supabaseTable)) {
638
- if (config.debug) console.warn("[Do11y] Invalid table name");
639
- return false;
640
- }
641
- return true;
588
+ /**
589
+ * Check and track scroll depth thresholds.
590
+ * Reads from the detected scroll container when present, otherwise
591
+ * falls back to the window/document.
592
+ *
593
+ * If the page fits entirely in the viewport (no scrollbar), all
594
+ * thresholds are marked as reached since the user can see 100% of
595
+ * the content without scrolling.
596
+ */
597
+ function checkScrollDepth(config, emit) {
598
+ let scrollTop;
599
+ let totalHeight;
600
+ let viewportHeight;
601
+ if (scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight) {
602
+ scrollTop = scrollContainer.scrollTop;
603
+ totalHeight = scrollContainer.scrollHeight;
604
+ viewportHeight = scrollContainer.clientHeight;
605
+ } else {
606
+ scrollTop = window.scrollY || document.documentElement.scrollTop;
607
+ totalHeight = document.documentElement.scrollHeight;
608
+ viewportHeight = window.innerHeight;
642
609
  }
643
- if (config.destination === "http") {
644
- if (!config.endpoint) {
645
- if (config.debug) console.warn("[Do11y] No HTTP endpoint configured");
646
- return false;
610
+ const docHeight = totalHeight - viewportHeight;
611
+ if (docHeight <= 0) {
612
+ config.scrollThresholds.forEach((threshold) => {
613
+ if (!trackedScrollDepths.has(threshold)) {
614
+ trackedScrollDepths.add(threshold);
615
+ emit(EVENT_SCROLL_DEPTH, {
616
+ [ATTR_DO11Y_SCROLL_THRESHOLD]: threshold,
617
+ [ATTR_DO11Y_SCROLL_PERCENT]: 100
618
+ });
619
+ }
620
+ });
621
+ return;
622
+ }
623
+ const scrollPercent = Math.round(scrollTop / docHeight * 100);
624
+ config.scrollThresholds.forEach((threshold) => {
625
+ if (scrollPercent >= threshold && !trackedScrollDepths.has(threshold)) {
626
+ trackedScrollDepths.add(threshold);
627
+ emit(EVENT_SCROLL_DEPTH, {
628
+ [ATTR_DO11Y_SCROLL_THRESHOLD]: threshold,
629
+ [ATTR_DO11Y_SCROLL_PERCENT]: scrollPercent
630
+ });
647
631
  }
648
- if (!validateEndpoint(config.endpoint)) {
649
- if (config.debug) console.warn("[Do11y] Invalid HTTP endpoint. Must be HTTPS and not a private address.");
650
- return false;
632
+ });
633
+ }
634
+ function setupScrollTracking(config, emit) {
635
+ if (!config.trackScrollDepth) return;
636
+ if (config.contentSelector) {
637
+ const contentEl = document.querySelector(config.contentSelector);
638
+ if (contentEl) scrollContainer = findScrollableAncestor(contentEl);
639
+ }
640
+ let ticking = false;
641
+ function onScroll() {
642
+ if (!ticking) {
643
+ window.requestAnimationFrame(() => {
644
+ checkScrollDepth(config, emit);
645
+ ticking = false;
646
+ });
647
+ ticking = true;
651
648
  }
652
- return true;
653
649
  }
654
- if (config.destination === "otlp") {
655
- if (!config.otelSdkEndpoint) {
656
- if (config.debug) console.warn("[Do11y] No OTLP endpoint configured");
657
- return false;
650
+ window.addEventListener("scroll", onScroll);
651
+ if (scrollContainer) {
652
+ scrollContainer.addEventListener("scroll", onScroll);
653
+ if (config.debug) {
654
+ const sc = scrollContainer;
655
+ console.log("[do11y] Using container-based scroll tracking:", sc.className || sc.tagName);
658
656
  }
659
- initOtelSdk().catch((err) => {
660
- if (config.debug) console.warn("[Do11y] OTel SDK initialization failed:", err);
661
- });
662
- return true;
663
657
  }
664
- if (config.debug) console.warn("[Do11y] Unknown destination:", config.destination);
665
- return false;
658
+ checkScrollDepth(config, emit);
666
659
  }
667
- /**
668
- * Dynamically import the OTel Browser SDK and set up the LoggerProvider.
669
- * Only called when destination is 'otlp'.
670
- */
671
- async function initOtelSdk() {
672
- if (_otelLogger) return;
673
- const cdnBase = config.otelSdkCdnUrl.replace(/\/+$/, "") + "/";
674
- const apiLogs = await import(
675
- /* @vite-ignore */
676
- `${cdnBase}@opentelemetry/api-logs`
677
- );
678
- const sdkLogs = await import(
679
- /* @vite-ignore */
680
- `${cdnBase}@opentelemetry/sdk-logs`
681
- );
682
- const otlpExporter = await import(
683
- /* @vite-ignore */
684
- `${cdnBase}@opentelemetry/exporter-logs-otlp-http`
685
- );
686
- const resourceAttrs = {
687
- "service.name": config.otelSdkServiceName || "do11y",
688
- "service.version": VERSION,
689
- "telemetry.sdk.name": "do11y",
690
- "telemetry.sdk.language": "webjs",
691
- "telemetry.sdk.version": VERSION,
692
- ...config.otelSdkResourceAttributes
693
- };
694
- const loggerProvider = new sdkLogs.LoggerProvider({
695
- resource: { attributes: resourceAttrs },
696
- processors: [new sdkLogs.BatchLogRecordProcessor({ exporter: new otlpExporter.OTLPLogExporter({
697
- url: config.otelSdkEndpoint.replace(/\/$/, "") + "/v1/logs",
698
- headers: config.otelSdkHeaders
699
- }) })]
660
+ function resetTrackedScrollDepths() {
661
+ trackedScrollDepths = /* @__PURE__ */ new Set();
662
+ }
663
+ function getTrackedScrollDepths() {
664
+ return trackedScrollDepths;
665
+ }
666
+ //#endregion
667
+ //#region src/core/tracking/sections.ts
668
+ function emitSectionEvent(emit, el, elapsedMs) {
669
+ emit(EVENT_SECTION_VISIBLE, {
670
+ [ATTR_DO11Y_SECTION_HEADING]: sanitizeText(el.textContent?.trim() ?? "", 100),
671
+ [ATTR_DO11Y_SECTION_HEADING_LEVEL]: parseInt(el.tagName.charAt(1), 10),
672
+ [ATTR_DO11Y_SECTION_VISIBLE_SECONDS]: Math.round(elapsedMs / 1e3)
700
673
  });
701
- apiLogs.logs.setGlobalLoggerProvider(loggerProvider);
702
- _otelLogger = loggerProvider.getLogger("do11y");
703
- if (config.debug) console.log("[Do11y] OTel SDK initialized with endpoint:", config.otelSdkEndpoint);
704
674
  }
705
- function buildRequest(events) {
706
- if (config.destination === "supabase") {
707
- const url = config.supabaseUrl.replace(/\/$/, "") + "/rest/v1/" + config.supabaseTable;
708
- const bodyTransform = config.bodyTransform ?? ((evts) => evts.map((e) => ({ payload: e })));
709
- return {
710
- url,
711
- headers: {
712
- "apikey": config.supabaseKey,
713
- "Authorization": "Bearer " + config.supabaseKey,
714
- "Content-Type": "application/json",
715
- "Prefer": "return=minimal"
716
- },
717
- body: JSON.stringify(bodyTransform(events))
718
- };
719
- }
720
- const bodyTransform = config.bodyTransform ?? ((evts) => evts);
721
- return {
722
- url: config.endpoint,
723
- headers: {
724
- "Content-Type": "application/json",
725
- ...config.headers
726
- },
727
- body: JSON.stringify(bodyTransform(events))
728
- };
675
+ let sectionObserver = null;
676
+ let sectionTimers = {};
677
+ function setupSectionVisibilityTracking(config, emit) {
678
+ if (!config.trackSectionVisibility) return;
679
+ if (typeof IntersectionObserver === "undefined") return;
680
+ const threshold = config.sectionVisibleThreshold * 1e3;
681
+ sectionObserver = new IntersectionObserver((entries) => {
682
+ entries.forEach((entry) => {
683
+ const id = entry.target.getAttribute("data-do11y-section-id");
684
+ if (!id) return;
685
+ if (entry.isIntersecting) {
686
+ if (!sectionTimers[id]) {
687
+ const timer = {
688
+ start: Date.now(),
689
+ reported: false,
690
+ timeoutId: null
691
+ };
692
+ timer.timeoutId = setTimeout(() => {
693
+ if (sectionTimers[id] && !sectionTimers[id].reported) {
694
+ emitSectionEvent(emit, entry.target, threshold);
695
+ sectionTimers[id].reported = true;
696
+ }
697
+ }, threshold);
698
+ sectionTimers[id] = timer;
699
+ }
700
+ } else {
701
+ if (sectionTimers[id]) {
702
+ if (sectionTimers[id].timeoutId) clearTimeout(sectionTimers[id].timeoutId);
703
+ if (!sectionTimers[id].reported) {
704
+ const elapsed = Date.now() - sectionTimers[id].start;
705
+ if (elapsed >= threshold) {
706
+ emitSectionEvent(emit, entry.target, elapsed);
707
+ sectionTimers[id].reported = true;
708
+ }
709
+ }
710
+ }
711
+ delete sectionTimers[id];
712
+ }
713
+ });
714
+ }, { threshold: .5 });
715
+ observeHeadings();
729
716
  }
730
- function flush(retriesLeft) {
731
- if (flushTimeout) {
732
- clearTimeout(flushTimeout);
733
- flushTimeout = null;
717
+ function observeHeadings() {
718
+ if (!sectionObserver) return;
719
+ document.querySelectorAll("h2, h3").forEach((h, i) => {
720
+ h.setAttribute("data-do11y-section-id", "section-" + i);
721
+ sectionObserver.observe(h);
722
+ });
723
+ }
724
+ function flushVisibleSections(config, emit) {
725
+ if (!sectionObserver) return;
726
+ const now = Date.now();
727
+ const threshold = config.sectionVisibleThreshold * 1e3;
728
+ Object.keys(sectionTimers).forEach((id) => {
729
+ const timer = sectionTimers[id];
730
+ if (timer && !timer.reported) {
731
+ if (timer.timeoutId) clearTimeout(timer.timeoutId);
732
+ const elapsed = now - timer.start;
733
+ if (elapsed >= threshold) {
734
+ const escapedId = typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(id) : id.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~ ]/g, "\\$&");
735
+ const el = document.querySelector("[data-do11y-section-id=\"" + escapedId + "\"]");
736
+ if (el) emitSectionEvent(emit, el, elapsed);
737
+ }
738
+ }
739
+ });
740
+ sectionTimers = {};
741
+ }
742
+ function disconnectSectionObserver() {
743
+ if (sectionObserver) {
744
+ if (sectionTimers && Object.keys(sectionTimers).length > 0) {
745
+ Object.keys(sectionTimers).forEach((id) => {
746
+ const timer = sectionTimers[id];
747
+ if (timer && !timer.reported) {
748
+ if (timer.timeoutId) clearTimeout(timer.timeoutId);
749
+ }
750
+ });
751
+ sectionTimers = {};
752
+ }
753
+ sectionObserver.disconnect();
754
+ sectionObserver = null;
734
755
  }
735
- if (eventQueue.length === 0) return;
736
- if (!validateConfig()) return;
737
- const retries = typeof retriesLeft === "number" ? retriesLeft : config.maxRetries;
738
- const events = eventQueue.slice();
739
- eventQueue = [];
740
- sendEvents(buildRequest(events), events, retries);
741
756
  }
757
+ //#endregion
758
+ //#region src/core/tracking/engagement.ts
759
+ let pageLoadTime = Date.now();
760
+ let lastActivityTime = Date.now();
761
+ let totalActiveTime = 0;
762
+ let isPageVisible = true;
763
+ let pageExited = false;
742
764
  /**
743
- * Check whether a request URL is cross-origin relative to the current page.
765
+ * @param afterEmit Optional callback invoked after the exit event is emitted.
766
+ * Used by the standalone build to flush the transport before the page unloads.
744
767
  */
745
- function isCrossOrigin(url) {
746
- try {
747
- return new URL(url).origin !== window.location.origin;
748
- } catch {
749
- return false;
750
- }
768
+ function emitPageExit(config, emit, afterEmit) {
769
+ if (pageExited) return;
770
+ pageExited = true;
771
+ if (isPageVisible) totalActiveTime += Date.now() - lastActivityTime;
772
+ const totalTime = Date.now() - pageLoadTime;
773
+ const engagementRatio = totalTime > 0 ? totalActiveTime / totalTime : 0;
774
+ let maxScroll = 0;
775
+ getTrackedScrollDepths().forEach((depth) => {
776
+ if (depth > maxScroll) maxScroll = depth;
777
+ });
778
+ flushVisibleSections(config, emit);
779
+ const session = getSession();
780
+ emit(EVENT_PAGE_EXIT, {
781
+ [ATTR_DO11Y_TOTAL_TIME_SECONDS]: Math.round(totalTime / 1e3),
782
+ [ATTR_DO11Y_ACTIVE_TIME_SECONDS]: Math.round(totalActiveTime / 1e3),
783
+ [ATTR_DO11Y_ENGAGEMENT_RATIO]: Math.round(engagementRatio * 100) / 100,
784
+ [ATTR_DO11Y_MAX_SCROLL_DEPTH]: maxScroll,
785
+ [ATTR_DO11Y_REFERRER_CATEGORY]: session.referrerCategory,
786
+ [ATTR_DO11Y_AI_PLATFORM]: session.aiPlatform
787
+ });
788
+ afterEmit?.();
751
789
  }
752
- function sendEvents(req, events, retriesLeft) {
753
- const crossOrigin = isCrossOrigin(req.url);
754
- if (config.debug && crossOrigin) console.log("[Do11y] Cross-origin request to", new URL(req.url).origin, "- requires CORS headers on the server");
755
- fetch(req.url, {
756
- method: "POST",
757
- headers: req.headers,
758
- body: req.body,
759
- keepalive: true,
760
- mode: crossOrigin ? "cors" : "same-origin"
761
- }).then((response) => {
762
- if (response.ok) {
763
- if (config.debug) console.log("[Do11y] Flushed", events.length, "events");
764
- return;
765
- }
766
- if (retriesLeft > 0 && (response.status >= 500 || response.status === 429)) {
767
- if (config.debug) console.log("[Do11y] Retrying after error:", response.status);
768
- eventQueue = events.concat(eventQueue);
769
- setTimeout(() => {
770
- flush(retriesLeft - 1);
771
- }, config.retryDelay * (config.maxRetries - retriesLeft + 1));
772
- return;
773
- }
774
- if (config.debug) response.text().then((text) => {
775
- const msg = `[Do11y] Ingest failed: ${response.status}`;
776
- if (response.status === 0 && response.type === "opaque") console.error(msg, "- CORS error: server did not return Access-Control-Allow-Origin");
777
- else console.error(msg, text);
778
- }).catch(() => {});
779
- }).catch((err) => {
780
- if (retriesLeft > 0) {
781
- if (config.debug) {
782
- const hint = crossOrigin ? " (this may be a CORS issue — try using an OTel Collector proxy)" : "";
783
- console.log("[Do11y] Network error, retrying:", err.message + hint);
790
+ function setupEngagementTracking(config, emit) {
791
+ document.addEventListener("visibilitychange", () => {
792
+ if (document.hidden) {
793
+ if (isPageVisible) {
794
+ totalActiveTime += Date.now() - lastActivityTime;
795
+ isPageVisible = false;
784
796
  }
785
- eventQueue = events.concat(eventQueue);
786
- setTimeout(() => {
787
- flush(retriesLeft - 1);
788
- }, config.retryDelay * (config.maxRetries - retriesLeft + 1));
789
- } else if (config.debug) console.error("[Do11y] Failed to send events:", err.message);
797
+ } else {
798
+ lastActivityTime = Date.now();
799
+ isPageVisible = true;
800
+ }
801
+ });
802
+ window.addEventListener("beforeunload", () => {
803
+ emitPageExit(config, emit);
790
804
  });
791
805
  }
806
+ function resetEngagementState() {
807
+ pageLoadTime = Date.now();
808
+ lastActivityTime = Date.now();
809
+ totalActiveTime = 0;
810
+ isPageVisible = true;
811
+ pageExited = false;
812
+ }
792
813
  /**
793
- * Synchronous flush used on `beforeunload`. For OTLP mode the SDK
794
- * handles flush on its own; for HTTP/Supabase we use fetch with keepalive.
795
- * sendBeacon is not used because Supabase requires custom headers
796
- * (apikey, Authorization) which sendBeacon does not support.
814
+ * Reset only the page_exit guard flag, without affecting timing data.
815
+ * Called by trackPageView() so that the guard is cleared even if
816
+ * resetEngagementState() (which also resets it) was not invoked.
797
817
  */
798
- function flushSync() {
799
- if (config.destination === "otlp") return;
800
- if (eventQueue.length === 0) return;
801
- if (!validateConfig()) return;
802
- const events = eventQueue;
803
- eventQueue = [];
804
- const req = buildRequest(events);
805
- try {
806
- fetch(req.url, {
807
- method: "POST",
808
- headers: req.headers,
809
- body: req.body,
810
- keepalive: true
811
- });
812
- } catch {}
813
- if (config.debug) console.log("[Do11y] Sync flushed", events.length, "events");
814
- }
815
- function trackPageView() {
818
+ function resetPageExitedGuard() {
816
819
  pageExited = false;
820
+ }
821
+ //#endregion
822
+ //#region src/core/tracking/page-view.ts
823
+ function trackPageView(config, emit) {
824
+ resetPageExitedGuard();
817
825
  const session = updatePageSequence(window.location.pathname);
818
826
  const referrerDomain = getReferrerDomain();
819
827
  const referrerInfo = classifyReferrer(referrerDomain);
@@ -822,7 +830,7 @@
822
830
  session.aiPlatform = referrerInfo.aiPlatform;
823
831
  saveSession(session);
824
832
  }
825
- queueEvent(EVENT_PAGE_VIEW, {
833
+ emit(EVENT_PAGE_VIEW, {
826
834
  [ATTR_DO11Y_REFERRER_DOMAIN]: referrerDomain,
827
835
  [ATTR_DO11Y_REFERRER_CATEGORY]: referrerInfo.referrerCategory,
828
836
  [ATTR_DO11Y_AI_PLATFORM]: referrerInfo.aiPlatform,
@@ -830,7 +838,37 @@
830
838
  [ATTR_DO11Y_PREVIOUS_PATH]: session.pageSequence.length > 1 ? session.pageSequence[session.pageSequence.length - 2].path : null
831
839
  });
832
840
  }
833
- function setupLinkTracking() {
841
+ //#endregion
842
+ //#region src/core/tracking/links.ts
843
+ function getLinkContext(link, config) {
844
+ if (link.closest(config.navigationSelector)) return "navigation";
845
+ if (link.closest(config.footerSelector)) return "footer";
846
+ if (link.closest(config.contentSelector)) return "content";
847
+ return "other";
848
+ }
849
+ /**
850
+ * Pre-compute same-href indices for all `<a>` elements on the page.
851
+ * This avoids O(n) querySelectorAll calls on every click.
852
+ * Data attributes are set at init time and read directly on click.
853
+ */
854
+ function precomputeLinkIndices() {
855
+ try {
856
+ const linkGroups = /* @__PURE__ */ new Map();
857
+ document.querySelectorAll("a[href]").forEach((link) => {
858
+ const href = link.getAttribute("href") ?? "";
859
+ const group = linkGroups.get(href) ?? [];
860
+ group.push(link);
861
+ linkGroups.set(href, group);
862
+ });
863
+ linkGroups.forEach((links) => {
864
+ links.forEach((link, idx) => {
865
+ link.setAttribute("data-do11y-link-idx", String(idx + 1));
866
+ });
867
+ });
868
+ } catch {}
869
+ }
870
+ function setupLinkTracking(config, emit) {
871
+ precomputeLinkIndices();
834
872
  document.addEventListener("click", (e) => {
835
873
  const link = e.target.closest("a");
836
874
  if (!link) return;
@@ -852,391 +890,562 @@
852
890
  } catch {}
853
891
  if (linkType === "internal" && !config.trackInternalLinks) return;
854
892
  if (linkType === "external" && !config.trackOutboundLinks) return;
855
- queueEvent(EVENT_LINK_CLICK, {
893
+ const linkIndex = parseInt(link.getAttribute("data-do11y-link-idx") ?? "1", 10);
894
+ emit(EVENT_LINK_CLICK, {
856
895
  [ATTR_DO11Y_LINK_TYPE]: linkType,
857
896
  [ATTR_DO11Y_LINK_TARGET_URL]: href,
858
897
  [ATTR_DO11Y_LINK_TARGET_DOMAIN]: targetDomain,
859
898
  [ATTR_DO11Y_LINK_TEXT]: sanitizeText(link.textContent, 100),
860
- [ATTR_DO11Y_LINK_CONTEXT]: getLinkContext(link),
899
+ [ATTR_DO11Y_LINK_CONTEXT]: getLinkContext(link, config),
861
900
  [ATTR_DO11Y_LINK_SECTION]: sanitizeText(getNearestHeading(link), 100),
862
- [ATTR_DO11Y_LINK_INDEX]: getLinkIndex(link, href)
901
+ [ATTR_DO11Y_LINK_INDEX]: linkIndex
863
902
  });
864
- flush();
865
903
  }, true);
866
904
  }
867
- function getLinkContext(link) {
868
- if (link.closest(config.navigationSelector)) return "navigation";
869
- if (link.closest(config.footerSelector)) return "footer";
870
- if (link.closest(config.contentSelector)) return "content";
871
- return "other";
905
+ //#endregion
906
+ //#region src/core/tracking/search.ts
907
+ function setupSearchTracking(config, emit) {
908
+ if (!config.trackSearch) return;
909
+ document.addEventListener("click", (e) => {
910
+ if (e.target.closest(config.searchSelector)) emit(EVENT_SEARCH_OPENED, {});
911
+ }, true);
912
+ document.addEventListener("keydown", (e) => {
913
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
914
+ if (!document.querySelector(config.searchSelector)) return;
915
+ emit(EVENT_SEARCH_OPENED, { [ATTR_DO11Y_SEARCH_TRIGGER]: "keyboard" });
916
+ }
917
+ });
872
918
  }
873
- function getNearestHeading(element) {
874
- let current = element;
875
- while (current && current !== document.body) {
876
- let sibling = current.previousElementSibling;
877
- while (sibling) {
878
- if (/^H[1-6]$/.test(sibling.tagName)) return sibling.textContent?.trim().substring(0, 100) ?? null;
879
- const headings = sibling.querySelectorAll("h1, h2, h3, h4, h5, h6");
880
- if (headings.length > 0) return headings[headings.length - 1].textContent?.trim().substring(0, 100) ?? null;
881
- sibling = sibling.previousElementSibling;
919
+ //#endregion
920
+ //#region src/core/tracking/copy.ts
921
+ /**
922
+ * Pre-compute code block indices at init time to avoid O(n) querySelectorAll
923
+ * calls on every copy button click. Elements are assigned a
924
+ * data-do11y-code-idx attribute read directly on click.
925
+ */
926
+ function precomputeCodeBlockIndices(config) {
927
+ try {
928
+ document.querySelectorAll(config.codeBlockSelector).forEach((block, idx) => {
929
+ block.setAttribute("data-do11y-code-idx", String(idx + 1));
930
+ });
931
+ } catch {}
932
+ }
933
+ function setupCopyTracking(config, emit) {
934
+ if (!config.trackCopy) return;
935
+ precomputeCodeBlockIndices(config);
936
+ document.addEventListener("click", (e) => {
937
+ const copyButton = e.target.closest(config.copyButtonSelector);
938
+ if (copyButton) {
939
+ const codeBlock = copyButton.closest("[class*=\"language-\"], [language]") ?? copyButton.closest(config.codeBlockSelector) ?? copyButton.closest(".expressive-code")?.querySelector("pre") ?? copyButton.closest("div, section")?.querySelector("pre") ?? copyButton.parentElement?.querySelector("pre") ?? null;
940
+ const language = extractCodeLanguage((codeBlock ? codeBlock.tagName === "PRE" ? codeBlock.querySelector("code") : codeBlock.querySelector("code[class*=\"language-\"], code[language]") ?? codeBlock.querySelector("code") : null) ?? codeBlock ?? copyButton);
941
+ const codeIndex = parseInt(codeBlock?.getAttribute("data-do11y-code-idx") ?? "1", 10);
942
+ emit(EVENT_CODE_COPIED, {
943
+ [ATTR_DO11Y_CODE_LANGUAGE]: language,
944
+ [ATTR_DO11Y_CODE_SECTION]: sanitizeText(getNearestHeading(codeBlock ?? copyButton), 100),
945
+ [ATTR_DO11Y_CODE_INDEX]: codeIndex
946
+ });
947
+ }
948
+ }, true);
949
+ }
950
+ //#endregion
951
+ //#region src/core/tracking/tabs.ts
952
+ function setupTabSwitchTracking(config, emit) {
953
+ if (!config.trackTabSwitches) return;
954
+ document.addEventListener("click", (e) => {
955
+ let baseSel = "[role=\"tab\"], .tabs button, .tabs a, .tabbed-labels label";
956
+ const safeTabSel = validateSelector(config.tabContainerSelector);
957
+ if (safeTabSel) baseSel += ", " + safeTabSel + " button, " + safeTabSel + " a, " + safeTabSel + " label";
958
+ const tab = e.target.closest(baseSel);
959
+ if (!tab) return;
960
+ if (tab.getAttribute("aria-selected") === "true" || tab.classList.contains("active") || tab.classList.contains("is-active")) return;
961
+ const label = sanitizeText(tab.textContent, 50);
962
+ if (!label) return;
963
+ const section = sanitizeText(getNearestHeading(tab), 100);
964
+ emit(EVENT_TAB_SWITCH, {
965
+ [ATTR_DO11Y_TAB_LABEL]: label,
966
+ [ATTR_DO11Y_TAB_GROUP]: section,
967
+ [ATTR_DO11Y_TAB_IS_DEFAULT]: false
968
+ });
969
+ });
970
+ }
971
+ //#endregion
972
+ //#region src/core/tracking/toc.ts
973
+ function setupTocClickTracking(config, emit) {
974
+ if (!config.trackTocClicks) return;
975
+ document.addEventListener("click", (e) => {
976
+ const link = e.target.closest("a");
977
+ if (!link) return;
978
+ const tocContainer = resolveTocContainer(link, config);
979
+ if (!tocContainer) return;
980
+ const href = link.getAttribute("href");
981
+ const hash = href ? resolveTocHash(href) : null;
982
+ if (!hash) return;
983
+ const headingText = sanitizeText(link.textContent, 100);
984
+ let headingLevel = null;
985
+ try {
986
+ const targetId = hash.slice(1);
987
+ const targetEl = document.getElementById(targetId);
988
+ if (targetEl && /^H[1-6]$/.test(targetEl.tagName)) headingLevel = parseInt(targetEl.tagName.charAt(1), 10);
989
+ } catch {}
990
+ const tocLinks = tocContainer.querySelectorAll("a[href*=\"#\"]");
991
+ let tocPosition = 1;
992
+ for (let i = 0; i < tocLinks.length; i++) if (tocLinks[i] === link) {
993
+ tocPosition = i + 1;
994
+ break;
995
+ }
996
+ emit(EVENT_TOC_CLICK, {
997
+ [ATTR_DO11Y_TOC_HEADING]: headingText,
998
+ [ATTR_DO11Y_TOC_HEADING_LEVEL]: headingLevel,
999
+ [ATTR_DO11Y_TOC_POSITION]: tocPosition
1000
+ });
1001
+ }, true);
1002
+ }
1003
+ //#endregion
1004
+ //#region src/core/tracking/feedback.ts
1005
+ function setupFeedbackTracking(config, emit) {
1006
+ if (!config.trackFeedback) return;
1007
+ document.addEventListener("click", (e) => {
1008
+ const button = e.target.closest("button, [role=\"button\"], a");
1009
+ if (!button) return;
1010
+ if (!button.closest(validateSelector(config.feedbackSelector) ?? "[class*=\"feedback\"], [class*=\"helpful\"], [class*=\"rating\"], [class*=\"was-this\"], [data-feedback]")) return;
1011
+ const buttonText = (button.textContent ?? "").trim().toLowerCase();
1012
+ const ariaLabel = (button.getAttribute("aria-label") ?? "").toLowerCase();
1013
+ const titleAttr = (button.getAttribute("title") ?? "").toLowerCase();
1014
+ const rawDataValue = button.getAttribute("data-value") ?? button.getAttribute("data-md-value") ?? button.getAttribute("data-feedback");
1015
+ const dataValue = rawDataValue && /^[\w\s.,!?-]{1,50}$/.test(rawDataValue) ? rawDataValue : null;
1016
+ let rating = null;
1017
+ if (dataValue) rating = dataValue;
1018
+ else if (/\byes\b|👍|thumbs.?up|helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "yes";
1019
+ else if (/\bno\b|👎|thumbs.?down|not.?helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "no";
1020
+ if (!rating) return;
1021
+ emit(EVENT_FEEDBACK, { [ATTR_DO11Y_FEEDBACK_RATING]: rating });
1022
+ });
1023
+ }
1024
+ //#endregion
1025
+ //#region src/core/tracking/expand.ts
1026
+ function setupExpandCollapseTracking(config, emit) {
1027
+ if (!config.trackExpandCollapse) return;
1028
+ document.addEventListener("toggle", (e) => {
1029
+ const details = e.target;
1030
+ if (details.tagName !== "DETAILS") return;
1031
+ const summary = details.querySelector("summary");
1032
+ const label = sanitizeText(summary ? summary.textContent : "", 100);
1033
+ emit(EVENT_EXPAND_COLLAPSE, {
1034
+ [ATTR_DO11Y_EXPAND_SUMMARY]: label,
1035
+ [ATTR_DO11Y_EXPAND_ACTION]: details.open ? "expand" : "collapse",
1036
+ [ATTR_DO11Y_EXPAND_SECTION]: sanitizeText(getNearestHeading(details), 100)
1037
+ });
1038
+ }, true);
1039
+ document.addEventListener("click", (e) => {
1040
+ const trigger = e.target.closest("[aria-expanded], [class*=\"accordion\"] button, [class*=\"collapsible\"] button");
1041
+ if (!trigger) return;
1042
+ if (trigger.closest("details")) return;
1043
+ if (trigger.closest("nav, [role=\"navigation\"], header")) return;
1044
+ const wasExpanded = trigger.getAttribute("aria-expanded") === "true";
1045
+ emit(EVENT_EXPAND_COLLAPSE, {
1046
+ [ATTR_DO11Y_EXPAND_SUMMARY]: sanitizeText(trigger.textContent, 100),
1047
+ [ATTR_DO11Y_EXPAND_ACTION]: wasExpanded ? "collapse" : "expand",
1048
+ [ATTR_DO11Y_EXPAND_SECTION]: sanitizeText(getNearestHeading(trigger), 100)
1049
+ });
1050
+ });
1051
+ }
1052
+ //#endregion
1053
+ //#region src/standalone/transport.ts
1054
+ let eventQueue = [];
1055
+ let flushTimeout = null;
1056
+ const rateLimiter = createRateLimiter();
1057
+ let isDisabled = false;
1058
+ let _otelLogger = null;
1059
+ /** Events queued while the OTel SDK is still loading from the CDN. They are
1060
+ * replayed once the SDK initializes and must NEVER fall through to the
1061
+ * HTTP transport (which would POST them to `config.endpoint`). */
1062
+ let pendingOtlpEvents = [];
1063
+ /** Single-flight guard so concurrent events don't trigger duplicate CDN loads. */
1064
+ let _otelInitPromise = null;
1065
+ /** Set once CDN init fails so we don't retry the load on every event. */
1066
+ let _otelInitFailed = false;
1067
+ /** Set once the self-hosted Supabase hint has been logged, so debug mode
1068
+ * doesn't repeat it on every flush. */
1069
+ let _selfHostedHintLogged = false;
1070
+ function setIsDisabled(v) {
1071
+ isDisabled = v;
1072
+ }
1073
+ function getIsDisabled() {
1074
+ return isDisabled;
1075
+ }
1076
+ function getQueueLength() {
1077
+ return eventQueue.length;
1078
+ }
1079
+ function queueEvent(config, eventName, eventData) {
1080
+ if (isDisabled) return;
1081
+ if (!rateLimiter.allow(eventName, eventData, config.rateLimitMs, config.debug)) return;
1082
+ const session = getSession();
1083
+ const eventTime = /* @__PURE__ */ new Date();
1084
+ const event = {
1085
+ _time: eventTime.toISOString(),
1086
+ eventName,
1087
+ [ATTR_DO11Y_DO11Y_VERSION]: VERSION,
1088
+ [ATTR_SESSION_ID]: session.id,
1089
+ [ATTR_DO11Y_SESSION_PAGE_COUNT]: session.pageCount,
1090
+ ...getPageInfo(),
1091
+ ...getBrowserContext(),
1092
+ ...eventData
1093
+ };
1094
+ if (config.debug) console.log("[Do11y] Event queued:", eventName, event);
1095
+ if (config.destination === "otlp") {
1096
+ if (_otelLogger) {
1097
+ emitOtlpRecord(eventName, event, eventTime);
1098
+ return;
882
1099
  }
883
- current = current.parentElement;
1100
+ if (_otelInitFailed) {
1101
+ if (config.debug) console.warn("[Do11y] OTel SDK unavailable; dropping event:", eventName);
1102
+ return;
1103
+ }
1104
+ pendingOtlpEvents.push(event);
1105
+ if (pendingOtlpEvents.length > 500) {
1106
+ pendingOtlpEvents = pendingOtlpEvents.slice(-500);
1107
+ console.warn("[Do11y] OTLP pending buffer capped at 500 events — oldest events dropped");
1108
+ }
1109
+ ensureOtelSdk(config);
1110
+ return;
884
1111
  }
885
- return null;
1112
+ eventQueue.push(event);
1113
+ if (eventQueue.length > 500) {
1114
+ eventQueue = eventQueue.slice(-500);
1115
+ console.warn("[Do11y] Event queue capped at 500 events — oldest events dropped");
1116
+ }
1117
+ if (eventQueue.length >= config.maxBatchSize) flush(config);
1118
+ else scheduleFlush(config);
1119
+ }
1120
+ function scheduleFlush(config) {
1121
+ if (flushTimeout) return;
1122
+ flushTimeout = setTimeout(() => flush(config), config.flushInterval);
1123
+ }
1124
+ function validateSupabaseUrl(url, debug = false) {
1125
+ return validateEndpoint(url, debug);
886
1126
  }
887
- function getLinkIndex(link, href) {
888
- if (typeof CSS === "undefined" || typeof CSS.escape !== "function") return 1;
1127
+ function isHostedSupabaseUrl(url) {
889
1128
  try {
890
- const allLinks = document.querySelectorAll("a[href=\"" + CSS.escape(href) + "\"]");
891
- for (let i = 0; i < allLinks.length; i++) if (allLinks[i] === link) return i + 1;
892
- } catch {}
893
- return 1;
1129
+ return new URL(url).hostname.endsWith(".supabase.co");
1130
+ } catch {
1131
+ return false;
1132
+ }
894
1133
  }
895
- let trackedScrollDepths = /* @__PURE__ */ new Set();
896
- let scrollContainer = null;
897
- function findScrollableAncestor(el) {
898
- let current = el;
899
- while (current && current !== document.body && current !== document.documentElement) {
900
- const overflowY = window.getComputedStyle(current).overflowY;
901
- if ((overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight) return current;
902
- current = current.parentElement;
1134
+ function validateEndpoint(url, debug = false) {
1135
+ try {
1136
+ const parsed = new URL(url);
1137
+ const host = parsed.hostname;
1138
+ const isPrivate = host === "localhost" || host === "127.0.0.1" || host === "::1" || /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host);
1139
+ if (debug && isPrivate && parsed.protocol === "http:") return true;
1140
+ if (parsed.protocol !== "https:") return false;
1141
+ if (isPrivate) return false;
1142
+ return true;
1143
+ } catch {
1144
+ return false;
903
1145
  }
904
- return null;
905
1146
  }
906
- /**
907
- * Track scroll depth.
908
- *
909
- * Some frameworks (MkDocs Material) use container-based
910
- * scrolling where the window itself never scrolls. We detect the scrollable
911
- * container by walking up from the content element and listen on it in
912
- * addition to the window.
913
- */
914
- function setupScrollTracking() {
915
- if (!config.trackScrollDepth) return;
916
- if (config.contentSelector) {
917
- const contentEl = document.querySelector(config.contentSelector);
918
- if (contentEl) scrollContainer = findScrollableAncestor(contentEl);
1147
+ function validateConfig(config) {
1148
+ if (config.destination === "supabase") {
1149
+ if (!config.supabaseUrl) {
1150
+ if (config.debug) console.warn("[Do11y] No Supabase URL configured");
1151
+ return false;
1152
+ }
1153
+ if (!validateSupabaseUrl(config.supabaseUrl, config.debug)) {
1154
+ if (config.debug) console.warn("[Do11y] Invalid Supabase URL. Must be a valid HTTPS URL (HTTP is allowed for localhost/private addresses when debug is enabled).");
1155
+ return false;
1156
+ }
1157
+ if (config.debug && !_selfHostedHintLogged && !isHostedSupabaseUrl(config.supabaseUrl)) {
1158
+ _selfHostedHintLogged = true;
1159
+ console.warn("[Do11y] Non-hosted Supabase URL. Ensure you configure your instance's REST endpoint and CORS settings.");
1160
+ }
1161
+ if (!config.supabaseKey || typeof config.supabaseKey !== "string" || config.supabaseKey.length < 10) {
1162
+ if (config.debug) console.warn("[Do11y] Invalid or missing Supabase publishable key");
1163
+ return false;
1164
+ }
1165
+ if (!/^[a-zA-Z0-9_-]+$/.test(config.supabaseTable)) {
1166
+ if (config.debug) console.warn("[Do11y] Invalid table name");
1167
+ return false;
1168
+ }
1169
+ return true;
919
1170
  }
920
- let ticking = false;
921
- function onScroll() {
922
- if (!ticking) {
923
- window.requestAnimationFrame(() => {
924
- checkScrollDepth();
925
- ticking = false;
926
- });
927
- ticking = true;
1171
+ if (config.destination === "http") {
1172
+ if (!config.endpoint) {
1173
+ if (config.debug) console.warn("[Do11y] No HTTP endpoint configured");
1174
+ return false;
1175
+ }
1176
+ if (!validateEndpoint(config.endpoint, config.debug)) {
1177
+ if (config.debug) console.warn("[Do11y] Invalid HTTP endpoint. Must be HTTPS and not a private address.");
1178
+ return false;
928
1179
  }
1180
+ return true;
929
1181
  }
930
- window.addEventListener("scroll", onScroll);
931
- if (scrollContainer) {
932
- scrollContainer.addEventListener("scroll", onScroll);
933
- if (config.debug) {
934
- const sc = scrollContainer;
935
- console.log("[do11y] Using container-based scroll tracking:", sc.className || sc.tagName);
1182
+ if (config.destination === "otlp") {
1183
+ if (!config.otelSdkEndpoint) {
1184
+ if (config.debug) console.warn("[Do11y] No OTLP endpoint configured");
1185
+ return false;
936
1186
  }
1187
+ return true;
937
1188
  }
938
- checkScrollDepth();
1189
+ if (config.debug) console.warn("[Do11y] Unknown destination:", config.destination);
1190
+ return false;
939
1191
  }
940
1192
  /**
941
- * Check and track scroll depth thresholds.
942
- * Reads from the detected scroll container when present, otherwise
943
- * falls back to the window/document.
944
- *
945
- * If the page fits entirely in the viewport (no scrollbar), all
946
- * thresholds are marked as reached since the user can see 100% of
947
- * the content without scrolling.
1193
+ * Dynamically import the OTel Browser SDK and set up the LoggerProvider.
1194
+ * Only called when destination is 'otlp'.
948
1195
  */
949
- function checkScrollDepth() {
950
- let scrollTop;
951
- let totalHeight;
952
- let viewportHeight;
953
- if (scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight) {
954
- scrollTop = scrollContainer.scrollTop;
955
- totalHeight = scrollContainer.scrollHeight;
956
- viewportHeight = scrollContainer.clientHeight;
957
- } else {
958
- scrollTop = window.scrollY || document.documentElement.scrollTop;
959
- totalHeight = document.documentElement.scrollHeight;
960
- viewportHeight = window.innerHeight;
961
- }
962
- const docHeight = totalHeight - viewportHeight;
963
- if (docHeight <= 0) {
964
- config.scrollThresholds.forEach((threshold) => {
965
- if (!trackedScrollDepths.has(threshold)) {
966
- trackedScrollDepths.add(threshold);
967
- queueEvent(EVENT_SCROLL_DEPTH, {
968
- [ATTR_DO11Y_SCROLL_THRESHOLD]: threshold,
969
- [ATTR_DO11Y_SCROLL_PERCENT]: 100
970
- });
971
- }
972
- });
973
- return;
974
- }
975
- const scrollPercent = Math.round(scrollTop / docHeight * 100);
976
- config.scrollThresholds.forEach((threshold) => {
977
- if (scrollPercent >= threshold && !trackedScrollDepths.has(threshold)) {
978
- trackedScrollDepths.add(threshold);
979
- queueEvent(EVENT_SCROLL_DEPTH, {
980
- [ATTR_DO11Y_SCROLL_THRESHOLD]: threshold,
981
- [ATTR_DO11Y_SCROLL_PERCENT]: scrollPercent
982
- });
983
- }
1196
+ /** CDN base URL for dynamic OTel SDK imports. Pinned at build time.
1197
+ * Change this constant (not a config field) to switch CDN providers. */
1198
+ const OTEL_CDN_BASE = "https://esm.sh/";
1199
+ /** Version of the OTel SDK packages loaded from the CDN.
1200
+ * Keep in sync with the `@opentelemetry/*` peer/dev dependencies in package.json. */
1201
+ const OTEL_SDK_VERSION = "0.221.0";
1202
+ /** Emit a single event through the OTel Logger. */
1203
+ function emitOtlpRecord(eventName, event, eventTime) {
1204
+ if (!_otelLogger) return;
1205
+ const otelAttributes = { ...event };
1206
+ delete otelAttributes._time;
1207
+ delete otelAttributes.eventName;
1208
+ _otelLogger.emit({
1209
+ eventName,
1210
+ severityNumber: 9,
1211
+ timestamp: eventTime.getTime(),
1212
+ attributes: otelAttributes,
1213
+ body: ""
984
1214
  });
985
1215
  }
986
- let pageLoadTime = Date.now();
987
- let lastActivityTime = Date.now();
988
- let totalActiveTime = 0;
989
- let isPageVisible = true;
990
- let pageExited = false;
991
- function emitPageExit() {
992
- if (pageExited) return;
993
- pageExited = true;
994
- if (isPageVisible) totalActiveTime += Date.now() - lastActivityTime;
995
- const totalTime = Date.now() - pageLoadTime;
996
- const engagementRatio = totalTime > 0 ? totalActiveTime / totalTime : 0;
997
- let maxScroll = 0;
998
- trackedScrollDepths.forEach((depth) => {
999
- if (depth > maxScroll) maxScroll = depth;
1000
- });
1001
- flushVisibleSections();
1002
- const session = getSession();
1003
- queueEvent(EVENT_PAGE_EXIT, {
1004
- [ATTR_DO11Y_TOTAL_TIME_SECONDS]: Math.round(totalTime / 1e3),
1005
- [ATTR_DO11Y_ACTIVE_TIME_SECONDS]: Math.round(totalActiveTime / 1e3),
1006
- [ATTR_DO11Y_ENGAGEMENT_RATIO]: Math.round(engagementRatio * 100) / 100,
1007
- [ATTR_DO11Y_MAX_SCROLL_DEPTH]: maxScroll,
1008
- [ATTR_DO11Y_REFERRER_CATEGORY]: session.referrerCategory,
1009
- [ATTR_DO11Y_AI_PLATFORM]: session.aiPlatform
1010
- });
1011
- flush();
1216
+ /** Replay events buffered while the OTel SDK was still loading. */
1217
+ function drainPendingOtlp() {
1218
+ if (!_otelLogger || pendingOtlpEvents.length === 0) return;
1219
+ const batch = pendingOtlpEvents;
1220
+ pendingOtlpEvents = [];
1221
+ for (const evt of batch) emitOtlpRecord(evt.eventName, evt, new Date(evt._time));
1012
1222
  }
1013
- function setupEngagementTracking() {
1014
- document.addEventListener("visibilitychange", () => {
1015
- if (document.hidden) {
1016
- if (isPageVisible) {
1017
- totalActiveTime += Date.now() - lastActivityTime;
1018
- isPageVisible = false;
1019
- }
1020
- } else {
1021
- lastActivityTime = Date.now();
1022
- isPageVisible = true;
1023
- }
1024
- });
1025
- window.addEventListener("beforeunload", () => {
1026
- emitPageExit();
1027
- cleanup();
1223
+ /** Kick off the async CDN SDK load exactly once. Buffered events are replayed
1224
+ * by initOtelSdk on success, or dropped with a warning on failure. */
1225
+ function ensureOtelSdk(config) {
1226
+ if (_otelLogger || _otelInitPromise || _otelInitFailed) return;
1227
+ _otelInitPromise = initOtelSdk(config).catch((err) => {
1228
+ _otelInitFailed = true;
1229
+ pendingOtlpEvents = [];
1230
+ console.warn("[Do11y] OTel SDK initialization failed; buffered events dropped:", err);
1231
+ }).finally(() => {
1232
+ _otelInitPromise = null;
1028
1233
  });
1029
1234
  }
1030
- function setupSearchTracking() {
1031
- document.addEventListener("click", (e) => {
1032
- if (e.target.closest(config.searchSelector)) queueEvent(EVENT_SEARCH_OPENED, {});
1033
- }, true);
1034
- document.addEventListener("keydown", (e) => {
1035
- if ((e.metaKey || e.ctrlKey) && e.key === "k") queueEvent(EVENT_SEARCH_OPENED, { [ATTR_DO11Y_SEARCH_TRIGGER]: "keyboard" });
1235
+ async function initOtelSdk(config) {
1236
+ if (_otelLogger) return;
1237
+ const cdnBase = OTEL_CDN_BASE;
1238
+ const importModule = async (spec) => {
1239
+ return import(
1240
+ /* @vite-ignore */
1241
+ spec
1242
+ );
1243
+ };
1244
+ const apiLogs = await importModule(`${cdnBase}@opentelemetry/api-logs@${OTEL_SDK_VERSION}`);
1245
+ const sdkLogs = await importModule(`${cdnBase}@opentelemetry/sdk-logs@${OTEL_SDK_VERSION}`);
1246
+ const otlpExporter = await importModule(`${cdnBase}@opentelemetry/exporter-logs-otlp-http@${OTEL_SDK_VERSION}`);
1247
+ const resourceAttrs = {
1248
+ "service.name": config.otelSdkServiceName || "do11y",
1249
+ "service.version": VERSION,
1250
+ "telemetry.sdk.name": "do11y",
1251
+ "telemetry.sdk.language": "webjs",
1252
+ "telemetry.sdk.version": VERSION,
1253
+ ...config.otelSdkResourceAttributes
1254
+ };
1255
+ const loggerProvider = new sdkLogs.LoggerProvider({
1256
+ resource: { attributes: resourceAttrs },
1257
+ processors: [new sdkLogs.BatchLogRecordProcessor({ exporter: new otlpExporter.OTLPLogExporter({
1258
+ url: config.otelSdkEndpoint.replace(/\/$/, "") + "/v1/logs",
1259
+ headers: config.otelSdkHeaders
1260
+ }) })]
1036
1261
  });
1262
+ apiLogs.logs.setGlobalLoggerProvider(loggerProvider);
1263
+ _otelLogger = loggerProvider.getLogger("do11y");
1264
+ drainPendingOtlp();
1265
+ if (config.debug) console.log("[Do11y] OTel SDK initialized with endpoint:", config.otelSdkEndpoint);
1037
1266
  }
1038
- function getCodeBlockIndex(codeBlock) {
1039
- if (!codeBlock) return 1;
1040
- try {
1041
- const allBlocks = document.querySelectorAll(config.codeBlockSelector);
1042
- for (let i = 0; i < allBlocks.length; i++) if (allBlocks[i] === codeBlock) return i + 1;
1043
- } catch {}
1044
- return 1;
1045
- }
1046
- function setupCopyTracking() {
1047
- document.addEventListener("click", (e) => {
1048
- const copyButton = e.target.closest(config.copyButtonSelector);
1049
- if (copyButton) {
1050
- const codeBlock = copyButton.closest("[class*=\"language-\"], [language]") ?? copyButton.closest(config.codeBlockSelector) ?? copyButton.closest(".expressive-code")?.querySelector("pre") ?? copyButton.closest("div, section")?.querySelector("pre") ?? copyButton.parentElement?.querySelector("pre") ?? null;
1051
- const language = extractCodeLanguage((codeBlock ? codeBlock.tagName === "PRE" ? codeBlock.querySelector("code") : codeBlock.querySelector("code[class*=\"language-\"], code[language]") ?? codeBlock.querySelector("code") : null) ?? codeBlock ?? copyButton);
1052
- queueEvent(EVENT_CODE_COPIED, {
1053
- [ATTR_DO11Y_CODE_LANGUAGE]: language,
1054
- [ATTR_DO11Y_CODE_SECTION]: sanitizeText(getNearestHeading(codeBlock ?? copyButton), 100),
1055
- [ATTR_DO11Y_CODE_INDEX]: getCodeBlockIndex(codeBlock)
1056
- });
1057
- }
1058
- }, true);
1059
- }
1060
- let sectionObserver = null;
1061
- let sectionTimers = {};
1062
- function setupSectionVisibilityTracking() {
1063
- if (!config.trackSectionVisibility) return;
1064
- if (typeof IntersectionObserver === "undefined") return;
1065
- const threshold = config.sectionVisibleThreshold * 1e3;
1066
- sectionObserver = new IntersectionObserver((entries) => {
1067
- entries.forEach((entry) => {
1068
- const id = entry.target.getAttribute("data-do11y-section-id");
1069
- if (!id) return;
1070
- if (entry.isIntersecting) {
1071
- if (!sectionTimers[id]) {
1072
- const timer = {
1073
- start: Date.now(),
1074
- reported: false,
1075
- timeoutId: null
1076
- };
1077
- timer.timeoutId = setTimeout(() => {
1078
- if (sectionTimers[id] && !sectionTimers[id].reported) {
1079
- const heading = entry.target.textContent?.trim() ?? "";
1080
- queueEvent(EVENT_SECTION_VISIBLE, {
1081
- [ATTR_DO11Y_SECTION_HEADING]: sanitizeText(heading, 100),
1082
- [ATTR_DO11Y_SECTION_HEADING_LEVEL]: parseInt(entry.target.tagName.charAt(1), 10),
1083
- [ATTR_DO11Y_SECTION_VISIBLE_SECONDS]: Math.round(threshold / 1e3)
1084
- });
1085
- sectionTimers[id].reported = true;
1086
- }
1087
- }, threshold);
1088
- sectionTimers[id] = timer;
1089
- }
1090
- } else {
1091
- if (sectionTimers[id]) {
1092
- if (sectionTimers[id].timeoutId) clearTimeout(sectionTimers[id].timeoutId);
1093
- if (!sectionTimers[id].reported) {
1094
- const elapsed = Date.now() - sectionTimers[id].start;
1095
- if (elapsed >= threshold) {
1096
- const heading = entry.target.textContent?.trim() ?? "";
1097
- queueEvent(EVENT_SECTION_VISIBLE, {
1098
- [ATTR_DO11Y_SECTION_HEADING]: sanitizeText(heading, 100),
1099
- [ATTR_DO11Y_SECTION_HEADING_LEVEL]: parseInt(entry.target.tagName.charAt(1), 10),
1100
- [ATTR_DO11Y_SECTION_VISIBLE_SECONDS]: Math.round(elapsed / 1e3)
1101
- });
1102
- sectionTimers[id].reported = true;
1103
- }
1104
- }
1105
- }
1106
- delete sectionTimers[id];
1107
- }
1108
- });
1109
- }, { threshold: .5 });
1110
- observeHeadings();
1267
+ function buildRequest(events, config) {
1268
+ if (config.destination === "supabase") {
1269
+ const url = config.supabaseUrl.replace(/\/$/, "") + "/rest/v1/" + config.supabaseTable;
1270
+ const bodyTransform = config.bodyTransform ?? ((evts) => evts.map((e) => ({ payload: e })));
1271
+ return {
1272
+ url,
1273
+ headers: {
1274
+ apikey: config.supabaseKey,
1275
+ Authorization: "Bearer " + config.supabaseKey,
1276
+ "Content-Type": "application/json",
1277
+ Prefer: "return=minimal"
1278
+ },
1279
+ body: JSON.stringify(bodyTransform(events))
1280
+ };
1281
+ }
1282
+ const bodyTransform = config.bodyTransform ?? ((evts) => evts);
1283
+ return {
1284
+ url: config.endpoint,
1285
+ headers: {
1286
+ "Content-Type": "application/json",
1287
+ ...config.headers
1288
+ },
1289
+ body: JSON.stringify(bodyTransform(events))
1290
+ };
1111
1291
  }
1112
- function observeHeadings() {
1113
- if (!sectionObserver) return;
1114
- document.querySelectorAll("h2, h3").forEach((h, i) => {
1115
- h.setAttribute("data-do11y-section-id", "section-" + i);
1116
- sectionObserver.observe(h);
1117
- });
1292
+ function isCrossOrigin(url) {
1293
+ try {
1294
+ return new URL(url).origin !== window.location.origin;
1295
+ } catch {
1296
+ return false;
1297
+ }
1118
1298
  }
1119
- function flushVisibleSections() {
1120
- if (!sectionObserver) return;
1121
- const now = Date.now();
1122
- const threshold = config.sectionVisibleThreshold * 1e3;
1123
- Object.keys(sectionTimers).forEach((id) => {
1124
- const timer = sectionTimers[id];
1125
- if (timer && !timer.reported) {
1126
- if (timer.timeoutId) clearTimeout(timer.timeoutId);
1127
- const elapsed = now - timer.start;
1128
- if (elapsed >= threshold) {
1129
- const escapedId = typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(id) : id.replace(/["\\]/g, "\\$&");
1130
- const el = document.querySelector("[data-do11y-section-id=\"" + escapedId + "\"]");
1131
- if (el) queueEvent(EVENT_SECTION_VISIBLE, {
1132
- [ATTR_DO11Y_SECTION_HEADING]: sanitizeText(el.textContent?.trim() ?? "", 100),
1133
- [ATTR_DO11Y_SECTION_HEADING_LEVEL]: parseInt(el.tagName.charAt(1), 10),
1134
- [ATTR_DO11Y_SECTION_VISIBLE_SECONDS]: Math.round(elapsed / 1e3)
1135
- });
1136
- }
1299
+ function sendEvents(req, events, retriesLeft, config) {
1300
+ const crossOrigin = isCrossOrigin(req.url);
1301
+ if (config.debug && crossOrigin) console.log("[Do11y] Cross-origin request to", new URL(req.url).origin, "- requires CORS headers on the server");
1302
+ fetch(req.url, {
1303
+ method: "POST",
1304
+ headers: req.headers,
1305
+ body: req.body,
1306
+ keepalive: true,
1307
+ mode: crossOrigin ? "cors" : "same-origin"
1308
+ }).then((response) => {
1309
+ if (response.ok) {
1310
+ if (config.debug) console.log("[Do11y] Flushed", events.length, "events");
1311
+ return;
1137
1312
  }
1138
- });
1139
- sectionTimers = {};
1140
- }
1141
- function setupTabSwitchTracking() {
1142
- if (!config.trackTabSwitches) return;
1143
- document.addEventListener("click", (e) => {
1144
- let baseSel = "[role=\"tab\"], .tabs button, .tabs a, .tabbed-labels label";
1145
- const safeTabSel = validateSelector(config.tabContainerSelector);
1146
- if (safeTabSel) baseSel += ", " + safeTabSel + " button, " + safeTabSel + " a, " + safeTabSel + " label";
1147
- const tab = e.target.closest(baseSel);
1148
- if (!tab) return;
1149
- if (tab.getAttribute("aria-selected") === "true" || tab.classList.contains("active") || tab.classList.contains("is-active")) return;
1150
- const label = sanitizeText(tab.textContent, 50);
1151
- if (!label) return;
1152
- const section = sanitizeText(getNearestHeading(tab), 100);
1153
- queueEvent(EVENT_TAB_SWITCH, {
1154
- [ATTR_DO11Y_TAB_LABEL]: label,
1155
- [ATTR_DO11Y_TAB_GROUP]: section,
1156
- [ATTR_DO11Y_TAB_IS_DEFAULT]: false
1157
- });
1313
+ if (retriesLeft > 0 && (response.status >= 500 || response.status === 429)) {
1314
+ if (config.debug) console.log("[Do11y] Retrying after error:", response.status);
1315
+ eventQueue = events.concat(eventQueue);
1316
+ setTimeout(() => flush(config, retriesLeft - 1), config.retryDelay * (config.maxRetries - retriesLeft + 1));
1317
+ return;
1318
+ }
1319
+ if (config.debug) response.text().then((text) => {
1320
+ const msg = `[Do11y] Ingest failed: ${response.status}`;
1321
+ if (response.status === 0 && response.type === "opaque") console.error(msg, "- CORS error: server did not return Access-Control-Allow-Origin");
1322
+ else console.error(msg, text);
1323
+ }).catch(() => {});
1324
+ }).catch((err) => {
1325
+ if (retriesLeft > 0) {
1326
+ if (config.debug) {
1327
+ const hint = crossOrigin ? " (this may be a CORS issue — try using an OTel Collector proxy)" : "";
1328
+ console.log("[Do11y] Network error, retrying:", err.message + hint);
1329
+ }
1330
+ eventQueue = events.concat(eventQueue);
1331
+ setTimeout(() => flush(config, retriesLeft - 1), config.retryDelay * (config.maxRetries - retriesLeft + 1));
1332
+ } else if (config.debug) console.error("[Do11y] Failed to send events:", err.message);
1158
1333
  });
1159
1334
  }
1160
- function setupTocClickTracking() {
1161
- if (!config.trackTocClicks) return;
1162
- document.addEventListener("click", (e) => {
1163
- const link = e.target.closest("a");
1164
- if (!link) return;
1165
- const tocContainer = resolveTocContainer(link);
1166
- if (!tocContainer) return;
1167
- const href = link.getAttribute("href");
1168
- const hash = href ? resolveTocHash(href) : null;
1169
- if (!hash) return;
1170
- const headingText = sanitizeText(link.textContent, 100);
1171
- let headingLevel = null;
1172
- try {
1173
- const targetId = hash.slice(1);
1174
- const targetEl = document.getElementById(targetId);
1175
- if (targetEl && /^H[1-6]$/.test(targetEl.tagName)) headingLevel = parseInt(targetEl.tagName.charAt(1), 10);
1176
- } catch {}
1177
- const tocLinks = tocContainer.querySelectorAll("a[href*=\"#\"]");
1178
- let tocPosition = 1;
1179
- for (let i = 0; i < tocLinks.length; i++) if (tocLinks[i] === link) {
1180
- tocPosition = i + 1;
1181
- break;
1335
+ function flush(config, retriesLeft) {
1336
+ if (flushTimeout) {
1337
+ clearTimeout(flushTimeout);
1338
+ flushTimeout = null;
1339
+ }
1340
+ if (config.destination === "otlp") {
1341
+ if (eventQueue.length > 0) {
1342
+ if (config.debug) console.warn("[Do11y] Dropping " + eventQueue.length + " queued events (OTLP mode has no HTTP transport)");
1343
+ eventQueue = [];
1182
1344
  }
1183
- queueEvent(EVENT_TOC_CLICK, {
1184
- [ATTR_DO11Y_TOC_HEADING]: headingText,
1185
- [ATTR_DO11Y_TOC_HEADING_LEVEL]: headingLevel,
1186
- [ATTR_DO11Y_TOC_POSITION]: tocPosition
1187
- });
1188
- }, true);
1189
- }
1190
- function setupFeedbackTracking() {
1191
- if (!config.trackFeedback) return;
1192
- document.addEventListener("click", (e) => {
1193
- const button = e.target.closest("button, [role=\"button\"], a");
1194
- if (!button) return;
1195
- if (!button.closest(validateSelector(config.feedbackSelector) ?? "[class*=\"feedback\"], [class*=\"helpful\"], [class*=\"rating\"], [class*=\"was-this\"], [data-feedback]")) return;
1196
- const buttonText = (button.textContent ?? "").trim().toLowerCase();
1197
- const ariaLabel = (button.getAttribute("aria-label") ?? "").toLowerCase();
1198
- const titleAttr = (button.getAttribute("title") ?? "").toLowerCase();
1199
- const rawDataValue = button.getAttribute("data-value") ?? button.getAttribute("data-md-value") ?? button.getAttribute("data-feedback");
1200
- const dataValue = rawDataValue && /^[\w\s.,!?-]{1,50}$/.test(rawDataValue) ? rawDataValue : null;
1201
- let rating = null;
1202
- if (dataValue) rating = dataValue;
1203
- else if (/\byes\b|👍|thumbs.?up|helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "yes";
1204
- else if (/\bno\b|👎|thumbs.?down|not.?helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "no";
1205
- if (!rating) return;
1206
- queueEvent(EVENT_FEEDBACK, { [ATTR_DO11Y_FEEDBACK_RATING]: rating });
1207
- });
1345
+ return;
1346
+ }
1347
+ if (eventQueue.length === 0) return;
1348
+ if (!validateConfig(config)) return;
1349
+ const retries = typeof retriesLeft === "number" ? retriesLeft : config.maxRetries;
1350
+ const events = eventQueue.slice();
1351
+ eventQueue = [];
1352
+ sendEvents(buildRequest(events, config), events, retries, config);
1208
1353
  }
1209
- function setupExpandCollapseTracking() {
1210
- if (!config.trackExpandCollapse) return;
1211
- document.addEventListener("toggle", (e) => {
1212
- const details = e.target;
1213
- if (details.tagName !== "DETAILS") return;
1214
- const summary = details.querySelector("summary");
1215
- const label = sanitizeText(summary ? summary.textContent : "", 100);
1216
- queueEvent(EVENT_EXPAND_COLLAPSE, {
1217
- [ATTR_DO11Y_EXPAND_SUMMARY]: label,
1218
- [ATTR_DO11Y_EXPAND_ACTION]: details.open ? "expand" : "collapse",
1219
- [ATTR_DO11Y_EXPAND_SECTION]: sanitizeText(getNearestHeading(details), 100)
1220
- });
1221
- }, true);
1222
- document.addEventListener("click", (e) => {
1223
- const trigger = e.target.closest("[aria-expanded], [class*=\"accordion\"] button, [class*=\"collapsible\"] button");
1224
- if (!trigger) return;
1225
- if (trigger.closest("details")) return;
1226
- if (trigger.closest("nav, [role=\"navigation\"], header")) return;
1227
- const wasExpanded = trigger.getAttribute("aria-expanded") === "true";
1228
- queueEvent(EVENT_EXPAND_COLLAPSE, {
1229
- [ATTR_DO11Y_EXPAND_SUMMARY]: sanitizeText(trigger.textContent, 100),
1230
- [ATTR_DO11Y_EXPAND_ACTION]: wasExpanded ? "collapse" : "expand",
1231
- [ATTR_DO11Y_EXPAND_SECTION]: sanitizeText(getNearestHeading(trigger), 100)
1354
+ /**
1355
+ * Synchronous flush used on `beforeunload`. For OTLP mode the SDK
1356
+ * handles flush on its own; for HTTP/Supabase we use fetch with keepalive.
1357
+ * sendBeacon is not used because Supabase requires custom headers
1358
+ * (apikey, Authorization) which sendBeacon does not support.
1359
+ */
1360
+ function flushSync(config) {
1361
+ if (config.destination === "otlp") return;
1362
+ if (eventQueue.length === 0) return;
1363
+ if (!validateConfig(config)) return;
1364
+ const events = eventQueue;
1365
+ eventQueue = [];
1366
+ const req = buildRequest(events, config);
1367
+ try {
1368
+ fetch(req.url, {
1369
+ method: "POST",
1370
+ headers: req.headers,
1371
+ body: req.body,
1372
+ keepalive: true
1232
1373
  });
1233
- });
1374
+ } catch {}
1375
+ if (config.debug) console.log("[Do11y] Sync flushed", events.length, "events");
1376
+ }
1377
+ function cleanup() {
1378
+ if (flushTimeout) {
1379
+ clearTimeout(flushTimeout);
1380
+ flushTimeout = null;
1381
+ }
1234
1382
  }
1383
+ //#endregion
1384
+ //#region src/standalone/index.ts
1385
+ const config = {
1386
+ destination: "supabase",
1387
+ supabaseUrl: "",
1388
+ supabaseKey: "",
1389
+ supabaseTable: "do11y_events",
1390
+ endpoint: "",
1391
+ headers: {},
1392
+ bodyTransform: void 0,
1393
+ otelSdkEndpoint: "",
1394
+ otelSdkHeaders: {},
1395
+ otelSdkServiceName: "do11y",
1396
+ otelSdkResourceAttributes: {},
1397
+ debug: false,
1398
+ flushInterval: 5e3,
1399
+ maxBatchSize: 10,
1400
+ trackOutboundLinks: true,
1401
+ trackInternalLinks: true,
1402
+ trackScrollDepth: true,
1403
+ scrollThresholds: [
1404
+ 25,
1405
+ 50,
1406
+ 75,
1407
+ 90
1408
+ ],
1409
+ allowedDomains: null,
1410
+ respectDNT: true,
1411
+ maxRetries: 2,
1412
+ retryDelay: 1e3,
1413
+ rateLimitMs: 100,
1414
+ framework: "mintlify",
1415
+ trackSectionVisibility: true,
1416
+ sectionVisibleThreshold: 3,
1417
+ trackSearch: true,
1418
+ trackCopy: true,
1419
+ trackTabSwitches: true,
1420
+ trackTocClicks: true,
1421
+ trackExpandCollapse: true,
1422
+ trackFeedback: true,
1423
+ tabContainerSelector: null,
1424
+ tocSelector: null,
1425
+ feedbackSelector: null,
1426
+ searchSelector: null,
1427
+ copyButtonSelector: null,
1428
+ codeBlockSelector: null,
1429
+ navigationSelector: null,
1430
+ footerSelector: null,
1431
+ contentSelector: null,
1432
+ trackSpaPathChanges: false,
1433
+ sessionAttributes: true
1434
+ };
1435
+ const _alreadyLoaded = !!window.__do11yInitialized;
1436
+ window.__do11yInitialized = true;
1437
+ const _isInIframe = window.self !== window.top;
1438
+ if (_isInIframe && !_alreadyLoaded) window.__do11yInitialized = false;
1235
1439
  let mutationObserver = null;
1236
1440
  let pathPollId = null;
1237
1441
  function init() {
1238
- if (window.Do11yConfig && typeof window.Do11yConfig === "object") {
1239
- for (const key in config) if (Object.prototype.hasOwnProperty.call(window.Do11yConfig, key)) config[key] = window.Do11yConfig[key];
1442
+ const cfg = config;
1443
+ const userCfg = window.Do11yConfig;
1444
+ if (userCfg && typeof userCfg === "object") {
1445
+ for (const key in config) if (Object.prototype.hasOwnProperty.call(userCfg, key)) {
1446
+ const val = userCfg[key];
1447
+ if (val !== void 0) cfg[key] = val;
1448
+ }
1240
1449
  }
1241
1450
  const metaDestination = document.querySelector("meta[name=\"do11y-destination\"]");
1242
1451
  if (metaDestination) {
@@ -1266,57 +1475,62 @@
1266
1475
  if (domainsStr) config.allowedDomains = domainsStr.split(",").map((d) => d.trim());
1267
1476
  }
1268
1477
  const metaFramework = document.querySelector("meta[name=\"do11y-framework\"]");
1269
- if (metaFramework) config.framework = metaFramework.getAttribute("content") ?? config.framework;
1270
- const metaUseOtelInstrumentations = document.querySelector("meta[name=\"do11y-use-otel-instrumentations\"]");
1271
- if (metaUseOtelInstrumentations && metaUseOtelInstrumentations.getAttribute("content") === "true") config.useOtelBrowserInstrumentations = true;
1272
- applyFrameworkSelectors();
1273
- if (config.debug) {
1274
- const hasCreds = config.destination === "supabase" ? !!config.supabaseKey : config.destination === "otlp" ? !!config.otelSdkEndpoint : !!config.endpoint;
1275
- console.log("[Do11y] Initializing with config:", {
1276
- destination: config.destination,
1277
- hasCredentials: hasCreds,
1278
- framework: config.framework,
1279
- allowedDomains: config.allowedDomains,
1280
- respectDNT: config.respectDNT
1281
- });
1478
+ if (metaFramework) {
1479
+ const rawFramework = metaFramework.getAttribute("content");
1480
+ if (rawFramework && [
1481
+ "mintlify",
1482
+ "docusaurus",
1483
+ "nextra",
1484
+ "mkdocs-material",
1485
+ "vitepress",
1486
+ "starlight",
1487
+ "docsy",
1488
+ "custom"
1489
+ ].includes(rawFramework)) config.framework = rawFramework;
1490
+ else if (rawFramework && config.debug) console.warn("[Do11y] Unknown framework in meta tag: \"" + rawFramework + "\". Using default: " + config.framework);
1282
1491
  }
1283
- if (shouldDisableTracking()) {
1284
- isDisabled = true;
1492
+ applyFrameworkSelectors(config);
1493
+ if (config.debug) console.log("[Do11y] Initializing with config:", {
1494
+ destination: config.destination,
1495
+ framework: config.framework,
1496
+ allowedDomains: config.allowedDomains,
1497
+ respectDNT: config.respectDNT
1498
+ });
1499
+ if (shouldDisableTracking(config)) {
1500
+ setIsDisabled(true);
1285
1501
  if (config.debug) console.log("[Do11y] Tracking disabled");
1286
1502
  return;
1287
1503
  }
1288
1504
  if (!(config.destination === "supabase" ? !!config.supabaseKey : config.destination === "otlp" ? !!config.otelSdkEndpoint : !!config.endpoint)) {
1289
- if (config.debug) {
1290
- console.warn("[Do11y] No destination configured. Events will not be sent.");
1291
- if (config.destination === "supabase") console.warn("[Do11y] Add <meta name=\"do11y-url\"> and <meta name=\"do11y-key\"> to enable.");
1292
- else if (config.destination === "otlp") console.warn("[Do11y] Add <meta name=\"do11y-otlp-endpoint\"> to enable.");
1293
- else console.warn("[Do11y] Add <meta name=\"do11y-endpoint\"> to enable.");
1294
- }
1505
+ console.warn("[Do11y] No destination configured. Events will not be sent.");
1506
+ if (config.destination === "supabase") console.warn("[Do11y] Add <meta name=\"do11y-url\"> and <meta name=\"do11y-key\"> to enable.");
1507
+ else if (config.destination === "otlp") console.warn("[Do11y] Add <meta name=\"do11y-otlp-endpoint\"> to enable.");
1508
+ else console.warn("[Do11y] Add <meta name=\"do11y-endpoint\"> to enable.");
1295
1509
  }
1296
- trackPageView();
1297
- setupLinkTracking();
1298
- setupScrollTracking();
1299
- setupEngagementTracking();
1300
- setupSearchTracking();
1301
- setupCopyTracking();
1302
- setupSectionVisibilityTracking();
1303
- setupTabSwitchTracking();
1304
- setupTocClickTracking();
1305
- setupFeedbackTracking();
1306
- setupExpandCollapseTracking();
1510
+ const emit = (eventName, eventData) => {
1511
+ queueEvent(config, eventName, eventData);
1512
+ };
1513
+ trackPageView(config, emit);
1514
+ setupLinkTracking(config, emit);
1515
+ setupScrollTracking(config, emit);
1516
+ setupEngagementTracking(config, emit);
1517
+ setupSearchTracking(config, emit);
1518
+ setupCopyTracking(config, emit);
1519
+ setupSectionVisibilityTracking(config, emit);
1520
+ setupTabSwitchTracking(config, emit);
1521
+ setupTocClickTracking(config, emit);
1522
+ setupFeedbackTracking(config, emit);
1523
+ setupExpandCollapseTracking(config, emit);
1307
1524
  let lastPath = window.location.pathname;
1308
1525
  const handlePathChange = () => {
1309
1526
  if (window.location.pathname === lastPath) return;
1310
1527
  lastPath = window.location.pathname;
1311
- emitPageExit();
1312
- trackedScrollDepths = /* @__PURE__ */ new Set();
1313
- pageLoadTime = Date.now();
1314
- lastActivityTime = Date.now();
1315
- totalActiveTime = 0;
1316
- isPageVisible = true;
1317
- trackPageView();
1528
+ emitPageExit(config, emit, () => flush(config));
1529
+ resetTrackedScrollDepths();
1530
+ resetEngagementState();
1531
+ trackPageView(config, emit);
1318
1532
  observeHeadings();
1319
- checkScrollDepth();
1533
+ checkScrollDepth(config, emit);
1320
1534
  };
1321
1535
  mutationObserver = new MutationObserver(handlePathChange);
1322
1536
  mutationObserver.observe(document.body, {
@@ -1326,27 +1540,37 @@
1326
1540
  window.addEventListener("popstate", handlePathChange);
1327
1541
  pathPollId = window.setInterval(handlePathChange, 200);
1328
1542
  Object.freeze(config);
1543
+ window.addEventListener("beforeunload", () => {
1544
+ if (pathPollId !== null) {
1545
+ clearInterval(pathPollId);
1546
+ pathPollId = null;
1547
+ }
1548
+ flushSync(config);
1549
+ cleanup();
1550
+ });
1551
+ document.addEventListener("visibilitychange", () => {
1552
+ if (document.hidden && pathPollId !== null) {
1553
+ clearInterval(pathPollId);
1554
+ pathPollId = null;
1555
+ } else if (!document.hidden && pathPollId === null && config.trackSpaPathChanges) pathPollId = window.setInterval(handlePathChange, 200);
1556
+ });
1329
1557
  if (config.debug) console.log("[Do11y] Initialized successfully");
1330
1558
  }
1331
- function cleanup() {
1559
+ /** Tear down all tracking: remove listeners, disconnect observers, flush queue. */
1560
+ function destroy() {
1332
1561
  if (mutationObserver) {
1333
1562
  mutationObserver.disconnect();
1334
1563
  mutationObserver = null;
1335
1564
  }
1336
- if (sectionObserver) {
1337
- flushVisibleSections();
1338
- sectionObserver.disconnect();
1339
- sectionObserver = null;
1340
- }
1341
- if (flushTimeout) {
1342
- clearTimeout(flushTimeout);
1343
- flushTimeout = null;
1344
- }
1345
1565
  if (pathPollId !== null) {
1346
1566
  clearInterval(pathPollId);
1347
1567
  pathPollId = null;
1348
1568
  }
1349
- flushSync();
1569
+ disconnectSectionObserver();
1570
+ flushSync(config);
1571
+ cleanup();
1572
+ setIsDisabled(true);
1573
+ window.__do11yInitialized = false;
1350
1574
  }
1351
1575
  if (!_alreadyLoaded && !_isInIframe) if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
1352
1576
  else init();
@@ -1354,19 +1578,22 @@
1354
1578
  getConfig: () => ({
1355
1579
  destination: config.destination,
1356
1580
  hasCredentials: config.destination === "supabase" ? !!config.supabaseKey : config.destination === "otlp" ? !!config.otelSdkEndpoint : !!config.endpoint,
1357
- isDisabled,
1581
+ isDisabled: getIsDisabled(),
1358
1582
  allowedDomains: config.allowedDomains,
1359
1583
  respectDNT: config.respectDNT
1360
1584
  }),
1361
- flush,
1585
+ flush: () => flush(config),
1362
1586
  isEnabled: () => {
1363
- if (isDisabled) return false;
1587
+ if (getIsDisabled()) return false;
1364
1588
  if (config.destination === "supabase") return !!config.supabaseKey;
1365
1589
  if (config.destination === "otlp") return !!config.otelSdkEndpoint;
1366
1590
  return !!config.endpoint;
1367
1591
  },
1368
- getQueueSize: () => eventQueue.length,
1369
- version: VERSION
1592
+ getQueueSize: () => getQueueLength(),
1593
+ version: "0.2.1",
1594
+ destroy: () => destroy()
1370
1595
  };
1371
1596
  //#endregion
1372
- })();
1597
+ exports.destroy = destroy;
1598
+ return exports;
1599
+ })({});