@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
|
@@ -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 };
|