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:
parent
785830937f
commit
d8291131ab
3 changed files with 407 additions and 0 deletions
55
docs/src/plugins/scroll-tracking/index.js
Normal file
55
docs/src/plugins/scroll-tracking/index.js
Normal 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;
|
||||
331
docs/src/plugins/scroll-tracking/scroll-tracking.js
Normal file
331
docs/src/plugins/scroll-tracking/scroll-tracking.js
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue