Documentation
¶
Overview ¶
Package md4agents implements a Caddy v2 HTTP middleware that serves a Markdown rendition of HTML pages when an agent (or any client) negotiates for it. See the README for design notes and the Cloudflare "Markdown for Agents" RFC this is modelled after.
Index ¶
- type MarkdownForAgents
- func (MarkdownForAgents) CaddyModule() caddy.ModuleInfo
- func (m *MarkdownForAgents) Cleanup() error
- func (m *MarkdownForAgents) Provision(ctx caddy.Context) error
- func (m *MarkdownForAgents) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error
- func (m *MarkdownForAgents) UnmarshalCaddyfile(d *caddyfile.Dispenser) error
- func (m *MarkdownForAgents) Validate() error
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type MarkdownForAgents ¶
type MarkdownForAgents struct {
// Root is the static file root to resolve requests against. When set,
// the static-first path is enabled: author `.md` files are served
// verbatim, generated artifacts are written to CacheDir and reused.
// When unset, the module only acts as a streaming converter in front
// of dynamic handlers (reverse_proxy, templates, etc.).
Root string `json:"root,omitempty"`
// CacheDir holds the on-disk write-through cache for generated
// markdown. Defaults to caddy.AppDataDir()/md4agents/<hash> so it
// can never be served by file_server.
CacheDir string `json:"cache_dir,omitempty"`
// URLSuffix appended to a path to explicitly request markdown
// (e.g. ".md"). Empty disables URL-suffix negotiation.
URLSuffix string `json:"url_suffix,omitempty"`
// QueryParam to check for markdown opt-in (e.g. "format"). The value
// must be "md" or "markdown". Empty disables query-param negotiation.
QueryParam string `json:"query_param,omitempty"`
// StripTags lists HTML tag names removed entirely (with their
// subtree) before conversion. Default:
// `script style noscript iframe svg`. Useful for stripping
// inline analytics, embedded videos, decorative SVGs, etc.
StripTags []string `json:"strip_tags,omitempty"`
// StripSelectors lists simple selectors removed before
// conversion. Supported forms: bare tag (`nav`), class
// (`.ads`), or id (`#cookie-banner`). Quote id selectors in
// the Caddyfile — `#` is a comment marker there.
StripSelectors []string `json:"strip_selectors,omitempty"`
// MainSelector, if set, restricts conversion to the subtree
// rooted at the first element matching this simple selector
// (e.g. `article`, `main`, `.post-body`). Everything outside
// is discarded, which is the cleanest way to strip site
// chrome on theme-heavy pages.
MainSelector string `json:"main_selector,omitempty"`
// CacheSize bounds the number of cached markdown responses held in
// memory. 0 → 4096.
CacheSize int `json:"cache_size,omitempty"`
// CacheBytes is the total in-memory cache byte budget. 0 → 256 MiB.
CacheBytes int64 `json:"cache_bytes,omitempty"`
// CacheEntryBytes caps the size of a single cached entry, both to
// reject pathological pages and to make the byte budget meaningful.
// 0 → 1 MiB.
CacheEntryBytes int64 `json:"cache_entry_bytes,omitempty"`
// CacheTTL bounds how long an in-memory cache entry is reused.
// 0 → use default (15m)
// <0 → never expire (use with care; the on-disk cache already
// mtime-invalidates, so this only matters for the dynamic
// path)
CacheTTL caddy.Duration `json:"cache_ttl,omitempty"`
// MaxBodyBytes limits the size of an HTML body we'll attempt to
// convert (both static disk reads and dynamic captures). 0 → 4 MiB.
MaxBodyBytes int64 `json:"max_body_bytes,omitempty"`
// ConvertTimeout bounds an individual HTML→MD conversion. 0 → 5s.
ConvertTimeout caddy.Duration `json:"convert_timeout,omitempty"`
// MaxConcurrent caps the number of conversions that can run at once.
// 0 → max(4, NumCPU). New requests wait on a buffered semaphore;
// hitting the ConvertTimeout while waiting returns 503.
MaxConcurrent int `json:"max_concurrent,omitempty"`
// Pregenerate, when true and Root is set, walks the root at startup
// and converts every .html file ahead of the first request.
Pregenerate bool `json:"pregenerate,omitempty"`
// AllowAuthenticated, when true, allows caching responses for
// requests carrying Authorization or Cookie headers. Default false —
// any such request bypasses the shared cache to avoid serving one
// user's markdown to another.
AllowAuthenticated bool `json:"allow_authenticated,omitempty"`
// JanitorInterval, when >0 and Root is set, runs a periodic cleanup
// of orphaned sidecar files whose source HTML no longer exists.
// 0 → off (the lazy mtime check is enough for correctness).
JanitorInterval caddy.Duration `json:"janitor_interval,omitempty"`
// contains filtered or unexported fields
}
MarkdownForAgents serves a Markdown rendition of HTML pages when a client — typically an AI agent — negotiates for it. It implements Cloudflare's "Markdown for Agents" convention on top of Caddy's static and dynamic handlers, with caching, content negotiation, and HTML sanitization built in.
## Why this matters
Modern AI agents (Claude, ChatGPT, Perplexity, crawler bots) waste tokens parsing HTML chrome — navigation, scripts, cookie banners, analytics — before reaching the content. Serving the same URL as Markdown gives them roughly 5–10× more useful content per token and measurably improves answer quality on long documents. Same URL, same auth, just `Accept: text/markdown` (or a `.md` suffix).
## Content negotiation
A request is served Markdown when any of these is true:
Trigger | Example -------------|-------- URL suffix | `GET /docs/page.md` Query param | `GET /docs/page?format=md` Accept hdr | `Accept: text/markdown` (q-value aware vs `text/html`)
The first two are stripped before the inner handler sees the request, so the upstream still resolves the underlying HTML.
## Quick start (static site)
```caddy
example.com {
root * /var/www/site
markdown_for_agents {
root /var/www/site
}
file_server
}
```
Caddyfile note: always use the block form to set `root`. A bare `markdown_for_agents /var/www/site` would be parsed by Caddy as a path matcher (`/var/www/site`), not as a positional argument to the directive.
Author-written `*.md` files win over generated ones; generated artifacts are written to a sidecar cache (`/var/cache/md4agents` by default) and reused on every subsequent request. Edits to source HTML invalidate cache entries automatically (mtime + size stat) — no watcher required.
## Reverse-proxy mode
Omit `root` and the module becomes a streaming converter in front of any dynamic upstream:
```caddy
example.com {
markdown_for_agents {
main_selector article
strip_selectors nav footer .ads
}
reverse_proxy backend:8080
}
```
## Cache safety
Only `GET` and `HEAD` are cacheable. Requests carrying `Authorization` or `Cookie` headers bypass the shared cache by default; upstream responses with `Set-Cookie`, `Cache-Control: private/no-store`, or a non-trivial `Vary` are converted and served once but never cached.
## More
Full documentation, performance notes, and security guidance live at https://github.com/mhupfauer/caddy-md4agents.
All durations and sizes are zero-value safe: any unset field falls back to a documented default during provisioning.
func (MarkdownForAgents) CaddyModule ¶
func (MarkdownForAgents) CaddyModule() caddy.ModuleInfo
func (*MarkdownForAgents) Cleanup ¶
func (m *MarkdownForAgents) Cleanup() error
func (*MarkdownForAgents) Provision ¶
func (m *MarkdownForAgents) Provision(ctx caddy.Context) error
func (*MarkdownForAgents) ServeHTTP ¶
func (m *MarkdownForAgents) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error
ServeHTTP dispatches to the static-first path when Root is configured and the request resolves to an HTML file on disk, falling back to the dynamic capture path otherwise.
func (*MarkdownForAgents) UnmarshalCaddyfile ¶
func (m *MarkdownForAgents) UnmarshalCaddyfile(d *caddyfile.Dispenser) error
func (*MarkdownForAgents) Validate ¶
func (m *MarkdownForAgents) Validate() error