Skip to content

Commit 6b39bb2

Browse files
authored
add a copy button + Open in ChatGPT/Claude (#427)
1 parent b5d9a8a commit 6b39bb2

File tree

5 files changed

+448
-93
lines changed

5 files changed

+448
-93
lines changed

astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export default defineConfig({
5353
baseUrl: 'https://github.com/localstack/localstack-docs/edit/main/',
5454
},
5555
components: {
56+
PageTitle: './src/components/PageTitleWithCopyButton.astro',
5657
PageSidebar: './src/components/PageSidebarWithBadges.astro',
5758
LanguageSelect: './src/components/LanguageSelectWithGetStarted.astro',
5859
Banner: './src/components/BannerWithPersistentAnnouncement.astro',
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
import { useState, useRef, useEffect } from 'react';
2+
import { Copy, Check, ChevronDown, ExternalLink } from 'lucide-react';
3+
4+
interface CopyPageDropdownProps {
5+
pageUrl: string;
6+
pageTitle: string;
7+
}
8+
9+
// ChatGPT logo SVG
10+
const ChatGPTIcon = () => (
11+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
12+
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.8956zm16.0993 3.8558L12.602 8.3829l2.0201-1.1638a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.1408 1.6465 4.4708 4.4708 0 0 1 .5765 3.0175zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" fill="currentColor"/>
13+
</svg>
14+
);
15+
16+
// Claude logo SVG (official from Wikimedia)
17+
const ClaudeIcon = () => (
18+
<svg width="16" height="16" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
19+
<path d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z" fill="#d97757"/>
20+
</svg>
21+
);
22+
23+
export function CopyPageDropdown({ pageUrl, pageTitle }: CopyPageDropdownProps) {
24+
const [isOpen, setIsOpen] = useState(false);
25+
const [copied, setCopied] = useState(false);
26+
const dropdownRef = useRef<HTMLDivElement>(null);
27+
28+
// Close dropdown when clicking outside
29+
useEffect(() => {
30+
const handleClickOutside = (event: MouseEvent) => {
31+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
32+
setIsOpen(false);
33+
}
34+
};
35+
36+
document.addEventListener('mousedown', handleClickOutside);
37+
return () => document.removeEventListener('mousedown', handleClickOutside);
38+
}, []);
39+
40+
const extractCleanMarkdown = (element: Element): string => {
41+
const lines: string[] = [];
42+
43+
const processNode = (node: Node, depth: number = 0): void => {
44+
if (node.nodeType === Node.TEXT_NODE) {
45+
const text = node.textContent?.trim();
46+
if (text) {
47+
lines.push(text);
48+
}
49+
return;
50+
}
51+
52+
if (node.nodeType !== Node.ELEMENT_NODE) return;
53+
54+
const el = node as Element;
55+
const tagName = el.tagName.toLowerCase();
56+
57+
// Skip unwanted elements
58+
if (
59+
el.classList.contains('sl-banner') ||
60+
el.classList.contains('copy-page-dropdown') ||
61+
el.classList.contains('pagination-links') ||
62+
el.classList.contains('edit-on-github') ||
63+
el.closest('.copy-page-dropdown') ||
64+
el.closest('astro-island') ||
65+
tagName === 'button' ||
66+
tagName === 'nav' ||
67+
tagName === 'footer' ||
68+
tagName === 'script' ||
69+
tagName === 'style' ||
70+
tagName === 'astro-island' ||
71+
tagName === 'astro-slot' ||
72+
tagName === 'astro-static-slot' ||
73+
tagName === 'template' ||
74+
tagName === 'noscript' ||
75+
tagName === 'input' ||
76+
tagName === 'textarea' ||
77+
tagName === 'svg' ||
78+
el.getAttribute('aria-hidden') === 'true' ||
79+
// Skip "Section titled" links and "Edit page" links
80+
(tagName === 'a' && el.textContent?.includes('Section titled')) ||
81+
(tagName === 'a' && el.textContent?.includes('Edit page'))
82+
) {
83+
return;
84+
}
85+
86+
// Handle headings
87+
if (/^h[1-6]$/.test(tagName)) {
88+
const level = parseInt(tagName[1]);
89+
const prefix = '#'.repeat(level);
90+
const text = el.textContent?.replace(/Section titled "[^"]*"/g, '').trim();
91+
if (text) {
92+
lines.push('');
93+
lines.push(`${prefix} ${text}`);
94+
lines.push('');
95+
}
96+
return;
97+
}
98+
99+
// Handle code blocks
100+
if (tagName === 'pre' || el.classList.contains('expressive-code')) {
101+
const codeEl = el.querySelector('code');
102+
if (codeEl) {
103+
const code = codeEl.textContent?.trim() || '';
104+
// Try to detect language from class
105+
const langClass = Array.from(codeEl.classList).find(c => c.startsWith('language-'));
106+
const lang = langClass ? langClass.replace('language-', '') : '';
107+
lines.push('');
108+
lines.push('```' + lang);
109+
lines.push(code);
110+
lines.push('```');
111+
lines.push('');
112+
}
113+
return;
114+
}
115+
116+
// Handle inline code
117+
if (tagName === 'code' && !el.closest('pre')) {
118+
lines.push(`\`${el.textContent}\``);
119+
return;
120+
}
121+
122+
// Handle links
123+
if (tagName === 'a' && !el.textContent?.includes('Section titled')) {
124+
const href = el.getAttribute('href');
125+
const text = el.textContent?.trim();
126+
if (text && href && !href.startsWith('#')) {
127+
// Make relative URLs absolute
128+
const fullUrl = href.startsWith('http') ? href : `https://docs.localstack.cloud${href}`;
129+
lines.push(`[${text}](${fullUrl})`);
130+
return;
131+
}
132+
}
133+
134+
// Handle lists
135+
if (tagName === 'ul' || tagName === 'ol') {
136+
lines.push('');
137+
const items = el.querySelectorAll(':scope > li');
138+
items.forEach((li, idx) => {
139+
const prefix = tagName === 'ol' ? `${idx + 1}.` : '-';
140+
const text = li.textContent?.trim();
141+
if (text) {
142+
lines.push(`${prefix} ${text}`);
143+
}
144+
});
145+
lines.push('');
146+
return;
147+
}
148+
149+
// Handle paragraphs
150+
if (tagName === 'p') {
151+
const text = el.textContent?.trim();
152+
if (text) {
153+
lines.push('');
154+
lines.push(text);
155+
}
156+
return;
157+
}
158+
159+
// Handle tables
160+
if (tagName === 'table') {
161+
lines.push('');
162+
const rows = el.querySelectorAll('tr');
163+
rows.forEach((row, rowIdx) => {
164+
const cells = row.querySelectorAll('th, td');
165+
const cellTexts = Array.from(cells).map(cell => cell.textContent?.trim() || '');
166+
lines.push('| ' + cellTexts.join(' | ') + ' |');
167+
if (rowIdx === 0) {
168+
lines.push('| ' + cellTexts.map(() => '---').join(' | ') + ' |');
169+
}
170+
});
171+
lines.push('');
172+
return;
173+
}
174+
175+
// Recursively process children for other elements
176+
el.childNodes.forEach(child => processNode(child, depth + 1));
177+
};
178+
179+
processNode(element);
180+
181+
// Clean up the output
182+
return lines
183+
.join('\n')
184+
.replace(/\n{3,}/g, '\n\n') // Remove excessive newlines
185+
.replace(/^[\s\n]+/, '') // Trim start
186+
.replace(/[\s\n]+$/, ''); // Trim end
187+
};
188+
189+
const handleCopyPage = async () => {
190+
try {
191+
// Get the main content, specifically the markdown content area
192+
const mainContent = document.querySelector('.sl-markdown-content') || document.querySelector('main');
193+
if (mainContent) {
194+
const markdown = extractCleanMarkdown(mainContent);
195+
const text = `# ${pageTitle}\n\nSource: ${pageUrl}\n\n${markdown}`;
196+
await navigator.clipboard.writeText(text);
197+
setCopied(true);
198+
setTimeout(() => {
199+
setCopied(false);
200+
setIsOpen(false);
201+
}, 2000);
202+
}
203+
} catch (err) {
204+
console.error('Failed to copy:', err);
205+
}
206+
};
207+
208+
const openInChatGPT = () => {
209+
const prompt = encodeURIComponent(`Read from ${pageUrl} so I can ask questions about it.`);
210+
window.open(`https://chatgpt.com/?prompt=${prompt}`, '_blank');
211+
setIsOpen(false);
212+
};
213+
214+
const openInClaude = () => {
215+
const prompt = encodeURIComponent(`Read from ${pageUrl} so I can ask questions about it.`);
216+
window.open(`https://claude.ai/new?q=${prompt}`, '_blank');
217+
setIsOpen(false);
218+
};
219+
220+
return (
221+
<div className="copy-page-dropdown" ref={dropdownRef}>
222+
<button
223+
className="copy-page-button"
224+
onClick={() => setIsOpen(!isOpen)}
225+
aria-expanded={isOpen}
226+
aria-haspopup="true"
227+
>
228+
{copied ? <Check size={16} /> : <Copy size={16} />}
229+
<span>{copied ? 'Copied!' : 'Copy page'}</span>
230+
<ChevronDown size={14} className={`chevron ${isOpen ? 'rotated' : ''}`} />
231+
</button>
232+
233+
{isOpen && (
234+
<div className="copy-page-menu">
235+
<button className="menu-item" onClick={handleCopyPage}>
236+
<Copy size={16} />
237+
<span>Copy page</span>
238+
<span className="menu-item-desc">Copy page as Markdown for LLMs</span>
239+
</button>
240+
241+
<div className="menu-divider" />
242+
243+
<button className="menu-item" onClick={openInChatGPT}>
244+
<ChatGPTIcon />
245+
<span>Open in ChatGPT</span>
246+
<ExternalLink size={12} className="external-icon" />
247+
</button>
248+
249+
<button className="menu-item" onClick={openInClaude}>
250+
<ClaudeIcon />
251+
<span>Open in Claude</span>
252+
<ExternalLink size={12} className="external-icon" />
253+
</button>
254+
</div>
255+
)}
256+
257+
<style>{`
258+
.copy-page-dropdown {
259+
position: relative;
260+
display: inline-flex;
261+
}
262+
263+
.copy-page-button {
264+
display: inline-flex;
265+
align-items: center;
266+
gap: 0.375rem;
267+
padding: 0.375rem 0.75rem;
268+
background-color: var(--sl-color-gray-6);
269+
border: 1px solid var(--sl-color-gray-5);
270+
border-radius: 0.375rem;
271+
color: var(--sl-color-gray-2);
272+
font-size: 0.8125rem;
273+
font-weight: 500;
274+
cursor: pointer;
275+
transition: all 0.15s ease;
276+
white-space: nowrap;
277+
}
278+
279+
.copy-page-button:hover {
280+
background-color: var(--sl-color-gray-5);
281+
border-color: var(--sl-color-gray-4);
282+
}
283+
284+
.copy-page-button .chevron {
285+
transition: transform 0.2s ease;
286+
margin-left: 0.125rem;
287+
}
288+
289+
.copy-page-button .chevron.rotated {
290+
transform: rotate(180deg);
291+
}
292+
293+
.copy-page-menu {
294+
position: absolute;
295+
top: calc(100% + 0.375rem);
296+
right: 0;
297+
min-width: 240px;
298+
background-color: var(--sl-color-gray-6);
299+
border: 1px solid var(--sl-color-gray-5);
300+
border-radius: 0.5rem;
301+
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.15);
302+
z-index: 100;
303+
padding: 0.375rem;
304+
animation: fadeIn 0.15s ease;
305+
}
306+
307+
@keyframes fadeIn {
308+
from {
309+
opacity: 0;
310+
transform: translateY(-4px);
311+
}
312+
to {
313+
opacity: 1;
314+
transform: translateY(0);
315+
}
316+
}
317+
318+
.menu-item {
319+
display: flex;
320+
align-items: center;
321+
gap: 0.5rem;
322+
width: 100%;
323+
padding: 0.5rem 0.625rem;
324+
background: none;
325+
border: none;
326+
border-radius: 0.375rem;
327+
color: var(--sl-color-gray-2);
328+
font-size: 0.8125rem;
329+
text-align: left;
330+
cursor: pointer;
331+
transition: background-color 0.15s ease;
332+
position: relative;
333+
}
334+
335+
.menu-item:hover {
336+
background-color: var(--sl-color-gray-5);
337+
}
338+
339+
.menu-item-desc {
340+
display: block;
341+
font-size: 0.6875rem;
342+
color: var(--sl-color-gray-3);
343+
position: absolute;
344+
bottom: 0.25rem;
345+
left: 2rem;
346+
line-height: 1;
347+
}
348+
349+
.menu-item:has(.menu-item-desc) {
350+
padding-bottom: 1.25rem;
351+
flex-wrap: wrap;
352+
}
353+
354+
.external-icon {
355+
margin-left: auto;
356+
color: var(--sl-color-gray-4);
357+
}
358+
359+
.menu-divider {
360+
height: 1px;
361+
background-color: var(--sl-color-gray-5);
362+
margin: 0.375rem 0;
363+
}
364+
365+
@media (max-width: 640px) {
366+
.copy-page-button span:not(.chevron) {
367+
display: none;
368+
}
369+
370+
.copy-page-button {
371+
padding: 0.375rem;
372+
}
373+
374+
.copy-page-menu {
375+
right: -0.5rem;
376+
}
377+
}
378+
`}</style>
379+
</div>
380+
);
381+
}

0 commit comments

Comments
 (0)