Skip to content

Add LLM Integration to Documentation with Dropdown Menu #1232

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "./scripts/import.sh && docusaurus build",
"build": "./scripts/import.sh && docusaurus build && node scripts/generate-llm-files.js",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
Expand Down Expand Up @@ -60,7 +60,8 @@
"tailwindcss": "^3.3.5",
"unist-util-visit": "^5.0.0",
"webpack-merge": "5.8.0",
"zod": "^3.24.1"
"zod": "^3.24.1",
"glob": "^10.3.10"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.6.3",
Expand Down
86 changes: 86 additions & 0 deletions scripts/generate-llm-files.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/* eslint-disable */
const fs = require('fs');
const path = require('path');
const glob = require('glob');

// Ensure the static directory exists
const staticDir = path.join(__dirname, '..', 'static');
if (!fs.existsSync(staticDir)) {
fs.mkdirSync(staticDir, { recursive: true });
}

// Create a directory for markdown files
const markdownDir = path.join(staticDir, 'markdown');
if (!fs.existsSync(markdownDir)) {
fs.mkdirSync(markdownDir, { recursive: true });
}

// Find all markdown files
const files = glob.sync('./docs/**/*.md?(x)');
const llms = [];
const fullContent = [];

files.forEach((file) => {
const content = fs.readFileSync(file, 'utf-8');
const relativePath = path.relative('./docs', file);
const fileDir = path.dirname(file);

// Add to llms.txt (just the paths)
llms.push(relativePath);

// Add to llms-full.txt (full content with separators)
fullContent.push(`\n=== ${relativePath} ===\n${content}`);

// Create a markdown file for each page
const markdownFileName = relativePath.replace(/\.mdx?$/, '.md');
const markdownFilePath = path.join(markdownDir, markdownFileName);

// Ensure the directory exists
const markdownFileDir = path.dirname(markdownFilePath);
if (!fs.existsSync(markdownFileDir)) {
fs.mkdirSync(markdownFileDir, { recursive: true });
}

// Extract import statements to create a map of image variables to file paths
const importMap = {};
const importRegex = /import\s+(\w+)\s+from\s+['"](.+?)['"];?/g;
let importMatch;
while ((importMatch = importRegex.exec(content)) !== null) {
const [_, varName, filePath] = importMatch;
// Handle relative paths
const fullPath =
filePath.startsWith('./') || filePath.startsWith('../')
? path.relative(fileDir, path.resolve(fileDir, filePath))
: filePath;
importMap[varName] = fullPath;
}

// Process the content to handle common React/MDX components
let processedContent = content
// Convert import statements to comments
.replace(
/import\s+(.*?)\s+from\s+['"](.+?)['"];?/g,
'<!-- Import: $1 from "$2" -->',
)
// Handle image components with src
.replace(
/<img\s+src=\{(.+?)\}\s+alt="(.+?)"\s+style=\{\{(.+?)\}\}\s*\/>/g,
'![$2]($1)',
)
// Replace image variables with their file paths
.replace(/!\[(.*?)\]\((\w+)\)/g, (match, alt, varName) => {
return importMap[varName] ? `![${alt}](${importMap[varName]})` : match;
})
// Clean up multiple newlines
.replace(/\n{3,}/g, '\n\n')
.trim();

// Write the processed markdown file - directly as Markdown, not wrapped in HTML
fs.writeFileSync(markdownFilePath, processedContent);
});

// Write the files
fs.writeFileSync(path.join(staticDir, 'llms.txt'), llms.join('\n'));
fs.writeFileSync(path.join(staticDir, 'llms-full.txt'), fullContent.join('\n'));

console.log('✅ Generated llms.txt, llms-full.txt, and markdown files');
91 changes: 91 additions & 0 deletions src/components/PageActionsDropdown.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
.menuContainer {
position: relative;
display: inline-block;
}

.menuButton {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--ifm-color-primary);
background-color: transparent;
border: 1px solid var(--ifm-color-primary);
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
}

.menuButton:hover {
background-color: var(--ifm-color-primary);
color: white;
}

.chevronIcon {
width: 0.75rem;
height: 0.75rem;
transition: transform 0.2s ease;
}

.menuButton[aria-expanded='true'] .chevronIcon {
transform: rotate(180deg);
}

.menuItems {
position: absolute;
right: 0;
z-index: 50;
margin-top: 0.5rem;
width: 16rem;
background-color: var(--ifm-background-color);
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 1px solid var(--ifm-color-emphasis-200);
}

.menuItemsContainer {
padding: 0.5rem;
}

.menuItem {
display: flex;
align-items: flex-start;
gap: 0.75rem;
width: 100%;
padding: 0.75rem;
text-align: left;
border-radius: 0.375rem;
color: var(--ifm-color-emphasis-800);
background-color: transparent;
border: none;
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
}

.menuItem:hover,
.menuItemActive {
background-color: var(--ifm-color-emphasis-100);
}

.menuIcon {
width: 1.25rem;
height: 1.25rem;
margin-top: 0.125rem;
color: var(--ifm-color-emphasis-600);
}

.menuItemTitle {
font-size: 0.875rem;
font-weight: 500;
color: var(--ifm-color-emphasis-900);
}

.menuItemDescription {
font-size: 0.75rem;
color: var(--ifm-color-emphasis-600);
margin-top: 0.125rem;
}
200 changes: 200 additions & 0 deletions src/components/PageActionsDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCopy,
faCode,
faRobot,
faBrain,
faChevronDown,
} from '@fortawesome/free-solid-svg-icons';
import styles from './PageActionsDropdown.module.css';
import BrowserOnly from '@docusaurus/BrowserOnly';

