Class: Mdlint::Linter::Rules::LinkCheck

Inherits:
Mdlint::Linter::Rule show all
Defined in:
lib/mdlint/linter/rules/link_check.rb,
sig/internal.rbs

Instance Attribute Summary

Attributes inherited from Mdlint::Linter::Rule

#violations

Instance Method Summary collapse

Methods inherited from Mdlint::Linter::Rule

#add_violation, #fix, inherited, #initialize

Constructor Details

This class inherits a constructor from Mdlint::Linter::Rule

Instance Method Details

#add_missing_target(path, token) ⇒ Object

Parameters:

  • path (Object)
  • token (Object)

Returns:

  • (Object)


83
84
85
86
87
88
89
# File 'lib/mdlint/linter/rules/link_check.rb', line 83

def add_missing_target(path, token)
  add_violation(
    message: "Link target does not exist: #{path}",
    line: (token.map&.first || 0) + 1,
    fixable: false
  )
end

#check(tokens, source) ⇒ Object



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/mdlint/linter/rules/link_check.rb', line 15

def check(tokens, source)
  return @violations unless @options[:check_links] || @options[:check_external_links]

  @headings = heading_slugs(tokens)

  tokens.each do |token|
    next unless token.type == :inline

    token.children.each do |child|
      next unless child.type == :link_open || child.type == :image

      target = child.attrs[:href] || child.attrs[:src]
      image = child.is_a?(Token) && child.type == :image
      check_target(target, token, image, source)
    end
  end
  @violations
end

#check_external_target(target, token) ⇒ Object

Parameters:

  • target (Object)
  • token (Object)

Returns:

  • (Object)


91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/mdlint/linter/rules/link_check.rb', line 91

def check_external_target(target, token)
  uri = URI.parse(target)
  host = uri.host.to_s
  request_path = uri.path.to_s
  request_path = "/" if request_path.empty?
  request_path += "?#{uri.query}" if uri.query
  response = Net::HTTP.start(
    host,
    uri.port,
    use_ssl: uri.scheme == "https",
    open_timeout: 3,
    read_timeout: 3
  ) do |http|
    result = http.head(request_path)
    result.is_a?(Net::HTTPMethodNotAllowed) ? http.get(request_path) : result
  end
  return if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)

  add_violation(
    message: "External link returned HTTP #{response.code}: #{target}",
    line: (token.map&.first || 0) + 1,
    fixable: false
  )
rescue URI::InvalidURIError, SocketError, SystemCallError, Timeout::Error, IOError => error
  add_violation(
    message: "External link could not be reached: #{target} (#{error.class})",
    line: (token.map&.first || 0) + 1,
    fixable: false
  )
end

#check_fragment(fragment, token, image, headings) ⇒ Object

Parameters:

  • fragment (Object)
  • token (Object)
  • image (Object)
  • headings (Object)

Returns:

  • (Object)


71
72
73
74
75
76
77
78
79
80
81
# File 'lib/mdlint/linter/rules/link_check.rb', line 71

def check_fragment(fragment, token, image, headings)
  return if image || fragment.nil? || fragment.empty?
  decoded = URI::DEFAULT_PARSER.unescape(fragment).downcase
  return if headings.include?(decoded)

  add_violation(
    message: "Link anchor does not exist: ##{fragment}",
    line: (token.map&.first || 0) + 1,
    fixable: false
  )
end

#check_target(target, token, image, source) ⇒ Object

Parameters:

  • target (Object)
  • token (Object)
  • image (Object)
  • source (Object)

Returns:

  • (Object)


36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/mdlint/linter/rules/link_check.rb', line 36

def check_target(target, token, image, source)
  return if target.nil? || target.empty?

  if target.match?(%r{\Ahttps?://}i)
    check_external_target(target, token) if @options[:check_external_links]
    return
  end
  return if target.match?(%r{\A(?:ftp|mailto):}i)

  path, fragment = target.split("#", 2)
  base = @options[:filename] && File.dirname(@options[:filename])
  return unless base || path.empty?

  if path.empty?
    check_fragment(fragment, token, image, @headings)
    return
  end

  resolved_path = File.expand_path(path, base)
  unless File.file?(resolved_path)
    add_missing_target(path, token)
    return
  end

  return if image || fragment.nil? || fragment.empty?
  return if non_markdown_file?(resolved_path)

  target_headings = begin
    heading_slugs(Parser.parse(File.read(resolved_path), @options))
  rescue StandardError
    []
  end
  check_fragment(fragment, token, image, target_headings)
end

#heading_slugs(tokens) ⇒ Object

Parameters:

  • tokens (Object)

Returns:

  • (Object)


126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/mdlint/linter/rules/link_check.rb', line 126

def heading_slugs(tokens)
  counts = Hash.new(0)
  slugs = []
  tokens.each_with_index do |token, index|
    next unless token.type == :heading_open

    inline = tokens[(index + 1)..]&.find { |candidate| candidate.type == :inline }
    base = slugify(inline&.content.to_s)
    next if base.empty?

    suffix = counts[base]
    counts[base] += 1
    slugs << (suffix.zero? ? base : "#{base}-#{suffix}")
  end
  slugs
end

#non_markdown_file?(path) ⇒ Boolean

Parameters:

  • path (Object)

Returns:

  • (Boolean)


122
123
124
# File 'lib/mdlint/linter/rules/link_check.rb', line 122

def non_markdown_file?(path)
  !%w[.md .markdown .mdown .mkdn].include?(File.extname(path).downcase)
end

#slugify(value) ⇒ String

Parameters:

  • value (Object)

Returns:

  • (String)


143
144
145
146
147
# File 'lib/mdlint/linter/rules/link_check.rb', line 143

def slugify(value)
  value = value.gsub(/[`*_~\[\]()<>]/, "")
  value.downcase.gsub(/[^\p{Alnum}\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}\s-]/, "")
       .strip.gsub(/\s+/, "-")
end