From dc50f1cf16abeabb7390233168fcfd80d588495f Mon Sep 17 00:00:00 2001 From: Alfonso Embid-Desmet Date: Wed, 23 Jul 2025 11:22:55 -0700 Subject: [PATCH 1/7] AI draft: add Copy for LLM button --- .gitignore | 4 +- docusaurus.config.js | 24 +-- src/components/CopyForLLMButton/index.tsx | 43 +++++ .../CopyForLLMButton/styles.module.css | 54 ++++++ src/theme/DocItem/Content/index.tsx | 18 ++ src/theme/Heading/index.tsx | 5 + src/theme/Heading/styles.module.css | 83 +++++++++ src/utils/copyPageContent.ts | 157 ++++++++++++++++++ 8 files changed, 376 insertions(+), 12 deletions(-) create mode 100644 src/components/CopyForLLMButton/index.tsx create mode 100644 src/components/CopyForLLMButton/styles.module.css create mode 100644 src/theme/DocItem/Content/index.tsx create mode 100644 src/utils/copyPageContent.ts 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..c0e6f26a --- /dev/null +++ b/src/components/CopyForLLMButton/index.tsx @@ -0,0 +1,43 @@ +import React, { useState } from 'react'; +import { usePageCopyContent } from '@site/src/utils/copyPageContent'; +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..15100997 --- /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/index.tsx b/src/theme/Heading/index.tsx index 475e45a0..3a3c5fba 100644 --- a/src/theme/Heading/index.tsx +++ b/src/theme/Heading/index.tsx @@ -24,6 +24,11 @@ export default function Heading({ // H1 headings do not need an id because they don't appear in the TOC. if (As === "h1" || !id) { + if (As === "h1") { + return + {props.children} + ; + } return ; } 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..95a0b0dd --- /dev/null +++ b/src/utils/copyPageContent.ts @@ -0,0 +1,157 @@ +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')) { + 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 + if (el.matches('h1')) { + const headingText = el.textContent?.trim(); + if (headingText) { + content += `\n# ${headingText}\n\n`; + } + } else if (el.matches('h2')) { + const headingText = el.textContent?.trim(); + if (headingText) { + content += `\n## ${headingText}\n\n`; + } + } else if (el.matches('h3')) { + const headingText = el.textContent?.trim(); + if (headingText) { + content += `\n### ${headingText}\n\n`; + } + } else if (el.matches('h4')) { + const headingText = el.textContent?.trim(); + if (headingText) { + content += `\n#### ${headingText}\n\n`; + } + } else if (el.matches('h5')) { + const headingText = el.textContent?.trim(); + if (headingText) { + content += `\n##### ${headingText}\n\n`; + } + } else if (el.matches('h6')) { + const headingText = el.textContent?.trim(); + if (headingText) { + content += `\n###### ${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- '; + } + } + } + + 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 From 54f07eb7a95321d11f1f311917199f7d046e0346 Mon Sep 17 00:00:00 2001 From: Alfonso Embid-Desmet Date: Wed, 23 Jul 2025 11:26:09 -0700 Subject: [PATCH 2/7] use CatIcon --- src/components/CopyForLLMButton/index.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/CopyForLLMButton/index.tsx b/src/components/CopyForLLMButton/index.tsx index c0e6f26a..a54bd0a2 100644 --- a/src/components/CopyForLLMButton/index.tsx +++ b/src/components/CopyForLLMButton/index.tsx @@ -1,5 +1,6 @@ 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() { @@ -26,10 +27,7 @@ export default function CopyForLLMButton() { aria-label="Copy page content for LLM" title="Copy for LLM" > - - - - + Copy for LLM {copyFeedback && ( From 8f862ef2e9cf5b7b066398001878d3a9f036e423 Mon Sep 17 00:00:00 2001 From: Alfonso Embid-Desmet Date: Wed, 23 Jul 2025 11:28:03 -0700 Subject: [PATCH 3/7] reduce margin --- src/theme/DocItem/Content/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/theme/DocItem/Content/index.tsx b/src/theme/DocItem/Content/index.tsx index 15100997..5ffc9be8 100644 --- a/src/theme/DocItem/Content/index.tsx +++ b/src/theme/DocItem/Content/index.tsx @@ -9,7 +9,7 @@ type Props = WrapperProps; export default function ContentWrapper(props: Props): JSX.Element { return (
-
+
From ed493afdd3173b171639beb717ce6fa5a33c5741 Mon Sep 17 00:00:00 2001 From: Alfonso Embid-Desmet Date: Wed, 23 Jul 2025 11:30:41 -0700 Subject: [PATCH 4/7] refine copied content --- src/utils/copyPageContent.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/utils/copyPageContent.ts b/src/utils/copyPageContent.ts index 95a0b0dd..411f8a9d 100644 --- a/src/utils/copyPageContent.ts +++ b/src/utils/copyPageContent.ts @@ -52,10 +52,17 @@ function extractStructuredContent(element: Element): string { // 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')) { + 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; } } @@ -114,6 +121,14 @@ function extractStructuredContent(element: Element): string { 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})`; + } } } } From 07afa32e9b571d93be6e749f3362b376353a331d Mon Sep 17 00:00:00 2001 From: Alfonso Embid-Desmet Date: Wed, 23 Jul 2025 11:33:33 -0700 Subject: [PATCH 5/7] not needed --- src/theme/Heading/index.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/theme/Heading/index.tsx b/src/theme/Heading/index.tsx index 3a3c5fba..475e45a0 100644 --- a/src/theme/Heading/index.tsx +++ b/src/theme/Heading/index.tsx @@ -24,11 +24,6 @@ export default function Heading({ // H1 headings do not need an id because they don't appear in the TOC. if (As === "h1" || !id) { - if (As === "h1") { - return - {props.children} - ; - } return ; } From 188b52817e8b5eb65950fdc847cc9a036cc53051 Mon Sep 17 00:00:00 2001 From: Alfonso Embid-Desmet Date: Wed, 23 Jul 2025 11:41:31 -0700 Subject: [PATCH 6/7] simplify --- src/utils/copyPageContent.ts | 32 +++++--------------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/src/utils/copyPageContent.ts b/src/utils/copyPageContent.ts index 411f8a9d..ff6d3051 100644 --- a/src/utils/copyPageContent.ts +++ b/src/utils/copyPageContent.ts @@ -79,35 +79,13 @@ function extractStructuredContent(element: Element): string { const el = node as Element; // Add markdown-style formatting for headings - if (el.matches('h1')) { + const headingMatch = el.tagName.match(/^H([1-6])$/); + if (headingMatch) { + const headingLevel = parseInt(headingMatch[1]); const headingText = el.textContent?.trim(); if (headingText) { - content += `\n# ${headingText}\n\n`; - } - } else if (el.matches('h2')) { - const headingText = el.textContent?.trim(); - if (headingText) { - content += `\n## ${headingText}\n\n`; - } - } else if (el.matches('h3')) { - const headingText = el.textContent?.trim(); - if (headingText) { - content += `\n### ${headingText}\n\n`; - } - } else if (el.matches('h4')) { - const headingText = el.textContent?.trim(); - if (headingText) { - content += `\n#### ${headingText}\n\n`; - } - } else if (el.matches('h5')) { - const headingText = el.textContent?.trim(); - if (headingText) { - content += `\n##### ${headingText}\n\n`; - } - } else if (el.matches('h6')) { - const headingText = el.textContent?.trim(); - if (headingText) { - content += `\n###### ${headingText}\n\n`; + const markdownPrefix = '#'.repeat(headingLevel); + content += `\n${markdownPrefix} ${headingText}\n\n`; } } else if (el.matches('p')) { content += '\n\n'; From b91489ad5977462be69a601946c002821cc1d755 Mon Sep 17 00:00:00 2001 From: Alfonso Embid-Desmet Date: Wed, 23 Jul 2025 11:42:18 -0700 Subject: [PATCH 7/7] bit more margin --- src/theme/DocItem/Content/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/theme/DocItem/Content/index.tsx b/src/theme/DocItem/Content/index.tsx index 5ffc9be8..c73679a1 100644 --- a/src/theme/DocItem/Content/index.tsx +++ b/src/theme/DocItem/Content/index.tsx @@ -9,7 +9,7 @@ type Props = WrapperProps; export default function ContentWrapper(props: Props): JSX.Element { return (
-
+