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
31 changes: 31 additions & 0 deletions customHttp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,34 @@ customHeaders:
- key: 'Content-Security-Policy'
value: 'upgrade-insecure-requests;'
# CSP also set in _document.tsx meta tag
# Link headers advertise agent-discovery resources (RFC 8288 / RFC 9727):
# the API catalog, the agent skills index, the MCP server card, and the
# LLM-friendly documentation index.
#
# Deliberately kept on the global block. Amplify header patterns are
# positive-match only (no exclusion), and with trailingSlash: true the
# actual page requests are extensionless directory URLs (e.g.
# /react/build-a-backend/), so an `**/*.html` pattern would miss real
# page loads and drop the header where agents need it most. The header
# therefore also rides asset responses (a few extra bytes); the
# missing-file risk this could have amplified is eliminated because the
# generators now fail the build if a linked file is not written.
- key: 'Link'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Link header sits on the global **/* block, so it rides every response (HTML, images, JSON), not just discovery routes — bytes on every request and a wider blast radius for the missing-file cases above. Worth a conscious choice vs. scoping it to the relevant paths.

value: '</.well-known/api-catalog>; rel="api-catalog", </.well-known/agent-skills/index.json>; rel="agent-skills"; type="application/json", </.well-known/mcp/server-card.json>; rel="mcp-server"; type="application/json", </ai/llms.txt>; rel="service-doc"; type="text/plain"'
# Serve the API catalog as a linkset (RFC 9727 / RFC 9264) at both the
# canonical extensionless path (served via a 200-rewrite in redirects.json)
# and the underlying .json file.
- pattern: '/.well-known/api-catalog'
headers:
- key: 'Content-Type'
value: 'application/linkset+json'
- pattern: '/.well-known/api-catalog.json'
headers:
- key: 'Content-Type'
value: 'application/linkset+json'
# Serve the generated agent-facing markdown twins as markdown so agents that
# request them get the correct media type (these live under /ai/**).
- pattern: '/ai/**/*.md'
headers:
- key: 'Content-Type'
value: 'text/markdown; charset=utf-8'
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,5 @@
"overrides": {
"tmp": "^0.2.4"
},
"packageManager": "yarn@4.14.1"
"packageManager": "yarn@4.17.0"
}
5 changes: 5 additions & 0 deletions redirects.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
[
{
"source": "/.well-known/api-catalog",
"target": "/.well-known/api-catalog.json",
"status": "200"
},
{
"source": "/lib/ssr/ssr/q/platform/js/",
"target": "/gen1/javascript/prev/build-a-backend/server-side-rendering/",
Expand Down
22 changes: 20 additions & 2 deletions src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ import { getPageSection } from '@/utils/getPageSection';
import { PinpointEOLBanner } from '@/components/PinpointEOLBanner';
import { LexV1EOLBanner } from '../LexV1EOLBanner';
import { ApiModalProvider } from '../ApiDocs/ApiModalProvider';
import { MarkdownMenu } from '@/components/MarkdownMenu';
import { MarkdownMenu, getMarkdownUrl } from '@/components/MarkdownMenu';
import { WebMcp } from '@/components/WebMcp';

export const Layout = ({
children,
Expand Down Expand Up @@ -167,9 +168,17 @@ export const Layout = ({
};

const isOverview =
children?.props?.childPageNodes?.length != 'undefined' &&
typeof children?.props?.childPageNodes?.length !== 'undefined' &&
children?.props?.childPageNodes?.length > 0;

// Per-page markdown alternate for agent autodiscovery. Only Gen2 content
// pages have a generated /ai/pages/*.md twin — mirror the MarkdownMenu gate
// (skip Gen1, home, and overview pages) so we never advertise a missing file.
const markdownUrl =
!isGen1 && !isHome && !isOverview
? getMarkdownUrl(asPathWithNoHash)
: null;

const showNextPrev = NEXT_PREVIOUS_SECTIONS.some(
(section) =>
pathname.includes(section) &&
Expand Down Expand Up @@ -267,6 +276,14 @@ export const Layout = ({
<>
<Head>
<title>{`${title}`}</title>
{markdownUrl && (
<link
rel="alternate"
type="text/markdown"
href={markdownUrl}
key="markdown-alternate"
/>
)}
<meta property="og:title" content={title} key="og:title" />
<meta name="description" content={description} />
<meta
Expand Down Expand Up @@ -298,6 +315,7 @@ export const Layout = ({
key="twitter:image"
/>
</Head>
{markdownUrl && <WebMcp route={asPathWithNoHash} />}
<LayoutProvider
value={{
colorMode,
Expand Down
38 changes: 28 additions & 10 deletions src/components/MarkdownMenu/MarkdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,36 @@ interface MarkdownMenuProps {
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('/');
export function getMarkdownUrl(route: string): string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getMarkdownUrl doesn't strip the query string. usePathWithoutHash splits on # only, so /react/build-a-backend/auth/?foo=bar/ai/pages/build-a-backend/auth/?foo=bar.md (404). This PR now routes this function into three consumers (<link rel="alternate">, the WebMcp fetch, and the copy/open menu), so one bad URL propagates everywhere.

// Strip any query string / hash, then the platform prefix and trailing slash.
// e.g. /react/build-a-backend/auth/set-up-auth/?foo=bar#x
// → build-a-backend/auth/set-up-auth
const pathOnly = route.replace(/[?#].*$/, '');
const parts = pathOnly.replace(/^\//, '').replace(/\/$/, '').split('/');
const withoutPlatform = parts.slice(1).join('/');
return `/ai/pages/${withoutPlatform}.md`;
}

/**
* Fetch a page's generated Markdown, rejecting the SPA HTML fallback (e.g. a
* 404 page) that Amplify serves when the .md file is missing. Shared by the
* copy/open menu and the WebMCP tools so the fallback detection lives in one
* place.
*/
export async function fetchPageMarkdown(url: string): Promise<string> {
const response = await fetch(url, {
headers: { accept: 'text/markdown, text/plain' }
});
if (!response.ok) {
throw new Error(`Request for ${url} failed with status ${response.status}`);
}
const text = await response.text();
if (/^\s*<!doctype/i.test(text) || /^\s*<html/i.test(text)) {
throw new Error(`No markdown available at ${url}`);
}
return text;
}

export function MarkdownMenu({ route, isGen1, isHome, isOverview }: MarkdownMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [copied, setCopied] = useState(false);
Expand All @@ -27,17 +49,13 @@ export function MarkdownMenu({ route, isGen1, isHome, isOverview }: MarkdownMenu

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;
const text = await fetchPageMarkdown(mdUrl);
await navigator.clipboard.writeText(text);
setCopied(true);
setIsOpen(false);
copiedTimerRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Silently fail if clipboard not available
// Silently fail if the markdown is unavailable or clipboard is blocked
}
}, [mdUrl]);

Expand Down
66 changes: 63 additions & 3 deletions src/components/MarkdownMenu/__tests__/MarkdownMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
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';
import {
MarkdownMenu,
getMarkdownUrl,
fetchPageMarkdown
} from '../MarkdownMenu';

// Mock fetch and clipboard
const mockFetch = jest.fn();
Expand Down Expand Up @@ -75,7 +79,10 @@ describe('MarkdownMenu', () => {

await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
'/ai/pages/build-a-backend/auth/set-up-auth.md'
'/ai/pages/build-a-backend/auth/set-up-auth.md',
expect.objectContaining({
headers: expect.objectContaining({ accept: expect.any(String) })
})
);
});
expect(mockWriteText).toHaveBeenCalledWith(mdContent);
Expand Down Expand Up @@ -119,7 +126,10 @@ describe('MarkdownMenu', () => {

await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
'/ai/pages/build-a-backend/auth/set-up-auth.md'
'/ai/pages/build-a-backend/auth/set-up-auth.md',
expect.objectContaining({
headers: expect.objectContaining({ accept: expect.any(String) })
})
);
});
});
Expand Down Expand Up @@ -147,3 +157,53 @@ describe('MarkdownMenu', () => {
});
});
});

describe('getMarkdownUrl', () => {
it('maps a platform route to its markdown twin', () => {
expect(getMarkdownUrl('/react/build-a-backend/auth/set-up-auth/')).toBe(
'/ai/pages/build-a-backend/auth/set-up-auth.md'
);
});

it('strips a query string before building the URL', () => {
expect(
getMarkdownUrl('/react/build-a-backend/auth/set-up-auth/?foo=bar')
).toBe('/ai/pages/build-a-backend/auth/set-up-auth.md');
});

it('strips a hash fragment before building the URL', () => {
expect(
getMarkdownUrl('/react/build-a-backend/auth/set-up-auth/#section')
).toBe('/ai/pages/build-a-backend/auth/set-up-auth.md');
});
});

describe('fetchPageMarkdown', () => {
beforeEach(() => {
global.fetch = mockFetch;
mockFetch.mockReset();
});

it('returns the markdown text on a successful response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve('# Title\n\nBody')
});
await expect(fetchPageMarkdown('/ai/pages/x.md')).resolves.toBe(
'# Title\n\nBody'
);
});

it('throws when the response is not ok', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
await expect(fetchPageMarkdown('/ai/pages/missing.md')).rejects.toThrow();
});

it('throws when the SPA HTML fallback is served instead of markdown', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve('<!DOCTYPE html><html><body>404</body></html>')
});
await expect(fetchPageMarkdown('/ai/pages/x.md')).rejects.toThrow();
});
});
2 changes: 1 addition & 1 deletion src/components/MarkdownMenu/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { MarkdownMenu } from './MarkdownMenu';
export { MarkdownMenu, getMarkdownUrl, fetchPageMarkdown } from './MarkdownMenu';
113 changes: 113 additions & 0 deletions src/components/WebMcp/WebMcp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useEffect } from 'react';
import { getMarkdownUrl, fetchPageMarkdown } from '@/components/MarkdownMenu';

