1+ export type ContentNegotiationResult = 'markdown' | 'html' | 'not-acceptable'
2+
13interface AcceptEntry {
24 type : string
35 q : number
@@ -25,7 +27,6 @@ export function parseAcceptHeader(accept: string): AcceptEntry[] {
2527 }
2628 else {
2729 type = part . slice ( 0 , semicolonIdx ) . trim ( )
28- // Extract q value without regex for performance
2930 const paramStr = part . slice ( semicolonIdx + 1 )
3031 const qIdx = paramStr . indexOf ( 'q=' )
3132 if ( qIdx !== - 1 ) {
@@ -43,36 +44,49 @@ export function parseAcceptHeader(accept: string): AcceptEntry[] {
4344}
4445
4546/**
46- * Determine if a client prefers markdown over HTML using proper content negotiation.
47- *
48- * Uses Accept header quality weights and position ordering:
49- * - If text/markdown or text/plain has higher quality than text/html -> markdown
50- * - If same quality, earlier position in Accept header wins
51- * - Bare wildcard does NOT trigger markdown (prevents breaking OG crawlers)
52- * - sec-fetch-dest: document always returns false (browser navigation)
47+ * Perform RFC 7231 content negotiation for HTML vs Markdown.
5348 *
54- * @param acceptHeader - The HTTP Accept header value
55- * @param secFetchDest - The Sec-Fetch-Dest header value
49+ * Resolution rules:
50+ * - `Sec-Fetch-Dest: document` always returns `'html'` (browser navigation).
51+ * - Missing or empty Accept header returns `'html'` (server picks default).
52+ * - q=0 entries are treated as explicit rejections and ignored for matching
53+ * (but still count towards "something was listed").
54+ * - `text/markdown` and `text/plain` are the markdown-capable types.
55+ * - `text/html` and `application/xhtml+xml` are the html-capable types.
56+ * - `*_/_*` and `text/*` are wildcards; they satisfy 406 but never on their
57+ * own tip negotiation towards markdown (preserves OG crawler behavior).
58+ * - If nothing in the Accept header can be served (no explicit match, no
59+ * wildcard), returns `'not-acceptable'` so the caller can send 406.
60+ * - Otherwise, compares best markdown entry vs best html-or-wildcard entry
61+ * by q, then by position.
5662 */
57- export function shouldServeMarkdown ( acceptHeader ?: string , secFetchDest ?: string ) : boolean {
58- if ( secFetchDest === 'document' ) {
59- return false
60- }
63+ export function negotiateContent ( acceptHeader ?: string , secFetchDest ?: string ) : ContentNegotiationResult {
64+ if ( secFetchDest === 'document' )
65+ return 'html'
6166
6267 const accept = acceptHeader || ''
6368 if ( ! accept )
64- return false
69+ return 'html'
6570
66- const parts = accept . split ( ',' )
6771 let bestMdQ = - 1
6872 let bestMdPos = - 1
69- let htmlQ = - 1
70- let htmlPos = - 1
73+ let bestHtmlQ = - 1
74+ let bestHtmlPos = - 1
75+ let bestWildcardQ = - 1
76+ let bestWildcardPos = - 1
77+ let sawAnyEntry = false
78+ let sawAcceptable = false
79+ // Track explicit q=0 rejections so wildcard fallback can't resurrect them.
80+ let rejectedMd = false
81+ let rejectedHtml = false
7182
83+ const parts = accept . split ( ',' )
7284 for ( let i = 0 ; i < parts . length ; i ++ ) {
7385 const part = parts [ i ] ! . trim ( )
7486 if ( ! part )
7587 continue
88+ sawAnyEntry = true
89+
7690 const semicolonIdx = part . indexOf ( ';' )
7791 let type : string
7892 let q = 1
@@ -82,7 +96,15 @@ export function shouldServeMarkdown(acceptHeader?: string, secFetchDest?: string
8296 else {
8397 type = part . slice ( 0 , semicolonIdx ) . trim ( )
8498 const paramStr = part . slice ( semicolonIdx + 1 )
85- const qIdx = paramStr . indexOf ( 'q=' )
99+ // Find q= case-insensitively without allocating.
100+ let qIdx = - 1
101+ for ( let j = 0 ; j < paramStr . length - 1 ; j ++ ) {
102+ const c = paramStr . charCodeAt ( j )
103+ if ( ( c === 113 || c === 81 ) && paramStr . charCodeAt ( j + 1 ) === 61 /* = */ ) {
104+ qIdx = j
105+ break
106+ }
107+ }
86108 if ( qIdx !== - 1 ) {
87109 const qStart = qIdx + 2
88110 let qEnd = qStart
@@ -93,26 +115,76 @@ export function shouldServeMarkdown(acceptHeader?: string, secFetchDest?: string
93115 }
94116 }
95117
96- if ( type === 'text/markdown' || type === 'text/plain' ) {
97- if ( q > bestMdQ || ( q === bestMdQ && ( bestMdPos === - 1 || i < bestMdPos ) ) ) {
118+ // Normalize type for case-insensitive comparison (media types per RFC 7231).
119+ const normalized = type . toLowerCase ( )
120+
121+ if ( normalized === 'text/markdown' || normalized === 'text/plain' ) {
122+ if ( q === 0 ) {
123+ rejectedMd = true
124+ continue
125+ }
126+ sawAcceptable = true
127+ if ( q > bestMdQ || ( q === bestMdQ && bestMdPos === - 1 ) ) {
98128 bestMdQ = q
99129 bestMdPos = i
100130 }
101131 }
102- else if ( type === 'text/html' ) {
103- htmlQ = q
104- htmlPos = i
132+ else if ( normalized === 'text/html' || normalized === 'application/xhtml+xml' ) {
133+ if ( q === 0 ) {
134+ rejectedHtml = true
135+ continue
136+ }
137+ sawAcceptable = true
138+ if ( q > bestHtmlQ || ( q === bestHtmlQ && bestHtmlPos === - 1 ) ) {
139+ bestHtmlQ = q
140+ bestHtmlPos = i
141+ }
142+ }
143+ else if ( normalized === '*/*' || normalized === 'text/*' ) {
144+ if ( q === 0 )
145+ continue
146+ sawAcceptable = true
147+ if ( q > bestWildcardQ || ( q === bestWildcardQ && bestWildcardPos === - 1 ) ) {
148+ bestWildcardQ = q
149+ bestWildcardPos = i
150+ }
105151 }
106152 }
107153
154+ if ( sawAnyEntry && ! sawAcceptable )
155+ return 'not-acceptable'
156+
157+ // Apply wildcard fallback only when the concrete type wasn't explicitly rejected.
158+ if ( bestMdPos === - 1 && ! rejectedMd && bestWildcardPos !== - 1 ) {
159+ bestMdQ = bestWildcardQ
160+ bestMdPos = bestWildcardPos
161+ }
162+ if ( bestHtmlPos === - 1 && ! rejectedHtml && bestWildcardPos !== - 1 ) {
163+ bestHtmlQ = bestWildcardQ
164+ bestHtmlPos = bestWildcardPos
165+ }
166+
167+ // Both concrete types were explicitly rejected (q=0) and only a wildcard
168+ // remained. The wildcard satisfied `sawAcceptable`, but we literally cannot
169+ // serve anything the client didn't veto, so 406 is the honest answer.
170+ if ( bestMdPos === - 1 && bestHtmlPos === - 1 )
171+ return 'not-acceptable'
108172 if ( bestMdPos === - 1 )
109- return false
110- if ( htmlPos === - 1 )
111- return true
112- if ( bestMdQ > htmlQ )
113- return true
114- if ( bestMdQ === htmlQ && bestMdPos < htmlPos )
115- return true
173+ return 'html'
174+ if ( bestHtmlPos === - 1 )
175+ return 'markdown'
176+ if ( bestMdQ > bestHtmlQ )
177+ return 'markdown'
178+ if ( bestMdQ === bestHtmlQ && bestMdPos < bestHtmlPos )
179+ return 'markdown'
180+ return 'html'
181+ }
116182
117- return false
183+ /**
184+ * Determine if a client prefers markdown over HTML. Convenience wrapper over
185+ * {@link negotiateContent}; treats `'not-acceptable'` the same as `'html'`
186+ * (callers that want 406 semantics should use `negotiateContent` directly).
187+ */
188+ export function shouldServeMarkdown ( acceptHeader ?: string , secFetchDest ?: string ) : boolean {
189+ return negotiateContent ( acceptHeader , secFetchDest ) === 'markdown'
118190}
0 commit comments