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,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Browsable
7
+ module Analyzers
8
+ # Base class for analyzers. An analyzer turns a list of files into Findings.
9
+ #
10
+ # browsable owns no parsing or compat-data logic of its own: analyzers are
11
+ # thin adapters over Herb (in-process) or stylelint/eslint (shelled out).
12
+ class Base
13
+ attr_reader :target, :config
14
+
15
+ def initialize(target:, config:)
16
+ @target = target
17
+ @config = config
18
+ end
19
+
20
+ # @param files [Array<String>] absolute paths to analyze
21
+ # @return [Array<Finding>]
22
+ def analyze(_files)
23
+ raise NotImplementedError, "#{self.class} must implement #analyze"
24
+ end
25
+
26
+ # External binaries this analyzer needs on PATH. Empty for in-process ones.
27
+ def required_tools = []
28
+
29
+ # The bundled MDN browser-compat-data subset, parsed once and shared.
30
+ def self.compat_data
31
+ @compat_data ||= JSON.parse(
32
+ File.read(File.join(Browsable.data_dir, "bcd-snapshot.json"))
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ def compat_data = self.class.compat_data
39
+
40
+ # Translate a severity category into a concrete Finding severity symbol,
41
+ # honouring the user's `severity:` config block.
42
+ def severity_for(category)
43
+ (config.severity[category.to_s] || "warning").to_sym
44
+ end
45
+
46
+ def ignored_feature?(feature_id)
47
+ config.ignore_features.include?(feature_id)
48
+ end
49
+
50
+ def dry_run? = ENV.key?("BROWSABLE_DRY_RUN")
51
+
52
+ # Run an external tool and return its stdout.
53
+ #
54
+ # In dry-run mode the process is never spawned: BROWSABLE_DRY_RUN_<KEY>
55
+ # supplies the output instead, either as inline JSON or as a path to a
56
+ # JSON file. This is the seam specs use to inject fake stylelint/eslint
57
+ # output without those tools installed.
58
+ def shell_out(argv, dry_run_key:)
59
+ if dry_run?
60
+ injected = ENV[dry_run_key]
61
+ return File.read(injected) if injected && File.file?(injected)
62
+ return injected if injected && !injected.empty?
63
+
64
+ return "[]"
65
+ end
66
+
67
+ # stylelint and eslint exit non-zero whenever they report problems, so
68
+ # the exit status is deliberately ignored — only stdout matters here.
69
+ stdout, _stderr, _status = Open3.capture3(*argv)
70
+ stdout
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tmpdir"
5
+
6
+ module Browsable
7
+ module Analyzers
8
+ # Audits CSS by shelling out to stylelint with the
9
+ # `stylelint-no-unsupported-browser-features` plugin configured for the
10
+ # project's target. browsable supplies the config; stylelint (and its
11
+ # bundled caniuse data) does the actual compatibility reasoning.
12
+ class CSS < Base
13
+ def required_tools = ["stylelint"]
14
+
15
+ def analyze(files)
16
+ return [] if files.empty?
17
+
18
+ argv = ["stylelint", "--config", write_stylelintrc,
19
+ "--formatter", "json", *files]
20
+ parse(shell_out(argv, dry_run_key: "BROWSABLE_DRY_RUN_CSS"))
21
+ end
22
+
23
+ private
24
+
25
+ # Write a throwaway .stylelintrc.json scoped to the current target.
26
+ #
27
+ # TODO(v0.2): pass --resolve-plugins-relative-to so a globally-installed
28
+ # stylelint-no-unsupported-browser-features resolves reliably regardless
29
+ # of where the temp config lives.
30
+ def write_stylelintrc
31
+ dir = Dir.mktmpdir("browsable-stylelint")
32
+ path = File.join(dir, ".stylelintrc.json")
33
+ File.write(path, JSON.pretty_generate(stylelint_config))
34
+ path
35
+ end
36
+
37
+ def stylelint_config
38
+ {
39
+ "plugins" => ["stylelint-no-unsupported-browser-features"],
40
+ "rules" => {
41
+ "plugin/no-unsupported-browser-features" => [
42
+ true,
43
+ { "browsers" => target.to_browserslist, "severity" => "warning" }
44
+ ]
45
+ }
46
+ }
47
+ end
48
+
49
+ def parse(raw)
50
+ data = JSON.parse(raw)
51
+ return [] unless data.is_a?(Array)
52
+
53
+ data.flat_map do |result|
54
+ Array(result["warnings"]).filter_map do |warning|
55
+ finding_from_warning(result["source"], warning)
56
+ end
57
+ end
58
+ rescue JSON::ParserError
59
+ []
60
+ end
61
+
62
+ def finding_from_warning(file, warning)
63
+ text = warning["text"].to_s
64
+ # stylelint phrases the feature as a quoted token, e.g. "css-has".
65
+ feature = text[/"([^"]+)"/, 1] || warning["rule"].to_s
66
+ return nil if ignored_feature?(feature)
67
+
68
+ Finding.new(
69
+ feature_id: "css.#{feature}",
70
+ feature_name: feature,
71
+ file: file,
72
+ line: warning["line"] || 1,
73
+ column: warning["column"] || 1,
74
+ required_browser_versions: {}, # stylelint does not expose exact versions
75
+ target_browser_versions: target.browsers,
76
+ severity: warning["severity"] == "error" ? severity_for("below_target") : :warning,
77
+ message: text
78
+ )
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module Analyzers
5
+ # Audits ERB templates (and plain HTML) by parsing them in-process with the
6
+ # Herb gem, then looking up every HTML element and global attribute against
7
+ # the bundled MDN browser-compat-data snapshot.
8
+ #
9
+ # No external tools are needed — Herb is a gem dependency — so ERB/HTML
10
+ # analysis works on a machine with nothing else installed.
11
+ class ERB < Base
12
+ Usage = Data.define(:kind, :name, :line, :column)
13
+
14
+ def required_tools = [] # Herb is in-process
15
+
16
+ def analyze(files)
17
+ files.flat_map do |file|
18
+ analyze_source(File.read(file), file: file)
19
+ rescue StandardError
20
+ []
21
+ end
22
+ end
23
+
24
+ # Analyze one template's source text. Exposed directly for the LSP server,
25
+ # which audits unsaved, in-memory buffer contents.
26
+ def analyze_source(source, file:)
27
+ extract_usages(source)
28
+ .uniq { |usage| [usage.kind, usage.name, usage.line] }
29
+ .filter_map { |usage| build_finding(usage, file: file) }
30
+ end
31
+
32
+ private
33
+
34
+ # --- feature extraction --------------------------------------------------
35
+
36
+ def extract_usages(source)
37
+ # Trust Herb's result, including an empty one: a template with no HTML
38
+ # (all-ERB partials, comment-only files) legitimately yields no usages.
39
+ # The coarse scan runs *only* when Herb actually raises — e.g. the gem
40
+ # is somehow missing — never because Herb returned nothing.
41
+ herb_usages(source)
42
+ rescue StandardError
43
+ scan_usages(source)
44
+ end
45
+
46
+ def herb_usages(source)
47
+ require "herb"
48
+
49
+ result = ::Herb.parse(source)
50
+ root = result.respond_to?(:value) ? result.value : result
51
+ usages = []
52
+ walk(root) { |node| collect_usage(node, usages) }
53
+ usages
54
+ end
55
+
56
+ def walk(node, &block)
57
+ return unless node
58
+
59
+ block.call(node)
60
+ children_of(node).each { |child| walk(child, &block) }
61
+ end
62
+
63
+ def children_of(node)
64
+ %i[child_nodes compact_child_nodes children].each do |accessor|
65
+ next unless node.respond_to?(accessor)
66
+
67
+ kids = node.public_send(accessor)
68
+ return Array(kids).compact if kids
69
+ end
70
+ []
71
+ end
72
+
73
+ def collect_usage(node, usages)
74
+ class_name = node.class.name.to_s
75
+
76
+ if class_name.include?("HTMLOpenTag") && node.respond_to?(:tag_name)
77
+ name = token_text(node.tag_name)
78
+ line, column = node_position(node)
79
+ usages << Usage.new(kind: :element, name: name.to_s.downcase, line: line, column: column) if name
80
+ elsif class_name.include?("HTMLAttribute") &&
81
+ !class_name.include?("Name") && !class_name.include?("Value")
82
+ name = token_text(node.respond_to?(:name) ? node.name : node)
83
+ line, column = node_position(node)
84
+ usages << Usage.new(kind: :attribute, name: name.to_s.downcase, line: line, column: column) if name
85
+ end
86
+ end
87
+
88
+ # Extract the text of a Herb token or name node. Herb represents a tag or
89
+ # attribute name as a Token (#value), a LiteralNode (#content), or a
90
+ # composite name node that wraps a LiteralNode in its children.
91
+ def token_text(obj)
92
+ return nil if obj.nil?
93
+ return obj if obj.is_a?(String)
94
+ return obj.value if obj.respond_to?(:value) && obj.value.is_a?(String)
95
+ return obj.content if obj.respond_to?(:content) && obj.content.is_a?(String)
96
+
97
+ if obj.respond_to?(:child_nodes)
98
+ Array(obj.child_nodes).compact.each do |child|
99
+ text = token_text(child)
100
+ return text if text
101
+ end
102
+ end
103
+ nil
104
+ end
105
+
106
+ def node_position(node)
107
+ loc = node.location if node.respond_to?(:location)
108
+ start = nil
109
+ start = loc.start if loc.respond_to?(:start)
110
+ start ||= loc.start_position if loc.respond_to?(:start_position)
111
+ line = start.respond_to?(:line) ? start.line : 1
112
+ column = start.respond_to?(:column) ? start.column.to_i + 1 : 1
113
+ [line, column]
114
+ rescue StandardError
115
+ [1, 1]
116
+ end
117
+
118
+ # Degraded extraction: a line-by-line scan for opening tags and bare
119
+ # attribute names. Noise is mostly harmless — only names present in the
120
+ # BCD snapshot survive the lookup in #build_finding — but ERB tags must be
121
+ # blanked first so Ruby code and comment prose are never read as markup.
122
+ # Blanking (rather than deleting) preserves line and column numbers.
123
+ def scan_usages(source)
124
+ usages = []
125
+ erb_blanked(source).each_line.with_index(1) do |line, number|
126
+ line.scan(/<([a-zA-Z][a-zA-Z0-9-]*)/) do
127
+ usages << Usage.new(kind: :element, name: Regexp.last_match(1).downcase,
128
+ line: number, column: (Regexp.last_match.begin(1) || 0))
129
+ end
130
+ line.scan(/[\s"']([a-zA-Z][a-zA-Z0-9-]*)(?==|>|\s|\z)/) do
131
+ usages << Usage.new(kind: :attribute, name: Regexp.last_match(1).downcase,
132
+ line: number, column: (Regexp.last_match.begin(1) || 0) + 1)
133
+ end
134
+ end
135
+ usages
136
+ end
137
+
138
+ # Replace every ERB tag (<% %>, <%= %>, <%# %>) with spaces, keeping
139
+ # newlines so line/column positions are unchanged. The contents of an ERB
140
+ # tag — Ruby code or comment text — are never HTML.
141
+ def erb_blanked(source)
142
+ source.gsub(/<%.*?%>/m) { |tag| tag.gsub(/[^\n]/, " ") }
143
+ end
144
+
145
+ # --- compat lookup -------------------------------------------------------
146
+
147
+ def build_finding(usage, file:)
148
+ compat = compat_for(usage)
149
+ return nil unless compat
150
+
151
+ feature_id = feature_id_for(usage)
152
+ return nil if ignored_feature?(feature_id)
153
+
154
+ support = compat["support"] || {}
155
+ required = required_versions(support)
156
+ below = browsers_below_target(required, unsupported_browsers(support))
157
+ baseline = compat.dig("status", "baseline")
158
+
159
+ category = categorize(below, baseline)
160
+ return nil unless category # widely available — nothing worth reporting
161
+
162
+ Finding.new(
163
+ feature_id: feature_id,
164
+ feature_name: usage.name,
165
+ file: file,
166
+ line: usage.line,
167
+ column: usage.column,
168
+ required_browser_versions: required,
169
+ target_browser_versions: target.browsers,
170
+ severity: severity_for(category),
171
+ message: message_for(usage, category, below, required)
172
+ )
173
+ end
174
+
175
+ def compat_for(usage)
176
+ table =
177
+ if usage.kind == :element
178
+ compat_data.dig("html", "elements")
179
+ else
180
+ compat_data.dig("html", "global_attributes")
181
+ end
182
+ entry = table&.dig(usage.name)
183
+ entry && entry["__compat"]
184
+ end
185
+
186
+ def feature_id_for(usage)
187
+ segment = usage.kind == :element ? "elements" : "global_attributes"
188
+ "html.#{segment}.#{usage.name}"
189
+ end
190
+
191
+ def categorize(below, baseline)
192
+ return "below_target" if below.any?
193
+ return "baseline_limited" if baseline == false
194
+ return "baseline_newly_available" if baseline == "low"
195
+
196
+ nil
197
+ end
198
+
199
+ def required_versions(support)
200
+ support.each_with_object({}) do |(browser, info), out|
201
+ added = support_version(info)
202
+ out[browser] = added if added.is_a?(String)
203
+ end
204
+ end
205
+
206
+ def support_version(info)
207
+ entry = info.is_a?(Array) ? info.first : info
208
+ entry && entry["version_added"]
209
+ end
210
+
211
+ def unsupported_browsers(support)
212
+ support.select { |_browser, info| support_version(info) == false }.keys
213
+ end
214
+
215
+ def browsers_below_target(required, unsupported)
216
+ target.browsers.filter_map do |browser, floor|
217
+ if unsupported.include?(browser)
218
+ browser
219
+ elsif (req = required[browser]) && version_gt?(req, floor)
220
+ browser
221
+ end
222
+ end
223
+ end
224
+
225
+ def version_gt?(left, right)
226
+ Gem::Version.new(left.to_s) > Gem::Version.new(right.to_s)
227
+ rescue ArgumentError
228
+ false
229
+ end
230
+
231
+ def message_for(usage, category, below, required)
232
+ label = usage.kind == :element ? "<#{usage.name}>" : "the '#{usage.name}' attribute"
233
+
234
+ case category
235
+ when "below_target"
236
+ clauses = below.map do |browser|
237
+ name = titleize(browser)
238
+ required_version = required[browser]
239
+ if required_version
240
+ "needs #{name} #{required_version}+, but your target allows #{name} #{target.minimum_version(browser)}"
241
+ else
242
+ "is not supported by #{name} at any version"
243
+ end
244
+ end
245
+ "#{label} #{clauses.join('; ')}."
246
+ when "baseline_limited"
247
+ "#{label} has limited availability (not Baseline) — provide a fallback."
248
+ else
249
+ "#{label} is newly available (Baseline low) — confirm it covers the browsers you support."
250
+ end
251
+ end
252
+
253
+ def titleize(browser)
254
+ browser.split("_").first.capitalize
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module Analyzers
5
+ # Audits static .html files. Herb parses HTML and ERB with the same API, so
6
+ # the only difference from the ERB analyzer is intent — this exists as a
7
+ # distinct class so the orchestrator can route plain-HTML sources explicitly.
8
+ class HTML < ERB
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tmpdir"
5
+
6
+ module Browsable
7
+ module Analyzers
8
+ # Audits JavaScript by shelling out to eslint with eslint-plugin-compat
9
+ # configured for the project's target. As with CSS, browsable supplies the
10
+ # config and eslint (with its bundled compat data) does the reasoning.
11
+ class Javascript < Base
12
+ def required_tools = ["eslint"]
13
+
14
+ def analyze(files)
15
+ return [] if files.empty?
16
+
17
+ # TODO(v0.2): emit an eslint 9 flat config (eslint.config.mjs) and
18
+ # detect which config format the installed eslint expects.
19
+ argv = ["eslint", "--no-eslintrc", "--config", write_eslintrc,
20
+ "--format", "json", *files]
21
+ parse(shell_out(argv, dry_run_key: "BROWSABLE_DRY_RUN_JS"))
22
+ end
23
+
24
+ private
25
+
26
+ def write_eslintrc
27
+ dir = Dir.mktmpdir("browsable-eslint")
28
+ path = File.join(dir, ".eslintrc.json")
29
+ File.write(path, JSON.pretty_generate(eslint_config))
30
+ path
31
+ end
32
+
33
+ def eslint_config
34
+ {
35
+ "root" => true,
36
+ "parserOptions" => { "ecmaVersion" => "latest", "sourceType" => "module" },
37
+ "env" => { "browser" => true, "es2024" => true },
38
+ "plugins" => ["compat"],
39
+ "extends" => ["plugin:compat/recommended"],
40
+ "settings" => { "browsers" => target.to_browserslist }
41
+ }
42
+ end
43
+
44
+ def parse(raw)
45
+ data = JSON.parse(raw)
46
+ return [] unless data.is_a?(Array)
47
+
48
+ data.flat_map do |result|
49
+ Array(result["messages"]).filter_map do |message|
50
+ finding_from_message(result["filePath"], message)
51
+ end
52
+ end
53
+ rescue JSON::ParserError
54
+ []
55
+ end
56
+
57
+ def finding_from_message(file, message)
58
+ rule = message["ruleId"]
59
+ return nil unless rule # syntax errors etc. carry no ruleId
60
+
61
+ text = message["message"].to_s
62
+ # eslint-plugin-compat reports every feature under the `compat/compat`
63
+ # rule; the feature itself is the leading token of the message.
64
+ feature = text[/\A(\S+)/, 1] || rule
65
+ return nil if ignored_feature?(feature)
66
+
67
+ Finding.new(
68
+ feature_id: "javascript.#{feature}",
69
+ feature_name: feature,
70
+ file: file,
71
+ line: message["line"] || 1,
72
+ column: message["column"] || 1,
73
+ required_browser_versions: {},
74
+ target_browser_versions: target.browsers,
75
+ severity: message["severity"] == 2 ? severity_for("below_target") : :warning,
76
+ message: text
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end