@manototh/do11y 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/do11y.js +1099 -0
- package/dist/do11y.min.js +1 -0
- package/examples/do11y-config.example.js +40 -0
- package/examples/sync-do11y-to-docs.yml +100 -0
- package/package.json +34 -0
package/dist/do11y.js
ADDED
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
//#region src/do11y.ts
|
|
3
|
+
const VERSION = "0.0.1";
|
|
4
|
+
const _alreadyLoaded = !!window.__do11yInitialized;
|
|
5
|
+
window.__do11yInitialized = true;
|
|
6
|
+
const config = {
|
|
7
|
+
destination: "supabase",
|
|
8
|
+
supabaseUrl: "",
|
|
9
|
+
supabaseKey: "",
|
|
10
|
+
supabaseTable: "do11y_events",
|
|
11
|
+
httpEndpoint: "",
|
|
12
|
+
httpHeaders: {},
|
|
13
|
+
debug: false,
|
|
14
|
+
flushInterval: 5e3,
|
|
15
|
+
maxBatchSize: 10,
|
|
16
|
+
trackOutboundLinks: true,
|
|
17
|
+
trackInternalLinks: true,
|
|
18
|
+
trackScrollDepth: true,
|
|
19
|
+
scrollThresholds: [
|
|
20
|
+
25,
|
|
21
|
+
50,
|
|
22
|
+
75,
|
|
23
|
+
90
|
|
24
|
+
],
|
|
25
|
+
allowedDomains: null,
|
|
26
|
+
respectDNT: true,
|
|
27
|
+
maxRetries: 2,
|
|
28
|
+
retryDelay: 1e3,
|
|
29
|
+
rateLimitMs: 100,
|
|
30
|
+
framework: "mintlify",
|
|
31
|
+
trackSectionVisibility: true,
|
|
32
|
+
sectionVisibleThreshold: 3,
|
|
33
|
+
trackTabSwitches: true,
|
|
34
|
+
trackTocClicks: true,
|
|
35
|
+
trackExpandCollapse: true,
|
|
36
|
+
trackFeedback: true,
|
|
37
|
+
tabContainerSelector: null,
|
|
38
|
+
tocSelector: null,
|
|
39
|
+
feedbackSelector: null,
|
|
40
|
+
searchSelector: null,
|
|
41
|
+
copyButtonSelector: null,
|
|
42
|
+
codeBlockSelector: null,
|
|
43
|
+
navigationSelector: null,
|
|
44
|
+
footerSelector: null,
|
|
45
|
+
contentSelector: null
|
|
46
|
+
};
|
|
47
|
+
const FRAMEWORK_PRESETS = {
|
|
48
|
+
mintlify: {
|
|
49
|
+
searchSelector: "#search-bar-entry, #search-bar-entry-mobile, [class*=\"search\"]",
|
|
50
|
+
copyButtonSelector: "[class*=\"copy\"], button[aria-label*=\"copy\" i]",
|
|
51
|
+
codeBlockSelector: "pre, [class*=\"code\"]",
|
|
52
|
+
navigationSelector: "nav, [role=\"navigation\"], #navbar, #sidebar, [class*=\"nav\"], [class*=\"sidebar\"]",
|
|
53
|
+
footerSelector: "footer, [role=\"contentinfo\"], [class*=\"footer\"]",
|
|
54
|
+
contentSelector: "main, article, [role=\"main\"], [class*=\"content\"]",
|
|
55
|
+
tabContainerSelector: "[role=\"tablist\"], [class*=\"tab\"]",
|
|
56
|
+
tocSelector: "#table-of-contents, [data-testid=\"table-of-contents\"], [class*=\"table-of-contents\"], [class*=\"toc\"]",
|
|
57
|
+
feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"]"
|
|
58
|
+
},
|
|
59
|
+
docusaurus: {
|
|
60
|
+
searchSelector: ".DocSearch, .DocSearch-Button",
|
|
61
|
+
copyButtonSelector: "button.clean-btn[aria-label*=\"copy\" i], button[class*=\"copyButton\"]",
|
|
62
|
+
codeBlockSelector: "pre, [class*=\"code\"]",
|
|
63
|
+
navigationSelector: "nav, [role=\"navigation\"], .navbar, .sidebar, [class*=\"nav\"], [class*=\"sidebar\"]",
|
|
64
|
+
footerSelector: "footer, [role=\"contentinfo\"], [class*=\"footer\"]",
|
|
65
|
+
contentSelector: "main, article, [role=\"main\"], [class*=\"content\"]",
|
|
66
|
+
tabContainerSelector: ".tabs[role=\"tablist\"], [class*=\"tabs\"]",
|
|
67
|
+
tocSelector: ".table-of-contents, [class*=\"toc\"]",
|
|
68
|
+
feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"]"
|
|
69
|
+
},
|
|
70
|
+
nextra: {
|
|
71
|
+
searchSelector: ".nextra-search input, input[placeholder*=\"search\" i], button[aria-label*=\"search\" i]",
|
|
72
|
+
copyButtonSelector: "button[class*=\"copy\"], button[aria-label*=\"copy\" i], button[title*=\"copy\" i]",
|
|
73
|
+
codeBlockSelector: "pre, [class*=\"code\"]",
|
|
74
|
+
navigationSelector: "nav, [role=\"navigation\"], [class*=\"nav\"], [class*=\"sidebar\"]",
|
|
75
|
+
footerSelector: "footer, [role=\"contentinfo\"], [class*=\"footer\"]",
|
|
76
|
+
contentSelector: "main, article, [role=\"main\"], [class*=\"content\"]",
|
|
77
|
+
tabContainerSelector: "[role=\"tablist\"], [class*=\"tab\"]",
|
|
78
|
+
tocSelector: ".nextra-toc, [class*=\"toc\"]",
|
|
79
|
+
feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"]"
|
|
80
|
+
},
|
|
81
|
+
gitbook: {
|
|
82
|
+
searchSelector: "[data-testid*=\"search\"], button[aria-label*=\"search\" i]",
|
|
83
|
+
copyButtonSelector: "[class*=\"copy\"], button[aria-label*=\"copy\" i]",
|
|
84
|
+
codeBlockSelector: "pre, code, [class*=\"code\"]",
|
|
85
|
+
navigationSelector: "nav, [role=\"navigation\"], [class*=\"nav\"], [class*=\"sidebar\"]",
|
|
86
|
+
footerSelector: "footer, [role=\"contentinfo\"], [class*=\"footer\"]",
|
|
87
|
+
contentSelector: "main, article, [role=\"main\"], [class*=\"content\"]",
|
|
88
|
+
tabContainerSelector: "[role=\"tablist\"], [class*=\"tab\"]",
|
|
89
|
+
tocSelector: "[class*=\"table-of-contents\"], [class*=\"toc\"], [class*=\"page-outline\"]",
|
|
90
|
+
feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"], [class*=\"rating\"]"
|
|
91
|
+
},
|
|
92
|
+
"mkdocs-material": {
|
|
93
|
+
searchSelector: ".md-search__input",
|
|
94
|
+
copyButtonSelector: ".md-clipboard, .md-code__button[title=\"Copy to clipboard\"]",
|
|
95
|
+
codeBlockSelector: "pre, code, [class*=\"code\"]",
|
|
96
|
+
navigationSelector: "nav, [role=\"navigation\"], .md-nav, .md-sidebar",
|
|
97
|
+
footerSelector: "footer, [role=\"contentinfo\"], .md-footer",
|
|
98
|
+
contentSelector: "main, article, [role=\"main\"], .md-content",
|
|
99
|
+
tabContainerSelector: ".tabbed-labels, .md-typeset .tabbed-set",
|
|
100
|
+
tocSelector: ".md-sidebar--secondary .md-nav, [class*=\"toc\"]",
|
|
101
|
+
feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"]"
|
|
102
|
+
},
|
|
103
|
+
vitepress: {
|
|
104
|
+
searchSelector: ".VPNavBarSearch button, .VPNavBarSearchButton, #local-search",
|
|
105
|
+
copyButtonSelector: ".vp-code-copy, button.copy[title*=\"Copy\"]",
|
|
106
|
+
codeBlockSelector: "pre, [class*=\"code\"]",
|
|
107
|
+
navigationSelector: "nav, [role=\"navigation\"], .VPNav, .VPSidebar, [class*=\"nav\"], [class*=\"sidebar\"]",
|
|
108
|
+
footerSelector: "footer, [role=\"contentinfo\"], .VPFooter, [class*=\"footer\"]",
|
|
109
|
+
contentSelector: "main, article, [role=\"main\"], .VPContent, [class*=\"content\"]",
|
|
110
|
+
tabContainerSelector: ".vp-code-group .tabs, [role=\"tablist\"]",
|
|
111
|
+
tocSelector: ".VPDocAsideOutline, [class*=\"toc\"]",
|
|
112
|
+
feedbackSelector: "[class*=\"feedback\"], [class*=\"helpful\"]"
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const SELECTOR_KEYS = [
|
|
116
|
+
"searchSelector",
|
|
117
|
+
"copyButtonSelector",
|
|
118
|
+
"codeBlockSelector",
|
|
119
|
+
"navigationSelector",
|
|
120
|
+
"footerSelector",
|
|
121
|
+
"contentSelector",
|
|
122
|
+
"tabContainerSelector",
|
|
123
|
+
"tocSelector",
|
|
124
|
+
"feedbackSelector"
|
|
125
|
+
];
|
|
126
|
+
/**
|
|
127
|
+
* Apply framework-specific selectors to the config.
|
|
128
|
+
* For 'custom', uses whatever the user set in config; for named
|
|
129
|
+
* frameworks, loads the preset and lets explicit config values override.
|
|
130
|
+
*/
|
|
131
|
+
function applyFrameworkSelectors() {
|
|
132
|
+
const preset = FRAMEWORK_PRESETS[config.framework];
|
|
133
|
+
if (preset) SELECTOR_KEYS.forEach((key) => {
|
|
134
|
+
if (!config[key]) config[key] = preset[key];
|
|
135
|
+
});
|
|
136
|
+
else if (config.framework !== "custom") {
|
|
137
|
+
if (config.debug) console.warn(`[Do11y] Unknown framework "${config.framework}". Falling back to generic selectors. Supported: ` + Object.keys(FRAMEWORK_PRESETS).join(", ") + ", custom");
|
|
138
|
+
}
|
|
139
|
+
const fallback = FRAMEWORK_PRESETS.mintlify;
|
|
140
|
+
if (!fallback) return;
|
|
141
|
+
SELECTOR_KEYS.forEach((key) => {
|
|
142
|
+
if (!config[key]) config[key] = fallback[key];
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function shouldDisableTracking() {
|
|
146
|
+
if (config.respectDNT && (navigator.doNotTrack === "1" || navigator.doNotTrack === "yes" || window.doNotTrack === "1")) {
|
|
147
|
+
if (config.debug) console.log("[Do11y] Disabled: Do Not Track is enabled");
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (config.allowedDomains && config.allowedDomains.length > 0) {
|
|
151
|
+
const currentDomain = window.location.hostname;
|
|
152
|
+
if (!config.allowedDomains.some((domain) => {
|
|
153
|
+
return currentDomain === domain || currentDomain.endsWith("." + domain);
|
|
154
|
+
})) {
|
|
155
|
+
if (config.debug) console.log("[Do11y] Disabled: Domain not allowed:", currentDomain);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Validate a CSS selector string supplied through user configuration.
|
|
163
|
+
* Returns the selector unchanged if it is syntactically valid, or null
|
|
164
|
+
* if it is not. This prevents CSS selector injection from attacker-
|
|
165
|
+
* controlled config values (window.Do11yConfig / meta tags) reaching
|
|
166
|
+
* querySelectorAll / closest calls.
|
|
167
|
+
*/
|
|
168
|
+
function validateSelector(selector) {
|
|
169
|
+
if (!selector || typeof selector !== "string") return null;
|
|
170
|
+
try {
|
|
171
|
+
document.querySelector(selector);
|
|
172
|
+
return selector;
|
|
173
|
+
} catch {
|
|
174
|
+
if (config.debug) console.warn("[Do11y] Invalid CSS selector rejected:", selector);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function sanitizeText(text, maxLength) {
|
|
179
|
+
if (!text || typeof text !== "string") return null;
|
|
180
|
+
const limit = maxLength ?? 100;
|
|
181
|
+
let sanitized = text;
|
|
182
|
+
sanitized = sanitized.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "[email]");
|
|
183
|
+
sanitized = sanitized.replace(/\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g, "[phone]");
|
|
184
|
+
sanitized = sanitized.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[redacted]");
|
|
185
|
+
sanitized = sanitized.replace(/\b(?:\d[ -]?){13,19}\b/g, "[card]");
|
|
186
|
+
sanitized = sanitized.replace(/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[token]");
|
|
187
|
+
sanitized = sanitized.replace(/\bxa[a-z]{2}-[A-Za-z0-9_-]{20,}/g, "[token]");
|
|
188
|
+
sanitized = sanitized.replace(/\b[0-9a-fA-F]{32,}\b/g, "[redacted]");
|
|
189
|
+
return sanitized.trim().substring(0, limit);
|
|
190
|
+
}
|
|
191
|
+
function generateSessionId() {
|
|
192
|
+
if (window.crypto && typeof window.crypto.randomUUID === "function") return window.crypto.randomUUID();
|
|
193
|
+
if (window.crypto && typeof window.crypto.getRandomValues === "function") {
|
|
194
|
+
const arr = new Uint8Array(16);
|
|
195
|
+
window.crypto.getRandomValues(arr);
|
|
196
|
+
arr[6] = arr[6] & 15 | 64;
|
|
197
|
+
arr[8] = arr[8] & 63 | 128;
|
|
198
|
+
const hex = Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
199
|
+
return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20);
|
|
200
|
+
}
|
|
201
|
+
return "no-crypto-00-0000-0000-000000000000";
|
|
202
|
+
}
|
|
203
|
+
function isValidSessionData(value) {
|
|
204
|
+
if (!value || typeof value !== "object") return false;
|
|
205
|
+
const v = value;
|
|
206
|
+
return typeof v.id === "string" && v.id.length > 0 && typeof v.startTime === "string" && Array.isArray(v.pageSequence) && typeof v.pageCount === "number";
|
|
207
|
+
}
|
|
208
|
+
function getSession() {
|
|
209
|
+
let session = null;
|
|
210
|
+
try {
|
|
211
|
+
const stored = sessionStorage.getItem("do11y_session");
|
|
212
|
+
if (stored) {
|
|
213
|
+
const parsed = JSON.parse(stored);
|
|
214
|
+
if (isValidSessionData(parsed)) session = parsed;
|
|
215
|
+
}
|
|
216
|
+
} catch {}
|
|
217
|
+
if (!session) {
|
|
218
|
+
session = {
|
|
219
|
+
id: generateSessionId(),
|
|
220
|
+
startTime: (/* @__PURE__ */ new Date()).toISOString(),
|
|
221
|
+
pageSequence: [],
|
|
222
|
+
pageCount: 0,
|
|
223
|
+
referrerCategory: null,
|
|
224
|
+
aiPlatform: null
|
|
225
|
+
};
|
|
226
|
+
saveSession(session);
|
|
227
|
+
}
|
|
228
|
+
return session;
|
|
229
|
+
}
|
|
230
|
+
function saveSession(session) {
|
|
231
|
+
try {
|
|
232
|
+
sessionStorage.setItem("do11y_session", JSON.stringify(session));
|
|
233
|
+
} catch {}
|
|
234
|
+
}
|
|
235
|
+
function updatePageSequence(path) {
|
|
236
|
+
const session = getSession();
|
|
237
|
+
session.pageCount++;
|
|
238
|
+
session.pageSequence.push({
|
|
239
|
+
path,
|
|
240
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
241
|
+
index: session.pageCount
|
|
242
|
+
});
|
|
243
|
+
if (session.pageSequence.length > 50) session.pageSequence = session.pageSequence.slice(-50);
|
|
244
|
+
saveSession(session);
|
|
245
|
+
return session;
|
|
246
|
+
}
|
|
247
|
+
function getBrowserContext() {
|
|
248
|
+
return {
|
|
249
|
+
viewportCategory: categorizeViewport(),
|
|
250
|
+
browserFamily: getBrowserFamily(),
|
|
251
|
+
deviceType: getDeviceType(),
|
|
252
|
+
language: (navigator.language || "").split("-")[0] || "unknown",
|
|
253
|
+
timezoneOffset: (/* @__PURE__ */ new Date()).getTimezoneOffset() / 60
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function categorizeViewport() {
|
|
257
|
+
const width = window.innerWidth;
|
|
258
|
+
if (width < 640) return "mobile";
|
|
259
|
+
if (width < 1024) return "tablet";
|
|
260
|
+
if (width < 1440) return "desktop";
|
|
261
|
+
return "large-desktop";
|
|
262
|
+
}
|
|
263
|
+
function getBrowserFamily() {
|
|
264
|
+
const ua = navigator.userAgent;
|
|
265
|
+
if (ua.includes("Firefox")) return "Firefox";
|
|
266
|
+
if (ua.includes("Edg")) return "Edge";
|
|
267
|
+
if (ua.includes("Chrome")) return "Chrome";
|
|
268
|
+
if (ua.includes("Safari")) return "Safari";
|
|
269
|
+
return "Other";
|
|
270
|
+
}
|
|
271
|
+
function getDeviceType() {
|
|
272
|
+
const ua = navigator.userAgent;
|
|
273
|
+
if (/Mobile|Android|iPhone|iPad/.test(ua)) {
|
|
274
|
+
if (/iPad|Tablet/.test(ua)) return "tablet";
|
|
275
|
+
return "mobile";
|
|
276
|
+
}
|
|
277
|
+
return "desktop";
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Known AI platform referrer patterns.
|
|
281
|
+
* Each entry maps a substring found in the referrer hostname to an AI
|
|
282
|
+
* platform label. Order matters: first match wins.
|
|
283
|
+
*/
|
|
284
|
+
const AI_REFERRER_PATTERNS = [
|
|
285
|
+
{
|
|
286
|
+
match: "chatgpt",
|
|
287
|
+
platform: "ChatGPT"
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
match: "chat.com",
|
|
291
|
+
platform: "ChatGPT"
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
match: "openai",
|
|
295
|
+
platform: "ChatGPT"
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
match: "perplexity",
|
|
299
|
+
platform: "Perplexity"
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
match: "claude.ai",
|
|
303
|
+
platform: "Claude"
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
match: "anthropic",
|
|
307
|
+
platform: "Claude"
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
match: "gemini",
|
|
311
|
+
platform: "Gemini"
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
match: "copilot",
|
|
315
|
+
platform: "Copilot"
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
match: "deepseek",
|
|
319
|
+
platform: "DeepSeek"
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
match: "meta.ai",
|
|
323
|
+
platform: "Meta AI"
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
match: "grok",
|
|
327
|
+
platform: "Grok"
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
match: "x.ai",
|
|
331
|
+
platform: "Grok"
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
match: "mistral",
|
|
335
|
+
platform: "Mistral"
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
match: "you.com",
|
|
339
|
+
platform: "You.com"
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
match: "phind",
|
|
343
|
+
platform: "Phind"
|
|
344
|
+
}
|
|
345
|
+
];
|
|
346
|
+
/**
|
|
347
|
+
* Classify a referrer hostname into a traffic source category.
|
|
348
|
+
* Returns { referrerCategory, aiPlatform } where aiPlatform is null
|
|
349
|
+
* for non-AI traffic.
|
|
350
|
+
*/
|
|
351
|
+
function classifyReferrer(hostname) {
|
|
352
|
+
if (!hostname || hostname === "direct") return {
|
|
353
|
+
referrerCategory: "direct",
|
|
354
|
+
aiPlatform: null
|
|
355
|
+
};
|
|
356
|
+
if (hostname === "internal") return {
|
|
357
|
+
referrerCategory: "internal",
|
|
358
|
+
aiPlatform: null
|
|
359
|
+
};
|
|
360
|
+
if (hostname === "unknown") return {
|
|
361
|
+
referrerCategory: "unknown",
|
|
362
|
+
aiPlatform: null
|
|
363
|
+
};
|
|
364
|
+
const h = hostname.toLowerCase();
|
|
365
|
+
for (const pattern of AI_REFERRER_PATTERNS) if (h.indexOf(pattern.match) !== -1) return {
|
|
366
|
+
referrerCategory: "ai",
|
|
367
|
+
aiPlatform: pattern.platform
|
|
368
|
+
};
|
|
369
|
+
if (/google\.|bing\.|baidu\.|yandex\.|duckduckgo\.|yahoo\./.test(h)) return {
|
|
370
|
+
referrerCategory: "search-engine",
|
|
371
|
+
aiPlatform: null
|
|
372
|
+
};
|
|
373
|
+
if (/github\.|gitlab\.|bitbucket\./.test(h)) return {
|
|
374
|
+
referrerCategory: "code-host",
|
|
375
|
+
aiPlatform: null
|
|
376
|
+
};
|
|
377
|
+
if (/stackoverflow\.|stackexchange\.|reddit\.|news\.ycombinator\./.test(h)) return {
|
|
378
|
+
referrerCategory: "community",
|
|
379
|
+
aiPlatform: null
|
|
380
|
+
};
|
|
381
|
+
if (/twitter\.|x\.com|linkedin\.|facebook\.|threads\.net/.test(h)) return {
|
|
382
|
+
referrerCategory: "social",
|
|
383
|
+
aiPlatform: null
|
|
384
|
+
};
|
|
385
|
+
return {
|
|
386
|
+
referrerCategory: "other",
|
|
387
|
+
aiPlatform: null
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function getReferrerDomain() {
|
|
391
|
+
try {
|
|
392
|
+
if (!document.referrer) return "direct";
|
|
393
|
+
const url = new URL(document.referrer);
|
|
394
|
+
if (url.hostname === window.location.hostname) return "internal";
|
|
395
|
+
return url.hostname;
|
|
396
|
+
} catch {
|
|
397
|
+
return "unknown";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function getPageInfo() {
|
|
401
|
+
return {
|
|
402
|
+
path: window.location.pathname,
|
|
403
|
+
hash: window.location.hash || null,
|
|
404
|
+
search: window.location.search ? "has_params" : null,
|
|
405
|
+
title: sanitizeText(document.title, 150)
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
let eventQueue = [];
|
|
409
|
+
let flushTimeout = null;
|
|
410
|
+
const lastEventTime = {};
|
|
411
|
+
let isDisabled = false;
|
|
412
|
+
function queueEvent(eventType, eventData) {
|
|
413
|
+
if (isDisabled) return;
|
|
414
|
+
const now = Date.now();
|
|
415
|
+
if (config.rateLimitMs > 0 && lastEventTime[eventType]) {
|
|
416
|
+
if (now - lastEventTime[eventType] < config.rateLimitMs) {
|
|
417
|
+
if (config.debug) console.log("[Do11y] Rate limited:", eventType);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
lastEventTime[eventType] = now;
|
|
422
|
+
const session = getSession();
|
|
423
|
+
const event = {
|
|
424
|
+
_time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
425
|
+
eventType,
|
|
426
|
+
"do11y_version": VERSION,
|
|
427
|
+
sessionId: session.id,
|
|
428
|
+
sessionPageCount: session.pageCount,
|
|
429
|
+
...getPageInfo(),
|
|
430
|
+
...getBrowserContext(),
|
|
431
|
+
...eventData
|
|
432
|
+
};
|
|
433
|
+
if (config.debug) console.log("[Do11y] Event queued:", event);
|
|
434
|
+
eventQueue.push(event);
|
|
435
|
+
if (eventQueue.length > 100) {
|
|
436
|
+
eventQueue = eventQueue.slice(-100);
|
|
437
|
+
if (config.debug) console.warn("[Do11y] Event queue capped at 100 events");
|
|
438
|
+
}
|
|
439
|
+
if (eventQueue.length >= config.maxBatchSize) flush();
|
|
440
|
+
else scheduleFlush();
|
|
441
|
+
}
|
|
442
|
+
function scheduleFlush() {
|
|
443
|
+
if (flushTimeout) return;
|
|
444
|
+
flushTimeout = setTimeout(flush, config.flushInterval);
|
|
445
|
+
}
|
|
446
|
+
function validateSupabaseUrl(url) {
|
|
447
|
+
try {
|
|
448
|
+
const parsed = new URL(url);
|
|
449
|
+
if (parsed.protocol !== "https:") return false;
|
|
450
|
+
if (!parsed.hostname.endsWith(".supabase.co")) return false;
|
|
451
|
+
return true;
|
|
452
|
+
} catch {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function validateHttpEndpoint(url) {
|
|
457
|
+
try {
|
|
458
|
+
const parsed = new URL(url);
|
|
459
|
+
if (parsed.protocol !== "https:") return false;
|
|
460
|
+
const host = parsed.hostname;
|
|
461
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "::1") return false;
|
|
462
|
+
if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host)) return false;
|
|
463
|
+
return true;
|
|
464
|
+
} catch {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function validateConfig() {
|
|
469
|
+
if (config.destination === "supabase") {
|
|
470
|
+
if (!config.supabaseUrl) {
|
|
471
|
+
if (config.debug) console.warn("[Do11y] No Supabase URL configured");
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
if (!validateSupabaseUrl(config.supabaseUrl)) {
|
|
475
|
+
if (config.debug) console.warn("[Do11y] Invalid Supabase URL. Must be https://<project>.supabase.co");
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
if (!config.supabaseKey || typeof config.supabaseKey !== "string" || config.supabaseKey.length < 10) {
|
|
479
|
+
if (config.debug) console.warn("[Do11y] Invalid or missing Supabase publishable key");
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(config.supabaseTable)) {
|
|
483
|
+
if (config.debug) console.warn("[Do11y] Invalid table name");
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
if (config.destination === "http") {
|
|
489
|
+
if (!config.httpEndpoint) {
|
|
490
|
+
if (config.debug) console.warn("[Do11y] No HTTP endpoint configured");
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
if (!validateHttpEndpoint(config.httpEndpoint)) {
|
|
494
|
+
if (config.debug) console.warn("[Do11y] Invalid HTTP endpoint. Must be HTTPS and not a private address.");
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
if (config.debug) console.warn("[Do11y] Unknown destination:", config.destination);
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
function buildRequest(events) {
|
|
503
|
+
if (config.destination === "supabase") return {
|
|
504
|
+
url: config.supabaseUrl.replace(/\/$/, "") + "/rest/v1/" + config.supabaseTable,
|
|
505
|
+
headers: {
|
|
506
|
+
"apikey": config.supabaseKey,
|
|
507
|
+
"Authorization": "Bearer " + config.supabaseKey,
|
|
508
|
+
"Content-Type": "application/json",
|
|
509
|
+
"Prefer": "return=minimal"
|
|
510
|
+
},
|
|
511
|
+
body: JSON.stringify(events.map((e) => ({ payload: e })))
|
|
512
|
+
};
|
|
513
|
+
return {
|
|
514
|
+
url: config.httpEndpoint,
|
|
515
|
+
headers: {
|
|
516
|
+
"Content-Type": "application/json",
|
|
517
|
+
...config.httpHeaders
|
|
518
|
+
},
|
|
519
|
+
body: JSON.stringify(events)
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function flush(retriesLeft) {
|
|
523
|
+
if (flushTimeout) {
|
|
524
|
+
clearTimeout(flushTimeout);
|
|
525
|
+
flushTimeout = null;
|
|
526
|
+
}
|
|
527
|
+
if (eventQueue.length === 0) return;
|
|
528
|
+
if (!validateConfig()) return;
|
|
529
|
+
const retries = typeof retriesLeft === "number" ? retriesLeft : config.maxRetries;
|
|
530
|
+
const events = eventQueue.slice();
|
|
531
|
+
eventQueue = [];
|
|
532
|
+
sendEvents(buildRequest(events), events, retries);
|
|
533
|
+
}
|
|
534
|
+
function sendEvents(req, events, retriesLeft) {
|
|
535
|
+
fetch(req.url, {
|
|
536
|
+
method: "POST",
|
|
537
|
+
headers: req.headers,
|
|
538
|
+
body: req.body,
|
|
539
|
+
keepalive: true
|
|
540
|
+
}).then((response) => {
|
|
541
|
+
if (response.ok) {
|
|
542
|
+
if (config.debug) console.log("[Do11y] Flushed", events.length, "events");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (retriesLeft > 0 && (response.status >= 500 || response.status === 429)) {
|
|
546
|
+
if (config.debug) console.log("[Do11y] Retrying after error:", response.status);
|
|
547
|
+
eventQueue = events.concat(eventQueue);
|
|
548
|
+
setTimeout(() => {
|
|
549
|
+
flush(retriesLeft - 1);
|
|
550
|
+
}, config.retryDelay * (config.maxRetries - retriesLeft + 1));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (config.debug) response.text().then((text) => {
|
|
554
|
+
console.error("[Do11y] Ingest failed:", response.status, text);
|
|
555
|
+
}).catch(() => {});
|
|
556
|
+
}).catch((err) => {
|
|
557
|
+
if (retriesLeft > 0) {
|
|
558
|
+
if (config.debug) console.log("[Do11y] Network error, retrying:", err.message);
|
|
559
|
+
eventQueue = events.concat(eventQueue);
|
|
560
|
+
setTimeout(() => {
|
|
561
|
+
flush(retriesLeft - 1);
|
|
562
|
+
}, config.retryDelay * (config.maxRetries - retriesLeft + 1));
|
|
563
|
+
} else if (config.debug) console.error("[Do11y] Failed to send events:", err);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
function flushSync() {
|
|
567
|
+
if (eventQueue.length === 0) return;
|
|
568
|
+
if (!validateConfig()) return;
|
|
569
|
+
const events = eventQueue;
|
|
570
|
+
eventQueue = [];
|
|
571
|
+
const req = buildRequest(events);
|
|
572
|
+
try {
|
|
573
|
+
fetch(req.url, {
|
|
574
|
+
method: "POST",
|
|
575
|
+
headers: req.headers,
|
|
576
|
+
body: req.body,
|
|
577
|
+
keepalive: true
|
|
578
|
+
});
|
|
579
|
+
} catch {}
|
|
580
|
+
if (config.debug) console.log("[Do11y] Sync flushed", events.length, "events");
|
|
581
|
+
}
|
|
582
|
+
function trackPageView() {
|
|
583
|
+
const session = updatePageSequence(window.location.pathname);
|
|
584
|
+
const referrerDomain = getReferrerDomain();
|
|
585
|
+
const referrerInfo = classifyReferrer(referrerDomain);
|
|
586
|
+
if (session.pageCount === 1) {
|
|
587
|
+
session.referrerCategory = referrerInfo.referrerCategory;
|
|
588
|
+
session.aiPlatform = referrerInfo.aiPlatform;
|
|
589
|
+
saveSession(session);
|
|
590
|
+
}
|
|
591
|
+
queueEvent("page_view", {
|
|
592
|
+
referrerDomain,
|
|
593
|
+
referrerCategory: referrerInfo.referrerCategory,
|
|
594
|
+
aiPlatform: referrerInfo.aiPlatform,
|
|
595
|
+
isFirstPage: session.pageCount === 1,
|
|
596
|
+
previousPath: session.pageSequence.length > 1 ? session.pageSequence[session.pageSequence.length - 2].path : null
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
function setupLinkTracking() {
|
|
600
|
+
document.addEventListener("click", (e) => {
|
|
601
|
+
const link = e.target.closest("a");
|
|
602
|
+
if (!link) return;
|
|
603
|
+
const href = link.getAttribute("href");
|
|
604
|
+
if (!href) return;
|
|
605
|
+
let linkType = "other";
|
|
606
|
+
let targetDomain = null;
|
|
607
|
+
try {
|
|
608
|
+
if (href.startsWith("#")) linkType = "anchor";
|
|
609
|
+
else if (href.startsWith("/") || href.startsWith("./") || href.startsWith("../")) linkType = "internal";
|
|
610
|
+
else if (href.startsWith("http")) {
|
|
611
|
+
const url = new URL(href);
|
|
612
|
+
if (url.hostname === window.location.hostname) linkType = "internal";
|
|
613
|
+
else {
|
|
614
|
+
linkType = "external";
|
|
615
|
+
targetDomain = url.hostname;
|
|
616
|
+
}
|
|
617
|
+
} else if (href.startsWith("mailto:")) linkType = "email";
|
|
618
|
+
} catch {}
|
|
619
|
+
if (linkType === "internal" && !config.trackInternalLinks) return;
|
|
620
|
+
if (linkType === "external" && !config.trackOutboundLinks) return;
|
|
621
|
+
queueEvent("link_click", {
|
|
622
|
+
linkType,
|
|
623
|
+
targetUrl: href,
|
|
624
|
+
targetDomain,
|
|
625
|
+
linkText: sanitizeText(link.textContent, 100),
|
|
626
|
+
linkContext: getLinkContext(link),
|
|
627
|
+
linkSection: sanitizeText(getNearestHeading(link), 100),
|
|
628
|
+
linkIndex: getLinkIndex(link, href)
|
|
629
|
+
});
|
|
630
|
+
flush();
|
|
631
|
+
}, true);
|
|
632
|
+
}
|
|
633
|
+
function getLinkContext(link) {
|
|
634
|
+
if (link.closest(config.navigationSelector)) return "navigation";
|
|
635
|
+
if (link.closest(config.footerSelector)) return "footer";
|
|
636
|
+
if (link.closest(config.contentSelector)) return "content";
|
|
637
|
+
return "other";
|
|
638
|
+
}
|
|
639
|
+
function getNearestHeading(element) {
|
|
640
|
+
let current = element;
|
|
641
|
+
while (current && current !== document.body) {
|
|
642
|
+
let sibling = current.previousElementSibling;
|
|
643
|
+
while (sibling) {
|
|
644
|
+
if (/^H[1-6]$/.test(sibling.tagName)) return sibling.textContent?.trim().substring(0, 100) ?? null;
|
|
645
|
+
const headings = sibling.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
|
646
|
+
if (headings.length > 0) return headings[headings.length - 1].textContent?.trim().substring(0, 100) ?? null;
|
|
647
|
+
sibling = sibling.previousElementSibling;
|
|
648
|
+
}
|
|
649
|
+
current = current.parentElement;
|
|
650
|
+
}
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
function getLinkIndex(link, href) {
|
|
654
|
+
if (typeof CSS === "undefined" || typeof CSS.escape !== "function") return 1;
|
|
655
|
+
try {
|
|
656
|
+
const allLinks = document.querySelectorAll("a[href=\"" + CSS.escape(href) + "\"]");
|
|
657
|
+
for (let i = 0; i < allLinks.length; i++) if (allLinks[i] === link) return i + 1;
|
|
658
|
+
} catch {}
|
|
659
|
+
return 1;
|
|
660
|
+
}
|
|
661
|
+
let trackedScrollDepths = /* @__PURE__ */ new Set();
|
|
662
|
+
let scrollContainer = null;
|
|
663
|
+
function findScrollableAncestor(el) {
|
|
664
|
+
let current = el;
|
|
665
|
+
while (current && current !== document.body && current !== document.documentElement) {
|
|
666
|
+
const overflowY = window.getComputedStyle(current).overflowY;
|
|
667
|
+
if ((overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight) return current;
|
|
668
|
+
current = current.parentElement;
|
|
669
|
+
}
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Track scroll depth.
|
|
674
|
+
*
|
|
675
|
+
* Some frameworks (GitBook/HonKit, MkDocs Material) use container-based
|
|
676
|
+
* scrolling where the window itself never scrolls. We detect the scrollable
|
|
677
|
+
* container by walking up from the content element and listen on it in
|
|
678
|
+
* addition to the window.
|
|
679
|
+
*/
|
|
680
|
+
function setupScrollTracking() {
|
|
681
|
+
if (!config.trackScrollDepth) return;
|
|
682
|
+
if (config.contentSelector) {
|
|
683
|
+
const contentEl = document.querySelector(config.contentSelector);
|
|
684
|
+
if (contentEl) scrollContainer = findScrollableAncestor(contentEl);
|
|
685
|
+
}
|
|
686
|
+
let ticking = false;
|
|
687
|
+
function onScroll() {
|
|
688
|
+
if (!ticking) {
|
|
689
|
+
window.requestAnimationFrame(() => {
|
|
690
|
+
checkScrollDepth();
|
|
691
|
+
ticking = false;
|
|
692
|
+
});
|
|
693
|
+
ticking = true;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
window.addEventListener("scroll", onScroll);
|
|
697
|
+
if (scrollContainer) {
|
|
698
|
+
scrollContainer.addEventListener("scroll", onScroll);
|
|
699
|
+
if (config.debug) {
|
|
700
|
+
const sc = scrollContainer;
|
|
701
|
+
console.log("[do11y] Using container-based scroll tracking:", sc.className || sc.tagName);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
checkScrollDepth();
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Check and track scroll depth thresholds.
|
|
708
|
+
* Reads from the detected scroll container when present, otherwise
|
|
709
|
+
* falls back to the window/document.
|
|
710
|
+
*
|
|
711
|
+
* If the page fits entirely in the viewport (no scrollbar), all
|
|
712
|
+
* thresholds are marked as reached since the user can see 100% of
|
|
713
|
+
* the content without scrolling.
|
|
714
|
+
*/
|
|
715
|
+
function checkScrollDepth() {
|
|
716
|
+
let scrollTop;
|
|
717
|
+
let totalHeight;
|
|
718
|
+
let viewportHeight;
|
|
719
|
+
if (scrollContainer && scrollContainer.scrollHeight > scrollContainer.clientHeight) {
|
|
720
|
+
scrollTop = scrollContainer.scrollTop;
|
|
721
|
+
totalHeight = scrollContainer.scrollHeight;
|
|
722
|
+
viewportHeight = scrollContainer.clientHeight;
|
|
723
|
+
} else {
|
|
724
|
+
scrollTop = window.scrollY || document.documentElement.scrollTop;
|
|
725
|
+
totalHeight = document.documentElement.scrollHeight;
|
|
726
|
+
viewportHeight = window.innerHeight;
|
|
727
|
+
}
|
|
728
|
+
const docHeight = totalHeight - viewportHeight;
|
|
729
|
+
if (docHeight <= 0) {
|
|
730
|
+
config.scrollThresholds.forEach((threshold) => {
|
|
731
|
+
if (!trackedScrollDepths.has(threshold)) {
|
|
732
|
+
trackedScrollDepths.add(threshold);
|
|
733
|
+
queueEvent("scroll_depth", {
|
|
734
|
+
threshold,
|
|
735
|
+
scrollPercent: 100
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const scrollPercent = Math.round(scrollTop / docHeight * 100);
|
|
742
|
+
config.scrollThresholds.forEach((threshold) => {
|
|
743
|
+
if (scrollPercent >= threshold && !trackedScrollDepths.has(threshold)) {
|
|
744
|
+
trackedScrollDepths.add(threshold);
|
|
745
|
+
queueEvent("scroll_depth", {
|
|
746
|
+
threshold,
|
|
747
|
+
scrollPercent
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
let pageLoadTime = Date.now();
|
|
753
|
+
let lastActivityTime = Date.now();
|
|
754
|
+
let totalActiveTime = 0;
|
|
755
|
+
let isPageVisible = true;
|
|
756
|
+
function emitPageExit() {
|
|
757
|
+
if (isPageVisible) totalActiveTime += Date.now() - lastActivityTime;
|
|
758
|
+
const totalTime = Date.now() - pageLoadTime;
|
|
759
|
+
const engagementRatio = totalTime > 0 ? totalActiveTime / totalTime : 0;
|
|
760
|
+
let maxScroll = 0;
|
|
761
|
+
trackedScrollDepths.forEach((depth) => {
|
|
762
|
+
if (depth > maxScroll) maxScroll = depth;
|
|
763
|
+
});
|
|
764
|
+
flushVisibleSections();
|
|
765
|
+
const session = getSession();
|
|
766
|
+
queueEvent("page_exit", {
|
|
767
|
+
totalTimeSeconds: Math.round(totalTime / 1e3),
|
|
768
|
+
activeTimeSeconds: Math.round(totalActiveTime / 1e3),
|
|
769
|
+
engagementRatio: Math.round(engagementRatio * 100) / 100,
|
|
770
|
+
maxScrollDepth: maxScroll,
|
|
771
|
+
referrerCategory: session.referrerCategory,
|
|
772
|
+
aiPlatform: session.aiPlatform
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
function setupEngagementTracking() {
|
|
776
|
+
document.addEventListener("visibilitychange", () => {
|
|
777
|
+
if (document.hidden) {
|
|
778
|
+
if (isPageVisible) {
|
|
779
|
+
totalActiveTime += Date.now() - lastActivityTime;
|
|
780
|
+
isPageVisible = false;
|
|
781
|
+
}
|
|
782
|
+
} else {
|
|
783
|
+
lastActivityTime = Date.now();
|
|
784
|
+
isPageVisible = true;
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
window.addEventListener("beforeunload", () => {
|
|
788
|
+
emitPageExit();
|
|
789
|
+
cleanup();
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
function setupSearchTracking() {
|
|
793
|
+
document.addEventListener("click", (e) => {
|
|
794
|
+
if (e.target.closest(config.searchSelector)) queueEvent("search_opened", {});
|
|
795
|
+
});
|
|
796
|
+
document.addEventListener("keydown", (e) => {
|
|
797
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") queueEvent("search_opened", { trigger: "keyboard" });
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
function getCodeBlockIndex(codeBlock) {
|
|
801
|
+
if (!codeBlock) return 1;
|
|
802
|
+
try {
|
|
803
|
+
const allBlocks = document.querySelectorAll(config.codeBlockSelector);
|
|
804
|
+
for (let i = 0; i < allBlocks.length; i++) if (allBlocks[i] === codeBlock) return i + 1;
|
|
805
|
+
} catch {}
|
|
806
|
+
return 1;
|
|
807
|
+
}
|
|
808
|
+
function setupCopyTracking() {
|
|
809
|
+
document.addEventListener("click", (e) => {
|
|
810
|
+
const copyButton = e.target.closest(config.copyButtonSelector);
|
|
811
|
+
if (copyButton) {
|
|
812
|
+
const codeBlock = copyButton.closest(config.codeBlockSelector) ?? copyButton.closest("div, section")?.querySelector("pre") ?? copyButton.parentElement?.querySelector("pre") ?? null;
|
|
813
|
+
const codeEl = codeBlock ? codeBlock.tagName === "PRE" ? codeBlock.querySelector("code") : codeBlock.querySelector("code[class*=\"language-\"]") ?? codeBlock.querySelector("code") : null;
|
|
814
|
+
queueEvent("code_copied", {
|
|
815
|
+
language: codeBlock?.getAttribute("language") ?? codeBlock?.getAttribute("data-language") ?? codeBlock?.getAttribute("data-lang") ?? codeBlock?.className.match(/language-(\w+)/)?.[1] ?? codeEl?.getAttribute("language") ?? codeEl?.getAttribute("data-language") ?? codeEl?.getAttribute("data-lang") ?? codeEl?.className.match(/language-(\w+)/)?.[1] ?? "unknown",
|
|
816
|
+
codeSection: sanitizeText(getNearestHeading(codeBlock ?? copyButton), 100),
|
|
817
|
+
codeBlockIndex: getCodeBlockIndex(codeBlock)
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}, true);
|
|
821
|
+
}
|
|
822
|
+
let sectionObserver = null;
|
|
823
|
+
let sectionTimers = {};
|
|
824
|
+
function setupSectionVisibilityTracking() {
|
|
825
|
+
if (!config.trackSectionVisibility) return;
|
|
826
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
827
|
+
const threshold = config.sectionVisibleThreshold * 1e3;
|
|
828
|
+
sectionObserver = new IntersectionObserver((entries) => {
|
|
829
|
+
entries.forEach((entry) => {
|
|
830
|
+
const id = entry.target.getAttribute("data-do11y-section-id");
|
|
831
|
+
if (!id) return;
|
|
832
|
+
if (entry.isIntersecting) {
|
|
833
|
+
if (!sectionTimers[id]) sectionTimers[id] = {
|
|
834
|
+
start: Date.now(),
|
|
835
|
+
reported: false
|
|
836
|
+
};
|
|
837
|
+
} else {
|
|
838
|
+
if (sectionTimers[id] && !sectionTimers[id].reported) {
|
|
839
|
+
const elapsed = Date.now() - sectionTimers[id].start;
|
|
840
|
+
if (elapsed >= threshold) {
|
|
841
|
+
queueEvent("section_visible", {
|
|
842
|
+
heading: sanitizeText(entry.target.textContent?.trim() ?? "", 100),
|
|
843
|
+
headingLevel: parseInt(entry.target.tagName.charAt(1), 10),
|
|
844
|
+
visibleSeconds: Math.round(elapsed / 1e3)
|
|
845
|
+
});
|
|
846
|
+
sectionTimers[id].reported = true;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
delete sectionTimers[id];
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
}, { threshold: .5 });
|
|
853
|
+
observeHeadings();
|
|
854
|
+
}
|
|
855
|
+
function observeHeadings() {
|
|
856
|
+
if (!sectionObserver) return;
|
|
857
|
+
document.querySelectorAll("h2, h3").forEach((h, i) => {
|
|
858
|
+
h.setAttribute("data-do11y-section-id", "section-" + i);
|
|
859
|
+
sectionObserver.observe(h);
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
function flushVisibleSections() {
|
|
863
|
+
if (!sectionObserver) return;
|
|
864
|
+
const now = Date.now();
|
|
865
|
+
const threshold = config.sectionVisibleThreshold * 1e3;
|
|
866
|
+
Object.keys(sectionTimers).forEach((id) => {
|
|
867
|
+
const timer = sectionTimers[id];
|
|
868
|
+
if (timer && !timer.reported) {
|
|
869
|
+
const elapsed = now - timer.start;
|
|
870
|
+
if (elapsed >= threshold) {
|
|
871
|
+
const escapedId = typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(id) : id.replace(/["\\]/g, "\\$&");
|
|
872
|
+
const el = document.querySelector("[data-do11y-section-id=\"" + escapedId + "\"]");
|
|
873
|
+
if (el) queueEvent("section_visible", {
|
|
874
|
+
heading: sanitizeText(el.textContent?.trim() ?? "", 100),
|
|
875
|
+
headingLevel: parseInt(el.tagName.charAt(1), 10),
|
|
876
|
+
visibleSeconds: Math.round(elapsed / 1e3)
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
sectionTimers = {};
|
|
882
|
+
}
|
|
883
|
+
function setupTabSwitchTracking() {
|
|
884
|
+
if (!config.trackTabSwitches) return;
|
|
885
|
+
document.addEventListener("click", (e) => {
|
|
886
|
+
let baseSel = "[role=\"tab\"], .tabs button, .tabs a, .tabbed-labels label";
|
|
887
|
+
const safeTabSel = validateSelector(config.tabContainerSelector);
|
|
888
|
+
if (safeTabSel) baseSel += ", " + safeTabSel + " button, " + safeTabSel + " a, " + safeTabSel + " label";
|
|
889
|
+
const tab = e.target.closest(baseSel);
|
|
890
|
+
if (!tab) return;
|
|
891
|
+
if (tab.getAttribute("aria-selected") === "true" || tab.classList.contains("active") || tab.classList.contains("is-active")) return;
|
|
892
|
+
const label = sanitizeText(tab.textContent, 50);
|
|
893
|
+
if (!label) return;
|
|
894
|
+
queueEvent("tab_switch", {
|
|
895
|
+
tabLabel: label,
|
|
896
|
+
tabGroup: sanitizeText(getNearestHeading(tab), 100),
|
|
897
|
+
isDefault: false
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
function setupTocClickTracking() {
|
|
902
|
+
if (!config.trackTocClicks) return;
|
|
903
|
+
document.addEventListener("click", (e) => {
|
|
904
|
+
const link = e.target.closest("a");
|
|
905
|
+
if (!link) return;
|
|
906
|
+
const tocContainer = link.closest(validateSelector(config.tocSelector) ?? ".table-of-contents, [class*=\"toc\"], [class*=\"outline\"], [class*=\"TableOfContents\"], [class*=\"page-outline\"]");
|
|
907
|
+
if (!tocContainer) return;
|
|
908
|
+
const href = link.getAttribute("href");
|
|
909
|
+
if (!href || !href.startsWith("#")) return;
|
|
910
|
+
const headingText = sanitizeText(link.textContent, 100);
|
|
911
|
+
let headingLevel = null;
|
|
912
|
+
try {
|
|
913
|
+
const targetId = href.slice(1);
|
|
914
|
+
const targetEl = document.getElementById(targetId);
|
|
915
|
+
if (targetEl && /^H[1-6]$/.test(targetEl.tagName)) headingLevel = parseInt(targetEl.tagName.charAt(1), 10);
|
|
916
|
+
} catch {}
|
|
917
|
+
const tocLinks = tocContainer.querySelectorAll("a[href^=\"#\"]");
|
|
918
|
+
let tocPosition = 1;
|
|
919
|
+
for (let i = 0; i < tocLinks.length; i++) if (tocLinks[i] === link) {
|
|
920
|
+
tocPosition = i + 1;
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
queueEvent("toc_click", {
|
|
924
|
+
heading: headingText,
|
|
925
|
+
headingLevel,
|
|
926
|
+
tocPosition
|
|
927
|
+
});
|
|
928
|
+
}, true);
|
|
929
|
+
}
|
|
930
|
+
function setupFeedbackTracking() {
|
|
931
|
+
if (!config.trackFeedback) return;
|
|
932
|
+
document.addEventListener("click", (e) => {
|
|
933
|
+
const button = e.target.closest("button, [role=\"button\"], a");
|
|
934
|
+
if (!button) return;
|
|
935
|
+
if (!button.closest(validateSelector(config.feedbackSelector) ?? "[class*=\"feedback\"], [class*=\"helpful\"], [class*=\"rating\"], [class*=\"was-this\"], [data-feedback]")) return;
|
|
936
|
+
const buttonText = (button.textContent ?? "").trim().toLowerCase();
|
|
937
|
+
const ariaLabel = (button.getAttribute("aria-label") ?? "").toLowerCase();
|
|
938
|
+
const titleAttr = (button.getAttribute("title") ?? "").toLowerCase();
|
|
939
|
+
const rawDataValue = button.getAttribute("data-value") ?? button.getAttribute("data-md-value") ?? button.getAttribute("data-feedback");
|
|
940
|
+
const dataValue = rawDataValue && /^[\w\s.,!?-]{1,50}$/.test(rawDataValue) ? rawDataValue : null;
|
|
941
|
+
let rating = null;
|
|
942
|
+
if (dataValue) rating = dataValue;
|
|
943
|
+
else if (/\byes\b|👍|thumbs.?up|helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "yes";
|
|
944
|
+
else if (/\bno\b|👎|thumbs.?down|not.?helpful/i.test(buttonText + " " + ariaLabel + " " + titleAttr)) rating = "no";
|
|
945
|
+
if (!rating) return;
|
|
946
|
+
queueEvent("feedback", { rating });
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
function setupExpandCollapseTracking() {
|
|
950
|
+
if (!config.trackExpandCollapse) return;
|
|
951
|
+
document.addEventListener("toggle", (e) => {
|
|
952
|
+
const details = e.target;
|
|
953
|
+
if (details.tagName !== "DETAILS") return;
|
|
954
|
+
const summary = details.querySelector("summary");
|
|
955
|
+
queueEvent("expand_collapse", {
|
|
956
|
+
summary: sanitizeText(summary ? summary.textContent : "", 100),
|
|
957
|
+
action: details.open ? "expand" : "collapse",
|
|
958
|
+
section: sanitizeText(getNearestHeading(details), 100)
|
|
959
|
+
});
|
|
960
|
+
}, true);
|
|
961
|
+
document.addEventListener("click", (e) => {
|
|
962
|
+
const trigger = e.target.closest("[aria-expanded], [class*=\"accordion\"] button, [class*=\"collapsible\"] button");
|
|
963
|
+
if (!trigger) return;
|
|
964
|
+
if (trigger.closest("details")) return;
|
|
965
|
+
if (trigger.closest("nav, [role=\"navigation\"], header")) return;
|
|
966
|
+
const wasExpanded = trigger.getAttribute("aria-expanded") === "true";
|
|
967
|
+
queueEvent("expand_collapse", {
|
|
968
|
+
summary: sanitizeText(trigger.textContent, 100),
|
|
969
|
+
action: wasExpanded ? "collapse" : "expand",
|
|
970
|
+
section: sanitizeText(getNearestHeading(trigger), 100)
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
let mutationObserver = null;
|
|
975
|
+
function init() {
|
|
976
|
+
if (window.Do11yConfig && typeof window.Do11yConfig === "object") {
|
|
977
|
+
for (const key in window.Do11yConfig) if (Object.prototype.hasOwnProperty.call(window.Do11yConfig, key) && Object.prototype.hasOwnProperty.call(config, key)) config[key] = window.Do11yConfig[key];
|
|
978
|
+
}
|
|
979
|
+
const metaDestination = document.querySelector("meta[name=\"do11y-destination\"]");
|
|
980
|
+
if (metaDestination) {
|
|
981
|
+
const dest = metaDestination.getAttribute("content");
|
|
982
|
+
if (dest === "supabase" || dest === "http") config.destination = dest;
|
|
983
|
+
}
|
|
984
|
+
const metaUrl = document.querySelector("meta[name=\"do11y-url\"]");
|
|
985
|
+
if (metaUrl) config.supabaseUrl = metaUrl.getAttribute("content") ?? config.supabaseUrl;
|
|
986
|
+
const metaKey = document.querySelector("meta[name=\"do11y-key\"]");
|
|
987
|
+
if (metaKey) config.supabaseKey = metaKey.getAttribute("content") ?? config.supabaseKey;
|
|
988
|
+
const metaTable = document.querySelector("meta[name=\"do11y-table\"]");
|
|
989
|
+
if (metaTable) config.supabaseTable = metaTable.getAttribute("content") ?? config.supabaseTable;
|
|
990
|
+
const metaHttpEndpoint = document.querySelector("meta[name=\"do11y-http-endpoint\"]");
|
|
991
|
+
if (metaHttpEndpoint) config.httpEndpoint = metaHttpEndpoint.getAttribute("content") ?? config.httpEndpoint;
|
|
992
|
+
const metaDebug = document.querySelector("meta[name=\"do11y-debug\"]");
|
|
993
|
+
if (metaDebug && metaDebug.getAttribute("content") === "true") config.debug = true;
|
|
994
|
+
const metaDomains = document.querySelector("meta[name=\"do11y-domains\"]");
|
|
995
|
+
if (metaDomains) {
|
|
996
|
+
const domainsStr = metaDomains.getAttribute("content");
|
|
997
|
+
if (domainsStr) config.allowedDomains = domainsStr.split(",").map((d) => d.trim());
|
|
998
|
+
}
|
|
999
|
+
const metaFramework = document.querySelector("meta[name=\"do11y-framework\"]");
|
|
1000
|
+
if (metaFramework) config.framework = metaFramework.getAttribute("content") ?? config.framework;
|
|
1001
|
+
applyFrameworkSelectors();
|
|
1002
|
+
if (config.debug) console.log("[Do11y] Initializing with config:", {
|
|
1003
|
+
destination: config.destination,
|
|
1004
|
+
hasCredentials: config.destination === "supabase" ? !!config.supabaseKey : !!config.httpEndpoint,
|
|
1005
|
+
framework: config.framework,
|
|
1006
|
+
allowedDomains: config.allowedDomains,
|
|
1007
|
+
respectDNT: config.respectDNT
|
|
1008
|
+
});
|
|
1009
|
+
if (shouldDisableTracking()) {
|
|
1010
|
+
isDisabled = true;
|
|
1011
|
+
if (config.debug) console.log("[Do11y] Tracking disabled");
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (!(config.destination === "supabase" ? !!config.supabaseKey : !!config.httpEndpoint)) {
|
|
1015
|
+
if (config.debug) {
|
|
1016
|
+
console.warn("[Do11y] No destination configured. Events will not be sent.");
|
|
1017
|
+
console.warn("[Do11y] Add <meta name=\"do11y-url\"> and <meta name=\"do11y-key\"> to enable.");
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
trackPageView();
|
|
1021
|
+
setupLinkTracking();
|
|
1022
|
+
setupScrollTracking();
|
|
1023
|
+
setupEngagementTracking();
|
|
1024
|
+
setupSearchTracking();
|
|
1025
|
+
setupCopyTracking();
|
|
1026
|
+
setupSectionVisibilityTracking();
|
|
1027
|
+
setupTabSwitchTracking();
|
|
1028
|
+
setupTocClickTracking();
|
|
1029
|
+
setupFeedbackTracking();
|
|
1030
|
+
setupExpandCollapseTracking();
|
|
1031
|
+
let lastPath = window.location.pathname;
|
|
1032
|
+
mutationObserver = new MutationObserver(() => {
|
|
1033
|
+
if (window.location.pathname !== lastPath) {
|
|
1034
|
+
lastPath = window.location.pathname;
|
|
1035
|
+
emitPageExit();
|
|
1036
|
+
trackedScrollDepths = /* @__PURE__ */ new Set();
|
|
1037
|
+
pageLoadTime = Date.now();
|
|
1038
|
+
lastActivityTime = Date.now();
|
|
1039
|
+
totalActiveTime = 0;
|
|
1040
|
+
isPageVisible = true;
|
|
1041
|
+
trackPageView();
|
|
1042
|
+
observeHeadings();
|
|
1043
|
+
checkScrollDepth();
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
mutationObserver.observe(document.body, {
|
|
1047
|
+
childList: true,
|
|
1048
|
+
subtree: true
|
|
1049
|
+
});
|
|
1050
|
+
window.addEventListener("popstate", () => {
|
|
1051
|
+
if (window.location.pathname !== lastPath) {
|
|
1052
|
+
lastPath = window.location.pathname;
|
|
1053
|
+
emitPageExit();
|
|
1054
|
+
trackedScrollDepths = /* @__PURE__ */ new Set();
|
|
1055
|
+
pageLoadTime = Date.now();
|
|
1056
|
+
lastActivityTime = Date.now();
|
|
1057
|
+
totalActiveTime = 0;
|
|
1058
|
+
isPageVisible = true;
|
|
1059
|
+
trackPageView();
|
|
1060
|
+
observeHeadings();
|
|
1061
|
+
checkScrollDepth();
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
Object.freeze(config);
|
|
1065
|
+
if (config.debug) console.log("[Do11y] Initialized successfully");
|
|
1066
|
+
}
|
|
1067
|
+
function cleanup() {
|
|
1068
|
+
if (mutationObserver) {
|
|
1069
|
+
mutationObserver.disconnect();
|
|
1070
|
+
mutationObserver = null;
|
|
1071
|
+
}
|
|
1072
|
+
if (sectionObserver) {
|
|
1073
|
+
flushVisibleSections();
|
|
1074
|
+
sectionObserver.disconnect();
|
|
1075
|
+
sectionObserver = null;
|
|
1076
|
+
}
|
|
1077
|
+
if (flushTimeout) {
|
|
1078
|
+
clearTimeout(flushTimeout);
|
|
1079
|
+
flushTimeout = null;
|
|
1080
|
+
}
|
|
1081
|
+
flushSync();
|
|
1082
|
+
}
|
|
1083
|
+
if (!_alreadyLoaded) if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
|
1084
|
+
else init();
|
|
1085
|
+
window.Do11y = window.Do11y ?? {
|
|
1086
|
+
getConfig: () => ({
|
|
1087
|
+
destination: config.destination,
|
|
1088
|
+
hasCredentials: config.destination === "supabase" ? !!config.supabaseKey : !!config.httpEndpoint,
|
|
1089
|
+
isDisabled,
|
|
1090
|
+
allowedDomains: config.allowedDomains,
|
|
1091
|
+
respectDNT: config.respectDNT
|
|
1092
|
+
}),
|
|
1093
|
+
flush,
|
|
1094
|
+
isEnabled: () => !isDisabled && (config.destination === "supabase" ? !!config.supabaseKey : !!config.httpEndpoint),
|
|
1095
|
+
getQueueSize: () => eventQueue.length,
|
|
1096
|
+
version: VERSION
|
|
1097
|
+
};
|
|
1098
|
+
//#endregion
|
|
1099
|
+
})();
|