index.tsx
1 // SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 2 // 3 // SPDX-License-Identifier: Apache-2.0 4 5 import React, { useState, useRef, useEffect, useMemo } from 'react'; 6 import TurndownService from 'turndown'; 7 import styles from './styles.module.css'; 8 9 // Icon imports 10 import CopyIcon from '@site/static/img/copy.svg'; 11 import ChevronDownIcon from '@site/static/img/chevron-down.svg'; 12 import MarkdownIcon from '@site/static/img/markdown.svg'; 13 import PDFIcon from '@site/static/img/pdf.svg'; 14 15 const SparkleIcon = () => ( 16 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 17 <path d="M12 2l2.09 6.26L20 10l-5.91 1.74L12 18l-2.09-6.26L4 10l5.91-1.74L12 2z" /> 18 <path d="M20 16l1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3z" /> 19 </svg> 20 ); 21 22 const ExternalLinkIcon = () => ( 23 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 24 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 25 <polyline points="15 3 21 3 21 9" /> 26 <line x1="10" y1="14" x2="21" y2="3" /> 27 </svg> 28 ); 29 30 const AI_ASSISTANTS = [ 31 { name: 'ChatGPT', buildUrl: (prompt: string) => `https://chatgpt.com/?q=${encodeURIComponent(prompt)}` }, 32 { name: 'Claude', buildUrl: (prompt: string) => `https://claude.ai/new?q=${encodeURIComponent(prompt)}` }, 33 { name: 'Perplexity', buildUrl: (prompt: string) => `https://www.perplexity.ai/search?q=${encodeURIComponent(prompt)}` }, 34 ]; 35 36 // Create and configure Turndown service 37 function createTurndownService(): TurndownService { 38 const turndownService = new TurndownService({ 39 headingStyle: 'atx', 40 codeBlockStyle: 'fenced', 41 bulletListMarker: '-', 42 emDelimiter: '*', 43 strongDelimiter: '**', 44 }); 45 46 // Custom rule for code blocks with language detection 47 turndownService.addRule('fencedCodeBlock', { 48 filter: (node) => { 49 return ( 50 node.nodeName === 'PRE' && 51 node.firstChild !== null && 52 node.firstChild.nodeName === 'CODE' 53 ); 54 }, 55 replacement: (_content, node) => { 56 const codeElement = node.firstChild as HTMLElement; 57 const className = codeElement.className || ''; 58 const language = className.match(/language-(\S+)/)?.[1] || ''; 59 const code = codeElement.textContent || ''; 60 return `\n\`\`\`${language}\n${code.trim()}\n\`\`\`\n\n`; 61 }, 62 }); 63 64 // Custom rule for inline code 65 turndownService.addRule('inlineCode', { 66 filter: (node) => { 67 return ( 68 node.nodeName === 'CODE' && 69 node.parentNode !== null && 70 node.parentNode.nodeName !== 'PRE' 71 ); 72 }, 73 replacement: (content) => { 74 return `\`${content}\``; 75 }, 76 }); 77 78 // Custom rule for Docusaurus admonitions 79 turndownService.addRule('admonition', { 80 filter: (node) => { 81 return ( 82 node.nodeName === 'DIV' && 83 (node as HTMLElement).classList.contains('theme-admonition') 84 ); 85 }, 86 replacement: (content, node) => { 87 const element = node as HTMLElement; 88 const type = element.classList.contains('alert--warning') 89 ? 'warning' 90 : element.classList.contains('alert--danger') 91 ? 'danger' 92 : element.classList.contains('alert--info') 93 ? 'info' 94 : element.classList.contains('alert--success') 95 ? 'tip' 96 : 'note'; 97 return `\n> **${type.toUpperCase()}:** ${content.trim()}\n\n`; 98 }, 99 }); 100 101 // Remove unwanted elements using a filter function 102 turndownService.remove((node) => { 103 if (node.nodeType !== Node.ELEMENT_NODE) return false; 104 const element = node as HTMLElement; 105 const tagName = element.tagName.toLowerCase(); 106 107 // Remove by tag name 108 if (['script', 'style', 'nav'].includes(tagName)) return true; 109 110 // Remove by class name 111 const classesToRemove = [ 112 'theme-doc-footer', 113 'copy-dropdown-container', 114 'table-of-contents', 115 'pagination-nav', 116 'theme-doc-breadcrumbs', 117 'theme-doc-version-badge', 118 'hash-link', 119 ]; 120 121 return classesToRemove.some((cls) => element.classList.contains(cls)); 122 }); 123 124 return turndownService; 125 } 126 127 interface CopyDropdownProps { 128 className?: string; 129 } 130 131 export default function CopyDropdown({ className }: CopyDropdownProps) { 132 const [isOpen, setIsOpen] = useState(false); 133 const [copySuccess, setCopySuccess] = useState(false); 134 const [docTitle, setDocTitle] = useState(''); 135 const dropdownRef = useRef<HTMLDivElement>(null); 136 137 // Memoize the Turndown service instance 138 const turndownService = useMemo(() => createTurndownService(), []); 139 140 // Set the document title from the page 141 useEffect(() => { 142 if (typeof document !== 'undefined') { 143 // Get title from h1 or document title 144 const h1 = document.querySelector('article h1, .markdown h1'); 145 const title = h1?.textContent || document.title.split(' | ')[0] || document.title; 146 setDocTitle(title); 147 } 148 }, []); 149 150 // Close dropdown when clicking outside 151 useEffect(() => { 152 const handleClickOutside = (event: MouseEvent) => { 153 if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { 154 setIsOpen(false); 155 } 156 }; 157 158 document.addEventListener('mousedown', handleClickOutside); 159 return () => document.removeEventListener('mousedown', handleClickOutside); 160 }, []); 161 162 // Get the page content as markdown using Turndown 163 const getPageMarkdown = (): string => { 164 const article = document.querySelector('article'); 165 if (!article) return ''; 166 167 // Clone the article to manipulate without affecting the page 168 const clone = article.cloneNode(true) as HTMLElement; 169 170 // Remove elements we don't want before conversion 171 clone 172 .querySelectorAll( 173 '.theme-doc-footer, .copy-dropdown-container, script, style, .hash-link, .table-of-contents' 174 ) 175 .forEach((el) => el.remove()); 176 177 // Convert HTML to Markdown using Turndown 178 const content = turndownService.turndown(clone); 179 180 // Build the final markdown with header 181 let markdown = `# ${docTitle}\n\n`; 182 markdown += `URL: ${window.location.href}\n\n`; 183 markdown += '---\n\n'; 184 markdown += content; 185 186 return markdown.trim(); 187 }; 188 189 const handleCopyPage = async () => { 190 const markdown = getPageMarkdown(); 191 try { 192 await navigator.clipboard.writeText(markdown); 193 setCopySuccess(true); 194 setTimeout(() => setCopySuccess(false), 2000); 195 } catch (err) { 196 console.error('Failed to copy:', err); 197 } 198 setIsOpen(false); 199 }; 200 201 const handleViewMarkdown = () => { 202 const markdown = getPageMarkdown(); 203 const blob = new Blob([markdown], { type: 'text/markdown' }); 204 const url = URL.createObjectURL(blob); 205 window.open(url, '_blank'); 206 setIsOpen(false); 207 }; 208 209 const handleExportPDF = () => { 210 window.print(); 211 setIsOpen(false); 212 }; 213 214 const handleAskAI = (buildUrl: (prompt: string) => string) => { 215 const prompt = `Read this page ${window.location.href} and answer my questions about it.`; 216 window.open(buildUrl(prompt), '_blank', 'noopener,noreferrer'); 217 setIsOpen(false); 218 }; 219 220 return ( 221 <div className={`${styles.copyDropdownContainer} ${className || ''}`} ref={dropdownRef}> 222 <button 223 className={`${styles.copyButton} ${copySuccess ? styles.success : ''}`} 224 onClick={() => setIsOpen(!isOpen)} 225 aria-expanded={isOpen} 226 aria-haspopup="true" 227 > 228 <CopyIcon /> 229 <span>{copySuccess ? 'Copied!' : 'Copy'}</span> 230 <ChevronDownIcon /> 231 </button> 232 233 {isOpen && ( 234 <div className={styles.dropdownMenu}> 235 <button className={styles.menuItem} onClick={handleCopyPage}> 236 <CopyIcon /> 237 <div className={styles.menuItemContent}> 238 <span className={styles.menuItemTitle}>Copy page</span> 239 <span className={styles.menuItemDescription}>Copy page as Markdown for LLMs</span> 240 </div> 241 </button> 242 243 <button className={styles.menuItem} onClick={handleViewMarkdown}> 244 <MarkdownIcon /> 245 <div className={styles.menuItemContent}> 246 <span className={styles.menuItemTitle}>View as Markdown</span> 247 <span className={styles.menuItemDescription}>View this page as plain text</span> 248 </div> 249 </button> 250 251 <div className={styles.menuDivider} /> 252 253 <button className={styles.menuItem} onClick={handleExportPDF}> 254 <PDFIcon /> 255 <div className={styles.menuItemContent}> 256 <span className={styles.menuItemTitle}>Export as PDF</span> 257 <span className={styles.menuItemDescription}>Save this page as a PDF file</span> 258 </div> 259 </button> 260 261 <div className={styles.menuDivider} /> 262 263 <div className={styles.menuSectionHeader}> 264 <SparkleIcon /> 265 <span>Ask AI about this page</span> 266 </div> 267 268 {AI_ASSISTANTS.map(({ name, buildUrl }) => ( 269 <button key={name} className={styles.menuItem} onClick={() => handleAskAI(buildUrl)}> 270 <SparkleIcon /> 271 <div className={styles.menuItemContent}> 272 <span className={styles.menuItemTitle}> 273 {name} 274 <ExternalLinkIcon /> 275 </span> 276 <span className={styles.menuItemDescription}>Open this page in {name}</span> 277 </div> 278 </button> 279 ))} 280 </div> 281 )} 282 </div> 283 ); 284 }