Skip to content

Commit a957fb5

Browse files
authored
feat(http): add markdown content negotiation (#50)
1 parent 3cca9f8 commit a957fb5

3 files changed

Lines changed: 182 additions & 5 deletions

File tree

app/llms.mdx/[[...slug]]/route.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
11
import { notFound } from 'next/navigation';
22
import { type NextRequest, NextResponse } from 'next/server';
33
import { getLLMText } from '@/lib/get-llm-text';
4+
import { appendMarkdownVaryHeader } from '@/lib/markdown-negotiation';
45
import { source } from '@/lib/source';
56

67
export const revalidate = false;
78

9+
function getPage(slug?: string[]) {
10+
let page = source.getPage(slug);
11+
if (!page && slug?.[0] !== 'en') {
12+
page = source.getPage(['en', ...(slug ?? [])]);
13+
}
14+
15+
return page;
16+
}
17+
818
export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug?: string[] }> }) {
919
const { slug } = await params;
10-
const page = source.getPage(slug);
20+
const page = getPage(slug);
1121
if (!page) notFound();
1222

23+
const headers = new Headers({ 'Content-Type': 'text/markdown; charset=utf-8' });
24+
appendMarkdownVaryHeader(headers);
25+
1326
return new NextResponse(await getLLMText(page), {
14-
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
27+
headers,
1528
});
1629
}
1730

lib/markdown-negotiation.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
const MARKDOWN_ACCEPT_TYPES = new Set([
2+
'application/markdown',
3+
'text/markdown',
4+
'text/x-markdown',
5+
'text/vnd.daringfireball.markdown',
6+
]);
7+
8+
export const EXACT_MARKDOWN_USER_AGENTS = [
9+
'anthropic-ai',
10+
'chatgpt-user',
11+
'claudebot',
12+
'gptbot',
13+
'oai-searchbot',
14+
'perplexitybot',
15+
];
16+
17+
export const MARKDOWN_USER_AGENT_SUBSTRINGS = [
18+
'anthropic',
19+
'chatgpt',
20+
'claude',
21+
'copilot',
22+
'cursor',
23+
'gemini',
24+
'gptbot',
25+
'mistral',
26+
'oai-searchbot',
27+
'openai',
28+
'perplexity',
29+
];
30+
31+
const MARKDOWN_VARY_HEADERS = ['Accept', 'User-Agent'];
32+
33+
function acceptsMarkdownType(mediaType: string): boolean {
34+
return MARKDOWN_ACCEPT_TYPES.has(mediaType) || mediaType.endsWith('+markdown');
35+
}
36+
37+
function hasNonZeroQuality(params: string[]): boolean {
38+
const qualityParam = params.find((param) => param.toLowerCase().startsWith('q='));
39+
if (!qualityParam) return true;
40+
41+
const quality = Number.parseFloat(qualityParam.slice(2));
42+
return Number.isNaN(quality) || quality > 0;
43+
}
44+
45+
export function acceptsMarkdown(acceptHeader: string | null): boolean {
46+
if (!acceptHeader) return false;
47+
48+
return acceptHeader.split(',').some((entry) => {
49+
const [rawMediaType, ...rawParams] = entry.split(';').map((part) => part.trim());
50+
const mediaType = rawMediaType.toLowerCase();
51+
52+
return acceptsMarkdownType(mediaType) && hasNonZeroQuality(rawParams);
53+
});
54+
}
55+
56+
function normalizeUserAgent(userAgent: string): string {
57+
return userAgent.toLowerCase().replace(/\s+/g, ' ').trim();
58+
}
59+
60+
export function isMarkdownUserAgent(userAgentHeader: string | null): boolean {
61+
if (!userAgentHeader) return false;
62+
63+
const userAgent = normalizeUserAgent(userAgentHeader);
64+
65+
return (
66+
EXACT_MARKDOWN_USER_AGENTS.includes(userAgent) ||
67+
MARKDOWN_USER_AGENT_SUBSTRINGS.some((match) => userAgent.includes(match))
68+
);
69+
}
70+
71+
export function shouldServeMarkdown(headers: Headers): boolean {
72+
return acceptsMarkdown(headers.get('accept')) || isMarkdownUserAgent(headers.get('user-agent'));
73+
}
74+
75+
export function appendMarkdownVaryHeader(headers: Headers) {
76+
const existingValues = new Set(
77+
(headers.get('Vary') ?? '')
78+
.split(',')
79+
.map((value) => value.trim())
80+
.filter(Boolean),
81+
);
82+
83+
for (const value of MARKDOWN_VARY_HEADERS) {
84+
existingValues.add(value);
85+
}
86+
87+
headers.set('Vary', [...existingValues].join(', '));
88+
}

