1// Markdown content negotiation for agents, as described in
2// https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/. A request whose
3// Accept header prefers text/markdown receives the Markdown file that
4// @signalwire/docusaurus-plugin-llms-txt generates next to each page at build time
5// (/docs/foo -> /docs/foo.md, / -> /index.md). Browsers keep receiving HTML.
6//
7// This middleware also sets the homepage Link headers (RFC 8288). They are not in static/_headers
8// because a _headers Link rule replaces the preconnect Link headers that Pages generates for Early
9// Hints. static/_routes.json keeps static assets from invoking this middleware.
10
11const HOMEPAGE_LINKS = [
12 '</.well-known/api-catalog>; rel="api-catalog"',
13 '</openapi.json>; rel="service-desc"; type="application/vnd.oai.openapi+json"',
14 '</docs/api>; rel="service-doc"; type="text/html"',
15 '</llms.txt>; rel="describedby"; type="text/plain"',
16 '</index.md>; rel="alternate"; type="text/markdown"'
17]
18
19export async function onRequest({ request, env, next }) {
20 const url = new URL(request.url)
21
22 if (
23 (request.method === 'GET' || request.method === 'HEAD') &&
24 prefersMarkdown(request.headers.get('Accept'))
25 ) {
26 const markdown = await fetchMarkdown(env, url)
27 if (markdown) {
28 return markdownResponse(request, url, markdown)
29 }
30 }
31
32 const response = await next()
33 if (!(response.headers.get('Content-Type') || '').startsWith('text/html')) {
34 return response
35 }
36
37 // The same URL serves HTML or Markdown, so caches must keep the two apart.
38 const html = new Response(response.body, response)
39 html.headers.append('Vary', 'Accept')
40 if (url.pathname === '/') {
41 appendHomepageLinks(html.headers)
42 }
43 return html
44}
45
46// Returns true when the Accept header lists text/markdown at a quality at least as high as HTML.
47function prefersMarkdown(accept) {
48 if (!accept) {
49 return false
50 }
51
52 const quality = new Map()
53 for (const part of accept.toLowerCase().split(',')) {
54 const [range, ...params] = part.split(';').map((value) => value.trim())
55 const q = params.find((param) => param.startsWith('q='))
56 const value = q ? Number.parseFloat(q.slice(2)) : 1
57 quality.set(range, Math.max(quality.get(range) ?? 0, Number.isFinite(value) ? value : 0))
58 }
59
60 const markdown = quality.get('text/markdown') ?? 0
61 const html = quality.get('text/html') ?? quality.get('text/*') ?? quality.get('*/*') ?? 0
62 return markdown > 0 && markdown >= html
63}
64
65// Returns the Markdown version of a page, or null when the page has none.
66async function fetchMarkdown(env, url) {
67 const path = url.pathname === '/' ? '/index.md' : `${url.pathname.replace(/\/+$/, '')}.md`
68 try {
69 const response = await env.ASSETS.fetch(new URL(path, url))
70 const contentType = response.headers.get('Content-Type') || ''
71 return response.ok && contentType.startsWith('text/markdown') ? response : null
72 } catch {
73 return null
74 }
75}
76
77async function markdownResponse(request, url, asset) {
78 const body = await asset.text()
79 const headers = new Headers(asset.headers)
80 headers.delete('Content-Encoding')
81 headers.delete('Content-Length')
82 headers.set('Content-Type', 'text/markdown; charset=utf-8')
83 headers.append('Vary', 'Accept')
84 // An estimate of four characters per token.
85 headers.set('x-markdown-tokens', String(Math.ceil(body.length / 4)))
86 if (url.pathname === '/') {
87 appendHomepageLinks(headers)
88 }
89 return new Response(request.method === 'HEAD' ? null : body, { status: 200, headers })
90}
91
92function appendHomepageLinks(headers) {
93 for (const link of HOMEPAGE_LINKS) {
94 headers.append('Link', link)
95 }
96}
97
1// Markdown content negotiation for agents, as described in
2// https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/. A request whose
3// Accept header prefers text/markdown receives the Markdown file that
4// @signalwire/docusaurus-plugin-llms-txt generates next to each page at build time
5// (/docs/foo -> /docs/foo.md, / -> /index.md). Browsers keep receiving HTML.
6//
7// This middleware also sets the homepage Link headers (RFC 8288). They are not in static/_headers
8// because a _headers Link rule replaces the preconnect Link headers that Pages generates for Early
9// Hints. static/_routes.json keeps static assets from invoking this middleware.
10
11const HOMEPAGE_LINKS = [
12 '</.well-known/api-catalog>; rel="api-catalog"',
13 '</openapi.json>; rel="service-desc"; type="application/vnd.oai.openapi+json"',
14 '</docs/api>; rel="service-doc"; type="text/html"',
15 '</llms.txt>; rel="describedby"; type="text/plain"',
16 '</index.md>; rel="alternate"; type="text/markdown"'
17]
18
19export async function onRequest({ request, env, next }) {
20 const url = new URL(request.url)
21
22 if (
23 (request.method === 'GET' || request.method === 'HEAD') &&
24 prefersMarkdown(request.headers.get('Accept'))
25 ) {
26 const markdown = await fetchMarkdown(env, url)
27 if (markdown) {
28 return markdownResponse(request, url, markdown)
29 }
30 }
31
32 const response = await next()
33 if (!(response.headers.get('Content-Type') || '').startsWith('text/html')) {
34 return response
35 }
36
37 // The same URL serves HTML or Markdown, so caches must keep the two apart.
38 const html = new Response(response.body, response)
39 html.headers.append('Vary', 'Accept')
40 if (url.pathname === '/') {
41 appendHomepageLinks(html.headers)
42 }
43 return html
44}
45
46// Returns true when the Accept header lists text/markdown at a quality at least as high as HTML.
47function prefersMarkdown(accept) {
48 if (!accept) {
49 return false
50 }
51
52 const quality = new Map()
53 for (const part of accept.toLowerCase().split(',')) {
54 const [range, ...params] = part.split(';').map((value) => value.trim())
55 const q = params.find((param) => param.startsWith('q='))
56 const value = q ? Number.parseFloat(q.slice(2)) : 1
57 quality.set(range, Math.max(quality.get(range) ?? 0, Number.isFinite(value) ? value : 0))
58 }
59
60 const markdown = quality.get('text/markdown') ?? 0
61 const html = quality.get('text/html') ?? quality.get('text/*') ?? quality.get('*/*') ?? 0
62 return markdown > 0 && markdown >= html
63}
64
65// Returns the Markdown version of a page, or null when the page has none.
66async function fetchMarkdown(env, url) {
67 const path = url.pathname === '/' ? '/index.md' : `${url.pathname.replace(/\/+$/, '')}.md`
68 try {
69 const response = await env.ASSETS.fetch(new URL(path, url))
70 const contentType = response.headers.get('Content-Type') || ''
71 return response.ok && contentType.startsWith('text/markdown') ? response : null
72 } catch {
73 return null
74 }
75}
76
77async function markdownResponse(request, url, asset) {
78 const body = await asset.text()
79 const headers = new Headers(asset.headers)
80 headers.delete('Content-Encoding')
81 headers.delete('Content-Length')
82 headers.set('Content-Type', 'text/markdown; charset=utf-8')
83 headers.append('Vary', 'Accept')
84 // An estimate of four characters per token.
85 headers.set('x-markdown-tokens', String(Math.ceil(body.length / 4)))
86 if (url.pathname === '/') {
87 appendHomepageLinks(headers)
88 }
89 return new Response(request.method === 'HEAD' ? null : body, { status: 200, headers })
90}
91
92function appendHomepageLinks(headers) {
93 for (const link of HOMEPAGE_LINKS) {
94 headers.append('Link', link)
95 }
96}
97
