diff --git a/docs/src/theme/Root/index.js b/docs/src/theme/Root/index.js new file mode 100644 index 000000000..82b0be22f --- /dev/null +++ b/docs/src/theme/Root/index.js @@ -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}; +} + diff --git a/docs/src/utils/tracking.js b/docs/src/utils/tracking.js new file mode 100644 index 000000000..5cb765a88 --- /dev/null +++ b/docs/src/utils/tracking.js @@ -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 };