html-proofer 3.19.4 → 4.0.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/bin/htmlproofer +31 -57
  3. data/lib/html-proofer.rb +1 -54
  4. data/lib/html_proofer/attribute/url.rb +237 -0
  5. data/lib/html_proofer/attribute.rb +15 -0
  6. data/lib/html_proofer/cache.rb +279 -0
  7. data/lib/html_proofer/check/favicon.rb +40 -0
  8. data/lib/html_proofer/check/images.rb +93 -0
  9. data/lib/html_proofer/check/links.rb +129 -0
  10. data/lib/html_proofer/check/open_graph.rb +39 -0
  11. data/lib/html_proofer/check/scripts.rb +46 -0
  12. data/lib/html_proofer/check.rb +92 -0
  13. data/lib/html_proofer/configuration.rb +88 -0
  14. data/lib/html_proofer/element.rb +122 -0
  15. data/lib/html_proofer/failure.rb +17 -0
  16. data/lib/{html-proofer → html_proofer}/log.rb +19 -19
  17. data/lib/html_proofer/reporter/cli.rb +33 -0
  18. data/lib/html_proofer/reporter.rb +23 -0
  19. data/lib/html_proofer/runner.rb +246 -0
  20. data/lib/html_proofer/url_validator/external.rb +194 -0
  21. data/lib/html_proofer/url_validator/internal.rb +96 -0
  22. data/lib/html_proofer/url_validator.rb +16 -0
  23. data/lib/{html-proofer → html_proofer}/utils.rb +9 -12
  24. data/lib/{html-proofer → html_proofer}/version.rb +1 -1
  25. data/lib/html_proofer/xpath_functions.rb +10 -0
  26. data/lib/html_proofer.rb +57 -0
  27. metadata +42 -22
  28. data/lib/html-proofer/cache.rb +0 -194
  29. data/lib/html-proofer/check/favicon.rb +0 -29
  30. data/lib/html-proofer/check/html.rb +0 -37
  31. data/lib/html-proofer/check/images.rb +0 -48
  32. data/lib/html-proofer/check/links.rb +0 -182
  33. data/lib/html-proofer/check/opengraph.rb +0 -46
  34. data/lib/html-proofer/check/scripts.rb +0 -42
  35. data/lib/html-proofer/check.rb +0 -75
  36. data/lib/html-proofer/configuration.rb +0 -88
  37. data/lib/html-proofer/element.rb +0 -265
  38. data/lib/html-proofer/issue.rb +0 -65
  39. data/lib/html-proofer/middleware.rb +0 -82
  40. data/lib/html-proofer/runner.rb +0 -249
  41. data/lib/html-proofer/url_validator.rb +0 -237
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "typhoeus"
4
+ require "uri"
5
+
6
+ module HTMLProofer
7
+ class UrlValidator
8
+ class External < UrlValidator
9
+ include HTMLProofer::Utils
10
+
11
+ attr_reader :external_urls
12
+ attr_writer :before_request
13
+
14
+ def initialize(runner, external_urls)
15
+ super(runner)
16
+
17
+ @external_urls = external_urls
18
+ @hydra = Typhoeus::Hydra.new(@runner.options[:hydra])
19
+ @before_request = []
20
+
21
+ @paths_with_queries = {}
22
+ end
23
+
24
+ def validate
25
+ if @cache.enabled?
26
+ urls_to_check = @runner.load_external_cache
27
+ run_external_link_checker(urls_to_check)
28
+ else
29
+ run_external_link_checker(@external_urls)
30
+ end
31
+
32
+ @failed_checks
33
+ end
34
+
35
+ # Proofer runs faster if we pull out all the external URLs and run the checks
36
+ # at the end. Otherwise, we're halting the consuming process for every file during
37
+ # `process_files`.
38
+ #
39
+ # In addition, sorting the list lets libcurl keep connections to the same hosts alive.
40
+ #
41
+ # Finally, we'll first make a HEAD request, rather than GETing all the contents.
42
+ # If the HEAD fails, we'll fall back to GET, as some servers are not configured
43
+ # for HEAD. If we've decided to check for hashes, we must do a GET--HEAD is
44
+ # not available as an option.
45
+ def run_external_link_checker(external_urls)
46
+ # Route log from Typhoeus/Ethon to our own logger
47
+ Ethon.logger = @logger
48
+
49
+ external_urls.each_pair do |external_url, metadata|
50
+ url = Attribute::Url.new(@runner, external_url, base_url: nil)
51
+
52
+ unless url.valid?
53
+ add_failure(metadata, "#{url} is an invalid URL", 0)
54
+ next
55
+ end
56
+
57
+ next unless new_url_query_values?(url)
58
+
59
+ method = if @runner.options[:check_external_hash] && url.hash?
60
+ :get
61
+ else
62
+ :head
63
+ end
64
+
65
+ queue_request(method, url, metadata)
66
+ end
67
+
68
+ @hydra.run
69
+ end
70
+
71
+ def queue_request(method, url, filenames)
72
+ opts = @runner.options[:typhoeus].merge(method: method)
73
+ request = Typhoeus::Request.new(url.url, opts)
74
+ @before_request.each do |callback|
75
+ callback.call(request)
76
+ end
77
+ request.on_complete { |response| response_handler(response, url, filenames) }
78
+ @hydra.queue(request)
79
+ end
80
+
81
+ def response_handler(response, url, filenames)
82
+ method = response.request.options[:method]
83
+ href = response.request.base_url.to_s
84
+ response_code = response.code
85
+ response.body.delete!("\x00")
86
+
87
+ @logger.log(:debug, "Received a #{response_code} for #{href}")
88
+
89
+ return if @runner.options[:ignore_status_codes].include?(response_code)
90
+
91
+ if response_code.between?(200, 299)
92
+ @cache.add_external(href, filenames, response_code, "OK") unless check_hash_in_2xx_response(href, url,
93
+ response, filenames)
94
+ elsif response.timed_out?
95
+ handle_timeout(href, filenames, response_code)
96
+ elsif response_code.zero?
97
+ handle_connection_failure(href, filenames, response_code, response.status_message)
98
+ elsif method == :head # some servers don't support HEAD
99
+ queue_request(:get, url, filenames)
100
+ else
101
+ return if @runner.options[:only_4xx] && !response_code.between?(400, 499)
102
+
103
+ # Received a non-successful http response.
104
+ status_message = blank?(response.status_message) ? "" : ": #{response.status_message}"
105
+ msg = "External link #{href} failed#{status_message}"
106
+ add_failure(filenames, msg, response_code)
107
+ @cache.add_external(href, filenames, response_code, msg)
108
+ end
109
+ end
110
+
111
+ # Even though the response was a success, we may have been asked to check
112
+ # if the hash on the URL exists on the page
113
+ def check_hash_in_2xx_response(href, url, response, filenames)
114
+ return false if @runner.options[:only_4xx]
115
+ return false unless @runner.options[:check_external_hash]
116
+ return false unless url.hash?
117
+
118
+ hash = url.hash
119
+
120
+ body_doc = create_nokogiri(response.body)
121
+
122
+ unencoded_hash = Addressable::URI.unescape(hash)
123
+ xpath = [%(//*[@name="#{hash}"]|/*[@name="#{unencoded_hash}"]|//*[@id="#{hash}"]|//*[@id="#{unencoded_hash}"])]
124
+ # user-content is a special addition by GitHub.
125
+ if url.host =~ /github\.com/i
126
+ xpath << [%(//*[@name="user-content-#{hash}"]|//*[@id="user-content-#{hash}"])]
127
+ # when linking to a file on GitHub, like #L12-L34, only the first "L" portion
128
+ # will be identified as a linkable portion
129
+ xpath << [%(//td[@id="#{Regexp.last_match[1]}"])] if hash =~ /\A(L\d)+/
130
+ end
131
+
132
+ return unless body_doc.xpath(xpath.join("|")).empty?
133
+
134
+ msg = "External link #{href} failed: #{url.sans_hash} exists, but the hash '#{hash}' does not"
135
+ add_failure(filenames, msg, response.code)
136
+ @cache.add_external(href, filenames, response.code, msg)
137
+ true
138
+ end
139
+
140
+ def handle_timeout(href, filenames, response_code)
141
+ msg = "External link #{href} failed: got a time out (response code #{response_code})"
142
+ @cache.add_external(href, filenames, 0, msg)
143
+ return if @runner.options[:only_4xx]
144
+
145
+ add_failure(filenames, msg, response_code)
146
+ end
147
+
148
+ def handle_connection_failure(href, metadata, response_code, status_message)
149
+ msgs = [<<~MSG,
150
+ External link #{href} failed with something very wrong.
151
+ It's possible libcurl couldn't connect to the server, or perhaps the request timed out.
152
+ Sometimes, making too many requests at once also breaks things.
153
+ MSG
154
+ ]
155
+
156
+ msgs << "Either way, the return message from the server is: #{status_message}" unless blank?(status_message)
157
+
158
+ msg = msgs.join("\n").chomp
159
+
160
+ @cache.add_external(href, metadata, 0, msg)
161
+ return if @runner.options[:only_4xx]
162
+
163
+ add_failure(metadata, msg, response_code)
164
+ end
165
+
166
+ def add_failure(metadata, description, status = nil)
167
+ if blank?(metadata) # possible if we're checking an array of links
168
+ @failed_checks << Failure.new("", "Links > External", description, status: status)
169
+ else
170
+ metadata.each do |m|
171
+ @failed_checks << Failure.new(m[:filename], "Links > External", description, line: m[:line], status: status)
172
+ end
173
+ end
174
+ end
175
+
176
+ # remember queries we've seen, ignore future ones
177
+ private def new_url_query_values?(url)
178
+ return true if (query_values = url.query_values).nil?
179
+
180
+ queries = query_values.keys.join("-")
181
+ domain_path = url.domain_path
182
+ if @paths_with_queries[domain_path].nil?
183
+ @paths_with_queries[domain_path] = [queries]
184
+ true
185
+ elsif !@paths_with_queries[domain_path].include?(queries)
186
+ @paths_with_queries[domain_path] << queries
187
+ true
188
+ else
189
+ false
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTMLProofer
4
+ class UrlValidator
5
+ class Internal < UrlValidator
6
+ attr_reader :internal_urls
7
+
8
+ def initialize(runner, internal_urls)
9
+ super(runner)
10
+
11
+ @internal_urls = internal_urls
12
+ end
13
+
14
+ def validate
15
+ if @cache.enabled?
16
+ urls_to_check = @runner.load_internal_cache
17
+ run_internal_link_checker(urls_to_check)
18
+ else
19
+ run_internal_link_checker(@internal_urls)
20
+ end
21
+
22
+ @failed_checks
23
+ end
24
+
25
+ def run_internal_link_checker(links)
26
+ to_add = []
27
+ links.each_pair do |link, matched_files|
28
+ matched_files.each do |metadata|
29
+ url = HTMLProofer::Attribute::Url.new(@runner, link, base_url: metadata[:base_url])
30
+
31
+ @runner.current_source = metadata[:source]
32
+ @runner.current_filename = metadata[:filename]
33
+
34
+ unless file_exists?(url)
35
+ @failed_checks << Failure.new(@runner.current_filename, "Links > Internal",
36
+ "internally linking to #{url}, which does not exist", line: metadata[:line], status: nil, content: nil)
37
+ to_add << [url, metadata, false]
38
+ next
39
+ end
40
+
41
+ unless hash_exists?(url)
42
+ @failed_checks << Failure.new(@runner.current_filename, "Links > Internal",
43
+ "internally linking to #{url}; the file exists, but the hash '#{url.hash}' does not", line: metadata[:line], status: nil, content: nil)
44
+ to_add << [url, metadata, false]
45
+ next
46
+ end
47
+
48
+ to_add << [url, metadata, true]
49
+ end
50
+ end
51
+
52
+ # adding directly to the cache above results in an endless loop
53
+ to_add.each do |(url, metadata, exists)|
54
+ @cache.add_internal(url.to_s, metadata, exists)
55
+ end
56
+
57
+ @failed_checks
58
+ end
59
+
60
+ private def file_exists?(url)
61
+ absolute_path = url.absolute_path
62
+ return @runner.checked_paths[url.absolute_path] if @runner.checked_paths.key?(absolute_path)
63
+
64
+ @runner.checked_paths[url.absolute_path] = File.exist?(absolute_path)
65
+ end
66
+
67
+ # verify the target hash
68
+ private def hash_exists?(url)
69
+ href_hash = url.hash
70
+ return true if blank?(href_hash)
71
+
72
+ # prevents searching files we didn't ask about
73
+ return false unless url.known_extension?
74
+
75
+ decoded_href_hash = Addressable::URI.unescape(href_hash)
76
+ fragment_ids = [href_hash, decoded_href_hash]
77
+ # https://www.w3.org/TR/html5/single-page.html#scroll-to-fragid
78
+ fragment_ids.include?("top") || !find_fragments(fragment_ids, url).empty?
79
+ end
80
+
81
+ private def find_fragments(fragment_ids, url)
82
+ xpaths = fragment_ids.uniq.flat_map do |frag_id|
83
+ escaped_frag_id = "'#{frag_id.split("'").join("', \"'\", '")}', ''"
84
+ [
85
+ "//*[case_sensitive_equals(@id, concat(#{escaped_frag_id}))]",
86
+ "//*[case_sensitive_equals(@name, concat(#{escaped_frag_id}))]",
87
+ ]
88
+ end
89
+ xpaths << XpathFunctions.new
90
+
91
+ html = create_nokogiri(url.absolute_path)
92
+ html.xpath(*xpaths)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTMLProofer
4
+ class UrlValidator
5
+ include HTMLProofer::Utils
6
+
7
+ def initialize(runner)
8
+ @runner = runner
9
+
10
+ @cache = @runner.cache
11
+ @logger = @runner.logger
12
+
13
+ @failed_checks = []
14
+ end
15
+ end
16
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'nokogiri'
3
+ require "nokogiri"
4
4
 
5
5
  module HTMLProofer
6
6
  module Utils
@@ -8,21 +8,18 @@ module HTMLProofer
8
8
  "#{count} #{count == 1 ? single : plural}"
9
9
  end
10
10
 
11
+ def blank?(obj)
12
+ obj.nil? || obj.empty?
13
+ end
14
+
11
15
  def create_nokogiri(path)
12
16
  content = if File.exist?(path) && !File.directory?(path)
13
- File.open(path).read
14
- else
15
- path
16
- end
17
+ File.read(path)
18
+ else
19
+ path
20
+ end
17
21
 
18
22
  Nokogiri::HTML5(content, max_errors: -1)
19
23
  end
20
-
21
- def swap(href, replacement)
22
- replacement.each do |link, replace|
23
- href = href.gsub(link, replace)
24
- end
25
- href
26
- end
27
24
  end
28
25
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HTMLProofer
4
- VERSION = '3.19.4'
4
+ VERSION = "4.0.0"
5
5
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTMLProofer
4
+ # https://stackoverflow.com/a/8812293
5
+ class XpathFunctions
6
+ def case_sensitive_equals(node_set, str_to_match)
7
+ node_set.find_all { |node| node.to_s.== str_to_match.to_s }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ lib_dir = File.join(File.dirname(__dir__), "lib")
5
+ gem_loader = Zeitwerk::Loader.for_gem
6
+ gem_loader.inflector.inflect(
7
+ "html_proofer" => "HTMLProofer"
8
+ )
9
+ gem_loader.ignore(File.join(lib_dir, "html-proofer.rb"))
10
+ gem_loader.setup
11
+
12
+ require "html_proofer/version"
13
+
14
+ require "parallel"
15
+ require "fileutils"
16
+
17
+ if ENV.fetch("DEBUG", false)
18
+ require "awesome_print"
19
+ require "debug"
20
+ end
21
+
22
+ module HTMLProofer
23
+ def self.check_file(file, options = {})
24
+ raise ArgumentError unless file.is_a?(String)
25
+ raise ArgumentError, "#{file} does not exist" unless File.exist?(file)
26
+
27
+ options[:type] = :file
28
+ HTMLProofer::Runner.new(file, options)
29
+ end
30
+
31
+ def self.check_directory(directory, options = {})
32
+ raise ArgumentError unless directory.is_a?(String)
33
+ raise ArgumentError, "#{directory} does not exist" unless Dir.exist?(directory)
34
+
35
+ options[:type] = :directory
36
+ HTMLProofer::Runner.new([directory], options)
37
+ end
38
+
39
+ def self.check_directories(directories, options = {})
40
+ raise ArgumentError unless directories.is_a?(Array)
41
+
42
+ options[:type] = :directory
43
+ directories.each do |directory|
44
+ raise ArgumentError, "#{directory} does not exist" unless Dir.exist?(directory)
45
+ end
46
+ HTMLProofer::Runner.new(directories, options)
47
+ end
48
+
49
+ def self.check_links(links, options = {})
50
+ raise ArgumentError unless links.is_a?(Array)
51
+
52
+ options[:type] = :links
53
+ HTMLProofer::Runner.new(links, options)
54
+ end
55
+ end
56
+
57
+ gem_loader.eager_load
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: html-proofer
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.19.4
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Garen Torikian
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-05-19 00:00:00.000000000 Z
11
+ date: 2022-07-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: addressable
@@ -108,6 +108,20 @@ dependencies:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
110
  version: '2.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: zeitwerk
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '2.5'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '2.5'
111
125
  - !ruby/object:Gem::Dependency
112
126
  name: awesome_print
113
127
  requirement: !ruby/object:Gem::Requirement
@@ -123,7 +137,7 @@ dependencies:
123
137
  - !ruby/object:Gem::Version
124
138
  version: '0'
125
139
  - !ruby/object:Gem::Dependency
126
- name: pry-byebug
140
+ name: debug
127
141
  requirement: !ruby/object:Gem::Requirement
128
142
  requirements:
129
143
  - - ">="
@@ -193,7 +207,7 @@ dependencies:
193
207
  - !ruby/object:Gem::Version
194
208
  version: '0'
195
209
  - !ruby/object:Gem::Dependency
196
- name: rubocop-performance
210
+ name: rubocop-rspec
197
211
  requirement: !ruby/object:Gem::Requirement
198
212
  requirements:
199
213
  - - ">="
@@ -258,23 +272,29 @@ extra_rdoc_files: []
258
272
  files:
259
273
  - bin/htmlproofer
260
274
  - lib/html-proofer.rb
261
- - lib/html-proofer/cache.rb
262
- - lib/html-proofer/check.rb
263
- - lib/html-proofer/check/favicon.rb
264
- - lib/html-proofer/check/html.rb
265
- - lib/html-proofer/check/images.rb
266
- - lib/html-proofer/check/links.rb
267
- - lib/html-proofer/check/opengraph.rb
268
- - lib/html-proofer/check/scripts.rb
269
- - lib/html-proofer/configuration.rb
270
- - lib/html-proofer/element.rb
271
- - lib/html-proofer/issue.rb
272
- - lib/html-proofer/log.rb
273
- - lib/html-proofer/middleware.rb
274
- - lib/html-proofer/runner.rb
275
- - lib/html-proofer/url_validator.rb
276
- - lib/html-proofer/utils.rb
277
- - lib/html-proofer/version.rb
275
+ - lib/html_proofer.rb
276
+ - lib/html_proofer/attribute.rb
277
+ - lib/html_proofer/attribute/url.rb
278
+ - lib/html_proofer/cache.rb
279
+ - lib/html_proofer/check.rb
280
+ - lib/html_proofer/check/favicon.rb
281
+ - lib/html_proofer/check/images.rb
282
+ - lib/html_proofer/check/links.rb
283
+ - lib/html_proofer/check/open_graph.rb
284
+ - lib/html_proofer/check/scripts.rb
285
+ - lib/html_proofer/configuration.rb
286
+ - lib/html_proofer/element.rb
287
+ - lib/html_proofer/failure.rb
288
+ - lib/html_proofer/log.rb
289
+ - lib/html_proofer/reporter.rb
290
+ - lib/html_proofer/reporter/cli.rb
291
+ - lib/html_proofer/runner.rb
292
+ - lib/html_proofer/url_validator.rb
293
+ - lib/html_proofer/url_validator/external.rb
294
+ - lib/html_proofer/url_validator/internal.rb
295
+ - lib/html_proofer/utils.rb
296
+ - lib/html_proofer/version.rb
297
+ - lib/html_proofer/xpath_functions.rb
278
298
  homepage: https://github.com/gjtorikian/html-proofer
279
299
  licenses:
280
300
  - MIT
@@ -299,7 +319,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
299
319
  - !ruby/object:Gem::Version
300
320
  version: '0'
301
321
  requirements: []
302
- rubygems_version: 3.3.13
322
+ rubygems_version: 3.3.7
303
323
  signing_key:
304
324
  specification_version: 4
305
325
  summary: A set of tests to validate your HTML output. These tests check if your image