/**
* Minimal shape of the WebMCP API we depend on. The spec exposes
* `document.modelContext.registerTool`; some early implementations also alias
* it on `navigator.modelContext`. We feature-detect both and treat the API as
* entirely optional so this is a silent no-op in browsers without WebMCP.
*
* See https://webmachinelearning.github.io/webmcp/
*/
interface ModelContextTool {
name: string;
description: string;
inputSchema?: object;
annotations?: { readOnlyHint?: boolean };
execute: (input: Record<string, unknown>) => Promise<unknown>;
}

interface ModelContextLike {
registerTool: (
tool: ModelContextTool,
options?: { signal?: AbortSignal }
) => Promise<void>;
}

function getModelContext(): ModelContextLike | null {
if (typeof document !== 'undefined') {
const fromDoc = (document as unknown as { modelContext?: ModelContextLike })
.modelContext;
if (fromDoc?.registerTool) return fromDoc;
}
if (typeof navigator !== 'undefined') {
const fromNav = (navigator as unknown as { modelContext?: ModelContextLike })
.modelContext;
if (fromNav?.registerTool) return fromNav;
}
return null;
}

/**
* Register a single tool, isolating failures so one rejected registration
* (e.g. a transient duplicate-name error during abort/re-register on fast
* client-side navigation) can't prevent the others from registering.
*/
async function safeRegister(
modelContext: ModelContextLike,
tool: ModelContextTool,
signal: AbortSignal
): Promise<void> {
try {
await modelContext.registerTool(tool, { signal });
} catch {
// Registration is best-effort; ignore failures for this tool.
}
}

