Skip to content
2 changes: 1 addition & 1 deletion data/onCreatePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type LayoutOptions = {
const mdxWrapper = path.resolve('src/components/Layout/MDXWrapper.tsx');

const pageLayoutOptions: Record<string, LayoutOptions> = {
'/docs': { leftSidebar: true, rightSidebar: false, template: 'index', mdx: false },
'/docs': { leftSidebar: false, rightSidebar: false, template: 'index', mdx: false },
'/docs/api/control-api': {
leftSidebar: false,
rightSidebar: false,
Expand Down
2 changes: 1 addition & 1 deletion src/components/Layout/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const Breadcrumbs: React.FC = () => {
})();

return (
<nav aria-label="breadcrumb" className="flex mt-8 items-center gap-1">
<nav aria-label="breadcrumb" className="flex items-center gap-1 min-w-0">
{lastActiveNodeIndex === null && (
<Icon
name="icon-gui-chevron-left-micro"
Expand Down
122 changes: 122 additions & 0 deletions src/components/Layout/CopyForLLM.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React, { ReactNode } from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import CopyForLLM from './CopyForLLM';

const mockUseLayoutContext = jest.fn(() => ({
activePage: {
language: 'javascript',
languages: ['javascript'],
product: 'pubsub',
page: {
name: 'Test Page',
link: '/docs/test-page',
},
tree: [],
template: 'mdx' as const,
},
}));

jest.mock('src/contexts/layout-context', () => ({
useLayoutContext: () => mockUseLayoutContext(),
}));

jest.mock('@reach/router', () => ({
useLocation: () => ({ pathname: '/docs/test-page' }),
}));

jest.mock('@ably/ui/core/Icon', () => ({
__esModule: true,
default: ({ name }: { name: string }) => <span data-testid={`icon-${name}`}>{name}</span>,
}));

jest.mock('@ably/ui/core/insights', () => ({
track: jest.fn(),
}));

// Mock Radix DropdownMenu to render content directly
jest.mock('@radix-ui/react-dropdown-menu', () => ({
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Trigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <div>{children}</div>,
Portal: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Content: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Item: ({ children, onSelect, ...props }: { children: ReactNode; onSelect?: (e: Event) => void; asChild?: boolean }) =>
props.asChild ? <>{children}</> : <div onClick={() => onSelect?.({ preventDefault: () => {} } as unknown as Event)}>{children}</div>,
Separator: () => <hr />,
}));

describe('CopyForLLM', () => {
beforeEach(() => {
jest.clearAllMocks();
});

afterEach(() => {
jest.restoreAllMocks();
jest.clearAllTimers();
});

it('renders the dropdown trigger button', () => {
global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response));

render(<CopyForLLM />);
expect(screen.getByText('Copy for LLM')).toBeInTheDocument();
});

it('renders LLM links for ChatGPT, Claude and Perplexity', () => {
global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response));

render(<CopyForLLM />);
expect(screen.getByText('Open in ChatGPT')).toBeInTheDocument();
expect(screen.getByText('Open in Claude')).toBeInTheDocument();
expect(screen.getByText('Open in Perplexity')).toBeInTheDocument();
});

it('shows markdown items when content is available', async () => {
const mockMarkdown = '# Test content';

global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
headers: {
get: (name: string) => (name === 'Content-Type' ? 'text/markdown' : null),
},
text: () => Promise.resolve(mockMarkdown),
} as Response),
);

const mockWriteText = jest.fn();
Object.assign(navigator, { clipboard: { writeText: mockWriteText } });

jest.useFakeTimers();

render(<CopyForLLM />);

await screen.findByText('View as markdown');
expect(screen.getByText('Copy as markdown')).toBeInTheDocument();

// Click copy
const copyItem = screen.getByText('Copy as markdown').closest('div');
if (copyItem) {
act(() => {
fireEvent.click(copyItem);
});
}

expect(mockWriteText).toHaveBeenCalledWith(mockMarkdown);

act(() => {
jest.runOnlyPendingTimers();
});
jest.useRealTimers();
});

it('does not show markdown items when fetch fails', async () => {
global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response));

render(<CopyForLLM />);

await new Promise<void>((resolve) => setTimeout(resolve, 50));

expect(screen.queryByText('View as markdown')).not.toBeInTheDocument();
expect(screen.queryByText('Copy as markdown')).not.toBeInTheDocument();
});
});
189 changes: 189 additions & 0 deletions src/components/Layout/CopyForLLM.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from '@reach/router';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import Icon from '@ably/ui/core/Icon';
import { IconName } from '@ably/ui/core/Icon/types';
import { track } from '@ably/ui/core/insights';
import { productData } from 'src/data';
import { languageInfo } from 'src/data/languages';
import { useLayoutContext } from 'src/contexts/layout-context';

const menuItemClassName =
'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';

