browsable 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ # The aggregated result of an audit: every Finding produced by every
5
+ # analyzer, plus a record of any analyzer or source that was skipped.
6
+ #
7
+ # A Report makes no decisions. It tells the user what their code requires and
8
+ # how that compares against their target; the formatters present it and the
9
+ # exit-code policy lives entirely in the caller's chosen --fail-on value.
10
+ class Report
11
+ # A skipped unit of work, e.g. { kind: :css, reason: "stylelint missing" }.
12
+ Skip = Data.define(:kind, :reason)
13
+
14
+ # A suggested allow_browser line that would resolve the below-target errors.
15
+ # `bumps` maps each raised browser to { from:, to: }.
16
+ Suggestion = Data.define(:line, :bumps)
17
+
18
+ attr_reader :findings, :skips, :notes, :policies, :target, :root, :config_file
19
+
20
+ # @param notes [Array<String>] caveats about the run itself (e.g. a target
21
+ # that could not be inferred) — distinct from per-file findings.
22
+ # @param policies [Array<PolicyScanner::Policy>] every allow_browser callsite
23
+ # discovered across the app's controllers — the policy landscape.
24
+ def initialize(findings: [], skips: [], notes: [], policies: [],
25
+ target: nil, root: nil, config_file: nil)
26
+ @findings = findings
27
+ @skips = skips
28
+ @notes = notes
29
+ @policies = policies
30
+ @target = target
31
+ @root = root
32
+ @config_file = config_file
33
+ end
34
+
35
+ def errors = findings.select(&:error?)
36
+ def warnings = findings.select(&:warning?)
37
+ def infos = findings.select(&:info?)
38
+
39
+ def empty? = findings.empty?
40
+
41
+ # Findings grouped by file path, files sorted, findings sorted by position.
42
+ def findings_by_file
43
+ findings
44
+ .group_by(&:file)
45
+ .sort_by(&:first)
46
+ .to_h
47
+ .transform_values { |group| group.sort_by { |f| [f.line, f.column] } }
48
+ end
49
+
50
+ # Exit code implementing the --fail-on policy.
51
+ def exit_code(fail_on:)
52
+ case fail_on.to_s
53
+ when "warning"
54
+ errors.any? || warnings.any? ? 1 : 0
55
+ when "error"
56
+ errors.any? ? 1 : 0
57
+ else
58
+ 0
59
+ end
60
+ end
61
+
62
+ # An allow_browser line that raises the offending browsers just enough to
63
+ # cover every below-target error, leaving the other browsers untouched.
64
+ #
65
+ # Returns nil when no error carries comparable version data — CSS/JS
66
+ # findings come from stylelint/eslint, which do not expose exact versions,
67
+ # so a suggestion can only be derived from HTML/ERB findings.
68
+ def suggestion
69
+ return @suggestion if defined?(@suggestion)
70
+
71
+ @suggestion = build_suggestion
72
+ end
73
+
74
+ def as_json
75
+ {
76
+ target: target&.as_json,
77
+ notes: notes,
78
+ summary: {
79
+ errors: errors.size,
80
+ warnings: warnings.size,
81
+ infos: infos.size,
82
+ files: findings_by_file.size
83
+ },
84
+ findings: findings.map(&:as_json),
85
+ skips: skips.map { |skip| { kind: skip.kind.to_s, reason: skip.reason } },
86
+ policies: policies.map { |policy| policy_as_json(policy) },
87
+ suggested_policy: suggestion && { line: suggestion.line, bumps: suggestion.bumps }
88
+ }
89
+ end
90
+
91
+ private
92
+
93
+ def policy_as_json(policy)
94
+ {
95
+ scope: policy.scope,
96
+ file: policy.file,
97
+ concern: policy.concern,
98
+ versions: policy.result.policy,
99
+ note: policy.result.note,
100
+ only: policy.only,
101
+ except: policy.except
102
+ }
103
+ end
104
+
105
+ def build_suggestion
106
+ return nil unless target
107
+
108
+ # The highest version each browser must reach to clear every error that
109
+ # offends it. A finding only contributes where required > current floor.
110
+ bumps = {}
111
+ errors.each do |finding|
112
+ finding.required_browser_versions.each do |browser, required|
113
+ floor = finding.target_browser_versions[browser]
114
+ next unless floor && newer?(required, floor)
115
+
116
+ bumps[browser] = required if bumps[browser].nil? || newer?(required, bumps[browser])
117
+ end
118
+ end
119
+ return nil if bumps.empty?
120
+
121
+ # Start from the current target and raise only the offending browsers.
122
+ versions = target.browsers.dup
123
+ detail = {}
124
+ bumps.each do |browser, raised_to|
125
+ detail[browser] = { from: versions[browser], to: raised_to }
126
+ versions[browser] = raised_to
127
+ end
128
+
129
+ pairs = versions.map { |browser, version| "#{browser}: #{version}" }
130
+ Suggestion.new(line: "allow_browser versions: { #{pairs.join(', ')} }", bumps: detail)
131
+ end
132
+
133
+ def newer?(left, right)
134
+ Gem::Version.new(left.to_s) > Gem::Version.new(right.to_s)
135
+ rescue ArgumentError
136
+ false
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module Sources
5
+ # Base class for a file source. A source expands a set of globs (relative
6
+ # to the project root) into a concrete, de-duplicated list of file paths.
7
+ #
8
+ # Sources only *discover* files — routing each file to the right analyzer
9
+ # is the orchestrator's job, done by extension.
10
+ class Base
11
+ attr_reader :root, :globs, :excludes
12
+
13
+ def initialize(root:, globs:, excludes: [])
14
+ @root = File.expand_path(root)
15
+ @globs = Array(globs)
16
+ @excludes = Array(excludes)
17
+ end
18
+
19
+ # A short symbol naming this source, used in reports (e.g. :stylesheets).
20
+ def name
21
+ self.class.name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
22
+ end
23
+
24
+ # The discovered files: existing, non-excluded, sorted, unique.
25
+ def files
26
+ globs
27
+ .flat_map { |glob| Dir.glob(File.join(root, glob), File::FNM_EXTGLOB) }
28
+ .select { |path| File.file?(path) }
29
+ .reject { |path| excluded?(path) }
30
+ .uniq
31
+ .sort
32
+ end
33
+
34
+ def any? = files.any?
35
+
36
+ private
37
+
38
+ def excluded?(path)
39
+ excludes.any? do |glob|
40
+ File.fnmatch?(File.join(root, glob), path, File::FNM_EXTGLOB | File::FNM_PATHNAME)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module Sources
5
+ # Compiled CSS under app/assets/builds — typically Tailwind output produced
6
+ # by the tailwindcss-rails gem. This is what Propshaft actually serves.
7
+ class Builds < Base
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "tmpdir"
6
+ require "digest"
7
+
8
+ module Browsable
9
+ module Sources
10
+ # Resolves importmap pins to their source and makes that source available
11
+ # to the JavaScript analyzer.
12
+ #
13
+ # Rails importmap apps keep no node_modules: vendored JS is referenced by
14
+ # CDN URL in config/importmap.rb. This source reads those pins and downloads
15
+ # each remote file into a tmpdir so eslint can be pointed at real files.
16
+ #
17
+ # config/importmap.rb is *parsed as text*, never evaluated — running an
18
+ # arbitrary project file just to read string literals is not worth the risk.
19
+ class Importmap
20
+ # `pin "name", to: "url", ...` — captures the name and an optional `to:`.
21
+ PIN_PATTERN = /^\s*pin\s+["']([^"']+)["'](?:[^\n]*?\bto:\s*["']([^"']+)["'])?/
22
+
23
+ Pin = Data.define(:name, :url) do
24
+ def remote? = url&.start_with?("http://", "https://")
25
+ end
26
+
27
+ attr_reader :root
28
+
29
+ def initialize(root:)
30
+ @root = File.expand_path(root)
31
+ end
32
+
33
+ def importmap_path
34
+ File.join(root, "config/importmap.rb")
35
+ end
36
+
37
+ def present?
38
+ File.file?(importmap_path)
39
+ end
40
+
41
+ # All pins declared in config/importmap.rb.
42
+ def pins
43
+ return [] unless present?
44
+
45
+ File.read(importmap_path).each_line.filter_map do |line|
46
+ next if line.strip.start_with?("#")
47
+
48
+ m = line.match(PIN_PATTERN)
49
+ m && Pin.new(name: m[1], url: m[2])
50
+ end
51
+ end
52
+
53
+ # Download every remote pin into `dir` and return the local file paths.
54
+ #
55
+ # Remote fetches are skipped entirely when BROWSABLE_OFFLINE=1 — useful in
56
+ # CI, in air-gapped environments, or when a fast offline audit is wanted.
57
+ def fetch(dir: Dir.mktmpdir("browsable-importmap"))
58
+ return [] if ENV["BROWSABLE_OFFLINE"] == "1"
59
+
60
+ pins.select(&:remote?).filter_map do |pin|
61
+ download(pin, dir)
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def download(pin, dir)
68
+ body = http_get(pin.url)
69
+ return nil unless body
70
+
71
+ filename = "#{pin.name.gsub(%r{[^\w.-]}, '_')}-#{Digest::SHA1.hexdigest(pin.url)[0, 8]}.js"
72
+ path = File.join(dir, filename)
73
+ File.write(path, body)
74
+ path
75
+ rescue StandardError
76
+ # A single unreachable CDN must not abort the whole audit.
77
+ nil
78
+ end
79
+
80
+ def http_get(url, redirects: 3)
81
+ return nil if redirects.negative?
82
+
83
+ uri = URI.parse(url)
84
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
85
+ open_timeout: 5, read_timeout: 10) do |http|
86
+ http.get(uri.request_uri)
87
+ end
88
+
89
+ case response
90
+ when Net::HTTPSuccess
91
+ response.body
92
+ when Net::HTTPRedirection
93
+ http_get(response["location"], redirects: redirects - 1)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module Sources
5
+ # First-party JavaScript under app/javascript (importmap-managed apps keep
6
+ # their own source here; pinned vendor code is handled by Sources::Importmap).
7
+ class Javascripts < Base
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module Sources
5
+ # Static assets shipped verbatim from public/ (error pages, etc.).
6
+ class PublicAssets < Base
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module Sources
5
+ # Hand-written stylesheets under app/assets/stylesheets.
6
+ class Stylesheets < Base
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module Sources
5
+ # ERB templates under app/views and view components under app/components.
6
+ class Views < Base
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Browsable
6
+ # A browser-support target: the browsers and minimum versions the project
7
+ # intends to support, expressed as a browserslist query.
8
+ #
9
+ # Target resolves a query into concrete minimum versions. It prefers the
10
+ # `browserslist` CLI (installed alongside node) and falls back to a small
11
+ # built-in table when node is not available.
12
+ class Target
13
+ # Rails 8's `allow_browser versions: :modern` resolves to roughly this set.
14
+ # Mirrors ActionController's modern-browser baseline so browsable can read
15
+ # the policy without booting Rails.
16
+ MODERN = {
17
+ "chrome" => "120", "edge" => "120", "firefox" => "121",
18
+ "safari" => "17.2", "opera" => "106"
19
+ }.freeze
20
+
21
+ # browsable's built-in approximation of the browserslist `defaults` query —
22
+ # a frozen snapshot, used only as a last resort when the `browserslist` CLI
23
+ # is not installed (so live caniuse data cannot be queried) and there is no
24
+ # policy or explicit target. #note flags whenever this fallback is in play.
25
+ DEFAULTS = {
26
+ "chrome" => "109", "edge" => "109", "firefox" => "115", "safari" => "15.6"
27
+ }.freeze
28
+
29
+ # Maps browserslist's browser codes onto the names browsable reports with.
30
+ BROWSERSLIST_ALIASES = {
31
+ "ios_saf" => "safari", "and_chr" => "chrome", "and_ff" => "firefox",
32
+ "samsung" => "samsung", "op_mob" => "opera"
33
+ }.freeze
34
+
35
+ attr_reader :query
36
+
37
+ # The Rails `:modern` baseline, fully resolved.
38
+ def self.modern
39
+ new("modern", resolved: MODERN)
40
+ end
41
+
42
+ # Build a Target from a Rails `allow_browser versions:` argument.
43
+ def self.from_rails_policy(versions)
44
+ case versions
45
+ when :modern, "modern", nil
46
+ modern
47
+ when Hash
48
+ new("custom allow_browser policy", resolved: normalize_hash(versions))
49
+ else
50
+ # A named policy we don't special-case — treat the name as a query.
51
+ new(versions.to_s)
52
+ end
53
+ end
54
+
55
+ def self.normalize_hash(hash)
56
+ hash.each_with_object({}) do |(browser, version), out|
57
+ out[browser.to_s] = version.to_s
58
+ end
59
+ end
60
+
61
+ def initialize(query, resolved: nil)
62
+ @query = query
63
+ @resolved = resolved
64
+ # How #browsers was obtained: :explicit (a caller supplied it, e.g. a
65
+ # Rails policy), :browserslist (the CLI), or :builtin (the frozen table).
66
+ @resolved_via = resolved ? :explicit : nil
67
+ end
68
+
69
+ # The resolved minimum versions, e.g. { "chrome" => "120", ... }.
70
+ def browsers
71
+ @resolved ||= resolve
72
+ end
73
+
74
+ # The minimum supported version of a browser, or nil if untargeted.
75
+ def minimum_version(browser)
76
+ browsers[browser.to_s]
77
+ end
78
+
79
+ # True when `version` of `browser` falls within the target.
80
+ def includes?(browser, version)
81
+ min = minimum_version(browser)
82
+ return false unless min && version
83
+
84
+ Gem::Version.new(version.to_s) >= Gem::Version.new(min.to_s)
85
+ rescue ArgumentError
86
+ false
87
+ end
88
+
89
+ # Format the target as browserslist query fragments, e.g.
90
+ # ["chrome >= 120", "safari >= 17.2"]. Used to configure stylelint/eslint.
91
+ def to_browserslist
92
+ browsers.map { |browser, version| "#{browser} >= #{version}" }
93
+ end
94
+
95
+ def to_s = query.to_s
96
+
97
+ # How #browsers was resolved: :explicit, :browserslist, or :builtin.
98
+ def resolved_via
99
+ browsers # force resolution
100
+ @resolved_via
101
+ end
102
+
103
+ # A caveat about this target's accuracy, or nil. Set when browsable had to
104
+ # fall back to its built-in table instead of querying live browserslist data.
105
+ def note
106
+ return nil unless resolved_via == :builtin
107
+ return nil if query.to_s.strip.downcase == "modern" # the builtin :modern set is exact
108
+
109
+ "The versions above are browsable's built-in approximation — the `browserslist` " \
110
+ "CLI is not installed, so live browser-share data could not be queried. Install " \
111
+ "it (npm install -g browserslist) or set an explicit `target:` in " \
112
+ "config/browsable.yml for accurate, stable versions."
113
+ end
114
+
115
+ def as_json
116
+ { query: query.to_s, browsers: browsers, resolved_via: resolved_via }
117
+ end
118
+
119
+ private
120
+
121
+ def resolve
122
+ if (from_cli = from_browserslist_cli)
123
+ @resolved_via = :browserslist
124
+ from_cli
125
+ else
126
+ @resolved_via = :builtin
127
+ builtin_fallback
128
+ end
129
+ end
130
+
131
+ # Shell out to the `browserslist` CLI when available. It emits one
132
+ # "<browser> <version>" line per supported version; we keep the lowest.
133
+ def from_browserslist_cli
134
+ stdout, _stderr, status = Open3.capture3("browserslist", query.to_s)
135
+ return nil unless status.success?
136
+
137
+ mins = {}
138
+ stdout.each_line do |line|
139
+ code, version = line.strip.split(/\s+/, 2)
140
+ next unless code && version
141
+
142
+ name = BROWSERSLIST_ALIASES.fetch(code, code)
143
+ version = version.split("-").first # ranges like "16.0-16.3"
144
+ current = mins[name]
145
+ mins[name] = version if current.nil? || gem_version(version) < gem_version(current)
146
+ end
147
+ mins.empty? ? nil : mins
148
+ rescue Errno::ENOENT
149
+ # `browserslist` is not installed — fall back to the built-in table.
150
+ nil
151
+ end
152
+
153
+ # A deliberately small parser for the common queries browsable sees when
154
+ # node is absent. Full browserslist semantics live in the CLI above.
155
+ def builtin_fallback
156
+ q = query.to_s.downcase.strip
157
+ return MODERN.dup if q == "modern"
158
+ return DEFAULTS.dup if q.empty? || q.include?("defaults")
159
+
160
+ parsed = {}
161
+ q.split(",").each do |clause|
162
+ if (m = clause.strip.match(/\A(\w[\w ]*?)\s*>=\s*([\d.]+)\z/))
163
+ parsed[m[1].strip] = m[2]
164
+ end
165
+ end
166
+ parsed.empty? ? DEFAULTS.dup : parsed
167
+ end
168
+
169
+ def gem_version(value)
170
+ Gem::Version.new(value)
171
+ rescue ArgumentError
172
+ Gem::Version.new("0")
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ VERSION = "0.1.0"
5
+ end
data/lib/browsable.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require_relative "browsable/version"
5
+
6
+ # browsable — a Rails-aware browser-compatibility audit toolkit.
7
+ #
8
+ # This file boots the Zeitwerk autoloader and declares the gem's error
9
+ # hierarchy. Every other constant under Browsable:: is autoloaded on first use.
10
+ module Browsable
11
+ # Base class for every error browsable raises deliberately.
12
+ class Error < StandardError; end
13
+
14
+ # Raised when a required external tool (node, stylelint, ...) is missing.
15
+ class DependencyError < Error; end
16
+
17
+ # Raised when a config file exists but cannot be parsed or is invalid.
18
+ class ConfigError < Error; end
19
+
20
+ class << self
21
+ # The shared Zeitwerk loader. Exposed so specs (and rake) can eager-load.
22
+ attr_accessor :loader
23
+
24
+ # Absolute path to the gem's bundled `data/` directory.
25
+ def data_dir
26
+ File.expand_path("../data", __dir__)
27
+ end
28
+ end
29
+ end
30
+
31
+ Browsable.loader = Zeitwerk::Loader.for_gem
32
+ Browsable.loader.inflector.inflect(
33
+ "cli" => "CLI",
34
+ "css" => "CSS",
35
+ "erb" => "ERB",
36
+ "html" => "HTML"
37
+ )
38
+ # These files intentionally do not define a constant matching their path.
39
+ Browsable.loader.ignore("#{__dir__}/browsable/version.rb")
40
+ Browsable.loader.ignore("#{__dir__}/browsable/rake_tasks.rb")
41
+ Browsable.loader.ignore("#{__dir__}/browsable/railtie.rb")
42
+ # Rails generators are discovered and loaded by Rails itself, not Zeitwerk.
43
+ Browsable.loader.ignore("#{__dir__}/generators")
44
+ Browsable.loader.setup
45
+
46
+ # The railtie wires rake tasks into a host Rails app. Only relevant inside Rails.
47
+ require_relative "browsable/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "browsable"
5
+
6
+ module Browsable
7
+ module Generators
8
+ # `rails g browsable:install` — writes a fully-commented config/browsable.yml.
9
+ #
10
+ # The generated file is a self-documenting reference: every option is
11
+ # present, commented out, and set to its default. browsable needs no config
12
+ # to run; this file exists only so overriding a default is one uncomment away.
13
+ class InstallGenerator < Rails::Generators::Base
14
+ desc "Creates a commented config/browsable.yml you can edit to override defaults."
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ class_option :minimal, type: :boolean, default: false,
18
+ desc: "Write section headers only, not the full commented body"
19
+ class_option :target, type: :string,
20
+ desc: "Pre-populate target.source: manual with this query"
21
+ class_option :force, type: :boolean, default: false,
22
+ desc: "Overwrite an existing config/browsable.yml"
23
+
24
+ def create_config_file
25
+ if File.exist?(config_destination) && !options[:force]
26
+ say_status :skip, "config/browsable.yml already exists (pass --force to overwrite)", :yellow
27
+ @skipped = true
28
+ return
29
+ end
30
+
31
+ template "browsable.yml.tt", "config/browsable.yml", force: options[:force]
32
+ end
33
+
34
+ def print_summary
35
+ return if @skipped
36
+
37
+ say ""
38
+ say "browsable: created config/browsable.yml", :green
39
+ say " Every option is commented out and set to its default — the file is", :white
40
+ say " optional and exists only for overrides. Uncomment a line to change it.", :white
41
+ say " Run `bundle exec browsable audit` for your first audit.", :white
42
+ end
43
+
44
+ private
45
+
46
+ def config_destination
47
+ File.join(destination_root, "config/browsable.yml")
48
+ end
49
+
50
+ # The following three methods are referenced as bare names by the ERB
51
+ # template (templates/browsable.yml.tt). They are private so Rails does
52
+ # not run them as generator steps.
53
+
54
+ def detected_comment
55
+ case allow_browser_policy
56
+ when nil
57
+ "# (No allow_browser call detected in ApplicationController.)"
58
+ when Hash
59
+ "# Detected: ApplicationController declares an explicit allow_browser versions hash."
60
+ else
61
+ "# Detected: ApplicationController uses `allow_browser versions: :#{allow_browser_policy}`"
62
+ end
63
+ end
64
+
65
+ def manual_query
66
+ options[:target]
67
+ end
68
+
69
+ def minimal
70
+ options[:minimal]
71
+ end
72
+
73
+ # Reuse the core gem's detector so the generator and the CLI agree on
74
+ # exactly which allow_browser forms (symbol, hash, commented-out) count.
75
+ def allow_browser_policy
76
+ return @allow_browser_policy if defined?(@allow_browser_policy)
77
+
78
+ @allow_browser_policy = Browsable::Config.load(root: destination_root).detected_policy
79
+ end
80
+ end
81
+ end
82
+ end