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  }