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,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ module Sitemap
5
+ CHANGEFREQS = %w[always hourly daily weekly monthly yearly never].freeze
6
+
7
+ # One resolved +<url>+ entry. +loc+ is absolute; +lastmod+ is nil unless a real date was supplied
8
+ # (seo_geo never invents +Time.now+).
9
+ Url = Data.define(:loc, :lastmod, :priority, :changefreq)
10
+
11
+ # One registered source: a static path or a record collection.
12
+ #
13
+ # @!attribute source [String, #call, #find_each, #each] path ("/about"), a lambda returning a scope (evaluated per
14
+ # request: +-> { Post.published }+ — the recommended form, no model is loaded at boot and dev reloads never
15
+ # leave a stale class behind) or a scope / enumerable of records
16
+ # @!attribute batch [Boolean, nil] nil/true => +find_each+ when the scope has it (batches by primary key:
17
+ # +select+ must include +id+, +order+ is ignored); false => +each+, so the scope's own +order+ is kept
18
+ # @!attribute path [#call, Symbol, nil] record -> path; required for collections
19
+ # @!attribute lastmod [#call, Symbol, Time, Date, nil] date, record method name, or record -> date
20
+ # @!attribute priority [Float, nil] 0.0..1.0
21
+ # @!attribute changefreq [String, nil] one of {CHANGEFREQS}
22
+ # @!attribute hosts [Array<String>, nil] hosts whose sitemap lists the entry; nil => canonical host only
23
+ # @!attribute noindex [Boolean] declared noindex (static paths only): never listed in sitemap.xml or
24
+ # llms.txt, and the head helper renders robots noindex for it
25
+ # @!attribute index [Boolean] declared indexable under {Configuration#default_noindex}; without it a
26
+ # +default_noindex+ site lists nothing (see {SeoGeo::Publication})
27
+ # @!attribute match [Regexp, #call, nil] collections only: which request paths belong to the collection
28
+ # (+->(path) { ... }+ or a Regexp over the path). With +index: true+ it is what makes the head of a record
29
+ # page indexable under +default_noindex+ ({SeoGeo::Publication.declared_index?}); without it the head
30
+ # needs +seo index: true+ and +rake seo_geo:audit+ warns (+sitemap.collection_index+)
31
+ # @!attribute markdown [Boolean, nil] true when the page also serves Markdown at +/path.md+ (its action includes
32
+ # {SeoGeo::Markdownable}). The llms.txt sitemap fallback links +.md+ only for true; nil/false => HTML URL.
33
+ # false also makes +rake seo_geo:audit+ skip the page's Markdown checks.
34
+ Entry = Data.define(:source, :path, :lastmod, :priority, :changefreq, :hosts, :noindex, :index, :markdown, :match,
35
+ :batch) do
36
+ # @param config [Configuration]
37
+ # @return [Boolean]
38
+ def markdown?(config)
39
+ config.markdown.suffix_urls? && markdown == true
40
+ end
41
+
42
+ # @return [Boolean]
43
+ def static?
44
+ source.is_a?(String)
45
+ end
46
+
47
+ # @param host [String] request host
48
+ # @param config [Configuration]
49
+ # @return [Boolean]
50
+ def for_host?(host, config)
51
+ wanted = hosts ? hosts.map { |name| name.to_s.downcase } : [config.canonical_host]
52
+ wanted.include?(host.to_s.downcase.sub(/:\d+\z/, ""))
53
+ end
54
+
55
+ # @param path [String] request path
56
+ # @return [Boolean] true when +path+ is this entry's static path or matches its collection +match:+
57
+ # (a timed-out Regexp does not match: membership grants indexing, so failing safe means not granting it)
58
+ def covers?(path)
59
+ return RobotsPath.path(source) == RobotsPath.path(path) if static?
60
+ return false if match.nil?
61
+ return SafeRegexp.match?(match, RobotsPath.path(path), on_timeout: false) if match.is_a?(Regexp)
62
+
63
+ match.call(RobotsPath.path(path)) ? true : false
64
+ end
65
+ end
66
+
67
+ # Boot-time registry of sitemap sources. Collections are stored unevaluated and enumerated per request.
68
+ #
69
+ # @example config/initializers/seo_geo.rb
70
+ # SeoGeo.sitemap.draw do
71
+ # add "/", priority: 1.0, changefreq: "daily"
72
+ # add -> { Presentation.published }, path: ->(p) { "/p/#{p.slug}" }, lastmod: :updated_at
73
+ # exclude %r{\A/s/}
74
+ # end
75
+ class Registry
76
+ # @return [Array<Entry>]
77
+ attr_reader :entries
78
+ # @return [Array<String, Regexp>]
79
+ attr_reader :exclusions
80
+
81
+ def initialize
82
+ @entries = []
83
+ @exclusions = []
84
+ end
85
+
86
+ # Evaluates a block with this registry as +self+.
87
+ # @return [self]
88
+ def draw(&block)
89
+ instance_eval(&block) if block
90
+ self
91
+ end
92
+
93
+ # Registers a static path or a record collection.
94
+ # @param path_or_scope [String, #find_each, #each]
95
+ # @param path [#call, Symbol, nil] record -> path (collections only)
96
+ # @param lastmod [#call, Symbol, Time, Date, nil]
97
+ # @param priority [Numeric, nil] 0.0..1.0
98
+ # @param changefreq [String, Symbol, nil] one of {CHANGEFREQS}
99
+ # @param hosts [Array<String>, String, nil] nil => canonical host only
100
+ # @param noindex [Boolean] declare a static path noindex: it stays out of sitemap.xml and llms.txt and
101
+ # the head helper renders robots noindex for it (see also {Configuration#noindex_paths})
102
+ # @param index [Boolean] declare the entry indexable when {Configuration#default_noindex} is on
103
+ # (the registry counterpart of a controller's +seo index: true+)
104
+ # @param markdown [Boolean, nil] true => the llms.txt fallback links +/path.md+ (action includes
105
+ # {SeoGeo::Markdownable}); false => also skip the audit's Markdown checks; nil => HTML URL
106
+ # @param match [Regexp, #call, nil] collections only: Regexp or +->(path) { bool }+ telling which request
107
+ # paths are records of this collection, so +index: true+ reaches their head too
108
+ # @param batch [Boolean, nil] collections only: false iterates with +each+ (keeps +order+, loads all records);
109
+ # default uses +find_each+ when available (+select+ must include +id+, +order+ is ignored)
110
+ # @raise [ConfigurationError] on invalid arguments
111
+ # @return [Entry]
112
+ def add(path_or_scope, path: nil, lastmod: nil, priority: nil, changefreq: nil, hosts: nil, noindex: false, index: false,
113
+ markdown: nil, match: nil, batch: nil)
114
+ raise ConfigurationError, "noindex: is only valid for static paths" if noindex && !path_or_scope.is_a?(String)
115
+ raise ConfigurationError, "match: is only valid for collections" if match && path_or_scope.is_a?(String)
116
+ raise ConfigurationError, "batch: is only valid for collections" if !batch.nil? && path_or_scope.is_a?(String)
117
+ if match && !(match.is_a?(Regexp) || match.respond_to?(:call))
118
+ raise ConfigurationError, "match: must be a Regexp or respond to #call"
119
+ end
120
+ raise ConfigurationError, "noindex: and index: are mutually exclusive" if noindex && index
121
+
122
+ if path_or_scope.is_a?(String)
123
+ raise ConfigurationError, "sitemap path #{path_or_scope.inspect} must start with /" unless path_or_scope.start_with?("/")
124
+ raise ConfigurationError, "path: is only valid for collections" if path
125
+ else
126
+ unless path_or_scope.respond_to?(:call) || path_or_scope.respond_to?(:find_each) || path_or_scope.respond_to?(:each)
127
+ raise ConfigurationError, "sitemap source must be a path String, a lambda returning a scope, or respond to #find_each/#each"
128
+ end
129
+ raise ConfigurationError, "collections need path: (a callable or a record method name)" unless path
130
+ end
131
+ if priority && !(priority.is_a?(Numeric) && priority.between?(0, 1))
132
+ raise ConfigurationError, "sitemap priority must be between 0.0 and 1.0 (got #{priority.inspect})"
133
+ end
134
+ if changefreq && !CHANGEFREQS.include?(changefreq.to_s)
135
+ raise ConfigurationError, "sitemap changefreq must be one of #{CHANGEFREQS.join(", ")}"
136
+ end
137
+
138
+ entry = Entry.new(source: path_or_scope, path: path, lastmod: lastmod, priority: priority&.to_f,
139
+ changefreq: changefreq&.to_s, hosts: hosts && Array(hosts).map(&:to_s), noindex: noindex == true,
140
+ index: index == true, markdown: markdown.nil? ? nil : markdown == true, match: match,
141
+ batch: batch)
142
+ @entries << entry
143
+ entry
144
+ end
145
+
146
+ # Excludes paths from every sitemap (user-generated/share pages, duplicates).
147
+ # @param patterns [Array<String, Regexp>] exact paths or regexps matched against the path
148
+ # @return [void]
149
+ def exclude(*patterns)
150
+ @exclusions.concat(patterns.flatten)
151
+ end
152
+
153
+ # @param path [String]
154
+ # @return [Boolean] true when a static entry for +path+ was added with +noindex: true+
155
+ def noindex?(path)
156
+ PathMatch.any?(@entries.select(&:noindex).map(&:source), path)
157
+ end
158
+
159
+ # @param path [String]
160
+ # @param host [String, nil] request host; entries added with +hosts:+ count only there (nil => any host)
161
+ # @param config [Configuration] resolves +hosts: nil+ to the canonical host
162
+ # @return [Boolean] true when an entry added with +index: true+ covers +path+ on +host+: a static path, or
163
+ # a collection whose +match:+ accepts it
164
+ def index?(path, host: nil, config: SeoGeo.config)
165
+ @entries.any? { |entry| entry.index && (host.nil? || entry.for_host?(host, config)) && entry.covers?(path) }
166
+ end
167
+
168
+ # @return [Array<Entry>] collections added with +index: true+ but no +match:+ — their record pages are
169
+ # in sitemap.xml while the head cannot know they are declared indexable
170
+ def unmatched_index_collections
171
+ @entries.select { |entry| entry.index && !entry.static? && entry.match.nil? }
172
+ end
173
+
174
+ # @param path [String]
175
+ # @return [Boolean] true when a static entry for +path+ was added with +markdown: false+
176
+ def markdown_disabled?(path)
177
+ PathMatch.any?(@entries.select { |entry| entry.static? && entry.markdown == false }.map(&:source), path)
178
+ end
179
+
180
+ # @param path [String] compared like every declaration ({PathMatch}); a Regexp that times out excludes
181
+ # @return [Boolean]
182
+ def excluded?(path)
183
+ PathMatch.any?(@exclusions, path)
184
+ end
185
+
186
+ # Enumerates resolved URLs for one host. The sitemaps controller streams these into XML,
187
+ # splitting into a sitemap index above 50,000 URLs.
188
+ # @param host [String] request host
189
+ # @param config [Configuration]
190
+ # @yieldparam url [Url]
191
+ # @return [Enumerator<Url>] when no block is given
192
+ def each_url(host, config: SeoGeo.config)
193
+ return enum_for(:each_url, host, config: config) unless block_given?
194
+
195
+ origin = config.origin_for(host)
196
+ entries.each do |entry|
197
+ next if entry.noindex || !entry.for_host?(host, config)
198
+ next if config.default_noindex && !entry.index
199
+
200
+ each_record(entry) do |record|
201
+ relative = record ? resolve(entry.path, record) : entry.source
202
+ next if relative.nil? || excluded?(relative.to_s) || config.noindex_path?(relative.to_s)
203
+
204
+ lastmod = entry.lastmod.is_a?(Symbol) || entry.lastmod.respond_to?(:call) ? resolve(entry.lastmod, record) : entry.lastmod
205
+ yield Url.new(loc: config.canonical_url("#{origin}#{relative}"), lastmod: lastmod,
206
+ priority: entry.priority, changefreq: entry.changefreq)
207
+ end
208
+ end
209
+ end
210
+
211
+ # Removes every entry and exclusion (tests, code reloading).
212
+ # @return [void]
213
+ def clear
214
+ @entries.clear
215
+ @exclusions.clear
216
+ end
217
+
218
+ private
219
+
220
+ def each_record(entry, &block)
221
+ return yield(nil) if entry.static?
222
+
223
+ source = entry.source.respond_to?(:call) ? entry.source.call : entry.source
224
+ if entry.batch != false && source.respond_to?(:find_each)
225
+ source.find_each(&block)
226
+ else
227
+ source.each(&block)
228
+ end
229
+ end
230
+
231
+ def resolve(callable_or_name, record)
232
+ return nil if callable_or_name.nil? || record.nil?
233
+ return record.public_send(callable_or_name) if callable_or_name.is_a?(Symbol)
234
+
235
+ callable_or_name.call(record)
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "uri"
5
+
6
+ module SeoGeo
7
+ module Sitemap
8
+ # Renders the sitemap for one request host from {Registry#each_url}.
9
+ #
10
+ # Up to {MAX_URLS} URLs => one +<urlset>+ at /sitemap.xml. Above that, /sitemap.xml is a
11
+ # +<sitemapindex>+ pointing at /sitemap-1.xml, /sitemap-2.xml, ... (sitemaps.org limit: 50,000 per file).
12
+ # URLs {SeoGeo::Publication} rejects are dropped (robots-disallowed, excluded, declared noindex,
13
+ # not opted in under +default_noindex+).
14
+ #
15
+ # @example
16
+ # xml = SeoGeo::Sitemap::Xml.new(SeoGeo.config, SeoGeo.sitemap, "www.example.com")
17
+ # xml.render(nil) # => String, or nil when the host has no sitemap
18
+ class Xml
19
+ MAX_URLS = 50_000
20
+ XMLNS = "http://www.sitemaps.org/schemas/sitemap/0.9"
21
+
22
+ # @param config [Configuration]
23
+ # @param registry [Registry]
24
+ # @param host [String] request host
25
+ # @param max_urls [Integer] URLs per file (tests may lower it)
26
+ def initialize(config, registry, host, max_urls: MAX_URLS)
27
+ @config = config
28
+ @registry = registry
29
+ @host = host
30
+ @max_urls = max_urls
31
+ @policy = config.host_policy(host)
32
+ end
33
+
34
+ # @param page [Integer, nil] nil => /sitemap.xml; n => /sitemap-n.xml
35
+ # @return [String, nil] XML, or nil when there is nothing to serve (=> 404)
36
+ def render(page = nil)
37
+ return nil unless @policy.sitemap
38
+
39
+ page.nil? ? render_root : render_part(page)
40
+ end
41
+
42
+ # @return [Enumerator<Url>] URLs this host's sitemap lists
43
+ def urls
44
+ Enumerator.new do |yielder|
45
+ @registry.each_url(@host, config: @config) { |url| yielder << url if listable?(url) }
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def render_root
52
+ head = urls.take(@max_urls + 1)
53
+ return nil if head.empty?
54
+ return urlset(head) if head.length <= @max_urls
55
+
56
+ sitemap_index
57
+ end
58
+
59
+ def render_part(page)
60
+ return nil unless page.is_a?(Integer) && page.positive?
61
+
62
+ chunk = []
63
+ split = false
64
+ urls.each_with_index do |url, index|
65
+ split = true if index == @max_urls
66
+ next unless index / @max_urls == page - 1
67
+ chunk << url
68
+ end
69
+ # Parts exist only when the root is an index; /sitemap-1.xml of a small site is a 404.
70
+ split && !chunk.empty? ? urlset(chunk) : nil
71
+ end
72
+
73
+ def sitemap_index
74
+ parts = []
75
+ urls.each_slice(@max_urls) { |slice| parts << slice.filter_map { |url| url.lastmod && as_time(url.lastmod) }.max }
76
+ origin = @config.origin_for(@host)
77
+ body = parts.each_with_index.map do |lastmod, index|
78
+ lines = [" <sitemap>", " <loc>#{escape("#{origin}/sitemap-#{index + 1}.xml")}</loc>"]
79
+ lines << " <lastmod>#{format_time(lastmod)}</lastmod>" if lastmod
80
+ lines << " </sitemap>"
81
+ lines.join("\n")
82
+ end
83
+ document("sitemapindex", body)
84
+ end
85
+
86
+ def urlset(list)
87
+ body = list.map do |url|
88
+ lines = [" <url>", " <loc>#{escape(url.loc)}</loc>"]
89
+ lines << " <lastmod>#{format_time(url.lastmod)}</lastmod>" if url.lastmod
90
+ lines << " <changefreq>#{url.changefreq}</changefreq>" if url.changefreq
91
+ lines << " <priority>#{format("%.1f", url.priority)}</priority>" if url.priority
92
+ lines << " </url>"
93
+ lines.join("\n")
94
+ end
95
+ document("urlset", body)
96
+ end
97
+
98
+ def document(root, body)
99
+ [%(<?xml version="1.0" encoding="UTF-8"?>), %(<#{root} xmlns="#{XMLNS}">), *body, "</#{root}>", ""].join("\n")
100
+ end
101
+
102
+ # {SeoGeo::Publication} decides (robots disallow, exclusions, noindex declarations,
103
+ # default_noindex) — the same predicate llms.txt and the head use.
104
+ def listable?(url)
105
+ # Registry#each_url already skipped entries without index: true under default_noindex.
106
+ Publication.publishable?(@host, url.loc, config: @config, sitemap: @registry, check_host: false, declared_index: true)
107
+ end
108
+
109
+ # Dates stay dates (W3C "YYYY-MM-DD"); times become W3C datetimes.
110
+ def format_time(value)
111
+ return value.to_s if value.is_a?(String)
112
+ return value.iso8601 if value.is_a?(Date) && !value.is_a?(DateTime)
113
+
114
+ value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
115
+ end
116
+
117
+ def as_time(value)
118
+ case value
119
+ when DateTime then value.to_time
120
+ when Date then Time.utc(value.year, value.month, value.day)
121
+ when String then parse_time(value)
122
+ else value.respond_to?(:to_time) ? value.to_time : nil
123
+ end
124
+ end
125
+
126
+ def parse_time(value)
127
+ Time.iso8601(value)
128
+ rescue ArgumentError
129
+ date = Date.iso8601(value) rescue nil # rubocop:disable Style/RescueModifier
130
+ date && Time.utc(date.year, date.month, date.day)
131
+ end
132
+
133
+ def escape(text)
134
+ text.to_s.encode(xml: :text)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ module Testing
5
+ # Runs the block as if the app were in another environment ({Configuration#environment}), then restores it,
6
+ # so a test can see production robots.txt, indexable heads and sitemaps. Shared by the Minitest assertions
7
+ # ({Assertions#with_seo_geo_environment}) and the RSpec helpers.
8
+ #
9
+ # @example
10
+ # SeoGeo::Testing.with_environment("production") { get "/robots.txt" }
11
+ # @param name [String, Symbol]
12
+ # @param config [Configuration]
13
+ # @return [Object] the block's value
14
+ def self.with_environment(name, config: SeoGeo.config)
15
+ saved = config.environment
16
+ config.environment = name.to_s
17
+ yield
18
+ ensure
19
+ config.environment = saved
20
+ end
21
+
22
+ # Instance helper mixed into Minitest tests and RSpec examples.
23
+ module EnvironmentHelper
24
+ # @param name [String, Symbol]
25
+ # @return [Object] the block's value
26
+ def with_seo_geo_environment(name, &block)
27
+ Testing.with_environment(name, &block)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "seo_geo"
4
+ require "uri"
5
+ require_relative "testing/environment"
6
+
7
+ module SeoGeo
8
+ # Minitest assertions for an app's integration tests. They use the same checks as
9
+ # +rake seo_geo:audit+ ({Audit::Checks}), so a passing test and a clean audit agree.
10
+ #
11
+ # @example test/integration/seo_test.rb
12
+ # require "seo_geo/testing"
13
+ #
14
+ # class SeoTest < ActionDispatch::IntegrationTest
15
+ # include SeoGeo::Testing::Assertions
16
+ #
17
+ # test("alias host folds") { assert_seo_geo_canonical "/pricing", host: "example.com" }
18
+ # test("faq is visible") { assert_seo_geo_faq_matches_visible "/" }
19
+ # test("one title") { assert_seo_geo_single_title "/" }
20
+ # test("sitemap") { assert_seo_geo_sitemap host: "www.example.com" }
21
+ # test("no alias sitemap") { assert_seo_geo_no_sitemap host: "example.com" }
22
+ # test("admin is closed") { refute_seo_geo_robots_allows "GPTBot", "/admin", host: "www.example.com" }
23
+ # test("production robots") { with_seo_geo_environment("production") { assert_seo_geo_sitemap host: "www.example.com" } }
24
+ # end
25
+ module Testing
26
+ # Include into +ActionDispatch::IntegrationTest+ (needs +host!+, +get+ and +response+).
27
+ # Each assertion takes a +path+ and requests it; pass +path = nil+ to check the last response.
28
+ module Assertions
29
+ include EnvironmentHelper
30
+ # The page's +<link rel="canonical">+ equals +expected+ (default: {Configuration#canonical_url} of the
31
+ # requested URL — on an alias host that is the canonical origin).
32
+ # @param path [String, nil]
33
+ # @param host [String, nil]
34
+ # @param expected [String, nil]
35
+ def assert_seo_geo_canonical(path, host: nil, expected: nil)
36
+ seo_geo_get(path, host)
37
+ expected ||= SeoGeo.config.canonical_url(request.url)
38
+ actual = Audit::HtmlDocument.new(response.body).canonical
39
+ assert_equal expected, actual, "canonical of #{request.url}"
40
+ end
41
+
42
+ # Exactly one +<title>+ in the head.
43
+ # @param path [String, nil]
44
+ # @param host [String, nil]
45
+ def assert_seo_geo_single_title(path = nil, host: nil)
46
+ seo_geo_get(path, host)
47
+ problems = Audit::Checks.title_count(Audit::HtmlDocument.new(response.body))
48
+ assert_empty problems, "#{request.url}: #{problems.join("; ")}"
49
+ end
50
+
51
+ # Every FAQPage question and answer in the JSON-LD also appears in the visible text (or the
52
+ # crawler fallback body), and at least one FAQPage exists.
53
+ # @param path [String, nil]
54
+ # @param host [String, nil]
55
+ def assert_seo_geo_faq_matches_visible(path = nil, host: nil)
56
+ seo_geo_get(path, host)
57
+ doc = Audit::HtmlDocument.new(response.body)
58
+ refute_empty doc.faq_items, "#{request.url} has no FAQPage JSON-LD"
59
+ problems = Audit::Checks.faq_visible(doc)
60
+ assert_empty problems, "#{request.url}: #{problems.join("; ")}"
61
+ end
62
+
63
+ # /sitemap.xml on +host+ is 200, well-formed, in the sitemaps.org namespace, and lists only URLs
64
+ # on the host's origin.
65
+ # @param host [String]
66
+ # @return [Array<String>] the +<loc>+ values
67
+ def assert_seo_geo_sitemap(host:)
68
+ seo_geo_get("/sitemap.xml", host)
69
+ assert_equal 200, response.status, "/sitemap.xml on #{host}"
70
+ problems, locs, = Audit::Checks.sitemap(response.body, origin: "#{SeoGeo.config.origin_for(host)}/")
71
+ assert_empty problems, "/sitemap.xml on #{host}: #{problems.join("; ")}"
72
+ locs
73
+ end
74
+
75
+ # /sitemap.xml on +host+ is a 404 (alias, undeclared or non-indexable host).
76
+ # @param host [String]
77
+ def assert_seo_geo_no_sitemap(host:)
78
+ seo_geo_get("/sitemap.xml", host)
79
+ assert_equal 404, response.status, "/sitemap.xml on #{host} should be 404"
80
+ end
81
+
82
+ # robots.txt on +host+ lets +user_agent+ fetch +path+ (RFC 9309 evaluation, {Robots::Evaluator}).
83
+ # @param user_agent [String] token or full User-Agent header
84
+ # @param path [String]
85
+ # @param host [String, nil]
86
+ def assert_seo_geo_robots_allows(user_agent, path, host: nil)
87
+ assert seo_geo_robots(host).allowed?(user_agent, path), "robots.txt should allow #{user_agent} #{path}"
88
+ end
89
+
90
+ # robots.txt on +host+ disallows +path+ for +user_agent+.
91
+ def refute_seo_geo_robots_allows(user_agent, path, host: nil)
92
+ refute seo_geo_robots(host).allowed?(user_agent, path), "robots.txt should disallow #{user_agent} #{path}"
93
+ end
94
+
95
+ private
96
+
97
+ def seo_geo_robots(host)
98
+ seo_geo_get("/robots.txt", host)
99
+ assert_equal 200, response.status, "/robots.txt"
100
+ Robots::Evaluator.new(response.body)
101
+ end
102
+
103
+ def seo_geo_get(path, host)
104
+ return if path.nil?
105
+
106
+ host!(host) if host
107
+ get path
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module SeoGeo
6
+ # Fail-closed hosts are silent by design (noindex + +Disallow: /+), which also hides a misconfiguration: a proxy
7
+ # that drops the Host header (+127.0.0.1+), or an apex/www mismatch, deindexes the whole site. In an indexable
8
+ # environment the robots.txt controller and the head warn once per host per process.
9
+ module UndeclaredHost
10
+ MESSAGE = 'seo_geo: host %<host>s is not declared (canonical_origin/canonical_aliases/host); ' \
11
+ "serving noindex + Disallow: /. Check proxy Host headers."
12
+
13
+ @warned = Set.new
14
+ @lock = Mutex.new
15
+
16
+ module_function
17
+
18
+ # @param host [String, nil] request host
19
+ # @param config [Configuration]
20
+ # @return [Boolean] true when this call logged the warning
21
+ def warn_once(host, config: SeoGeo.config)
22
+ return false unless config.indexable_environment?
23
+ return false if config.host_policy(host.to_s).known
24
+
25
+ key = host.to_s.downcase
26
+ return false unless @lock.synchronize { @warned.add?(key) }
27
+
28
+ message = format(MESSAGE, host: key.inspect)
29
+ logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
30
+ logger ? logger.warn(message) : Kernel.warn(message)
31
+ true
32
+ end
33
+
34
+ # Forgets which hosts were reported (tests).
35
+ # @return [void]
36
+ def reset!
37
+ @lock.synchronize { @warned.clear }
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ VERSION = "0.1.0"
5
+ end
data/lib/seo_geo.rb ADDED
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "seo_geo/version"
4
+ require_relative "seo_geo/crawlers"
5
+ require_relative "seo_geo/safe_regexp"
6
+ require_relative "seo_geo/robots_path"
7
+ require_relative "seo_geo/robots_directives"
8
+ require_relative "seo_geo/undeclared_host"
9
+ require_relative "seo_geo/configuration"
10
+ require_relative "seo_geo/sitemap/registry"
11
+ require_relative "seo_geo/page"
12
+ # Head, JSON-LD, crawler fallback
13
+ require_relative "seo_geo/json_ld"
14
+ require_relative "seo_geo/meta"
15
+ require_relative "seo_geo/crawler_fallback"
16
+ require_relative "seo_geo/controller"
17
+ # Robots, sitemap, llms.txt
18
+ require_relative "seo_geo/robots"
19
+ require_relative "seo_geo/sitemap/xml"
20
+ require_relative "seo_geo/llms_txt"
21
+ # Markdown, IndexNow, OG image
22
+ require_relative "seo_geo/markdown/negotiation"
23
+ require_relative "seo_geo/markdown/document"
24
+ require_relative "seo_geo/markdown/path_suffix"
25
+ require_relative "seo_geo/markdownable"
26
+ require_relative "seo_geo/index_now"
27
+ require_relative "seo_geo/model"
28
+ require_relative "seo_geo/og_image/renderer"
29
+ require_relative "seo_geo/og_image/ferrum_backend"
30
+ # Noindex path matching, robots evaluator, audit, bot verification
31
+ require_relative "seo_geo/path_match"
32
+ require_relative "seo_geo/publication"
33
+ require_relative "seo_geo/robots/evaluator"
34
+ require_relative "seo_geo/audit"
35
+ require_relative "seo_geo/bot_verifier"
36
+
37
+ # SEO and GEO (Generative Engine Optimization: visibility in AI search and assistants) for Rails.
38
+ # Not a geolocation library.
39
+ module SeoGeo
40
+ class << self
41
+ # @return [Configuration]
42
+ def config
43
+ @config ||= Configuration.new
44
+ end
45
+
46
+ # @yieldparam config [Configuration]
47
+ # @return [Configuration]
48
+ def configure
49
+ yield config
50
+ config
51
+ end
52
+
53
+ # @return [Sitemap::Registry]
54
+ def sitemap
55
+ @sitemap ||= Sitemap::Registry.new
56
+ end
57
+
58
+ # @return [PageRegistry]
59
+ def pages
60
+ @pages ||= PageRegistry.new
61
+ end
62
+
63
+ # @param key [Symbol, String]
64
+ # @param locale [Symbol, String, nil]
65
+ # @return [Page]
66
+ def page(key, locale: nil)
67
+ pages.fetch(key, locale: locale)
68
+ end
69
+
70
+ # Replaces configuration and registries with fresh instances (tests).
71
+ # @return [void]
72
+ def reset!
73
+ @config = Configuration.new
74
+ @sitemap = Sitemap::Registry.new
75
+ @pages = PageRegistry.new
76
+ end
77
+ end
78
+ end
79
+
80
+ require_relative "seo_geo/engine" if defined?(Rails::Engine)
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :seo_geo do
4
+ # `rake seo_geo:audit` (requests every registered URL and reports problems).
5
+ # Set before :environment so the engine adds the audited hosts to config.hosts outside production
6
+ # (SeoGeo::Engine.allow_audit_hosts!). For `bin/rails runner`, export SEO_GEO_AUDIT=1 yourself.
7
+ task :audit_flag do
8
+ ENV["SEO_GEO_AUDIT"] = "1"
9
+ end
10
+
11
+ desc "Audit robots.txt, sitemap.xml, llms.txt and every sitemap page in-process " \
12
+ "(FORMAT=json, HOSTS=a.com,b.com, MAX_PAGES=200); exits 1 when there are errors"
13
+ task audit: %i[audit_flag environment] do
14
+ report = SeoGeo::Audit::Runner.new(
15
+ app: Rails.application,
16
+ root: Rails.root,
17
+ hosts: ENV["HOSTS"]&.split(",")&.map(&:strip)&.reject(&:empty?),
18
+ max_pages: Integer(ENV.fetch("MAX_PAGES", "200"), 10)
19
+ ).run
20
+ puts ENV["FORMAT"].to_s.casecmp?("json") ? report.to_json : report.to_s
21
+ exit(1) if report.errors?
22
+ end
23
+ end