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