@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.
@@ -0,0 +1,1293 @@
1
+ import { InstrumentationBase } from "@opentelemetry/instrumentation";
2
+ import { logs } from "@opentelemetry/api-logs";
3
+ //#region src/core/constants.ts
4
+ /**
5
+ * Do11y — Documentation Observability
6
+ *
7
+ * OTel semantic convention attribute keys and event names.
8
+ *
9
+ * Standard attrs from https://opentelemetry.io/docs/specs/semconv/.
10
+ * Custom do11y attrs use the `browser.do11y.*` namespace.
11
+ */
12
+ const VERSION = "0.2.0";
13
+ const ATTR_SESSION_ID = "session.id";
14
+ const ATTR_URL_PATH = "url.path";
15
+ const ATTR_URL_FRAGMENT = "url.fragment";
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";
22
+ const ATTR_DEVICE_TYPE = "device.type";
23
+ const ATTR_BROWSER_FAMILY = "browser.family";
24
+ const ATTR_BROWSER_LANGUAGE = "browser.language";
25
+ const ATTR_DO11Y_SESSION_PAGE_COUNT = "browser.do11y.session_page_count";
26
+ const ATTR_DO11Y_PAGE_TITLE = "browser.do11y.page_title";
27
+ const ATTR_DO11Y_VIEWPORT_CATEGORY = "browser.do11y.viewport_category";
28
+ const ATTR_DO11Y_TIMEZONE_OFFSET = "browser.do11y.timezone_offset";
29
+ const ATTR_DO11Y_REFERRER_CATEGORY = "browser.do11y.referrer_category";
30
+ const ATTR_DO11Y_AI_PLATFORM = "browser.do11y.ai_platform";
31
+ const ATTR_DO11Y_DO11Y_VERSION = "browser.do11y.version";
32
+ const ATTR_DO11Y_IS_FIRST_PAGE = "browser.do11y.is_first_page";
33
+ const ATTR_DO11Y_PREVIOUS_PATH = "browser.do11y.previous_path";
34
+ const ATTR_DO11Y_REFERRER_DOMAIN = "browser.do11y.referrer_domain";
35
+ const ATTR_DO11Y_LINK_TYPE = "browser.do11y.link.type";
36
+ const ATTR_DO11Y_LINK_TARGET_URL = "browser.do11y.link.target_url";
37
+ const ATTR_DO11Y_LINK_TARGET_DOMAIN = "browser.do11y.link.target_domain";
38
+ const ATTR_DO11Y_LINK_TEXT = "browser.do11y.link.text";
39
+ const ATTR_DO11Y_LINK_CONTEXT = "browser.do11y.link.context";
40
+ const ATTR_DO11Y_LINK_SECTION = "browser.do11y.link.section";
41
+ const ATTR_DO11Y_LINK_INDEX = "browser.do11y.link.index";
42
+ const ATTR_DO11Y_SCROLL_THRESHOLD = "browser.do11y.scroll.threshold";
43
+ const ATTR_DO11Y_SCROLL_PERCENT = "browser.do11y.scroll.percent";
44
+ const ATTR_DO11Y_TOTAL_TIME_SECONDS = "browser.do11y.page_exit.total_time_seconds";
45
+ const ATTR_DO11Y_ACTIVE_TIME_SECONDS = "browser.do11y.page_exit.active_time_seconds";
46
+ const ATTR_DO11Y_ENGAGEMENT_RATIO = "browser.do11y.page_exit.engagement_ratio";
47
+ const ATTR_DO11Y_MAX_SCROLL_DEPTH = "browser.do11y.page_exit.max_scroll_depth";
48
+ const ATTR_DO11Y_SEARCH_TRIGGER = "browser.do11y.search.trigger";
49
+ const ATTR_DO11Y_CODE_LANGUAGE = "browser.do11y.code.language";
50
+ const ATTR_DO11Y_CODE_SECTION = "browser.do11y.code.section";
51
+ const ATTR_DO11Y_CODE_INDEX = "browser.do11y.code.index";
52
+ const ATTR_DO11Y_SECTION_HEADING = "browser.do11y.section.heading";
53
+ const ATTR_DO11Y_SECTION_HEADING_LEVEL = "browser.do11y.section.heading_level";
54
+ const ATTR_DO11Y_SECTION_VISIBLE_SECONDS = "browser.do11y.section.visible_seconds";
55
+ const ATTR_DO11Y_TAB_LABEL = "browser.do11y.tab.label";
56
+ const ATTR_DO11Y_TAB_GROUP = "browser.do11y.tab.group";
57
+ const ATTR_DO11Y_TAB_IS_DEFAULT = "browser.do11y.tab.is_default";
58
+ const ATTR_DO11Y_TOC_HEADING = "browser.do11y.toc.heading";
59
+ const ATTR_DO11Y_TOC_HEADING_LEVEL = "browser.do11y.toc.heading_level";
60
+ const ATTR_DO11Y_TOC_POSITION = "browser.do11y.toc.position";
61
+ const ATTR_DO11Y_FEEDBACK_RATING = "browser.do11y.feedback.rating";
62
+ const ATTR_DO11Y_EXPAND_SUMMARY = "browser.do11y.expand.summary";
63
+ const ATTR_DO11Y_EXPAND_ACTION = "browser.do11y.expand.action";
64
+ const ATTR_DO11Y_EXPAND_SECTION = "browser.do11y.expand.section";
65
+ const EVENT_PAGE_VIEW = "browser.do11y.page_view";
66
+ const EVENT_PAGE_EXIT = "browser.do11y.page_exit";
67
+ const EVENT_SCROLL_DEPTH = "browser.do11y.scroll_depth";
68
+ const EVENT_LINK_CLICK = "browser.do11y.link_click";
69
+ const EVENT_SEARCH_OPENED = "browser.do11y.search_opened";
70
+ const EVENT_CODE_COPIED = "browser.do11y.code_copied";
71
+ const EVENT_SECTION_VISIBLE = "browser.do11y.section_visible";
72
+ const EVENT_TAB_SWITCH = "browser.do11y.tab_switch";
73
+ const EVENT_TOC_CLICK = "browser.do11y.toc_click";
74
+ const EVENT_FEEDBACK = "browser.do11y.feedback";
75
+ const EVENT_EXPAND_COLLAPSE = "browser.do11y.expand_collapse";
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/presets.ts
89
+ const FRAMEWORK_PRESETS = {
90
+ mintlify: {
91
+ searchSelector: "#search-bar-entry, #search-bar-entry-mobile, [class*=\"search\"]",
92
+ copyButtonSelector: "button[class*=\"copy\"], button[aria-label*=\"copy\" i]",
93
+ codeBlockSelector: "pre, [class*=\"code\"]",
94
+ navigationSelector: "nav, [role=\"navigation\"], #navbar, #sidebar, [class*=\"nav\"], [class*=\"sidebar\"]",
95
+ footerSelector: "footer, [role=\"contentinfo\"], [class*=\"footer\"]",
96
+ contentSelector: "main, article, [role=\"main\"], [class*=\"content\"]",
97
+ tabContainerSelector: "tabs, [role=\"tablist\"], [class*=\"tab\"]",
98
+ tocSelector: "#table-of-contents, [data-testid=\"table-of-contents\"], [class*=\"table-of-contents\"], [class*=\"toc\"]",
99
+ feedbackSelector: "feedback-toolbar, #feedback-thumbs-up, #feedback-thumbs-down, [class*=\"feedback\"], [class*=\"helpful\"]"
100
+ },
101
+ docusaurus: {
102
+ searchSelector: ".DocSearch, .DocSearch-Button",
103
+ copyButtonSelector: "button.clean-btn[aria-label*=\"copy\" i], button[class*=\"copyButton\"]",
104
+ codeBlockSelector: "pre, [class*=\"code\"]",
105
+ navigationSelector: "nav, [role=\"navigation\"], .navbar, .sidebar, [class*=\"nav\"], [class*=\"sidebar\"]",
106
+ footerSelector: "footer, [role=\"contentinfo\"], [class*=\"footer\"]",
107
+ contentSelector: "main, article, [role=\"main\"], [class*=\"content\"]",
108
+ tabContainerSelector: ".tabs[role=\"tablist\"], [class*=\"tabs\"]",
109
+ tocSelector: ".table-of-contents, [class*=\"toc\"]",
110
+ feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"]"
111
+ },
112
+ nextra: {
113
+ searchSelector: ".nextra-search input, input[placeholder*=\"search\" i], button[aria-label*=\"search\" i]",
114
+ copyButtonSelector: "button[class*=\"copy\"], button[aria-label*=\"copy\" i], button[title*=\"copy\" i]",
115
+ codeBlockSelector: "pre, [class*=\"code\"]",
116
+ navigationSelector: "nav, [role=\"navigation\"], [class*=\"nav\"], [class*=\"sidebar\"]",
117
+ footerSelector: "footer, [role=\"contentinfo\"], [class*=\"footer\"]",
118
+ contentSelector: "main, article, [role=\"main\"], [class*=\"content\"]",
119
+ tabContainerSelector: "[role=\"tablist\"], [class*=\"tab\"]",
120
+ tocSelector: ".nextra-toc, [class*=\"toc\"]",
121
+ feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"]"
122
+ },
123
+ "mkdocs-material": {
124
+ searchSelector: ".md-search__input",
125
+ copyButtonSelector: ".md-clipboard, .md-code__button[title=\"Copy to clipboard\"]",
126
+ codeBlockSelector: "pre, code, [class*=\"code\"]",
127
+ navigationSelector: "nav, [role=\"navigation\"], .md-nav, .md-sidebar",
128
+ footerSelector: "footer, [role=\"contentinfo\"], .md-footer",
129
+ contentSelector: "main, article, [role=\"main\"], .md-content",
130
+ tabContainerSelector: ".tabbed-labels, .md-typeset .tabbed-set",
131
+ tocSelector: ".md-sidebar--secondary .md-nav, [class*=\"toc\"]",
132
+ feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"]"
133
+ },
134
+ vitepress: {
135
+ searchSelector: ".VPNavBarSearch button, .VPNavBarSearchButton, #local-search",
136
+ copyButtonSelector: "button.copy, .vp-code-copy, button.copy[title*=\"Copy\"]",
137
+ codeBlockSelector: "div[class*=\"language-\"], pre, [class*=\"code\"]",
138
+ navigationSelector: "nav, [role=\"navigation\"], .VPNav, .VPSidebar, [class*=\"nav\"], [class*=\"sidebar\"]",
139
+ footerSelector: "footer, [role=\"contentinfo\"], .VPFooter, [class*=\"footer\"]",
140
+ contentSelector: "main, article, [role=\"main\"], .VPContent, [class*=\"content\"]",
141
+ tabContainerSelector: ".vp-code-group .tabs, [role=\"tablist\"]",
142
+ tocSelector: ".VPDocAsideOutline, .VPLocalNavOutlineDropdown, a.outline-link",
143
+ feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"]"
144
+ },
145
+ starlight: {
146
+ searchSelector: "site-search button[data-open-modal], sl-doc-search .DocSearch-Button, button[aria-label*=\"search\" i]",
147
+ copyButtonSelector: ".expressive-code .copy button, .copy button[data-code]",
148
+ codeBlockSelector: ".expressive-code pre, pre",
149
+ navigationSelector: "nav, [role=\"navigation\"], [class*=\"sidebar\"]",
150
+ footerSelector: "footer, [role=\"contentinfo\"], [class*=\"footer\"]",
151
+ contentSelector: "main, .sl-markdown-content, [role=\"main\"]",
152
+ tabContainerSelector: "starlight-tabs [role=\"tablist\"], [role=\"tablist\"]",
153
+ tocSelector: ".right-sidebar-panel, starlight-toc, mobile-starlight-toc",
154
+ feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"]"
155
+ },
156
+ docsy: {
157
+ searchSelector: ".td-search input, .td-search__input, #docsearch-0, #docsearch-1",
158
+ copyButtonSelector: "button[aria-label*=\"copy\" i], button[title*=\"copy\" i], .td-click-to-copy",
159
+ codeBlockSelector: ".highlight, pre.chroma, pre",
160
+ navigationSelector: "nav, [role=\"navigation\"], .td-sidebar, .td-navbar, [class*=\"sidebar\"]",
161
+ footerSelector: "footer, [role=\"contentinfo\"], .td-footer, [class*=\"footer\"]",
162
+ contentSelector: "main, article, [role=\"main\"], .td-content, [class*=\"content\"]",
163
+ tabContainerSelector: ".nav-tabs[role=\"tablist\"], [role=\"tablist\"], .tab-content",
164
+ tocSelector: ".td-toc, nav[id=\"TableOfContents\"], [class*=\"toc\"]",
165
+ feedbackSelector: ".feedback--answer, [class*=\"feedback\"], [class*=\"helpful\"]"
166
+ }
167
+ };
168
+ /**
169
+ * Apply framework-specific selectors to the config.
170
+ * For 'custom', uses whatever the user set in config; for named
171
+ * frameworks, loads the preset and lets explicit config values override.
172
+ */
173
+ function applyFrameworkSelectors(config) {
174
+ const preset = FRAMEWORK_PRESETS[config.framework];
175
+ if (preset) SELECTOR_KEYS.forEach((key) => {
176
+ if (!config[key]) config[key] = preset[key];
177
+ });
178
+ else if (config.framework !== "custom") {
179
+ if (config.debug) console.warn(`[Do11y] Unknown framework "${config.framework}". Falling back to generic selectors. Supported: ` + Object.keys(FRAMEWORK_PRESETS).join(", ") + ", custom");
180
+ }
181
+ const fallback = FRAMEWORK_PRESETS.mintlify;
182
+ if (!fallback) return;
183
+ SELECTOR_KEYS.forEach((key) => {
184
+ if (!config[key]) config[key] = fallback[key];
185
+ });
186
+ }
187
+ //#endregion
188
+ //#region src/core/privacy.ts
189
+ /**
190
+ * Validate a CSS selector string supplied through user configuration.
191
+ * Returns the selector unchanged if it is syntactically valid, or null
192
+ * if it is not. This prevents CSS selector injection from attacker-
193
+ * controlled config values (window.Do11yConfig / meta tags) reaching
194
+ * querySelectorAll / closest calls.
195
+ */
196
+ function validateSelector(selector) {
197
+ if (!selector || typeof selector !== "string") return null;
198
+ try {
199
+ document.querySelector(selector);
200
+ return selector;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+ function shouldDisableTracking(config) {
206
+ if (config.respectDNT && (navigator.doNotTrack === "1" || navigator.doNotTrack === "yes" || window.doNotTrack === "1")) {
207
+ if (config.debug) console.log("[Do11y] Disabled: Do Not Track is enabled");
208
+ return true;
209
+ }
210
+ if (config.allowedDomains && config.allowedDomains.length > 0) {
211
+ const currentDomain = window.location.hostname;
212
+ if (!config.allowedDomains.some((domain) => {
213
+ return currentDomain === domain || currentDomain.endsWith("." + domain);
214
+ })) {
215
+ if (config.debug) console.log("[Do11y] Disabled: Domain not allowed:", currentDomain);
216
+ return true;
217
+ }
218
+ }
219
+ return false;
220
+ }
221
+ //#endregion
222
+ //#region src/core/dom-utils.ts
223
+ function getElementClassName(el) {
224
+ if (typeof el.className === "string") return el.className;
225
+ const svgClass = el.className;
226
+ if (svgClass && typeof svgClass.baseVal === "string") return svgClass.baseVal;
227
+ return "";
228
+ }
229
+ function languageFromClassName(className) {
230
+ const match = className.match(/(?:^|\s)language-([\w-]+)(?:\s|$)/);
231
+ return match ? match[1] : null;
232
+ }
233
+ /**
234
+ * Read the code block language from the element and its ancestors.
235
+ * Frameworks often put `language-*` on a wrapper div (VitePress, Prism)
236
+ * rather than on the pre/code element itself.
237
+ */
238
+ function extractCodeLanguage(start) {
239
+ if (!start) return "unknown";
240
+ let el = start;
241
+ for (let depth = 0; el && depth < 12; depth++, el = el.parentElement) {
242
+ for (const attr of [
243
+ "language",
244
+ "data-language",
245
+ "data-lang",
246
+ "data-code-lang"
247
+ ]) {
248
+ const value = el.getAttribute(attr);
249
+ if (value) return value;
250
+ }
251
+ const fromClass = languageFromClassName(getElementClassName(el));
252
+ if (fromClass) return fromClass;
253
+ const langText = el.querySelector(":scope > span.lang")?.textContent?.trim();
254
+ if (langText) return langText;
255
+ const deepLang = el.querySelector("[data-language], [data-lang], [data-code-lang], [class*=\"language-\"], [language]");
256
+ if (deepLang) {
257
+ const dl = deepLang.getAttribute("language") ?? deepLang.getAttribute("data-language") ?? deepLang.getAttribute("data-lang") ?? deepLang.getAttribute("data-code-lang") ?? languageFromClassName(getElementClassName(deepLang));
258
+ if (dl) return dl;
259
+ }
260
+ }
261
+ return "unknown";
262
+ }
263
+ function resolveTocHash(href) {
264
+ if (href.startsWith("#")) return href;
265
+ const hashIndex = href.indexOf("#");
266
+ if (hashIndex === -1) return null;
267
+ const pathPart = href.slice(0, hashIndex);
268
+ if (!pathPart || pathPart === window.location.pathname || pathPart === `${window.location.pathname}${window.location.search}`) return href.slice(hashIndex);
269
+ return null;
270
+ }
271
+ function resolveTocContainer(link, config) {
272
+ const userSelector = validateSelector(config.tocSelector);
273
+ if (userSelector) {
274
+ const container = link.closest(userSelector);
275
+ if (container && container !== link && container.tagName !== "A") return container;
276
+ }
277
+ for (const sel of [
278
+ ".VPDocAsideOutline",
279
+ ".VPLocalNavOutlineDropdown",
280
+ ".table-of-contents",
281
+ ".right-sidebar-panel",
282
+ "starlight-toc",
283
+ "[class*=\"TableOfContents\"]",
284
+ "[class*=\"page-outline\"]",
285
+ "[class*=\"toc\"]",
286
+ "nav[id=\"TableOfContents\"]"
287
+ ]) {
288
+ const container = link.closest(sel);
289
+ if (container && container !== link && container.tagName !== "A") return container;
290
+ }
291
+ return link.parentElement && link.parentElement !== document.body ? link.parentElement : null;
292
+ }
293
+ function getNearestHeading(element) {
294
+ let current = element;
295
+ while (current && current !== document.body) {
296
+ let sibling = current.previousElementSibling;
297
+ while (sibling) {
298
+ if (/^H[1-6]$/.test(sibling.tagName)) return sibling.textContent?.trim().substring(0, 100) ?? null;
299
+ const headings = sibling.querySelectorAll("h1, h2, h3, h4, h5, h6");
300
+ if (headings.length > 0) return headings[headings.length - 1].textContent?.trim().substring(0, 100) ?? null;
301
+ sibling = sibling.previousElementSibling;
302
+ }
303
+ current = current.parentElement;
304
+ }
305
+ return null;
306
+ }
307
+ function sanitizeText(text, maxLength) {
308
+ if (!text || typeof text !== "string") return null;
309
+ const limit = maxLength ?? 100;
310
+ let sanitized = text;
311
+ sanitized = sanitized.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "[email]");
312
+ sanitized = sanitized.replace(/\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g, "[phone]");
313
+ sanitized = sanitized.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[redacted]");
314
+ sanitized = sanitized.replace(/\b(?:\d[ -]?){13,19}\b/g, "[card]");
315
+ sanitized = sanitized.replace(/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[token]");
316
+ sanitized = sanitized.replace(/\bxa[a-z]{2}-[A-Za-z0-9_-]{20,}/g, "[token]");
317
+ sanitized = sanitized.replace(/\b[0-9a-fA-F]{32,}\b/g, "[redacted]");
318
+ return sanitized.trim().substring(0, limit);
319
+ }
320
+ //#endregion
321
+ //#region src/core/context.ts
322
+ function categorizeViewport() {
323
+ const width = window.innerWidth;
324
+ if (width < 640) return "mobile";
325
+ if (width < 1024) return "tablet";
326
+ if (width < 1440) return "desktop";
327
+ return "large-desktop";
328
+ }
329
+ function getBrowserFamily() {
330
+ const ua = navigator.userAgent;
331
+ if (ua.includes("Firefox")) return "Firefox";
332
+ if (ua.includes("Edg")) return "Edge";
333
+ if (ua.includes("Chrome")) return "Chrome";
334
+ if (ua.includes("Safari")) return "Safari";
335
+ return "Other";
336
+ }
337
+ function getDeviceType() {
338
+ const ua = navigator.userAgent;
339
+ if (/Mobile|Android|iPhone|iPad/.test(ua)) {
340
+ if (/iPad|Tablet/.test(ua)) return "tablet";
341
+ return "mobile";
342
+ }
343
+ return "desktop";
344
+ }
345
+ function getBrowserContext() {
346
+ return {
347
+ [ATTR_DO11Y_VIEWPORT_CATEGORY]: categorizeViewport(),
348
+ [ATTR_BROWSER_FAMILY]: getBrowserFamily(),
349
+ [ATTR_DEVICE_TYPE]: getDeviceType(),
350
+ [ATTR_BROWSER_LANGUAGE]: (navigator.language || "").split("-")[0] || "unknown",
351
+ [ATTR_DO11Y_TIMEZONE_OFFSET]: (/* @__PURE__ */ new Date()).getTimezoneOffset() / 60
352
+ };
353
+ }
354
+ /**
355
+ * Known AI platform referrer patterns.
356
+ * Each entry maps a substring found in the referrer hostname to an AI
357
+ * platform label. Order matters: first match wins.
358
+ */
359
+ const AI_REFERRER_PATTERNS = [
360
+ {
361
+ match: "chatgpt",
362
+ platform: "ChatGPT"
363
+ },
364
+ {
365
+ match: "chat.com",
366
+ platform: "ChatGPT"
367
+ },
368
+ {
369
+ match: "openai",
370
+ platform: "ChatGPT"
371
+ },
372
+ {
373
+ match: "perplexity",
374
+ platform: "Perplexity"
375
+ },
376
+ {
377
+ match: "claude.ai",
378
+ platform: "Claude"
379
+ },
380
+ {
381
+ match: "anthropic",
382
+ platform: "Claude"
383
+ },
384
+ {
385
+ match: "gemini",
386
+ platform: "Gemini"
387
+ },
388
+ {
389
+ match: "copilot",
390
+ platform: "Copilot"
391
+ },
392
+ {
393
+ match: "deepseek",
394
+ platform: "DeepSeek"
395
+ },
396
+ {
397
+ match: "meta.ai",
398
+ platform: "Meta AI"
399
+ },
400
+ {
401
+ match: "grok",
402
+ platform: "Grok"
403
+ },
404
+ {
405
+ match: "x.ai",
406
+ platform: "Grok"
407
+ },
408
+ {
409
+ match: "mistral",
410
+ platform: "Mistral"
411
+ },
412
+ {
413
+ match: "you.com",
414
+ platform: "You.com"
415
+ },
416
+ {
417
+ match: "phind",
418
+ platform: "Phind"
419
+ }
420
+ ];
421
+ /**
422
+ * Classify a referrer hostname into a traffic source category.
423
+ * Returns { referrerCategory, aiPlatform } where aiPlatform is null
424
+ * for non-AI traffic.
425
+ */
426
+ function classifyReferrer(hostname) {
427
+ if (!hostname || hostname === "direct") return {
428
+ referrerCategory: "direct",
429
+ aiPlatform: null
430
+ };
431
+ if (hostname === "internal") return {
432
+ referrerCategory: "internal",
433
+ aiPlatform: null
434
+ };
435
+ if (hostname === "unknown") return {
436
+ referrerCategory: "unknown",
437
+ aiPlatform: null
438
+ };
439
+ const h = hostname.toLowerCase();
440
+ for (const pattern of AI_REFERRER_PATTERNS) if (h.indexOf(pattern.match) !== -1) return {
441
+ referrerCategory: "ai",
442
+ aiPlatform: pattern.platform
443
+ };
444
+ if (/google\.|bing\.|baidu\.|yandex\.|duckduckgo\.|yahoo\./.test(h)) return {
445
+ referrerCategory: "search-engine",
446
+ aiPlatform: null
447
+ };
448
+ if (/github\.|gitlab\.|bitbucket\./.test(h)) return {
449
+ referrerCategory: "code-host",
450
+ aiPlatform: null
451
+ };
452
+ if (/stackoverflow\.|stackexchange\.|reddit\.|news\.ycombinator\./.test(h)) return {
453
+ referrerCategory: "community",
454
+ aiPlatform: null
455
+ };
456
+ if (/twitter\.|x\.com|linkedin\.|facebook\.|threads\.net/.test(h)) return {
457
+ referrerCategory: "social",
458
+ aiPlatform: null
459
+ };
460
+ return {
461
+ referrerCategory: "other",
462
+ aiPlatform: null
463
+ };
464
+ }
465
+ function getReferrerDomain() {
466
+ try {
467
+ if (!document.referrer) return "direct";
468
+ const url = new URL(document.referrer);
469
+ if (url.hostname === window.location.hostname) return "internal";
470
+ return url.hostname;
471
+ } catch {
472
+ return "unknown";
473
+ }
474
+ }
475
+ function getPageInfo() {
476
+ return {
477
+ [ATTR_URL_PATH]: window.location.pathname,
478
+ [ATTR_URL_FRAGMENT]: window.location.hash || null,
479
+ [ATTR_DO11Y_URL_HAS_PARAMS]: window.location.search ? "has_params" : null,
480
+ [ATTR_DO11Y_PAGE_TITLE]: sanitizeText(document.title, 150)
481
+ };
482
+ }
483
+ //#endregion
484
+ //#region src/core/session.ts
485
+ function generateSessionId() {
486
+ if (window.crypto && typeof window.crypto.randomUUID === "function") return window.crypto.randomUUID();
487
+ if (window.crypto && typeof window.crypto.getRandomValues === "function") {
488
+ const arr = new Uint8Array(16);
489
+ window.crypto.getRandomValues(arr);
490
+ arr[6] = arr[6] & 15 | 64;
491
+ arr[8] = arr[8] & 63 | 128;
492
+ const hex = Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
493
+ return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20);
494
+ }
495
+ return "no-crypto-00-0000-0000-000000000000";
496
+ }
497
+ function isValidSessionData(value) {
498
+ if (!value || typeof value !== "object") return false;
499
+ const v = value;
500
+ return typeof v.id === "string" && v.id.length > 0 && typeof v.startTime === "string" && Array.isArray(v.pageSequence) && typeof v.pageCount === "number";
501
+ }
502
+ function getSession() {
503
+ let session = null;
504
+ try {
505
+ const stored = sessionStorage.getItem("do11y_session");
506
+ if (stored) {
507
+ const parsed = JSON.parse(stored);
508
+ if (isValidSessionData(parsed)) session = parsed;
509
+ }
510
+ } catch {}
511
+ if (!session) {
512
+ session = {
513
+ id: generateSessionId(),
514
+ startTime: (/* @__PURE__ */ new Date()).toISOString(),
515
+ pageSequence: [],
516
+ pageCount: 0,
517
+ referrerCategory: null,
518
+ aiPlatform: null
519
+ };
520
+ saveSession(session);
521
+ }
522
+ return session;
523
+ }
524
+ function saveSession(session) {
525
+ try {
526
+ sessionStorage.setItem("do11y_session", JSON.stringify(session));
527
+ } catch {}
528
+ }
529
+ function updatePageSequence(path) {
530
+ const session = getSession();
531
+ session.pageCount++;
532
+ session.pageSequence.push({
533
+ path,
534
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
535
+ index: session.pageCount
536
+ });
537
+ if (session.pageSequence.length > 50) session.pageSequence = session.pageSequence.slice(-50);
538
+ saveSession(session);
539
+ return session;
540
+ }
541
+ //#endregion
542
+ //#region src/core/tracking/scroll.ts
543
+ let trackedScrollDepths = /* @__PURE__ */ new Set();
544
+ let scrollContainer = null;
545
+ function findScrollableAncestor(el) {
546
+ let current = el;
547
+ while (current && current !== document.body && current !== document.documentElement) {
548
+ const overflowY = window.getComputedStyle(current).overflowY;
549
+ if ((overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight) return current;
550
+ current = current.parentElement;
551
+ }
552
+ return null;
553
+ }
554
+ /**
555
+ * Check and track scroll depth thresholds.
556
+ * Reads from the detected scroll container when present, otherwise
557
+ * falls back to the window/document.
558
+ *
559
+ * If the page fits entirely in the viewport (no scrollbar), all
560
+ * thresholds are marked as reached since the user can see 100% of
561
+ * the content without scrolling.
562
+ */
563
+ function checkScrollDepth(config, emit) {
564
+ let scrollTop;
565
+ let totalHeight;
566
+ let viewportHeight;
567
+ if (scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight) {
568
+ scrollTop = scrollContainer.scrollTop;
569
+ totalHeight = scrollContainer.scrollHeight;
570
+ viewportHeight = scrollContainer.clientHeight;
571
+ } else {
572
+ scrollTop = window.scrollY || document.documentElement.scrollTop;
573
+ totalHeight = document.documentElement.scrollHeight;
574
+ viewportHeight = window.innerHeight;
575
+ }
576
+ const docHeight = totalHeight - viewportHeight;
577
+ if (docHeight <= 0) {
578
+ config.scrollThresholds.forEach((threshold) => {
579
+ if (!trackedScrollDepths.has(threshold)) {
580
+ trackedScrollDepths.add(threshold);
581
+ emit(EVENT_SCROLL_DEPTH, {
582
+ [ATTR_DO11Y_SCROLL_THRESHOLD]: threshold,
583
+ [ATTR_DO11Y_SCROLL_PERCENT]: 100
584
+ });
585
+ }
586
+ });
587
+ return;
588
+ }
589
+ const scrollPercent = Math.round(scrollTop / docHeight * 100);
590
+ config.scrollThresholds.forEach((threshold) => {
591
+ if (scrollPercent >= threshold && !trackedScrollDepths.has(threshold)) {
592
+ trackedScrollDepths.add(threshold);
593
+ emit(EVENT_SCROLL_DEPTH, {
594
+ [ATTR_DO11Y_SCROLL_THRESHOLD]: threshold,
595
+ [ATTR_DO11Y_SCROLL_PERCENT]: scrollPercent
596
+ });
597
+ }
598
+ });
599
+ }
600
+ function setupScrollTracking(config, emit) {
601
+ if (!config.trackScrollDepth) return;
602
+ if (config.contentSelector) {
603
+ const contentEl = document.querySelector(config.contentSelector);
604
+ if (contentEl) scrollContainer = findScrollableAncestor(contentEl);
605
+ }
606
+ let ticking = false;
607
+ function onScroll() {
608
+ if (!ticking) {
609
+ window.requestAnimationFrame(() => {
610
+ checkScrollDepth(config, emit);
611
+ ticking = false;
612
+ });
613
+ ticking = true;
614
+ }
615
+ }
616
+ window.addEventListener("scroll", onScroll);
617
+ if (scrollContainer) {
618
+ scrollContainer.addEventListener("scroll", onScroll);
619
+ if (config.debug) {
620
+ const sc = scrollContainer;
621
+ console.log("[do11y] Using container-based scroll tracking:", sc.className || sc.tagName);
622
+ }
623
+ }
624
+ checkScrollDepth(config, emit);
625
+ }
626
+ function resetTrackedScrollDepths() {
627
+ trackedScrollDepths = /* @__PURE__ */ new Set();
628
+ }
629
+ function getTrackedScrollDepths() {
630
+ return trackedScrollDepths;
631
+ }
632
+ //#endregion
633
+ //#region src/core/tracking/sections.ts
634
+ function emitSectionEvent(emit, el, elapsedMs) {
635
+ emit(EVENT_SECTION_VISIBLE, {
636
+ [ATTR_DO11Y_SECTION_HEADING]: sanitizeText(el.textContent?.trim() ?? "", 100),
637
+ [ATTR_DO11Y_SECTION_HEADING_LEVEL]: parseInt(el.tagName.charAt(1), 10),
638
+ [ATTR_DO11Y_SECTION_VISIBLE_SECONDS]: Math.round(elapsedMs / 1e3)
639
+ });
640
+ }
641
+ let sectionObserver = null;
642
+ let sectionTimers = {};
643
+ function setupSectionVisibilityTracking(config, emit) {
644
+ if (!config.trackSectionVisibility) return;
645
+ if (typeof IntersectionObserver === "undefined") return;
646
+ const threshold = config.sectionVisibleThreshold * 1e3;
647
+ sectionObserver = new IntersectionObserver((entries) => {
648
+ entries.forEach((entry) => {
649
+ const id = entry.target.getAttribute("data-do11y-section-id");
650
+ if (!id) return;
651
+ if (entry.isIntersecting) {
652
+ if (!sectionTimers[id]) {
653
+ const timer = {
654
+ start: Date.now(),
655
+ reported: false,
656
+ timeoutId: null
657
+ };
658
+ timer.timeoutId = setTimeout(() => {
659
+ if (sectionTimers[id] && !sectionTimers[id].reported) {
660
+ emitSectionEvent(emit, entry.target, threshold);
661
+ sectionTimers[id].reported = true;
662
+ }
663
+ }, threshold);
664
+ sectionTimers[id] = timer;
665
+ }
666
+ } else {
667
+ if (sectionTimers[id]) {
668
+ if (sectionTimers[id].timeoutId) clearTimeout(sectionTimers[id].timeoutId);
669
+ if (!sectionTimers[id].reported) {
670
+ const elapsed = Date.now() - sectionTimers[id].start;
671
+ if (elapsed >= threshold) {
672
+ emitSectionEvent(emit, entry.target, elapsed);
673
+ sectionTimers[id].reported = true;
674
+ }
675
+ }
676
+ }
677
+ delete sectionTimers[id];
678
+ }
679
+ });
680
+ }, { threshold: .5 });
681
+ observeHeadings();
682
+ }
683
+ function observeHeadings() {
684
+ if (!sectionObserver) return;
685
+ document.querySelectorAll("h2, h3").forEach((h, i) => {
686
+ h.setAttribute("data-do11y-section-id", "section-" + i);
687
+ sectionObserver.observe(h);
688
+ });
689
+ }
690
+ function flushVisibleSections(config, emit) {
691
+ if (!sectionObserver) return;
692
+ const now = Date.now();
693
+ const threshold = config.sectionVisibleThreshold * 1e3;
694
+ Object.keys(sectionTimers).forEach((id) => {
695
+ const timer = sectionTimers[id];
696
+ if (timer && !timer.reported) {
697
+ if (timer.timeoutId) clearTimeout(timer.timeoutId);
698
+ const elapsed = now - timer.start;
699
+ if (elapsed >= threshold) {
700
+ const escapedId = typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(id) : id.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~ ]/g, "\\$&");
701
+ const el = document.querySelector("[data-do11y-section-id=\"" + escapedId + "\"]");
702
+ if (el) emitSectionEvent(emit, el, elapsed);
703
+ }
704
+ }
705
+ });
706
+ sectionTimers = {};
707
+ }
708
+ function disconnectSectionObserver() {
709
+ if (sectionObserver) {
710
+ if (sectionTimers && Object.keys(sectionTimers).length > 0) {
711
+ Object.keys(sectionTimers).forEach((id) => {
712
+ const timer = sectionTimers[id];
713
+ if (timer && !timer.reported) {
714
+ if (timer.timeoutId) clearTimeout(timer.timeoutId);
715
+ }
716
+ });
717
+ sectionTimers = {};
718
+ }
719
+ sectionObserver.disconnect();
720
+ sectionObserver = null;
721
+ }
722
+ }
723
+ //#endregion
724
+ //#region src/core/tracking/engagement.ts
725
+ let pageLoadTime = Date.now();
726
+ let lastActivityTime = Date.now();
727
+ let totalActiveTime = 0;
728
+ let isPageVisible = true;
729
+ let pageExited = false;
730
+ /**
731
+ * @param afterEmit Optional callback invoked after the exit event is emitted.
732
+ * Used by the standalone build to flush the transport before the page unloads.
733
+ */
734
+ function emitPageExit(config, emit, afterEmit) {
735
+ if (pageExited) return;
736
+ pageExited = true;
737
+ if (isPageVisible) totalActiveTime += Date.now() - lastActivityTime;
738
+ const totalTime = Date.now() - pageLoadTime;
739
+ const engagementRatio = totalTime > 0 ? totalActiveTime / totalTime : 0;
740
+ let maxScroll = 0;
741
+ getTrackedScrollDepths().forEach((depth) => {
742
+ if (depth > maxScroll) maxScroll = depth;
743
+ });
744
+ flushVisibleSections(config, emit);
745
+ const session = getSession();
746
+ emit(EVENT_PAGE_EXIT, {
747
+ [ATTR_DO11Y_TOTAL_TIME_SECONDS]: Math.round(totalTime / 1e3),
748
+ [ATTR_DO11Y_ACTIVE_TIME_SECONDS]: Math.round(totalActiveTime / 1e3),
749
+ [ATTR_DO11Y_ENGAGEMENT_RATIO]: Math.round(engagementRatio * 100) / 100,
750
+ [ATTR_DO11Y_MAX_SCROLL_DEPTH]: maxScroll,
751
+ [ATTR_DO11Y_REFERRER_CATEGORY]: session.referrerCategory,
752
+ [ATTR_DO11Y_AI_PLATFORM]: session.aiPlatform
753
+ });
754
+ afterEmit?.();
755
+ }
756
+ function setupEngagementTracking(config, emit) {
757
+ document.addEventListener("visibilitychange", () => {
758
+ if (document.hidden) {
759
+ if (isPageVisible) {
760
+ totalActiveTime += Date.now() - lastActivityTime;
761
+ isPageVisible = false;
762
+ }
763
+ } else {
764
+ lastActivityTime = Date.now();
765
+ isPageVisible = true;
766
+ }
767
+ });
768
+ window.addEventListener("beforeunload", () => {
769
+ emitPageExit(config, emit);
770
+ });
771
+ }
772
+ function resetEngagementState() {
773
+ pageLoadTime = Date.now();
774
+ lastActivityTime = Date.now();
775
+ totalActiveTime = 0;
776
+ isPageVisible = true;
777
+ pageExited = false;
778
+ }
779
+ /**
780
+ * Reset only the page_exit guard flag, without affecting timing data.
781
+ * Called by trackPageView() so that the guard is cleared even if
782
+ * resetEngagementState() (which also resets it) was not invoked.
783
+ */
784
+ function resetPageExitedGuard() {
785
+ pageExited = false;
786
+ }
787
+ //#endregion
788
+ //#region src/core/tracking/page-view.ts
789
+ function trackPageView(config, emit) {
790
+ resetPageExitedGuard();
791
+ const session = updatePageSequence(window.location.pathname);
792
+ const referrerDomain = getReferrerDomain();
793
+ const referrerInfo = classifyReferrer(referrerDomain);
794
+ if (session.pageCount === 1) {
795
+ session.referrerCategory = referrerInfo.referrerCategory;
796
+ session.aiPlatform = referrerInfo.aiPlatform;
797
+ saveSession(session);
798
+ }
799
+ emit(EVENT_PAGE_VIEW, {
800
+ [ATTR_DO11Y_REFERRER_DOMAIN]: referrerDomain,
801
+ [ATTR_DO11Y_REFERRER_CATEGORY]: referrerInfo.referrerCategory,
802
+ [ATTR_DO11Y_AI_PLATFORM]: referrerInfo.aiPlatform,
803
+ [ATTR_DO11Y_IS_FIRST_PAGE]: session.pageCount === 1,
804
+ [ATTR_DO11Y_PREVIOUS_PATH]: session.pageSequence.length > 1 ? session.pageSequence[session.pageSequence.length - 2].path : null
805
+ });
806
+ }
807
+ //#endregion
808
+ //#region src/core/tracking/links.ts
809
+ function getLinkContext(link, config) {
810
+ if (link.closest(config.navigationSelector)) return "navigation";
811
+ if (link.closest(config.footerSelector)) return "footer";
812
+ if (link.closest(config.contentSelector)) return "content";
813
+ return "other";
814
+ }
815
+ /**
816
+ * Pre-compute same-href indices for all `<a>` elements on the page.
817
+ * This avoids O(n) querySelectorAll calls on every click.
818
+ * Data attributes are set at init time and read directly on click.
819
+ */
820
+ function precomputeLinkIndices() {
821
+ try {
822
+ const linkGroups = /* @__PURE__ */ new Map();
823
+ document.querySelectorAll("a[href]").forEach((link) => {
824
+ const href = link.getAttribute("href") ?? "";
825
+ const group = linkGroups.get(href) ?? [];
826
+ group.push(link);
827
+ linkGroups.set(href, group);
828
+ });
829
+ linkGroups.forEach((links) => {
830
+ links.forEach((link, idx) => {
831
+ link.setAttribute("data-do11y-link-idx", String(idx + 1));
832
+ });
833
+ });
834
+ } catch {}
835
+ }
836
+ function setupLinkTracking(config, emit) {
837
+ precomputeLinkIndices();
838
+ document.addEventListener("click", (e) => {
839
+ const link = e.target.closest("a");
840
+ if (!link) return;
841
+ const href = link.getAttribute("href");
842
+ if (!href) return;
843
+ let linkType = "other";
844
+ let targetDomain = null;
845
+ try {
846
+ if (href.startsWith("#")) linkType = "anchor";
847
+ else if (href.startsWith("/") || href.startsWith("./") || href.startsWith("../")) linkType = "internal";
848
+ else if (href.startsWith("http")) {
849
+ const url = new URL(href);
850
+ if (url.hostname === window.location.hostname) linkType = "internal";
851
+ else {
852
+ linkType = "external";
853
+ targetDomain = url.hostname;
854
+ }
855
+ } else if (href.startsWith("mailto:")) linkType = "email";
856
+ } catch {}
857
+ if (linkType === "internal" && !config.trackInternalLinks) return;
858
+ if (linkType === "external" && !config.trackOutboundLinks) return;
859
+ const linkIndex = parseInt(link.getAttribute("data-do11y-link-idx") ?? "1", 10);
860
+ emit(EVENT_LINK_CLICK, {
861
+ [ATTR_DO11Y_LINK_TYPE]: linkType,
862
+ [ATTR_DO11Y_LINK_TARGET_URL]: href,
863
+ [ATTR_DO11Y_LINK_TARGET_DOMAIN]: targetDomain,
864
+ [ATTR_DO11Y_LINK_TEXT]: sanitizeText(link.textContent, 100),
865
+ [ATTR_DO11Y_LINK_CONTEXT]: getLinkContext(link, config),
866
+ [ATTR_DO11Y_LINK_SECTION]: sanitizeText(getNearestHeading(link), 100),
867
+ [ATTR_DO11Y_LINK_INDEX]: linkIndex
868
+ });
869
+ }, true);
870
+ }
871
+ //#endregion
872
+ //#region src/core/tracking/search.ts
873
+ function setupSearchTracking(config, emit) {
874
+ if (!config.trackSearch) return;
875
+ document.addEventListener("click", (e) => {
876
+ if (e.target.closest(config.searchSelector)) emit(EVENT_SEARCH_OPENED, {});
877
+ }, true);
878
+ document.addEventListener("keydown", (e) => {
879
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
880
+ if (!document.querySelector(config.searchSelector)) return;
881
+ emit(EVENT_SEARCH_OPENED, { [ATTR_DO11Y_SEARCH_TRIGGER]: "keyboard" });
882
+ }
883
+ });
884
+ }
885
+ //#endregion
886
+ //#region src/core/tracking/copy.ts
887
+ /**
888
+ * Pre-compute code block indices at init time to avoid O(n) querySelectorAll
889
+ * calls on every copy button click. Elements are assigned a
890
+ * data-do11y-code-idx attribute read directly on click.
891
+ */
892
+ function precomputeCodeBlockIndices(config) {
893
+ try {
894
+ document.querySelectorAll(config.codeBlockSelector).forEach((block, idx) => {
895
+ block.setAttribute("data-do11y-code-idx", String(idx + 1));
896
+ });
897
+ } catch {}
898
+ }
899
+ function setupCopyTracking(config, emit) {
900
+ if (!config.trackCopy) return;
901
+ precomputeCodeBlockIndices(config);
902
+ document.addEventListener("click", (e) => {
903
+ const copyButton = e.target.closest(config.copyButtonSelector);
904
+ if (copyButton) {
905
+ 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;
906
+ const language = extractCodeLanguage((codeBlock ? codeBlock.tagName === "PRE" ? codeBlock.querySelector("code") : codeBlock.querySelector("code[class*=\"language-\"], code[language]") ?? codeBlock.querySelector("code") : null) ?? codeBlock ?? copyButton);
907
+ const codeIndex = parseInt(codeBlock?.getAttribute("data-do11y-code-idx") ?? "1", 10);
908
+ emit(EVENT_CODE_COPIED, {
909
+ [ATTR_DO11Y_CODE_LANGUAGE]: language,
910
+ [ATTR_DO11Y_CODE_SECTION]: sanitizeText(getNearestHeading(codeBlock ?? copyButton), 100),
911
+ [ATTR_DO11Y_CODE_INDEX]: codeIndex
912
+ });
913
+ }
914
+ }, true);
915
+ }
916
+ //#endregion
917
+ //#region src/core/tracking/tabs.ts
918
+ function setupTabSwitchTracking(config, emit) {
919
+ if (!config.trackTabSwitches) return;
920
+ document.addEventListener("click", (e) => {
921
+ let baseSel = "[role=\"tab\"], .tabs button, .tabs a, .tabbed-labels label";
922
+ const safeTabSel = validateSelector(config.tabContainerSelector);
923
+ if (safeTabSel) baseSel += ", " + safeTabSel + " button, " + safeTabSel + " a, " + safeTabSel + " label";
924
+ const tab = e.target.closest(baseSel);
925
+ if (!tab) return;
926
+ if (tab.getAttribute("aria-selected") === "true" || tab.classList.contains("active") || tab.classList.contains("is-active")) return;
927
+ const label = sanitizeText(tab.textContent, 50);
928
+ if (!label) return;
929
+ const section = sanitizeText(getNearestHeading(tab), 100);
930
+ emit(EVENT_TAB_SWITCH, {
931
+ [ATTR_DO11Y_TAB_LABEL]: label,
932
+ [ATTR_DO11Y_TAB_GROUP]: section,
933
+ [ATTR_DO11Y_TAB_IS_DEFAULT]: false
934
+ });
935
+ });
936
+ }
937
+ //#endregion
938
+ //#region src/core/tracking/toc.ts
939
+ function setupTocClickTracking(config, emit) {
940
+ if (!config.trackTocClicks) return;
941
+ document.addEventListener("click", (e) => {
942
+ const link = e.target.closest("a");
943
+ if (!link) return;
944
+ const tocContainer = resolveTocContainer(link, config);
945
+ if (!tocContainer) return;
946
+ const href = link.getAttribute("href");
947
+ const hash = href ? resolveTocHash(href) : null;
948
+ if (!hash) return;
949
+ const headingText = sanitizeText(link.textContent, 100);
950
+ let headingLevel = null;
951
+ try {
952
+ const targetId = hash.slice(1);
953
+ const targetEl = document.getElementById(targetId);
954
+ if (targetEl && /^H[1-6]$/.test(targetEl.tagName)) headingLevel = parseInt(targetEl.tagName.charAt(1), 10);
955
+ } catch {}
956
+ const tocLinks = tocContainer.querySelectorAll("a[href*=\"#\"]");
957
+ let tocPosition = 1;
958
+ for (let i = 0; i < tocLinks.length; i++) if (tocLinks[i] === link) {
959
+ tocPosition = i + 1;
960
+ break;
961
+ }
962
+ emit(EVENT_TOC_CLICK, {
963
+ [ATTR_DO11Y_TOC_HEADING]: headingText,
964
+ [ATTR_DO11Y_TOC_HEADING_LEVEL]: headingLevel,
965
+ [ATTR_DO11Y_TOC_POSITION]: tocPosition
966
+ });
967
+ }, true);
968
+ }
969
+ //#endregion
970
+ //#region src/core/tracking/feedback.ts
971
+ function setupFeedbackTracking(config, emit) {
972
+ if (!config.trackFeedback) return;
973
+ document.addEventListener("click", (e) => {
974
+ const button = e.target.closest("button, [role=\"button\"], a");
975
+ if (!button) return;
976
+ if (!button.closest(validateSelector(config.feedbackSelector) ?? "[class*=\"feedback\"], [class*=\"helpful\"], [class*=\"rating\"], [class*=\"was-this\"], [data-feedback]")) return;
977
+ const buttonText = (button.textContent ?? "").trim().toLowerCase();
978
+ const ariaLabel = (button.getAttribute("aria-label") ?? "").toLowerCase();
979
+ const titleAttr = (button.getAttribute("title") ?? "").toLowerCase();
980
+ const rawDataValue = button.getAttribute("data-value") ?? button.getAttribute("data-md-value") ?? button.getAttribute("data-feedback");
981
+ const dataValue = rawDataValue && /^[\w\s.,!?-]{1,50}$/.test(rawDataValue) ? rawDataValue : null;
982
+ let rating = null;
983
+ if (dataValue) rating = dataValue;
984
+ else if (/\byes\b|👍|thumbs.?up|helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "yes";
985
+ else if (/\bno\b|👎|thumbs.?down|not.?helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "no";
986
+ if (!rating) return;
987
+ emit(EVENT_FEEDBACK, { [ATTR_DO11Y_FEEDBACK_RATING]: rating });
988
+ });
989
+ }
990
+ //#endregion
991
+ //#region src/core/tracking/expand.ts
992
+ function setupExpandCollapseTracking(config, emit) {
993
+ if (!config.trackExpandCollapse) return;
994
+ document.addEventListener("toggle", (e) => {
995
+ const details = e.target;
996
+ if (details.tagName !== "DETAILS") return;
997
+ const summary = details.querySelector("summary");
998
+ const label = sanitizeText(summary ? summary.textContent : "", 100);
999
+ emit(EVENT_EXPAND_COLLAPSE, {
1000
+ [ATTR_DO11Y_EXPAND_SUMMARY]: label,
1001
+ [ATTR_DO11Y_EXPAND_ACTION]: details.open ? "expand" : "collapse",
1002
+ [ATTR_DO11Y_EXPAND_SECTION]: sanitizeText(getNearestHeading(details), 100)
1003
+ });
1004
+ }, true);
1005
+ document.addEventListener("click", (e) => {
1006
+ const trigger = e.target.closest("[aria-expanded], [class*=\"accordion\"] button, [class*=\"collapsible\"] button");
1007
+ if (!trigger) return;
1008
+ if (trigger.closest("details")) return;
1009
+ if (trigger.closest("nav, [role=\"navigation\"], header")) return;
1010
+ const wasExpanded = trigger.getAttribute("aria-expanded") === "true";
1011
+ emit(EVENT_EXPAND_COLLAPSE, {
1012
+ [ATTR_DO11Y_EXPAND_SUMMARY]: sanitizeText(trigger.textContent, 100),
1013
+ [ATTR_DO11Y_EXPAND_ACTION]: wasExpanded ? "collapse" : "expand",
1014
+ [ATTR_DO11Y_EXPAND_SECTION]: sanitizeText(getNearestHeading(trigger), 100)
1015
+ });
1016
+ });
1017
+ }
1018
+ //#endregion
1019
+ //#region src/core/rate-limit.ts
1020
+ /**
1021
+ * Do11y — Documentation Observability
1022
+ *
1023
+ * Shared event rate limiter used by both the standalone transport and the
1024
+ * OTel instrumentation build.
1025
+ *
1026
+ * Rate-limiting prevents event spam (duplicate `page_exit` on SPA
1027
+ * navigation, rapid same-type bursts). The rate-limit key is per event
1028
+ * name, except for scroll depth milestones: a fast scroll can cross several
1029
+ * thresholds in a single frame, so the key includes the threshold attribute
1030
+ * to let each milestone through independently.
1031
+ */
1032
+ function createRateLimiter() {
1033
+ const lastEventTime = {};
1034
+ return {
1035
+ allow(eventName, eventData, rateLimitMs, debug) {
1036
+ const now = Date.now();
1037
+ const rateKey = eventData["browser.do11y.scroll.threshold"] !== null && eventData["browser.do11y.scroll.threshold"] !== void 0 ? `${eventName}:${String(eventData[ATTR_DO11Y_SCROLL_THRESHOLD])}` : eventName;
1038
+ if (rateLimitMs > 0 && lastEventTime[rateKey]) {
1039
+ if (now - lastEventTime[rateKey] < rateLimitMs) {
1040
+ if (debug) console.log("[Do11y] Rate limited:", eventName);
1041
+ return false;
1042
+ }
1043
+ }
1044
+ lastEventTime[rateKey] = now;
1045
+ return true;
1046
+ },
1047
+ reset() {
1048
+ for (const key of Object.keys(lastEventTime)) delete lastEventTime[key];
1049
+ }
1050
+ };
1051
+ }
1052
+ //#endregion
1053
+ //#region src/instrumentation/config.ts
1054
+ /**
1055
+ * Build a normalized Do11yConfig from the instrumentation's user config.
1056
+ * This bridges the gap between the simplified DocsInstrumentationConfig
1057
+ * and the full Do11yConfig used by the core tracking modules.
1058
+ */
1059
+ function buildConfig(userConfig) {
1060
+ return {
1061
+ framework: userConfig.framework ?? "mintlify",
1062
+ debug: userConfig.debug ?? false,
1063
+ rateLimitMs: userConfig.rateLimitMs ?? 100,
1064
+ trackScrollDepth: userConfig.trackScrollDepth ?? true,
1065
+ scrollThresholds: userConfig.scrollThresholds ?? [
1066
+ 25,
1067
+ 50,
1068
+ 75,
1069
+ 90
1070
+ ],
1071
+ trackOutboundLinks: userConfig.trackOutboundLinks ?? true,
1072
+ trackInternalLinks: userConfig.trackInternalLinks ?? true,
1073
+ trackSectionVisibility: userConfig.trackSectionVisibility ?? true,
1074
+ sectionVisibleThreshold: userConfig.sectionVisibleThreshold ?? 3,
1075
+ trackSearch: userConfig.trackSearch ?? true,
1076
+ trackCopy: userConfig.trackCopy ?? true,
1077
+ trackTabSwitches: userConfig.trackTabSwitches ?? true,
1078
+ trackTocClicks: userConfig.trackTocClicks ?? true,
1079
+ trackExpandCollapse: userConfig.trackExpandCollapse ?? true,
1080
+ trackFeedback: userConfig.trackFeedback ?? true,
1081
+ trackSpaPathChanges: userConfig.trackSpaPathChanges ?? false,
1082
+ sessionAttributes: userConfig.sessionAttributes ?? true,
1083
+ respectDNT: userConfig.respectDNT ?? true,
1084
+ allowedDomains: userConfig.allowedDomains ?? null,
1085
+ searchSelector: userConfig.searchSelector ?? null,
1086
+ copyButtonSelector: userConfig.copyButtonSelector ?? null,
1087
+ codeBlockSelector: userConfig.codeBlockSelector ?? null,
1088
+ navigationSelector: userConfig.navigationSelector ?? null,
1089
+ footerSelector: userConfig.footerSelector ?? null,
1090
+ contentSelector: userConfig.contentSelector ?? null,
1091
+ tabContainerSelector: userConfig.tabContainerSelector ?? null,
1092
+ tocSelector: userConfig.tocSelector ?? null,
1093
+ feedbackSelector: userConfig.feedbackSelector ?? null
1094
+ };
1095
+ }
1096
+ //#endregion
1097
+ //#region src/instrumentation/index.ts
1098
+ /**
1099
+ * Do11y — Documentation Observability
1100
+ *
1101
+ * OpenTelemetry Instrumentation for documentation sites.
1102
+ *
1103
+ * This is the npm/bundler distribution path. Users install
1104
+ * @opentelemetry/browser-sdk and @manototh/do11y, then register
1105
+ * DocsInstrumentation to get docs-specific events (scroll depth,
1106
+ * tab switches, code copies, etc.) flowing through the same OTel
1107
+ * pipeline as their auto-instrumentations.
1108
+ *
1109
+ * Example:
1110
+ * import { startLogsSdk } from '@opentelemetry/browser-sdk/logs';
1111
+ * import { DocsInstrumentation } from '@manototh/do11y/instrumentation';
1112
+ *
1113
+ * startLogsSdk({
1114
+ * serviceName: 'my-docs',
1115
+ * logs: { exportConfig: { url: 'https://otel.example.com/v1/logs' } },
1116
+ * });
1117
+ *
1118
+ * // DocsInstrumentation self-enables on construction; the LoggerProvider
1119
+ * // must be registered first (see startLogsSdk above).
1120
+ * new DocsInstrumentation({ framework: 'mintlify' });
1121
+ */
1122
+ /**
1123
+ * True once a global LoggerProvider is registered. Before registration,
1124
+ * logs.getLoggerProvider() returns api-logs' ProxyLoggerProvider, which
1125
+ * exposes an internal `_setDelegate` method; real providers (e.g. sdk-logs
1126
+ * LoggerProvider) do not. api-logs version negotiation shares the same
1127
+ * proxy across copies, so this holds regardless of which copy registered.
1128
+ */
1129
+ function providerIsRegistered() {
1130
+ try {
1131
+ return typeof logs.getLoggerProvider()._setDelegate !== "function";
1132
+ } catch {
1133
+ return true;
1134
+ }
1135
+ }
1136
+ /**
1137
+ * OpenTelemetry instrumentation for documentation sites.
1138
+ *
1139
+ * Emits log records for documentation-specific events (page views,
1140
+ * scroll depth, tab switches, code copies, etc.) through the
1141
+ * OpenTelemetry API. Works alongside @opentelemetry/browser-sdk
1142
+ * and other browser instrumentations.
1143
+ */
1144
+ var DocsInstrumentation = class extends InstrumentationBase {
1145
+ constructor(config = {}) {
1146
+ super("@manototh/do11y", VERSION, config);
1147
+ }
1148
+ /**
1149
+ * Init is called by the base class constructor.
1150
+ * For browser instrumentations that don't patch Node.js modules,
1151
+ * this can return void.
1152
+ */
1153
+ init() {}
1154
+ /**
1155
+ * Enable the instrumentation: register all DOM event listeners.
1156
+ */
1157
+ enable() {
1158
+ this._do11yConfig = buildConfig(this.getConfig());
1159
+ applyFrameworkSelectors(this._do11yConfig);
1160
+ if (shouldDisableTracking(this._do11yConfig)) return;
1161
+ if (this._do11yConfig.debug) console.log("[Do11y] Instrumentation enabled:", this._do11yConfig.framework);
1162
+ if (!providerIsRegistered()) console.warn("[Do11y] No LoggerProvider registered yet — events will be buffered and replayed once the OTel SDK is started. Call startLogsSdk()/startBrowserSdk() BEFORE creating DocsInstrumentation.");
1163
+ const rateLimiter = createRateLimiter();
1164
+ const MAX_PENDING = 500;
1165
+ const pending = [];
1166
+ const buildAttributes = (eventData) => {
1167
+ const sessionAttributes = this._do11yConfig.sessionAttributes !== false ? {
1168
+ [ATTR_SESSION_ID]: getSession().id,
1169
+ [ATTR_DO11Y_SESSION_PAGE_COUNT]: getSession().pageCount
1170
+ } : {};
1171
+ return {
1172
+ [ATTR_DO11Y_DO11Y_VERSION]: VERSION,
1173
+ ...sessionAttributes,
1174
+ ...getBrowserContext(),
1175
+ ...getPageInfo(),
1176
+ ...eventData
1177
+ };
1178
+ };
1179
+ const drainPending = () => {
1180
+ if (pending.length === 0) return;
1181
+ const logger = logs.getLogger("@manototh/do11y");
1182
+ for (const evt of pending) logger.emit({
1183
+ eventName: evt.eventName,
1184
+ severityNumber: 9,
1185
+ timestamp: evt.timestamp,
1186
+ attributes: evt.attributes,
1187
+ body: ""
1188
+ });
1189
+ if (this._do11yConfig.debug) console.log("[Do11y] Replayed", pending.length, "buffered events after LoggerProvider registration");
1190
+ pending.length = 0;
1191
+ };
1192
+ const startDrainTimer = () => {
1193
+ if (this._drainTimer !== null) return;
1194
+ this._drainTimer = window.setInterval(() => {
1195
+ if (providerIsRegistered()) {
1196
+ if (this._drainTimer !== null) {
1197
+ window.clearInterval(this._drainTimer);
1198
+ this._drainTimer = null;
1199
+ }
1200
+ drainPending();
1201
+ }
1202
+ }, 100);
1203
+ };
1204
+ const emit = (eventName, eventData) => {
1205
+ if (!rateLimiter.allow(eventName, eventData, this._do11yConfig.rateLimitMs ?? 100, this._do11yConfig.debug ?? false)) return;
1206
+ if (this._do11yConfig.debug) console.log("[Do11y] Event:", eventName, eventData);
1207
+ const fullAttributes = buildAttributes(eventData);
1208
+ if (!providerIsRegistered()) {
1209
+ if (pending.length >= MAX_PENDING) {
1210
+ if (this._do11yConfig.debug) console.log("[Do11y] Pending buffer full; dropping event:", eventName);
1211
+ return;
1212
+ }
1213
+ pending.push({
1214
+ eventName,
1215
+ attributes: fullAttributes,
1216
+ timestamp: Date.now()
1217
+ });
1218
+ startDrainTimer();
1219
+ return;
1220
+ }
1221
+ drainPending();
1222
+ logs.getLogger("@manototh/do11y").emit({
1223
+ eventName,
1224
+ severityNumber: 9,
1225
+ timestamp: Date.now(),
1226
+ attributes: fullAttributes,
1227
+ body: ""
1228
+ });
1229
+ };
1230
+ this._emit = emit;
1231
+ trackPageView(this._do11yConfig, emit);
1232
+ setupLinkTracking(this._do11yConfig, emit);
1233
+ setupScrollTracking(this._do11yConfig, emit);
1234
+ setupEngagementTracking(this._do11yConfig, emit);
1235
+ setupSearchTracking(this._do11yConfig, emit);
1236
+ setupCopyTracking(this._do11yConfig, emit);
1237
+ setupSectionVisibilityTracking(this._do11yConfig, emit);
1238
+ setupTabSwitchTracking(this._do11yConfig, emit);
1239
+ setupTocClickTracking(this._do11yConfig, emit);
1240
+ setupFeedbackTracking(this._do11yConfig, emit);
1241
+ setupExpandCollapseTracking(this._do11yConfig, emit);
1242
+ if (this._do11yConfig.trackSpaPathChanges) {
1243
+ this._lastPath = window.location.pathname;
1244
+ const config = this._do11yConfig;
1245
+ this._boundHandlePathChange = () => {
1246
+ if (window.location.pathname === this._lastPath) return;
1247
+ this._lastPath = window.location.pathname;
1248
+ emitPageExit(config, emit);
1249
+ resetTrackedScrollDepths();
1250
+ resetEngagementState();
1251
+ trackPageView(config, emit);
1252
+ observeHeadings();
1253
+ checkScrollDepth(config, emit);
1254
+ };
1255
+ this._boundPopstateHandler = this._boundHandlePathChange;
1256
+ window.addEventListener("popstate", this._boundPopstateHandler);
1257
+ this._mutationObserver = new MutationObserver(this._boundHandlePathChange);
1258
+ this._mutationObserver.observe(document.body, {
1259
+ childList: true,
1260
+ subtree: true
1261
+ });
1262
+ this._pathPollId = window.setInterval(this._boundHandlePathChange, 200);
1263
+ }
1264
+ }
1265
+ /**
1266
+ * Disable the instrumentation: tear down all event listeners and observers.
1267
+ */
1268
+ disable() {
1269
+ disconnectSectionObserver();
1270
+ if (this._drainTimer !== null) {
1271
+ window.clearInterval(this._drainTimer);
1272
+ this._drainTimer = null;
1273
+ }
1274
+ if (this._mutationObserver) {
1275
+ this._mutationObserver.disconnect();
1276
+ this._mutationObserver = null;
1277
+ }
1278
+ if (this._pathPollId !== null) {
1279
+ window.clearInterval(this._pathPollId);
1280
+ this._pathPollId = null;
1281
+ }
1282
+ if (this._boundPopstateHandler) {
1283
+ window.removeEventListener("popstate", this._boundPopstateHandler);
1284
+ this._boundPopstateHandler = null;
1285
+ }
1286
+ this._boundHandlePathChange = null;
1287
+ this._lastPath = "";
1288
+ this._emit = () => {};
1289
+ this._do11yConfig = {};
1290
+ }
1291
+ };
1292
+ //#endregion
1293
+ export { DocsInstrumentation };