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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/README.md +240 -0
- data/bin/update-bcd-snapshot +65 -0
- data/data/bcd-snapshot.json +341 -0
- data/exe/browsable +6 -0
- data/lib/browsable/analyzers/base.rb +74 -0
- data/lib/browsable/analyzers/css.rb +82 -0
- data/lib/browsable/analyzers/erb.rb +258 -0
- data/lib/browsable/analyzers/html.rb +11 -0
- data/lib/browsable/analyzers/javascript.rb +81 -0
- data/lib/browsable/cli.rb +333 -0
- data/lib/browsable/config.rb +163 -0
- data/lib/browsable/doctor.rb +189 -0
- data/lib/browsable/finding.rb +41 -0
- data/lib/browsable/formatters/github.rb +50 -0
- data/lib/browsable/formatters/human.rb +162 -0
- data/lib/browsable/formatters/json.rb +20 -0
- data/lib/browsable/policy_detector.rb +267 -0
- data/lib/browsable/policy_scanner.rb +73 -0
- data/lib/browsable/railtie.rb +16 -0
- data/lib/browsable/rake_tasks.rb +28 -0
- data/lib/browsable/report.rb +139 -0
- data/lib/browsable/sources/base.rb +45 -0
- data/lib/browsable/sources/builds.rb +10 -0
- data/lib/browsable/sources/importmap.rb +98 -0
- data/lib/browsable/sources/javascripts.rb +10 -0
- data/lib/browsable/sources/public_assets.rb +9 -0
- data/lib/browsable/sources/stylesheets.rb +9 -0
- data/lib/browsable/sources/views.rb +9 -0
- data/lib/browsable/target.rb +175 -0
- data/lib/browsable/version.rb +5 -0
- data/lib/browsable.rb +47 -0
- data/lib/generators/browsable/install/install_generator.rb +82 -0
- data/lib/generators/browsable/install/templates/browsable.yml.tt +51 -0
- metadata +185 -0
|
@@ -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,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,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
|
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
|