diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 22339eeb3..0e6d38484 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -131,6 +131,27 @@ const config = { ["docusaurus-node-polyfills", { excludeAliases: ["console"] }], "docusaurus-plugin-image-zoom", ["./src/plugins/segment", { segmentPublicWriteKey: process.env.SEGMENT_PUBLIC_WRITE_KEY, allowedInDev: true }], + ["./src/plugins/scroll-tracking", { + segmentPublicWriteKey: process.env.SEGMENT_PUBLIC_WRITE_KEY, + allowedInDev: true, + selectors: [ + { + selector: 'h1, h2, h3, h4, h5, h6', + eventName: 'Docs.langflow.org - Heading Viewed', + properties: { + element_type: 'heading' + } + }, + { + selector: '.ch-codeblock', + eventName: 'Docs.langflow.org - Codeblock Viewed', + properties: { + element_type: 'code', + language: 'helper:codeLanguage' + } + } + ] + }], [ "@docusaurus/plugin-client-redirects", { diff --git a/docs/src/plugins/scroll-tracking/index.js b/docs/src/plugins/scroll-tracking/index.js new file mode 100644 index 000000000..66ad81c83 --- /dev/null +++ b/docs/src/plugins/scroll-tracking/index.js @@ -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; \ No newline at end of file diff --git a/docs/src/plugins/scroll-tracking/scroll-tracking.js b/docs/src/plugins/scroll-tracking/scroll-tracking.js new file mode 100644 index 000000000..dd712d8c3 --- /dev/null +++ b/docs/src/plugins/scroll-tracking/scroll-tracking.js @@ -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); + } +} \ No newline at end of file