/** * Format Selector Component * * Provides a dropdown menu for users and AI agents to access documentation * in different formats (Markdown for LLMs, ChatGPT/Claude integration, MCP servers). * * FEATURES: * - Copy page/section as Markdown to clipboard * - Open page in ChatGPT or Claude with context * - Connect to MCP servers (Cursor, VS Code) - future enhancement * - Adaptive UI for leaf nodes (single pages) vs branch nodes (sections) * - Smart section download for large sections (>10 pages) * * UI PATTERN: * Matches Mintlify's format selector with dark dropdown, icons, and sublabels. * See `.context/Screenshot 2025-11-13 at 11.39.13 AM.png` for reference. */ interface FormatSelectorConfig { pageType: 'leaf' | 'branch'; // Leaf = single page, Branch = section with children markdownUrl: string; sectionMarkdownUrl?: string; // For branch nodes - aggregated content markdownContent?: string; // For clipboard copy (lazy-loaded) pageTitle: string; pageUrl: string; // For branch nodes (sections) childPageCount?: number; estimatedTokens?: number; sectionDownloadUrl?: string; // AI integration URLs chatGptUrl: string; claudeUrl: string; // Future MCP server links mcpCursorUrl?: string; mcpVSCodeUrl?: string; } interface FormatSelectorOption { label: string; sublabel: string; icon: string; // SVG icon name or class action: () => void; href?: string; // For external links target?: string; // '_blank' for external links external: boolean; // Shows ↗ arrow visible: boolean; // Conditional display based on pageType/size dataAttribute: string; // For testing (e.g., 'copy-page', 'open-chatgpt') } interface ComponentOptions { component: HTMLElement; } /** * Initialize format selector component * @param {ComponentOptions} options - Component configuration */ export default function FormatSelector(options: ComponentOptions) { const { component } = options; // State let isOpen = false; let config: FormatSelectorConfig = { pageType: 'leaf', markdownUrl: '', pageTitle: '', pageUrl: '', chatGptUrl: '', claudeUrl: '', }; // DOM elements const button = component.querySelector('button') as HTMLButtonElement; const dropdownMenu = component.querySelector( '[data-dropdown-menu]' ) as HTMLElement; if (!button || !dropdownMenu) { console.error('Format selector: Missing required elements'); return; } /** * Initialize component config from page context and data attributes */ function initConfig(): void { // page-context exports individual properties, not a detect() function const currentUrl = window.location.href; const currentPath = window.location.pathname; // Determine page type (leaf vs branch) const childCount = parseInt(component.dataset.childCount || '0', 10); const pageType: 'leaf' | 'branch' = childCount > 0 ? 'branch' : 'leaf'; // Construct markdown URL // Hugo generates markdown files as index.md in directories matching the URL path let markdownUrl = currentPath; if (!markdownUrl.endsWith('.md')) { // Ensure path ends with / if (!markdownUrl.endsWith('/')) { markdownUrl += '/'; } // Append index.md markdownUrl += 'index.md'; } // Construct section markdown URL (for branch pages only) let sectionMarkdownUrl: string | undefined; if (pageType === 'branch') { sectionMarkdownUrl = markdownUrl.replace('index.md', 'index.section.md'); } // Get page title from meta or h1 const pageTitle = document .querySelector('meta[property="og:title"]') ?.getAttribute('content') || document.querySelector('h1')?.textContent || document.title; config = { pageType, markdownUrl, sectionMarkdownUrl, pageTitle, pageUrl: currentUrl, childPageCount: childCount, estimatedTokens: parseInt(component.dataset.estimatedTokens || '0', 10), sectionDownloadUrl: component.dataset.sectionDownloadUrl, // AI integration URLs chatGptUrl: generateChatGPTUrl(pageTitle, currentUrl, markdownUrl), claudeUrl: generateClaudeUrl(pageTitle, currentUrl, markdownUrl), // Future MCP server links mcpCursorUrl: component.dataset.mcpCursorUrl, mcpVSCodeUrl: component.dataset.mcpVSCodeUrl, }; // Update button label based on page type updateButtonLabel(); } /** * Update button label: "Copy page for AI" vs "Copy section for AI" */ function updateButtonLabel(): void { const label = config.pageType === 'leaf' ? 'Copy page for AI' : 'Copy section for AI'; const buttonText = button.querySelector('[data-button-text]'); if (buttonText) { buttonText.textContent = label; } } /** * Generate ChatGPT share URL with page context */ function generateChatGPTUrl( title: string, pageUrl: string, markdownUrl: string ): string { // ChatGPT share URL pattern (as of 2025) // This may need updating based on ChatGPT's URL scheme const baseUrl = 'https://chatgpt.com'; const markdownFullUrl = `${window.location.origin}${markdownUrl}`; const prompt = `Read from ${markdownFullUrl} so I can ask questions about it.`; return `${baseUrl}/?q=${encodeURIComponent(prompt)}`; } /** * Generate Claude share URL with page context */ function generateClaudeUrl( title: string, pageUrl: string, markdownUrl: string ): string { // Claude.ai share URL pattern (as of 2025) const baseUrl = 'https://claude.ai/new'; const markdownFullUrl = `${window.location.origin}${markdownUrl}`; const prompt = `Read from ${markdownFullUrl} so I can ask questions about it.`; return `${baseUrl}?q=${encodeURIComponent(prompt)}`; } /** * Fetch markdown content for clipboard copy */ async function fetchMarkdownContent(): Promise { try { const response = await fetch(config.markdownUrl); if (!response.ok) { throw new Error(`Failed to fetch Markdown: ${response.statusText}`); } return await response.text(); } catch (error) { console.error('Error fetching Markdown content:', error); throw error; } } /** * Copy content to clipboard */ async function copyToClipboard(text: string): Promise { try { await navigator.clipboard.writeText(text); showNotification('Copied to clipboard!', 'success'); } catch (error) { console.error('Failed to copy to clipboard:', error); showNotification('Failed to copy to clipboard', 'error'); } } /** * Show notification (integrates with existing notifications module) */ function showNotification(message: string, type: 'success' | 'error'): void { // TODO: Integrate with existing notifications module // For now, use a simple console log console.log(`[${type.toUpperCase()}] ${message}`); // Optionally add a simple visual notification const notification = document.createElement('div'); notification.textContent = message; notification.style.cssText = ` position: fixed; bottom: 20px; right: 20px; padding: 12px 20px; background: ${type === 'success' ? '#10b981' : '#ef4444'}; color: white; border-radius: 6px; z-index: 10000; font-size: 14px; `; document.body.appendChild(notification); setTimeout(() => notification.remove(), 3000); } /** * Handle copy page action */ async function handleCopyPage(): Promise { try { const markdown = await fetchMarkdownContent(); await copyToClipboard(markdown); closeDropdown(); } catch (error) { console.error('Failed to copy page:', error); } } /** * Handle copy section action (aggregates child pages) */ async function handleCopySection(): Promise { try { // Fetch aggregated section markdown (includes all child pages) const url = config.sectionMarkdownUrl || config.markdownUrl; const response = await fetch(url); if (!response.ok) { throw new Error( `Failed to fetch section markdown: ${response.statusText}` ); } const markdown = await response.text(); await copyToClipboard(markdown); showNotification('Section copied to clipboard', 'success'); closeDropdown(); } catch (error) { console.error('Failed to copy section:', error); showNotification('Failed to copy section', 'error'); } } /** * Handle download page action (for single pages) * Commented out - not needed right now */ /* function handleDownloadPage(): void { // Trigger download of current page as markdown window.open(config.markdownUrl, '_self'); closeDropdown(); } */ /** * Handle download section action * Commented out - not yet implemented */ /* function handleDownloadSection(): void { if (config.sectionDownloadUrl) { window.open(config.sectionDownloadUrl, '_self'); closeDropdown(); } } */ /** * Handle external link action */ function handleExternalLink(url: string): void { window.open(url, '_blank', 'noopener,noreferrer'); closeDropdown(); } /** * Build dropdown options based on config */ function buildOptions(): FormatSelectorOption[] { const options: FormatSelectorOption[] = []; // Option 1: Copy page/section if (config.pageType === 'leaf') { options.push({ label: 'Copy page for AI', sublabel: 'Clean Markdown optimized for AI assistants', icon: 'document', action: handleCopyPage, external: false, visible: true, dataAttribute: 'copy-page', }); } else { options.push({ label: 'Copy section for AI', sublabel: `${config.childPageCount} pages combined as clean Markdown for AI assistants`, icon: 'document', action: handleCopySection, external: false, visible: true, dataAttribute: 'copy-section', }); } // Option 1b: Download page (for leaf nodes) // Removed - not needed right now /* if (config.pageType === 'leaf' && config.markdownUrl) { options.push({ label: 'Download page', sublabel: 'Download page as Markdown file', icon: 'download', action: handleDownloadPage, external: false, visible: true, dataAttribute: 'download-page', }); } */ // Option 2: Open in ChatGPT options.push({ label: 'Open in ChatGPT', sublabel: 'Ask questions about this page', icon: 'chatgpt', action: () => handleExternalLink(config.chatGptUrl), href: config.chatGptUrl, target: '_blank', external: true, visible: true, dataAttribute: 'open-chatgpt', }); // Option 3: Open in Claude options.push({ label: 'Open in Claude', sublabel: 'Ask questions about this page', icon: 'claude', action: () => handleExternalLink(config.claudeUrl), href: config.claudeUrl, target: '_blank', external: true, visible: true, dataAttribute: 'open-claude', }); // Future: Download section option // Commented out - not yet implemented /* if (config.pageType === 'branch') { const shouldShowDownload = (config.childPageCount && config.childPageCount > 10) || (config.estimatedTokens && config.estimatedTokens >= 50000); if (shouldShowDownload && config.sectionDownloadUrl) { options.push({ label: 'Download section', sublabel: `Download all ${config.childPageCount} pages (.zip with /md and /txt folders)`, icon: 'download', action: handleDownloadSection, external: false, visible: true, dataAttribute: 'download-section', }); } } */ // Future: MCP server options // Commented out for now - will be implemented as future enhancement /* if (config.mcpCursorUrl) { options.push({ label: 'Connect to Cursor', sublabel: 'Install MCP Server on Cursor', icon: 'cursor', action: () => handleExternalLink(config.mcpCursorUrl!), href: config.mcpCursorUrl, target: '_blank', external: true, visible: true, dataAttribute: 'connect-cursor', }); } if (config.mcpVSCodeUrl) { options.push({ label: 'Connect to VS Code', sublabel: 'Install MCP Server on VS Code', icon: 'vscode', action: () => handleExternalLink(config.mcpVSCodeUrl!), href: config.mcpVSCodeUrl, target: '_blank', external: true, visible: true, dataAttribute: 'connect-vscode', }); } */ return options.filter((opt) => opt.visible); } /** * Get SVG icon for option */ function getIconSVG(iconName: string): string { const icons: Record = { document: ` `, chatgpt: ` `, claude: ` `, download: ` `, cursor: ` `, vscode: ` `, }; return icons[iconName] || icons.document; } /** * Render dropdown options */ function renderOptions(): void { const options = buildOptions(); dropdownMenu.innerHTML = ''; options.forEach((option) => { const optionEl = document.createElement(option.href ? 'a' : 'button'); optionEl.classList.add('format-selector__option'); optionEl.setAttribute('data-option', option.dataAttribute); if (option.href) { (optionEl as HTMLAnchorElement).href = option.href; if (option.target) { (optionEl as HTMLAnchorElement).target = option.target; (optionEl as HTMLAnchorElement).rel = 'noopener noreferrer'; } } optionEl.innerHTML = ` ${getIconSVG(option.icon)} ${option.label} ${option.external ? '' : ''} ${option.sublabel} `; optionEl.addEventListener('click', (e) => { if (!option.href) { e.preventDefault(); option.action(); } }); dropdownMenu.appendChild(optionEl); }); } /** * Position dropdown relative to button using fixed positioning * Ensures dropdown stays within viewport bounds */ function positionDropdown(): void { const buttonRect = button.getBoundingClientRect(); const dropdownWidth = dropdownMenu.offsetWidth; const viewportWidth = window.innerWidth; const padding = 8; // Minimum padding from viewport edge // Always position dropdown below button with 8px gap dropdownMenu.style.top = `${buttonRect.bottom + 8}px`; // Calculate ideal left position (right-aligned with button) let leftPos = buttonRect.right - dropdownWidth; // Ensure dropdown doesn't go off the left edge if (leftPos < padding) { leftPos = padding; } // Ensure dropdown doesn't go off the right edge if (leftPos + dropdownWidth > viewportWidth - padding) { leftPos = viewportWidth - dropdownWidth - padding; } dropdownMenu.style.left = `${leftPos}px`; } /** * Handle resize events to reposition dropdown */ function handleResize(): void { if (isOpen) { positionDropdown(); } } /** * Open dropdown */ function openDropdown(): void { isOpen = true; dropdownMenu.classList.add('is-open'); button.setAttribute('aria-expanded', 'true'); // Position dropdown relative to button positionDropdown(); // Add listeners for repositioning and closing setTimeout(() => { document.addEventListener('click', handleClickOutside); }, 0); window.addEventListener('resize', handleResize); window.addEventListener('scroll', handleResize, true); // Capture scroll on any element } /** * Close dropdown */ function closeDropdown(): void { isOpen = false; dropdownMenu.classList.remove('is-open'); button.setAttribute('aria-expanded', 'false'); document.removeEventListener('click', handleClickOutside); window.removeEventListener('resize', handleResize); window.removeEventListener('scroll', handleResize, true); } /** * Toggle dropdown */ function toggleDropdown(): void { if (isOpen) { closeDropdown(); } else { openDropdown(); } } /** * Handle click outside dropdown */ function handleClickOutside(event: Event): void { if (!component.contains(event.target as Node)) { closeDropdown(); } } /** * Handle button click */ function handleButtonClick(event: Event): void { event.preventDefault(); event.stopPropagation(); toggleDropdown(); } /** * Handle escape key */ function handleKeyDown(event: KeyboardEvent): void { if (event.key === 'Escape' && isOpen) { closeDropdown(); button.focus(); } } /** * Initialize component */ function init(): void { // Initialize config initConfig(); // Render options renderOptions(); // Add event listeners button.addEventListener('click', handleButtonClick); document.addEventListener('keydown', handleKeyDown); // Set initial ARIA attributes button.setAttribute('aria-expanded', 'false'); button.setAttribute('aria-haspopup', 'true'); dropdownMenu.setAttribute('role', 'menu'); } // Initialize on load init(); // Expose for debugging return { get config() { return config; }, openDropdown, closeDropdown, renderOptions, }; }