# Browsable **Rails-aware browser-compatibility auditing for your frontend.** Find out which browsers your Rails app is actually *browsable by* β€” before your users do. [![Gem Version](https://img.shields.io/gem/v/browsable.svg)](https://rubygems.org/gems/browsable) [![CI](https://github.com/romanhood/browsable/actions/workflows/ci.yml/badge.svg)](https://github.com/romanhood/browsable/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D.svg)](https://www.ruby-lang.org/)

Browsable audits a Rails application's CSS, HTML, ERB, and JavaScript and reports which browsers can actually render and run it β€” then compares the answer against the allow_browser policy you've declared.

The name is a play on Rails 8's allow_browser controller API. Instead of declaring which browsers you allow, browsable tells you which browsers your code is actually browsable by.

πŸ“¦ This is the core gem of the browsable monorepo. See also browsable-lsp for editor diagnostics and browsable.nvim for Neovim.

Table of contents

Why Browsable?

Rails 8 made browser support a first-class concern with allow_browser. But the framework has no opinion on whether your CSS actually works in the browsers you allowed. You can declare allow_browser :modern and silently ship :has() selectors that break in Safari 15. There was no tool that closed that loop β€” until now.

Browsable closes it by:

  • πŸ” Reading your allow_browser policy straight from ApplicationController
  • 🎯 Translating it into a precise browserslist query
  • πŸ“‚ Discovering your stylesheets, views, JavaScript, and importmap pins
  • βœ… Auditing each against best-in-class compat databases (MDN BCD, caniuse)
  • πŸ“‹ Reporting by file, with exact lines and suggested fixes

No package.json. No node_modules. No build-system pollution in your Rails repo.

Installation

Add it to your Rails app's Gemfile:

group :development, :test do
  gem "browsable"
end

Then bundle install. Or install it standalone:

gem install browsable

πŸ’‘ Heads up: Browsable shells out to stylelint and eslint for CSS and JS analysis. These live globally on your machine β€” not in your Rails repo. Run browsable doctor to check and install them.

Quick start

bundle exec browsable audit

That's it. With zero configuration, Browsable will:

  1. Read ApplicationController's allow_browser policy to learn your target
  2. Discover your stylesheets, views, JavaScript, and importmap pins
  3. Audit each against that target
  4. Report the findings, grouped by file

Example output

$ bundle exec browsable audit

βœ“ Target inferred from ApplicationController.allow_browser :modern
  β†’ chrome 120, edge 120, firefox 121, safari 17.2, opera 106

⚠ app/assets/stylesheets/cards.css
    42:3   :has() selector requires Safari 15.4+ (policy allows 17.2 βœ“)
    87:5   @container query requires Firefox 110+ (policy allows 121 βœ“)

βœ— app/views/legacy/embed.html.erb
    14:22  <dialog> element requires Safari 15.4+, but the LegacyController
           policy allows Safari 12.0

Browser policies (2 found)
    ApplicationController             :modern
    LegacyController                  { safari: 12, chrome: 60 }  (only: embed)

1 error, 0 warnings β€” exit 1

System dependencies

Browsable shells out to a few external tools that live globally on your machine:

Tool Purpose Required?
node JavaScript runtime for stylelint & eslint Yes
stylelint CSS compatibility analysis Yes (CSS audits)
eslint + eslint-plugin-compat JavaScript compatibility analysis Yes (JS audits)
browserslist Live resolution of defaults queries Optional
herb ERB parsing Bundled (gem dep)

The doctor workflow

bundle exec browsable doctor

For each missing tool, doctor prints the exact command to install it. Or let it do the work:

bundle exec browsable doctor --fix

This installs missing tools via brew or npm β€” opt-in, never automatic.

CLI reference

Commands

Command Purpose
browsable (or browsable audit) Full project audit
browsable audit [PATH] Audit a specific directory
browsable check FILE [FILE...] Audit specific files (used by editors)
browsable doctor Check system dependencies
browsable doctor --fix Install missing dependencies
browsable target [PATH] Show the inferred browser-support target
browsable replay PATH Reformat a JSON audit dump (test-suite mode β†’ GitHub annotations)
browsable init Generate .browsable.yml (non-Rails projects)
browsable version Print the version

Flags

