# Rails

Rails ships a `text/markdown` MIME type, a `markdown:` renderer, and auto `Vary: Accept`. Define `to_markdown` on your model and `respond_to` the `:md` format.

**Requires Rails 8+.** The `text/markdown` MIME type and the
`markdown:` renderer described below shipped in Rails 8. On Rails 7
and earlier you'd need to register the MIME type yourself
(`Mime::Type.register 'text/markdown', :md`) and render manually with
`render plain: @post.to_markdown, content_type: 'text/markdown; charset=utf-8'`.

Rails has real content negotiation baked in. The `text/markdown` MIME
type is registered by default, there's a dedicated `markdown:`
renderer that calls `to_markdown` on the object you pass it, and
`Vary: Accept` is set automatically when you negotiate via `respond_to`
— no MIME initializer, no manual render, no `after_action` for `Vary`.

## 1. Define `to_markdown` on the model

```ruby
class Post < ApplicationRecord
  def to_markdown
    # Whatever returns the Markdown representation — a stored column,
    # a computed conversion, or a cached field.
    body_markdown
  end
end
```

## 2. Use `respond_to` in the controller

```ruby
class PostsController < ApplicationController
  def show
    @post = Post.find_by!(slug: params[:slug])

    respond_to do |format|
      format.html
      format.md { render markdown: @post }
    end
  end
end
```

`render markdown: @post` calls `@post.to_markdown` and sends the result
with `Content-Type: text/markdown; charset=utf-8`. Rails parses
`Accept` and returns `406 Not Acceptable` if the client asked for
something you didn't register.

## Gotcha: `*/*` ties favor HTML

Rails' tie-breaking on equal-q formats falls back to registration
order, and HTML is registered before Markdown. That means a common
real-world header like:

```
Accept: text/markdown, text/html, */*
```

(Claude Code sends this) still resolves to **HTML** on current Rails,
because `*/*` pulls HTML up to a q=1 tie with `text/markdown` and HTML
wins the tie. Force the issue with a `before_action`:

```ruby
class ApplicationController < ActionController::Base
  before_action :prioritize_markdown_format

  private

  def prioritize_markdown_format
    return unless request.accepts.first&.to_s == 'text/markdown'
    request.formats = [:md, :html]
  end
end
```

If the client's top preference is `text/markdown`, pin `:md` to the
front of the format list for this request. Leaves other agents
unaffected.

This only fires when `text/markdown` is the **first** entry in Accept
with no explicit q-value. Headers like
`text/markdown;q=0.9, text/html;q=0.8` (Markdown preferred via
q-value rather than order) won't trigger it. For full q-value-aware
negotiation, replace the `before_action` with a custom
`ActionController::MimeResponds` extension — the header shape Claude
Code and other well-behaved agents send is covered by the version
above.

## 3. `Vary: Accept` is automatic

Rails sets `Vary: Accept` for you on negotiated responses. If you
already set a `Vary` header elsewhere (e.g. `Accept-Encoding`), Rails
won't overwrite it — you'll need to merge the value yourself in that
case:

```ruby
after_action :ensure_vary_accept

def ensure_vary_accept
  existing = response.headers['Vary']
  return if existing.to_s.downcase.split(',').map(&:strip).include?('accept')
  response.headers['Vary'] = existing ? "#{existing}, Accept" : 'Accept'
end
```

Otherwise, do nothing.

## Where the Markdown comes from

The value `to_markdown` returns can be:

- A dedicated column on the `posts` table, maintained by the author.
- **Action Text content** — for now, convert the rendered HTML via
  [`reverse_markdown`](https://github.com/xijo/reverse_markdown):
  ```ruby
  def to_markdown
    ReverseMarkdown.convert(body.to_s, github_flavored: true)
  end
  ```
  ```bash
  bundle add reverse_markdown
  ```
  [rails/rails#56858](https://github.com/rails/rails/pull/56858)
  (`to_markdown` on Action Text rich text) and
  [rails/rails#56896](https://github.com/rails/rails/pull/56896)
  (Trix-specific handling) are merged on main. Once they land in a
  tagged Rails release, you can drop the gem and delegate:
  `def to_markdown; body.to_markdown; end`.
- A cached representation regenerated on `after_save` — worth the DB
  bytes if rendering is hot.

## Verify

```bash
curl -sI -H "Accept: text/markdown" https://yoursite.com/posts/hello
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept

curl -sI -H "Accept: application/x-does-not-exist" \
  https://yoursite.com/posts/hello
# HTTP/1.1 406 Not Acceptable
```

## Notes

- Action Pack's format negotiation respects q-values by default. You
  don't need to write a parser.
- `request.format` in the controller returns the negotiated format —
  useful for conditional logic elsewhere.
- For JSON-API-style apps, prefer `format.json` over Markdown; this
  site is about *content* resources (articles, docs), not API objects.