Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 6 additions & 6 deletions examples/next/app/docs/getting-started/agent-ready-docs/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ packages/fumadocs/src/docs-api.ts
```ts
"/docs.md" -> "/api/docs?format=markdown"
"/docs/:slug*.md" -> "/api/docs?format=markdown&path=:slug*"
"/docs" + Signature-Agent -> "/api/docs/markdown"
"/docs/:slug*" + Signature-Agent -> "/api/docs/markdown/:slug*"
"/docs" + Signature-Agent -> "/api/docs?format=markdown"
"/docs/:slug*" + Signature-Agent -> "/api/docs?format=markdown&path=:slug*"
```

For this page:
Expand Down Expand Up @@ -74,16 +74,16 @@ Implementation contract:

- detect any non-empty `Signature-Agent` header
- only apply the markdown response under the configured docs entry route
- preserve request headers when forwarding through the generated markdown bridge route
- derive `path` from the canonical docs slug and call `/api/docs` with `format=markdown`
- rewrite to the existing `/api/docs` handler with `format=markdown`
- derive `path` from the canonical docs slug without generating another API wrapper
- keep normal browser requests on the HTML page

## Implementation Notes

- `contentDir` must fall back to `app/${entry}` in the Next example
- `withDocs()` should auto-generate the markdown bridge route and rewrites
- `withDocs()` should auto-generate the existing docs API route and markdown rewrites
- the existing `/api/docs` GET handler should own markdown mode
- `Signature-Agent` rewrites should target the generated markdown bridge route, not a `.md` URL
- `Signature-Agent` rewrites should target `/api/docs?format=markdown`, not a separate wrapper
- normalize slug paths before matching page URLs
- use the shared docs page source for fallback markdown, so standard pages do not need extra setup
- fall back to normal page markdown when a page does not have `agent.md`
Expand Down
24 changes: 8 additions & 16 deletions examples/next/app/docs/getting-started/agent-ready-docs/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ if (markdownRequest) {

Some agents fetch the canonical page URL and identify themselves with `Signature-Agent` instead of
sending `Accept: text/markdown`. `withDocs()` handles that case automatically: the normal HTML URL
continues to work for browsers, while requests with the header are rewritten to a generated markdown
bridge route.
continues to work for browsers, while requests with the header are rewritten to the existing
`/api/docs` handler with `format=markdown`.

```bash title="terminal"
curl http://localhost:3000/docs/getting-started/agent-ready-docs \
Expand All @@ -131,21 +131,13 @@ curl http://localhost:3000/docs/getting-started/agent-ready-docs \
{
source: `/${entry}/:slug*`,
has: [{ type: "header", key: "signature-agent" }],
destination: "/api/docs/markdown/:slug*",
destination: "/api/docs?format=markdown&path=:slug*",
}
```

The bridge route converts the slug back into the shared markdown API shape:

```ts title="app/api/docs/markdown/[[...slug]]/route.ts"
url.searchParams.set("format", "markdown");
if (slug) url.searchParams.set("path", slug);

return docsApi.GET(new Request(url.toString(), request));
```

No docs config flag is required. The header only changes docs page responses under the configured
entry route, so unrelated pages are not hijacked.
entry route, so unrelated pages are not hijacked. No extra API wrapper is generated; the existing
`app/api/docs/route.ts` remains the single docs API entry point.

## MCP Route

Expand Down Expand Up @@ -204,9 +196,9 @@ adapters that pass the public URL through, then resolves the requested slug and
page has `agentRawContent`.

When a request comes in for `/docs/getting-started/agent-ready-docs` with `Signature-Agent`,
`withDocs()` routes it through the generated markdown bridge route. That route preserves the request
headers, sets `format=markdown`, derives `path` from the current docs slug, and calls the same
`/api/docs` markdown handler.
`withDocs()` rewrites it into the existing `/api/docs` handler with `format=markdown` and the
current docs slug as `path`. The same generated `app/api/docs/route.ts` handles search, markdown,
llms.txt, skill, sitemap, feedback, and agent discovery formats.

Because this page has a sibling `agent.md`, the response becomes the raw contents of that file. If
another page does not have `agent.md`, the same route falls back to the normal page markdown.
Expand Down
2 changes: 1 addition & 1 deletion examples/next/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next-build/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
22 changes: 15 additions & 7 deletions packages/astro/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
normalizeDocsRelated,
performDocsSearch,
renderDocsMarkdownDocument,
renderDocsMarkdownNotFound,
renderDocsSkillDocument,
readDocsSitemapManifestFromContentMap,
stripGeneratedAgentProvenance,
Expand Down Expand Up @@ -900,14 +901,21 @@ export function createDocsServer(config: Record<string, any> = {}): DocsServer {
const varyHeader = getDocsMarkdownVaryHeader(context.request);

if (!document) {
return new Response("Not Found", {
status: 404,
headers: {
"Content-Type": "text/plain; charset=utf-8",
...(varyHeader ? { Vary: varyHeader } : {}),
"X-Robots-Tag": "noindex",
return new Response(
renderDocsMarkdownNotFound({
entry,
requestedPath: markdownRequest.requestedPath,
sitemap: config.sitemap,
}),
{
status: 404,
headers: {
"Content-Type": "text/markdown; charset=utf-8",
...(varyHeader ? { Vary: varyHeader } : {}),
"X-Robots-Tag": "noindex",
},
},
});
);
}

return new Response(document, {
Expand Down
18 changes: 18 additions & 0 deletions packages/docs/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isDocsPublicGetRequest,
isDocsSkillRequest,
renderDocsMarkdownDocument,
renderDocsMarkdownNotFound,
renderDocsSkillDocument,
resolveDocsAgentMdxContent,
resolveDocsLlmsTxtFormat,
Expand Down Expand Up @@ -188,6 +189,23 @@ describe("agent route helpers", () => {
);
});

it("renders recovery links for markdown 404 responses", () => {
const document = renderDocsMarkdownNotFound({
entry: "docs",
requestedPath: "missing/page",
sitemap: { routePrefix: "/docs-map" },
});

expect(document).toContain("# Docs Page Not Found");
expect(document).toContain("`/docs/missing/page.md`");
expect(document).toContain("`/.well-known/agent.json`");
expect(document).toContain("`/api/docs?query={query}`");
expect(document).toContain("`/api/docs?format=markdown&path=missing/page`");
expect(document).toContain("`/docs-map/sitemap.md`");
expect(document).toContain("`/docs-map/.well-known/sitemap.md`");
expect(document).toContain("`/docs-map/sitemap.xml`");
});

it("renders agent-specific markdown documents", () => {
const human = resolveDocsAgentMdxContent("Visible\n\n<Agent>\nHidden\n</Agent>", "human");
const agent = resolveDocsAgentMdxContent("Visible\n\n<Agent>\nHidden\n</Agent>", "agent");
Expand Down
62 changes: 62 additions & 0 deletions packages/docs/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ export interface DocsMarkdownPage {
agentFallbackRawContent?: string;
}

export interface DocsMarkdownNotFoundOptions {
entry?: string;
requestedPath: string;
sitemap?: boolean | DocsSitemapConfig;
}

export function normalizeDocsPathSegment(value: string): string {
return value.replace(/^\/+|\/+$/g, "");
}
Expand Down Expand Up @@ -230,6 +236,62 @@ export function getDocsMarkdownVaryHeader(request: Request): string | null {
return acceptsMarkdown(request) ? "Accept" : null;
}

export function renderDocsMarkdownNotFound({
entry = "docs",
requestedPath,
sitemap,
}: DocsMarkdownNotFoundOptions): string {
const normalizedEntry = normalizeDocsPathSegment(entry) || "docs";
const normalizedRequest = normalizeRequestedMarkdownPath(normalizedEntry, requestedPath);
const slugPrefix = `/${normalizedEntry}/`;
const requestedSlug =
normalizedRequest === `/${normalizedEntry}` ? "" : normalizedRequest.slice(slugPrefix.length);
const encodedRequestedSlug = requestedSlug.split("/").map(encodeURIComponent).join("/");
const requestedMarkdownRoute = toDocsMarkdownUrl(normalizedRequest);
const requestedApiRoute = requestedSlug
? `${DEFAULT_DOCS_API_ROUTE}?format=markdown&path=${encodedRequestedSlug}`
: `${DEFAULT_DOCS_API_ROUTE}?format=markdown`;
const sitemapConfig = resolveDocsSitemapConfig(sitemap);
const lines = [
"# Docs Page Not Found",
"",
`Could not find a markdown page for \`${requestedMarkdownRoute}\`.`,
"",
"Use these discovery routes to find the right page:",
"",
`- Agent discovery spec: \`${DEFAULT_AGENT_SPEC_WELL_KNOWN_JSON_ROUTE}\``,
`- Agent discovery fallback: \`${DEFAULT_AGENT_SPEC_WELL_KNOWN_ROUTE}\``,
`- Agent discovery API: \`${DEFAULT_AGENT_SPEC_ROUTE}\``,
`- Search endpoint: \`${DEFAULT_DOCS_API_ROUTE}?query={query}\``,
`- Docs index markdown: \`/${normalizedEntry}.md\``,
`- Requested markdown API route: \`${requestedApiRoute}\``,
];

if (sitemapConfig.enabled) {
if (sitemapConfig.markdown.enabled) {
lines.push(`- Semantic sitemap: \`${sitemapConfig.markdown.route}\``);
lines.push(
`- Semantic sitemap well-known alias: \`${sitemapConfig.markdown.wellKnownRoute}\``,
);
}

if (sitemapConfig.xml.enabled) {
lines.push(`- XML sitemap: \`${sitemapConfig.xml.route}\``);
}
} else {
lines.push(
`- Sitemap discovery, if enabled: \`${DEFAULT_SITEMAP_MD_ROUTE}\`, \`${DEFAULT_SITEMAP_MD_WELL_KNOWN_ROUTE}\`, or \`${DEFAULT_SITEMAP_XML_ROUTE}\``,
);
}

lines.push(
"",
"The agent discovery spec is the safest first step because it lists the active markdown, sitemap, search, MCP, and feedback routes for this deployment.",
);

return lines.join("\n");
}

export function findDocsMarkdownPage<T extends DocsMarkdownPage>(
entry: string,
pages: T[],
Expand Down
1 change: 1 addition & 0 deletions packages/docs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export {
normalizeDocsPathSegment,
normalizeDocsUrlPath,
renderDocsMarkdownDocument,
renderDocsMarkdownNotFound,
renderDocsSkillDocument,
resolveDocsAgentMdxContent,
resolveDocsLlmsTxtFormat,
Expand Down
8 changes: 7 additions & 1 deletion packages/fumadocs/src/docs-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1284,7 +1284,13 @@ title: "Home"
new Request("http://localhost/api/docs?format=markdown&path=missing"),
);
expect(response.status).toBe(404);
expect(await response.text()).toBe("Not Found");
expect(response.headers.get("content-type")).toContain("text/markdown");
const notFoundDocument = await response.text();
expect(notFoundDocument).toContain("# Docs Page Not Found");
expect(notFoundDocument).toContain("`/docs/missing.md`");
expect(notFoundDocument).toContain("`/.well-known/agent.json`");
expect(notFoundDocument).toContain("`/api/docs?query={query}`");
expect(notFoundDocument).toContain("`/sitemap.md`");
});

it("serves the agent discovery spec through the shared docs api handler", async () => {
Expand Down
22 changes: 15 additions & 7 deletions packages/fumadocs/src/docs-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
emitDocsAgentTraceEvent,
emitDocsAnalyticsEvent,
getDocsMarkdownVaryHeader,
renderDocsMarkdownNotFound,
resolveDocsI18n,
resolveDocsLocale,
resolvePageSidebarFolderIndexBehavior,
Expand Down Expand Up @@ -2718,14 +2719,21 @@ export function createDocsAPI(options?: DocsAPIOptions) {
found: false,
},
});
return new Response("Not Found", {
status: 404,
headers: {
"Content-Type": "text/plain; charset=utf-8",
...(varyHeader ? { Vary: varyHeader } : {}),
"X-Robots-Tag": "noindex",
return new Response(
renderDocsMarkdownNotFound({
entry,
requestedPath: markdownRequest.requestedPath,
sitemap: sitemapConfig,
}),
{
status: 404,
headers: {
"Content-Type": "text/markdown; charset=utf-8",
...(varyHeader ? { Vary: varyHeader } : {}),
"X-Robots-Tag": "noindex",
},
},
});
);
}

await emitDocsAnalyticsEvent(analytics, {
Expand Down
18 changes: 4 additions & 14 deletions packages/next/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,11 +278,7 @@ describe("withDocs (app dir: src/app vs app)", () => {

const nextConfig = withDocs({});

const markdownRoute = join(tmpDir, "app/api/docs/markdown/[[...slug]]/route.ts");
expect(existsSync(markdownRoute)).toBe(true);
expect(readFileSync(markdownRoute, "utf-8")).toContain(
'url.searchParams.set("format", "markdown")',
);
expect(existsSync(join(tmpDir, "app/api/docs/markdown/[[...slug]]/route.ts"))).toBe(false);

const rewrites = getBeforeFilesRewrites(await readRewrites(nextConfig));

Expand Down Expand Up @@ -353,12 +349,12 @@ describe("withDocs (app dir: src/app vs app)", () => {
expect.objectContaining({
source: "/docs",
has: [MARKDOWN_SIGNATURE_AGENT_HEADER],
destination: "/api/docs/markdown",
destination: "/api/docs?format=markdown",
}),
expect.objectContaining({
source: "/docs/:slug*",
has: [MARKDOWN_SIGNATURE_AGENT_HEADER],
destination: "/api/docs/markdown/:slug*",
destination: "/api/docs?format=markdown&path=:slug*",
}),
]),
);
Expand Down Expand Up @@ -633,12 +629,6 @@ describe("withDocs (app dir: src/app vs app)", () => {

expect(nextConfig.outputFileTracingIncludes).toMatchObject({
"/api/docs": ["app/docs/**/*", "skill.md", ".farming-labs/sitemap-manifest.json"],
"/api/docs/markdown": ["app/docs/**/*", "skill.md", ".farming-labs/sitemap-manifest.json"],
"/api/docs/markdown/:path*": [
"app/docs/**/*",
"skill.md",
".farming-labs/sitemap-manifest.json",
],
"/api/docs/mcp": ["app/docs/**/*"],
});
});
Expand Down Expand Up @@ -735,7 +725,7 @@ describe("withDocs (app dir: src/app vs app)", () => {
expect.objectContaining({
source: "/docs/:slug*",
has: [MARKDOWN_SIGNATURE_AGENT_HEADER],
destination: "/api/docs/markdown/:slug*",
destination: "/api/docs?format=markdown&path=:slug*",
}),
]),
);
Expand Down
Loading
Loading