seo_geo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +244 -0
  3. data/CHANGELOG.md +40 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +428 -0
  6. data/app/controllers/seo_geo/index_now_controller.rb +13 -0
  7. data/app/controllers/seo_geo/llms_controller.rb +29 -0
  8. data/app/controllers/seo_geo/og_images_controller.rb +67 -0
  9. data/app/controllers/seo_geo/robots_controller.rb +14 -0
  10. data/app/controllers/seo_geo/sitemaps_controller.rb +16 -0
  11. data/app/jobs/seo_geo/index_now_job.rb +21 -0
  12. data/app/views/seo_geo/fallback/_body.html.erb +21 -0
  13. data/app/views/seo_geo/fallback/_faq.html.erb +12 -0
  14. data/config/routes.rb +17 -0
  15. data/lib/generators/seo_geo/install/install_generator.rb +138 -0
  16. data/lib/generators/seo_geo/install/templates/home.yml.tt +14 -0
  17. data/lib/generators/seo_geo/install/templates/initializer.rb.tt +52 -0
  18. data/lib/seo_geo/audit/checks.rb +107 -0
  19. data/lib/seo_geo/audit/html_document.rb +132 -0
  20. data/lib/seo_geo/audit/report.rb +85 -0
  21. data/lib/seo_geo/audit/runner.rb +327 -0
  22. data/lib/seo_geo/audit/static_scan.rb +60 -0
  23. data/lib/seo_geo/audit.rb +14 -0
  24. data/lib/seo_geo/bot_verifier.rb +278 -0
  25. data/lib/seo_geo/configuration.rb +832 -0
  26. data/lib/seo_geo/controller.rb +72 -0
  27. data/lib/seo_geo/crawler_fallback.rb +45 -0
  28. data/lib/seo_geo/crawlers.rb +79 -0
  29. data/lib/seo_geo/engine.rb +123 -0
  30. data/lib/seo_geo/index_now.rb +187 -0
  31. data/lib/seo_geo/json_ld.rb +259 -0
  32. data/lib/seo_geo/llms_txt.rb +183 -0
  33. data/lib/seo_geo/markdown/document.rb +163 -0
  34. data/lib/seo_geo/markdown/negotiation.rb +49 -0
  35. data/lib/seo_geo/markdown/path_suffix.rb +72 -0
  36. data/lib/seo_geo/markdownable.rb +141 -0
  37. data/lib/seo_geo/meta.rb +382 -0
  38. data/lib/seo_geo/model.rb +95 -0
  39. data/lib/seo_geo/og_image/card.html.erb +22 -0
  40. data/lib/seo_geo/og_image/ferrum_backend.rb +72 -0
  41. data/lib/seo_geo/og_image/renderer.rb +163 -0
  42. data/lib/seo_geo/page.rb +201 -0
  43. data/lib/seo_geo/path_match.rb +36 -0
  44. data/lib/seo_geo/publication.rb +68 -0
  45. data/lib/seo_geo/robots/evaluator.rb +85 -0
  46. data/lib/seo_geo/robots.rb +105 -0
  47. data/lib/seo_geo/robots_directives.rb +170 -0
  48. data/lib/seo_geo/robots_path.rb +125 -0
  49. data/lib/seo_geo/rspec.rb +197 -0
  50. data/lib/seo_geo/safe_regexp.rb +50 -0
  51. data/lib/seo_geo/sitemap/registry.rb +239 -0
  52. data/lib/seo_geo/sitemap/xml.rb +138 -0
  53. data/lib/seo_geo/testing/environment.rb +31 -0
  54. data/lib/seo_geo/testing.rb +111 -0
  55. data/lib/seo_geo/undeclared_host.rb +40 -0
  56. data/lib/seo_geo/version.rb +5 -0
  57. data/lib/seo_geo.rb +80 -0
  58. data/lib/tasks/seo_geo.rake +23 -0
  59. data/llms.txt +27 -0
  60. metadata +224 -0
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module SeoGeo
6
+ module Generators
7
+ # +bin/rails g seo_geo:install [CANONICAL_ORIGIN]+: initializer, sample page copy, engine
8
+ # mount, layout instructions, and a warning for static files that would shadow the engine routes.
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ SHADOWED_FILES = %w[robots.txt sitemap.xml llms.txt llms-full.txt].freeze
13
+ MOUNT = 'mount SeoGeo::Engine, at: "/"'
14
+ # A catch-all route (+get "*path"+, +match "/*slug"+, ...).
15
+ CATCH_ALL = /\A[ \t]*(?:get|match|any|post)\b[ \t(]*["']\/?\*/
16
+ DRAW = /\.routes\.draw\s+do\b/
17
+ # Lines that open a block closed by +end+: a trailing +do+ (optionally +do |args|+) or a leading keyword.
18
+ OPENS_BLOCK = /(?:\bdo(?:\s*\|[^|]*\|)?\s*(?:#.*)?\z)|\A\s*(?:if|unless|case|begin|while|until|def|class|module)\b/
19
+ CLOSES_BLOCK = /\A\s*end\b/
20
+
21
+ # @param lines [Array<String>] config/routes.rb
22
+ # @return [Integer, nil] line index for the mount: directly inside +routes.draw+ (never inside a scope or
23
+ # namespace), before its first top-level catch-all, else before the +end+ closing the draw block
24
+ def self.mount_index(lines)
25
+ start = lines.index { |line| line.match?(DRAW) }
26
+ return nil if start.nil?
27
+
28
+ depth = 1
29
+ lines.each_with_index.drop(start + 1).each do |line, index|
30
+ return index if depth == 1 && line.match?(CATCH_ALL)
31
+
32
+ if line.match?(CLOSES_BLOCK)
33
+ depth -= 1
34
+ return index if depth.zero?
35
+ elsif line.match?(OPENS_BLOCK)
36
+ depth += 1
37
+ end
38
+ end
39
+ nil
40
+ end
41
+
42
+ argument :canonical_origin, type: :string, default: "https://www.example.com",
43
+ banner: "https://www.example.com"
44
+
45
+ class_option :site_name, type: :string, default: nil, desc: "site.name (defaults to the application name)"
46
+
47
+ def validate_origin
48
+ return if canonical_origin.match?(%r{\Ahttps?://[^/\s]+/?\z})
49
+
50
+ raise Thor::Error, "CANONICAL_ORIGIN must look like https://www.example.com (got #{canonical_origin.inspect})"
51
+ end
52
+
53
+ def create_initializer
54
+ template "initializer.rb.tt", "config/initializers/seo_geo.rb"
55
+ end
56
+
57
+ def create_home_page
58
+ template "home.yml.tt", "config/seo_geo/pages/home.yml"
59
+ end
60
+
61
+ # The mount goes last (before a top-level catch-all +get "*path"+ if there is one), not at the top where Rails'
62
+ # +route+ puts it: routes match in order, so the app's own routes (e.g. +/og/:username.png+) stay first. One
63
+ # insertion point, directly inside +routes.draw+; an existing mount is left alone.
64
+ def mount_engine
65
+ routes = File.join(destination_root, "config/routes.rb")
66
+ return route(MOUNT) unless File.exist?(routes)
67
+
68
+ content = File.read(routes)
69
+ if content.include?("SeoGeo::Engine")
70
+ say_status :identical, "config/routes.rb (SeoGeo::Engine already mounted)", :blue
71
+ return
72
+ end
73
+
74
+ lines = content.lines
75
+ index = self.class.mount_index(lines.map(&:chomp))
76
+ return route(MOUNT) if index.nil?
77
+
78
+ catch_all = lines[index].match?(CATCH_ALL)
79
+ indent = lines[index][/\A[ \t]*/]
80
+ indent = "#{indent} " unless catch_all
81
+ lines.insert(index, "#{indent}#{MOUNT}\n")
82
+ say_status :insert, "config/routes.rb", :green
83
+ File.write(routes, lines.join) unless options[:pretend]
84
+ say_status :note, "mounted SeoGeo::Engine after your routes#{catch_all ? " (before the catch-all)" : ""}: " \
85
+ "app routes win where paths overlap, and /robots.txt, /sitemap.xml, /llms.txt still reach " \
86
+ "the engine. Keep any catch-all route below the mount.", :blue
87
+ end
88
+
89
+ def warn_about_static_files
90
+ SHADOWED_FILES.each do |name|
91
+ next unless File.exist?(File.join(destination_root, "public", name))
92
+
93
+ say_status :warning, "public/#{name} is served before the seo_geo route — delete it " \
94
+ "(rake seo_geo:audit reports it as an error)", :yellow
95
+ end
96
+ end
97
+
98
+ def show_layout_instructions
99
+ say <<~TEXT
100
+
101
+ seo_geo is installed. In app/views/layouts/application.html.erb:
102
+
103
+ <html lang="<%= seo_geo_html_lang %>">
104
+ <head>
105
+ <%= seo_geo_head %> <!-- replaces your <title>, meta description, canonical, JSON-LD -->
106
+ </head>
107
+
108
+ Remove any other <title> tag (seo_geo_head emits exactly one).
109
+
110
+ Client-rendered pages (Inertia/React/Vue): render the copy for crawlers that do not run JavaScript
111
+ inside <body>, and call window.SeoGeo.mounted() once the app has mounted:
112
+
113
+ <%= seo_geo_fallback_body(:home) %>
114
+
115
+ In controllers: seo title: "...", description: "..." or seo_geo_page(:home).
116
+ Then run: bin/rails seo_geo:audit
117
+ TEXT
118
+ end
119
+
120
+ private
121
+
122
+ def origin
123
+ canonical_origin.chomp("/")
124
+ end
125
+
126
+ def site_name
127
+ options[:site_name] || default_site_name
128
+ end
129
+
130
+ def default_site_name
131
+ name = defined?(::Rails.application) && ::Rails.application ? ::Rails.application.class.module_parent_name : nil
132
+ name ? name.underscore.humanize : "My site"
133
+ rescue StandardError
134
+ "My site"
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,14 @@
1
+ # Page copy for SeoGeo.page(:home): title/description feed the head, faq feeds both the visible FAQ
2
+ # (seo_geo_faq / seo_geo_fallback_body) and the FAQPage JSON-LD, so they always match.
3
+ # Keyed by locale; a flat file (no locale keys) also works.
4
+ en:
5
+ title: <%= site_name.to_json %>
6
+ description: Replace this with a 70–160 character summary of the home page for search results.
7
+ heading: <%= site_name.to_json %>
8
+ intro: One or two sentences that say what the site does and for whom.
9
+ features:
10
+ - title: First feature
11
+ description: What it does for the reader.
12
+ faq:
13
+ - question: <%= "What is #{site_name}?".to_json %>
14
+ answer: A short, factual answer that is also shown on the page.
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # seo_geo: SEO and GEO (AI search visibility). Every option is documented in docs/DESIGN.md of the gem.
4
+ SeoGeo.configure do |seo|
5
+ seo.site.name = <%= site_name.inspect %>
6
+ seo.site.description = "One sentence that describes the site."
7
+ # seo.site.logo_url = "<%= origin %>/logo.png"
8
+ # seo.site.default_locale = :en # <html lang>, inLanguage and og:locale (:ko => ko_KR) use this
9
+ # seo.site.same_as = ["https://github.com/your-org"]
10
+
11
+ # One canonical origin; alias hosts are folded onto it, undeclared hosts get Disallow: /.
12
+ seo.canonical_origin = <%= origin.inspect %>
13
+ # seo.canonical_aliases = ["example.com"] # aliases inherit the canonical host's disallow/allow
14
+ # seo.host "www.example.com", disallow: ["/admin", "/oauth/"]
15
+ # seo.share_prefixes = ["/s/"] # open for link-preview bots
16
+ # seo.noindex_paths = ["/waitlist", %r{\A/drafts/}] # robots meta, sitemap.xml and llms.txt agree
17
+
18
+ # Crawlers by purpose (:allow or :deny): training, search, user-triggered fetches.
19
+ # seo.crawlers.training = :deny
20
+ # seo.crawlers.override "Bytespider", :deny
21
+ # seo.crawlers.override "Mediapartners-Google", :full_access # Allow: / — ignores host disallows (opt-in)
22
+
23
+ # Search-console verification tokens.
24
+ # seo.verification.google = "..."
25
+ # seo.verification.bing = "..."
26
+ # seo.verification.naver = "..."
27
+
28
+ # seo.locales = %i[en ko] # only then does I18n.locale pick the page language (else default_locale)
29
+ # seo.title_template = "%{title} | %{site}"
30
+ # seo.default_noindex = false
31
+
32
+ # GET /og/:key.png is routed only once a backend (or template) is set, and only for known keys
33
+ # (page keys, og_image.keys, og_image.key_resolver); other /og/... URLs reach your own routes.
34
+ # seo.og_image.backend = ->(key, page, params) { MyOgRenderer.png(key, page) }
35
+ # seo.og_image.fallback_url = "<%= origin %>/og-default.png"
36
+ # seo.og_image.fallback_width = 1200 # og:image:width/height for the fallback only when both are set
37
+ # seo.og_image.fallback_height = 630
38
+ end
39
+
40
+ # Sitemap sources. Never pass Time.now as lastmod; use real date columns.
41
+ # Collections: pass a lambda so no model is loaded at boot and dev reloads never keep a stale class;
42
+ # it is called per sitemap request. Records are read with find_each (select must include :id, order is
43
+ # ignored); add batch: false to keep the scope's order.
44
+ SeoGeo.sitemap.draw do
45
+ add "/", priority: 1.0
46
+ # add -> { Post.published }, path: ->(post) { "/posts/#{post.slug}" }, lastmod: :updated_at
47
+ # Under default_noindex: add ..., index: true, match: %r{\A/posts/[^/]+\z} # head of each post indexable
48
+ # exclude %r{\A/s/}
49
+ end
50
+
51
+ # llms.txt: list the pages agents should read first (page copy comes from config/seo_geo/pages/*.yml).
52
+ SeoGeo.config.llms_txt.page :home, path: "/"
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "nokogiri"
5
+
6
+ module SeoGeo
7
+ module Audit
8
+ # Pure checks over response bodies. Shared by {Runner}, {SeoGeo::Testing::Assertions} and the
9
+ # RSpec matchers, so a test and the audit can never disagree. Each returns problem messages
10
+ # (empty when fine).
11
+ module Checks
12
+ TITLE_MAX = 60
13
+ DESCRIPTION_RANGE = (70..160)
14
+ SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9"
15
+
16
+ module_function
17
+
18
+ # @param doc [HtmlDocument]
19
+ # @return [Array<String>] problems with +<title>+ count (errors)
20
+ def title_count(doc)
21
+ count = doc.titles.size
22
+ return [] if count == 1
23
+
24
+ [count.zero? ? "no <title>" : "#{count} <title> elements (exactly one expected)"]
25
+ end
26
+
27
+ # @param doc [HtmlDocument]
28
+ # @return [Array<String>] FAQPage questions or answers missing from the visible text
29
+ def faq_visible(doc)
30
+ text = doc.visible_text
31
+ doc.faq_items.flat_map do |item|
32
+ problems = []
33
+ problems << "FAQ question not in visible text: #{item[:question].inspect}" unless text.include?(item[:question])
34
+ problems << "FAQ answer not in visible text: #{item[:answer].inspect}" unless text.include?(item[:answer])
35
+ problems
36
+ end
37
+ end
38
+
39
+ # @param doc [HtmlDocument]
40
+ # @return [Array<String>] unparsable blocks and nodes missing properties {JsonLd::REQUIRED} lists
41
+ def json_ld(doc)
42
+ nodes, problems = doc.json_ld
43
+ nodes.each do |node|
44
+ doc.types(node).each do |type|
45
+ missing = JsonLd::REQUIRED.fetch(type, []).select { |key| blank?(node[key]) }
46
+ problems << [type, node["@id"], "is missing #{missing.join(", ")}"].compact.join(" ") unless missing.empty?
47
+ end
48
+ end
49
+ problems
50
+ end
51
+
52
+ # @param xml [String] sitemap.xml body
53
+ # @param origin [String, nil] every +<loc>+ must start with it (e.g. "https://www.example.com/")
54
+ # @return [Array(Array<String>, Array<String>, Symbol)] problems, +<loc>+ values, :urlset/:sitemapindex
55
+ def sitemap(xml, origin: nil)
56
+ doc = Nokogiri::XML(xml.to_s) { |options| options.strict.nonet }
57
+ root = doc.root
58
+ kind = root&.name&.to_sym
59
+ unless %i[urlset sitemapindex].include?(kind) && root.namespace&.href == SITEMAP_NS
60
+ return [["root element must be <urlset> or <sitemapindex> in #{SITEMAP_NS}"], [], kind]
61
+ end
62
+
63
+ locs = doc.xpath("//s:loc", "s" => SITEMAP_NS).map { |node| node.text.strip }
64
+ problems = []
65
+ problems << "sitemap lists no URL" if locs.empty?
66
+ problems << "#{locs.size} URLs in one file (limit 50,000)" if locs.size > 50_000
67
+ locs.each do |loc|
68
+ problems << "<loc> #{loc} is not absolute" unless loc.match?(%r{\Ahttps?://}i)
69
+ problems << "<loc> #{loc} is not under #{origin}" if origin && !same_origin?(loc, origin)
70
+ end
71
+ [problems, locs, kind]
72
+ rescue Nokogiri::XML::SyntaxError => e
73
+ [["sitemap is not well-formed XML: #{e.message.strip}"], [], nil]
74
+ end
75
+
76
+ # @param canonical [String, nil] canonical href
77
+ # @param expected_origin [String] e.g. "https://www.example.com"
78
+ # @return [Array<String>]
79
+ def canonical_origin(canonical, expected_origin)
80
+ return ["no <link rel=\"canonical\">"] if canonical.nil? || canonical.strip.empty?
81
+
82
+ uri = URI.parse(canonical)
83
+ actual = uri.host && "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless [80, 443].include?(uri.port)}"
84
+ actual&.casecmp?(expected_origin.chomp("/")) ? [] : ["canonical #{canonical} is not on #{expected_origin}"]
85
+ rescue URI::InvalidURIError
86
+ ["canonical #{canonical.inspect} is not a valid URL"]
87
+ end
88
+
89
+ # @param url [String]
90
+ # @param origin [String]
91
+ # @return [Boolean] same scheme, host and effective port
92
+ def same_origin?(url, origin)
93
+ a = URI.parse(url)
94
+ b = URI.parse(origin)
95
+ a.host && b.host && a.scheme&.downcase == b.scheme&.downcase && a.host.casecmp?(b.host) && a.port == b.port
96
+ rescue URI::InvalidURIError
97
+ false
98
+ end
99
+
100
+ # @param value [Object]
101
+ # @return [Boolean]
102
+ def blank?(value)
103
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) || (value.is_a?(String) && value.strip.empty?)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "nokogiri"
5
+
6
+ module SeoGeo
7
+ module Audit
8
+ # Read-only view of one HTML response: the values crawlers take from it.
9
+ class HtmlDocument
10
+ NON_VISIBLE = "script, style, template, head"
11
+
12
+ # @param html [String]
13
+ def initialize(html)
14
+ @doc = Nokogiri::HTML(html.to_s)
15
+ end
16
+
17
+ # @return [Array<String>] text of every +<title>+ in the head (SVG titles ignored)
18
+ def titles
19
+ @doc.xpath("//head/title").map { |node| squish(node.text) }
20
+ end
21
+
22
+ # @return [String, nil] the first title
23
+ def title
24
+ titles.first
25
+ end
26
+
27
+ # @return [String, nil]
28
+ def description
29
+ meta_content("description")
30
+ end
31
+
32
+ # @return [String, nil] href of +<link rel="canonical">+
33
+ def canonical
34
+ @doc.css("link[rel~=canonical]").first&.[]("href")
35
+ end
36
+
37
+ # @return [String, nil] first robots meta content (lower case); {#noindex?} reads all of them
38
+ def robots
39
+ meta_content("robots")&.downcase
40
+ end
41
+
42
+ # @return [RobotsDirectives] every robots and crawler-specific meta ({RobotsDirectives.from_html}, the
43
+ # parser the Markdown response uses)
44
+ def robots_directives
45
+ @robots_directives ||= RobotsDirectives.from_html(@doc)
46
+ end
47
+
48
+ # @param header [String, Array<String>, nil] the response's +X-Robots-Tag+
49
+ # @return [Boolean] true when any meta or the header tells any crawler noindex (+none+ included)
50
+ def noindex?(header = nil)
51
+ robots_directives.merge(RobotsDirectives.parse_header(header)).noindex_for_any?
52
+ end
53
+
54
+ # @return [String, nil] +<html lang>+
55
+ def html_lang
56
+ value = @doc.at_xpath("/html")&.[]("lang")
57
+ value.nil? || value.strip.empty? ? nil : value
58
+ end
59
+
60
+ # @return [String, nil] href of +<link rel="alternate" type="text/markdown">+
61
+ def markdown_alternate
62
+ @doc.css("link[rel~=alternate]").find { |node| node["type"].to_s.casecmp?("text/markdown") }&.[]("href")
63
+ end
64
+
65
+ # @param name [String] meta name, e.g. "google-site-verification"
66
+ # @return [String, nil]
67
+ def meta_content(name)
68
+ node = @doc.css("meta[name]").find { |item| item["name"].casecmp?(name) }
69
+ node && node["content"]
70
+ end
71
+
72
+ # @return [Array<String>] raw bodies of +<script type="application/ld+json">+
73
+ def json_ld_sources
74
+ @doc.css("script").select { |node| node["type"].to_s.strip.casecmp?("application/ld+json") }.map(&:text)
75
+ end
76
+
77
+ # @return [Array(Array<Hash>, Array<String>)] parsed nodes (@graph flattened) and parse error messages
78
+ def json_ld
79
+ nodes = []
80
+ errors = []
81
+ json_ld_sources.each_with_index do |source, index|
82
+ nodes.concat(flatten(JSON.parse(source)))
83
+ rescue JSON::ParserError => e
84
+ errors << "JSON-LD block #{index + 1} does not parse: #{e.message.lines.first.to_s.strip[0, 120]}"
85
+ end
86
+ [nodes, errors]
87
+ end
88
+
89
+ # @return [Array<Hash{Symbol => String}>] +{question:, answer:}+ from every FAQPage node
90
+ def faq_items
91
+ json_ld.first.select { |node| types(node).include?("FAQPage") }.flat_map do |node|
92
+ Array(node["mainEntity"]).filter_map do |question|
93
+ next unless question.is_a?(Hash)
94
+
95
+ answer = question["acceptedAnswer"]
96
+ answer = answer.first if answer.is_a?(Array)
97
+ { question: squish(question["name"].to_s), answer: squish(answer.is_a?(Hash) ? answer["text"].to_s : "") }
98
+ end
99
+ end
100
+ end
101
+
102
+ # @return [String] text a crawler that does not run JavaScript reads (scripts, styles, head removed)
103
+ def visible_text
104
+ @visible_text ||= begin
105
+ copy = @doc.dup
106
+ copy.css(NON_VISIBLE).each(&:remove)
107
+ squish(copy.text)
108
+ end
109
+ end
110
+
111
+ # @param node [Hash]
112
+ # @return [Array<String>]
113
+ def types(node)
114
+ Array(node["@type"]).map(&:to_s)
115
+ end
116
+
117
+ private
118
+
119
+ def flatten(value)
120
+ case value
121
+ when Array then value.flat_map { |item| flatten(item) }
122
+ when Hash then value.key?("@graph") ? flatten(value["@graph"]) : [value]
123
+ else []
124
+ end
125
+ end
126
+
127
+ def squish(text)
128
+ text.to_s.gsub(/[[:space:]]+/, " ").strip
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SeoGeo
6
+ module Audit
7
+ SEVERITIES = %i[error warning].freeze
8
+
9
+ # One problem.
10
+ #
11
+ # @!attribute severity [Symbol] :error (exit non-zero) or :warning
12
+ # @!attribute check [String] check id, e.g. "title.count"
13
+ # @!attribute host [String, nil]
14
+ # @!attribute url [String, nil] URL or file:line
15
+ # @!attribute message [String]
16
+ Finding = Data.define(:severity, :check, :host, :url, :message) do
17
+ # @return [Hash{String => String}]
18
+ def to_h
19
+ { "severity" => severity.to_s, "check" => check, "host" => host, "url" => url, "message" => message }
20
+ end
21
+ end
22
+
23
+ # Collected findings plus counters. +to_s+ for people, +to_json+ for CI.
24
+ class Report
25
+ # @return [Array<Finding>]
26
+ attr_reader :findings
27
+ # @return [Hash{String => Integer}] e.g. "pages" => 12
28
+ attr_reader :stats
29
+
30
+ def initialize
31
+ @findings = []
32
+ @stats = Hash.new(0)
33
+ end
34
+
35
+ # @param severity [Symbol] :error or :warning
36
+ # @param check [String]
37
+ # @param message [String]
38
+ # @param host [String, nil]
39
+ # @param url [String, nil]
40
+ # @return [Finding]
41
+ def add(severity, check, message, host: nil, url: nil)
42
+ raise ArgumentError, "unknown severity #{severity.inspect}" unless SEVERITIES.include?(severity)
43
+
44
+ Finding.new(severity: severity, check: check, host: host, url: url, message: message).tap { |item| @findings << item }
45
+ end
46
+
47
+ # @return [Array<Finding>]
48
+ def errors
49
+ findings.select { |item| item.severity == :error }
50
+ end
51
+
52
+ # @return [Array<Finding>]
53
+ def warnings
54
+ findings.select { |item| item.severity == :warning }
55
+ end
56
+
57
+ # @return [Boolean]
58
+ def errors?
59
+ !errors.empty?
60
+ end
61
+
62
+ # @return [Hash]
63
+ def to_h
64
+ { "errors" => errors.size, "warnings" => warnings.size, "stats" => stats.dup,
65
+ "findings" => findings.map(&:to_h) }
66
+ end
67
+
68
+ # @return [String]
69
+ def to_json(*args)
70
+ JSON.pretty_generate(to_h, *args)
71
+ end
72
+
73
+ # @return [String] one line per finding, then a summary line
74
+ def to_s
75
+ lines = findings.sort_by { |item| [SEVERITIES.index(item.severity), item.host.to_s, item.check] }.map do |item|
76
+ where = [item.host, item.url].compact.join(" ")
77
+ "#{item.severity.to_s.upcase.ljust(7)} #{item.check.ljust(22)} #{where}#{where.empty? ? "" : " — "}#{item.message}"
78
+ end
79
+ counted = stats.map { |name, count| "#{count} #{name}" }.join(", ")
80
+ lines << "seo_geo audit: #{errors.size} error(s), #{warnings.size} warning(s)#{counted.empty? ? "" : " (#{counted})"}"
81
+ "#{lines.join("\n")}\n"
82
+ end
83
+ end
84
+ end
85
+ end