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.
- checksums.yaml +7 -0
- data/AGENTS.md +244 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE.txt +21 -0
- data/README.md +428 -0
- data/app/controllers/seo_geo/index_now_controller.rb +13 -0
- data/app/controllers/seo_geo/llms_controller.rb +29 -0
- data/app/controllers/seo_geo/og_images_controller.rb +67 -0
- data/app/controllers/seo_geo/robots_controller.rb +14 -0
- data/app/controllers/seo_geo/sitemaps_controller.rb +16 -0
- data/app/jobs/seo_geo/index_now_job.rb +21 -0
- data/app/views/seo_geo/fallback/_body.html.erb +21 -0
- data/app/views/seo_geo/fallback/_faq.html.erb +12 -0
- data/config/routes.rb +17 -0
- data/lib/generators/seo_geo/install/install_generator.rb +138 -0
- data/lib/generators/seo_geo/install/templates/home.yml.tt +14 -0
- data/lib/generators/seo_geo/install/templates/initializer.rb.tt +52 -0
- data/lib/seo_geo/audit/checks.rb +107 -0
- data/lib/seo_geo/audit/html_document.rb +132 -0
- data/lib/seo_geo/audit/report.rb +85 -0
- data/lib/seo_geo/audit/runner.rb +327 -0
- data/lib/seo_geo/audit/static_scan.rb +60 -0
- data/lib/seo_geo/audit.rb +14 -0
- data/lib/seo_geo/bot_verifier.rb +278 -0
- data/lib/seo_geo/configuration.rb +832 -0
- data/lib/seo_geo/controller.rb +72 -0
- data/lib/seo_geo/crawler_fallback.rb +45 -0
- data/lib/seo_geo/crawlers.rb +79 -0
- data/lib/seo_geo/engine.rb +123 -0
- data/lib/seo_geo/index_now.rb +187 -0
- data/lib/seo_geo/json_ld.rb +259 -0
- data/lib/seo_geo/llms_txt.rb +183 -0
- data/lib/seo_geo/markdown/document.rb +163 -0
- data/lib/seo_geo/markdown/negotiation.rb +49 -0
- data/lib/seo_geo/markdown/path_suffix.rb +72 -0
- data/lib/seo_geo/markdownable.rb +141 -0
- data/lib/seo_geo/meta.rb +382 -0
- data/lib/seo_geo/model.rb +95 -0
- data/lib/seo_geo/og_image/card.html.erb +22 -0
- data/lib/seo_geo/og_image/ferrum_backend.rb +72 -0
- data/lib/seo_geo/og_image/renderer.rb +163 -0
- data/lib/seo_geo/page.rb +201 -0
- data/lib/seo_geo/path_match.rb +36 -0
- data/lib/seo_geo/publication.rb +68 -0
- data/lib/seo_geo/robots/evaluator.rb +85 -0
- data/lib/seo_geo/robots.rb +105 -0
- data/lib/seo_geo/robots_directives.rb +170 -0
- data/lib/seo_geo/robots_path.rb +125 -0
- data/lib/seo_geo/rspec.rb +197 -0
- data/lib/seo_geo/safe_regexp.rb +50 -0
- data/lib/seo_geo/sitemap/registry.rb +239 -0
- data/lib/seo_geo/sitemap/xml.rb +138 -0
- data/lib/seo_geo/testing/environment.rb +31 -0
- data/lib/seo_geo/testing.rb +111 -0
- data/lib/seo_geo/undeclared_host.rb +40 -0
- data/lib/seo_geo/version.rb +5 -0
- data/lib/seo_geo.rb +80 -0
- data/lib/tasks/seo_geo.rake +23 -0
- data/llms.txt +27 -0
- 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
|