Flag Effect
--target QUERY Override the inferred browserslist query
--json Emit findings as JSON (shortcut for --format json)
`--format human\ json\
--no-build Scan only what's on disk (Browsable never builds assets itself)
--include GLOB Add a path glob (repeatable)
--exclude GLOB Exclude a path glob (repeatable)
`--fail-on warning\ error`
--config PATH Override the config file location

πŸ’‘ The --json output is the universal interface. The LSP server and any future MCP server consume that exact structure. The human and github formatters are just alternate presentations of the same data.

Configuration

Browsable needs no config file. Configuration is for overrides only.

When a file is present, it's discovered in this order:

  1. The path passed to --config
  2. config/browsable.yml (preferred in Rails apps)
  3. .browsable.yml in the working directory

Resolution precedence (highest wins):

CLI flags  β†’  config file  β†’  inferred Rails config  β†’  gem defaults

Generating a config file

rails g browsable:install

This writes a fully-commented config/browsable.yml β€” every option present, commented out, set to its default. It's a self-documenting reference: uncomment a line to override it.

Flag Effect
--minimal Section headers only, no option reference
--target QUERY Pre-populate the target
--force Overwrite an existing config

Non-Rails projects use browsable init, which writes .browsable.yml instead.

How it works

The inference chain

   ApplicationController.allow_browser           β†’    Target
       :modern                                        chrome 120, safari 17.2, ...
                                                          β”‚
                                                          β–Ό
   config/importmap.rb ─┐                              Sources
   app/assets/**       ─┼─→   discovered files   ─→     β”‚
   app/views/**        ──                               β”‚
   app/javascript/**   β”€β”˜                               β–Ό
                                                     Analyzers
                                                        β”‚
                                              CSS  β†’ stylelint
                                              ERB  β†’ Herb + MDN BCD
                                              HTML β†’ Herb + MDN BCD
                                              JS   β†’ eslint + eslint-plugin-compat
                                                        β”‚
                                                        β–Ό
                                                     Report β†’ Formatter

Browsable's job is the glue between Rails-land and browserslist-land. It reads allow_browser :modern, expands it to concrete browser versions, configures stylelint and eslint with that target, and runs Herb against a bundled MDN browser-compat-data snapshot for ERB and HTML.

Partial allow_browser policies

If your allow_browser policy is a hash that pins only some browsers β€” say versions: { safari: 16.4, firefox: 121 } β€” Rails leaves every browser you don't list allowed at any version. It only blocks a browser it was explicitly given a minimum (or false) for.

Browsable audits exactly the browsers you pinned and prints a note naming the rest. To audit against more, set an explicit target: in config/browsable.yml. The same note-and-fall-back-to-defaults behavior applies when Browsable can't resolve your policy statically.

Where defaults comes from

When there's no allow_browser policy at all, Browsable audits against the browserslist defaults query β€” the "reasonable broad support" baseline the wider frontend ecosystem uses.

  • With browserslist installed (npm install -g browserslist): resolved live from caniuse data
  • Without it: a small built-in approximation, with a note saying so

Either way, these versions are not a Rails concept β€” Rails blocks nothing unless you call allow_browser β€” and they aren't derived from stylelint or eslint. For a precise, stable target, set target: in config/browsable.yml.

Suggested allow_browser fix

When an audit finds errors that are purely a version conflict β€” your code needs a browser version newer than your policy permits β€” Browsable prints a ready-to-paste allow_browser line that raises only the offending browsers to the minimum those features require:

πŸ’‘ Suggested allow_browser policy

   allow_browser versions: {
     chrome:  120,
     edge:    120,
     firefox: 125,    # ← was 121
     safari:  17.2,
     opera:   106
   }

It's a suggestion, not an instruction. Tightening the policy is one fix; changing the code (a fallback, a @supports rule) is another. Browsable reports β€” you decide.

The suggestion is derived from HTML/ERB findings, which carry exact version data. It also appears in --json output as suggested_policy and as a GitHub Actions notice.

Runtime auditing (test-suite mode)

Static mode answers the question β€œdoes my codebase satisfy a single browser-support target?”. Runtime mode answers a sharper one: β€œfor every endpoint in my app, does the HTML it actually renders satisfy that endpoint's policy?” It does this without trying to build a static asset β†’ endpoint graph β€” instead, it lets Rails itself say what each endpoint renders during a test run, and audits that.

Runtime mode uses the same machine-level tools as static mode β€” node, stylelint, eslint. No package.json, no node_modules in your Rails app. The middleware records during the suite; analysis happens once, at the end, with one stylelint and one eslint invocation regardless of how many request specs you ran.

Adoption

# Gemfile
group :development, :test do
  gem "browsable"
end
bundle install
bundle exec browsable doctor       # one-time, installs stylelint / eslint if missing
# spec/rails_helper.rb (RSpec)
require "browsable/rspec"

# OR test/test_helper.rb (Minitest)
require "browsable/minitest"

Then run your suite as you normally would:

bundle exec rspec        # or: bundle exec rails test

At end-of-suite Browsable prints a report grouped by Controller#action, with each finding evaluated against that endpoint's effective allow_browser policy.

How it works

   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚   Rack middleware    β”‚   per request: parse HTML, resolve asset URLs,
   β”‚   (records only)     β”‚   look up policy, push to AuditLog. NO subprocesses.
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚     AuditLog         β”‚   thread-safe accumulator of
   β”‚     (in-memory)      β”‚   (endpoint, policy, html, asset_paths)
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚  end of suite
              β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚     TestReport       β”‚   deduplicated asset universe ➜
   β”‚  (one stylelint,     β”‚     stylelint Γ— 1, eslint Γ— 1
   β”‚   one eslint call)   β”‚     findings ➜ attributed back to endpoints
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • The middleware never analyzes. Per-request work is a Nokogiri parse plus URL resolution β€” under a few milliseconds on a typical page.
  • The middleware never runs in production. It raises at construction if Rails.env.production?.
  • Analysis is one batch. A 500-spec suite that hits 50 unique HTML pages loading the same 10 CSS files spawns two Node processes, total β€” not 500.
  • Endpoint-level policies. The PolicyResolver walks each controller's ancestor chain, applies each allow_browser call's only:/except: filter, and picks the last matching one β€” matching Rails' own filter-callback semantics.

Configuration

The drivers ship with sensible defaults. Override per-suite:

Browsable::RSpec.configure do |c|
  c.fail_on = :error         # :error | :warning | :never
  c.format  = :human         # :human | :json | :github
  c.output  = "tmp/browsable_report.json"
end

For CI: dump the report as JSON during the test run, then re-render it as GitHub annotations:

bundle exec rspec
bundle exec browsable replay tmp/browsable_report.json --format github

Example output

browsable audit
target: runtime-union  (chrome 100, firefox 121, safari 17.2)

[response] PostsController#show
  βœ— 14:22  popover  the 'popover' attribute needs Safari 17+, but PostsController#show
                    policy allows Safari 15

app/assets/builds/application.css
  β–² 42:3   css-has  ":has()" is not a known feature

Browser policies (2 found)
  ApplicationController             :modern
  LegacyController                  { safari: 15, chrome: 100 }  (only: embed)

1 error, 1 warning  across 2 file(s)

[response] Controller#action lines are findings against an endpoint's rendered HTML; ordinary file paths are findings against assets the endpoint loaded β€” the JSON output (browsable replay … --format json) preserves the full endpoint-to-finding mapping so dashboards can reconstruct it.

Compatibility

  • Rails 7.1+ (middleware reads env["action_controller.instance"])
  • Ruby 3.2+
  • Propshaft (preferred), with a Sprockets + filesystem fallback
  • RSpec 3.10+ or Minitest 5.15+

Per-controller and per-action policies

Rails lets any controller override allow_browser and scope the override to certain actions with only: / except:. Browsable scans every file under app/controllers/ (including concerns/) and lists each allow_browser call it finds β€” with its versions and any action scope β€” under Browser policies in the report.

In static mode, the audit runs against a single target. CSS and importmap JavaScript are global assets, included via layout helpers on nearly every page, so they have no single owning controller action β€” and a static asset β†’ endpoint graph would be guesswork.

In runtime mode (v0.2+), this is solved properly: the middleware sees the actual HTML each endpoint renders during a test run, so findings are attached to the endpoints that actually loaded the asset, against each endpoint's specific policy. See Runtime auditing above.

Rake tasks

Inside a Rails app, the railtie registers three tasks:

Task Behavior
rake browsable:audit Audit app/assets/builds/ as it stands
rake browsable:audit:fresh Run assets:precompile first, then audit
rake browsable:doctor Run the dependency check

⚠️ Browsable never precompiles assets on its own. In CI, compose the pipeline explicitly:

bundle exec rails assets:precompile && bundle exec browsable audit

Contributing

This gem lives in the browsable/ subdirectory of the monorepo. To work on it:

git clone https://github.com/romanhood/browsable
cd browsable/browsable
bundle install
bundle exec rspec

Refresh the bundled MDN browser-compat-data snapshot:

ruby bin/update-bcd-snapshot

Bug reports and pull requests welcome. The monorepo has a CONTRIBUTING.md with the broader workflow.

License

MIT β€” see the LICENSE file at the monorepo root.


Made with care for Rails developers who refuse to add a `package.json` to their app. πŸ›€οΈ [Monorepo][monorepo] Β· [LSP server][lsp] Β· [Neovim plugin][nvim] Β· [Report an issue][issues]