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,832 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "erb"
5
+
6
+ module SeoGeo
7
+ # Raised by {Configuration#validate!} (at boot) and by registry DSLs on invalid input.
8
+ class ConfigurationError < ArgumentError; end
9
+
10
+ # Site identity used by meta tags, JSON-LD (WebSite/Organization) and llms.txt.
11
+ class SiteOptions
12
+ # @return [String, nil] site name; used as +%{site}+ in {Configuration#title_template}
13
+ attr_accessor :name
14
+ # @return [String, nil] one-sentence summary; the llms.txt blockquote and default description
15
+ attr_accessor :description
16
+ # @return [String, nil] absolute URL of the logo (Organization.logo)
17
+ attr_accessor :logo_url
18
+ # @return [Symbol] locale used for +<html lang>+ and when a page has no localized copy
19
+ attr_accessor :default_locale
20
+ # @return [Array<String>] Organization.sameAs profile URLs
21
+ attr_accessor :same_as
22
+
23
+ def initialize
24
+ @name = nil
25
+ @description = nil
26
+ @logo_url = nil
27
+ @default_locale = :en
28
+ @same_as = []
29
+ end
30
+ end
31
+
32
+ # Search-console ownership tokens. Each non-nil value becomes one meta tag (Daum: a robots.txt line).
33
+ class VerificationOptions
34
+ # @return [String, nil] +<meta name="google-site-verification">+
35
+ attr_accessor :google
36
+ # @return [String, nil] +<meta name="msvalidate.01">+
37
+ attr_accessor :bing
38
+ # @return [String, nil] +<meta name="naver-site-verification">+
39
+ attr_accessor :naver
40
+ # @return [String, nil] Daum webmaster PIN, emitted as +#DaumWebMasterTool:<pin>+ in robots.txt
41
+ attr_accessor :daum_pin
42
+
43
+ # @return [Hash{String => String}] meta name => content for every configured HTML token
44
+ def meta_tags
45
+ { "google-site-verification" => google, "msvalidate.01" => bing, "naver-site-verification" => naver }
46
+ .reject { |_name, value| value.nil? || value.to_s.empty? }
47
+ end
48
+ end
49
+
50
+ # Purpose-based crawler policy. One object drives robots.txt groups, the Content-Signal
51
+ # header/line and (experimental) AIPREF Content-Usage.
52
+ class CrawlerOptions
53
+ POLICIES = %i[allow deny].freeze
54
+ # Per-token overrides may also grant +:full_access+ (see {#override}).
55
+ OVERRIDE_POLICIES = %i[allow deny full_access].freeze
56
+
57
+ # @return [Symbol] :allow or :deny for training crawlers (default :allow)
58
+ attr_accessor :training
59
+ # @return [Symbol] :allow or :deny for search crawlers (default :allow)
60
+ attr_accessor :search
61
+ # @return [Symbol] :allow or :deny for user-triggered fetchers (default :allow)
62
+ attr_accessor :user
63
+ # @return [Hash{String => Symbol}] per-token policy overriding its purpose, e.g. {"Bytespider" => :deny}
64
+ attr_reader :overrides
65
+
66
+ def initialize
67
+ @training = :allow
68
+ @search = :allow
69
+ @user = :allow
70
+ @extra = Crawlers::PURPOSES.to_h { |purpose| [purpose, []] }
71
+ @overrides = {}
72
+ end
73
+
74
+ # Adds a user-agent token to a purpose group (in addition to {Crawlers::DEFAULT_USER_AGENTS}).
75
+ # @param purpose [Symbol] one of {Crawlers::PURPOSES}
76
+ # @param user_agents [Array<String>]
77
+ # @return [void]
78
+ def add(purpose, *user_agents)
79
+ raise ConfigurationError, "unknown crawler purpose #{purpose.inspect}" unless @extra.key?(purpose)
80
+
81
+ @extra[purpose].concat(user_agents.flatten.map(&:to_s))
82
+ end
83
+
84
+ # @param user_agent [String] robots.txt token
85
+ # @param policy [Symbol] :allow, :deny or :full_access. +:full_access+ (opt-in, never a default) gives the
86
+ # token its own group with +Allow: /+ and the allow exceptions — it **ignores every host +disallow+**
87
+ # (e.g. +"Mediapartners-Google"+ must see the pages it serves ads on). It still gets +Disallow: /+ wherever
88
+ # the whole host is closed (non-indexable host or environment, undeclared host).
89
+ # @return [void]
90
+ def override(user_agent, policy)
91
+ @overrides[user_agent.to_s] = policy
92
+ end
93
+
94
+ # @param purpose [Symbol]
95
+ # @return [Symbol] :allow or :deny
96
+ def policy_for_purpose(purpose)
97
+ case purpose
98
+ when :training then training
99
+ when :search then search
100
+ when :user then user
101
+ else raise ConfigurationError, "unknown crawler purpose #{purpose.inspect}"
102
+ end
103
+ end
104
+
105
+ # @param purpose [Symbol]
106
+ # @return [Array<String>] default plus added tokens, de-duplicated, in table order
107
+ def user_agents(purpose)
108
+ (Crawlers::DEFAULT_USER_AGENTS.fetch(purpose) + @extra.fetch(purpose)).uniq
109
+ end
110
+
111
+ # Effective policy for one token: override, else its purpose, else nil (not managed by seo_geo).
112
+ # @param user_agent [String]
113
+ # @return [Symbol, nil]
114
+ def policy_for(user_agent)
115
+ key = @overrides.keys.find { |token| token.casecmp?(user_agent.to_s) }
116
+ return @overrides[key] if key
117
+
118
+ purpose = Crawlers::PURPOSES.find { |name| user_agents(name).any? { |token| token.casecmp?(user_agent.to_s) } }
119
+ purpose && policy_for_purpose(purpose)
120
+ end
121
+
122
+ # @return [Array<String>] validation messages
123
+ def errors
124
+ messages = Crawlers::PURPOSES.filter_map do |purpose|
125
+ value = policy_for_purpose(purpose)
126
+ "crawlers.#{purpose} must be :allow or :deny (got #{value.inspect})" unless POLICIES.include?(value)
127
+ end
128
+ @overrides.each do |token, value|
129
+ next if OVERRIDE_POLICIES.include?(value)
130
+
131
+ messages << "crawlers.override(#{token.inspect}) must be :allow, :deny or :full_access (got #{value.inspect})"
132
+ end
133
+ messages
134
+ end
135
+ end
136
+
137
+ # llms.txt / llms-full.txt contents (https://llmstxt.org/).
138
+ class LlmsTxtOptions
139
+ # One link line: +- [title](url): description+. +page+ links take title/description from {SeoGeo.page}.
140
+ #
141
+ # @!attribute page [Symbol, nil] page key ({SeoGeo.page}); nil for a plain link
142
+ # @!attribute title [String, nil] link text (nil => page title, else the path)
143
+ # @!attribute path [String] path ("/pricing") or absolute URL
144
+ # @!attribute description [String, nil] note after the link (nil => page description)
145
+ # @!attribute section [String] H2 the link is listed under
146
+ # @!attribute optional [Boolean] list under +## Optional+ (secondary, droppable by readers)
147
+ # @!attribute hosts [Array<String>, nil] hosts whose llms.txt lists the link; nil => canonical host only
148
+ # @!attribute markdown [Boolean, nil] link the +.md+ form. nil => page links follow
149
+ # {MarkdownOptions#enabled} (a registered page has copy for Markdown); plain links do not (most actions do
150
+ # not include {SeoGeo::Markdownable} and their +.md+ URL answers 406). URLs on other origins are never rewritten.
151
+ Link = Data.define(:page, :title, :path, :description, :section, :optional, :hosts, :markdown) do
152
+ # @param config [Configuration]
153
+ # @return [Boolean] whether llms.txt links this entry's +.md+ URL
154
+ def markdown_link?(config)
155
+ config.markdown.suffix_urls? && (markdown.nil? ? !page.nil? : markdown == true)
156
+ end
157
+ end
158
+
159
+ # @return [Boolean] serve /llms.txt and /llms-full.txt (false => 404)
160
+ attr_accessor :enabled
161
+ # @return [Boolean] serve /llms-full.txt (concatenated page Markdown)
162
+ attr_accessor :full
163
+ # @return [String, nil] Markdown placed after the blockquote: paragraphs, lists and headings. H1/H2 are demoted
164
+ # to H3 (the H1 is the site name, H2s are the link sections); H3 and deeper stay as written
165
+ attr_accessor :details
166
+ # @return [String] section used when none is given
167
+ attr_accessor :default_section
168
+ # @return [Array<Link>]
169
+ attr_reader :links
170
+
171
+ def initialize
172
+ @enabled = true
173
+ @full = true
174
+ @details = nil
175
+ @default_section = "Pages"
176
+ @links = []
177
+ end
178
+
179
+ # Lists a registered page; its copy also goes into llms-full.txt.
180
+ # @param key [Symbol, String] page key ({SeoGeo.page})
181
+ # @param path [String] where the page is served
182
+ # @param section [String, nil]
183
+ # @param optional [Boolean]
184
+ # @param hosts [Array<String>, String, nil]
185
+ # @param markdown [Boolean, nil] link +/path.md+; nil => {MarkdownOptions#enabled}. Pass false when the
186
+ # action does not include {SeoGeo::Markdownable}.
187
+ # @return [Link]
188
+ def page(key, path:, section: nil, optional: false, hosts: nil, markdown: nil)
189
+ add(Link.new(page: key.to_sym, title: nil, path: path.to_s, description: nil, section: section,
190
+ optional: optional, hosts: hosts && Array(hosts).map(&:to_s), markdown: markdown))
191
+ end
192
+
193
+ # Lists a plain link.
194
+ # @param title [String]
195
+ # @param path [String] path or absolute URL
196
+ # @param description [String, nil]
197
+ # @param section [String, nil]
198
+ # @param optional [Boolean]
199
+ # @param hosts [Array<String>, String, nil]
200
+ # @param markdown [Boolean, nil] link +/path.md+ only when true (the action includes {SeoGeo::Markdownable});
201
+ # nil/false => the HTML URL
202
+ # @return [Link]
203
+ def link(title, path, description: nil, section: nil, optional: false, hosts: nil, markdown: nil)
204
+ add(Link.new(page: nil, title: title.to_s, path: path.to_s, description: description, section: section,
205
+ optional: optional, hosts: hosts && Array(hosts).map(&:to_s), markdown: markdown))
206
+ end
207
+
208
+ # @return [Array<String>]
209
+ def errors
210
+ links.filter_map do |item|
211
+ next if item.path.start_with?("/") || item.path.match?(%r{\Ahttps?://})
212
+
213
+ "llms_txt link #{item.path.inspect} must be a path starting with / or an http(s) URL"
214
+ end
215
+ end
216
+
217
+ private
218
+
219
+ def add(item)
220
+ @links << item
221
+ item
222
+ end
223
+ end
224
+
225
+ # IETF AIPREF (draft-ietf-aipref-vocab / -attach). Off by default because the drafts are not RFCs.
226
+ class AiprefOptions
227
+ # @return [Boolean] emit +Content-Usage+ header and robots.txt lines (experimental)
228
+ attr_accessor :enabled
229
+
230
+ def initialize
231
+ @enabled = false
232
+ end
233
+ end
234
+
235
+ # Markdown content negotiation (+Accept: text/markdown+ and +.md+ URLs).
236
+ class MarkdownOptions
237
+ # @return [Boolean]
238
+ attr_accessor :enabled
239
+ # @return [Boolean] add the +Content-Signal+ header to Markdown responses
240
+ attr_accessor :content_signal_header
241
+ # @return [Symbol, #call] :reverse_markdown, or a callable +(html) -> markdown+
242
+ attr_accessor :converter
243
+ # @return [String] CSS selector for the main content; the first match is converted, else +<body>+
244
+ attr_accessor :selector
245
+ # @return [Boolean] serve +/<path>.md+ through {Markdown::PathSuffix} (default true). The middleware rewrites
246
+ # every GET +*.md+ app-wide; set false when the app has its own +.md+ routes. Markdown is then reachable only
247
+ # with +Accept: text/markdown+, and the head, llms.txt and the audit stop using +.md+ URLs.
248
+ attr_accessor :path_suffix
249
+
250
+ def initialize
251
+ @enabled = true
252
+ @content_signal_header = true
253
+ @converter = :reverse_markdown
254
+ @selector = "main, [role=main]"
255
+ @path_suffix = true
256
+ end
257
+
258
+ # @return [Boolean] Markdown is served at +/<path>.md+ URLs
259
+ def suffix_urls?
260
+ enabled && path_suffix != false
261
+ end
262
+
263
+ # @return [Array<String>]
264
+ def errors
265
+ messages = []
266
+ unless converter == :reverse_markdown || converter.respond_to?(:call)
267
+ messages << "markdown.converter must be :reverse_markdown or respond to #call"
268
+ end
269
+ messages << "markdown.selector must be a non-empty String" if selector.to_s.strip.empty?
270
+ messages
271
+ end
272
+ end
273
+
274
+ # IndexNow submission (one shared endpoint relays to Bing, Naver, Yandex, Seznam, Yep).
275
+ class IndexNowOptions
276
+ KEY_FORMAT = /\A[a-zA-Z0-9-]{8,128}\z/
277
+ MAX_URLS_PER_REQUEST = 10_000
278
+
279
+ # @return [String, nil] 8–128 chars of a-zA-Z0-9-; served at +/<key>.txt+
280
+ attr_accessor :key
281
+ # @return [String]
282
+ attr_accessor :endpoint
283
+ # @return [Boolean]
284
+ attr_accessor :enabled
285
+ # @return [Boolean, nil] nil = dry-run outside indexable environments
286
+ attr_accessor :dry_run
287
+ # @return [Integer] URLs per POST (protocol maximum 10,000)
288
+ attr_accessor :batch_size
289
+
290
+ def initialize
291
+ @key = nil
292
+ @endpoint = "https://api.indexnow.org/indexnow"
293
+ @enabled = false
294
+ @dry_run = nil
295
+ @batch_size = MAX_URLS_PER_REQUEST
296
+ end
297
+
298
+ # @return [Array<String>]
299
+ def errors
300
+ messages = []
301
+ if enabled && !KEY_FORMAT.match?(key.to_s)
302
+ messages << "indexnow.key must be 8-128 characters of a-zA-Z0-9- when indexnow.enabled"
303
+ end
304
+ if %w[robots llms llms-full humans security ads app-ads].include?(key.to_s.downcase)
305
+ messages << "indexnow.key #{key.inspect} collides with another /<name>.txt route"
306
+ end
307
+ unless batch_size.is_a?(Integer) && batch_size.between?(1, MAX_URLS_PER_REQUEST)
308
+ messages << "indexnow.batch_size must be an Integer between 1 and #{MAX_URLS_PER_REQUEST}"
309
+ end
310
+ messages << "indexnow.endpoint must be an https URL" unless endpoint.to_s.start_with?("https://")
311
+ messages
312
+ end
313
+ end
314
+
315
+ # Open Graph image rendering.
316
+ class OgImageOptions
317
+ # @return [#call, nil] +(key, page, params) -> String (PNG bytes) | nil+; nil uses the built-in
318
+ # ferrum renderer when ferrum is available, else {#fallback_url}
319
+ attr_accessor :backend
320
+ # @return [String] part of the cache key; bump it to invalidate every cached image
321
+ attr_accessor :template_version
322
+ # @return [Integer]
323
+ attr_accessor :width
324
+ # @return [Integer]
325
+ attr_accessor :height
326
+ # @return [String, nil] absolute URL used when rendering fails (and as the page image when none is given)
327
+ attr_accessor :fallback_url
328
+ # @return [Integer, nil] real pixel size of {#fallback_url}; +og:image:width/height+ are emitted for the
329
+ # fallback image only when both are set (a static image is rarely exactly {#width} x {#height})
330
+ attr_accessor :fallback_width
331
+ # @return [Integer, nil] see {#fallback_width}
332
+ attr_accessor :fallback_height
333
+ # @return [Array<String>] query parameters passed to the backend and part of the cache key;
334
+ # every other parameter is ignored so arbitrary query strings cannot fill the cache.
335
+ # Each one needs a {#param_constraints} entry.
336
+ attr_accessor :permitted_params
337
+ # @return [Hash{String => Regexp, Array<String>}] per-parameter value constraint: a Regexp the whole
338
+ # value must match, or the list of allowed values. A value that fails it is a 400 without rendering.
339
+ attr_accessor :param_constraints
340
+ # @return [Integer] longest accepted parameter value (characters); longer values are a 400
341
+ attr_accessor :max_param_length
342
+ # @return [Array<String, Symbol>] keys served in addition to {SeoGeo.pages} keys; any other key is a 404
343
+ attr_accessor :keys
344
+ # @return [#call, nil] +(key) -> Page | true | nil+ for keys not known statically (e.g. records);
345
+ # nil/false => 404 without rendering, a {Page} becomes the card's page copy
346
+ attr_accessor :key_resolver
347
+ # @return [Integer] seconds a rendered image stays in the cache (+expires_in+)
348
+ attr_accessor :cache_ttl
349
+ # @return [Integer] renders allowed at once per process; a cache miss beyond it gets the fallback
350
+ # redirect (or a 503 with +Retry-After+) instead of queueing
351
+ attr_accessor :max_concurrent
352
+ # @return [String, nil] ERB template path for the built-in ferrum renderer; nil uses the bundled card
353
+ attr_accessor :template
354
+ # @return [Boolean, nil] whether +GET /og/:key.png+ is routed at all. nil (default) => only when {#backend}
355
+ # or {#template} is set; true also serves the bundled ferrum card. Unrouted, +/og/...+ falls through to
356
+ # the application's own routes.
357
+ attr_accessor :enabled
358
+
359
+ def initialize
360
+ @backend = nil
361
+ @template_version = "1"
362
+ @width = 1200
363
+ @height = 630
364
+ @fallback_url = nil
365
+ @fallback_width = nil
366
+ @fallback_height = nil
367
+ @permitted_params = []
368
+ @param_constraints = {}
369
+ @max_param_length = 100
370
+ @keys = []
371
+ @key_resolver = nil
372
+ @cache_ttl = 7 * 24 * 60 * 60
373
+ @max_concurrent = 2
374
+ @template = nil
375
+ @enabled = nil
376
+ end
377
+
378
+ # @return [Boolean] whether the OG route answers at all (see {#enabled})
379
+ def enabled?
380
+ enabled.nil? ? !(backend.nil? && template.nil?) : enabled == true
381
+ end
382
+
383
+ # @return [Boolean] whether the optional ferrum gem can be loaded
384
+ def ferrum_available?
385
+ return true if defined?(::Ferrum)
386
+
387
+ require "ferrum"
388
+ true
389
+ rescue LoadError
390
+ false
391
+ end
392
+
393
+ # @return [Array<String>]
394
+ def errors
395
+ messages = []
396
+ messages << "og_image.backend must respond to #call" unless backend.nil? || backend.respond_to?(:call)
397
+ %i[width height max_param_length cache_ttl max_concurrent].each do |name|
398
+ value = public_send(name)
399
+ messages << "og_image.#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
400
+ end
401
+ %i[fallback_width fallback_height].each do |name|
402
+ value = public_send(name)
403
+ messages << "og_image.#{name} must be a positive Integer or nil" unless value.nil? || (value.is_a?(Integer) && value.positive?)
404
+ end
405
+ messages << "og_image.permitted_params must be an Array" unless permitted_params.is_a?(Array)
406
+ messages << "og_image.keys must be an Array" unless keys.is_a?(Array)
407
+ messages << "og_image.key_resolver must respond to #call" unless key_resolver.nil? || key_resolver.respond_to?(:call)
408
+ messages.concat(param_constraint_errors)
409
+ messages << "og_image.template #{template.inspect} does not exist" if template && !File.file?(template.to_s)
410
+ messages
411
+ end
412
+
413
+ private
414
+
415
+ def param_constraint_errors
416
+ return ["og_image.param_constraints must be a Hash"] unless param_constraints.is_a?(Hash)
417
+
418
+ constraints = param_constraints.transform_keys(&:to_s)
419
+ invalid = constraints.reject { |_name, rule| rule.is_a?(Regexp) || (rule.is_a?(Array) && !rule.empty?) }
420
+ messages = invalid.keys.map { |name| "og_image.param_constraints[#{name.inspect}] must be a Regexp or a non-empty Array" }
421
+ return messages unless permitted_params.is_a?(Array)
422
+
423
+ permitted_params.map(&:to_s).reject { |name| constraints.key?(name) }.each do |name|
424
+ messages << "og_image.permitted_params #{name.inspect} needs an og_image.param_constraints entry"
425
+ end
426
+ messages
427
+ end
428
+ end
429
+
430
+ # Per-host crawl/index policy. Built by {Configuration#host}; unknown hosts get {.fail_closed}.
431
+ #
432
+ # @!attribute host [String] lower-case host name, no port
433
+ # @!attribute indexable [Boolean] false => robots meta noindex and robots.txt +Disallow: /+
434
+ # @!attribute disallow [Array<String>] path prefixes disallowed for every crawler group
435
+ # @!attribute allow [Array<String>] path prefixes always allowed (allow_exceptions + share prefixes)
436
+ # @!attribute sitemap [Boolean] serve /sitemap.xml for this host (false => 404)
437
+ # @!attribute known [Boolean] false only for the fail-closed policy of an undeclared host
438
+ HostPolicy = Data.define(:host, :indexable, :disallow, :allow, :sitemap, :known) do
439
+ # @return [HostPolicy] policy for a host nobody declared: nothing indexable, no sitemap
440
+ def self.fail_closed(host, allow: [])
441
+ new(host: host, indexable: false, disallow: ["/"].freeze, allow: allow.dup.freeze, sitemap: false, known: false)
442
+ end
443
+
444
+ # @return [Boolean] true when robots.txt should disallow the whole host
445
+ def disallow_all?
446
+ !indexable
447
+ end
448
+ end
449
+
450
+ # Root configuration object. Exposed as +SeoGeo.config+ and, in Rails, as +config.seo_geo+.
451
+ #
452
+ # @example
453
+ # SeoGeo.configure do |c|
454
+ # c.site.name = "Example"
455
+ # c.canonical_origin = "https://www.example.com"
456
+ # c.canonical_aliases = ["example.com"]
457
+ # c.host "www.example.com", disallow: ["/admin"]
458
+ # c.crawlers.training = :deny
459
+ # end
460
+ class Configuration
461
+ UNKNOWN_HOST_MODES = %i[fail_closed].freeze
462
+
463
+ # @return [SiteOptions]
464
+ attr_reader :site
465
+ # @return [VerificationOptions]
466
+ attr_reader :verification
467
+ # @return [CrawlerOptions]
468
+ attr_reader :crawlers
469
+ # @return [AiprefOptions]
470
+ attr_reader :aipref
471
+ # @return [MarkdownOptions]
472
+ attr_reader :markdown
473
+ # @return [IndexNowOptions]
474
+ attr_reader :indexnow
475
+ # @return [OgImageOptions]
476
+ attr_reader :og_image
477
+ # @return [LlmsTxtOptions]
478
+ attr_reader :llms_txt
479
+
480
+ # @return [String, nil] scheme + host (+ port), no path, e.g. "https://www.example.com"
481
+ attr_reader :canonical_origin
482
+ # @return [Array<String>] alias hosts folded onto {#canonical_origin}
483
+ attr_accessor :canonical_aliases
484
+ # @return [Array<String>] hosts that keep their own canonical (e.g. a separate store host)
485
+ attr_accessor :canonical_exclude_hosts
486
+ # @return [Array<String>] query parameters kept in canonical URLs (all others are dropped)
487
+ attr_accessor :canonical_keep_params
488
+
489
+ # @return [Symbol] behaviour for undeclared hosts; only :fail_closed is supported
490
+ attr_accessor :unknown_host
491
+ # @return [Array<String>] path prefixes allowed even on disallowed/fail-closed hosts
492
+ attr_accessor :allow_exceptions
493
+ # @return [Array<String>] share-link prefixes (e.g. "/s/") left open so link-preview bots can read them
494
+ attr_accessor :share_prefixes
495
+
496
+ # @return [Array<Symbol>] locales emitted as hreflang alternates
497
+ attr_accessor :locales
498
+ # @return [Symbol, nil] locale used for +hreflang="x-default"+ (defaults to site.default_locale)
499
+ attr_accessor :x_default_locale
500
+ # @return [#call, nil] +(locale, canonical_url) -> String+; nil => +?locale=<locale>+
501
+ attr_accessor :hreflang_url
502
+
503
+ # @return [#call, String, nil] current environment name; nil => Rails.env / RACK_ENV
504
+ attr_accessor :environment
505
+ # @return [Array<String>] environments allowed to be indexed; everything else is noindex + Disallow: /
506
+ attr_accessor :indexable_environments
507
+
508
+ # @return [String] format string with %{title} and optional %{site}
509
+ attr_accessor :title_template
510
+ # @return [Integer] descriptions are truncated to this many characters
511
+ attr_accessor :description_max_length
512
+ # @return [Boolean] true => pages are noindex unless a controller opts in with +seo index: true+
513
+ attr_accessor :default_noindex
514
+ # @return [Integer] Cache-Control max-age (seconds) for robots.txt, sitemap.xml, llms.txt
515
+ attr_accessor :cache_control_seconds
516
+ # @return [Hash{Symbol => Boolean}, nil] explicit Content-Signal; nil derives it from {#crawlers}
517
+ attr_accessor :content_signal
518
+ # @return [Array<String, Regexp>] paths that are always noindex: exact path Strings or
519
+ # Regexps matched against the path. One declaration drives the robots meta, sitemap.xml and llms.txt,
520
+ # so the three cannot disagree. See {#noindex_path?}.
521
+ attr_accessor :noindex_paths
522
+
523
+ def initialize
524
+ @site = SiteOptions.new
525
+ @verification = VerificationOptions.new
526
+ @crawlers = CrawlerOptions.new
527
+ @aipref = AiprefOptions.new
528
+ @markdown = MarkdownOptions.new
529
+ @indexnow = IndexNowOptions.new
530
+ @og_image = OgImageOptions.new
531
+ @llms_txt = LlmsTxtOptions.new
532
+
533
+ @canonical_origin = nil
534
+ @canonical_aliases = []
535
+ @canonical_exclude_hosts = []
536
+ @canonical_keep_params = []
537
+
538
+ @unknown_host = :fail_closed
539
+ @allow_exceptions = ["/.well-known/"]
540
+ @share_prefixes = []
541
+ @hosts = {}
542
+
543
+ @locales = []
544
+ @x_default_locale = nil
545
+ @hreflang_url = nil
546
+
547
+ @environment = nil
548
+ @indexable_environments = ["production"]
549
+
550
+ @title_template = "%{title} | %{site}"
551
+ @description_max_length = 160
552
+ @default_noindex = false
553
+ @cache_control_seconds = 3600
554
+ @content_signal = nil
555
+ @noindex_paths = []
556
+ end
557
+
558
+ # @param origin [String] e.g. "https://www.example.com"; a trailing slash is removed
559
+ def canonical_origin=(origin)
560
+ @canonical_origin = origin&.to_s&.chomp("/")
561
+ end
562
+
563
+ # @return [String, nil] host part of {#canonical_origin}
564
+ def canonical_host
565
+ canonical_uri&.host&.downcase
566
+ end
567
+
568
+ # Declares the policy for one host.
569
+ # @param name [String] host name
570
+ # @param indexable [Boolean]
571
+ # @param disallow [Array<String>] path prefixes
572
+ # @param allow [Array<String>] extra always-allowed prefixes (added to allow_exceptions/share_prefixes)
573
+ # @param sitemap [Boolean, nil] nil => same as +indexable+
574
+ # @return [void]
575
+ def host(name, indexable: true, disallow: [], allow: [], sitemap: nil)
576
+ key = normalize_host(name)
577
+ raise ConfigurationError, "host #{name.inspect} is not a valid host name" if key.empty? || key.include?("/")
578
+
579
+ @hosts[key] = { indexable: indexable, disallow: Array(disallow), allow: Array(allow),
580
+ sitemap: sitemap.nil? ? indexable : sitemap }
581
+ end
582
+
583
+ # @return [Array<String>] canonical host, aliases, excluded hosts and declared hosts
584
+ def known_hosts
585
+ ([canonical_host] + canonical_aliases + canonical_exclude_hosts + @hosts.keys)
586
+ .compact.map { |name| normalize_host(name) }.uniq
587
+ end
588
+
589
+ # Resolves the effective policy for a request host, applying environment and fail-closed rules.
590
+ # Defaults when a known host was not declared with {#host}: the canonical host and excluded hosts
591
+ # are indexable with a sitemap; aliases are crawlable (so bots see the canonical link) without a sitemap.
592
+ # @param name [String] request host (port is ignored)
593
+ # @return [HostPolicy]
594
+ def host_policy(name)
595
+ key = normalize_host(name)
596
+ always = (allow_exceptions + share_prefixes).uniq
597
+ return HostPolicy.fail_closed(key, allow: always) unless known_hosts.include?(key)
598
+
599
+ declared = @hosts[key] || default_host_options(key)
600
+ indexable = declared[:indexable] && indexable_environment?
601
+ HostPolicy.new(host: key, indexable: indexable, disallow: declared[:disallow].dup.freeze,
602
+ allow: (always + declared[:allow]).uniq.freeze,
603
+ sitemap: declared[:sitemap] && indexable, known: true)
604
+ end
605
+
606
+ # Folds any URL onto its canonical form: alias and unknown hosts -> {#canonical_origin}; excluded hosts
607
+ # and hosts declared with {#host} (that are not aliases) keep their own host; query strings dropped except {#canonical_keep_params}; fragments dropped;
608
+ # non-ASCII path characters percent-encoded.
609
+ # @param url_or_path [String] absolute URL or path ("/a?b=1")
610
+ # @return [String]
611
+ def canonical_url(url_or_path)
612
+ raise ConfigurationError, "canonical_origin is not configured" unless canonical_origin
613
+
614
+ uri = URI.parse(encode_non_ascii(url_or_path.to_s))
615
+ host = uri.host&.downcase
616
+ origin = host && self_canonical_hosts.include?(host) ? "#{canonical_uri.scheme}://#{host}" : canonical_origin
617
+ path = uri.path.to_s.empty? ? "/" : uri.path
618
+ query = kept_query(uri.query)
619
+ query ? "#{origin}#{path}?#{query}" : "#{origin}#{path}"
620
+ end
621
+
622
+ # @return [String] absolute origin for a host: canonical origin for the canonical host, else https://host
623
+ def origin_for(name)
624
+ key = normalize_host(name)
625
+ key == canonical_host ? canonical_origin : "#{canonical_uri&.scheme || "https"}://#{key}"
626
+ end
627
+
628
+ # @return [String] the current environment name
629
+ def environment_name
630
+ value = environment.respond_to?(:call) ? environment.call : environment
631
+ value ||= Rails.env if defined?(Rails) && Rails.respond_to?(:env)
632
+ (value || ENV["RACK_ENV"] || "development").to_s
633
+ end
634
+
635
+ # @return [Boolean] false outside {#indexable_environments}: every host is noindex + Disallow: /
636
+ def indexable_environment?
637
+ indexable_environments.map(&:to_s).include?(environment_name)
638
+ end
639
+
640
+ # Default territory per language for Open Graph (+og:locale+ is language_TERRITORY).
641
+ OG_TERRITORIES = { "ko" => "KR", "en" => "US", "ja" => "JP", "zh" => "CN", "de" => "DE", "fr" => "FR",
642
+ "es" => "ES", "it" => "IT", "pt" => "BR", "ru" => "RU", "vi" => "VN", "th" => "TH",
643
+ "id" => "ID", "nl" => "NL", "pl" => "PL", "tr" => "TR", "ar" => "SA", "hi" => "IN" }.freeze
644
+
645
+ # The one locale rule for +<html lang>+, +WebSite.inLanguage+ and +og:locale+: the request's +I18n.locale+
646
+ # only when {#locales} lists it, otherwise {SiteOptions#default_locale}. An app that does not use I18n
647
+ # (+I18n.locale+ stays +:en+) keeps its configured language.
648
+ # @param current [Symbol, String, nil] usually +I18n.locale+
649
+ # @return [Symbol]
650
+ def request_locale(current)
651
+ names = Array(locales).map(&:to_sym)
652
+ current && names.include?(current.to_sym) ? current.to_sym : site.default_locale.to_sym
653
+ end
654
+
655
+ # @param locale [Symbol, String, nil] +:ko+, +:"ko-KR"+, +"en_GB"+
656
+ # @return [String, nil] +ko_KR+ / +en_GB+; a bare language gets its default territory ({OG_TERRITORIES}),
657
+ # nil when unknown
658
+ def og_locale(locale)
659
+ language, region = locale.to_s.split(/[-_]/, 2)
660
+ return nil if language.nil? || language.empty?
661
+
662
+ region ||= OG_TERRITORIES[language.downcase]
663
+ region && "#{language.downcase}_#{region.upcase}"
664
+ end
665
+
666
+ # @return [Symbol, nil]
667
+ def effective_x_default_locale
668
+ x_default_locale || (locales.empty? ? nil : site.default_locale)
669
+ end
670
+
671
+ # @param title [String]
672
+ # @return [String] title rendered through {#title_template}; the bare title when site.name is unset
673
+ def format_title(title)
674
+ return title.to_s if site.name.nil? || site.name.to_s.empty? || title.to_s == site.name.to_s
675
+
676
+ format(title_template, title: title, site: site.name)
677
+ end
678
+
679
+ # @param text [String, nil]
680
+ # @return [String, nil] text truncated on a word boundary to {#description_max_length} characters
681
+ def truncate_description(text)
682
+ return nil if text.nil?
683
+
684
+ squished = text.to_s.gsub(/\s+/, " ").strip
685
+ return squished if squished.length <= description_max_length
686
+
687
+ cut = squished[0, description_max_length - 1]
688
+ cut = cut.sub(/\s+\S*\z/, "") if cut.include?(" ")
689
+ "#{cut}…"
690
+ end
691
+
692
+ # @return [Hash{Symbol => Boolean}] :search, :ai_input, :ai_train
693
+ def content_signals
694
+ return content_signal.to_h.transform_keys(&:to_sym) if content_signal
695
+
696
+ { search: crawlers.search == :allow, ai_input: crawlers.user == :allow, ai_train: crawlers.training == :allow }
697
+ end
698
+
699
+ # @return [String] Cloudflare Content-Signal value, e.g. "search=yes, ai-input=yes, ai-train=no"
700
+ def content_signal_value
701
+ signals = content_signals
702
+ { "search" => :search, "ai-input" => :ai_input, "ai-train" => :ai_train }
703
+ .map { |name, key| "#{name}=#{signals[key] ? "yes" : "no"}" }.join(", ")
704
+ end
705
+
706
+ # @return [String, nil] AIPREF Content-Usage value (experimental), nil when aipref is disabled
707
+ def content_usage_value
708
+ return nil unless aipref.enabled
709
+
710
+ signals = content_signals
711
+ "train-ai=#{signals[:ai_train] ? "y" : "n"}, search=#{signals[:search] ? "y" : "n"}"
712
+ end
713
+
714
+ # True when +path+ is declared noindex through {#noindex_paths}. Percent-encoded paths are
715
+ # decoded first, so "/%EA%B0%80" matches a declared "/가". Query strings are ignored.
716
+ # @param path [String] path or absolute URL
717
+ # @return [Boolean]
718
+ def noindex_path?(path)
719
+ PathMatch.any?(Array(noindex_paths), path)
720
+ end
721
+
722
+ # @return [Array<String>] every problem found; empty when valid
723
+ def errors
724
+ messages = []
725
+ messages.concat(origin_errors)
726
+ messages << "unknown_host must be one of #{UNKNOWN_HOST_MODES.inspect}" unless UNKNOWN_HOST_MODES.include?(unknown_host)
727
+ (allow_exceptions + share_prefixes).each do |prefix|
728
+ messages << "path prefix #{prefix.inspect} must start with /" unless prefix.to_s.start_with?("/")
729
+ end
730
+ @hosts.each do |name, options|
731
+ (options[:disallow] + options[:allow]).each do |prefix|
732
+ messages << "host #{name.inspect}: path prefix #{prefix.inspect} must start with /" unless prefix.to_s.start_with?("/")
733
+ end
734
+ end
735
+ messages.concat(locale_errors)
736
+ Array(noindex_paths).each do |pattern|
737
+ next if pattern.is_a?(Regexp) || (pattern.is_a?(String) && pattern.start_with?("/"))
738
+
739
+ messages << "noindex_paths entry #{pattern.inspect} must be a path String starting with / or a Regexp"
740
+ end
741
+ messages << "title_template must contain %{title}" unless title_template.to_s.include?("%{title}")
742
+ unless description_max_length.is_a?(Integer) && description_max_length >= 50
743
+ messages << "description_max_length must be an Integer >= 50"
744
+ end
745
+ unless cache_control_seconds.is_a?(Integer) && !cache_control_seconds.negative?
746
+ messages << "cache_control_seconds must be a non-negative Integer"
747
+ end
748
+ messages << "hreflang_url must respond to #call" unless hreflang_url.nil? || hreflang_url.respond_to?(:call)
749
+ messages.concat(crawlers.errors, markdown.errors, indexnow.errors, og_image.errors, llms_txt.errors)
750
+ end
751
+
752
+ # @raise [ConfigurationError] listing every problem
753
+ # @return [self]
754
+ def validate!
755
+ problems = errors
756
+ raise ConfigurationError, "invalid seo_geo configuration:\n- #{problems.join("\n- ")}" unless problems.empty?
757
+
758
+ self
759
+ end
760
+
761
+ private
762
+
763
+ def canonical_uri
764
+ canonical_origin && URI.parse(canonical_origin)
765
+ rescue URI::InvalidURIError
766
+ nil
767
+ end
768
+
769
+ def normalize_host(name)
770
+ name.to_s.downcase.strip.sub(/:\d+\z/, "")
771
+ end
772
+
773
+ # Hosts that are their own canonical: excluded hosts plus declared hosts that are not aliases.
774
+ def self_canonical_hosts
775
+ aliases = canonical_aliases.map { |name| normalize_host(name) }
776
+ (canonical_exclude_hosts.map { |name| normalize_host(name) } + @hosts.keys) - aliases - [canonical_host]
777
+ end
778
+
779
+ # Options for a known host without its own +host(...)+ declaration. An alias serves the canonical site's
780
+ # pages (folded canonical), so it inherits the canonical host's +indexable+, +disallow+ and +allow+: otherwise
781
+ # robots.txt on +example.com+ would open +/admin+ (or the whole site) that +www.example.com+ closes. Aliases never
782
+ # serve a sitemap.
783
+ def default_host_options(key)
784
+ if key == canonical_host || canonical_exclude_hosts.map { |name| normalize_host(name) }.include?(key)
785
+ { indexable: true, disallow: [], allow: [], sitemap: true }
786
+ elsif canonical_aliases.map { |name| normalize_host(name) }.include?(key) && @hosts[canonical_host]
787
+ canonical = @hosts[canonical_host]
788
+ { indexable: canonical[:indexable], disallow: canonical[:disallow], allow: canonical[:allow], sitemap: false }
789
+ else
790
+ { indexable: true, disallow: [], allow: [], sitemap: false }
791
+ end
792
+ end
793
+
794
+ def kept_query(query)
795
+ return nil if query.nil? || canonical_keep_params.empty?
796
+
797
+ kept = query.split("&").select { |pair| canonical_keep_params.map(&:to_s).include?(pair.split("=", 2).first) }
798
+ kept.empty? ? nil : kept.join("&")
799
+ end
800
+
801
+ def encode_non_ascii(value)
802
+ value.gsub(/[^\x00-\x7F]+/) { |chunk| ERB::Util.url_encode(chunk) }
803
+ end
804
+
805
+ def origin_errors
806
+ return ["canonical_origin is required (e.g. \"https://www.example.com\")"] if canonical_origin.nil?
807
+
808
+ uri = canonical_uri
809
+ if uri.nil? || !%w[http https].include?(uri.scheme) || uri.host.nil?
810
+ ["canonical_origin must be an absolute http(s) URL (got #{canonical_origin.inspect})"]
811
+ elsif !uri.path.to_s.empty? || uri.query || uri.fragment
812
+ ["canonical_origin must not contain a path, query or fragment (got #{canonical_origin.inspect})"]
813
+ else
814
+ []
815
+ end
816
+ end
817
+
818
+ def locale_errors
819
+ return [] if locales.empty?
820
+
821
+ messages = []
822
+ names = locales.map(&:to_sym)
823
+ unless names.include?(site.default_locale.to_sym)
824
+ messages << "site.default_locale #{site.default_locale.inspect} must be listed in locales"
825
+ end
826
+ if x_default_locale && !names.include?(x_default_locale.to_sym)
827
+ messages << "x_default_locale #{x_default_locale.inspect} must be listed in locales"
828
+ end
829
+ messages
830
+ end
831
+ end
832
+ end