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 Accept header 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 cooked HTML to Markdown on the fly and serves it with Content-Type: text/markdown; charset=utf-8 and Vary: Accept.
  • Returns 406 Not Acceptable when the client explicitly rejects every representation the plugin can serve (toggle via discourse_to_markdown_strict_accept).
  • Covers topics, single posts, categories, tags, and the /latest, /top, /hot, and /u/:username/activity topic lists — plus their RSS feeds, which carry an <atom:link> pointing at the Markdown equivalent.
  • Accepts a .md URL suffix on any supported route as a first-class, shareable alternative to the Accept header. .md responses carry X-Robots-Tag: noindex, nofollow so 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 Link header and as an HTML <link rel="alternate"> in the <head> — so RFC 8288-aware crawlers and HTML-parsing clients can discover it without sending Accept: text/markdown. See advertising .md siblings.
  • 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.

SettingDefaultPurpose
discourse_to_markdown_enabledfalseMaster switch for the plugin
discourse_to_markdown_md_urls_enabledtrueAccept .md URL suffixes as a sibling to the HTML route
discourse_to_markdown_strict_acceptfalseReturn 406 when the client’s Accept excludes both text/html and text/markdown
discourse_to_markdown_emit_varytrueEmit 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_metadatatrueInclude 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