Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public/**/**/nextImageExportOptimizer/
public/next-image-export-optimizer-hashes.json
src/directory/directory.json
src/directory/flatDirectory.json
public/ai/
src/references/raw-references.json
.yarn/*

Expand Down
3 changes: 2 additions & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./client/www/next-build/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
"next-start": "next start --webpack",
"prepare": "husky install",
"analyze": "ANALYZE=true yarn next-build",
"prebuild": "node src/directory/generateDirectory.mjs && node src/directory/generateFlatDirectory.mjs",
"prebuild": "node src/directory/generateDirectory.mjs && node src/directory/generateFlatDirectory.mjs && node tasks/generate-llms-txt.mjs",
"lint": "next lint --webpack",
"clean-references": "node tasks/clean-references.mjs"
},
Expand Down
15 changes: 14 additions & 1 deletion src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { Gen1Banner } from '@/components/Gen1Banner';
import { PinpointEOLBanner } from '@/components/PinpointEOLBanner';
import { LexV1EOLBanner } from '../LexV1EOLBanner';
import { ApiModalProvider } from '../ApiDocs/ApiModalProvider';
import { MarkdownMenu } from '@/components/MarkdownMenu';

export const Layout = ({
children,
Expand Down Expand Up @@ -283,7 +284,19 @@ export const Layout = ({
) : null}
{shouldShowAIBanner ? <AIBanner /> : null}
{useCustomTitle ? null : (
<Heading level={1}>{pageTitle}</Heading>
<Flex
justifyContent="space-between"
alignItems="flex-start"
wrap="nowrap"
>
<Heading level={1}>{pageTitle}</Heading>
<MarkdownMenu
route={asPathWithNoHash}
isGen1={isGen1}
isHome={isHome}
isOverview={isOverview}
/>
</Flex>
)}
{(isGen1GettingStarted || isGen1HowAmplifyWorks) && (
<Gen1Banner currentPlatform={currentPlatform} />
Expand Down
194 changes: 194 additions & 0 deletions src/components/MarkdownMenu/MarkdownMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Button, Flex, Text } from '@aws-amplify/ui-react';

interface MarkdownMenuProps {
route: string;
isGen1?: boolean;
isHome?: boolean;
isOverview?: boolean;
}

function getMarkdownUrl(route: string): string {
// Strip platform prefix and trailing slash
// e.g. /react/build-a-backend/auth/set-up-auth/ → build-a-backend/auth/set-up-auth
const parts = route.replace(/^\//, '').replace(/\/$/, '').split('/');
const withoutPlatform = parts.slice(1).join('/');
return `/ai/pages/${withoutPlatform}.md`;
}

export function MarkdownMenu({ route, isGen1, isHome, isOverview }: MarkdownMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const shouldRender = !isGen1 && !isHome && !isOverview;
const mdUrl = getMarkdownUrl(route);

const handleCopy = useCallback(async () => {
try {
const response = await fetch(mdUrl);
if (!response.ok) return;
const text = await response.text();
// Guard against accidentally copying HTML (e.g. 404 page)
if (/^\s*<!doctype/i.test(text) || /^\s*<html/i.test(text)) return;
await navigator.clipboard.writeText(text);
setCopied(true);
setIsOpen(false);
copiedTimerRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Silently fail if clipboard not available
}
}, [mdUrl]);

const handleOpenMd = useCallback(() => {
window.open(mdUrl, '_blank');
setIsOpen(false);
}, [mdUrl]);

useEffect(() => {
return () => {
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
};
}, []);

useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);

const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
if (!isOpen) return;
const items = menuRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]'
);
if (!items?.length) return;
const focused = document.activeElement;
const index = Array.from(items).indexOf(focused as HTMLButtonElement);
if (event.key === 'ArrowDown') {
event.preventDefault();
items[index < items.length - 1 ? index + 1 : 0].focus();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
items[index > 0 ? index - 1 : items.length - 1].focus();
}
},
[isOpen]
);

if (!shouldRender) return null;

return (
<div ref={menuRef} className="markdown-menu" onKeyDown={handleKeyDown}>
<Button
size="small"
variation="link"
className="markdown-menu__button"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="true"
>
<Flex alignItems="center" gap="xs">
<span aria-hidden="true">✨</span>
<Text as="span" fontSize="small">
{copied ? 'Copied!' : 'Use with AI'}
</Text>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
aria-hidden="true"
className="markdown-menu__chevron"
>
<path
d="M2 4L5 7L8 4"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Flex>
</Button>
{isOpen && (
<div className="markdown-menu__dropdown" role="menu">
<button
className="markdown-menu__item"
onClick={handleCopy}
role="menuitem"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
aria-hidden="true"
>
<rect
x="4"
y="4"
width="8"
height="8"
rx="1"
stroke="currentColor"
strokeWidth="1.2"
/>
<path
d="M10 4V2.5C10 1.95 9.55 1.5 9 1.5H2.5C1.95 1.5 1.5 1.95 1.5 2.5V9C1.5 9.55 1.95 10 2.5 10H4"
stroke="currentColor"
strokeWidth="1.2"
/>
</svg>
Copy page MD
</button>
<button
className="markdown-menu__item"
onClick={handleOpenMd}
role="menuitem"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
aria-hidden="true"
>
<path
d="M10 1.5H12.5V4"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 7L12.5 1.5"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
<path
d="M6 2H2.5C1.95 2 1.5 2.45 1.5 3V11.5C1.5 12.05 1.95 12.5 2.5 12.5H11C11.55 12.5 12 12.05 12 11.5V8"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
Open this page as MD
</button>
</div>
)}
</div>
);
}
149 changes: 149 additions & 0 deletions src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import * as React from 'react';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MarkdownMenu } from '../MarkdownMenu';

// Mock fetch and clipboard
const mockFetch = jest.fn();
const mockWriteText = jest.fn();
const mockOpen = jest.fn();

beforeEach(() => {
global.fetch = mockFetch;
Object.assign(navigator, {
clipboard: { writeText: mockWriteText }
});
window.open = mockOpen;
mockFetch.mockReset();
mockWriteText.mockReset();
mockOpen.mockReset();
});

describe('MarkdownMenu', () => {
it('should render button with "Use with AI" label', () => {
render(
<MarkdownMenu route="/react/build-a-backend/auth/set-up-auth/" />
);
expect(screen.getByText('Use with AI')).toBeInTheDocument();
});

it('should not render on Gen1 pages', () => {
const { container } = render(
<MarkdownMenu route="/gen1/react/start/" isGen1={true} />
);
expect(container.innerHTML).toBe('');
});

it('should not render on home page', () => {
const { container } = render(
<MarkdownMenu route="/" isHome={true} />
);
expect(container.innerHTML).toBe('');
});

it('should show dropdown menu items on click', async () => {
render(
<MarkdownMenu route="/react/build-a-backend/auth/set-up-auth/" />
);

await act(async () => {
userEvent.click(screen.getByText('Use with AI'));
});

expect(screen.getByText('Copy page MD')).toBeVisible();
expect(screen.getByText('Open this page as MD')).toBeVisible();
});

it('should copy markdown to clipboard when "Copy page MD" is clicked', async () => {
const mdContent = '# Test Page\n\nSome content.';
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(mdContent)
});
mockWriteText.mockResolvedValueOnce(undefined);

render(
<MarkdownMenu route="/react/build-a-backend/auth/set-up-auth/" />
);

await act(async () => {
userEvent.click(screen.getByText('Use with AI'));
});
await act(async () => {
userEvent.click(screen.getByText('Copy page MD'));
});

await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
'/ai/pages/build-a-backend/auth/set-up-auth.md'
);
});
expect(mockWriteText).toHaveBeenCalledWith(mdContent);
});

it('should open markdown file in new tab when "Open this page as MD" is clicked', async () => {
render(
<MarkdownMenu route="/react/build-a-backend/auth/set-up-auth/" />
);

await act(async () => {
userEvent.click(screen.getByText('Use with AI'));
});
await act(async () => {
userEvent.click(screen.getByText('Open this page as MD'));
});

expect(mockOpen).toHaveBeenCalledWith(
'/ai/pages/build-a-backend/auth/set-up-auth.md',
'_blank'
);
});

it('should construct correct markdown URL from route', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve('content')
});
mockWriteText.mockResolvedValueOnce(undefined);

render(
<MarkdownMenu route="/react/build-a-backend/auth/set-up-auth/" />
);

await act(async () => {
userEvent.click(screen.getByText('Use with AI'));
});
await act(async () => {
userEvent.click(screen.getByText('Copy page MD'));
});

await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
'/ai/pages/build-a-backend/auth/set-up-auth.md'
);
});
});

it('should show "Copied!" feedback after copy', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve('content')
});
mockWriteText.mockResolvedValueOnce(undefined);

render(
<MarkdownMenu route="/react/build-a-backend/auth/set-up-auth/" />
);

await act(async () => {
userEvent.click(screen.getByText('Use with AI'));
});
await act(async () => {
userEvent.click(screen.getByText('Copy page MD'));
});

await waitFor(() => {
expect(screen.getByText('Copied!')).toBeInTheDocument();
});
});
});
Loading
Loading