function PageActionsDropdownContent(): JSX.Element {
const [isOpen, setIsOpen] = useState(false);

// Get the current path and create the markdown URL
const currentPath = window.location.pathname;
// Remove any trailing slash to match our directory structure
const cleanPath = currentPath.endsWith('/')
? currentPath.slice(0, -1)
: currentPath;
// If we're at the root docs page (/docs), use /index.md
const mdPath =
cleanPath === '/docs' ? '/index' : cleanPath.replace('/docs', '');
const markdownUrl = `/markdown${mdPath}.md`;

// Create the ChatGPT and Claude URLs with the markdown URL
const fullUrl = `${window.location.origin}${markdownUrl}`;
const chatGptUrl = `https://chat.openai.com/?hints=search&q=Read%20from%20${encodeURIComponent(
fullUrl,
)}%20so%20I%20can%20ask%20questions%20about%20it.`;
const claudeUrl = `https://claude.ai/new?q=Read%20from%20${encodeURIComponent(
fullUrl,
)}%20so%20I%20can%20ask%20questions%20about%20it.`;

const handleCopyMarkdown = async (): Promise<void> => {
try {
// Fetch the actual Markdown file
const response = await fetch(markdownUrl);
if (!response.ok) {
throw new Error(`Failed to fetch Markdown: ${response.status}`);
}

const markdown = await response.text();
await navigator.clipboard.writeText(markdown);

setIsOpen(false); // Close the dropdown after copying
} catch (err) {
console.error('Failed to copy markdown:', err);

// Fallback to copying the article text if fetch fails
try {
const pageContent = document.querySelector('article')?.innerText ?? '';
await navigator.clipboard.writeText(pageContent);
setIsOpen(false);
} catch (fallbackErr) {
console.error('Fallback copy failed:', fallbackErr);
}
}
};

// Reference to track button position for the portal
const [buttonPosition, setButtonPosition] = React.useState({
top: 0,
right: 0,
});
const buttonRef = React.useRef<HTMLButtonElement>(null);

// Update position when the button is clicked
const updatePosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setButtonPosition({
top: rect.bottom + window.scrollY,
right: window.innerWidth - rect.right,
});
}
};

// Toggle the dropdown
const toggleDropdown = () => {
updatePosition();
setIsOpen(!isOpen);
};

// Handle clicks outside to close the dropdown
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
buttonRef.current &&
!buttonRef.current.contains(event.target as Node) &&
!(event.target as Element).closest(`.${styles.menuItems}`)
) {
setIsOpen(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);

return (
<div className={styles.menuContainer}>
<div style={{ position: 'relative' }}>
<button
ref={buttonRef}
onClick={toggleDropdown}
className={styles.menuButton}
>
<span>Copy page</span>
<FontAwesomeIcon
icon={faChevronDown}
className={styles.chevronIcon}
/>
</button>
</div>

{isOpen &&
createPortal(
<div
className={styles.menuItems}
style={{
position: 'absolute',
top: `${buttonPosition.top}px`,
right: `${buttonPosition.right}px`,
zIndex: 9999, // Very high z-index
}}
>
<div className={styles.menuItemsContainer}>
<button onClick={handleCopyMarkdown} className={styles.menuItem}>
<FontAwesomeIcon icon={faCopy} className={styles.menuIcon} />
<div>
<div className={styles.menuItemTitle}>Copy page</div>
<div className={styles.menuItemDescription}>
Copy page as Markdown for LLMs
</div>
</div>
</button>

<a
href={markdownUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.menuItem}
onClick={() => setIsOpen(false)}
>
<FontAwesomeIcon icon={faCode} className={styles.menuIcon} />
<div>
<div className={styles.menuItemTitle}>View as Markdown</div>
<div className={styles.menuItemDescription}>
View this page as plain text
</div>
</div>
</a>

<a
href={chatGptUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.menuItem}
onClick={() => setIsOpen(false)}
>
<FontAwesomeIcon icon={faRobot} className={styles.menuIcon} />
<div>
<div className={styles.menuItemTitle}>Open in ChatGPT</div>
<div className={styles.menuItemDescription}>
Ask questions about this page
</div>
</div>
</a>

<a
href={claudeUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.menuItem}
onClick={() => setIsOpen(false)}
>
<FontAwesomeIcon icon={faBrain} className={styles.menuIcon} />
<div>
<div className={styles.menuItemTitle}>Open in Claude</div>
<div className={styles.menuItemDescription}>
Ask questions about this page
</div>
</div>
</a>
</div>
</div>,
document.body,
)}
</div>
);
}

export default function PageActionsDropdown() {
return <BrowserOnly>{() => <PageActionsDropdownContent />}</BrowserOnly>;
}
Loading