middleware.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,97 @@
11
import type { NextRequest } from 'next/server';
22
import { NextResponse } from 'next/server';
3+
import { appendMarkdownVaryHeader, shouldServeMarkdown } from '@/lib/markdown-negotiation';
4+
5+
const EXCLUDED_EXACT_PATHS = new Set([
6+
'/.well-known/llms.txt',
7+
'/favicon.ico',
8+
'/llms-full.txt',
9+
'/llms.txt',
10+
'/overview/llms-full.txt',
11+
'/robots.txt',
12+
'/sitemap.xml',
13+
]);
14+
15+
const EXCLUDED_PATH_PREFIXES = ['/_next', '/api', '/llms.mdx', '/og'];
16+
const EXCLUDED_ASSET_EXTENSIONS = new Set([
17+
'.avif',
18+
'.css',
19+
'.gif',
20+
'.ico',
21+
'.jpeg',
22+
'.jpg',
23+
'.js',
24+
'.json',
25+
'.map',
26+
'.otf',
27+
'.pdf',
28+
'.png',
29+
'.svg',
30+
'.ttf',
31+
'.txt',
32+
'.webp',
33+
'.woff',
34+
'.woff2',
35+
'.xml',
36+
'.zip',
37+
]);
338

439
function isProgrammaticClient(request: NextRequest): boolean {
540
// Browsers always send Sec-Fetch-Dest; curl/WebFetch/python-requests do not
641
return !request.headers.has('sec-fetch-dest');
742
}
843

44+
function matchesPathPrefix(pathname: string, prefix: string): boolean {
45+
return pathname === prefix || pathname.startsWith(`${prefix}/`);
46+
}
47+
48+
function hasExcludedAssetExtension(pathname: string): boolean {
49+
const extension = pathname.match(/\.[^./]+$/)?.[0]?.toLowerCase();
50+
return extension ? EXCLUDED_ASSET_EXTENSIONS.has(extension) : false;
51+
}
52+
53+
function isNegotiableDocsPath(pathname: string): boolean {
54+
if (EXCLUDED_EXACT_PATHS.has(pathname)) return false;
55+
if (EXCLUDED_PATH_PREFIXES.some((prefix) => matchesPathPrefix(pathname, prefix))) return false;
56+
57+
return !hasExcludedAssetExtension(pathname);
58+
}
59+
60+
function isNegotiableMethod(method: string): boolean {
61+
return method === 'GET' || method === 'HEAD';
62+
}
63+
64+
function withMarkdownVary(response: NextResponse): NextResponse {
65+
appendMarkdownVaryHeader(response.headers);
66+
return response;
67+
}
68+
969
export default function middleware(request: NextRequest) {
1070
const { pathname } = request.nextUrl;
1171

12-
if (pathname === '/' && isProgrammaticClient(request)) {
72+
if (!isNegotiableMethod(request.method)) {
73+
return NextResponse.next();
74+
}
75+
76+
const wantsMarkdown = shouldServeMarkdown(request.headers);
77+
78+
if (pathname === '/' && (wantsMarkdown || isProgrammaticClient(request))) {
1379
const redirectUrl = request.nextUrl.clone();
1480
redirectUrl.pathname = '/llms.txt';
15-
return NextResponse.redirect(redirectUrl);
81+
return withMarkdownVary(NextResponse.redirect(redirectUrl));
82+
}
83+
84+
if (!isNegotiableDocsPath(pathname)) {
85+
return NextResponse.next();
86+
}
87+
88+
if (wantsMarkdown) {
89+
const rewriteUrl = request.nextUrl.clone();
90+
rewriteUrl.pathname = `/llms.mdx${pathname}`;
91+
return withMarkdownVary(NextResponse.rewrite(rewriteUrl));
1692
}
1793

18-
return NextResponse.next();
94+
return withMarkdownVary(NextResponse.next());
1995
}
2096

2197
export const config = {

0 commit comments

Comments
 (0)