Module: Jekyll::AgentAudit::Rules::Publication

Defined in:
lib/jekyll/agent_audit/rules/publication.rb

Constant Summary collapse

IDS =
%w[
  architecture.output_collision links.target_missing links.fragment_missing architecture.orphan
  identity.canonical_invalid identity.canonical_multiple identity.canonical_target_missing
  structure.title_missing structure.h1_absent structure.heading_empty structure.heading_jump
  structure.id_duplicate structure.link_unnamed
].freeze
SEVERITIES =
{
  "architecture.output_collision" => :error, "links.target_missing" => :error,
  "links.fragment_missing" => :error, "architecture.orphan" => :warning,
  "identity.canonical_invalid" => :error, "identity.canonical_multiple" => :error,
  "identity.canonical_target_missing" => :error, "structure.title_missing" => :warning,
  "structure.h1_absent" => :info, "structure.heading_empty" => :warning,
  "structure.heading_jump" => :info, "structure.id_duplicate" => :error,
  "structure.link_unnamed" => :warning
}.freeze

Class Method Summary collapse

Class Method Details

.architecture_orphan(document, context) ⇒ Object



63
64
65
66
67
68
69
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 63

def architecture_orphan(document, context)
  return [] if root?(document, context) || document[:redirect]
  return [] unless document[:content_confident]
  return [] unless graph(context)
  return [] unless graph(context).incoming(document).empty?
  [finding("architecture.orphan", document, "No incoming links in this build.", {incoming: 0})]
end

.architecture_output_collision(_document, context) ⇒ Object



40
41
42
43
44
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 40

def architecture_output_collision(_document, context)
  Array(context[:inventory]&.collisions).map do |collision|
    finding("architecture.output_collision", collision[:primary], "Multiple owners claim #{collision[:output_path]}.", {output_path: collision[:output_path], owners: collision[:owners].map { |o| o[:identity] }})
  end
end

.asset_target?(entry) ⇒ Boolean

Returns:

  • (Boolean)


156
157
158
159
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 156

def asset_target?(entry)
  path = entry[:output_path].to_s
  !path.empty? && !path.end_with?(".html", "/")
end

.collision(context) ⇒ Object



146
147
148
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 146

def collision(context)
  {status: :evaluated, findings: architecture_output_collision(nil, context)}
end

.evaluate(id, document, context) ⇒ Object



28
29
30
31
32
33
34
35
36
37
38
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 28

def evaluate(id, document, context)
  return {status: :skipped, findings: [], reason: :unknown_rule} unless IDS.include?(id)
  return collision(context) if id == "architecture.output_collision"
  if document && document[:redirect] && %w[architecture.orphan structure.title_missing structure.h1_absent structure.heading_empty structure.heading_jump structure.id_duplicate structure.link_unnamed].include?(id)
    return {status: :not_applicable, findings: [], reason: :redirect}
  end
  if document && !document[:content_confident] && %w[structure.h1_absent structure.heading_empty structure.heading_jump].include?(id)
    return {status: :skipped, findings: [], reason: :content_extraction_uncertain}
  end
  return {status: :evaluated, findings: public_send(id.tr(".", "_"), document, context)}
end

.finding(rule, document, message, observation, line = nil, related_locations = []) ⇒ Object



179
180
181
182
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 179

def finding(rule, document, message, observation, line = nil, related_locations = [])
  {document: document, message: message, observation: observation, line: line,
   related_locations: related_locations, severity: SEVERITIES[rule]}
end

.graph(context) ⇒ Object



161
162
163
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 161

def graph(context)
  context[:graph]
end

.identity_canonical_invalid(document, _context) ⇒ Object



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 71

def identity_canonical_invalid(document, _context)
  Array(document[:canonicals]).map do |canonical|
    href = canonical[:href].to_s
    next if canonical[:href] == ""
    if canonical[:href].nil?
      next finding("identity.canonical_invalid", document, "Canonical declaration has no href.", {href: nil}, canonical[:line])
    end
    next if href.match?(/[^\x00-\x7F]/)
    begin
      URI.parse(href)
      nil
    rescue URI::InvalidURIError
      finding("identity.canonical_invalid", document, "Canonical declaration is not a valid URI reference.", {href: href}, canonical[:line])
    end
  end.compact
end

.identity_canonical_multiple(document, context) ⇒ Object



88
89
90
91
92
93
94
95
96
97
98
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 88

def identity_canonical_multiple(document, context)
  declarations = Array(document[:canonicals]).map do |canonical|
    href = canonical[:href].to_s
    next if href.empty?
    [context[:resolver].resolve(href, document)[:url], canonical] rescue [href, canonical]
  end.compact
  values = declarations.map(&:first).uniq
  return [] unless values.length > 1
  locations = declarations.map { |value, canonical| {line: canonical[:line], href: canonical[:href]} }
  [finding("identity.canonical_multiple", document, "Multiple distinct canonical URLs are declared.", {canonicals: values}, locations.first[:line], locations.drop(1))]
end

.identity_canonical_target_missing(document, context) ⇒ Object



