@manototh/do11y 0.1.2 → 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 +1020 -800
- package/dist/do11y.min.js +1 -1
- package/dist/instrumentation/index.js +1293 -0
- package/examples/do11y-config.example.js +1 -1
- package/package.json +29 -6
package/dist/do11y.js
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
(function() {
|
|
2
|
-
|
|
1
|
+
var Do11yBundle = (function(exports) {
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
//#region src/core/constants.ts
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
594
|
-
else scheduleFlush();
|
|
529
|
+
return "no-crypto-00-0000-0000-000000000000";
|
|
595
530
|
}
|
|
596
|
-
function
|
|
597
|
-
if (
|
|
598
|
-
|
|
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
|
-
|
|
601
|
-
|
|
536
|
+
function getSession() {
|
|
537
|
+
let session = null;
|
|
602
538
|
try {
|
|
603
|
-
const
|
|
604
|
-
if (
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
558
|
+
function saveSession(session) {
|
|
612
559
|
try {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
665
|
-
return false;
|
|
658
|
+
checkScrollDepth(config, emit);
|
|
666
659
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
)
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
|
731
|
-
if (
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
*
|
|
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
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,555 @@
|
|
|
852
890
|
} catch {}
|
|
853
891
|
if (linkType === "internal" && !config.trackInternalLinks) return;
|
|
854
892
|
if (linkType === "external" && !config.trackOutboundLinks) return;
|
|
855
|
-
|
|
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]:
|
|
901
|
+
[ATTR_DO11Y_LINK_INDEX]: linkIndex
|
|
863
902
|
});
|
|
864
|
-
flush();
|
|
865
903
|
}, true);
|
|
866
904
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
if (
|
|
871
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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;
|
|
1069
|
+
}
|
|
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;
|
|
882
1096
|
}
|
|
883
|
-
|
|
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;
|
|
884
1108
|
}
|
|
885
|
-
|
|
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);
|
|
886
1116
|
}
|
|
887
|
-
function
|
|
888
|
-
if (
|
|
1117
|
+
function scheduleFlush(config) {
|
|
1118
|
+
if (flushTimeout) return;
|
|
1119
|
+
flushTimeout = setTimeout(() => flush(config), config.flushInterval);
|
|
1120
|
+
}
|
|
1121
|
+
function validateSupabaseUrl(url) {
|
|
889
1122
|
try {
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
+
}
|
|
894
1130
|
}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
if (
|
|
902
|
-
|
|
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;
|
|
903
1142
|
}
|
|
904
|
-
return null;
|
|
905
1143
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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;
|
|
919
1163
|
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1164
|
+
if (config.destination === "http") {
|
|
1165
|
+
if (!config.endpoint) {
|
|
1166
|
+
if (config.debug) console.warn("[Do11y] No HTTP endpoint configured");
|
|
1167
|
+
return false;
|
|
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;
|
|
928
1172
|
}
|
|
1173
|
+
return true;
|
|
929
1174
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
const sc = scrollContainer;
|
|
935
|
-
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;
|
|
936
1179
|
}
|
|
1180
|
+
return true;
|
|
937
1181
|
}
|
|
938
|
-
|
|
1182
|
+
if (config.debug) console.warn("[Do11y] Unknown destination:", config.destination);
|
|
1183
|
+
return false;
|
|
939
1184
|
}
|
|
940
1185
|
/**
|
|
941
|
-
*
|
|
942
|
-
*
|
|
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.
|
|
1186
|
+
* Dynamically import the OTel Browser SDK and set up the LoggerProvider.
|
|
1187
|
+
* Only called when destination is 'otlp'.
|
|
948
1188
|
*/
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
}
|
|
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: ""
|
|
984
1207
|
});
|
|
985
1208
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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();
|
|
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));
|
|
1012
1215
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
}
|
|
1024
|
-
});
|
|
1025
|
-
window.addEventListener("beforeunload", () => {
|
|
1026
|
-
emitPageExit();
|
|
1027
|
-
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;
|
|
1028
1226
|
});
|
|
1029
1227
|
}
|
|
1030
|
-
function
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
+
}) })]
|
|
1036
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);
|
|
1037
1259
|
}
|
|
1038
|
-
function
|
|
1039
|
-
if (
|
|
1040
|
-
|
|
1041
|
-
const
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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();
|
|
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
|
+
};
|
|
1111
1284
|
}
|
|
1112
|
-
function
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
}
|
|
1285
|
+
function isCrossOrigin(url) {
|
|
1286
|
+
try {
|
|
1287
|
+
return new URL(url).origin !== window.location.origin;
|
|
1288
|
+
} catch {
|
|
1289
|
+
return false;
|
|
1290
|
+
}
|
|
1118
1291
|
}
|
|
1119
|
-
function
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
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
|
-
}
|
|
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;
|
|
1137
1305
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
});
|
|
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);
|
|
1158
1326
|
});
|
|
1159
1327
|
}
|
|
1160
|
-
function
|
|
1161
|
-
if (
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
if (
|
|
1167
|
-
|
|
1168
|
-
|
|
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;
|
|
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 = [];
|
|
1182
1337
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
});
|
|
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);
|
|
1208
1346
|
}
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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)
|
|
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
|
|
1232
1366
|
});
|
|
1233
|
-
}
|
|
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
|
+
}
|
|
1234
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;
|
|
1235
1432
|
let mutationObserver = null;
|
|
1236
1433
|
let pathPollId = null;
|
|
1237
1434
|
function init() {
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
+
}
|
|
1240
1442
|
}
|
|
1241
1443
|
const metaDestination = document.querySelector("meta[name=\"do11y-destination\"]");
|
|
1242
1444
|
if (metaDestination) {
|
|
@@ -1266,57 +1468,62 @@
|
|
|
1266
1468
|
if (domainsStr) config.allowedDomains = domainsStr.split(",").map((d) => d.trim());
|
|
1267
1469
|
}
|
|
1268
1470
|
const metaFramework = document.querySelector("meta[name=\"do11y-framework\"]");
|
|
1269
|
-
if (metaFramework)
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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);
|
|
1282
1484
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
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);
|
|
1285
1494
|
if (config.debug) console.log("[Do11y] Tracking disabled");
|
|
1286
1495
|
return;
|
|
1287
1496
|
}
|
|
1288
1497
|
if (!(config.destination === "supabase" ? !!config.supabaseKey : config.destination === "otlp" ? !!config.otelSdkEndpoint : !!config.endpoint)) {
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
else console.warn("[Do11y] Add <meta name=\"do11y-endpoint\"> to enable.");
|
|
1294
|
-
}
|
|
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.");
|
|
1295
1502
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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);
|
|
1307
1517
|
let lastPath = window.location.pathname;
|
|
1308
1518
|
const handlePathChange = () => {
|
|
1309
1519
|
if (window.location.pathname === lastPath) return;
|
|
1310
1520
|
lastPath = window.location.pathname;
|
|
1311
|
-
emitPageExit();
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
totalActiveTime = 0;
|
|
1316
|
-
isPageVisible = true;
|
|
1317
|
-
trackPageView();
|
|
1521
|
+
emitPageExit(config, emit, () => flush(config));
|
|
1522
|
+
resetTrackedScrollDepths();
|
|
1523
|
+
resetEngagementState();
|
|
1524
|
+
trackPageView(config, emit);
|
|
1318
1525
|
observeHeadings();
|
|
1319
|
-
checkScrollDepth();
|
|
1526
|
+
checkScrollDepth(config, emit);
|
|
1320
1527
|
};
|
|
1321
1528
|
mutationObserver = new MutationObserver(handlePathChange);
|
|
1322
1529
|
mutationObserver.observe(document.body, {
|
|
@@ -1326,27 +1533,37 @@
|
|
|
1326
1533
|
window.addEventListener("popstate", handlePathChange);
|
|
1327
1534
|
pathPollId = window.setInterval(handlePathChange, 200);
|
|
1328
1535
|
Object.freeze(config);
|
|
1536
|
+
window.addEventListener("beforeunload", () => {
|
|
1537
|
+
if (pathPollId !== null) {
|
|
1538
|
+
clearInterval(pathPollId);
|
|
1539
|
+
pathPollId = null;
|
|
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);
|
|
1549
|
+
});
|
|
1329
1550
|
if (config.debug) console.log("[Do11y] Initialized successfully");
|
|
1330
1551
|
}
|
|
1331
|
-
|
|
1552
|
+
/** Tear down all tracking: remove listeners, disconnect observers, flush queue. */
|
|
1553
|
+
function destroy() {
|
|
1332
1554
|
if (mutationObserver) {
|
|
1333
1555
|
mutationObserver.disconnect();
|
|
1334
1556
|
mutationObserver = null;
|
|
1335
1557
|
}
|
|
1336
|
-
if (sectionObserver) {
|
|
1337
|
-
flushVisibleSections();
|
|
1338
|
-
sectionObserver.disconnect();
|
|
1339
|
-
sectionObserver = null;
|
|
1340
|
-
}
|
|
1341
|
-
if (flushTimeout) {
|
|
1342
|
-
clearTimeout(flushTimeout);
|
|
1343
|
-
flushTimeout = null;
|
|
1344
|
-
}
|
|
1345
1558
|
if (pathPollId !== null) {
|
|
1346
1559
|
clearInterval(pathPollId);
|
|
1347
1560
|
pathPollId = null;
|
|
1348
1561
|
}
|
|
1349
|
-
|
|
1562
|
+
disconnectSectionObserver();
|
|
1563
|
+
flushSync(config);
|
|
1564
|
+
cleanup();
|
|
1565
|
+
setIsDisabled(true);
|
|
1566
|
+
window.__do11yInitialized = false;
|
|
1350
1567
|
}
|
|
1351
1568
|
if (!_alreadyLoaded && !_isInIframe) if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
|
1352
1569
|
else init();
|
|
@@ -1354,19 +1571,22 @@
|
|
|
1354
1571
|
getConfig: () => ({
|
|
1355
1572
|
destination: config.destination,
|
|
1356
1573
|
hasCredentials: config.destination === "supabase" ? !!config.supabaseKey : config.destination === "otlp" ? !!config.otelSdkEndpoint : !!config.endpoint,
|
|
1357
|
-
isDisabled,
|
|
1574
|
+
isDisabled: getIsDisabled(),
|
|
1358
1575
|
allowedDomains: config.allowedDomains,
|
|
1359
1576
|
respectDNT: config.respectDNT
|
|
1360
1577
|
}),
|
|
1361
|
-
flush,
|
|
1578
|
+
flush: () => flush(config),
|
|
1362
1579
|
isEnabled: () => {
|
|
1363
|
-
if (
|
|
1580
|
+
if (getIsDisabled()) return false;
|
|
1364
1581
|
if (config.destination === "supabase") return !!config.supabaseKey;
|
|
1365
1582
|
if (config.destination === "otlp") return !!config.otelSdkEndpoint;
|
|
1366
1583
|
return !!config.endpoint;
|
|
1367
1584
|
},
|
|
1368
|
-
getQueueSize: () =>
|
|
1369
|
-
version:
|
|
1585
|
+
getQueueSize: () => getQueueLength(),
|
|
1586
|
+
version: "0.2.0",
|
|
1587
|
+
destroy: () => destroy()
|
|
1370
1588
|
};
|
|
1371
1589
|
//#endregion
|
|
1372
|
-
|
|
1590
|
+
exports.destroy = destroy;
|
|
1591
|
+
return exports;
|
|
1592
|
+
})({});
|