Skip to content

Commit d076cfa

Browse files
committed
feat: add Copy for LLM button to all doc pages
Adds a "Copy page" button to the footer of every documentation page that lets users copy the page's raw Markdown to the clipboard — ideal for pasting into LLMs like Claude or ChatGPT. Also includes a "View as Markdown" option that links directly to the raw GitHub file for the current page. The button is placed in content.tsx alongside the existing "Edit on GitHub" link, so it automatically appears on all doc pages without touching individual MDX files.
1 parent 9b5fd81 commit d076cfa

3 files changed

Lines changed: 209 additions & 9 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
3+
interface CopyForLLMProps {
4+
/** URL to the raw Markdown source of this page */
5+
rawUrl: string
6+
}
7+
8+
/**
9+
* A "Copy page" button with a dropdown that lets users:
10+
* 1. Copy the page's raw Markdown to the clipboard (great for LLMs)
11+
* 2. Open the raw Markdown in a new tab
12+
*/
13+
export function CopyForLLM({ rawUrl }: CopyForLLMProps) {
14+
const [open, setOpen] = useState(false)
15+
const [status, setStatus] = useState<'idle' | 'copying' | 'copied'>('idle')
16+
const menuRef = useRef<HTMLDivElement>(null)
17+
18+
// Close the dropdown when clicking outside
19+
useEffect(() => {
20+
const handleClickOutside = (event: MouseEvent) => {
21+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
22+
setOpen(false)
23+
}
24+
}
25+
document.addEventListener('mousedown', handleClickOutside)
26+
return () => document.removeEventListener('mousedown', handleClickOutside)
27+
}, [])
28+
29+
const copyMarkdown = async () => {
30+
setOpen(false)
31+
setStatus('copying')
32+
try {
33+
const res = await fetch(rawUrl)
34+
const text = await res.text()
35+
await navigator.clipboard.writeText(text)
36+
setStatus('copied')
37+
setTimeout(() => setStatus('idle'), 2000)
38+
} catch {
39+
setStatus('idle')
40+
}
41+
}
42+
43+
const label =
44+
status === 'copied' ? 'Copied!' : status === 'copying' ? 'Copying…' : 'Copy page'
45+
46+
return (
47+
<div ref={menuRef} className="relative flex items-center">
48+
{/* Primary button */}
49+
<button
50+
onClick={copyMarkdown}
51+
className="
52+
flex items-center gap-1.5
53+
rounded-s border border-space-1400
54+
px-2.5 py-1
55+
text-body-xsmall text-space-700
56+
outline-offset-2 transition
57+
hover:border-space-1200 hover:text-space-300
58+
"
59+
>
60+
<ClipboardIcon />
61+
{label}
62+
</button>
63+
64+
{/* Chevron toggle */}
65+
<button
66+
onClick={() => setOpen((v) => !v)}
67+
aria-label="More options"
68+
aria-expanded={open}
69+
className="
70+
flex items-center
71+
rounded-e border border-s-0 border-space-1400
72+
px-1.5 py-1
73+
text-space-700
74+
outline-offset-2 transition
75+
hover:border-space-1200 hover:text-space-300
76+
"
77+
>
78+
<ChevronIcon open={open} />
79+
</button>
80+
81+
{/* Dropdown */}
82+
{open && (
83+
<div className="absolute end-0 top-full z-20 mt-1.5 w-64 overflow-hidden rounded border border-space-1400 bg-space-1700 shadow-lg">
84+
<button
85+
onClick={copyMarkdown}
86+
className="flex w-full items-start gap-3 px-4 py-3 text-start transition hover:bg-space-1600"
87+
>
88+
<ClipboardIcon className="mt-0.5 shrink-0 text-space-500" size={16} />
89+
<div>
90+
<div className="text-body-small text-white">Copy page</div>
91+
<div className="text-body-xsmall text-space-600">Copy page as Markdown for LLMs</div>
92+
</div>
93+
</button>
94+
95+
<a
96+
href={rawUrl}
97+
target="_blank"
98+
rel="noopener noreferrer"
99+
onClick={() => setOpen(false)}
100+
className="flex items-start gap-3 px-4 py-3 transition hover:bg-space-1600"
101+
>
102+
<MarkdownIcon className="mt-0.5 shrink-0 text-space-500" />
103+
<div>
104+
<div className="text-body-small text-white">View as Markdown ↗</div>
105+
<div className="text-body-xsmall text-space-600">View this page as plain text</div>
106+
</div>
107+
</a>
108+
</div>
109+
)}
110+
</div>
111+
)
112+
}
113+
114+
// ---------------------------------------------------------------------------
115+
// Inline SVG icons (avoids adding an icon library dependency)
116+
// ---------------------------------------------------------------------------
117+
118+
function ClipboardIcon({
119+
className,
120+
size = 14,
121+
}: {
122+
className?: string
123+
size?: number
124+
}) {
125+
return (
126+
<svg
127+
width={size}
128+
height={size}
129+
viewBox="0 0 24 24"
130+
fill="none"
131+
stroke="currentColor"
132+
strokeWidth="2"
133+
strokeLinecap="round"
134+
strokeLinejoin="round"
135+
aria-hidden="true"
136+
className={className}
137+
>
138+
<rect x="9" y="2" width="13" height="13" rx="2" ry="2" />
139+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
140+
</svg>
141+
)
142+
}
143+
144+
function ChevronIcon({ open }: { open: boolean }) {
145+
return (
146+
<svg
147+
width="10"
148+
height="10"
149+
viewBox="0 0 10 10"
150+
fill="none"
151+
stroke="currentColor"
152+
strokeWidth="1.5"
153+
strokeLinecap="round"
154+
aria-hidden="true"
155+
>
156+
<path d={open ? 'M1 7l4-4 4 4' : 'M1 3l4 4 4-4'} />
157+
</svg>
158+
)
159+
}
160+
161+
function MarkdownIcon({ className }: { className?: string }) {
162+
return (
163+
<svg
164+
width="16"
165+
height="16"
166+
viewBox="0 0 24 24"
167+
fill="none"
168+
stroke="currentColor"
169+
strokeWidth="2"
170+
strokeLinecap="round"
171+
strokeLinejoin="round"
172+
aria-hidden="true"
173+
className={className}
174+
>
175+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
176+
<polyline points="14 2 14 8 20 8" />
177+
<line x1="16" y1="13" x2="8" y2="13" />
178+
<line x1="16" y1="17" x2="8" y2="17" />
179+
<polyline points="10 9 9 9 8 9" />
180+
</svg>
181+
)
182+
}

