# 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.ServeMux`es —
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`

```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

```go
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

```bash
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.org`](https://wp-packages.org) runs this pattern in
  production — every public page has a Markdown sibling. Source for
  the negotiation middleware:
  [`internal/http/negotiate.go`](https://github.com/roots/wp-packages/blob/main/internal/http/negotiate.go).

---

_Markdown content negotiation: serve clean Markdown to AI agents from the same URL that serves HTML to browsers._ — [acceptmarkdown.com](https://acceptmarkdown.com/)