100
101
102
103
104
105
106
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 100

def identity_canonical_target_missing(document, context)
  Array(document[:canonicals]).map do |canonical|
    resolution = context[:resolver].resolve(canonical[:href], document)
    next unless resolution[:status] == :missing
    finding("identity.canonical_target_missing", document, "Canonical points to a missing local target.", {href: canonical[:href], path: resolution[:path]}, canonical[:line])
  end.compact
end


150
151
152
153
154
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 150

def links(document, context)
  graph = context[:graph]
  return graph.edges_for(document) if graph && graph.respond_to?(:edges_for)
  Array(graph&.edges).select { |edge| edge[:source][:identity] == document[:identity] }
end


53
54
55
56
57
58
59
60
61
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 53

def links_fragment_missing(document, context)
  links(document, context).map do |edge|
    resolution, occurrence = edge[:resolution], edge[:occurrence]
    next unless resolution[:status] == :local && resolution[:fragment] && resolution[:target]
    next if asset_target?(resolution[:target])
    next if resolution[:target][:ids]&.include?(resolution[:fragment])
    finding("links.fragment_missing", document, "Local target does not contain the requested fragment.", {href: occurrence[:href], fragment: resolution[:fragment], target: resolution[:target][:identity]}, occurrence[:line])
  end.compact
end


46
47
48
49
50
51
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 46

def links_target_missing(document, context)
  links(document, context).map do |edge|
    next unless edge[:resolution][:status] == :missing
    finding("links.target_missing", document, "Link points to a missing local target.", {href: edge[:occurrence][:href], path: edge[:resolution][:path]}, edge[:occurrence][:line])
  end.compact
end

.root?(document, context) ⇒ Boolean

Returns:

  • (Boolean)


165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 165

def root?(document, context)
  config = context[:configuration].respond_to?(:to_h) ? context[:configuration].to_h : context[:configuration]
  roots = config.dig(:graph, :roots) || config.dig("graph", "roots") || ["/"]
  base = context.fetch(:site_config, {}).fetch("baseurl", "").to_s.sub(%r{/\z}, "")
  roots.map(&:to_s).any? do |root|
    route = root == "/" && !base.empty? ? "#{base}/" : root
    document[:route].to_s == route || (root == "/" && document[:route].to_s == "/index.html") || begin
      URI.parse(document[:url].to_s).path == route
    rescue URI::InvalidURIError
      false
    end
  end
end

.structure_h1_absent(document, _context) ⇒ Object



112
113
114
115
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 112

def structure_h1_absent(document, _context)
  return [] unless document[:content_confident]
  document[:headings].none? { |h| h[:level] == 1 && h[:name_supported] != false && !h[:hidden] && !h[:name].to_s.empty? } ? [finding("structure.h1_absent", document, "No level-one heading detected within the extracted content.", {})] : []
end

.structure_heading_empty(document, _context) ⇒ Object



117
118
119
120
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 117

def structure_heading_empty(document, _context)
  return [] unless document[:content_confident]
  document[:headings].map { |heading| finding("structure.heading_empty", document, "Heading has no supported accessible name.", {level: heading[:level]}, heading[:line]) if heading[:name_supported] != false && !heading[:hidden] && heading[:name].to_s.empty? }.compact
end

.structure_heading_jump(document, _context) ⇒ Object



122
123
124
125
126
127
128
129
130
131
132
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 122

def structure_heading_jump(document, _context)
  return [] unless document[:content_confident]
  previous = nil
  document[:headings].map do |heading|
    result = if previous && heading[:level] > previous[:level] + 1 && !heading[:hidden] && heading[:name_supported] && previous[:name_supported]
      finding("structure.heading_jump", document, "Heading level jumps from h#{previous[:level]} to h#{heading[:level]}.", {from: previous[:level], to: heading[:level]}, heading[:line], [{line: previous[:line]}])
    end
    previous = heading unless heading[:hidden]
    result
  end.compact
end

.structure_id_duplicate(document, _context) ⇒ Object



134
135
136
137
138
139
140
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 134

def structure_id_duplicate(document, _context)
  locations = document[:duplicate_id_locations] || {}
  Array(document[:duplicate_ids]).map do |id|
    lines = Array(locations[id])
    finding("structure.id_duplicate", document, "ID is declared more than once.", {id: id}, lines.first, lines.drop(1).map { |line| {line: line, id: id} })
  end
end


142
143
144
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 142

def structure_link_unnamed(document, _context)
  document[:links].map { |link| finding("structure.link_unnamed", document, "Navigation link has no supported accessible name.", {href: link[:href]}, link[:line]) if link[:kind] == :navigation && !link[:hidden] && link[:name_supported] && link[:name].to_s.empty? }.compact
end

.structure_title_missing(document, _context) ⇒ Object



108
109
110
# File 'lib/jekyll/agent_audit/rules/publication.rb', line 108

def structure_title_missing(document, _context)
  document[:title].to_s.strip.empty? ? [finding("structure.title_missing", document, "Rendered HTML has no nonempty title.", {})] : []
end