This commit is contained in:
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
$stdout.sync = true
|
||||
|
||||
$LOAD_PATH.unshift("#{__dir__}/../lib")
|
||||
|
||||
require "html-proofer"
|
||||
require "benchmark"
|
||||
|
||||
exit_status = -1
|
||||
cli = HTMLProofer::CLI.new
|
||||
|
||||
time = Benchmark.realtime { exit_status = cli.run }
|
||||
|
||||
puts "Finished in #{time.round(2)} seconds"
|
||||
exit exit_status
|
||||
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "html_proofer"
|
||||
@@ -0,0 +1,62 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "zeitwerk"
|
||||
lib_dir = File.join(File.dirname(__dir__), "lib")
|
||||
gem_loader = Zeitwerk::Loader.for_gem
|
||||
gem_loader.inflector.inflect(
|
||||
"html_proofer" => "HTMLProofer",
|
||||
"cli" => "CLI",
|
||||
)
|
||||
gem_loader.ignore(File.join(lib_dir, "html-proofer.rb"))
|
||||
gem_loader.setup
|
||||
|
||||
require "html_proofer/version"
|
||||
|
||||
require "fileutils"
|
||||
|
||||
if ENV.fetch("DEBUG", false)
|
||||
require "debug"
|
||||
begin
|
||||
require "amazing_print"
|
||||
rescue LoadError # rubocop:disable Lint/SuppressedException
|
||||
end
|
||||
end
|
||||
|
||||
module HTMLProofer
|
||||
class << self
|
||||
def check_file(file, options = {})
|
||||
raise ArgumentError, "File isn't a string" unless file.is_a?(String)
|
||||
raise ArgumentError, "#{file} does not exist" unless File.exist?(file)
|
||||
|
||||
options[:type] = :file
|
||||
HTMLProofer::Runner.new(file, options)
|
||||
end
|
||||
|
||||
def check_directory(directory, options = {})
|
||||
raise ArgumentError unless directory.is_a?(String)
|
||||
raise ArgumentError, "#{directory} does not exist" unless Dir.exist?(directory)
|
||||
|
||||
options[:type] = :directory
|
||||
HTMLProofer::Runner.new([directory], options)
|
||||
end
|
||||
|
||||
def check_directories(directories, options = {})
|
||||
raise ArgumentError unless directories.is_a?(Array)
|
||||
|
||||
options[:type] = :directory
|
||||
directories.each do |directory|
|
||||
raise ArgumentError, "#{directory} does not exist" unless Dir.exist?(directory)
|
||||
end
|
||||
HTMLProofer::Runner.new(directories, options)
|
||||
end
|
||||
|
||||
def check_links(links, options = {})
|
||||
raise ArgumentError unless links.is_a?(Array)
|
||||
|
||||
options[:type] = :links
|
||||
HTMLProofer::Runner.new(links, options)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
gem_loader.eager_load
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
# Represents an element currently being processed
|
||||
class Attribute
|
||||
include HTMLProofer::Utils
|
||||
|
||||
attr_reader :raw_attribute
|
||||
|
||||
def initialize(runner, raw_attribute, **_)
|
||||
@runner = runner
|
||||
@raw_attribute = raw_attribute
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,268 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
class Attribute
|
||||
class Url < HTMLProofer::Attribute
|
||||
attr_reader :url, :size, :source, :filename
|
||||
|
||||
REMOTE_SCHEMES = ["http", "https"].freeze
|
||||
|
||||
def initialize(runner, link_attribute, base_url: nil, source: nil, filename: nil, extract_size: false)
|
||||
super
|
||||
|
||||
@source = source
|
||||
@filename = filename
|
||||
|
||||
if @raw_attribute.nil?
|
||||
@url = nil
|
||||
else
|
||||
@url = @raw_attribute.delete("\u200b").strip
|
||||
@url, @size = @url.split(/\s+/) if extract_size
|
||||
@url = Addressable::URI.join(base_url, @url).to_s unless blank?(base_url)
|
||||
@url = "" if @url.nil?
|
||||
|
||||
swap_urls!
|
||||
clean_url!
|
||||
end
|
||||
end
|
||||
|
||||
def protocol_relative?
|
||||
url.start_with?("//")
|
||||
end
|
||||
|
||||
def to_s
|
||||
@url
|
||||
end
|
||||
|
||||
def known_extension?
|
||||
return true if hash_link?
|
||||
return true if path.end_with?("/")
|
||||
|
||||
ext = File.extname(path)
|
||||
|
||||
# no extension means we use the assumed one
|
||||
return @runner.options[:extensions].include?(@runner.options[:assume_extension]) if blank?(ext)
|
||||
|
||||
@runner.options[:extensions].include?(ext)
|
||||
end
|
||||
|
||||
def unknown_extension?
|
||||
!known_extension?
|
||||
end
|
||||
|
||||
def ignore?
|
||||
return true if /^javascript:/.match?(@url)
|
||||
|
||||
true if ignores_pattern?(@runner.options[:ignore_urls])
|
||||
end
|
||||
|
||||
def valid?
|
||||
!parts.nil?
|
||||
end
|
||||
|
||||
def path?
|
||||
!parts.host.nil? && !parts.path.nil?
|
||||
end
|
||||
|
||||
def parts
|
||||
@parts ||= Addressable::URI.parse(@url)
|
||||
rescue URI::Error, Addressable::URI::InvalidURIError
|
||||
@parts = nil
|
||||
end
|
||||
|
||||
def path
|
||||
Addressable::URI.unencode(parts.path) unless parts.nil?
|
||||
end
|
||||
|
||||
def hash
|
||||
parts&.fragment
|
||||
end
|
||||
|
||||
# Does the URL have a hash?
|
||||
def hash?
|
||||
!blank?(hash)
|
||||
end
|
||||
|
||||
def scheme
|
||||
parts&.scheme
|
||||
end
|
||||
|
||||
def remote?
|
||||
REMOTE_SCHEMES.include?(scheme)
|
||||
end
|
||||
|
||||
def http?
|
||||
scheme == "http"
|
||||
end
|
||||
|
||||
def https?
|
||||
scheme == "https"
|
||||
end
|
||||
|
||||
def non_http_remote?
|
||||
!scheme.nil? && !remote?
|
||||
end
|
||||
|
||||
def host
|
||||
parts&.host
|
||||
end
|
||||
|
||||
def domain_path
|
||||
(host || "") + path
|
||||
end
|
||||
|
||||
def query_values
|
||||
parts&.query_values
|
||||
end
|
||||
|
||||
# checks if a file exists relative to the current pwd
|
||||
def exists?
|
||||
return true if base64?
|
||||
|
||||
!resolved_path.nil?
|
||||
end
|
||||
|
||||
def resolved_path
|
||||
path_to_resolve = absolute_path
|
||||
|
||||
return @runner.resolved_paths[path_to_resolve] if @runner.resolved_paths.key?(path_to_resolve)
|
||||
|
||||
# extensionless URLs
|
||||
path_with_extension = "#{path_to_resolve}#{@runner.options[:assume_extension]}"
|
||||
resolved = if @runner.options[:assume_extension] && File.file?(path_with_extension)
|
||||
path_with_extension # existence checked implicitly by File.file?
|
||||
# implicit index support
|
||||
elsif File.directory?(path_to_resolve) && !unslashed_directory?(path_to_resolve)
|
||||
path_with_index = File.join(path_to_resolve, @runner.options[:directory_index_file])
|
||||
path_with_index if File.file?(path_with_index)
|
||||
# explicit file or directory
|
||||
elsif File.exist?(path_to_resolve)
|
||||
path_to_resolve
|
||||
end
|
||||
@runner.resolved_paths[path_to_resolve] = resolved
|
||||
|
||||
resolved
|
||||
end
|
||||
|
||||
def base64?
|
||||
/^data:image/.match?(@raw_attribute)
|
||||
end
|
||||
|
||||
def absolute_path
|
||||
path = full_path || @filename
|
||||
|
||||
File.expand_path(path, Dir.pwd)
|
||||
end
|
||||
|
||||
def full_path
|
||||
return if path.nil? || path.empty?
|
||||
|
||||
base = if absolute_path?(path) # path relative to root
|
||||
# either overwrite with root_dir; or, if source is directory, use that; or, just get the source file's dirname
|
||||
@runner.options[:root_dir] || (File.directory?(@source) ? @source : File.dirname(@source))
|
||||
else
|
||||
# path relative to the file where the link is defined
|
||||
File.dirname(@filename)
|
||||
end
|
||||
|
||||
File.join(base, path)
|
||||
end
|
||||
|
||||
def unslashed_directory?(file)
|
||||
return false unless File.directory?(file)
|
||||
|
||||
!file.end_with?(File::SEPARATOR) && !follow_location?
|
||||
end
|
||||
|
||||
def follow_location?
|
||||
@runner.options[:typhoeus] && @runner.options[:typhoeus][:followlocation]
|
||||
end
|
||||
|
||||
def absolute_path?(path)
|
||||
path.start_with?("/")
|
||||
end
|
||||
|
||||
# path is external to the file
|
||||
def external?
|
||||
!internal?
|
||||
end
|
||||
|
||||
def internal?
|
||||
relative_link? || internal_absolute_link? || hash_link?
|
||||
end
|
||||
|
||||
def internal_absolute_link?
|
||||
url.start_with?("/")
|
||||
end
|
||||
|
||||
def relative_link?
|
||||
return false if remote?
|
||||
|
||||
hash_link? || param_link? || url.start_with?(".") || url =~ /^\S/
|
||||
end
|
||||
|
||||
def link_points_to_same_page?
|
||||
hash_link || param_link
|
||||
end
|
||||
|
||||
def hash_link?
|
||||
url.start_with?("#")
|
||||
end
|
||||
|
||||
def has_hash?
|
||||
url.include?("#")
|
||||
end
|
||||
|
||||
def param_link?
|
||||
url.start_with?("?")
|
||||
end
|
||||
|
||||
def without_hash
|
||||
@url.to_s.sub(/##{hash}/, "")
|
||||
end
|
||||
|
||||
# catch any obvious issues
|
||||
private def clean_url!
|
||||
parsed_url = Addressable::URI.parse(@url)
|
||||
url = if parsed_url.scheme.nil?
|
||||
parsed_url
|
||||
else
|
||||
parsed_url.normalize
|
||||
end.to_s
|
||||
|
||||
# normalize strips this off, which causes issues with cache
|
||||
@url = if @url.end_with?("/") && !url.end_with?("/")
|
||||
"#{url}/"
|
||||
elsif !@url.end_with?("/") && url.end_with?("/")
|
||||
url.chop
|
||||
else
|
||||
url
|
||||
end
|
||||
rescue Addressable::URI::InvalidURIError # rubocop:disable Lint/SuppressedException -- error will be reported at check time
|
||||
end
|
||||
|
||||
private def swap_urls!
|
||||
return @url if blank?(replacements = @runner.options[:swap_urls])
|
||||
|
||||
replacements.each do |link, replace|
|
||||
@url = @url.gsub(link, replace)
|
||||
end
|
||||
end
|
||||
|
||||
private def ignores_pattern?(links_to_ignore)
|
||||
return false unless links_to_ignore.is_a?(Array)
|
||||
|
||||
links_to_ignore.each do |link_to_ignore|
|
||||
case link_to_ignore
|
||||
when String
|
||||
return true if link_to_ignore == @raw_attribute
|
||||
when Regexp
|
||||
return true if link_to_ignore&.match?(@raw_attribute)
|
||||
end
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,295 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "date"
|
||||
require "json"
|
||||
require "uri"
|
||||
|
||||
module HTMLProofer
|
||||
class Cache
|
||||
include HTMLProofer::Utils
|
||||
|
||||
CACHE_VERSION = 2
|
||||
|
||||
DEFAULT_STORAGE_DIR = File.join("tmp", ".htmlproofer")
|
||||
DEFAULT_CACHE_FILE_NAME = "cache.json"
|
||||
|
||||
URI_REGEXP = URI::DEFAULT_PARSER.make_regexp
|
||||
|
||||
attr_reader :exists, :cache_log, :storage_dir, :cache_file
|
||||
|
||||
def initialize(runner, options)
|
||||
@runner = runner
|
||||
@logger = @runner.logger
|
||||
|
||||
@cache_datetime = Time.now
|
||||
@cache_time = @cache_datetime.to_time
|
||||
|
||||
if blank?(options)
|
||||
define_singleton_method(:enabled?) { false }
|
||||
define_singleton_method(:external_enabled?) { false }
|
||||
define_singleton_method(:internal_enabled?) { false }
|
||||
else
|
||||
# we still consider the cache as enabled, regardless of the specic timeframes
|
||||
define_singleton_method(:enabled?) { true }
|
||||
setup_cache!(options)
|
||||
|
||||
@external_timeframe = parsed_timeframe(options[:timeframe][:external])
|
||||
define_singleton_method(:external_enabled?) { !@external_timeframe.nil? }
|
||||
@internal_timeframe = parsed_timeframe(options[:timeframe][:internal])
|
||||
define_singleton_method(:internal_enabled?) { !@internal_timeframe.nil? }
|
||||
end
|
||||
end
|
||||
|
||||
def parsed_timeframe(timeframe)
|
||||
return if timeframe.nil?
|
||||
|
||||
time, date = timeframe.match(/(\d+)(\D)/).captures
|
||||
time = time.to_i
|
||||
case date
|
||||
when "M"
|
||||
time_ago(time, :months)
|
||||
when "w"
|
||||
time_ago(time, :weeks)
|
||||
when "d"
|
||||
time_ago(time, :days)
|
||||
when "h"
|
||||
time_ago(time, :hours)
|
||||
else
|
||||
raise ArgumentError, "#{date} is not a valid timeframe!"
|
||||
end
|
||||
end
|
||||
|
||||
def add_internal(url, metadata, found)
|
||||
return unless internal_enabled?
|
||||
|
||||
@cache_log[:internal][url] = { time: @cache_time, metadata: [] } if @cache_log[:internal][url].nil?
|
||||
|
||||
@cache_log[:internal][url][:metadata] << construct_internal_link_metadata(metadata, found)
|
||||
end
|
||||
|
||||
def add_external(url, filenames, status_code, msg, found)
|
||||
return unless external_enabled?
|
||||
|
||||
clean_url = cleaned_url(url)
|
||||
@cache_log[:external][clean_url] =
|
||||
{ time: @cache_time.to_s, found: found, status_code: status_code, message: msg, metadata: filenames }
|
||||
end
|
||||
|
||||
def detect_url_changes(urls_detected, type)
|
||||
determine_deletions(urls_detected, type)
|
||||
|
||||
additions = determine_additions(urls_detected, type)
|
||||
|
||||
additions
|
||||
end
|
||||
|
||||
def write
|
||||
return unless enabled?
|
||||
|
||||
File.write(@cache_file, @cache_log.to_json)
|
||||
end
|
||||
|
||||
def retrieve_urls(urls_detected, type)
|
||||
# if there are no urls, bail
|
||||
return {} if urls_detected.empty?
|
||||
|
||||
if type == :external
|
||||
urls_detected = urls_detected.transform_keys do |url|
|
||||
cleaned_url(url)
|
||||
end
|
||||
end
|
||||
|
||||
urls_to_check = detect_url_changes(urls_detected, type)
|
||||
|
||||
urls_to_check
|
||||
end
|
||||
|
||||
def within_external_timeframe?(time)
|
||||
within_timeframe?(time, @external_timeframe)
|
||||
end
|
||||
|
||||
def within_internal_timeframe?(time)
|
||||
within_timeframe?(time, @internal_timeframe)
|
||||
end
|
||||
|
||||
def empty?
|
||||
blank?(@cache_log) || (@cache_log[:internal].empty? && @cache_log[:external].empty?)
|
||||
end
|
||||
|
||||
def size(type)
|
||||
@cache_log[type].size
|
||||
end
|
||||
|
||||
private def construct_internal_link_metadata(metadata, found)
|
||||
{
|
||||
source: metadata[:source],
|
||||
filename: metadata[:filename],
|
||||
line: metadata[:line],
|
||||
base_url: metadata[:base_url],
|
||||
found: found,
|
||||
}
|
||||
end
|
||||
|
||||
# prepare to add new URLs detected
|
||||
private def determine_additions(urls_detected, type)
|
||||
additions = type == :external ? determine_external_additions(urls_detected) : determine_internal_additions(urls_detected)
|
||||
|
||||
new_link_count = additions.length
|
||||
new_link_text = pluralize(new_link_count, "new #{type} link", "new #{type} links")
|
||||
@logger.log(:debug, "Adding #{new_link_text} to the cache")
|
||||
|
||||
additions
|
||||
end
|
||||
|
||||
private def determine_external_additions(urls_detected)
|
||||
urls_detected.reject do |url, _metadata|
|
||||
if @cache_log[:external].include?(url)
|
||||
found = @cache_log[:external][url][:found] # if this is false, we're trying again
|
||||
unless found
|
||||
@logger.log(:debug, "Adding #{url} to external cache (not found)")
|
||||
end
|
||||
found
|
||||
else
|
||||
@logger.log(:debug, "Adding #{url} to external cache")
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def determine_internal_additions(urls_detected)
|
||||
urls_detected.each_with_object({}) do |(url, detected_metadata), hsh|
|
||||
# url is not even in cache
|
||||
if @cache_log[:internal][url].nil?
|
||||
@logger.log(:debug, "Adding #{url} to internal cache")
|
||||
hsh[url] = detected_metadata
|
||||
next
|
||||
end
|
||||
|
||||
# detect metadata additions
|
||||
# NOTE: the time-stamp for the whole url key will not be updated,
|
||||
# so that it reflects the earliest time any of the metadata was checked
|
||||
cache_metadata = @cache_log[:internal][url][:metadata]
|
||||
metadata_additions = detected_metadata.reject do |detected|
|
||||
existing_cache_metadata = cache_metadata.find { |cached, _| cached[:filename] == detected[:filename] }
|
||||
# cache for this url, from an existing path, exists as found
|
||||
found = !existing_cache_metadata.nil? && !existing_cache_metadata.empty? && existing_cache_metadata[:found]
|
||||
unless found
|
||||
@logger.log(:debug, "Adding #{detected} to internal cache for #{url}")
|
||||
end
|
||||
found
|
||||
end
|
||||
|
||||
if metadata_additions.empty?
|
||||
next
|
||||
end
|
||||
|
||||
hsh[url] = metadata_additions
|
||||
# remove from the cache the detected metadata additions as they correspond to failures to be rechecked
|
||||
# (this works assuming the detected url metadata have "found" set to false)
|
||||
@cache_log[:internal][url][:metadata] = cache_metadata.difference(metadata_additions)
|
||||
end
|
||||
end
|
||||
|
||||
# remove from cache URLs that no longer exist
|
||||
private def determine_deletions(urls_detected, type)
|
||||
deletions = 0
|
||||
|
||||
@cache_log[type].delete_if do |url, cache|
|
||||
expired_timeframe = type == :external ? !within_external_timeframe?(cache[:time]) : !within_internal_timeframe?(cache[:time])
|
||||
if expired_timeframe
|
||||
@logger.log(:debug, "Removing #{url} from #{type} cache (expired timeframe)")
|
||||
deletions += 1
|
||||
true
|
||||
elsif urls_detected.include?(url)
|
||||
false
|
||||
elsif url_matches_type?(url, type)
|
||||
@logger.log(:debug, "Removing #{url} from #{type} cache (not detected anymore)")
|
||||
deletions += 1
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
del_link_text = pluralize(deletions, "outdated #{type} link", "outdated #{type} links")
|
||||
@logger.log(:debug, "Removing #{del_link_text} from the cache")
|
||||
end
|
||||
|
||||
private def setup_cache!(options)
|
||||
default_structure = {
|
||||
version: CACHE_VERSION,
|
||||
internal: {},
|
||||
external: {},
|
||||
}
|
||||
|
||||
@storage_dir = options[:storage_dir] || DEFAULT_STORAGE_DIR
|
||||
|
||||
FileUtils.mkdir_p(storage_dir) unless Dir.exist?(storage_dir)
|
||||
|
||||
cache_file_name = options[:cache_file] || DEFAULT_CACHE_FILE_NAME
|
||||
|
||||
@cache_file = File.join(storage_dir, cache_file_name)
|
||||
|
||||
return (@cache_log = default_structure) unless File.exist?(@cache_file)
|
||||
|
||||
contents = File.read(@cache_file)
|
||||
|
||||
return (@cache_log = default_structure) if blank?(contents)
|
||||
|
||||
log = JSON.parse(contents, symbolize_names: true)
|
||||
|
||||
old_cache = (cache_version = log[:version]).nil?
|
||||
@cache_log = if old_cache # previous cache version, create a new one
|
||||
default_structure
|
||||
elsif cache_version != CACHE_VERSION
|
||||
# if cache version is newer...do something
|
||||
else
|
||||
log[:internal] = log[:internal].transform_keys(&:to_s)
|
||||
log[:external] = log[:external].transform_keys(&:to_s)
|
||||
log
|
||||
end
|
||||
end
|
||||
|
||||
# https://github.com/rails/rails/blob/3872bc0e54d32e8bf3a6299b0bfe173d94b072fc/activesupport/lib/active_support/duration.rb#L112-L117
|
||||
SECONDS_PER_HOUR = 3600
|
||||
SECONDS_PER_DAY = 86400
|
||||
SECONDS_PER_WEEK = 604800
|
||||
SECONDS_PER_MONTH = 2629746 # 1/12 of a gregorian year
|
||||
|
||||
private def time_ago(measurement, unit)
|
||||
case unit
|
||||
when :months
|
||||
@cache_datetime - (SECONDS_PER_MONTH * measurement)
|
||||
when :weeks
|
||||
@cache_datetime - (SECONDS_PER_WEEK * measurement)
|
||||
when :days
|
||||
@cache_datetime - (SECONDS_PER_DAY * measurement)
|
||||
when :hours
|
||||
@cache_datetime - Rational(SECONDS_PER_HOUR * measurement)
|
||||
end.to_time
|
||||
end
|
||||
|
||||
private def url_matches_type?(url, type)
|
||||
return true if type == :internal && url !~ URI_REGEXP
|
||||
|
||||
true if type == :external && url =~ URI_REGEXP
|
||||
end
|
||||
|
||||
private def cleaned_url(url)
|
||||
cleaned_url = escape_unescape(url)
|
||||
|
||||
return cleaned_url unless cleaned_url.end_with?("/", "#", "?") && cleaned_url.length > 1
|
||||
|
||||
cleaned_url[0..-2]
|
||||
end
|
||||
|
||||
private def escape_unescape(url)
|
||||
Addressable::URI.parse(url).normalize.to_s
|
||||
end
|
||||
|
||||
private def within_timeframe?(current_time, parsed_timeframe)
|
||||
return false if current_time.nil? || parsed_timeframe.nil?
|
||||
|
||||
current_time = Time.parse(current_time) if current_time.is_a?(String)
|
||||
(parsed_timeframe..@cache_time).cover?(current_time)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
# Mostly handles issue management and collecting of external URLs.
|
||||
class Check
|
||||
include HTMLProofer::Utils
|
||||
|
||||
attr_reader :failures, :options, :internal_urls, :external_urls
|
||||
|
||||
def initialize(runner, html)
|
||||
@runner = runner
|
||||
@html = remove_ignored(html)
|
||||
|
||||
@external_urls = {}
|
||||
@internal_urls = {}
|
||||
@failures = []
|
||||
end
|
||||
|
||||
def create_element(node)
|
||||
Element.new(@runner, node, base_url: base_url)
|
||||
end
|
||||
|
||||
def run
|
||||
raise NotImplementedError, "HTMLProofer::Check subclasses must implement #run"
|
||||
end
|
||||
|
||||
def add_failure(description, element: nil, line: nil, status: nil, content: nil)
|
||||
@failures << Failure.new(
|
||||
@runner.current_filename,
|
||||
short_name,
|
||||
description,
|
||||
line: element.nil? ? line : element.line,
|
||||
status: status,
|
||||
content: element.nil? ? content : element.content,
|
||||
)
|
||||
end
|
||||
|
||||
def short_name
|
||||
self.class.name.split("::").last
|
||||
end
|
||||
|
||||
def add_to_internal_urls(url, line)
|
||||
url_string = url.raw_attribute
|
||||
|
||||
@internal_urls[url_string] = [] if @internal_urls[url_string].nil?
|
||||
|
||||
metadata = {
|
||||
source: url.source,
|
||||
filename: url.filename,
|
||||
line: line,
|
||||
base_url: base_url,
|
||||
found: false,
|
||||
}
|
||||
@internal_urls[url_string] << metadata
|
||||
end
|
||||
|
||||
def add_to_external_urls(url, line)
|
||||
url_string = url.to_s
|
||||
|
||||
@external_urls[url_string] = [] if @external_urls[url_string].nil?
|
||||
|
||||
@external_urls[url_string] << { filename: url.filename, line: line }
|
||||
end
|
||||
|
||||
class << self
|
||||
def subchecks(runner_options)
|
||||
# grab all known checks
|
||||
checks = ObjectSpace.each_object(Class).select do |klass|
|
||||
klass < self
|
||||
end
|
||||
|
||||
# remove any checks not explicitly included
|
||||
checks.each_with_object([]) do |check, arr|
|
||||
next unless runner_options[:checks].include?(check.short_name)
|
||||
|
||||
arr << check
|
||||
end
|
||||
end
|
||||
|
||||
def short_name
|
||||
name.split("::").last
|
||||
end
|
||||
end
|
||||
|
||||
private def base_url
|
||||
return @base_url if defined?(@base_url)
|
||||
|
||||
return (@base_url = "") if (base = @html.at_css("base")).nil?
|
||||
|
||||
@base_url = base["href"]
|
||||
end
|
||||
|
||||
private def remove_ignored(html)
|
||||
return if html.nil?
|
||||
|
||||
html.css("code, pre, tt").each(&:unlink)
|
||||
html
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
class Check
|
||||
class Favicon < HTMLProofer::Check
|
||||
def run
|
||||
found = false
|
||||
@html.css("link").each do |node|
|
||||
@favicon = create_element(node)
|
||||
|
||||
next if @favicon.ignore?
|
||||
|
||||
break if (found = @favicon.node["rel"].split.last.eql?("icon"))
|
||||
end
|
||||
|
||||
return if immediate_redirect?
|
||||
|
||||
if found
|
||||
if @favicon.url.protocol_relative?
|
||||
add_failure(
|
||||
"favicon link #{@favicon.url} is a protocol-relative URL, use explicit https:// instead",
|
||||
element: @favicon,
|
||||
)
|
||||
elsif @favicon.url.remote?
|
||||
add_to_external_urls(@favicon.url, @favicon.line)
|
||||
elsif !@favicon.url.exists?
|
||||
add_failure(
|
||||
"internal favicon #{@favicon.url.raw_attribute} does not exist",
|
||||
element: @favicon,
|
||||
)
|
||||
end
|
||||
else
|
||||
add_failure("no favicon provided")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# allow any instant-redirect meta tag
|
||||
def immediate_redirect?
|
||||
@html.xpath("//meta[@http-equiv='refresh']").attribute("content").value.start_with?("0;")
|
||||
rescue StandardError
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
class Check
|
||||
class Images < HTMLProofer::Check
|
||||
SCREEN_SHOT_REGEX = /Screen(?: |%20)Shot(?: |%20)\d+-\d+-\d+(?: |%20)at(?: |%20)\d+.\d+.\d+/
|
||||
|
||||
def run
|
||||
@html.css("img, source").each do |node|
|
||||
@img = create_element(node)
|
||||
|
||||
next if @img.ignore?
|
||||
|
||||
# screenshot filenames should return because of terrible names
|
||||
add_failure(
|
||||
"image has a terrible filename (#{@img.url.raw_attribute})",
|
||||
element: @img,
|
||||
) if terrible_filename?
|
||||
|
||||
# does the image exist?
|
||||
if missing_src?
|
||||
add_failure("image has no src or srcset attribute", element: @img)
|
||||
elsif @img.multiple_srcsets? || @img.multiple_sizes?
|
||||
@img.srcsets_wo_sizes.each do |srcset|
|
||||
srcset_url = HTMLProofer::Attribute::Url.new(@runner, srcset, base_url: @img.base_url, source: @img.url.source, filename: @img.url.filename, extract_size: true)
|
||||
|
||||
if srcset_url.protocol_relative?
|
||||
add_failure(
|
||||
"image link #{srcset_url.url} is a protocol-relative URL, use explicit https:// instead",
|
||||
element: @img,
|
||||
)
|
||||
elsif srcset_url.remote?
|
||||
add_to_external_urls(srcset_url.url, @img.line)
|
||||
elsif !srcset_url.exists?
|
||||
add_failure("internal image #{srcset} does not exist", element: @img)
|
||||
end
|
||||
end
|
||||
elsif @img.url.protocol_relative?
|
||||
add_failure(
|
||||
"image link #{@img.url} is a protocol-relative URL, use explicit https:// instead",
|
||||
element: @img,
|
||||
)
|
||||
elsif @img.url.remote?
|
||||
add_to_external_urls(@img.url, @img.line)
|
||||
elsif !@img.url.exists? && !@img.multiple_srcsets? && !@img.multiple_sizes?
|
||||
add_failure(
|
||||
"internal image #{@img.url.raw_attribute} does not exist",
|
||||
element: @img,
|
||||
)
|
||||
end
|
||||
|
||||
# if this is an img element, check that the alt attribute is present
|
||||
if @img.img_tag? && !ignore_element?
|
||||
if missing_alt_tag? && !ignore_missing_alt?
|
||||
add_failure(
|
||||
"image #{@img.url.raw_attribute} does not have an alt attribute",
|
||||
element: @img,
|
||||
)
|
||||
elsif (empty_alt_tag? || alt_all_spaces?) && !ignore_empty_alt?
|
||||
add_failure(
|
||||
"image #{@img.url.raw_attribute} has an alt attribute, but no content",
|
||||
element: @img,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
add_failure(
|
||||
"image #{@img.url.raw_attribute} uses the http scheme",
|
||||
element: @img,
|
||||
) if @runner.enforce_https? && @img.url.http?
|
||||
end
|
||||
|
||||
external_urls
|
||||
end
|
||||
|
||||
def ignore_missing_alt?
|
||||
@runner.options[:ignore_missing_alt]
|
||||
end
|
||||
|
||||
def ignore_empty_alt?
|
||||
@runner.options[:ignore_empty_alt]
|
||||
end
|
||||
|
||||
def ignore_element?
|
||||
@img.url.ignore? || @img.aria_hidden?
|
||||
end
|
||||
|
||||
def missing_alt_tag?
|
||||
@img.node["alt"].nil?
|
||||
end
|
||||
|
||||
def empty_alt_tag?
|
||||
!missing_alt_tag? && @img.node["alt"].empty?
|
||||
end
|
||||
|
||||
def empty_whitespace_alt_tag?
|
||||
!missing_alt_tag? && @img.node["alt"].strip.empty?
|
||||
end
|
||||
|
||||
def alt_all_spaces?
|
||||
!missing_alt_tag? && @img.node["alt"].split.all?(" ")
|
||||
end
|
||||
|
||||
def terrible_filename?
|
||||
@img.url.to_s =~ SCREEN_SHOT_REGEX
|
||||
end
|
||||
|
||||
def missing_src?
|
||||
blank?(@img.url.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,150 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
class Check
|
||||
class Links < HTMLProofer::Check
|
||||
def run
|
||||
@html.css("a, link").each do |node|
|
||||
@link = create_element(node)
|
||||
|
||||
next if @link.ignore?
|
||||
|
||||
if !allow_hash_href? && @link.node["href"] == "#"
|
||||
add_failure("linking to internal hash #, which points to nowhere", element: @link)
|
||||
next
|
||||
end
|
||||
|
||||
# is there even an href?
|
||||
if blank?(@link.url.raw_attribute)
|
||||
next if allow_missing_href?
|
||||
|
||||
add_failure("'#{@link.node.name}' tag is missing a reference", element: @link)
|
||||
next
|
||||
end
|
||||
|
||||
# is it even a valid URL?
|
||||
unless @link.url.valid?
|
||||
add_failure("#{@link.href} is an invalid URL", element: @link)
|
||||
next
|
||||
end
|
||||
|
||||
if @link.url.protocol_relative?
|
||||
add_failure(
|
||||
"#{@link.url} is a protocol-relative URL, use explicit https:// instead",
|
||||
element: @link,
|
||||
)
|
||||
next
|
||||
end
|
||||
|
||||
check_schemes
|
||||
|
||||
# intentionally down here because we still want valid? & missing_href? to execute
|
||||
next if @link.url.non_http_remote?
|
||||
|
||||
if !@link.url.internal? && @link.url.remote?
|
||||
check_sri if @runner.check_sri? && @link.link_tag?
|
||||
|
||||
# we need to skip these for now; although the domain main be valid,
|
||||
# curl/Typheous inaccurately return 404s for some links. cc https://git.io/vyCFx
|
||||
next if @link.node["rel"] == "dns-prefetch"
|
||||
|
||||
unless @link.url.path?
|
||||
add_failure("#{@link.url.raw_attribute} is an invalid URL", element: @link)
|
||||
next
|
||||
end
|
||||
|
||||
add_to_external_urls(@link.url, @link.line)
|
||||
elsif @link.url.internal?
|
||||
# does the local directory have a trailing slash?
|
||||
if @link.url.unslashed_directory?(@link.url.absolute_path)
|
||||
add_failure(
|
||||
"internally linking to a directory #{@link.url.raw_attribute} without trailing slash",
|
||||
element: @link,
|
||||
)
|
||||
next
|
||||
end
|
||||
|
||||
add_to_internal_urls(@link.url, @link.line)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def allow_missing_href?
|
||||
@runner.options[:allow_missing_href]
|
||||
end
|
||||
|
||||
def allow_hash_href?
|
||||
@runner.options[:allow_hash_href]
|
||||
end
|
||||
|
||||
def check_schemes
|
||||
case @link.url.scheme
|
||||
when "mailto"
|
||||
handle_mailto
|
||||
when "tel"
|
||||
handle_tel
|
||||
when "http"
|
||||
return unless @runner.options[:enforce_https]
|
||||
|
||||
add_failure("#{@link.url.raw_attribute} is not an HTTPS link", element: @link)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_mailto
|
||||
if @link.url.path.empty?
|
||||
add_failure(
|
||||
"#{@link.url.raw_attribute} contains no email address",
|
||||
element: @link,
|
||||
) unless ignore_empty_mailto?
|
||||
# eg., if any do not match a valid URL
|
||||
elsif @link.url.path.split(",").any? { |email| !/#{URI::MailTo::EMAIL_REGEXP}/o.match?(email) }
|
||||
add_failure(
|
||||
"#{@link.url.raw_attribute} contains an invalid email address",
|
||||
element: @link,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_tel
|
||||
add_failure(
|
||||
"#{@link.url.raw_attribute} contains no phone number",
|
||||
element: @link,
|
||||
) if @link.url.path.empty?
|
||||
end
|
||||
|
||||
def ignore_empty_mailto?
|
||||
@runner.options[:ignore_empty_mailto]
|
||||
end
|
||||
|
||||
# Allowed elements from Subresource Integrity specification
|
||||
# https://w3c.github.io/webappsec-subresource-integrity/#link-element-for-stylesheets
|
||||
SRI_REL_TYPES = %(stylesheet)
|
||||
|
||||
def check_sri
|
||||
return unless SRI_REL_TYPES.include?(@link.node["rel"])
|
||||
|
||||
if blank?(@link.node["integrity"]) && blank?(@link.node["crossorigin"])
|
||||
add_failure(
|
||||
"SRI and CORS not provided in: #{@link.url.raw_attribute}",
|
||||
element: @link,
|
||||
)
|
||||
elsif blank?(@link.node["integrity"])
|
||||
add_failure("Integrity is missing in: #{@link.url.raw_attribute}", element: @link)
|
||||
elsif blank?(@link.node["crossorigin"])
|
||||
add_failure(
|
||||
"CORS not provided for external resource in: #{@link.link.url.raw_attribute}",
|
||||
element: @link,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private def source_tag?
|
||||
@link.node.name == "source"
|
||||
end
|
||||
|
||||
private def anchor_tag?
|
||||
@link.node.name == "a"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
class Check
|
||||
class OpenGraph < HTMLProofer::Check
|
||||
def run
|
||||
@html.css('meta[property="og:url"], meta[property="og:image"]').each do |node|
|
||||
@open_graph = create_element(node)
|
||||
|
||||
next if @open_graph.ignore?
|
||||
|
||||
# does the open_graph exist?
|
||||
if missing_content?
|
||||
add_failure("open graph has no content attribute", element: @open_graph)
|
||||
elsif empty_content?
|
||||
add_failure("open graph content attribute is empty", element: @open_graph)
|
||||
elsif !@open_graph.url.valid?
|
||||
add_failure("#{@open_graph.src} is an invalid URL", element: @open_graph)
|
||||
elsif @open_graph.url.protocol_relative?
|
||||
add_failure(
|
||||
"open graph link #{@open_graph.url} is a protocol-relative URL, use explicit https:// instead",
|
||||
element: @open_graph,
|
||||
)
|
||||
elsif @open_graph.url.remote?
|
||||
add_to_external_urls(@open_graph.url, @open_graph.line)
|
||||
else
|
||||
add_failure(
|
||||
"internal open graph #{@open_graph.url.raw_attribute} does not exist",
|
||||
element: @open_graph,
|
||||
) unless @open_graph.url.exists?
|
||||
end
|
||||
end
|
||||
|
||||
external_urls
|
||||
end
|
||||
|
||||
private def missing_content?
|
||||
@open_graph.node["content"].nil?
|
||||
end
|
||||
|
||||
private def empty_content?
|
||||
@open_graph.node["content"].empty?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
class Check
|
||||
class Scripts < HTMLProofer::Check
|
||||
def run
|
||||
@html.css("script").each do |node|
|
||||
@script = create_element(node)
|
||||
|
||||
next if @script.ignore?
|
||||
next unless @script.content.strip.empty?
|
||||
|
||||
# does the script exist?
|
||||
if missing_src?
|
||||
add_failure("script is empty and has no src attribute", element: @script)
|
||||
elsif @script.url.protocol_relative?
|
||||
add_failure(
|
||||
"script link #{@script.url} is a protocol-relative URL, use explicit https:// instead",
|
||||
element: @script,
|
||||
)
|
||||
elsif @script.url.remote?
|
||||
add_to_external_urls(@script.url, @script.line)
|
||||
check_sri if @runner.check_sri?
|
||||
elsif !@script.url.exists?
|
||||
add_failure(
|
||||
"internal script reference #{@script.src} does not exist",
|
||||
element: @script,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
external_urls
|
||||
end
|
||||
|
||||
def missing_src?
|
||||
@script.node["src"].nil?
|
||||
end
|
||||
|
||||
def check_sri
|
||||
if blank?(@script.node["integrity"]) && blank?(@script.node["crossorigin"])
|
||||
add_failure(
|
||||
"SRI and CORS not provided in: #{@script.url.raw_attribute}",
|
||||
element: @script,
|
||||
)
|
||||
elsif blank?(@script.node["integrity"])
|
||||
add_failure(
|
||||
"Integrity is missing in: #{@script.url.raw_attribute}",
|
||||
element: @script,
|
||||
)
|
||||
elsif blank?(@script.node["crossorigin"])
|
||||
add_failure(
|
||||
"CORS not provided for external resource in: #{@script.url.raw_attribute}",
|
||||
element: @script,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
# The CLI is a class responsible of handling all the command line interface
|
||||
# logic.
|
||||
class CLI
|
||||
attr_reader :options
|
||||
|
||||
def initialize
|
||||
@options = {}
|
||||
end
|
||||
|
||||
def run(args = ARGV)
|
||||
@options, path = HTMLProofer::Configuration.new.parse_cli_options(args)
|
||||
|
||||
paths = path.split(",")
|
||||
|
||||
if @options[:as_links]
|
||||
links = path.split(",").map(&:strip)
|
||||
HTMLProofer.check_links(links, @options).run
|
||||
elsif File.directory?(paths.first)
|
||||
HTMLProofer.check_directories(paths, @options).run
|
||||
else
|
||||
HTMLProofer.check_file(path, @options).run
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,351 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "optparse"
|
||||
|
||||
module HTMLProofer
|
||||
class Configuration
|
||||
DEFAULT_TESTS = ["Links", "Images", "Scripts"].freeze
|
||||
|
||||
PROOFER_DEFAULTS = {
|
||||
allow_hash_href: true,
|
||||
allow_missing_href: false,
|
||||
assume_extension: ".html",
|
||||
check_external_hash: true,
|
||||
check_internal_hash: true,
|
||||
checks: DEFAULT_TESTS,
|
||||
directory_index_file: "index.html",
|
||||
disable_external: false,
|
||||
ignore_empty_alt: true,
|
||||
ignore_empty_mailto: false,
|
||||
ignore_files: [],
|
||||
ignore_missing_alt: false,
|
||||
ignore_status_codes: [],
|
||||
ignore_urls: [],
|
||||
enforce_https: true,
|
||||
extensions: [".html"],
|
||||
log_level: :info,
|
||||
only_4xx: false,
|
||||
swap_attributes: {},
|
||||
swap_urls: {},
|
||||
}.freeze
|
||||
|
||||
TYPHOEUS_DEFAULTS = {
|
||||
followlocation: true,
|
||||
headers: {
|
||||
"User-Agent" => "Mozilla/5.0 (compatible; HTML Proofer/#{HTMLProofer::VERSION}; +https://github.com/gjtorikian/html-proofer)",
|
||||
"Accept" => "application/xml,application/xhtml+xml,text/html;q=0.9, text/plain;q=0.8,image/png,*/*;q=0.5",
|
||||
},
|
||||
connecttimeout: 10,
|
||||
timeout: 30,
|
||||
}.freeze
|
||||
|
||||
HYDRA_DEFAULTS = {
|
||||
max_concurrency: 50,
|
||||
}.freeze
|
||||
|
||||
CACHE_DEFAULTS = {}.freeze
|
||||
|
||||
class << self
|
||||
def generate_defaults(opts)
|
||||
options = PROOFER_DEFAULTS.merge(opts)
|
||||
|
||||
options[:typhoeus] = HTMLProofer::Configuration::TYPHOEUS_DEFAULTS.merge(opts[:typhoeus] || {})
|
||||
options[:hydra] = HTMLProofer::Configuration::HYDRA_DEFAULTS.merge(opts[:hydra] || {})
|
||||
|
||||
options[:cache] = HTMLProofer::Configuration::CACHE_DEFAULTS.merge(opts[:cache] || {})
|
||||
|
||||
options.delete(:src)
|
||||
|
||||
options
|
||||
end
|
||||
end
|
||||
|
||||
def initialize
|
||||
@options = {}
|
||||
end
|
||||
|
||||
def parse_cli_options(args)
|
||||
define_options.parse!(args)
|
||||
|
||||
input = ARGV.empty? ? "." : ARGV.join(",")
|
||||
|
||||
[@options, input]
|
||||
end
|
||||
|
||||
private def define_options
|
||||
OptionParser.new do |opts|
|
||||
opts.banner = "Usage: htmlproofer [options] PATH/LINK"
|
||||
|
||||
section(opts, "Input Options") do
|
||||
set_option(opts, "--as-links") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--assume-extension EXT") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--directory-index-file FILENAME") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--extensions [EXT1,EXT2,...]") do |long_opt_symbol, list|
|
||||
@options[long_opt_symbol] = list.nil? ? [] : list.split(",")
|
||||
end
|
||||
end
|
||||
|
||||
section(opts, "Check Configuration") do
|
||||
set_option(opts, "--[no-]allow-hash-href") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--[no-]allow-missing-href") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--checks [CHECK1,CHECK2,...]") do |long_opt_symbol, list|
|
||||
@options[long_opt_symbol] = list.nil? ? [] : list.split(",")
|
||||
end
|
||||
|
||||
set_option(opts, "--[no-]check-external-hash") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--[no-]check-internal-hash") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--[no-]check-sri") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--[no-]disable-external") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--[no-]enforce-https") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--root-dir <DIR>") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
end
|
||||
|
||||
section(opts, "Ignore Configuration") do
|
||||
set_option(opts, "--ignore-files [FILE1,FILE2,...]") do |long_opt_symbol, list|
|
||||
@options[long_opt_symbol] = if list.nil?
|
||||
[]
|
||||
else
|
||||
list.split(",").map.each do |l|
|
||||
if l.start_with?("/") && l.end_with?("/")
|
||||
Regexp.new(l[1...-1])
|
||||
else
|
||||
l
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
set_option(opts, "--[no-]ignore-empty-alt") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--[no-]ignore-empty-mailto") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--[no-]ignore-missing-alt") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
|
||||
set_option(opts, "--ignore-status-codes [500,401,420,...]") do |long_opt_symbol, list|
|
||||
@options[long_opt_symbol] = list.nil? ? [] : list.split(",").map(&:to_i)
|
||||
end
|
||||
|
||||
set_option(opts, "--ignore-urls [URL1, URL2,...]") do |long_opt_symbol, list|
|
||||
@options[long_opt_symbol] = if list.nil?
|
||||
[]
|
||||
else
|
||||
list.split(",").each_with_object([]) do |url, arr|
|
||||
arr << to_regex?(url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
set_option(opts, "--only-status-codes [404,451,...]") do |long_opt_symbol, list|
|
||||
@options[long_opt_symbol] = list.nil? ? [] : list.split(",")
|
||||
end
|
||||
|
||||
set_option(opts, "--only-4xx") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg
|
||||
end
|
||||
end
|
||||
|
||||
section(opts, "Transforms Configuration") do
|
||||
set_option(opts, "--swap-attributes <CONFIG>") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = parse_json_option("swap_attributes", arg, symbolize_names: false)
|
||||
end
|
||||
|
||||
set_option(opts, "--swap-urls [re:string,re:string,...]") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = str_to_regexp_map(arg)
|
||||
end
|
||||
end
|
||||
|
||||
section(opts, "Dependencies Configuration") do
|
||||
set_option(opts, "--typhoeus <CONFIG>") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = parse_json_option("typhoeus", arg, symbolize_names: false)
|
||||
end
|
||||
|
||||
set_option(opts, "--hydra <CONFIG>") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = parse_json_option("hydra", arg, symbolize_names: true)
|
||||
end
|
||||
|
||||
set_option(opts, "--cache <CONFIG>") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = parse_json_option("cache", arg, symbolize_names: true)
|
||||
end
|
||||
end
|
||||
|
||||
section(opts, "Reporting Configuration") do
|
||||
set_option(opts, "--log-level <LEVEL>") do |long_opt_symbol, arg|
|
||||
@options[long_opt_symbol] = arg.to_sym
|
||||
end
|
||||
end
|
||||
|
||||
section(opts, "General Configuration") do
|
||||
set_option(opts, "--version") do
|
||||
puts HTMLProofer::VERSION
|
||||
exit(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private def to_regex?(item)
|
||||
if item.start_with?("/") && item.end_with?("/")
|
||||
Regexp.new(item[1...-1])
|
||||
else
|
||||
item
|
||||
end
|
||||
end
|
||||
|
||||
private def str_to_regexp_map(arg)
|
||||
arg.split(",").each_with_object({}) do |s, hsh|
|
||||
split = s.split(/(?<!\\):/, 2)
|
||||
|
||||
re = split[0].gsub("\\:", ":")
|
||||
string = split[1].gsub("\\:", ":")
|
||||
hsh[Regexp.new(re)] = string
|
||||
end
|
||||
end
|
||||
|
||||
private def section(opts, heading, &_block)
|
||||
opts.separator("\n#{heading}:\n")
|
||||
yield
|
||||
end
|
||||
|
||||
private def set_option(opts, long_arg, &block)
|
||||
long_opt_symbol = parse_long_opt(long_arg)
|
||||
args = []
|
||||
args += Array(ConfigurationHelp::TEXT[long_opt_symbol])
|
||||
|
||||
opts.on(long_arg, *args) do |arg|
|
||||
yield long_opt_symbol, arg
|
||||
end
|
||||
end
|
||||
|
||||
# Converts the option into a symbol,
|
||||
# e.g. '--allow-hash-href' => :allow_hash_href.
|
||||
private def parse_long_opt(long_opt)
|
||||
long_opt[2..].sub("[no-]", "").sub(/ .*/, "").tr("-", "_").gsub(/[\[\]]/, "").to_sym
|
||||
end
|
||||
|
||||
def parse_json_option(option_name, config, symbolize_names: true)
|
||||
raise ArgumentError, "Must provide an option name in string format." unless option_name.is_a?(String)
|
||||
raise ArgumentError, "Must provide an option name in string format." if option_name.strip.empty?
|
||||
|
||||
return {} if config.nil?
|
||||
|
||||
raise ArgumentError, "Must provide a JSON configuration in string format." unless config.is_a?(String)
|
||||
|
||||
return {} if config.strip.empty?
|
||||
|
||||
begin
|
||||
JSON.parse(config, { symbolize_names: symbolize_names })
|
||||
rescue StandardError
|
||||
raise ArgumentError, "Option '#{option_name} did not contain valid JSON."
|
||||
end
|
||||
end
|
||||
|
||||
module ConfigurationHelp
|
||||
TEXT = {
|
||||
as_links: ["Assumes that `PATH` is a comma-separated array of links to check."],
|
||||
assume_extension: [
|
||||
"Automatically add specified extension to files for internal links, ",
|
||||
"to allow extensionless URLs (as supported by most servers) (default: `.html`).",
|
||||
],
|
||||
directory_index_file: ["Sets the file to look for when a link refers to a directory. (default: `index.html`)."],
|
||||
extensions: [
|
||||
"A comma-separated list of Strings indicating the file extensions you",
|
||||
"would like to check (default: `.html`)",
|
||||
],
|
||||
|
||||
allow_hash_href: ['"If `true`, assumes `href="#"` anchors are valid (default: `true`)"'],
|
||||
allow_missing_href: [
|
||||
"If `true`, does not flag `a` tags missing `href`. In HTML5, this is technically ",
|
||||
"allowed, but could also be human error. (default: `false`)",
|
||||
],
|
||||
checks: [
|
||||
"A comma-separated list of Strings indicating which checks you",
|
||||
"want to run (default: `[\"Links\", \"Images\", \"Scripts\"]",
|
||||
],
|
||||
check_external_hash: ["Checks whether external hashes exist (even if the webpage exists) (default: `true`)."],
|
||||
check_internal_hash: ["Checks whether internal hashes exist (even if the webpage exists) (default: `true`)."],
|
||||
check_sri: ["Check that `<link>` and `<script>` external resources use SRI (default: `false`)."],
|
||||
disable_external: ["If `true`, does not run the external link checker (default: `false`)."],
|
||||
enforce_https: ["Fails a link if it's not marked as `https` (default: `true`)."],
|
||||
root_dir: ["The absolute path to the directory serving your html-files."],
|
||||
|
||||
ignore_empty_alt: [
|
||||
"If `true`, ignores images with empty/missing ",
|
||||
"alt tags (in other words, `<img alt>` and `<img alt=\"\">`",
|
||||
"are valid; set this to `false` to flag those) (default: `true`).",
|
||||
],
|
||||
ignore_empty_mailto: [
|
||||
"If `true`, allows `mailto:` `href`s which don't",
|
||||
"contain an email address (default: `false`)'.",
|
||||
],
|
||||
ignore_missing_alt: ["If `true`, ignores images with missing alt tags (default: `false`)."],
|
||||
ignore_status_codes: ["A comma-separated list of numbers representing status codes to ignore."],
|
||||
ignore_files: ["A comma-separated list of Strings or RegExps containing file paths that are safe to ignore"],
|
||||
ignore_urls: [
|
||||
"A comma-separated list of Strings or RegExps containing URLs that are",
|
||||
"safe to ignore. This affects all HTML attributes, such as `alt` tags on images.",
|
||||
],
|
||||
only_status_codes: ["A comma-separated list of numbers representing the only status codes to report on."],
|
||||
only_4xx: ["Only reports errors for links that fall within the 4xx status code range."],
|
||||
|
||||
swap_attributes: [
|
||||
"JSON-formatted config that maps element names to the",
|
||||
"preferred attribute to check (default: `{}`).",
|
||||
],
|
||||
swap_urls: [
|
||||
"A comma-separated list containing key-value pairs of `RegExp => String`.",
|
||||
"It transforms URLs that match `RegExp` into `String` via `gsub`.",
|
||||
"The escape sequences `\\:` should be used to produce literal `:`s.",
|
||||
],
|
||||
|
||||
typhoeus: ["JSON-formatted string of Typhoeus config; if set, overrides the html-proofer defaults."],
|
||||
hydra: ["JSON-formatted string of Hydra config; if set, overrides the html-proofer defaults."],
|
||||
cache: ["JSON-formatted string of cache config; if set, overrides the html-proofer defaults."],
|
||||
|
||||
log_level: [
|
||||
"Sets the logging level. One of `:debug`, `:info`, ",
|
||||
"`:warn`, `:error`, or `:fatal`. (default: `:info`)",
|
||||
],
|
||||
|
||||
version: ["Prints the version of html-proofer."],
|
||||
}.freeze
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,158 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "addressable/uri"
|
||||
|
||||
module HTMLProofer
|
||||
# Represents the element currently being processed
|
||||
class Element
|
||||
include HTMLProofer::Utils
|
||||
|
||||
attr_reader :node, :url, :base_url, :line, :content
|
||||
|
||||
def initialize(runner, node, base_url: nil)
|
||||
@runner = runner
|
||||
@node = node
|
||||
|
||||
swap_attributes!
|
||||
|
||||
@base_url = base_url
|
||||
@url = Attribute::Url.new(runner, link_attribute, base_url: base_url, source: @runner.current_source, filename: @runner.current_filename)
|
||||
|
||||
@line = node.line
|
||||
@content = node.content
|
||||
end
|
||||
|
||||
def link_attribute
|
||||
meta_content || src || srcset || href
|
||||
end
|
||||
|
||||
def meta_content
|
||||
return unless meta_tag?
|
||||
|
||||
@node["content"]
|
||||
end
|
||||
|
||||
def meta_tag?
|
||||
@node.name == "meta"
|
||||
end
|
||||
|
||||
def src
|
||||
return if !img_tag? && !script_tag? && !source_tag?
|
||||
|
||||
@node["src"]
|
||||
end
|
||||
|
||||
def img_tag?
|
||||
@node.name == "img"
|
||||
end
|
||||
|
||||
def script_tag?
|
||||
@node.name == "script"
|
||||
end
|
||||
|
||||
def srcset
|
||||
return if !img_tag? && !source_tag?
|
||||
|
||||
@node["srcset"]
|
||||
end
|
||||
|
||||
def source_tag?
|
||||
@node.name == "source"
|
||||
end
|
||||
|
||||
def href
|
||||
return if !a_tag? && !link_tag?
|
||||
|
||||
@node["href"]
|
||||
end
|
||||
|
||||
def a_tag?
|
||||
@node.name == "a"
|
||||
end
|
||||
|
||||
def link_tag?
|
||||
@node.name == "link"
|
||||
end
|
||||
|
||||
def aria_hidden?
|
||||
@node.attributes["aria-hidden"]&.value == "true"
|
||||
end
|
||||
|
||||
def multiple_srcsets?
|
||||
!blank?(srcset) && srcset.split(",").size > 1
|
||||
end
|
||||
|
||||
# From https://github.com/sindresorhus/srcset/blob/f7c48acd7facf18e94dec47e6b96e84e0f0e69dc/index.js#LL1-L16C71
|
||||
# This regex represents a loose rule of an “image candidate string”; see https://html.spec.whatwg.org/multipage/images.html#srcset-attribute
|
||||
# An “image candidate string” roughly consists of the following:
|
||||
# 1. Zero or more whitespace characters.
|
||||
# 2. A non-empty URL that does not start or end with `,`.
|
||||
# 3. Zero or more whitespace characters.
|
||||
# 4. An optional “descriptor” that starts with a whitespace character.
|
||||
# 5. Zero or more whitespace characters.
|
||||
# 6. Each image candidate string is separated by a `,`.
|
||||
# We intentionally implement a loose rule here so that we can perform more aggressive error handling and reporting in the below code.
|
||||
|
||||
IMAGE_CANDIDATE_REGEX = /\s*([^,]\S*[^,](?:\s+[^,]+)?)\s*(?:,|$)/
|
||||
|
||||
def srcsets
|
||||
return if blank?(srcset)
|
||||
|
||||
srcset.split(IMAGE_CANDIDATE_REGEX).select.with_index do |_part, idx|
|
||||
idx.odd?
|
||||
end.map(&:strip)
|
||||
end
|
||||
|
||||
def multiple_sizes?
|
||||
return false if blank?(srcsets)
|
||||
|
||||
srcsets.any? do |srcset|
|
||||
!blank?(srcset) && srcset.split(" ").size > 1
|
||||
end
|
||||
end
|
||||
|
||||
def srcsets_wo_sizes
|
||||
return if blank?(srcsets)
|
||||
|
||||
srcsets.map do |srcset|
|
||||
srcset.split(" ").first
|
||||
end
|
||||
end
|
||||
|
||||
def ignore?
|
||||
return true if @node.attributes["data-proofer-ignore"]
|
||||
return true if ancestors_ignorable?
|
||||
|
||||
return true if url&.ignore?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
private def attribute_swapped?
|
||||
return false if blank?(@runner.options[:swap_attributes])
|
||||
|
||||
attrs = @runner.options[:swap_attributes][@node.name]
|
||||
|
||||
true unless blank?(attrs)
|
||||
end
|
||||
|
||||
private def swap_attributes!
|
||||
return unless attribute_swapped?
|
||||
|
||||
attr_swaps = @runner.options[:swap_attributes][@node.name]
|
||||
|
||||
attr_swaps.flatten.each_slice(2) do |(old_attr, new_attr)|
|
||||
next if blank?(node[old_attr])
|
||||
|
||||
node[new_attr] = node[old_attr]
|
||||
node.delete(old_attr)
|
||||
end
|
||||
end
|
||||
|
||||
private def ancestors_ignorable?
|
||||
ancestors_attributes = @node.ancestors.map { |a| a.respond_to?(:attributes) && a.attributes }
|
||||
ancestors_attributes.pop # remove document at the end
|
||||
ancestors_attributes.any? { |a| !a["data-proofer-ignore"].nil? }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
class Failure
|
||||
attr_reader :path, :check_name, :description, :status, :line, :content
|
||||
|
||||
def initialize(path, check_name, description, line: nil, status: nil, content: nil)
|
||||
@path = path
|
||||
@check_name = check_name
|
||||
@description = description
|
||||
|
||||
@line = line
|
||||
@status = status
|
||||
@content = content
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "yell"
|
||||
require "rainbow"
|
||||
|
||||
module HTMLProofer
|
||||
class Log
|
||||
include Yell::Loggable
|
||||
|
||||
STDOUT_LEVELS = [:debug, :info, :warn].freeze
|
||||
STDERR_LEVELS = [:error, :fatal].freeze
|
||||
|
||||
def initialize(log_level)
|
||||
@logger = Yell.new(
|
||||
format: false,
|
||||
name: "HTMLProofer",
|
||||
level: "gte.#{log_level}",
|
||||
) do |l|
|
||||
l.adapter(:stdout, level: "lte.warn")
|
||||
l.adapter(:stderr, level: "gte.error")
|
||||
end
|
||||
end
|
||||
|
||||
def log(level, message)
|
||||
log_with_color(level, message)
|
||||
end
|
||||
|
||||
def log_with_color(level, message)
|
||||
@logger.send(level, colorize(level, message))
|
||||
end
|
||||
|
||||
def colorize(level, message)
|
||||
color = case level
|
||||
when :debug
|
||||
:cyan
|
||||
when :info
|
||||
:blue
|
||||
when :warn
|
||||
:yellow
|
||||
when :error, :fatal
|
||||
:red
|
||||
end
|
||||
|
||||
if STDOUT_LEVELS.include?(level) ||
|
||||
STDERR_LEVELS.include?(level)
|
||||
Rainbow(message).send(color)
|
||||
else
|
||||
message
|
||||
end
|
||||
end
|
||||
|
||||
# dumb override to play nice with Typhoeus/Ethon
|
||||
def debug(message = nil)
|
||||
log(:debug, message) unless message.nil?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
class Reporter
|
||||
include HTMLProofer::Utils
|
||||
|
||||
attr_reader :failures
|
||||
|
||||
def initialize(logger: nil)
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
def failures=(failures)
|
||||
@failures = failures.group_by(&:check_name)
|
||||
.transform_values { |issues| issues.sort_by { |issue| [issue.path, issue.line] } }
|
||||
.sort
|
||||
end
|
||||
|
||||
def report
|
||||
raise NotImplementedError, "HTMLProofer::Reporter subclasses must implement #report"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
class Reporter
|
||||
class Terminal < HTMLProofer::Reporter
|
||||
def report
|
||||
msg = failures.each_with_object([]) do |(check_name, failures), arr|
|
||||
str = ["For the #{check_name} check, the following failures were found:\n"]
|
||||
|
||||
failures.each do |failure|
|
||||
path_str = blank?(failure.path) ? "" : "At #{failure.path}"
|
||||
|
||||
line_str = failure.line.nil? ? "" : ":#{failure.line}"
|
||||
|
||||
path_and_line = "#{path_str}#{line_str}"
|
||||
path_and_line = blank?(path_and_line) ? "" : "* #{path_and_line}:\n\n"
|
||||
|
||||
status_str = failure.status.nil? ? "" : " (status code #{failure.status})"
|
||||
|
||||
indent = blank?(path_and_line) ? "* " : " "
|
||||
str << <<~MSG
|
||||
#{path_and_line}#{indent}#{failure.description}#{status_str}
|
||||
MSG
|
||||
end
|
||||
|
||||
arr << str.join("\n")
|
||||
end
|
||||
|
||||
@logger.log(:error, msg.join("\n"))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,256 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "async"
|
||||
|
||||
module HTMLProofer
|
||||
class Runner
|
||||
include HTMLProofer::Utils
|
||||
|
||||
attr_reader :options, :cache, :logger, :internal_urls, :external_urls, :resolved_paths, :current_check, :current_filename, :current_source
|
||||
attr_accessor :reporter
|
||||
|
||||
URL_TYPES = [:external, :internal].freeze
|
||||
|
||||
def initialize(src, opts = {})
|
||||
@options = HTMLProofer::Configuration.generate_defaults(opts)
|
||||
|
||||
@type = @options.delete(:type)
|
||||
@source = src
|
||||
|
||||
@logger = HTMLProofer::Log.new(@options[:log_level])
|
||||
@cache = Cache.new(self, @options[:cache])
|
||||
|
||||
@external_urls = {}
|
||||
@internal_urls = {}
|
||||
@failures = []
|
||||
|
||||
@before_request = []
|
||||
|
||||
@resolved_paths = {}
|
||||
|
||||
@current_check = nil
|
||||
@current_source = nil
|
||||
@current_filename = nil
|
||||
|
||||
@reporter = Reporter::Terminal.new(logger: @logger)
|
||||
end
|
||||
|
||||
def run
|
||||
check_text = pluralize(checks.length, "check", "checks")
|
||||
|
||||
if @type == :links
|
||||
@logger.log(:info, "Running #{check_text} (#{format_checks_list(checks)}) on #{@source} ...\n\n")
|
||||
check_list_of_links unless @options[:disable_external]
|
||||
else
|
||||
@logger.log(
|
||||
:info,
|
||||
"Running #{check_text} (#{format_checks_list(checks)}) in #{@source} on *#{@options[:extensions].join(", ")} files ...\n\n",
|
||||
)
|
||||
|
||||
check_files
|
||||
@logger.log(:info, "Ran on #{pluralize(files.length, "file", "files")}!\n\n")
|
||||
end
|
||||
|
||||
@cache.write
|
||||
|
||||
@reporter.failures = @failures
|
||||
|
||||
if @failures.empty?
|
||||
@logger.log(:info, "HTML-Proofer finished successfully.")
|
||||
else
|
||||
@failures.uniq!
|
||||
report_failed_checks
|
||||
end
|
||||
end
|
||||
|
||||
def check_list_of_links
|
||||
@external_urls = @source.uniq.each_with_object({}) do |link, hash|
|
||||
url = Attribute::Url.new(self, link, base_url: nil).to_s
|
||||
|
||||
hash[url] = []
|
||||
end
|
||||
|
||||
validate_external_urls
|
||||
end
|
||||
|
||||
# Walks over each implemented check and runs them on the files, in parallel.
|
||||
# Sends the collected external URLs to Typhoeus for batch processing.
|
||||
def check_files
|
||||
process_files.each do |result|
|
||||
URL_TYPES.each do |url_type|
|
||||
type = :"#{url_type}_urls"
|
||||
ivar_name = "@#{type}"
|
||||
ivar = instance_variable_get(ivar_name)
|
||||
|
||||
if ivar.empty?
|
||||
instance_variable_set(ivar_name, result[type])
|
||||
else
|
||||
result[type].each do |url, metadata|
|
||||
ivar[url] = [] if ivar[url].nil?
|
||||
ivar[url].concat(metadata)
|
||||
end
|
||||
end
|
||||
end
|
||||
@failures.concat(result[:failures])
|
||||
end
|
||||
|
||||
validate_external_urls unless @options[:disable_external]
|
||||
|
||||
validate_internal_urls
|
||||
end
|
||||
|
||||
# Walks over each implemented check and runs them on the files, in parallel.
|
||||
def process_files
|
||||
loaded_files = []
|
||||
files.each do |file|
|
||||
Async do |task|
|
||||
task.async do
|
||||
loaded_files << load_file(file[:path], file[:source])
|
||||
end
|
||||
end
|
||||
end
|
||||
loaded_files
|
||||
end
|
||||
|
||||
def load_file(path, source)
|
||||
@html = create_nokogiri(path)
|
||||
check_parsed(path, source)
|
||||
end
|
||||
|
||||
# Collects any external URLs found in a directory of files. Also collectes
|
||||
# every failed test from process_files.
|
||||
def check_parsed(path, source)
|
||||
result = { internal_urls: {}, external_urls: {}, failures: [] }
|
||||
|
||||
checks.each do |klass|
|
||||
@current_source = source
|
||||
@current_filename = path
|
||||
|
||||
check = Object.const_get(klass).new(self, @html)
|
||||
@logger.log(:debug, "Running #{check.short_name} in #{path}")
|
||||
|
||||
@current_check = check
|
||||
|
||||
check.run
|
||||
|
||||
result[:external_urls].merge!(check.external_urls) { |_key, old, current| old.concat(current) }
|
||||
result[:internal_urls].merge!(check.internal_urls) { |_key, old, current| old.concat(current) }
|
||||
result[:failures].concat(check.failures)
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def validate_external_urls
|
||||
external_url_validator = HTMLProofer::UrlValidator::External.new(self, @external_urls)
|
||||
external_url_validator.before_request = @before_request
|
||||
@failures.concat(external_url_validator.validate)
|
||||
end
|
||||
|
||||
def validate_internal_urls
|
||||
internal_link_validator = HTMLProofer::UrlValidator::Internal.new(self, @internal_urls)
|
||||
@failures.concat(internal_link_validator.validate)
|
||||
end
|
||||
|
||||
def files
|
||||
@files ||= if @type == :directory
|
||||
@source.map do |src|
|
||||
pattern = File.join(src, "**", "*{#{@options[:extensions].join(",")}}")
|
||||
Dir.glob(pattern).select do |f|
|
||||
File.file?(f) && !ignore_file?(f)
|
||||
end.map { |f| { source: src, path: f } }
|
||||
end.flatten
|
||||
elsif @type == :file && @options[:extensions].include?(File.extname(@source))
|
||||
[@source].reject { |f| ignore_file?(f) }.map { |f| { source: f, path: f } }
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def ignore_file?(file)
|
||||
@options[:ignore_files].each do |pattern|
|
||||
if pattern.is_a?(String) && pattern == file
|
||||
@logger.log(:debug, "Ignoring #{file} because it matches #{pattern}")
|
||||
return true
|
||||
end
|
||||
next unless pattern.is_a?(Regexp) && pattern.match(file)
|
||||
|
||||
@logger.log(:debug, "Ignoring #{file} because it matches regexp #{pattern}")
|
||||
return true
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def check_sri?
|
||||
@options[:check_sri]
|
||||
end
|
||||
|
||||
def enforce_https?
|
||||
@options[:enforce_https]
|
||||
end
|
||||
|
||||
def checks
|
||||
return @checks if defined?(@checks) && !@checks.nil?
|
||||
|
||||
return (@checks = ["LinkCheck"]) if @type == :links
|
||||
|
||||
@checks = HTMLProofer::Check.subchecks(@options).map(&:name)
|
||||
|
||||
@checks
|
||||
end
|
||||
|
||||
def failed_checks
|
||||
@reporter.failures.flatten.select { |f| f.is_a?(Failure) }
|
||||
end
|
||||
|
||||
def report_failed_checks
|
||||
@reporter.report
|
||||
|
||||
failure_text = pluralize(@failures.length, "failure", "failures")
|
||||
@logger.log(:fatal, "\nHTML-Proofer found #{failure_text}!")
|
||||
exit(1)
|
||||
end
|
||||
|
||||
# Set before_request callback.
|
||||
#
|
||||
# @example Set before_request.
|
||||
# request.before_request { |request| p "yay" }
|
||||
#
|
||||
# @param [ Block ] block The block to execute.
|
||||
#
|
||||
# @yield [ Typhoeus::Request ]
|
||||
#
|
||||
# @return [ Array<Block> ] All before_request blocks.
|
||||
def before_request(&block)
|
||||
@before_request ||= []
|
||||
@before_request << block if block
|
||||
@before_request
|
||||
end
|
||||
|
||||
def load_internal_cache
|
||||
load_cache(:internal)
|
||||
end
|
||||
|
||||
def load_external_cache
|
||||
load_cache(:external)
|
||||
end
|
||||
|
||||
private def load_cache(type)
|
||||
ivar = instance_variable_get(:"@#{type}_urls")
|
||||
|
||||
existing_urls_count = @cache.size(type)
|
||||
cache_text = pluralize(existing_urls_count, "#{type} link", "#{type} links")
|
||||
@logger.log(:debug, "Found #{cache_text} in the cache")
|
||||
|
||||
urls_to_check = @cache.retrieve_urls(ivar, type)
|
||||
|
||||
urls_to_check
|
||||
end
|
||||
|
||||
private def format_checks_list(checks)
|
||||
checks.map do |check|
|
||||
check.sub("HTMLProofer::Check::", "")
|
||||
end.sort.join(", ")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
class UrlValidator
|
||||
include HTMLProofer::Utils
|
||||
|
||||
def initialize(runner)
|
||||
@runner = runner
|
||||
|
||||
@cache = @runner.cache
|
||||
@logger = @runner.logger
|
||||
|
||||
@failed_checks = []
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,221 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "typhoeus"
|
||||
require "open-uri"
|
||||
# require "uri"
|
||||
require "pdf-reader"
|
||||
|
||||
module HTMLProofer
|
||||
class UrlValidator
|
||||
class External < UrlValidator
|
||||
include HTMLProofer::Utils
|
||||
|
||||
attr_reader :external_urls
|
||||
attr_writer :before_request
|
||||
|
||||
def initialize(runner, external_urls)
|
||||
super(runner)
|
||||
|
||||
@external_urls = external_urls
|
||||
@hydra = Typhoeus::Hydra.new(@runner.options[:hydra])
|
||||
@before_request = []
|
||||
|
||||
@paths_with_queries = {}
|
||||
end
|
||||
|
||||
def validate
|
||||
urls_to_check = @cache.external_enabled? ? @runner.load_external_cache : @external_urls
|
||||
urls_detected = pluralize(urls_to_check.count, "external link", "external links")
|
||||
@logger.log(:info, "Checking #{urls_detected}")
|
||||
|
||||
run_external_link_checker(urls_to_check)
|
||||
|
||||
@failed_checks
|
||||
end
|
||||
|
||||
# Proofer runs faster if we pull out all the external URLs and run the checks
|
||||
# at the end. Otherwise, we're halting the consuming process for every file during
|
||||
# `process_files`.
|
||||
#
|
||||
# In addition, sorting the list lets libcurl keep connections to the same hosts alive.
|
||||
#
|
||||
# Finally, we'll first make a HEAD request, rather than GETing all the contents.
|
||||
# If the HEAD fails, we'll fall back to GET, as some servers are not configured
|
||||
# for HEAD. If we've decided to check for hashes, we must do a GET--HEAD is
|
||||
# not available as an option.
|
||||
def run_external_link_checker(external_urls)
|
||||
# Route log from Typhoeus/Ethon to our own logger
|
||||
Ethon.logger = @logger
|
||||
|
||||
external_urls.each_pair do |external_url, metadata|
|
||||
url = Attribute::Url.new(@runner, external_url, base_url: nil)
|
||||
|
||||
unless url.valid?
|
||||
add_failure(metadata, "#{url} is an invalid URL", 0)
|
||||
next
|
||||
end
|
||||
|
||||
next unless new_url_query_values?(url)
|
||||
|
||||
method = if @runner.options[:check_external_hash] && url.hash?
|
||||
:get
|
||||
else
|
||||
:head
|
||||
end
|
||||
|
||||
queue_request(method, url, metadata)
|
||||
end
|
||||
|
||||
@hydra.run
|
||||
end
|
||||
|
||||
def queue_request(method, url, filenames)
|
||||
opts = @runner.options[:typhoeus].merge(method: method)
|
||||
request = Typhoeus::Request.new(url.url, opts)
|
||||
@before_request.each do |callback|
|
||||
callback.call(request)
|
||||
end
|
||||
request.on_complete { |response| response_handler(response, url, filenames) }
|
||||
@hydra.queue(request)
|
||||
end
|
||||
|
||||
def response_handler(response, url, filenames)
|
||||
method = response.request.options[:method]
|
||||
href = response.request.base_url.to_s
|
||||
response_code = response.code
|
||||
response.body.delete!("\x00")
|
||||
|
||||
@logger.log(:debug, "Received a #{response_code} for #{href}")
|
||||
|
||||
return if @runner.options[:ignore_status_codes].include?(response_code)
|
||||
|
||||
if response_code.between?(200, 299)
|
||||
@cache.add_external(href, filenames, response_code, "OK", true) unless check_hash_in_2xx_response(
|
||||
href,
|
||||
url,
|
||||
response,
|
||||
filenames,
|
||||
)
|
||||
elsif response.timed_out?
|
||||
handle_timeout(href, filenames, response_code)
|
||||
elsif response_code.zero?
|
||||
handle_connection_failure(href, filenames, response_code, response.status_message)
|
||||
elsif method == :head # some servers don't support HEAD
|
||||
queue_request(:get, url, filenames)
|
||||
else
|
||||
return if @runner.options[:only_4xx] && !response_code.between?(400, 499)
|
||||
|
||||
# Received a non-successful http response.
|
||||
status_message = blank?(response.status_message) ? "" : ": #{response.status_message}"
|
||||
msg = "External link #{href} failed#{status_message}"
|
||||
add_failure(filenames, msg, response_code)
|
||||
@cache.add_external(href, filenames, response_code, msg, false)
|
||||
end
|
||||
end
|
||||
|
||||
# Even though the response was a success, we may have been asked to check
|
||||
# if the hash on the URL exists on the page
|
||||
def check_hash_in_2xx_response(href, url, response, filenames)
|
||||
return false if @runner.options[:only_4xx]
|
||||
return false unless @runner.options[:check_external_hash]
|
||||
return false unless url.hash?
|
||||
|
||||
hash = url.hash
|
||||
headers = response.options.fetch(:headers, {})
|
||||
content_type = headers.find { |k, _| k.casecmp("content-type").zero? }
|
||||
|
||||
# attempt to verify PDF hash ref; see #787 for more details
|
||||
# FIXME: this is re-reading the PDF response
|
||||
if content_type && content_type[1].include?("pdf")
|
||||
io = URI.parse(url.to_s).open
|
||||
reader = PDF::Reader.new(io)
|
||||
|
||||
pages = reader.pages
|
||||
if hash =~ /\Apage=(\d+)\z/
|
||||
page = Regexp.last_match[1].to_i
|
||||
|
||||
unless pages[page - 1]
|
||||
msg = "External link #{href} failed: #{url.without_hash} exists, but the hash '#{hash}' does not"
|
||||
add_failure(filenames, msg, response.code)
|
||||
@cache.add_external(href, filenames, response.code, msg, false)
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
body_doc = create_nokogiri(response.body)
|
||||
|
||||
unencoded_hash = Addressable::URI.unescape(hash)
|
||||
xpath = [%(//*[@name="#{hash}"]|/*[@name="#{unencoded_hash}"]|//*[@id="#{hash}"]|//*[@id="#{unencoded_hash}"])]
|
||||
# user-content is a special addition by GitHub.
|
||||
if url.host =~ /github\.com/i
|
||||
xpath << [%(//*[@name="user-content-#{hash}"]|//*[@id="user-content-#{hash}"])]
|
||||
# when linking to a file on GitHub, like #L12-L34, only the first "L" portion
|
||||
# will be identified as a linkable portion
|
||||
xpath << [%(//td[@id="#{Regexp.last_match[1]}"])] if hash =~ /\A(L\d)+/
|
||||
end
|
||||
|
||||
return unless body_doc.xpath(xpath.join("|")).empty?
|
||||
|
||||
msg = "External link #{href} failed: #{url.without_hash} exists, but the hash '#{hash}' does not"
|
||||
add_failure(filenames, msg, response.code)
|
||||
@cache.add_external(href, filenames, response.code, msg, false)
|
||||
true
|
||||
end
|
||||
|
||||
def handle_timeout(href, filenames, response_code)
|
||||
msg = "External link #{href} failed: got a time out (response code #{response_code})"
|
||||
@cache.add_external(href, filenames, 0, msg, false)
|
||||
return if @runner.options[:only_4xx]
|
||||
|
||||
add_failure(filenames, msg, response_code)
|
||||
end
|
||||
|
||||
def handle_connection_failure(href, metadata, response_code, status_message)
|
||||
msgs = [<<~MSG,
|
||||
External link #{href} failed with something very wrong.
|
||||
It's possible libcurl couldn't connect to the server, or perhaps the request timed out.
|
||||
Sometimes, making too many requests at once also breaks things.
|
||||
MSG
|
||||
]
|
||||
|
||||
msgs << "Either way, the return message from the server is: #{status_message}" unless blank?(status_message)
|
||||
|
||||
msg = msgs.join("\n").chomp
|
||||
|
||||
@cache.add_external(href, metadata, 0, msg, false)
|
||||
return if @runner.options[:only_4xx]
|
||||
|
||||
add_failure(metadata, msg, response_code)
|
||||
end
|
||||
|
||||
def add_failure(metadata, description, status = nil)
|
||||
if blank?(metadata) # possible if we're checking an array of links
|
||||
@failed_checks << Failure.new("", "Links > External", description, status: status)
|
||||
else
|
||||
metadata.each do |m|
|
||||
@failed_checks << Failure.new(m[:filename], "Links > External", description, line: m[:line], status: status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# remember queries we've seen, ignore future ones
|
||||
private def new_url_query_values?(url)
|
||||
return true if (query_values = url.query_values).nil?
|
||||
|
||||
queries = query_values.keys.join("-")
|
||||
domain_path = url.domain_path
|
||||
if @paths_with_queries[domain_path].nil?
|
||||
@paths_with_queries[domain_path] = [queries]
|
||||
true
|
||||
elsif !@paths_with_queries[domain_path].include?(queries)
|
||||
@paths_with_queries[domain_path] << queries
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,146 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
class UrlValidator
|
||||
class Internal < UrlValidator
|
||||
attr_reader :internal_urls
|
||||
|
||||
def initialize(runner, internal_urls)
|
||||
super(runner)
|
||||
|
||||
@internal_urls = internal_urls
|
||||
end
|
||||
|
||||
def validate
|
||||
urls_to_check = @cache.internal_enabled? ? @runner.load_internal_cache : @internal_urls
|
||||
urls_detected = pluralize(urls_to_check.count, "internal link", "internal links")
|
||||
@logger.log(:info, "Checking #{urls_detected}")
|
||||
|
||||
run_internal_link_checker(urls_to_check)
|
||||
|
||||
@failed_checks
|
||||
end
|
||||
|
||||
def run_internal_link_checker(links)
|
||||
# collect urls and metadata for hashes to be checked in the same target file
|
||||
file_paths_hashes_to_check = {}
|
||||
to_add = []
|
||||
links.each_with_index do |(link, matched_files), i|
|
||||
matched_count_to_log = pluralize(matched_files.count, "reference", "references")
|
||||
@logger.log(:debug, "(#{i + 1} / #{links.count}) Internal link #{link}: Checking #{matched_count_to_log}")
|
||||
matched_files.each do |metadata|
|
||||
url = HTMLProofer::Attribute::Url.new(@runner, link, base_url: metadata[:base_url], source: metadata[:source], filename: metadata[:filename])
|
||||
|
||||
unless url.exists?
|
||||
@failed_checks << Failure.new(
|
||||
metadata[:filename],
|
||||
"Links > Internal",
|
||||
"internally linking to #{url}, which does not exist",
|
||||
line: metadata[:line],
|
||||
status: nil,
|
||||
content: nil,
|
||||
)
|
||||
to_add << [url, metadata, false]
|
||||
next
|
||||
end
|
||||
|
||||
hash_exists = hash_exists_for_url?(url)
|
||||
if hash_exists.nil?
|
||||
# the hash needs to be checked in the target file, we collect the url and metadata
|
||||
target_file_path = url.resolved_path
|
||||
unless file_paths_hashes_to_check.key?(target_file_path)
|
||||
file_paths_hashes_to_check[target_file_path] = {}
|
||||
end
|
||||
unless file_paths_hashes_to_check[target_file_path].key?(url.hash)
|
||||
file_paths_hashes_to_check[target_file_path][url.hash] = []
|
||||
end
|
||||
file_paths_hashes_to_check[target_file_path][url.hash] << [url, metadata]
|
||||
next
|
||||
end
|
||||
unless hash_exists
|
||||
@failed_checks << Failure.new(
|
||||
metadata[:filename],
|
||||
"Links > Internal",
|
||||
"internally linking to #{url}; the file exists, but the hash '#{url.hash}' does not",
|
||||
line: metadata[:line],
|
||||
status: nil,
|
||||
content: nil,
|
||||
)
|
||||
to_add << [url, metadata, false]
|
||||
next
|
||||
end
|
||||
|
||||
to_add << [url, metadata, true]
|
||||
end
|
||||
end
|
||||
|
||||
# check hashes by target file
|
||||
@logger.log(:info, "Checking internal link hashes in #{pluralize(file_paths_hashes_to_check.count, "file", "files")}")
|
||||
file_paths_hashes_to_check.each_with_index do |(file_path, hashes_to_check), i|
|
||||
hash_count_to_log = pluralize(hashes_to_check.count, "hash", "hashes")
|
||||
@logger.log(:debug, "(#{i + 1} / #{file_paths_hashes_to_check.count}) Checking #{hash_count_to_log} in #{file_path}")
|
||||
html = create_nokogiri(file_path)
|
||||
hashes_to_check.each_pair do |href_hash, url_metadata|
|
||||
exists = hash_exists_in_html?(href_hash, html)
|
||||
url_metadata.each do |(url, metadata)|
|
||||
unless exists
|
||||
@failed_checks << Failure.new(
|
||||
metadata[:filename],
|
||||
"Links > Internal",
|
||||
"internally linking to #{url}; the file exists, but the hash '#{href_hash}' does not",
|
||||
line: metadata[:line],
|
||||
status: nil,
|
||||
content: nil,
|
||||
)
|
||||
end
|
||||
to_add << [url, metadata, exists]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# adding directly to the cache above results in an endless loop
|
||||
to_add.each do |(url, metadata, exists)|
|
||||
@cache.add_internal(url.to_s, metadata, exists)
|
||||
end
|
||||
|
||||
@failed_checks
|
||||
end
|
||||
|
||||
# verify the hash w/o just based on the URL, w/o looking at the target file
|
||||
# => returns nil if the has could not be verified
|
||||
private def hash_exists_for_url?(url)
|
||||
href_hash = url.hash
|
||||
return true if blank?(href_hash)
|
||||
return true unless @runner.options[:check_internal_hash]
|
||||
|
||||
# prevents searching files we didn't ask about
|
||||
return false unless url.known_extension?
|
||||
return false unless url.has_hash?
|
||||
|
||||
decoded_href_hash = Addressable::URI.unescape(href_hash)
|
||||
fragment_ids = [href_hash, decoded_href_hash]
|
||||
# https://www.w3.org/TR/html5/single-page.html#scroll-to-fragid
|
||||
true if fragment_ids.include?("top")
|
||||
end
|
||||
|
||||
private def hash_exists_in_html?(href_hash, html)
|
||||
decoded_href_hash = Addressable::URI.unescape(href_hash)
|
||||
fragment_ids = [href_hash, decoded_href_hash]
|
||||
!find_fragments(fragment_ids, html).empty?
|
||||
end
|
||||
|
||||
private def find_fragments(fragment_ids, html)
|
||||
xpaths = fragment_ids.uniq.flat_map do |frag_id|
|
||||
escaped_frag_id = "'#{frag_id.split("'").join("', \"'\", '")}', ''"
|
||||
[
|
||||
"//*[case_sensitive_equals(@id, concat(#{escaped_frag_id}))]",
|
||||
"//*[case_sensitive_equals(@name, concat(#{escaped_frag_id}))]",
|
||||
]
|
||||
end
|
||||
xpaths << XpathFunctions.new
|
||||
|
||||
html.xpath(*xpaths)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "nokogiri"
|
||||
|
||||
module HTMLProofer
|
||||
module Utils
|
||||
def pluralize(count, single, plural)
|
||||
"#{count} #{count == 1 ? single : plural}"
|
||||
end
|
||||
|
||||
def blank?(obj)
|
||||
obj.nil? || obj.empty?
|
||||
end
|
||||
|
||||
def create_nokogiri(path)
|
||||
content = if File.exist?(path) && !File.directory?(path)
|
||||
File.read(path)
|
||||
else
|
||||
path
|
||||
end
|
||||
|
||||
Nokogiri::HTML5(content, max_errors: -1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
VERSION = "5.0.10"
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HTMLProofer
|
||||
# https://stackoverflow.com/a/8812293
|
||||
class XpathFunctions
|
||||
def case_sensitive_equals(node_set, str_to_match)
|
||||
node_set.find_all { |node| node.to_s == str_to_match.to_s }
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user