chore(docs): add google analytics tracking code

This commit is contained in:
Maribeth Moffatt
2026-01-15 17:45:50 -05:00
parent 2aa1aa8b9f
commit f4c6a271e5
2 changed files with 430 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
/**
* Root theme component - wraps the entire app
* Handles client-side tracking
*/
import React, { useEffect } from 'react';
import { trackSiteSearch, trackCTAClick, trackCopyCode, extractFunctionName } from '../../utils/tracking';
export default function Root({ children }) {
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
if (!window.dataLayer) {
window.dataLayer = [];
}
let lastTrackedQuery = '';
let searchTimeout = null;
const handleDocSearchQuery = (event) => {
if (event.detail && event.detail.query) {
const query = event.detail.query.trim();
if (query && query !== lastTrackedQuery && query.length > 0) {
trackSiteSearch(query);
lastTrackedQuery = query;
}
}
};
document.addEventListener('docsearch:query', handleDocSearchQuery);
const setupInputTracking = (searchInput) => {
if (searchInput.hasAttribute('data-tracking-setup')) {
return;
}
searchInput.setAttribute('data-tracking-setup', 'true');
const handleInput = (e) => {
const query = e.target.value.trim();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (query.length > 0 && query !== lastTrackedQuery) {
searchTimeout = setTimeout(() => {
trackSiteSearch(query);
lastTrackedQuery = query;
}, 800);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
const query = e.target.value.trim();
if (query && query !== lastTrackedQuery && query.length > 0) {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
trackSiteSearch(query);
lastTrackedQuery = query;
}
}
};
searchInput.addEventListener('input', handleInput);
searchInput.addEventListener('keydown', handleKeyDown);
};
const observer = new MutationObserver(() => {
const searchInput = document.querySelector('.DocSearch-Input');
if (searchInput) {
setupInputTracking(searchInput);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
const initialSearchInput = document.querySelector('.DocSearch-Input');
if (initialSearchInput) {
setupInputTracking(initialSearchInput);
}
const handleResultClick = (e) => {
const hitElement = e.target.closest('.DocSearch-Hit');
if (hitElement) {
const searchInput = document.querySelector('.DocSearch-Input');
if (searchInput && searchInput.value) {
const query = searchInput.value.trim();
if (query && query !== lastTrackedQuery && query.length > 0) {
trackSiteSearch(query);
lastTrackedQuery = query;
}
}
}
};
document.addEventListener('click', handleResultClick);
// CTA Click Tracking
// Track clicks on buttons and links that should be treated as CTAs
const handleCTAClick = (e) => {
// Find the clicked element (could be button, link, or child element)
let target = e.target;
// Traverse up to find the actual button/link element
while (target && target !== document.body) {
// Check if it's a button or link with CTA classes
const ctaSelector = 'a.getStarted, a.button, button.button, .button, a.cardButton, .cardButton, .downloadAsset, a.downloadAsset, .assetDownloadLink, a.assetDownloadLink';
const isCTA = target.matches && (
target.matches(ctaSelector) ||
target.closest(ctaSelector)
);
if (isCTA) {
const ctaElement = target.matches(ctaSelector)
? target
: target.closest(ctaSelector);
if (ctaElement) {
let clickUrl = '';
if (ctaElement.href) {
clickUrl = ctaElement.href;
} else if (ctaElement.getAttribute('href')) {
const href = ctaElement.getAttribute('href');
clickUrl = href.startsWith('http') ? href : window.location.origin + href;
} else if (ctaElement.getAttribute('to')) {
// Docusaurus Link component uses 'to' attribute
const to = ctaElement.getAttribute('to');
clickUrl = to.startsWith('http') ? to : window.location.origin + to;
} else {
clickUrl = window.location.href;
}
// Get the text content
let clickText = ctaElement.textContent?.trim() ||
ctaElement.innerText?.trim() ||
ctaElement.getAttribute('aria-label') ||
ctaElement.getAttribute('title') ||
'CTA Click';
// Clean up the text (remove extra whitespace)
clickText = clickText.replace(/\s+/g, ' ').trim();
// Track the CTA click
if (clickUrl && clickText) {
trackCTAClick(clickUrl, clickText);
}
}
break;
}
target = target.parentElement;
}
};
document.addEventListener('click', handleCTAClick);
// Code Copy Tracking
const handleCodeCopyClick = (e) => {
// Find the copy button (Docusaurus uses a button with aria-label containing "copy")
const copyButton = e.target.closest('button[aria-label*="copy" i], button[aria-label*="copier" i]');
if (copyButton) {
const codeBlock = copyButton.closest('div[class*="codeBlock"], .theme-code-block, .prism-code, pre');
if (codeBlock) {
let codeElement = codeBlock.querySelector('code');
if (!codeElement) {
const preElement = codeBlock.querySelector('pre code') || codeBlock.closest('pre')?.querySelector('code');
if (preElement) {
codeElement = preElement;
}
}
if (codeElement) {
// Extract language from class names (recursive search)
const findLanguage = (element) => {
if (!element || element === document.body) return 'unknown';
const classList = Array.from(element.classList);
const langClass = classList.find(cls => cls.startsWith('language-'));
if (langClass) {
return langClass.replace('language-', '');
}
return findLanguage(element.parentElement);
};
// Also check pre element and container for language class
let languageName = findLanguage(codeElement);
if (languageName === 'unknown') {
const preElement = codeElement.closest('pre');
if (preElement) {
languageName = findLanguage(preElement);
}
}
if (languageName === 'unknown') {
languageName = findLanguage(codeBlock);
}
const codeContent = codeElement.textContent || codeElement.innerText || '';
const functionName = extractFunctionName(codeContent, languageName);
trackCopyCode(functionName, languageName);
}
}
}
};
// Also listen for copy events to catch any copy operations
const handleCopyEvent = (e) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
let node = range.commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) {
node = node.parentElement;
}
const codeElement = node?.closest?.('code');
if (!codeElement) return;
// Extract language
const findLanguage = (element) => {
const classList = Array.from(element.classList);
const langClass = classList.find(cls => cls.startsWith('language-'));
console.log("hello", langClass);
if (langClass) return langClass.replace('language-', '');
const parent = element.parentElement;
if (parent && parent !== document.body) {
return findLanguage(parent);
}
return 'unknown';
};
const languageName = findLanguage(codeElement);
const codeContent = selection.toString() || codeElement.textContent || '';
const functionName = extractFunctionName(codeContent, languageName);
trackCopyCode(functionName, languageName);
};
document.addEventListener('click', handleCodeCopyClick);
document.addEventListener('copy', handleCopyEvent);
// Cleanup
return () => {
document.removeEventListener('docsearch:query', handleDocSearchQuery);
document.removeEventListener('click', handleResultClick);
document.removeEventListener('click', handleCTAClick);
document.removeEventListener('click', handleCodeCopyClick);
document.removeEventListener('copy', handleCopyEvent);
observer.disconnect();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
};
}, []);
return <>{children}</>;
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Utility functions for pushing custom events to dataLayer for GA4 tracking via GTM
*/
/**
* Push custom events to dataLayer for GA4 tracking
* @param {string} eventName - The name of the event (e.g., 'site_search', 'cta_click', 'copy_code')
* @param {object} eventParams - Additional parameters to send with the event
*/
export function pushToDataLayer(eventName, eventParams = {}) {
if (typeof window === 'undefined') {
return;
}
// Ensure dataLayer exists
if (!window.dataLayer) {
window.dataLayer = [];
}
const eventData = {
event: eventName,
...eventParams
};
window.dataLayer.push(eventData);
}
/**
* Track site search with query
* @param {string} searchQuery - The search query entered by the user
*/
export function trackSiteSearch(searchQuery) {
if (!searchQuery || typeof searchQuery !== 'string' || searchQuery.trim() === '') {
return;
}
const trimmedQuery = searchQuery.trim();
pushToDataLayer('site_search', {
search_query: trimmedQuery
});
}
/**
* Track CTA button clicks
* @param {string} clickUrl - The URL the CTA links to (cta_location)
* @param {string} clickText - The text/label of the CTA button (cta_label)
*/
export function trackCTAClick(clickUrl, clickText) {
if (!clickUrl || !clickText) {
return;
}
pushToDataLayer('cta_click', {
cta_location: clickUrl,
cta_label: clickText
});
}
/**
* Extract function/method/class/enum name from code
* @param {string} code - The code content
* @param {string} language - The programming language (optional, helps with extraction)
* @returns {string} - The function/method/class/enum name or meaningful identifier
*/
function extractFunctionName(code, language = '') {
if (!code || typeof code !== 'string') {
return 'unknown';
}
const trimmedCode = code.trim();
if (trimmedCode.length === 0) {
return 'unknown';
}
// Common keywords and variable names to skip
const skipNames = new Set(['code', 'let', 'const', 'var', 'import', 'export', 'function',
'preamble', 'postscript', 'result', 'value', 'data', 'item',
'element', 'obj', 'arr', 'str', 'num', 'bool', 'temp',
'if', 'for', 'while', 'new', 'return', 'class', 'enum', 'def',
'try', 'catch', 'then', 'else', 'async', 'await', 'from', 'as']);
// For JSON/XML: Extract meaningful keys or identifiers
if (language === 'json' || language === 'xml') {
const jsonKeyPattern = /"([a-zA-Z_$][a-zA-Z0-9_$]{2,})"\s*:/;
const jsonMatch = trimmedCode.match(jsonKeyPattern);
if (jsonMatch && jsonMatch[1] && !skipNames.has(jsonMatch[1].toLowerCase())) {
return jsonMatch[1];
}
// XML: Extract tag name or attribute
const xmlTagPattern = /<([a-zA-Z_$][a-zA-Z0-9_$]{2,})/;
const xmlMatch = trimmedCode.match(xmlTagPattern);
if (xmlMatch && xmlMatch[1] && !skipNames.has(xmlMatch[1].toLowerCase())) {
return xmlMatch[1];
}
// Fallback: Extract any meaningful identifier
const identifierPattern = /\b([a-zA-Z_$][a-zA-Z0-9_$]{3,})\b/;
const identifierMatch = trimmedCode.match(identifierPattern);
if (identifierMatch && identifierMatch[1] && !skipNames.has(identifierMatch[1].toLowerCase())) {
return identifierMatch[1];
}
return 'unknown';
}
// Priority 1: Method calls with object (e.g., javascriptGenerator.workspaceToCode())
const methodCallPattern = /([a-zA-Z_$][a-zA-Z0-9_$]*\.)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/;
const methodMatch = trimmedCode.match(methodCallPattern);
if (methodMatch && methodMatch[2] && !skipNames.has(methodMatch[2].toLowerCase())) {
return methodMatch[2];
}
// Priority 2: Function/Method/Class declarations
const declPattern = /(?:function|const|let|var|class|enum|def)\s+([a-zA-Z_$][a-zA-Z0-9_$]{2,})/;
const declMatch = trimmedCode.match(declPattern);
if (declMatch && declMatch[1] && !skipNames.has(declMatch[1].toLowerCase())) {
return declMatch[1];
}
// Priority 3: Function calls without object (e.g., myFunction())
const functionCallPattern = /\b([a-zA-Z_$][a-zA-Z0-9_$]{3,})\s*\(/;
const funcMatch = trimmedCode.match(functionCallPattern);
if (funcMatch && funcMatch[1] && !skipNames.has(funcMatch[1].toLowerCase())) {
return funcMatch[1];
}
// Last resort: First meaningful identifier
const identifierPattern = /\b([a-zA-Z_$][a-zA-Z0-9_$]{3,})\b/;
const identifierMatch = trimmedCode.match(identifierPattern);
if (identifierMatch && identifierMatch[1] && !skipNames.has(identifierMatch[1].toLowerCase())) {
return identifierMatch[1];
}
return 'unknown';
}
/**
* Track code copy events
* @param {string} functionName
* @param {string} languageName
*/
export function trackCopyCode(functionName, languageName) {
if (!functionName || !languageName) {
return;
}
pushToDataLayer('copy_code', {
function_name: functionName,
language_name: languageName
});
}
export { extractFunctionName };