diff --git a/.gitignore b/.gitignore index cc4f76f7..1a88dcad 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ package-lock.json build .idea -scripts/image_analysis/ \ No newline at end of file +scripts/image_analysis/ + +.claude/settings.local.json diff --git a/docusaurus.config.js b/docusaurus.config.js index 8c3fa475..f76ffb20 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -45,18 +45,20 @@ const config = { locales: ["en"], }, - future: { - experimental_faster: { - lightningCssMinimizer: true, - mdxCrossCompilerCache: true, - swcJsLoader: true, - swcJsMinimizer: true, - swcHtmlMinimizer: true, - rspackBundler: true, - rspackPersistentCache: true, - ssgWorkerThreads: false, // redocusaurus doesn't support this yet, so we'll disable it for now + customFields: { + future: { + experimental_faster: { + lightningCssMinimizer: true, + mdxCrossCompilerCache: true, + swcJsLoader: true, + swcJsMinimizer: true, + swcHtmlMinimizer: true, + rspackBundler: true, + rspackPersistentCache: true, + ssgWorkerThreads: false, // redocusaurus doesn't support this yet, so we'll disable it for now + }, + v4: true, }, - v4: true, }, presets: [ diff --git a/src/components/CopyForLLMButton/index.tsx b/src/components/CopyForLLMButton/index.tsx new file mode 100644 index 00000000..a54bd0a2 --- /dev/null +++ b/src/components/CopyForLLMButton/index.tsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import { usePageCopyContent } from '@site/src/utils/copyPageContent'; +import { CatIcon } from '@site/src/theme/CatIcon/CatIcon'; +import styles from './styles.module.css'; + +export default function CopyForLLMButton() { + const { copyToClipboard } = usePageCopyContent(); + const [copyFeedback, setCopyFeedback] = useState(''); + + const handleCopyPage = async () => { + const success = await copyToClipboard(); + if (success) { + setCopyFeedback('Copied!'); + setTimeout(() => setCopyFeedback(''), 2000); + } else { + setCopyFeedback('Failed to copy'); + setTimeout(() => setCopyFeedback(''), 2000); + } + }; + + return ( +
+
+ + {copyFeedback && ( + + {copyFeedback} + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/CopyForLLMButton/styles.module.css b/src/components/CopyForLLMButton/styles.module.css new file mode 100644 index 00000000..a0b43ba8 --- /dev/null +++ b/src/components/CopyForLLMButton/styles.module.css @@ -0,0 +1,54 @@ +.container { + margin-bottom: 1.5rem; +} + +.buttonWrapper { + position: relative; + display: inline-block; +} + +.copyButton { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border: 1px solid var(--ifm-color-emphasis-300); + background: var(--ifm-background-color); + cursor: pointer; + border-radius: 0.375rem; + transition: all var(--ifm-transition-fast); + font-size: 0.875rem; + color: var(--ifm-color-emphasis-700); + user-select: none; +} + +.copyButton svg { + width: 1rem; + height: 1rem; + fill: var(--ifm-color-emphasis-600); +} + +.copyButton:hover { + background-color: var(--ifm-color-emphasis-100); + border-color: var(--ifm-color-emphasis-400); + color: var(--ifm-color-emphasis-800); +} + +.copyButton:hover svg { + fill: var(--ifm-color-emphasis-800); +} + +.copyFeedback { + position: absolute; + left: calc(100% + 0.5rem); + top: 50%; + transform: translateY(-50%); + padding: 0.25rem 0.5rem; + background-color: var(--ifm-color-emphasis-800); + color: var(--ifm-color-emphasis-100); + border-radius: 0.25rem; + font-size: 0.75rem; + white-space: nowrap; + pointer-events: none; + z-index: 10; +} \ No newline at end of file diff --git a/src/theme/DocItem/Content/index.tsx b/src/theme/DocItem/Content/index.tsx new file mode 100644 index 00000000..c73679a1 --- /dev/null +++ b/src/theme/DocItem/Content/index.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import Content from '@theme-original/DocItem/Content'; +import type ContentType from '@theme/DocItem/Content'; +import type { WrapperProps } from '@docusaurus/types'; +import CopyForLLMButton from '@site/src/components/CopyForLLMButton'; + +type Props = WrapperProps; + +export default function ContentWrapper(props: Props): JSX.Element { + return ( +
+
+ +
+ +
+ ); +} \ No newline at end of file diff --git a/src/theme/Heading/styles.module.css b/src/theme/Heading/styles.module.css index 9cd0e73c..dd1945aa 100644 --- a/src/theme/Heading/styles.module.css +++ b/src/theme/Heading/styles.module.css @@ -26,3 +26,86 @@ See https://twitter.com/JoshWComeau/status/1332015868725891076 :global(*:hover > .hash-link) { opacity: 1; } + +.copyButton { + opacity: 0; + margin-left: 0.5rem; + padding: 0.25rem; + border: none; + background: transparent; + cursor: pointer; + border-radius: 0.25rem; + transition: all var(--ifm-transition-fast); + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + user-select: none; +} + +.copyButton svg { + width: 1rem; + height: 1rem; + fill: var(--ifm-color-emphasis-600); +} + +.copyButton:hover { + background-color: var(--ifm-color-emphasis-200); +} + +.copyButton:hover svg { + fill: var(--ifm-color-emphasis-800); +} + +:global(.group:hover) .copyButton { + opacity: 1; +} + +.copyFeedback { + position: absolute; + left: 100%; + top: 50%; + transform: translateY(-50%); + margin-left: 0.5rem; + padding: 0.25rem 0.5rem; + background-color: var(--ifm-color-emphasis-800); + color: var(--ifm-color-emphasis-100); + border-radius: 0.25rem; + font-size: 0.75rem; + white-space: nowrap; + pointer-events: none; + z-index: 10; +} + +.copyButtonBelow { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + margin-bottom: 1rem; + padding: 0.5rem 1rem; + border: 1px solid var(--ifm-color-emphasis-300); + background: var(--ifm-background-color); + cursor: pointer; + border-radius: 0.375rem; + transition: all var(--ifm-transition-fast); + font-size: 0.875rem; + color: var(--ifm-color-emphasis-700); + user-select: none; +} + +.copyButtonBelow svg { + width: 1rem; + height: 1rem; + fill: var(--ifm-color-emphasis-600); +} + +.copyButtonBelow:hover { + background-color: var(--ifm-color-emphasis-100); + border-color: var(--ifm-color-emphasis-400); + color: var(--ifm-color-emphasis-800); +} + +.copyButtonBelow:hover svg { + fill: var(--ifm-color-emphasis-800); +} diff --git a/src/utils/copyPageContent.ts b/src/utils/copyPageContent.ts new file mode 100644 index 00000000..ff6d3051 --- /dev/null +++ b/src/utils/copyPageContent.ts @@ -0,0 +1,150 @@ +import { useDoc } from "@docusaurus/plugin-content-docs/client"; + +export function usePageCopyContent() { + const doc = useDoc(); + + const extractPageContent = (): string => { + try { + // Get the main article content + const articleElement = document.querySelector('article'); + if (!articleElement) { + return "Content not found"; + } + + // Extract text content while preserving structure + const content = extractStructuredContent(articleElement); + + // Format for LLM consumption + const pageTitle = doc?.metadata?.title || document.title; + const pageUrl = window.location.href; + const frontMatter = doc?.frontMatter ? formatFrontMatter(doc.frontMatter) : ''; + + return formatForLLM(pageTitle, pageUrl, frontMatter, content); + } catch (error) { + console.error('Error extracting page content:', error); + return "Error extracting page content"; + } + }; + + const copyToClipboard = async (): Promise => { + try { + const content = extractPageContent(); + await navigator.clipboard.writeText(content); + return true; + } catch (error) { + console.error('Failed to copy content:', error); + return false; + } + }; + + return { extractPageContent, copyToClipboard }; +} + +function extractStructuredContent(element: Element): string { + let content = ''; + + // Walk through the DOM and extract content with structure + const walker = document.createTreeWalker( + element, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => { + // Skip navigation, TOC, and other non-content elements + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element; + if (el.matches('nav, .table-of-contents, .navbar, .pagination-nav, .theme-doc-toc, .hash-link, .buttonWrapper, [aria-label*="Copy"]') || + el.closest('.buttonWrapper') || + el.textContent?.trim() === 'Copy for LLM' || + el.textContent?.trim() === 'On this page') { + return NodeFilter.FILTER_REJECT; + } + } + // Skip text nodes that are inside anchor tags (we'll handle them when processing the anchor) + if (node.nodeType === Node.TEXT_NODE && node.parentElement?.tagName === 'A') { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + } + ); + + let node; + while ((node = walker.nextNode())) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent?.trim(); + if (text && text.length > 0) { + content += text + ' '; + } + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element; + + // Add markdown-style formatting for headings + const headingMatch = el.tagName.match(/^H([1-6])$/); + if (headingMatch) { + const headingLevel = parseInt(headingMatch[1]); + const headingText = el.textContent?.trim(); + if (headingText) { + const markdownPrefix = '#'.repeat(headingLevel); + content += `\n${markdownPrefix} ${headingText}\n\n`; + } + } else if (el.matches('p')) { + content += '\n\n'; + } else if (el.matches('pre, code[class*="language-"]')) { + // Extract code blocks + const codeText = el.textContent?.trim(); + if (codeText) { + content += `\n\`\`\`\n${codeText}\n\`\`\`\n\n`; + } + } else if (el.matches('ul, ol')) { + content += '\n'; + } else if (el.matches('li')) { + content += '\n- '; + } else if (el.matches('a')) { + const linkText = el.textContent?.trim(); + const href = el.getAttribute('href'); + if (linkText && href) { + // Convert relative URLs to absolute URLs + const absoluteUrl = href.startsWith('http') ? href : new URL(href, window.location.origin).href; + content += `[${linkText}](${absoluteUrl})`; + } + } + } + } + + return content.replace(/\n\s*\n\s*\n/g, '\n\n').trim(); +} + +function formatFrontMatter(frontMatter: Record): string { + // Whitelist of attributes to include in the copied content + const allowedAttributes = [ + 'title', + 'description', + 'keywords', + 'tags', + 'author', + 'date', + 'updated', + 'category', + 'platform' + ]; + + const filtered = Object.entries(frontMatter) + .filter(([key]) => allowedAttributes.includes(key)) + .reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {}); + + if (Object.keys(filtered).length === 0) { + return ''; + } + + return `---\n${Object.entries(filtered) + .map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`) + .join('\n')}\n---\n\n`; +} + +function formatForLLM(title: string, url: string, frontMatter: string, content: string): string { + return `# ${title} + +**Source URL:** ${url} + +${frontMatter}${content}`; +} \ No newline at end of file