/**
* Registers read-only WebMCP tools that expose this documentation site's key
* actions to in-browser AI agents. Every tool is backed by content the site
* already generates (the per-page markdown twins under /ai/pages and the
* llms.txt index), so the tools return real data rather than stubs.
*
* Renders nothing; the registration happens as a side effect on mount and is
* torn down via an AbortSignal on unmount.
*/
export function WebMcp({ route }: { route: string }) {
useEffect(() => {
const modelContext = getModelContext();
if (!modelContext) return;

const controller = new AbortController();
const { signal } = controller;
const origin = window.location.origin;

// Each tool is registered independently so one failure can't block the rest.
safeRegister(
modelContext,
{
name: 'get_current_page_markdown',
description:
'Return the current AWS Amplify documentation page as clean Markdown, ideal for reading or summarizing without HTML chrome.',
inputSchema: { type: 'object', properties: {} },
annotations: { readOnlyHint: true },
execute: async () => {
const markdown = await fetchPageMarkdown(origin + getMarkdownUrl(route));
return { markdown };
}
},
signal
);

safeRegister(
modelContext,
{
name: 'get_documentation_index',
description:
'Return the AWS Amplify documentation index (llms.txt), a Markdown list of all documentation pages with descriptions and links to their Markdown versions.',
inputSchema: { type: 'object', properties: {} },
annotations: { readOnlyHint: true },
execute: async () => {
const index = await fetchPageMarkdown(origin + '/ai/llms.txt');
return { index };
}
},
signal
);

return () => controller.abort();
}, [route]);

return null;
}
Loading
Loading