Skip to content
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
175 changes: 175 additions & 0 deletions website/src/components/CopyForLLM.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { useEffect, useRef, useState } from 'react'

interface CopyForLLMProps {
/** URL to the raw Markdown source of this page */
rawUrl: string
}

/**
* A "Copy page" button with a dropdown that lets users:
* 1. Copy the page's raw Markdown to the clipboard (great for LLMs)
* 2. Open the raw Markdown in a new tab
*/
export function CopyForLLM({ rawUrl }: CopyForLLMProps) {
const [open, setOpen] = useState(false)
const [status, setStatus] = useState<'idle' | 'copying' | 'copied'>('idle')
const menuRef = useRef<HTMLDivElement>(null)

// Close the dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])

const copyMarkdown = async () => {
setOpen(false)
setStatus('copying')
try {
const res = await fetch(rawUrl)
const text = await res.text()
await navigator.clipboard.writeText(text)
setStatus('copied')
setTimeout(() => setStatus('idle'), 2000)
} catch {
setStatus('idle')
}
}

const label = status === 'copied' ? 'Copied!' : status === 'copying' ? 'Copying…' : 'Copy page'

return (
<div ref={menuRef} className="relative flex items-center">
{/* Primary button */}
<button
onClick={() => void copyMarkdown()}
className="
rounded-s text-body-xsmall flex
items-center gap-1.5 border
border-space-1400 px-2.5
py-1 text-space-700
outline-offset-2 transition
hover:border-space-1200 hover:text-space-300
"
>
<ClipboardIcon />
{label}
</button>

{/* Chevron toggle */}
<button
onClick={() => setOpen((v) => !v)}
aria-label="More options"
aria-expanded={open}
className="
rounded-e flex
items-center border border-s-0 border-space-1400
px-1.5 py-1
text-space-700
outline-offset-2 transition
hover:border-space-1200 hover:text-space-300
"
>
<ChevronIcon open={open} />
</button>

{/* Dropdown */}
{open && (
<div className="rounded absolute end-0 top-full z-20 mt-1.5 w-64 overflow-hidden border border-space-1400 bg-space-1700 shadow-lg">
<button
onClick={() => void copyMarkdown()}
className="flex w-full items-start gap-3 px-4 py-3 text-start transition hover:bg-space-1600"
>
<ClipboardIcon className="mt-0.5 shrink-0 text-space-500" size={16} />
<div>
<div className="text-body-small text-white">Copy page</div>
<div className="text-body-xsmall text-space-600">Copy page as Markdown for LLMs</div>
</div>
</button>

<a
href={rawUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className="flex items-start gap-3 px-4 py-3 transition hover:bg-space-1600"
>
<MarkdownIcon className="mt-0.5 shrink-0 text-space-500" />
<div>
<div className="text-body-small text-white">View as Markdown ↗</div>
<div className="text-body-xsmall text-space-600">View this page as plain text</div>
</div>
</a>
</div>
)}
</div>
)
}

// ---------------------------------------------------------------------------
// Inline SVG icons (avoids adding an icon library dependency)
// ---------------------------------------------------------------------------

function ClipboardIcon({ className, size = 14 }: { className?: string; size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<rect x="9" y="2" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)
}

function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden="true"
>
<path d={open ? 'M1 7l4-4 4 4' : 'M1 3l4 4 4-4'} />
</svg>
)
}

function MarkdownIcon({ className }: { className?: string }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
)
}
1 change: 1 addition & 0 deletions website/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './Callout'
export * from './Card'
export * from './CodeBlock'
export * from './CopyForLLM'
export * from './DocSearch'
export * from './Heading'
export * from './Image'
Expand Down
33 changes: 24 additions & 9 deletions website/src/layout/templates/default/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type ComponentProps, useContext } from 'react'
import { ButtonOrLink, classNames, ExperimentalDivider, ExperimentalLink } from '@edgeandnode/gds'
import { ArrowLeft, ArrowRight, CalendarDynamic, HourglassDynamic, SocialGitHub } from '@edgeandnode/gds/icons'

import { CopyForLLM } from '@/components'
import { useI18n } from '@/i18n'

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

// Derive the raw Markdown URL so users can copy it directly into an LLM.
// For remote pages (e.g. sourced from another repo), transform the GitHub
// blob URL into a raw.githubusercontent.com URL. For local pages, build
// the raw URL from the file path.
const rawMarkdownUrl = remotePageUrl
? remotePageUrl.replace('https://github.com/', 'https://raw.githubusercontent.com/').replace('/blob/', '/')
: (() => {
const [_src, _pages, ...segments] = filePath.split('/')
return `https://raw.githubusercontent.com/graphprotocol/docs/main/website/src/pages/${segments.join('/')}`
})()

return (
<div
data-hide-content-header={frontMatter.hideContentHeader || undefined}
Expand Down Expand Up @@ -68,7 +80,7 @@ export default function TemplateDefaultContent({ className, children, ...props }
group-data-[unwrap-content]/layout-content-grid:grid
group-data-[unwrap-content]/layout-content-grid:auto-rows-max
group-data-[unwrap-content]/layout-content-grid:grid-cols-subgrid
${/* The following allows one child to be full height by setting `row-[full]`; see https://codepen.io/benface/pen/PwoaKJg */ ''}
${/* The following allows one child to be full height by setting \`row-[full]\`; see https://codepen.io/benface/pen/PwoaKJg */ ''}
group-data-[unwrap-content]/layout-content-grid:grid-rows-[repeat(auto-fit,minmax(0,max-content))_[full]_minmax(0,1fr)]
-:group-data-[unwrap-content]/layout-content-grid:*:col-span-full
-:group-not-data-[hide-content-header]/layout-content-grid:first:*:mt-6
Expand Down Expand Up @@ -123,14 +135,17 @@ export default function TemplateDefaultContent({ className, children, ...props }
</time>
</div>
) : null}
<ExperimentalLink
href={editPageUrl}
target="_blank"
iconBefore={<SocialGitHub alt="" />}
className="ms-auto text-space-700"
>
{t('global.page.edit')}
</ExperimentalLink>
<div className="ms-auto flex items-center gap-3">
<CopyForLLM rawUrl={rawMarkdownUrl} />
<ExperimentalLink
href={editPageUrl}
target="_blank"
iconBefore={<SocialGitHub alt="" />}
className="text-space-700"
>
{t('global.page.edit')}
</ExperimentalLink>
</div>
</div>
<ExperimentalDivider variant="subtle" />
<div
Expand Down
Loading