Files
Rich Haines eafa640057 Add AI agent detection and automatic markdown rewrites (#351)
* Add AI agent detection and automatic markdown rewrites

When AI agents (Claude, ChatGPT, Cursor, etc.) request docs pages,
the proxy now detects them and transparently rewrites to the markdown
route — matching the geistdocs template default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: Missing `detectionMethod` property in `TrackMdRequestParams` type causes TypeScript compilation error and build failure.

This commit fixes the issue reported at apps/docs/lib/geistdocs/md-tracking.ts:24

**Bug explanation:**

The `trackMdRequest` function in `apps/docs/lib/geistdocs/md-tracking.ts` destructures `detectionMethod` from its parameter (line 24) and includes it in the JSON body sent to the tracking endpoint (line 38). However, the `TrackMdRequestParams` type definition (lines 6-12) does not include `detectionMethod` as a property.

In `apps/docs/proxy.ts` (line 82-87), `trackMdRequest` is called with `detectionMethod: agentResult.method` when an AI agent is detected, where `agentResult.method` is of type `DetectionMethod | null` from `@/lib/ai-agent-detection`.

This causes an exact TypeScript compilation error confirmed in the build logs:
```
./lib/geistdocs/md-tracking.ts:24:3
Type error: Property 'detectionMethod' does not exist on type 'TrackMdRequestParams'.
```

This error causes the entire `docs` build to fail (`next build` exits with code 1).

**Fix explanation:**

Added `detectionMethod?: DetectionMethod | null` as an optional property to the `TrackMdRequestParams` type, and added the corresponding `import type { DetectionMethod } from "@/lib/ai-agent-detection"` import at the top of the file. The property is optional (`?`) because most call sites (for `.md` URL tracking, `llms.txt` tracking, and header-negotiated tracking) don't pass a `detectionMethod` — only the agent-rewrite tracking path does. The `| null` union matches the `DetectionResult.method` type from `ai-agent-detection.ts`.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: molebox <hello@richardhaines.dev>

* format

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: Dominik Ferber <dominik.ferber@gmail.com>
2026-03-29 02:19:37 +00:00

125 lines
3.8 KiB
TypeScript

import { precompute } from "flags/next";
import { createI18nMiddleware } from "fumadocs-core/i18n/middleware";
import { isMarkdownPreferred, rewritePath } from "fumadocs-core/negotiation";
import {
type NextFetchEvent,
type NextRequest,
NextResponse,
} from "next/server";
import { rootFlags } from "@/flags";
import { i18n } from "@/lib/geistdocs/i18n";
import { isAIAgent } from "@/lib/ai-agent-detection";
import { trackMdRequest } from "@/lib/geistdocs/md-tracking";
const { rewrite: rewriteLLM } = rewritePath(
"/docs/*path",
`/${i18n.defaultLanguage}/llms.mdx/*path`
);
const internationalizer = createI18nMiddleware(i18n);
const proxy = async (request: NextRequest, context: NextFetchEvent) => {
const pathname = request.nextUrl.pathname;
// Precompute flags and rewrite homepage
if (pathname === "/") {
const code = await precompute(rootFlags);
return NextResponse.rewrite(new URL(`/${i18n.defaultLanguage}/home/${code}`, request.url));
}
// Track llms.txt requests
if (pathname === "/llms.txt") {
context.waitUntil(
trackMdRequest({
path: "/llms.txt",
userAgent: request.headers.get("user-agent"),
referer: request.headers.get("referer"),
acceptHeader: request.headers.get("accept"),
})
);
}
// Handle .md/.mdx URL requests before i18n runs
if (
(pathname === "/docs.md" ||
pathname === "/docs.mdx" ||
pathname.startsWith("/docs/")) &&
(pathname.endsWith(".md") || pathname.endsWith(".mdx"))
) {
const stripped = pathname.replace(/\.mdx?$/, "");
const result =
stripped === "/docs"
? `/${i18n.defaultLanguage}/llms.mdx`
: rewriteLLM(stripped);
if (result) {
context.waitUntil(
trackMdRequest({
path: pathname,
userAgent: request.headers.get("user-agent"),
referer: request.headers.get("referer"),
acceptHeader: request.headers.get("accept"),
})
);
return NextResponse.rewrite(new URL(result, request.nextUrl));
}
}
// AI agent detection — rewrite docs pages to markdown for agents
// so they always get structured content without needing .md URLs or Accept headers
if (
(pathname === "/docs" || pathname.startsWith("/docs/")) &&
!pathname.includes("/llms.mdx/")
) {
const agentResult = isAIAgent(request);
if (agentResult.detected && !isMarkdownPreferred(request)) {
const result =
pathname === "/docs"
? `/${i18n.defaultLanguage}/llms.mdx`
: rewriteLLM(pathname);
if (result) {
context.waitUntil(
trackMdRequest({
path: pathname,
userAgent: request.headers.get("user-agent"),
referer: request.headers.get("referer"),
acceptHeader: request.headers.get("accept"),
requestType: "agent-rewrite",
detectionMethod: agentResult.method,
})
);
return NextResponse.rewrite(new URL(result, request.nextUrl));
}
}
}
// Handle Accept header content negotiation and track the request
if (isMarkdownPreferred(request)) {
const result = rewriteLLM(pathname);
if (result) {
context.waitUntil(
trackMdRequest({
path: pathname,
userAgent: request.headers.get("user-agent"),
referer: request.headers.get("referer"),
acceptHeader: request.headers.get("accept"),
requestType: "header-negotiated",
})
);
return NextResponse.rewrite(new URL(result, request.nextUrl));
}
}
// Fallback to i18n middleware
return internationalizer(request, context);
};
export const config = {
// Matcher ignoring `/_next/`, `/api/`, static assets, favicon, sitemap, robots, etc.
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|\\.well-known/vercel/flags).*)",
],
};
export default proxy;