Discourse
The Roots discourse-to-markdown plugin serves topics, categories, tags, and topic lists as Markdown when requested via the Accept header, with proper Vary, 406, and q-value handling.
Canonical source: https://github.com/roots/discourse-to-markdown
The cleanest way to content-negotiate a Discourse forum is the Roots discourse-to-markdown plugin. It handles the conversion and the Accept-header routing without touching your theme or any Rails code.
What the plugin does
- Parses the incoming
Acceptheader with a spec-correct RFC 9110 §12.5.1 parser, so specificity wins over the wildcard tie that would otherwise make Rails favour HTML. - If Markdown is preferred, converts Discourse’s rendered
cookedHTML to Markdown on the fly and serves it withContent-Type: text/markdown; charset=utf-8andVary: Accept. - Returns
406 Not Acceptablewhen the client explicitly rejects every representation the plugin can serve (toggle viadiscourse_to_markdown_strict_accept). - Covers topics, single posts, categories, tags, and the
/latest,/top,/hot, and/u/:username/activitytopic lists — plus their RSS feeds, which carry an<atom:link>pointing at the Markdown equivalent. - Accepts a
.mdURL suffix on any supported route as a first-class, shareable alternative to theAcceptheader..mdresponses carryX-Robots-Tag: noindex, nofollowso search engines don’t index the Markdown alias alongside the canonical HTML page. - Advertises the Markdown sibling on every HTML response — both as an
HTTP
Linkheader and as an HTML<link rel="alternate">in the<head>— so RFC 8288-aware crawlers and HTML-parsing clients can discover it without sendingAccept: text/markdown. See advertising.mdsiblings. - Otherwise, Discourse renders the forum as normal.
Install
Add the plugin to your app.yml:
hooks:
after_code:
- exec:
cd: $home/plugins
cmd:
- git clone https://github.com/roots/discourse-to-markdown.git
Then rebuild:
cd /var/discourse
./launcher rebuild app
Configure
All settings live under Admin → Settings → Plugins. The master
switch ships off — flip discourse_to_markdown_enabled on to activate
negotiation.
| Setting | Default | Purpose |
|---|---|---|
discourse_to_markdown_enabled | false | Master switch for the plugin |
discourse_to_markdown_md_urls_enabled | true | Accept .md URL suffixes as a sibling to the HTML route |
discourse_to_markdown_strict_accept | false | Return 406 when the client’s Accept excludes both text/html and text/markdown |
discourse_to_markdown_emit_vary | true | Emit Vary: Accept on Markdown and 406 responses so caches don’t cross-serve representations. Disable if your reverse proxy manages Vary itself |
discourse_to_markdown_include_post_metadata | true | Include URL, category, tags, author, and timestamps in the Markdown |
Converted Markdown is cached per post in Discourse.cache (Redis in
production) keyed on post.id + post.updated_at, so edits produce a
fresh key automatically — no explicit invalidation hook needed.
Verify
curl -sI -H "Accept: text/markdown" https://yourforum.com/t/welcome/5
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept
curl -sI https://yourforum.com/latest.md
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept
# X-Robots-Tag: noindex, nofollow