docs: Add heading and codeblock events for segment (#9050)

* docs: Add heading and codeblock events for segment

* docs: Update scroll event names

* docs: improve capture of code block language

* docs: ensure code block language capture for mobile
This commit is contained in:
Ronnie Miller 2025-07-15 08:17:11 -07:00 committed by GitHub
commit d8291131ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 407 additions and 0 deletions

View file

@ -0,0 +1,55 @@
// Default configuration shared between client and server
const DEFAULT_SELECTORS = [
{
selector: 'h1, h2, h3, h4, h5, h6',
eventName: 'Scroll - Heading Viewed',
properties: {
element_type: 'heading'
}
}
];
// Custom Docusaurus plugin for scroll tracking with Segment analytics
function pluginScrollTracking(context, options = {}) {
const isProd = process.env.NODE_ENV === "production" || options.allowedInDev;
const segmentPublicWriteKey = options.segmentPublicWriteKey;
if (!segmentPublicWriteKey) {
console.warn('Scroll tracking plugin: No Segment write key provided. Analytics will not be initialized.');
return { name: 'docusaurus-plugin-scroll-tracking' };
}
return {
name: 'docusaurus-plugin-scroll-tracking',
getClientModules() {
return isProd ? [require.resolve('./scroll-tracking')] : [];
},
injectHtmlTags() {
if (!isProd) {
return {};
}
// Inject configuration into global scope for client-side access
const config = {
selectors: options.selectors || DEFAULT_SELECTORS
};
const configScript = `
window.__SCROLL_TRACKING_CONFIG__ = ${JSON.stringify(config)};
`;
return {
headTags: [
{
tagName: 'script',
innerHTML: configScript,
},
],
};
},
};
}
module.exports = pluginScrollTracking;

View file

@ -0,0 +1,331 @@
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
let isScrollTrackingInitialized = false;
// Helper functions for extracting dynamic properties
const propertyHelpers = {
// Extract language from code elements - try multiple approaches
codeLanguage: (element) => {
// Method 1: Look for data-ch-lang attribute
const codeElement = element.querySelector('[data-ch-lang]') ||
element.closest('[data-ch-lang]');
if (codeElement) {
const lang = codeElement.getAttribute('data-ch-lang');
if (lang && lang !== 'text') return lang;
}
// Method 2: Look for active tab in the same container
const container = element.closest('.theme-code-block') ||
element.parentElement?.closest('[class*="code"]') ||
element.parentElement;
if (container) {
const activeTab = container.querySelector('li[role="tab"][aria-selected="true"]');
if (activeTab) {
const tabText = activeTab.textContent?.trim();
if (tabText && tabText.toLowerCase() !== 'text') {
return tabText.toLowerCase();
}
}
}
// Method 3: Look for any tab as fallback
if (container) {
const anyTab = container.querySelector('li[role="tab"]');
if (anyTab) {
const tabText = anyTab.textContent?.trim();
if (tabText && tabText.toLowerCase() !== 'text') {
return tabText.toLowerCase();
}
}
}
return null;
}
};
// Default configuration (fallback if no config is injected)
const defaultConfig = {
selectors: [
{
selector: 'h1, h2, h3, h4, h5, h6',
eventName: 'Scroll - Heading Viewed',
properties: {
element_type: 'heading'
}
}
]
};
/**
* Get scroll depth percentage
*/
function getScrollDepthPercentage() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const documentHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
const windowHeight = window.innerHeight;
const scrollableHeight = documentHeight - windowHeight;
if (scrollableHeight <= 0) return 100;
return Math.min(100, Math.round((scrollTop / scrollableHeight) * 100));
}
/**
* Get element properties for tracking
*/
function getElementProperties(element, baseProperties = {}) {
const properties = {};
// Process base properties, handling helper function references
Object.keys(baseProperties).forEach(key => {
const value = baseProperties[key];
if (typeof value === 'function') {
// Direct function (for programmatic config)
try {
const result = value(element);
if (result !== null && result !== undefined) {
properties[key] = result;
}
} catch (error) {
console.warn(`Scroll tracking: Error executing function for property "${key}":`, error);
}
} else if (typeof value === 'string' && value.startsWith('helper:')) {
// Helper function reference (for config-based setup)
const helperName = value.replace('helper:', '');
if (propertyHelpers[helperName]) {
try {
const result = propertyHelpers[helperName](element);
if (result !== null && result !== undefined) {
properties[key] = result;
}
} catch (error) {
console.warn(`Scroll tracking: Error executing helper "${helperName}" for property "${key}":`, error);
}
} else {
console.warn(`Scroll tracking: Unknown helper function "${helperName}"`);
}
} else {
properties[key] = value;
}
});
// Add common properties
properties.page_path = window.location.pathname;
properties.page_url = window.location.href;
properties.scroll_depth = getScrollDepthPercentage();
// Add element-specific properties
if (element.tagName) {
properties.tag_name = element.tagName.toLowerCase();
}
if (element.id) {
properties.element_id = element.id;
}
if (element.className) {
properties.element_class = element.className;
}
// For headings, add text content and level
if (element.tagName && element.tagName.match(/^H[1-6]$/)) {
properties.heading_level = element.tagName.toLowerCase();
properties.heading_text = element.textContent?.trim().substring(0, 200); // Limit text length to 200 chars
properties.text = element.textContent?.trim().substring(0, 200); // Add 'text' property as requested
}
return properties;
}
/**
* Set up intersection observer for element tracking
*/
function setupElementTracking(config) {
if (!window.IntersectionObserver) {
console.warn('IntersectionObserver not supported, element tracking disabled');
return;
}
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// Fire event every time element comes into view (not just first time)
if (entry.isIntersecting) {
// Find matching selector config
const selectorConfig = config.selectors.find(sc =>
entry.target.matches(sc.selector)
);
if (selectorConfig) {
// For code blocks on mobile, add a small delay to ensure DOM has updated
const isMobile = window.innerWidth <= 768;
const isCodeBlock = entry.target.matches('.ch-codeblock');
const delay = (isMobile && isCodeBlock) ? 100 : 0;
setTimeout(() => {
const properties = getElementProperties(entry.target, selectorConfig.properties || {});
if (window.analytics && typeof window.analytics.track === 'function') {
window.analytics.track(selectorConfig.eventName, properties);
}
}, delay);
}
}
});
}, {
threshold: 0.1, // Element needs to be 10% visible
rootMargin: '0px'
});
// Function to observe elements for a given selector
const observeElementsForSelector = (selectorConfig) => {
const elements = document.querySelectorAll(selectorConfig.selector);
elements.forEach(element => {
if (!element._scrollTrackingObserved) {
observer.observe(element);
element._scrollTrackingObserved = true;
}
});
};
// Observe all existing elements matching the selectors
config.selectors.forEach(observeElementsForSelector);
// Also scan after a delay for dynamically rendered content
setTimeout(() => {
config.selectors.forEach(observeElementsForSelector);
}, 1000);
// Set up mutation observer for dynamically added elements
if (window.MutationObserver) {
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if the added node or any of its children match our selectors
config.selectors.forEach(selectorConfig => {
// Check the node itself
if (node.matches && node.matches(selectorConfig.selector)) {
if (!node._scrollTrackingObserved) {
observer.observe(node);
node._scrollTrackingObserved = true;
}
}
// Check children
const childElements = node.querySelectorAll ? node.querySelectorAll(selectorConfig.selector) : [];
childElements.forEach(child => {
if (!child._scrollTrackingObserved) {
observer.observe(child);
child._scrollTrackingObserved = true;
}
});
});
}
});
});
});
mutationObserver.observe(document.body, {
childList: true,
subtree: true
});
// Store mutation observer for cleanup
observer._mutationObserver = mutationObserver;
}
return observer;
}
/**
* Initialize scroll tracking
*/
function initializeScrollTracking(userConfig = {}) {
// Only run on client side and prevent duplicate initialization
if (!ExecutionEnvironment.canUseDOM || isScrollTrackingInitialized) return;
// Merge default config with injected config and user config
const injectedConfig = window.__SCROLL_TRACKING_CONFIG__ || {};
const config = { ...defaultConfig, ...injectedConfig, ...userConfig };
// Set up element intersection tracking
const observer = setupElementTracking(config);
// Mark as initialized
isScrollTrackingInitialized = true;
// Store observer for cleanup
window._scrollTrackingObserver = observer;
}
/**
* Cleanup scroll tracking
*/
function cleanupScrollTracking() {
if (window._scrollTrackingObserver) {
// Clean up mutation observer
if (window._scrollTrackingObserver._mutationObserver) {
window._scrollTrackingObserver._mutationObserver.disconnect();
}
// Clean up intersection observer
window._scrollTrackingObserver.disconnect();
window._scrollTrackingObserver = null;
}
// Clear tracking flags from elements
document.querySelectorAll('[data-scroll-tracked]').forEach(el => {
delete el._scrollTrackingObserved;
el.removeAttribute('data-scroll-tracked');
});
isScrollTrackingInitialized = false;
}
// Initialize on DOM ready
if (ExecutionEnvironment.canUseDOM) {
// Function to ensure DOM is fully ready before initializing
const initWhenReady = () => {
// Wait a bit longer to ensure all content is rendered
setTimeout(() => {
initializeScrollTracking();
}, 250);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWhenReady);
} else if (document.readyState === 'interactive') {
// DOM is loaded but resources might still be loading
setTimeout(initWhenReady, 100);
} else {
// Document is fully loaded
initWhenReady();
}
// Re-initialize on route changes for SPA navigation
window.addEventListener('popstate', () => {
cleanupScrollTracking();
setTimeout(() => initializeScrollTracking(), 100);
});
}
// Export for route change handling
export function onRouteDidUpdate({location, previousLocation}) {
if (
ExecutionEnvironment.canUseDOM &&
previousLocation &&
location.pathname !== previousLocation.pathname
) {
cleanupScrollTracking();
setTimeout(() => initializeScrollTracking(), 100);
}
}