const CopyForLLM: React.FC = () => {
const { activePage } = useLayoutContext();
const { language, product, page } = activePage;
const location = useLocation();
const [markdownContent, setMarkdownContent] = useState<string | null>(null);
const [copyFeedback, setCopyFeedback] = useState<string | null>(null);

const llmLinks = useMemo(() => {
const docUrl = `https://ably.com${page.link}.md`;
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}` : ''}`;
const gptPath = `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`;
const claudePath = `https://claude.ai/new?q=${encodeURIComponent(prompt)}`;
const perplexityPath = `https://www.perplexity.ai/?q=${encodeURIComponent(prompt)}`;

return [
{ model: 'gpt', label: 'Open in ChatGPT', icon: 'icon-tech-openai', link: gptPath },
{ model: 'claude', label: 'Open in Claude', icon: 'icon-tech-claude-mono', link: claudePath },
{ model: 'perplexity', label: 'Open in Perplexity', icon: 'icon-tech-perplexity', link: perplexityPath },
];
}, [product, page.name, page.link, language]);

useEffect(() => {
const abortController = new AbortController();
let isMounted = true;

const fetchMarkdown = async () => {
try {
const response = await fetch(`${location.pathname}.md`, {
signal: abortController.signal,
headers: { Accept: 'text/markdown' },
});

if (!isMounted) return;

if (!response.ok) {
if (response.status === 404) {
setMarkdownContent(null);
return;
}
throw new Error(`Failed to fetch markdown: ${response.status} ${response.statusText}`);
}

const contentType = response.headers.get('Content-Type')?.toLowerCase() || '';
const isMarkdownType =
contentType.includes('text/markdown') ||
contentType.includes('application/markdown') ||
contentType.includes('text/plain');

if (contentType && !isMarkdownType) {
if (contentType.includes('text/html') || contentType.includes('application/json')) {
throw new Error(`Received ${contentType} response instead of markdown for ${location.pathname}.md`);
}
console.warn(
`Markdown fetch: unexpected content type "${contentType}" for ${location.pathname}.md, accepting anyway`,
);
}

const content = await response.text();
if (isMounted) {
setMarkdownContent(content);
}
} catch (error) {
if (!isMounted || (error instanceof Error && error.name === 'AbortError')) return;
const errorMessage = error instanceof Error ? error.message : String(error);
if (!errorMessage.includes('404')) {
console.error(`Failed to fetch markdown for ${location.pathname}:`, {
error: errorMessage,
path: `${location.pathname}.md`,
errorType: error instanceof Error ? error.name : typeof error,
});
}
setMarkdownContent(null);
}
};

fetchMarkdown();

return () => {
isMounted = false;
abortController.abort();
};
}, [location.pathname]);

const handleCopyMarkdown = useCallback(() => {
if (!markdownContent) return;

try {
navigator.clipboard.writeText(markdownContent);
setCopyFeedback('Copied!');
setTimeout(() => setCopyFeedback(null), 2000);

track('markdown_copy_link_clicked', {
location: location.pathname,
});
} catch (error) {
console.error('Failed to copy markdown:', error);
setCopyFeedback('Error!');
setTimeout(() => setCopyFeedback(null), 2000);
}
}, [markdownContent, location.pathname]);

return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="flex items-center gap-1.5 ui-text-label4 font-semibold text-neutral-900 dark:text-neutral-400 hover:text-neutral-1300 dark:hover:text-neutral-000 focus-base rounded px-2 py-1 transition-colors cursor-pointer">
<Icon name="icon-gui-document-text-outline" size="16px" />
<span>Copy for LLM</span>
<Icon name="icon-gui-chevron-down-micro" size="16px" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
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"
sideOffset={5}
align="start"
>
{markdownContent && (
<>
<DropdownMenu.Item className={menuItemClassName} asChild>
<a
href={`${location.pathname}.md`}
target="_blank"
rel="noopener noreferrer"
onClick={() => {
track('markdown_preview_link_clicked', {
location: location.pathname,
});
}}
>
<Icon name="icon-gui-eye-outline" size="20px" />
<span>View as markdown</span>
</a>
</DropdownMenu.Item>
<DropdownMenu.Item
className={menuItemClassName}
onSelect={(e) => {
e.preventDefault();
handleCopyMarkdown();
}}
>
<Icon name="icon-gui-square-2-stack-outline" size="20px" />
<span>{copyFeedback ?? 'Copy as markdown'}</span>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-neutral-300 dark:bg-neutral-1000 my-1" />
</>
)}
{llmLinks.map(({ model, label, icon, link }) => (
<DropdownMenu.Item key={model} className={menuItemClassName} asChild>
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="justify-between"
onClick={() => {
track('llm_link_clicked', {
model,
location: location.pathname,
link,
});
}}
>
<div className="flex items-center gap-2">
<Icon name={icon as IconName} size="20px" />
<span>{label}</span>
</div>
<Icon name="icon-gui-arrow-top-right-on-square-outline" size="16px" />
</a>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

export default CopyForLLM;
Loading