A Caddy middleware module that intercepts HTTP requests containing Accept: text/markdown and serves precomputed .md files located alongside the originally requested resources.
When a client sends a request with Accept: text/markdown (or text/x-markdown) in the header, this middleware:
- Determines which
.mdfile corresponds to the requested path - Checks if that
.mdfile exists on disk - If found, serves the markdown content with
Content-Type: text/markdown; charset=utf-8 - If not found, passes the request to the next handler as normal (or returns
406in strict mode)
When strict_mode is enabled, the middleware also rejects requests whose Accept header contains only types incompatible with text content (e.g. image/png) with 406 Not Acceptable, before any file lookup takes place.
| Request Path | Markdown File Checked |
|---|---|
/docs/page.html |
/docs/page.md |
/docs/page.php |
/docs/page.md |
/docs/ |
/docs/index.md |
/about |
/about.md |
/ |
/index.md |
Build Caddy with this module using xcaddy:
xcaddy build --with github.com/avvertix/caddy-content-negotiationA sample Docker setup is included. It builds a custom Caddy image with the
module baked in and serves the demo content in docker/content/.
# Build and start
docker compose up --build
# Test content negotiation
curl -H "Accept: text/markdown" http://localhost/
curl -H "Accept: text/markdown" http://localhost/docs/page.html
curl -H "Accept: text/markdown" http://localhost/aboutTo use your own content, mount a volume over /srv in docker-compose.yml
or copy files into docker/content/ before building.
markdown_intercept is not a standard ordered directive, so you must register
its position in the global options block:
{
order markdown_intercept before file_server
}
example.com {
markdown_intercept
file_server
}{
order markdown_intercept before file_server
}
example.com {
markdown_intercept {
root /var/www/html
index_names index.html index.htm index.php
extensions .html .htm .php .txt
experimental_range_requests
strict_mode
}
file_server
}| Directive | Default | Description |
|---|---|---|
root |
Site root ({http.vars.root}) |
Filesystem path to look for .md files |
index_names |
index.html index.htm index.php |
Index filenames to try for directory requests |
extensions |
.html .htm .php .txt |
File extensions eligible for .md substitution |
experimental_range_requests |
disabled | Enable the x-frontmatter range unit (see below) |
strict_mode |
disabled | Reject unsupported Accept types with 406 (see below) |
{
"handler": "markdown_intercept",
"root": "/var/www/html",
"index_names": ["index.html", "index.htm"],
"extensions": [".html", ".htm", ".php"],
"experimental_range_requests": true,
"strict_mode": true
}Request markdown from any endpoint by setting the Accept header:
# Get the markdown version of a page
curl -H "Accept: text/markdown" https://example.com/docs/page.html
# Normal requests are unaffected
curl https://example.com/docs/page.htmlWhen experimental_range_requests is enabled, clients can request only the
frontmatter block of a markdown file using the non-standard x-frontmatter
range unit:
curl -H "Accept: text/markdown" \
-H "Range: x-frontmatter" \
https://example.com/docs/page.htmlThe server responds with 206 Partial Content and only the frontmatter section
(the content between the opening and closing --- delimiters). If the file has
no frontmatter block, the server returns 416 Range Not Satisfiable.
When the feature is enabled, every markdown response includes
Accept-Ranges: x-frontmatter so clients can discover support before issuing a
range request.
When strict_mode is enabled the middleware enforces two rules:
1. Unsupported Accept types are rejected with 406 Not Acceptable
If the request's Accept header contains only types outside the text/* family
and no */* wildcard, the middleware returns 406 immediately without
performing any file lookup or calling the next handler. This rejects probes and
requests for content the server cannot produce for text-based resources:
# Rejected — not a text type
curl -i -H "Accept: image/png" https://example.com/docs/page.html
# → 406 Not Acceptable
curl -i -H "Accept: application/x-content-negotiation-probe" https://example.com/about
# → 406 Not AcceptableRequests that include at least one compatible type are allowed through:
# Allowed — text/html matches text/*
curl -i -H "Accept: text/html, application/json" https://example.com/docs/page.html
# Allowed — wildcard covers everything
curl -i -H "Accept: */*" https://example.com/docs/page.html2. Missing markdown files return 406 instead of passing through
When the client explicitly requests text/markdown but no .md file exists for
the requested path, the middleware returns 406 rather than forwarding the
request to the next handler:
# 406 if /docs/page.md does not exist
curl -i -H "Accept: text/markdown" https://example.com/docs/page.htmlWithout strict_mode the same request would be forwarded to the next handler
(e.g. a file server that serves the HTML version), and the X-Content-Md: requested header would be added to the forwarded request.
When a markdown file is served, the response includes:
Content-Type: text/markdown; charset=utf-8Accept-Ranges: x-frontmatter(only whenexperimental_range_requestsis enabled)
A 206 Partial Content frontmatter response additionally includes:
Content-Range: x-frontmatter 0-<end>/<total>— byte offsets of the frontmatter block within the full file
# Run tests
go test -v -race ./...
# Build Caddy locally with the module (requires xcaddy)
xcaddy build --with github.com/avvertix/caddy-content-negotiation=.