Go
Stdlib net/http middleware that parses Accept (q-values, RFC 9110 specificity), serves Markdown siblings, sets Vary, returns 406, and advertises the alternate via Link.
Go’s stdlib has everything you need. The pattern: two http.ServeMuxes —
one for HTML, one for Markdown — wrapped by a middleware that picks
between them based on Accept (or a .md URL suffix). Because the
Markdown mux mirrors HTML routes, a single mux.Handler(req) lookup is
also the answer to “does this path have a Markdown rep?” — no parallel
route table to maintain.
negotiate.go
package main
import (
"mime"
"net/http"
"strconv"
"strings"
)
var produces = []string{"text/html", "text/markdown"}
type acceptEntry struct {
mediaType string
q float64
specificity int // 0=*/*, 1=type/*, 2=exact
}
func parseAccept(header string) []acceptEntry {
if header == "" {
return nil
}
out := make([]acceptEntry, 0)
for _, raw := range strings.Split(header, ",") {
// `mime.ParseMediaType` handles quoted params, whitespace, and
// case for us; malformed segments are dropped rather than
// best-efforted.
mt, params, err := mime.ParseMediaType(strings.TrimSpace(raw))
if err != nil {
continue
}
q := 1.0
if v, ok := params["q"]; ok {
if f, err := strconv.ParseFloat(v, 64); err == nil {
q = max(0, min(1, f))
}
}
spec := 2
switch {
case mt == "*/*":
spec = 0
case strings.HasSuffix(mt, "/*"):
spec = 1
}
out = append(out, acceptEntry{mt, q, spec})
}
return out
}
// preferredType returns the producible media type the client most
// prefers, or "" when every option is rejected.
//
// Per RFC 9110 §12.5.1, a more specific media range overrides a less
// specific one regardless of q-value — so `text/html;q=0, */*;q=1`
// correctly rejects HTML even though the wildcard claims q=1.
func preferredType(header string, produces []string) string {
if header == "" && len(produces) > 0 {
return produces[0]
}
entries := parseAccept(header)
if len(entries) == 0 && len(produces) > 0 {
return produces[0]
}
bestType, bestQ, bestPos := "", -1.0, -1
for _, candidate := range produces {
matchedQ, matchedSpec, matchedPos := -1.0, -1, -1
for i, e := range entries {
if !matches(e, candidate) {
continue
}
if matchedPos < 0 || e.specificity > matchedSpec ||
(e.specificity == matchedSpec && i < matchedPos) {
matchedQ = e.q
matchedSpec = e.specificity
matchedPos = i
}
}
if matchedPos < 0 || matchedQ <= 0 {
continue
}
if matchedQ > bestQ || (matchedQ == bestQ && (bestPos < 0 || matchedPos < bestPos)) {
bestType, bestQ, bestPos = candidate, matchedQ, matchedPos
}
}
return bestType
}
func matches(e acceptEntry, candidate string) bool {
if e.mediaType == "*/*" {
return true
}
if strings.HasSuffix(e.mediaType, "/*") {
return strings.HasPrefix(candidate, e.mediaType[:len(e.mediaType)-1])
}
return e.mediaType == candidate
}
// negotiate wraps two muxes — html and md — and picks between them
// based on the request's `.md` suffix or `Accept` header.
func negotiate(html, md *http.ServeMux) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Negotiation applies to GET/HEAD only. Other methods fall
// through so the mux can answer 405 with `Allow`.
if r.Method != http.MethodGet && r.Method != http.MethodHead {
html.ServeHTTP(w, r)
return
}
path := r.URL.Path
// `.md` URL → strip and dispatch to the Markdown mux.
if strings.HasSuffix(path, ".md") {
r2 := r.Clone(r.Context())
r2.URL.Path = strings.TrimSuffix(path, ".md")
if r2.URL.Path == "" || r2.URL.Path == "/index" {
r2.URL.Path = "/"
}
if _, p := md.Handler(r2); p == "" {
http.NotFound(w, r)
return
}
w.Header().Set("Vary", "Accept")
md.ServeHTTP(w, r2)
return
}
chosen := preferredType(r.Header.Get("Accept"), produces)
w.Header().Set("Vary", "Accept")
if chosen == "" {
http.Error(w, "Not Acceptable\n\nAvailable: text/html, text/markdown\n",
http.StatusNotAcceptable)
return
}
_, mdPattern := md.Handler(r)
hasMD := mdPattern != ""
if chosen == "text/markdown" {
if hasMD {
md.ServeHTTP(w, r)
return
}
// Markdown not available for this route. Fall through to
// HTML only if HTML is still acceptable; otherwise 406.
if preferredType(r.Header.Get("Accept"), []string{"text/html"}) == "" {
http.Error(w,
"Not Acceptable\n\nMarkdown not available and HTML not acceptable.\n",
http.StatusNotAcceptable)
return
}
}
// HTML — advertise the Markdown sibling.
if hasMD {
sibling := path + ".md"
if path == "/" {
sibling = "/index.md"
}
w.Header().Set("Link",
"<"+sibling+`>; rel="alternate"; type="text/markdown"`)
}
html.ServeHTTP(w, r)
})
}
Wire it up
func main() {
html := http.NewServeMux()
html.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("<h1>Hello</h1>"))
})
html.HandleFunc("GET /docs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("<h1>Docs</h1>"))
})
md := http.NewServeMux()
md.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Write([]byte("# Hello\n"))
})
md.HandleFunc("GET /docs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Write([]byte("# Docs\n"))
})
http.ListenAndServe(":8080", negotiate(html, md))
}
Register on the md mux only the routes that have a Markdown rep. The
mux.Handler(req) lookup tells the middleware whether to serve
Markdown or fall through — no parallel route list to keep in sync.
Verify
curl -sI -H "Accept: text/markdown" http://localhost:8080/docs
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept
curl -sI http://localhost:8080/docs
# Content-Type: text/html; charset=utf-8
# Link: </docs.md>; rel="alternate"; type="text/markdown"
# Vary: Accept
curl -sI -H "Accept: application/pdf" http://localhost:8080/docs
# HTTP/1.1 406 Not Acceptable
To exercise the missing-Markdown branch, register a route on the html
mux that has no counterpart on md and request it with Accept: text/markdown — you should get 406 Not Acceptable, not the HTML
response.
The middleware sets Vary and Link before delegating, which is fine
when handlers don’t touch those headers themselves. If yours do — and
you can’t accept that a downstream Header().Set("Vary", "Cookie")
clobbers the Accept token — wrap the writer and inject in
WriteHeader instead. The wp-packages source linked below has a worked
example.
External resources
wp-packages.orgruns this pattern in production — every public page has a Markdown sibling. Source for the negotiation middleware:internal/http/negotiate.go.