website/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './Callout'
22
export * from './Card'
33
export * from './CodeBlock'
4+
export * from './CopyForLLM'
45
export * from './DocSearch'
56
export * from './Heading'
67
export * from './Image'

website/src/layout/templates/default/content.tsx

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type ComponentProps, useContext } from 'react'
33
import { ButtonOrLink, classNames, ExperimentalDivider, ExperimentalLink } from '@edgeandnode/gds'
44
import { ArrowLeft, ArrowRight, CalendarDynamic, HourglassDynamic, SocialGitHub } from '@edgeandnode/gds/icons'
55

6+
import { CopyForLLM } from '@/components'
67
import { useI18n } from '@/i18n'
78

89
import { LayoutContext } from '../../shared'
@@ -22,6 +23,19 @@ export default function TemplateDefaultContent({ className, children, ...props }
2223
return `https://github.com/graphprotocol/docs/blob/main/website/src/pages/${segments.map(encodeURIComponent).join('/')}`
2324
})()
2425

26+
// Derive the raw Markdown URL so users can copy it directly into an LLM.
27+
// For remote pages (e.g. sourced from another repo), transform the GitHub
28+
// blob URL into a raw.githubusercontent.com URL. For local pages, build
29+
// the raw URL from the file path.
30+
const rawMarkdownUrl = remotePageUrl
31+
? remotePageUrl
32+
.replace('https://github.com/', 'https://raw.githubusercontent.com/')
33+
.replace('/blob/', '/')
34+
: (() => {
35+
const [_src, _pages, ...segments] = filePath.split('/')
36+
return `https://raw.githubusercontent.com/graphprotocol/docs/main/website/src/pages/${segments.join('/')}`
37+
})()
38+
2539
return (
2640
<div
2741
data-hide-content-header={frontMatter.hideContentHeader || undefined}
@@ -68,7 +82,7 @@ export default function TemplateDefaultContent({ className, children, ...props }
6882
group-data-[unwrap-content]/layout-content-grid:grid
6983
group-data-[unwrap-content]/layout-content-grid:auto-rows-max
7084
group-data-[unwrap-content]/layout-content-grid:grid-cols-subgrid
71-
${/* The following allows one child to be full height by setting `row-[full]`; see https://codepen.io/benface/pen/PwoaKJg */ ''}
85+
${/* The following allows one child to be full height by setting \`row-[full]\`; see https://codepen.io/benface/pen/PwoaKJg */ ''}
7286
group-data-[unwrap-content]/layout-content-grid:grid-rows-[repeat(auto-fit,minmax(0,max-content))_[full]_minmax(0,1fr)]
7387
-:group-data-[unwrap-content]/layout-content-grid:*:col-span-full
7488
-:group-not-data-[hide-content-header]/layout-content-grid:first:*:mt-6
@@ -123,14 +137,17 @@ export default function TemplateDefaultContent({ className, children, ...props }
123137
</time>
124138
</div>
125139
) : null}
126-
<ExperimentalLink
127-
href={editPageUrl}
128-
target="_blank"
129-
iconBefore={<SocialGitHub alt="" />}
130-
className="ms-auto text-space-700"
131-
>
132-
{t('global.page.edit')}
133-
</ExperimentalLink>
140+
<div className="ms-auto flex items-center gap-3">
141+
<CopyForLLM rawUrl={rawMarkdownUrl} />
142+
<ExperimentalLink
143+
href={editPageUrl}
144+
target="_blank"
145+
iconBefore={<SocialGitHub alt="" />}
146+
className="text-space-700"
147+
>
148+
{t('global.page.edit')}
149+
</ExperimentalLink>
150+
</div>
134151
</div>
135152
<ExperimentalDivider variant="subtle" />
136153
<div

0 commit comments

Comments
 (0)