Skip to content

Commit e24e69f

Browse files
m-hulbertclaude
andcommitted
feat(nav): add CopyForLLM split button
Extract the copy-for-LLM functionality from PageHeader into a dedicated CopyForLLM component with a split button (copy action + dropdown for markdown preview, copy, and LLM links). Add ButtonGroup and Separator UI primitives. Simplify PageHeader after extraction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f527680 commit e24e69f

8 files changed

Lines changed: 494 additions & 386 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,15 @@
4848
"@radix-ui/react-accordion": "^1.2.12",
4949
"@radix-ui/react-dropdown-menu": "^2.1.16",
5050
"@radix-ui/react-select": "^2.2.6",
51+
"@radix-ui/react-separator": "^1.1.8",
5152
"@radix-ui/react-tabs": "^1.1.13",
5253
"@radix-ui/react-tooltip": "^1.2.8",
5354
"@react-hook/media-query": "^1.1.1",
5455
"@sentry/gatsby": "^9.19.0",
5556
"@types/cheerio": "^1.0.0",
5657
"@types/prop-types": "^15.7.4",
5758
"cheerio": "^1.0.0-rc.10",
59+
"class-variance-authority": "^0.7.1",
5860
"dompurify": "^3.4.0",
5961
"fast-glob": "^3.3.3",
6062
"front-matter": "^4.0.2",
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React, { ReactNode } from 'react';
2+
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
3+
import CopyForLLM from './CopyForLLM';
4+
5+
const mockUseLayoutContext = jest.fn(() => ({
6+
activePage: {
7+
language: 'javascript',
8+
languages: ['javascript'],
9+
product: 'pubsub',
10+
page: {
11+
name: 'Test Page',
12+
link: '/docs/test-page',
13+
},
14+
tree: [],
15+
template: 'mdx' as const,
16+
},
17+
}));
18+
19+
jest.mock('src/contexts/layout-context', () => ({
20+
useLayoutContext: () => mockUseLayoutContext(),
21+
}));
22+
23+
jest.mock('@reach/router', () => ({
24+
useLocation: () => ({ pathname: '/docs/test-page' }),
25+
}));
26+
27+
jest.mock('@ably/ui/core/Icon', () => ({
28+
__esModule: true,
29+
default: ({ name }: { name: string }) => <span data-testid={`icon-${name}`}>{name}</span>,
30+
}));
31+
32+
jest.mock('@ably/ui/core/insights', () => ({
33+
track: jest.fn(),
34+
}));
35+
36+
// Mock Radix DropdownMenu to render content directly
37+
jest.mock('@radix-ui/react-dropdown-menu', () => ({
38+
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
39+
Trigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <div>{children}</div>,
40+
Portal: ({ children }: { children: ReactNode }) => <div>{children}</div>,
41+
Content: ({ children }: { children: ReactNode }) => <div>{children}</div>,
42+
Item: ({
43+
children,
44+
onSelect,
45+
...props
46+
}: {
47+
children: ReactNode;
48+
onSelect?: (e: Event) => void;
49+
asChild?: boolean;
50+
}) =>
51+
props.asChild ? (
52+
<>{children}</>
53+
) : (
54+
<div onClick={() => onSelect?.({ preventDefault: () => undefined } as unknown as Event)}>{children}</div>
55+
),
56+
Separator: () => <hr />,
57+
}));
58+
59+
describe('CopyForLLM', () => {
60+
beforeEach(() => {
61+
jest.clearAllMocks();
62+
});
63+
64+
afterEach(() => {
65+
jest.useRealTimers();
66+
jest.restoreAllMocks();
67+
jest.clearAllTimers();
68+
});
69+
70+
it('renders the dropdown trigger button', () => {
71+
global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response));
72+
73+
render(<CopyForLLM />);
74+
expect(screen.getByText('Copy for LLM')).toBeInTheDocument();
75+
});
76+
77+
it('renders LLM links for ChatGPT, Claude and Perplexity', () => {
78+
global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response));
79+
80+
render(<CopyForLLM />);
81+
expect(screen.getByText('Open in ChatGPT')).toBeInTheDocument();
82+
expect(screen.getByText('Open in Claude')).toBeInTheDocument();
83+
expect(screen.getByText('Open in Perplexity')).toBeInTheDocument();
84+
});
85+
86+
it('shows markdown items when content is available', async () => {
87+
const mockMarkdown = '# Test content';
88+
89+
global.fetch = jest.fn(() =>
90+
Promise.resolve({
91+
ok: true,
92+
headers: {
93+
get: (name: string) => (name === 'Content-Type' ? 'text/markdown' : null),
94+
},
95+
text: () => Promise.resolve(mockMarkdown),
96+
} as Response),
97+
);
98+
99+
const mockWriteText = jest.fn();
100+
Object.assign(navigator, { clipboard: { writeText: mockWriteText } });
101+
102+
render(<CopyForLLM />);
103+
104+
// Wait for the markdown to be fetched and state to update (button becomes enabled)
105+
const copyButton = await screen.findByText('Copy for LLM');
106+
await waitFor(() => {
107+
expect(copyButton.closest('button')).not.toBeDisabled();
108+
});
109+
110+
jest.useFakeTimers();
111+
112+
// Click copy via the dropdown item
113+
const copyItem = screen.getByText('Copy as markdown').closest('div');
114+
if (copyItem) {
115+
act(() => {
116+
fireEvent.click(copyItem);
117+
});
118+
}
119+
120+
expect(mockWriteText).toHaveBeenCalledWith(mockMarkdown);
121+
122+
act(() => {
123+
jest.runOnlyPendingTimers();
124+
});
125+
jest.useRealTimers();
126+
});
127+
128+
it('disables copy button when fetch fails', async () => {
129+
global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response));
130+
131+
render(<CopyForLLM />);
132+
133+
await new Promise<void>((resolve) => setTimeout(resolve, 50));
134+
135+
const copyButton = screen.getByText('Copy for LLM').closest('button');
136+
expect(copyButton).toBeDisabled();
137+
});
138+
});
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2+
import { useLocation } from '@reach/router';
3+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
4+
import cn from '@ably/ui/core/utils/cn';
5+
import Icon from '@ably/ui/core/Icon';
6+
import { IconName } from '@ably/ui/core/Icon/types';
7+
import { track } from '@ably/ui/core/insights';
8+
import { productData } from 'src/data';
9+
import { languageInfo } from 'src/data/languages';
10+
import { useLayoutContext } from 'src/contexts/layout-context';
11+
import { ButtonGroup, ButtonGroupSeparator } from 'src/components/ui/ButtonGroup';
12+
import { secondaryButtonClassName } from './utils/styles';
13+
14+
const menuItemClassName =
15+
'flex items-center gap-2 px-3 py-2 text-sm text-neutral-1300 dark:text-neutral-000 hover:bg-neutral-100 dark:hover:bg-neutral-1200 rounded cursor-pointer outline-none';
16+
17+
const CopyForLLM: React.FC = () => {
18+
const { activePage } = useLayoutContext();
19+
const { language, product, page } = activePage;
20+
const location = useLocation();
21+
const [markdownContent, setMarkdownContent] = useState<string | null>(null);
22+
const [copyFeedback, setCopyFeedback] = useState<string | null>(null);
23+
24+
const llmLinks = useMemo(() => {
25+
const docUrl = `https://ably.com${page.link}.md`;
26+
const prompt = `Fetch the documentation from ${docUrl} and tell me more about ${product ? productData[product]?.nav.name : 'Ably'}'s '${page.name}' feature${language ? ` for ${languageInfo[language]?.label}` : ''}`;
27+
const gptPath = `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`;
28+
const claudePath = `https://claude.ai/new?q=${encodeURIComponent(prompt)}`;
29+
const perplexityPath = `https://www.perplexity.ai/?q=${encodeURIComponent(prompt)}`;
30+
31+
return [
32+
{ model: 'gpt', label: 'Open in ChatGPT', icon: 'icon-tech-openai', link: gptPath },
33+
{ model: 'claude', label: 'Open in Claude', icon: 'icon-tech-claude-mono', link: claudePath },
34+
{ model: 'perplexity', label: 'Open in Perplexity', icon: 'icon-tech-perplexity', link: perplexityPath },
35+
];
36+
}, [product, page.name, page.link, language]);
37+
38+
useEffect(() => {
39+
const abortController = new AbortController();
40+
let isMounted = true;
41+
42+
const fetchMarkdown = async () => {
43+
try {
44+
const response = await fetch(`${location.pathname}.md`, {
45+
signal: abortController.signal,
46+
headers: { Accept: 'text/markdown' },
47+
});
48+
49+
if (!isMounted) {
50+
return;
51+
}
52+
53+
if (!response.ok) {
54+
if (response.status === 404) {
55+
setMarkdownContent(null);
56+
return;
57+
}
58+
throw new Error(`Failed to fetch markdown: ${response.status} ${response.statusText}`);
59+
}
60+
61+
const contentType = response.headers.get('Content-Type')?.toLowerCase() || '';
62+
const isMarkdownType =
63+
contentType.includes('text/markdown') ||
64+
contentType.includes('application/markdown') ||
65+
contentType.includes('text/plain');
66+
67+
if (contentType && !isMarkdownType) {
68+
if (contentType.includes('text/html') || contentType.includes('application/json')) {
69+
throw new Error(`Received ${contentType} response instead of markdown for ${location.pathname}.md`);
70+
}
71+
console.warn(
72+
`Markdown fetch: unexpected content type "${contentType}" for ${location.pathname}.md, accepting anyway`,
73+
);
74+
}
75+
76+
const content = await response.text();
77+
if (isMounted) {
78+
setMarkdownContent(content);
79+
}
80+
} catch (error) {
81+
if (!isMounted || (error instanceof Error && error.name === 'AbortError')) {
82+
return;
83+
}
84+
const errorMessage = error instanceof Error ? error.message : String(error);
85+
if (!errorMessage.includes('404')) {
86+
console.error(`Failed to fetch markdown for ${location.pathname}:`, {
87+
error: errorMessage,
88+
path: `${location.pathname}.md`,
89+
errorType: error instanceof Error ? error.name : typeof error,
90+
});
91+
}
92+
setMarkdownContent(null);
93+
}
94+
};
95+
96+
fetchMarkdown();
97+
98+
return () => {
99+
isMounted = false;
100+
abortController.abort();
101+
};
102+
}, [location.pathname]);
103+
104+
const handleCopyMarkdown = useCallback(async () => {
105+
if (!markdownContent) {
106+
return;
107+
}
108+
109+
try {
110+
await navigator.clipboard.writeText(markdownContent);
111+
setCopyFeedback('Copied!');
112+
113+
track('markdown_copy_link_clicked', {
114+
location: location.pathname,
115+
});
116+
} catch (error) {
117+
console.error('Failed to copy markdown:', error);
118+
setCopyFeedback('Error!');
119+
}
120+
121+
setTimeout(() => setCopyFeedback(null), 2000);
122+
}, [markdownContent, location.pathname]);
123+
124+
return (
125+
<DropdownMenu.Root>
126+
<ButtonGroup>
127+
<button
128+
className={cn(secondaryButtonClassName, 'gap-1.5')}
129+
onClick={handleCopyMarkdown}
130+
disabled={!markdownContent}
131+
>
132+
<Icon name="icon-gui-square-2-stack-outline" size="16px" />
133+
<span>{copyFeedback ?? 'Copy for LLM'}</span>
134+
</button>
135+
<ButtonGroupSeparator />
136+
<DropdownMenu.Trigger asChild>
137+
<button className={cn(secondaryButtonClassName, 'px-2')}>
138+
<Icon name="icon-gui-chevron-down-solid" size="12px" />
139+
</button>
140+
</DropdownMenu.Trigger>
141+
</ButtonGroup>
142+
<DropdownMenu.Portal>
143+
<DropdownMenu.Content
144+
className="min-w-[220px] bg-neutral-000 dark:bg-neutral-1300 border border-neutral-300 dark:border-neutral-1000 rounded-lg ui-shadow-lg-medium p-1 z-50"
145+
sideOffset={5}
146+
align="end"
147+
>
148+
<DropdownMenu.Item className={menuItemClassName} asChild>
149+
<a
150+
href={`${location.pathname}.md`}
151+
target="_blank"
152+
rel="noopener noreferrer"
153+
onClick={() => {
154+
track('markdown_preview_link_clicked', {
155+
location: location.pathname,
156+
});
157+
}}
158+
>
159+
<Icon name="icon-gui-eye-outline" size="20px" />
160+
<span>View as markdown</span>
161+
</a>
162+
</DropdownMenu.Item>
163+
<DropdownMenu.Item
164+
className={cn(menuItemClassName, !markdownContent && 'opacity-50 pointer-events-none')}
165+
onSelect={(e) => {
166+
e.preventDefault();
167+
handleCopyMarkdown();
168+
}}
169+
>
170+
<Icon name="icon-gui-square-2-stack-outline" size="20px" />
171+
<span>{copyFeedback ?? 'Copy as markdown'}</span>
172+
</DropdownMenu.Item>
173+
<DropdownMenu.Separator className="h-px bg-neutral-300 dark:bg-neutral-1000 my-1" />
174+
{llmLinks.map(({ model, label, icon, link }) => (
175+
<DropdownMenu.Item key={model} className={menuItemClassName} asChild>
176+
<a
177+
href={link}
178+
target="_blank"
179+
rel="noopener noreferrer"
180+
className="justify-between"
181+
onClick={() => {
182+
track('llm_link_clicked', {
183+
model,
184+
location: location.pathname,
185+
link,
186+
});
187+
}}
188+
>
189+
<div className="flex items-center gap-2">
190+
<Icon name={icon as IconName} size="20px" />
191+
<span>{label}</span>
192+
</div>
193+
<Icon name="icon-gui-arrow-top-right-on-square-outline" size="16px" />
194+
</a>
195+
</DropdownMenu.Item>
196+
))}
197+
</DropdownMenu.Content>
198+
</DropdownMenu.Portal>
199+
</DropdownMenu.Root>
200+
);
201+
};
202+
203+
export default CopyForLLM;

0 commit comments

Comments
 (0)