@manototh/do11y 0.1.1 → 0.2.0

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