Skip to content

Commit 8bfced2

Browse files
conico974Copilot
andauthored
Add negotiator for markdown acceptance in middleware (#4238)
Co-authored-by: Copilot <copilot@github.com>
1 parent 61ee4e3 commit 8bfced2

5 files changed

Lines changed: 97 additions & 1 deletion

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/gitbook/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"micromark-extension-frontmatter": "^2.0.0",
5757
"micromark-extension-gfm": "^3.0.0",
5858
"motion": "^12.23.24",
59+
"negotiator": "^1.0.0",
5960
"next": "^16.2.3",
6061
"next-themes": "^0.4.6",
6162
"nuqs": "^2.2.3",

packages/gitbook/src/middleware.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
SiteInsightsDisplayContext,
44
SiteInsightsLLMSVariant,
55
} from '@gitbook/api';
6-
import { acceptsMarkdown, isAIAgent } from '@vercel/agent-readability';
6+
import { isAIAgent } from '@vercel/agent-readability';
77
import { cookies } from 'next/headers';
88
import type { NextRequest } from 'next/server';
99
import { NextResponse } from 'next/server';
@@ -44,6 +44,7 @@ import {
4444
} from '@/lib/visitors';
4545
import { waitUntil } from '@/lib/waitUntil';
4646
import { serveResizedImage } from '@/routes/image';
47+
import Negotiator from 'negotiator';
4748
import {
4849
type ServerInsightsEventInput,
4950
serveProxyAnalyticsEvent,
@@ -498,6 +499,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
498499
response.headers.set('x-gitbook-route-type', routeType);
499500
response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol);
500501

502+
// AI related headers
503+
// This one is technically useless, but is used by a bunch of scoring systems
504+
response.headers.set(
505+
'vary',
506+
'rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, accept-encoding, accept'
507+
);
508+
501509
// When we use adaptive content, we want to ensure that the cache is not used at all on the client side.
502510
// Vercel already set this header, this is needed in OpenNext.
503511
if (siteURLData.contextId && !siteRequestURL.pathname.endsWith('~gitbook/site-index')) {
@@ -849,3 +857,20 @@ async function writeResponseCookies<R extends NextResponse>(
849857

850858
return response;
851859
}
860+
861+
function acceptsMarkdown(request: Request): boolean {
862+
const acceptHeader = request.headers.get('accept') || '';
863+
864+
const negotiator = new Negotiator({ headers: { accept: acceptHeader } });
865+
const mediaTypes = negotiator.mediaTypes();
866+
867+
// Media types are in order of preference, so we check if the client has markdown as one of its favorites,
868+
// but text/html and */* should take precedence.
869+
const markdownIndex = mediaTypes.findIndex(
870+
(type) => type === 'text/markdown' || type === 'text/x-markdown'
871+
);
872+
if (markdownIndex === -1) return false;
873+
874+
const htmlIndex = mediaTypes.findIndex((type) => type === 'text/html' || type === '*/*');
875+
return htmlIndex === -1 || markdownIndex < htmlIndex;
876+
}

packages/gitbook/src/routes/markdownPage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export async function serveMarkdown(fn: () => Promise<string>) {
126126
headers: {
127127
'Content-Type': 'text/markdown; charset=utf-8',
128128
'X-Robots-Tag': 'noindex',
129+
Vary: 'Accept',
129130
},
130131
});
131132
} catch (error) {
@@ -134,6 +135,7 @@ export async function serveMarkdown(fn: () => Promise<string>) {
134135
status: exposable.code,
135136
headers: {
136137
'Content-Type': 'text/plain; charset=utf-8',
138+
Vary: 'Accept',
137139
},
138140
});
139141
}

packages/gitbook/tests/markdown.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,73 @@ describe('markdown pages', () => {
105105
});
106106
});
107107

108+
describe('Accept header content negotiation', () => {
109+
const PAGE_URL = getContentTestURL(TEST_PAGE_URL);
110+
111+
it('should NOT serve markdown for Accept: text/html', async () => {
112+
const response = await fetch(PAGE_URL, {
113+
headers: { Accept: 'text/html' },
114+
});
115+
116+
expect(response.status).toBe(200);
117+
expect(response.headers.get('content-type')).toContain('text/html');
118+
});
119+
120+
it('should NOT serve markdown for Accept: */*', async () => {
121+
const response = await fetch(PAGE_URL, {
122+
headers: { Accept: '*/*' },
123+
});
124+
125+
expect(response.status).toBe(200);
126+
expect(response.headers.get('content-type')).toContain('text/html');
127+
});
128+
129+
it('should NOT serve markdown when text/html is preferred over text/markdown (Accept: text/html, text/markdown)', async () => {
130+
const response = await fetch(PAGE_URL, {
131+
headers: { Accept: 'text/html, text/markdown' },
132+
});
133+
134+
expect(response.status).toBe(200);
135+
expect(response.headers.get('content-type')).toContain('text/html');
136+
});
137+
138+
it('should serve markdown when text/markdown is preferred over text/html (Accept: text/markdown, text/html)', async () => {
139+
const response = await fetch(PAGE_URL, {
140+
headers: { Accept: 'text/markdown, text/html' },
141+
});
142+
143+
expect(response.status).toBe(200);
144+
expect(response.headers.get('content-type')).toContain('text/markdown');
145+
});
146+
147+
it('should serve markdown when text/markdown has a higher q-value (Accept: text/html;q=0.9, text/markdown)', async () => {
148+
const response = await fetch(PAGE_URL, {
149+
headers: { Accept: 'text/html;q=0.9, text/markdown' },
150+
});
151+
152+
expect(response.status).toBe(200);
153+
expect(response.headers.get('content-type')).toContain('text/markdown');
154+
});
155+
156+
it('should NOT serve markdown when text/markdown has a lower q-value (Accept: text/html, text/markdown;q=0.9)', async () => {
157+
const response = await fetch(PAGE_URL, {
158+
headers: { Accept: 'text/html, text/markdown;q=0.9' },
159+
});
160+
161+
expect(response.status).toBe(200);
162+
expect(response.headers.get('content-type')).toContain('text/html');
163+
});
164+
165+
it('should serve markdown for Accept: text/x-markdown', async () => {
166+
const response = await fetch(PAGE_URL, {
167+
headers: { Accept: 'text/x-markdown' },
168+
});
169+
170+
expect(response.status).toBe(200);
171+
expect(response.headers.get('content-type')).toContain('text/markdown');
172+
});
173+
});
174+
108175
describe('markdown ask responses', () => {
109176
const ASK_QUESTION = 'What is GitBook?';
110177
const ASK_QUESTION_HEADING = `# ${ASK_QUESTION}`;

0 commit comments

Comments
 (0)