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
browsablemonorepo. See alsobrowsable-lspfor editor diagnostics andbrowsable.nvimfor Neovim.
Table of contents
- Why Browsable?
- Installation
- Quick start
- System dependencies
- CLI reference
- Configuration
- How it works
- Runtime auditing (test-suite mode)
- Per-controller policies
- Suggested policy fixes
- Rake tasks
- Contributing
- License
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_browserpolicy straight fromApplicationController - π― 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
stylelintandeslintfor CSS and JS analysis. These live globally on your machine β not in your Rails repo. Runbrowsable doctorto check and install them.
Quick start
bundle exec browsable audit
That's it. With zero configuration, Browsable will:
- Read
ApplicationController'sallow_browserpolicy to learn your target - Discover your stylesheets, views, JavaScript, and importmap pins
- Audit each against that target
- 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
--jsonoutput is the universal interface. The LSP server and any future MCP server consume that exact structure. Thehumanandgithubformatters 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:
- The path passed to
--config config/browsable.yml(preferred in Rails apps).browsable.ymlin 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
browserslistinstalled (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
PolicyResolverwalks each controller's ancestor chain, applies eachallow_browsercall'sonly:/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.