1/** 2 * DOIT Analytics â theme-agnostic GA4 tracker (uses existing window.gtag). 3 * 4 * Integration contract: 5 * - Themes mark DOM with data-analytics-* (see Config / INTEGRATION.md) 6 * - Optional: dispatch CustomEvent('doit-analytics:share', { detail: { method } }) 7 * - Optional plugins: doit-quizzes:started|completed, doit-polls:voted 8 */ 9(function () { 10 'use strict'; 11 12 var DEFAULT_EVENTS = { 13 comment_submit: true, 14 select_content: true, 15 article_read_progress: true 16 }; 17 18 var BLOCKED_PARAM_KEYS = { 19 email: true, 20 user_email: true, 21 login: true, 22 user_login: true, 23 username: true, 24 user_name: true, 25 display_name: true, 26 first_name: true, 27 last_name: true, 28 name: true, 29 ip: true, 30 ip_address: true, 31 comment: true, 32 comment_content: true, 33 comment_author: true, 34 comment_author_email: true, 35 password: true, 36 phone: true 37 }; 38 39 var sentKeys = Object.create(null); 40 var runtime = window.doitAnalyticsRuntime || {}; 41 var page = runtime.page || {}; 42 var cfg = runtime.config || {}; 43 var attr = cfg.attr || {}; 44 var debug = !!runtime.debug; 45 var dryRun = !!cfg.dryRun; 46 var gaDebugMode = !!cfg.gaDebugMode; 47 var trackingEnabled = cfg.trackingEnabled !== false; 48 49 var ATTR_MODULE = attr.module || 'data-analytics-module'; 50 var ATTR_POSITION = attr.position || 'data-analytics-position'; 51 var ATTR_CONTENT_ID = attr.contentId || 'data-analytics-content-id'; 52 var ATTR_CONTENT_TYPE = attr.contentType || 'data-analytics-content-type'; 53 var ATTR_CONTENT_GROUP = attr.contentGroup || 'data-analytics-content-group'; 54 var ATTR_SURFACE = attr.surface || 'data-analytics-surface'; 55 var ATTR_SHARE = attr.share || 'data-analytics-share'; 56 var ATTR_SELECT = attr.select || 'data-analytics-select'; 57 var ATTR_ARTICLE = attr.articleContent || 'data-analytics-article-content'; 58 59 function allowedEventMap() { 60 var map = {}; 61 var list = Array.isArray(cfg.allowedEvents) ? cfg.allowedEvents : null; 62 var i; 63 if (list && list.length) { 64 for (i = 0; i < list.length; i++) { 65 map[String(list[i])] = true; 66 } 67 return map; 68 } 69 return DEFAULT_EVENTS; 70 } 71 72 var ALLOWED_EVENTS = allowedEventMap(); 73 74 function isDebug() { 75 return debug || !!(window.doitAnalytics && window.doitAnalytics.debug); 76 } 77 78 function cleanParams(params) { 79 var out = {}; 80 var key; 81 if (!params || typeof params !== 'object') { 82 return out; 83 } 84 for (key in params) { 85 if (!Object.prototype.hasOwnProperty.call(params, key)) { 86 continue; 87 } 88 if (BLOCKED_PARAM_KEYS[key]) { 89 continue; 90 } 91 if (params[key] === undefined || params[key] === null || params[key] === '') { 92 continue; 93 } 94 out[key] = params[key]; 95 } 96 return out; 97 } 98 99 function track(eventName, params, options) { 100 var name = String(eventName || ''); 101 var opts = options || {}; 102 var payload; 103 104 if (!trackingEnabled) { 105 return false; 106 } 107 108 if (!name || !ALLOWED_EVENTS[name]) { 109 if (isDebug()) { 110 console.warn('[doit-analytics] blocked event', name, params); 111 } 112 return false; 113 } 114 115 payload = cleanParams(params); 116 117 if (gaDebugMode) { 118 payload.debug_mode = true; 119 } 120 121 if (opts.dedupeKey) { 122 if (sentKeys[opts.dedupeKey]) { 123 return false; 124 } 125 sentKeys[opts.dedupeKey] = true; 126 } 127 128 if (isDebug() || dryRun) { 129 console.info('[doit-analytics]' + (dryRun ? ' [dry-run]' : ''), name, payload); 130 } 131 132 if (dryRun) { 133 return true; 134 } 135 136 if (typeof window.gtag !== 'function') { 137 return false; 138 } 139 140 try { 141 window.gtag('event', name, payload); 142 } catch (err) { 143 if (isDebug()) { 144 console.warn('[doit-analytics] gtag error', err); 145 } 146 return false; 147 } 148 149 return true; 150 } 151 152 function flushPending() { 153 var list = Array.isArray(runtime.pendingEvents) ? runtime.pendingEvents : []; 154 var i; 155 for (i = 0; i < list.length; i++) { 156 var item = list[i]; 157 if (!item || !item.name) { 158 continue; 159 } 160 track(item.name, item.params || {}, { 161 dedupeKey: 'pending:' + item.name + ':' + JSON.stringify(item.params || {}) 162 }); 163 } 164 } 165 166 function pageDefaults() { 167 return { 168 content_id: page.content_id || '', 169 content_type: page.content_type || '', 170 content_group: page.content_group || '', 171 surface: page.surface || '' 172 }; 173 } 174 175 function trackShare(method, extra) { 176 var defaults = pageDefaults(); 177 var params = { 178 method: method, 179 content_type: defaults.content_type || 'article', 180 content_id: defaults.content_id, 181 item_id: defaults.content_id, 182 content_group: defaults.content_group, 183 surface: defaults.surface || 'article' 184 }; 185 var key; 186 if (extra && typeof extra === 'object') { 187 for (key in extra) { 188 if (Object.prototype.hasOwnProperty.call(extra, key) && extra[key] != null && extra[key] !== '') { 189 params[key] = extra[key]; 190 } 191 } 192 } 193 track('share', params); 194 } 195 196 function bindShare() { 197 document.addEventListener( 198 'click', 199 function (event) { 200 var target = event.target; 201 if (!target || !target.closest) { 202 return; 203 } 204 var link = target.closest('[' + ATTR_SHARE + ']'); 205 if (!link) { 206 return; 207 } 208 var method = link.getAttribute(ATTR_SHARE) || ''; 209 if (!method || method === 'copy_link') { 210 // copy_link is tracked after confirmed copy via CustomEvent 211 return; 212 } 213 trackShare(method); 214 }, 215 true 216 ); 217 218 var copyEvents = Array.isArray(cfg.shareCopyEvents) && cfg.shareCopyEvents.length 219 ? cfg.shareCopyEvents 220 : ['doit-analytics:share']; 221
222 copyEvents.forEach(function (eventName) { 223 document.addEventListener(String(eventName), function (event) { 224 var detail = (event && event.detail) || {}; 225 var method = detail.method || 'copy_link'; 226 trackShare(method, detail); 227 }); 228 }); 229 } 230 231 function selectLinkSelector() { 232 return cfg.selectLinkSelector || 'a[' + ATTR_SELECT + ']'; 233 } 234 235 function selectIgnoreSelector() { 236 return cfg.selectIgnoreSelector || ''; 237 } 238 239 function isIgnoredSelectTarget(el) { 240 var ignore = selectIgnoreSelector(); 241 if (!ignore || !el || !el.closest) { 242 return false; 243 } 244 try { 245 return !!el.closest(ignore); 246 } catch (err) { 247 return false; 248 } 249 } 250 251 function bindSelectContent() { 252 document.addEventListener( 253 'click', 254 function (event) { 255 var target = event.target; 256 if (!target || !target.closest || isIgnoredSelectTarget(target)) { 257 return; 258 } 259 260 var link; 261 try { 262 link = target.closest(selectLinkSelector()); 263 } catch (err) { 264 link = null; 265 } 266 if (!link) { 267 return; 268 } 269 270 var host = link.closest('[' + ATTR_MODULE + ']') || link; 271 var moduleName = host.getAttribute(ATTR_MODULE) || ''; 272 if (!moduleName) { 273 return; 274 } 275 276 var position = parseInt(host.getAttribute(ATTR_POSITION) || '0', 10); 277 var contentId = host.getAttribute(ATTR_CONTENT_ID) || ''; 278 var contentType = host.getAttribute(ATTR_CONTENT_TYPE) || 'article'; 279 var contentGroup = host.getAttribute(ATTR_CONTENT_GROUP) || ''; 280 var surface = 281 host.getAttribute(ATTR_SURFACE) || 282 page.surface || 283 ''; 284 285 track('select_content', { 286 content_type: contentType, 287 content_id: contentId, 288 content_group: contentGroup, 289 surface: surface, 290 module_name: moduleName, 291 module_position: position > 0 ? position : 1 292 }); 293 }, 294 true 295 ); 296 } 297 298 function getReadDepth(el) { 299 var rect = el.getBoundingClientRect(); 300 var viewH = window.innerHeight || document.documentElement.clientHeight || 0; 301 var top = rect.top; 302 var height = rect.height; 303 304 if (height <= 0) { 305 return 100; 306 } 307 308 var scrolled = viewH - top; 309 var pct = (scrolled / height) * 100; 310 if (pct < 0) { 311 return 0; 312 } 313 if (pct > 100) { 314 return 100; 315 } 316 return pct; 317 } 318 319 function bindReadProgress() { 320 if ((page.content_type || '') !== 'article' || !page.article) { 321 return; 322 } 323 324 var thresholds = Array.isArray(runtime.readThresholds) 325 ? runtime.readThresholds.slice() 326 : [25, 50, 75, 90]; 327 var fired = Object.create(null); 328 var selector = 329 (page.article && page.article.content_selector) || 330 '[' + ATTR_ARTICLE + '], .entry-content'; 331 var ticking = false; 332 333 function contentEl() { 334 try { 335 return document.querySelector(selector); 336 } catch (err) { 337 return null; 338 } 339 } 340 341 function emitForDepth(depth) { 342 var i; 343 var t; 344 for (i = 0; i < thresholds.length; i++) { 345 t = thresholds[i]; 346 if (depth >= t && !fired[t]) { 347 fired[t] = true; 348 track( 349 'article_read_progress', 350 { 351 content_id: page.content_id, 352 content_type: 'article', 353 content_group: page.content_group || '', 354 read_depth: t, 355 article_length_chars: page.article.article_length_chars || 0, 356 article_length_bucket: page.article.article_length_bucket || '' 357 }, 358 { dedupeKey: 'read:' + page.content_id + ':' + t } 359 ); 360 } 361 } 362 } 363 364 function onScroll() { 365 if (ticking) { 366 return; 367 } 368 ticking = true; 369 window.requestAnimationFrame(function () { 370 ticking = false; 371 var el = contentEl(); 372 if (!el) { 373 return; 374 } 375 emitForDepth(getReadDepth(el)); 376 }); 377 } 378 379 window.addEventListener('scroll', onScroll, { passive: true }); 380 window.addEventListener('resize', onScroll, { passive: true }); 381 onScroll(); 382 383 var observeSelector = cfg.mutationObserveSelector || ''; 384 var container = null; 385 if (observeSelector) {
386 try { 387 container = document.querySelector(observeSelector); 388 } catch (err) { 389 container = null; 390 } 391 } 392 if (!container) { 393 container = contentEl() || document.body; 394 } 395 396 if (container && typeof MutationObserver !== 'undefined') { 397 var observer = new MutationObserver(function () { 398 onScroll(); 399 }); 400 observer.observe(container, { childList: true, subtree: true }); 401 } 402 } 403 404 function bindQuizEvents() { 405 document.addEventListener('doit-quizzes:started', function (event) { 406 var detail = (event && event.detail) || {}; 407 var quizId = parseInt(detail.quizId || 0, 10); 408 if (!quizId) { 409 return; 410 } 411 track( 412 'quiz_start', 413 { 414 content_id: 'quiz_' + quizId, 415 content_type: 'quiz', 416 content_group: detail.content_group || page.content_group || '', 417 surface: 'quiz', 418 question_count: parseInt(detail.question_count || 0, 10) || undefined 419 }, 420 { dedupeKey: 'quiz_start:' + quizId + ':' + (detail.attemptKey || '') } 421 ); 422 }); 423 424 document.addEventListener('doit-quizzes:completed', function (event) { 425 var detail = (event && event.detail) || {}; 426 var quizId = parseInt(detail.quizId || 0, 10); 427 if (!quizId) { 428 return; 429 } 430 track( 431 'quiz_complete', 432 { 433 content_id: 'quiz_' + quizId, 434 content_type: 'quiz', 435 content_group: detail.content_group || page.content_group || '', 436 surface: 'quiz', 437 question_count: parseInt(detail.question_count || detail.total || 0, 10) || undefined, 438 quiz_score: detail.score, 439 quiz_max_score: detail.total, 440 quiz_score_percent: detail.percent, 441 quiz_duration_seconds: detail.durationSeconds 442 }, 443 { dedupeKey: 'quiz_complete:' + quizId + ':' + (detail.attemptKey || '') } 444 ); 445 }); 446 } 447 448 function resolvePollSurface(instanceId) { 449 if (page.surface === 'homepage' || page.surface === 'article') { 450 return page.surface; 451 } 452 453 var prefixes = cfg.pollSurfacePrefixes || {}; 454 var raw = String(instanceId || ''); 455 var key; 456 for (key in prefixes) { 457 if (Object.prototype.hasOwnProperty.call(prefixes, key) && raw.indexOf(key) === 0) { 458 return prefixes[key]; 459 } 460 } 461 462 return page.surface || 'other'; 463 } 464 465 function resolvePollModule(instanceId) { 466 if (!instanceId) { 467 return ''; 468 } 469 var raw = String(instanceId); 470 if (raw.indexOf('slot-') !== 0) { 471 return 'poll_embed'; 472 } 473 var without = raw.slice(5); 474 var lastDash = without.lastIndexOf('-'); 475 if (lastDash <= 0) { 476 return without || 'poll_slot'; 477 } 478 return without.slice(0, lastDash) || 'poll_slot'; 479 } 480 481 function bindPollEvents() { 482 document.addEventListener('doit-polls:voted', function (event) { 483 var detail = (event && event.detail) || {}; 484 var pollId = parseInt(detail.pollId || 0, 10); 485 var optionId = detail.voted_option_id || ''; 486 if (!pollId || !optionId) { 487 return; 488 } 489 490 var instanceId = detail.instanceId || ''; 491 if (!instanceId) { 492 var root = document.querySelector( 493 '[data-doit-polls-root][data-poll-id="' + String(pollId) + '"]' 494 ); 495 instanceId = root ? root.getAttribute('data-instance-id') || '' : ''; 496 } 497 498 var params = { 499 poll_id: 'poll_' + pollId, 500 poll_option_id: 501 String(optionId).indexOf('option_') === 0 502 ? String(optionId) 503 : 'option_' + String(optionId), 504 surface: resolvePollSurface(instanceId), 505 module_name: resolvePollModule(instanceId) 506 }; 507 508 if (page.content_id) { 509 params.content_id = page.content_id; 510 } 511 512 track('poll_vote', params, { 513 dedupeKey: 'poll_vote:' + pollId + ':' + optionId 514 }); 515 }); 516 } 517 518 function init() { 519 flushPending(); 520 bindShare(); 521 bindSelectContent(); 522 bindReadProgress(); 523 bindQuizEvents(); 524 bindPollEvents(); 525 } 526 527 window.doitAnalytics = { 528 track: track, 529 debug: debug, 530 version: '1.2.0', 531 runtime: runtime 532 }; 533 534 if (document.readyState === 'loading') { 535 document.addEventListener('DOMContentLoaded', init); 536 } else { 537 init(); 538 } 539})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.