Skip to content

Commit 2e3c5c2

Browse files
heiskrCopilot
andauthored
Support Accept: text/markdown content negotiation and extend .md to all page types (#59999)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e721915 commit 2e3c5c2

5 files changed

Lines changed: 60 additions & 39 deletions

File tree

src/frame/middleware/cache-control.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,20 +84,26 @@ export function defaultCacheControl(res: Response): void {
8484
defaultBrowserCacheControl(res)
8585
}
8686

87+
// Vary on content type for pages that support content negotiation (HTML vs markdown)
88+
export function contentTypeCacheControl(res: Response): void {
89+
defaultCacheControl(res)
90+
res.append('vary', 'accept')
91+
}
92+
8793
// Vary on language when needed
8894
// x-user-language is a custom request header derived from req.cookie:user_language
8995
// accept-language is truncated to one of our available languages
9096
// https://bit.ly/3u5UeRN
9197
export function languageCacheControl(res: Response): void {
9298
defaultCacheControl(res)
93-
res.set('vary', 'accept-language, x-user-language')
99+
res.append('vary', 'accept-language, x-user-language')
94100
}
95101

96102
// Vary on both language and version for homepage redirects
97103
// x-user-version is a custom request header derived from req.cookie:user_version
98104
export function languageAndVersionCacheControl(res: Response): void {
99105
defaultCacheControl(res)
100-
res.set('vary', 'accept-language, x-user-language, x-user-version')
106+
res.append('vary', 'accept-language, x-user-language, x-user-version')
101107
}
102108

103109
// Long cache control for versioned assets: images, CSS, JS...

src/frame/middleware/render-page.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import statsd from '@/observability/lib/statsd'
1010
import type { ExtendedRequest } from '@/types'
1111
import { allVersions } from '@/versions/lib/all-versions'
1212
import { minimumNotFoundHtml } from '../lib/constants'
13-
import { defaultCacheControl } from './cache-control'
13+
import { contentTypeCacheControl, defaultCacheControl } from './cache-control'
1414
import { isConnectionDropped } from './halt-on-dropped-connection'
1515
import { nextHandleRequest } from './next'
1616

@@ -90,6 +90,12 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
9090
// Stop processing if the connection was already dropped
9191
if (isConnectionDropped(req, res)) return
9292

93+
// Content negotiation: serve markdown when the client prefers it over HTML.
94+
// Agents like Claude Code send Accept headers that omit text/html.
95+
if (req.accepts(['text/html', 'text/markdown']) === 'text/markdown') {
96+
context.markdownRequested = true
97+
}
98+
9399
if (!req.context) throw new Error('request not contextualized')
94100
req.context.renderedPage = await buildRenderedPage(req)
95101
req.context.miniTocItems = buildMiniTocItems(req)
@@ -145,15 +151,11 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
145151
}
146152

147153
if (context.markdownRequested) {
148-
if (!page.autogenerated && page.documentType === 'article') {
149-
return res.type('text/markdown').send(req.context.renderedPage)
150-
} else {
151-
const newUrl = req.originalUrl.replace(req.path, req.path.replace(/\.md$/, ''))
152-
return res.redirect(newUrl)
153-
}
154+
contentTypeCacheControl(res)
155+
return res.type('text/markdown').send(req.context.renderedPage)
154156
}
155157

156-
defaultCacheControl(res)
158+
contentTypeCacheControl(res)
157159

158160
return nextHandleRequest(req, res)
159161
}

src/frame/tests/server.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,40 @@ describe('server', () => {
278278
expect(res.headers['cache-control']).toMatch(/max-age=\d+/)
279279
})
280280
})
281+
282+
describe('Accept: text/markdown content negotiation', () => {
283+
test('returns markdown when Accept header prefers text/markdown', async () => {
284+
const res = await get('/en', {
285+
headers: {
286+
accept: 'text/markdown',
287+
},
288+
})
289+
expect(res.statusCode).toBe(200)
290+
expect(res.headers['content-type']).toContain('text/markdown')
291+
expect(res.headers.vary).toContain('accept')
292+
})
293+
294+
test('returns HTML when Accept header prefers text/html', async () => {
295+
const res = await get('/en', {
296+
headers: {
297+
accept: 'text/html,application/xhtml+xml',
298+
},
299+
})
300+
expect(res.statusCode).toBe(200)
301+
expect(res.headers['content-type']).toContain('text/html')
302+
expect(res.headers.vary).toContain('accept')
303+
})
304+
305+
test('returns HTML when Accept header is */*', async () => {
306+
const res = await get('/en', {
307+
headers: {
308+
accept: '*/*',
309+
},
310+
})
311+
expect(res.statusCode).toBe(200)
312+
expect(res.headers['content-type']).toContain('text/html')
313+
})
314+
})
281315
})
282316

283317
describe('static routes', () => {

src/shielding/middleware/handle-invalid-paths.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,21 +85,12 @@ export default function handleInvalidPaths(
8585

8686
if (req.path.endsWith('/index.md')) {
8787
defaultCacheControl(res)
88-
// The originalUrl is the full URL including query string.
89-
// E.g. `/en/foo.md?bar=baz`
9088
const newUrl = req.originalUrl.replace(req.path, req.path.replace(/\/index\.md$/, ''))
9189
return res.redirect(newUrl)
9290
} else if (req.path.endsWith('.md')) {
93-
// encode the query params but also make them pretty so we can see
94-
// them as `/` and `@` in the address bar
95-
// e.g. /api/article/body?pathname=/en/enterprise-server@3.16/admin...
96-
// NOT: /api/article/body?pathname=%2Fen%2Fenterprise-server%403.16%2Fadmin...
97-
const encodedPath = encodeURIComponent(req.path.replace(/\.md$/, ''))
98-
.replace(/%2F/g, '/')
99-
.replace(/%40/g, '@')
100-
const newUrl = `/api/article/body?pathname=${encodedPath}`
101-
res.redirect(newUrl)
102-
return
91+
req.url = req.url.replace(/\.md($|\?)/, '$1')
92+
req.headers.accept = 'text/markdown'
93+
return next()
10394
}
10495
return next()
10596
}

src/shielding/tests/shielding.ts

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -72,24 +72,12 @@ describe('index.md and .md suffixes', () => {
7272
}
7373
})
7474

75-
test('any URL that ends with /.md redirects', async () => {
76-
// With language prefix
77-
{
78-
const res = await get('/en/get-started/hello.md')
79-
expect(res.statusCode).toBe(302)
80-
expect(res.headers.location).toBe('/api/article/body?pathname=/en/get-started/hello')
81-
}
82-
// Without language prefix
75+
test('any URL that ends with .md serves markdown directly', async () => {
76+
// .md is stripped and request flows through with Accept: text/markdown
8377
{
84-
const res = await get('/get-started/hello.md')
85-
expect(res.statusCode).toBe(302)
86-
expect(res.headers.location).toBe('/api/article/body?pathname=/get-started/hello')
87-
}
88-
// With query string
89-
{
90-
const res = await get('/get-started/hello.md?foo=bar')
91-
expect(res.statusCode).toBe(302)
92-
expect(res.headers.location).toBe('/api/article/body?pathname=/get-started/hello')
78+
const res = await get('/en/get-started.md')
79+
// Should not redirect — serves markdown directly (or 404 if page doesn't exist)
80+
expect(res.statusCode).not.toBe(302)
9381
}
9482
})
9583
})

0 commit comments

Comments
 (0)