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,22 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <style>
6
+ html, body { margin: 0; width: <%= width %>px; height: <%= height %>px; }
7
+ body {
8
+ box-sizing: border-box; padding: 72px 84px; display: flex; flex-direction: column; justify-content: flex-end;
9
+ background: #f5f5f7; color: #111; font-family: -apple-system, "Segoe UI", "Noto Sans KR", "Apple SD Gothic Neo", sans-serif;
10
+ word-break: keep-all; overflow-wrap: break-word;
11
+ }
12
+ .site { position: absolute; top: 72px; left: 84px; font-size: 32px; font-weight: 600; color: #555; }
13
+ h1 { margin: 0; font-size: 72px; line-height: 1.15; text-wrap: balance; }
14
+ p { margin: 24px 0 0; font-size: 34px; line-height: 1.4; color: #555; text-wrap: pretty; }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <% if site.name %><div class="site"><%= h(site.name) %></div><% end %>
19
+ <h1><%= h(page&.heading || page&.title || site.name || key) %></h1>
20
+ <% if page&.description %><p><%= h(page.description) %></p><% end %>
21
+ </body>
22
+ </html>
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module SeoGeo
6
+ module OgImage
7
+ # Built-in backend: renders an ERB template to HTML and screenshots it with headless Chrome via
8
+ # the optional ferrum gem. Add +gem "ferrum"+ and a Chrome/Chromium binary to use it.
9
+ #
10
+ # Template locals: +key+, +page+ (or nil), +params+, +site+ (SiteOptions), +width+, +height+.
11
+ # Everything interpolated must go through +h(...)+ — the template is HTML.
12
+ class FerrumBackend
13
+ DEFAULT_TEMPLATE = File.expand_path("card.html.erb", __dir__)
14
+
15
+ # @param config [Configuration]
16
+ # @param browser_options [Hash] extra options for +Ferrum::Browser.new+
17
+ def initialize(config: SeoGeo.config, browser_options: {})
18
+ @config = config
19
+ @browser_options = browser_options
20
+ end
21
+
22
+ # @return [String] PNG bytes
23
+ def call(key, page, params)
24
+ require "ferrum"
25
+ width = @config.og_image.width
26
+ height = @config.og_image.height
27
+ browser = Ferrum::Browser.new({ headless: true, window_size: [width, height], timeout: 15 }
28
+ .merge(@browser_options))
29
+ begin
30
+ # window_size includes browser chrome; the viewport must be exactly width x height.
31
+ browser.resize(width: width, height: height)
32
+ browser.go_to("about:blank")
33
+ browser.main_frame.content = html(key, page, params)
34
+ wait_for_assets(browser)
35
+ browser.screenshot(format: "png", encoding: :binary)
36
+ ensure
37
+ browser.quit
38
+ end
39
+ end
40
+
41
+ # Web fonts and images referenced by the template; a slow asset must not fail the card.
42
+ def wait_for_assets(browser)
43
+ browser.network.wait_for_idle(timeout: 5)
44
+ rescue Ferrum::TimeoutError
45
+ nil
46
+ end
47
+
48
+ # @return [String] the rendered HTML card
49
+ def html(key, page, params)
50
+ Template.new(File.read(@config.og_image.template || DEFAULT_TEMPLATE)).render(
51
+ key: key, page: page, params: params, site: @config.site,
52
+ width: @config.og_image.width, height: @config.og_image.height
53
+ )
54
+ end
55
+
56
+ # Minimal ERB context exposing locals and +h+.
57
+ class Template
58
+ include ERB::Util
59
+
60
+ def initialize(source)
61
+ @erb = ERB.new(source, trim_mode: "-")
62
+ end
63
+
64
+ def render(locals)
65
+ context = binding
66
+ locals.each { |name, value| context.local_variable_set(name, value) }
67
+ @erb.result(context)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module SeoGeo
7
+ # Open Graph images served at +GET /og/:key.png+.
8
+ #
9
+ # A backend is any callable +(key, page, params) -> String+ returning PNG bytes (a Satori
10
+ # sidecar, a vips composer, ...). Without one, the bundled {FerrumBackend} renders an ERB
11
+ # card when the optional ferrum gem is installed. Results are cached in +Rails.cache+ for
12
+ # +og_image.cache_ttl+ under a key that includes +og_image.template_version+; failures are never cached.
13
+ #
14
+ # Every cache miss costs a render (with ferrum, a Chrome launch), so the controller serves only
15
+ # {.resolve_key known keys} and {.validated_params constrained params}, and at most
16
+ # +og_image.max_concurrent+ renders run at once per process.
17
+ module OgImage
18
+ # Raised when no backend can render, or a backend returns something that is not a PNG.
19
+ class RenderError < StandardError; end
20
+ # Raised on a cache miss while +og_image.max_concurrent+ renders are already running.
21
+ class Busy < RenderError; end
22
+
23
+ SLOTS = Mutex.new
24
+ @active_renders = 0
25
+
26
+ PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
27
+ KEY_FORMAT = /\A[a-zA-Z0-9_-]{1,100}\z/
28
+
29
+ module_function
30
+
31
+ # @param key [String]
32
+ # @param page [Page, nil] page copy for +key+ when one is defined
33
+ # @param params [Hash{String => String}] permitted query parameters
34
+ # @param config [Configuration]
35
+ # @param cache [#fetch, nil] cache store; nil disables caching
36
+ # @raise [RenderError]
37
+ # @return [String] PNG bytes (binary)
38
+ # @raise [Busy] when every render slot is taken
39
+ def render(key, page: nil, params: {}, config: SeoGeo.config, cache: default_cache)
40
+ produce = -> { with_render_slot(config.og_image.max_concurrent) { render_uncached(key, page, params, config) } }
41
+ return produce.call if cache.nil?
42
+
43
+ cache.fetch(cache_key(key, page, params, config), expires_in: config.og_image.cache_ttl) { produce.call }
44
+ end
45
+
46
+ # Route constraint for +GET /og/:key.png+. The engine is mounted at "/" before or after the app's routes,
47
+ # so a request it does not want must not match at all (a controller 404 does not cascade): +/og/alice.png+
48
+ # reaches the app's own +/og/:username.png+ unless OG images are enabled and +alice+ is a known key.
49
+ # The resolved value is kept in +env+ for the controller, so +key_resolver+ runs once per request.
50
+ #
51
+ # A +key_resolver+ that raises claims the request (+true+) and leaves the error in +env+: the controller then
52
+ # answers with the configured fallback (redirect to +og_image.fallback_url+, else 404) and logs once. Falling
53
+ # through instead would hand a resolver outage to whatever app route matches next — a 404/500 that preview bots
54
+ # cache, or the wrong image — and skip +fallback_url+ entirely.
55
+ module RouteConstraint
56
+ ENV_KEY = "seo_geo.og_key"
57
+ ERROR_KEY = "seo_geo.og_key_error"
58
+
59
+ module_function
60
+
61
+ # @param request [ActionDispatch::Request]
62
+ # @return [Boolean]
63
+ def matches?(request)
64
+ config = SeoGeo.config
65
+ key = request.path_parameters[:key].to_s
66
+ return false unless config.og_image.enabled? && KEY_FORMAT.match?(key)
67
+
68
+ resolved = OgImage.resolve_key(key, config)
69
+ request.env[ENV_KEY] = resolved if resolved
70
+ resolved ? true : false
71
+ rescue StandardError => e
72
+ request.env[ERROR_KEY] = e
73
+ true
74
+ end
75
+ end
76
+
77
+ # @param key [String]
78
+ # @return [Page, true, nil] truthy when +key+ is a {SeoGeo.pages} key, listed in +og_image.keys+ or
79
+ # accepted by +og_image.key_resolver+ (whose {Page} is returned); nil => 404
80
+ def resolve_key(key, config)
81
+ return true if config.og_image.keys.any? { |name| name.to_s == key }
82
+ return true if SeoGeo.pages.keys.any? { |name| name.to_s == key }
83
+
84
+ config.og_image.key_resolver&.call(key) || nil
85
+ end
86
+
87
+ # @param query [Hash] request query parameters
88
+ # @return [Hash{String => String}, nil] permitted params that carry a constraint; nil when any value
89
+ # is not a String, is longer than +og_image.max_param_length+ or fails its constraint
90
+ def validated_params(query, config)
91
+ options = config.og_image
92
+ constraints = options.param_constraints.transform_keys(&:to_s)
93
+ names = options.permitted_params.map(&:to_s).select { |name| constraints.key?(name) }
94
+ params = query.to_h.transform_keys(&:to_s).slice(*names)
95
+ valid = params.all? do |name, value|
96
+ value.is_a?(String) && value.length <= options.max_param_length && allowed_value?(constraints[name], value)
97
+ end
98
+ valid ? params : nil
99
+ end
100
+
101
+ # @return [Boolean] whole-value match for a Regexp, membership for an Array. A Regexp that exceeds
102
+ # {SafeRegexp::TIMEOUT} rejects the value (=> 400) instead of holding the worker.
103
+ def allowed_value?(rule, value)
104
+ return rule.map(&:to_s).include?(value) if rule.is_a?(Array)
105
+
106
+ match = SafeRegexp.match(rule, value)
107
+ !match.nil? && match.begin(0).zero? && match.end(0) == value.length
108
+ rescue Regexp::TimeoutError, SafeRegexp::TooLong
109
+ false
110
+ end
111
+
112
+ # Runs the block in one of +limit+ process-wide slots; never waits.
113
+ # @raise [Busy] when all slots are taken
114
+ def with_render_slot(limit)
115
+ acquired = SLOTS.synchronize do
116
+ next false if OgImage.active_renders >= limit
117
+
118
+ OgImage.active_renders += 1
119
+ true
120
+ end
121
+ raise Busy, "#{limit} OG image renders already running" unless acquired
122
+
123
+ begin
124
+ yield
125
+ ensure
126
+ SLOTS.synchronize { OgImage.active_renders -= 1 }
127
+ end
128
+ end
129
+
130
+ class << self
131
+ # @return [Integer] renders running right now in this process
132
+ attr_accessor :active_renders
133
+ end
134
+
135
+ # @return [String] cache key covering template version, key, locale and permitted params
136
+ def cache_key(key, page, params, config)
137
+ digest = Digest::SHA256.hexdigest(JSON.generate(params.to_h.sort))[0, 16]
138
+ ["seo_geo/og", config.og_image.template_version, key, page&.locale, digest].compact.join("/")
139
+ end
140
+
141
+ # @return [#call, nil] the configured backend, else ferrum when available
142
+ def backend(config)
143
+ config.og_image.backend || (config.og_image.ferrum_available? ? FerrumBackend.new(config: config) : nil)
144
+ end
145
+
146
+ # @return [#fetch, nil] Rails.cache when Rails is loaded
147
+ def default_cache
148
+ defined?(Rails) && Rails.respond_to?(:cache) ? Rails.cache : nil
149
+ end
150
+
151
+ def render_uncached(key, page, params, config)
152
+ renderer = backend(config)
153
+ raise RenderError, "no og_image.backend configured and the ferrum gem is not available" if renderer.nil?
154
+
155
+ bytes = renderer.call(key, page, params)
156
+ unless bytes.is_a?(String) && bytes.b.start_with?(PNG_SIGNATURE)
157
+ raise RenderError, "og_image backend returned #{bytes.class} instead of PNG bytes"
158
+ end
159
+
160
+ bytes.b
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module SeoGeo
6
+ # The single source of page copy. The same object feeds the crawler fallback HTML, JSON-LD
7
+ # (FAQPage must match visible text) and Inertia props, so the three can never drift.
8
+ #
9
+ # @!attribute key [Symbol]
10
+ # @!attribute locale [Symbol, nil]
11
+ # @!attribute title [String, nil]
12
+ # @!attribute description [String, nil]
13
+ # @!attribute heading [String, nil] visible H1
14
+ # @!attribute intro [String, Array<String>, nil] lead paragraph, or several ({#intro_paragraphs})
15
+ # @!attribute features [Array<Hash{Symbol => String}>] each +{title:, description:}+
16
+ # @!attribute faq [Array<Hash{Symbol => String}>] each +{question:, answer:}+
17
+ # @!attribute extra [Hash{Symbol => Object}] any other keys from the source, passed through to {#props}
18
+ Page = Data.define(:key, :locale, :title, :description, :heading, :intro, :features, :faq, :extra) do
19
+ # Builds a page from a loosely shaped hash (YAML, I18n or Ruby). Accepts +q+/+a+ as FAQ aliases
20
+ # and +name+/+body+ as feature aliases.
21
+ # @param key [Symbol, String]
22
+ # @param data [Hash]
23
+ # @param locale [Symbol, nil]
24
+ # @return [Page]
25
+ def self.from_hash(key, data, locale: nil)
26
+ data = deep_copy(deep_symbolize(data || {}), freeze: true)
27
+ known = %i[title description heading intro features faq]
28
+ new(key: key.to_sym, locale: locale&.to_sym,
29
+ title: data[:title], description: data[:description], heading: data[:heading], intro: data[:intro],
30
+ features: Array(data[:features]).map { |item| normalize_feature(item) }.freeze,
31
+ faq: Array(data[:faq]).map { |item| normalize_faq(item) }.freeze,
32
+ extra: data.except(*known).freeze)
33
+ end
34
+
35
+ def self.deep_symbolize(value)
36
+ case value
37
+ when Hash then value.to_h { |name, inner| [name.to_sym, deep_symbolize(inner)] }
38
+ when Array then value.map { |inner| deep_symbolize(inner) }
39
+ else value
40
+ end
41
+ end
42
+
43
+ # @api private
44
+ # Copies Hash/Array/String nodes so a Page never shares mutable state with its source or its callers.
45
+ # @param value [Object]
46
+ # @param freeze [Boolean] freeze every copied node (registry storage) or leave them mutable ({#props})
47
+ # @return [Object]
48
+ def self.deep_copy(value, freeze:)
49
+ copy = case value
50
+ when Hash then value.to_h { |name, inner| [deep_copy(name, freeze: freeze), deep_copy(inner, freeze: freeze)] }
51
+ when Array then value.map { |inner| deep_copy(inner, freeze: freeze) }
52
+ when String then value.dup
53
+ else return value
54
+ end
55
+ freeze ? copy.freeze : copy
56
+ end
57
+
58
+ def self.normalize_feature(item)
59
+ return { title: item.to_s, description: nil }.freeze unless item.is_a?(Hash)
60
+
61
+ { title: item[:title] || item[:name], description: item[:description] || item[:body] }.freeze
62
+ end
63
+
64
+ def self.normalize_faq(item)
65
+ raise ConfigurationError, "faq items must be hashes with question/answer" unless item.is_a?(Hash)
66
+
67
+ { question: item[:question] || item[:q], answer: item[:answer] || item[:a] }.freeze
68
+ end
69
+ private_class_method :deep_symbolize, :normalize_feature, :normalize_faq
70
+
71
+ # @return [Array<String>] +intro+ as paragraphs (a String is one paragraph; blanks dropped)
72
+ def intro_paragraphs
73
+ Array(intro).map(&:to_s).reject { |line| line.strip.empty? }
74
+ end
75
+
76
+ # Shape (all keys always present, Symbol keys; +extra+ keys merged at the top level):
77
+ # { key: Symbol, locale: Symbol | nil, title: String | nil, description: String | nil, heading: String | nil,
78
+ # intro: String | Array<String> | nil (as written in the source),
79
+ # features: [{ title:, description: }], faq: [{ question:, answer: }], **extra }
80
+ # Sources may write FAQ items as +q+/+a+ and features as +name+/+body+; props always carry the canonical
81
+ # +question+/+answer+ and +title+/+description+.
82
+ # @return [Hash{Symbol => Object}] detached, mutable deep copy for Inertia props / JSON APIs — changing it
83
+ # never reaches the registry or other requests
84
+ def props
85
+ Page.deep_copy({ key: key, locale: locale, title: title, description: description, heading: heading,
86
+ intro: intro, features: features, faq: faq }.merge(extra), freeze: false)
87
+ end
88
+
89
+ # Markdown rendering of the copy, used for Markdown negotiation on client-rendered pages.
90
+ # @return [String]
91
+ def to_markdown
92
+ parts = []
93
+ parts << "# #{heading || title}" if heading || title
94
+ parts.concat(intro_paragraphs)
95
+ unless features.empty?
96
+ parts << "## Features"
97
+ parts << features.map { |item| item[:description] ? "- **#{item[:title]}**: #{item[:description]}" : "- #{item[:title]}" }.join("\n")
98
+ end
99
+ unless faq.empty?
100
+ parts << "## FAQ"
101
+ faq.each { |item| parts << "### #{item[:question]}\n\n#{item[:answer]}" }
102
+ end
103
+ "#{parts.join("\n\n")}\n"
104
+ end
105
+ end
106
+
107
+ # Loads {Page}s from three interchangeable sources, in this precedence: Ruby hash, I18n scope, YAML file.
108
+ #
109
+ # YAML files live at +config/seo_geo/pages/<key>.yml+. A file is either a flat attribute hash or a
110
+ # hash keyed by locale (+ko:+, +en:+), in which case the requested locale (or site.default_locale) is used.
111
+ #
112
+ # @example
113
+ # SeoGeo.pages.register(:pricing, title: "Pricing", faq: [{ q: "Free?", a: "Yes" }])
114
+ # SeoGeo.pages.register_i18n(:login, scope: "views.login.seo", faq: :numbered) # q1_q / q1_a ...
115
+ # SeoGeo.page(:home).props
116
+ class PageRegistry
117
+ # @return [Array<String>] directories searched for +<key>.yml+
118
+ attr_accessor :paths
119
+
120
+ def initialize
121
+ @paths = []
122
+ @hashes = {}
123
+ @i18n = {}
124
+ end
125
+
126
+ # @param key [Symbol, String]
127
+ # @param data [Hash] page attributes, or a hash keyed by locale
128
+ # @return [void]
129
+ def register(key, data = nil, **attributes)
130
+ @hashes[key.to_sym] = Page.deep_copy(data || attributes, freeze: true)
131
+ end
132
+
133
+ # @param key [Symbol, String]
134
+ # @param scope [String] I18n scope holding title/description/heading/intro/features/faq
135
+ # @param faq [Symbol] :list (an array under +faq+) or :numbered (+q1_q+/+q1_a+, +q2_q+/... keys)
136
+ # @return [void]
137
+ def register_i18n(key, scope:, faq: :list)
138
+ raise ConfigurationError, "faq: must be :list or :numbered" unless %i[list numbered].include?(faq)
139
+
140
+ @i18n[key.to_sym] = { scope: scope, faq: faq }
141
+ end
142
+
143
+ # @param key [Symbol, String]
144
+ # @param locale [Symbol, String, nil] nil => I18n.locale when available, else site.default_locale
145
+ # @raise [KeyError] when no source defines the page
146
+ # @return [Page]
147
+ def fetch(key, locale: nil)
148
+ key = key.to_sym
149
+ locale = (locale || current_locale).to_sym
150
+ data = if @hashes.key?(key) then localized(@hashes[key], locale)
151
+ elsif @i18n.key?(key) then from_i18n(@i18n[key], locale)
152
+ else from_yaml(key, locale)
153
+ end
154
+ raise KeyError, "seo_geo page #{key.inspect} is not defined (looked in Ruby, I18n and #{paths.inspect})" if data.nil?
155
+
156
+ Page.from_hash(key, data, locale: locale)
157
+ end
158
+
159
+ # @return [Array<Symbol>] every key known to any source
160
+ def keys
161
+ yaml_keys = paths.flat_map { |dir| Dir[File.join(dir, "*.yml")].map { |file| File.basename(file, ".yml").to_sym } }
162
+ (@hashes.keys + @i18n.keys + yaml_keys).uniq
163
+ end
164
+
165
+ # @return [void]
166
+ def clear
167
+ @hashes.clear
168
+ @i18n.clear
169
+ end
170
+
171
+ private
172
+
173
+ # The request-locale rule ({Configuration#request_locale}) shared with +<html lang>+ and +og:locale+, so a
174
+ # default fetch ({SeoGeo.page}, +seo_geo_page+, the crawler fallback) picks the same copy as the head.
175
+ def current_locale
176
+ SeoGeo.config.request_locale(defined?(I18n) && I18n.respond_to?(:locale) ? I18n.locale : nil)
177
+ end
178
+
179
+ def localized(data, locale)
180
+ data = Page.send(:deep_symbolize, data)
181
+ return data unless data.keys.any? { |name| name.to_s.match?(/\A[a-z]{2}(-[A-Za-z]{2,4})?\z/) && data[name].is_a?(Hash) }
182
+
183
+ data[locale] || data[SeoGeo.config.site.default_locale.to_sym]
184
+ end
185
+
186
+ def from_yaml(key, locale)
187
+ file = paths.map { |dir| File.join(dir, "#{key}.yml") }.find { |path| File.file?(path) }
188
+ file && localized(YAML.safe_load_file(file, aliases: true) || {}, locale)
189
+ end
190
+
191
+ def from_i18n(source, locale)
192
+ raw = I18n.t(source[:scope], locale: locale, default: nil)
193
+ return nil unless raw.is_a?(Hash)
194
+ return raw if source[:faq] == :list
195
+
196
+ numbered = raw.keys.filter_map { |name| name.to_s[/\Aq(\d+)_q\z/, 1]&.to_i }.sort
197
+ raw.reject { |name, _| name.to_s.match?(/\Aq\d+_[qa]\z/) }
198
+ .merge(faq: numbered.map { |n| { question: raw[:"q#{n}_q"], answer: raw[:"q#{n}_a"] } })
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ # Declaration matching shared by {Configuration#noindex_path?} and {Sitemap::Registry} (+noindex?+,
5
+ # +index?+, +excluded?+). Paths and String declarations are compared as {RobotsPath.path} (query dropped,
6
+ # RFC 9309 normalization), so +/%61dmin+, +/admin?x=1+ and +/admin+ are one path.
7
+ module PathMatch
8
+ module_function
9
+
10
+ # @param value [String] path or absolute URL, raw or percent-encoded
11
+ # @return [Array<String>] the normalized path and, when it differs, its fully decoded UTF-8 form
12
+ # (the forms a declaration Regexp is matched against)
13
+ def variants(value)
14
+ [RobotsPath.path(value), RobotsPath.decoded(value)].compact.uniq
15
+ end
16
+
17
+ # @param patterns [Array<String, Regexp>] exact paths (normalized like the candidate) or regexps
18
+ # @param path [String]
19
+ # @return [Boolean] a Regexp that times out ({SafeRegexp}) counts as a match: every declaration
20
+ # restricts publication, so failing safe means restricting
21
+ def any?(patterns, path)
22
+ return false if patterns.nil? || patterns.empty?
23
+
24
+ plain = RobotsPath.path(path)
25
+ candidates = nil
26
+ patterns.any? do |pattern|
27
+ if pattern.is_a?(Regexp)
28
+ candidates ||= variants(path)
29
+ candidates.any? { |candidate| SafeRegexp.match?(pattern, candidate, on_timeout: true) }
30
+ else
31
+ RobotsPath.path(pattern) == plain
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ # One answer to "may this path be published to crawlers on this host?". sitemap.xml, llms.txt,
5
+ # llms-full.txt and the head's robots meta all ask it, so they cannot disagree.
6
+ #
7
+ # A path is publishable on a host when all hold:
8
+ # - the host serves a sitemap ({HostPolicy#sitemap}: declared/canonical, indexable, indexable environment);
9
+ # - robots.txt does not disallow it for the host (RFC 9309 longest match over the host's
10
+ # +allow+/+disallow+ prefixes, Allow wins a tie);
11
+ # - it is not excluded with {Sitemap::Registry#exclude};
12
+ # - it is not declared noindex ({Configuration#noindex_paths}, +sitemap.add path, noindex: true+);
13
+ # - under {Configuration#default_noindex}, it is declared indexable (+sitemap.add path, index: true+ —
14
+ # the registry counterpart of a controller's +seo index: true+).
15
+ #
16
+ # Request-time +seo noindex: true+ is invisible here; +rake seo_geo:audit+ reports such pages
17
+ # (+sitemap.noindex+).
18
+ module Publication
19
+ module_function
20
+
21
+ # @param host [String] request host
22
+ # @param path [String] path or absolute URL; robots rules see the {RobotsPath.target} (query kept),
23
+ # declarations the {RobotsPath.path}. A +.md+ URL is judged as the page it is the Markdown of — callers
24
+ # pass {RobotsPath.html_path} for those.
25
+ # @param config [Configuration]
26
+ # @param sitemap [Sitemap::Registry]
27
+ # @param check_host [Boolean] false skips the host sitemap check (callers that already did it)
28
+ # @param declared_index [Boolean] true when the caller already knows the entry was added with
29
+ # +index: true+ (sitemap.xml, whose Registry#each_url only yields such entries under default_noindex)
30
+ # @return [Boolean]
31
+ def publishable?(host, path, config: SeoGeo.config, sitemap: SeoGeo.sitemap, check_host: true, declared_index: false)
32
+ policy = config.host_policy(host)
33
+ return false if check_host && !policy.sitemap
34
+ return false if robots_blocked?(policy, path)
35
+ return false if sitemap.excluded?(path)
36
+ return false if declared_noindex?(path, config: config, sitemap: sitemap)
37
+
38
+ !config.default_noindex || declared_index || declared_index?(host, path, config: config, sitemap: sitemap)
39
+ end
40
+
41
+ # Head robots meta: a declared-noindex path is noindex even when the controller calls +seo index: true+.
42
+ # Declarations apply on every host (restricting is the safe side).
43
+ # @param path [String]
44
+ # @return [Boolean]
45
+ def declared_noindex?(path, config: SeoGeo.config, sitemap: SeoGeo.sitemap)
46
+ config.noindex_path?(path) || sitemap.noindex?(path)
47
+ end
48
+
49
+ # Head robots meta under +default_noindex+: an +index: true+ entry for this host covers the path — a static
50
+ # path, or a collection whose +match:+ accepts it ({Sitemap::Entry#covers?}). The same answer
51
+ # sitemap.xml and llms.txt use, so the head cannot be noindex for a listed page on one host and indexable
52
+ # on another.
53
+ # @param host [String] request host
54
+ # @param path [String]
55
+ # @return [Boolean]
56
+ def declared_index?(host, path, config: SeoGeo.config, sitemap: SeoGeo.sitemap)
57
+ sitemap.index?(path, host: host, config: config)
58
+ end
59
+
60
+ # RFC 9309 §2.2.2 over the host policy's lists, through the same matcher as {Robots::Evaluator}.
61
+ # @param policy [HostPolicy]
62
+ # @param path [String]
63
+ # @return [Boolean]
64
+ def robots_blocked?(policy, path)
65
+ RobotsPath.blocked?(policy.allow, policy.disallow, path)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ class Robots
5
+ # Reads a robots.txt body and answers "may this crawler fetch this path?" the way RFC 9309 says a
6
+ # crawler decides. Used by the test helpers and
7
+ # +rake seo_geo:audit+; it is never used for access decisions at request time.
8
+ #
9
+ # - Group choice (§2.2.1): the groups naming the crawler's product token (case-insensitive) are merged;
10
+ # only when none names it does the crawler fall back to the +*+ groups.
11
+ # - Rule choice (§2.2.2): the longest matching path wins; +Allow+ wins a tie; no matching rule => allowed.
12
+ # - Patterns: +*+ matches any sequence, a trailing +$+ anchors the end. An empty +Disallow:+ is ignored.
13
+ # Rules and paths are compared in {RobotsPath.normalize}d form: +/%61dmin+ is +/admin+, but an encoded
14
+ # reserved delimiter stays one (+/download%3Fpublic+ is not +/download?public+).
15
+ #
16
+ # @example
17
+ # rules = SeoGeo::Robots::Evaluator.new(body)
18
+ # rules.allowed?("GPTBot", "/admin") # => false
19
+ # rules.allowed?("Mozilla/5.0 (compatible; GPTBot/1.2)", "/") # full User-Agent headers work too
20
+ class Evaluator
21
+ Group = Struct.new(:agents, :rules)
22
+
23
+ # @param body [String] robots.txt contents
24
+ def initialize(body)
25
+ @groups = []
26
+ current = nil
27
+ body.to_s.each_line do |raw|
28
+ line = raw.sub(/#.*/, "").strip
29
+ next if line.empty?
30
+
31
+ field, value = line.split(":", 2).map { |part| part.to_s.strip }
32
+ case field.downcase
33
+ when "user-agent"
34
+ current = nil if current && !current.rules.empty?
35
+ current ||= Group.new([], []).tap { |group| @groups << group }
36
+ current.agents << value.downcase
37
+ when "allow", "disallow"
38
+ current&.rules&.push([field.downcase.to_sym, value])
39
+ end
40
+ end
41
+ end
42
+
43
+ # @param user_agent [String] robots.txt product token ("GPTBot", "*") or a full User-Agent header
44
+ # @param path [String] path, optionally with a query string
45
+ # @return [Boolean]
46
+ def allowed?(user_agent, path)
47
+ rule = matching_rule(user_agent, path)
48
+ rule.nil? || rule.first == :allow
49
+ end
50
+
51
+ # @param user_agent [String]
52
+ # @param path [String]
53
+ # @return [Array(Symbol, String), nil] the deciding +[:allow | :disallow, pattern]+, nil when no rule matches
54
+ def matching_rule(user_agent, path)
55
+ RobotsPath.decide(rules_for(user_agent), path.to_s.empty? ? "/" : path.to_s)
56
+ end
57
+
58
+ # @return [Array<String>] every agent token that has a group (lower case)
59
+ def agents
60
+ @groups.flat_map(&:agents)
61
+ end
62
+
63
+ # @param user_agent [String]
64
+ # @return [String] the group token the crawler obeys ("*" when no group names it)
65
+ def group_token(user_agent)
66
+ token_for(user_agent.to_s.downcase) || "*"
67
+ end
68
+
69
+ private
70
+
71
+ def rules_for(user_agent)
72
+ token = group_token(user_agent)
73
+ @groups.select { |group| group.agents.include?(token) }.flat_map(&:rules)
74
+ end
75
+
76
+ # Exact token first; otherwise the longest group token contained in a full User-Agent header.
77
+ def token_for(needle)
78
+ named = agents.uniq - ["*"]
79
+ return needle if named.include?(needle)
80
+
81
+ named.select { |token| needle.include?(token) }.max_by(&:length)
82
+ end
83
+ end
84
+ end
85
+ end