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
data/README.md ADDED
@@ -0,0 +1,428 @@
1
+ # seo_geo
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/seo_geo)](https://rubygems.org/gems/seo_geo) [![CI](https://github.com/seunghan91/seo_geo/actions/workflows/ci.yml/badge.svg)](https://github.com/seunghan91/seo_geo/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
4
+
5
+ SEO and GEO for Rails in one engine: one initializer and one layout line.
6
+
7
+ seo_geo handles **SEO** (search engines) and **GEO**, *Generative Engine Optimization*: being found,
8
+ read and quoted by AI search and assistants such as ChatGPT, Claude, Perplexity and Gemini. It is **not** a
9
+ geolocation, GeoIP or maps library.
10
+
11
+ Rails apps usually rewrite the same SEO code by hand, and the copies drift apart. seo_geo replaces that code
12
+ with one configuration file. It renders the head (title, description, canonical URL, hreflang, Open
13
+ Graph, JSON-LD). It serves `robots.txt`, `sitemap.xml` and `llms.txt` per host, and Markdown versions of
14
+ your pages for agents. It notifies IndexNow and audits the result. All of this follows one crawler policy
15
+ and one copy source per page, so the FAQ a crawler reads in JSON-LD is the FAQ a person sees.
16
+
17
+ ## Features
18
+
19
+ - **Head in one line** — `<%= seo_geo_head %>` emits exactly one `<title>` (through `title_template`), a
20
+ truncated meta description, a canonical URL folded onto `canonical_origin`, hreflang + `x-default`, robots
21
+ `noindex` outside production, search-console verification metas, Open Graph/Twitter tags, a
22
+ `<link rel="alternate" type="text/markdown">` and a JSON-LD `@graph` (CSP nonce aware). `seo_geo_html_lang`
23
+ fills `<html lang>`.
24
+ - **Per-request values** — `seo title:, description:, image:, noindex:, index:, canonical:, type:, json_ld:` in
25
+ controllers; `SeoGeo::JsonLd` checks the properties Google requires for each type.
26
+ - **One copy source per page** — `SeoGeo.page(:home)` / `seo_geo_page(:home)` reads
27
+ `config/seo_geo/pages/*.yml`, I18n or Ruby. The same copy feeds the head, the FAQPage JSON-LD, the visible
28
+ FAQ (`seo_geo_faq`) and Inertia props (`Page#props`).
29
+ - **Crawler fallback body** — `seo_geo_fallback_body(:home)` renders that copy as static HTML for crawlers that
30
+ do not run JavaScript on Inertia/SPA pages; `window.SeoGeo.mounted()` hides it for people.
31
+ - **Purpose-based crawler policy** — `crawlers.training`, `crawlers.search` and `crawlers.user` (user-triggered
32
+ fetches such as ChatGPT-User) are `:allow` or `:deny`. That one policy drives the robots.txt groups and the
33
+ `Content-Signal` line and header, plus AIPREF `Content-Usage` (experimental).
34
+ - **`GET /robots.txt` per host** — RFC 9309 groups that repeat every host `Disallow`. Undeclared hosts
35
+ fail closed (`Disallow: /`), and so do non-production environments.
36
+ - **`GET /sitemap.xml`** — static paths and lazy collections (`add -> { Post.published }, path: ...`), a
37
+ sitemap index above 50,000 URLs, never an invented `lastmod`.
38
+ - **`GET /llms.txt` and `/llms-full.txt`** — generated from the page copy and the sitemap. Both use the same
39
+ publication rule as sitemap.xml, so robots-blocked and noindex pages stay out.
40
+ - **Markdown for agents** — `include SeoGeo::Markdownable` answers `Accept: text/markdown` and `/page.md` URLs
41
+ with YAML frontmatter, the main content and JSON-LD, plus `Vary: Accept`.
42
+ - **IndexNow** — `SeoGeo::IndexNow.submit(urls)`, `SeoGeo::IndexNowJob` with retries, the model macro
43
+ `seo_geo_indexnow on: :publish, url: ->(post) { post_url(post) }`, and the key file at `/<key>.txt`.
44
+ - **OG images** — `GET /og/:key.png` with your own backend (or the optional `ferrum` card renderer), cached,
45
+ with a bounded number of concurrent renders and a fallback image.
46
+ - **`rake seo_geo:audit`** — fetches every host's robots.txt, sitemap.xml, llms.txt and listed pages
47
+ in-process and reports title, canonical, noindex, JSON-LD, FAQ, Markdown and shadowing problems. Exits 1 on
48
+ errors; `FORMAT=json` for CI.
49
+ - **`rails g seo_geo:install`** — initializer, sample page copy and the engine mount.
50
+ - **Test helpers** — `SeoGeo::Testing` Minitest assertions and RSpec matchers (`require "seo_geo/rspec"`)
51
+ built on the audit's checks; `with_seo_geo_environment("production") { ... }` /
52
+ `SeoGeo::Testing.with_environment` to see production robots.txt and sitemaps from a test.
53
+ - **`SeoGeo::BotVerifier`** (opt-in) — reverse DNS and published IP ranges tell you whether a request that
54
+ *claims* to be Googlebot or GPTBot really is. The gem itself never uses the User-Agent for access decisions.
55
+
56
+ <!-- example: install/Gemfile -->
57
+ ```ruby
58
+ # Gemfile
59
+ gem "seo_geo"
60
+ ```
61
+
62
+ ## Status
63
+
64
+ - Version 0.1.0, **not yet released** to RubyGems. Until then, use
65
+ `gem "seo_geo", github: "seunghan91/seo_geo"`.
66
+ - CI runs Ruby 3.2, 3.3 and 3.4 × Rails 7.2, 8.0 and 8.1 (every cell), each against an ERB app and an
67
+ Inertia-style app.
68
+ - Runtime dependencies: `railties`, `actionpack`, `meta-tags ~> 2.24` and `reverse_markdown ~> 3.0`.
69
+ `ferrum` (OG card rendering) and ActiveJob (`SeoGeo::IndexNowJob`) are optional.
70
+ - Piloted against a real Rails + Inertia (Svelte) app's codebase (four hostnames folded onto one canonical origin):
71
+ its full request spec suite and `rake seo_geo:audit` pass in a production-like configuration. Not yet deployed
72
+ to production.
73
+ - Every code example in this README and in [AGENTS.md](AGENTS.md) lives in [`docs/examples/`](docs/examples)
74
+ and runs in the test suite (`test/docs/`), together with the output it claims.
75
+
76
+ ## Quick start
77
+
78
+ ```sh
79
+ bundle add seo_geo
80
+ bin/rails g seo_geo:install https://www.example.com
81
+ ```
82
+
83
+ The generator writes `config/initializers/seo_geo.rb` and `config/seo_geo/pages/home.yml`. It also mounts the
84
+ engine as the last route, so your own routes win where paths overlap. A minimal configuration:
85
+
86
+ <!-- example: quickstart/config/initializers/seo_geo.rb -->
87
+ ```ruby
88
+ # config/initializers/seo_geo.rb
89
+ SeoGeo.configure do |seo|
90
+ seo.site.name = "Example"
91
+ seo.site.description = "Example helps small teams plan their week."
92
+ seo.canonical_origin = "https://www.example.com"
93
+ seo.canonical_aliases = ["example.com"] # folded onto www.example.com
94
+ seo.host "www.example.com", disallow: ["/admin"] # every crawler group repeats this
95
+ seo.crawlers.training = :deny # GPTBot, ClaudeBot, CCBot, ... => Disallow: /
96
+ end
97
+
98
+ SeoGeo.sitemap.draw do
99
+ add "/", priority: 1.0
100
+ add "/pricing"
101
+ end
102
+
103
+ SeoGeo.config.llms_txt.page :home, path: "/"
104
+ ```
105
+
106
+ <!-- example: quickstart/config/routes.rb -->
107
+ ```ruby
108
+ # config/routes.rb
109
+ Rails.application.routes.draw do
110
+ root "pages#home"
111
+ get "/pricing", to: "pages#pricing"
112
+
113
+ mount SeoGeo::Engine, at: "/" # last, so your own routes win where paths overlap
114
+ end
115
+ ```
116
+
117
+ One layout line replaces your `<title>`, meta description, canonical link and JSON-LD:
118
+
119
+ <!-- example: quickstart/app/views/layouts/application.html.erb -->
120
+ ```erb
121
+ <%# app/views/layouts/application.html.erb %>
122
+ <!DOCTYPE html>
123
+ <html lang="<%= seo_geo_html_lang %>">
124
+ <head>
125
+ <meta charset="utf-8">
126
+ <%= seo_geo_head %>
127
+ </head>
128
+ <body>
129
+ <main><%= yield %></main>
130
+ </body>
131
+ </html>
132
+ ```
133
+
134
+ Page copy lives in one file per page. `title` and `description` feed the head; `faq` feeds both the visible
135
+ FAQ and the FAQPage JSON-LD:
136
+
137
+ <!-- example: quickstart/config/seo_geo/pages/home.yml -->
138
+ ```yaml
139
+ # config/seo_geo/pages/home.yml — one copy source for the head, the visible FAQ and FAQPage JSON-LD
140
+ en:
141
+ title: Plan your week in five minutes
142
+ description: Example turns a messy task list into a weekly plan your whole team can see, in five minutes.
143
+ heading: Plan your week in five minutes
144
+ intro: Example turns a messy task list into a weekly plan your whole team can see.
145
+ faq:
146
+ - question: Is Example free?
147
+ answer: Teams of up to three people use Example for free.
148
+ ```
149
+
150
+ <!-- example: quickstart/app/controllers/pages_controller.rb -->
151
+ ```ruby
152
+ # app/controllers/pages_controller.rb
153
+ class PagesController < ApplicationController
154
+ include SeoGeo::Markdownable # Accept: text/markdown and /pricing.md
155
+
156
+ def home
157
+ @page = seo_geo_page(:home) # title, description and FAQPage JSON-LD from home.yml
158
+ end
159
+
160
+ def pricing
161
+ seo title: "Pricing", description: "Free for up to three people; paid plans for larger teams, billed monthly."
162
+ end
163
+ end
164
+ ```
165
+
166
+ <!-- example: quickstart/app/views/pages/home.html.erb -->
167
+ ```erb
168
+ <%# app/views/pages/home.html.erb %>
169
+ <h1><%= @page.heading %></h1>
170
+ <p><%= @page.intro %></p>
171
+ <%= seo_geo_faq(@page) %>
172
+ ```
173
+
174
+ <!-- example: quickstart/app/views/pages/pricing.html.erb -->
175
+ ```erb
176
+ <%# app/views/pages/pricing.html.erb %>
177
+ <h1>Pricing</h1>
178
+ <p>Free for up to three people. Paid plans start at $8 per user per month.</p>
179
+ ```
180
+
181
+ In production, `GET /pricing` on `example.com` (an alias) renders this head (excerpt):
182
+
183
+ <!-- output: quickstart GET /pricing Host=example.com -->
184
+ ```html
185
+ <title>Pricing | Example</title>
186
+ <meta name="description" content="Free for up to three people; paid plans for larger teams, billed monthly.">
187
+ <link rel="canonical" href="https://www.example.com/pricing">
188
+ <link rel="alternate" href="https://www.example.com/pricing.md" type="text/markdown">
189
+ <meta property="og:locale" content="en_US">
190
+ ```
191
+
192
+ `GET /robots.txt` on `www.example.com`: search and user-triggered fetchers share one rule block, and denied
193
+ training crawlers get `Disallow: /` plus every host rule:
194
+
195
+ <!-- output: quickstart GET /robots.txt Host=www.example.com -->
196
+ ```text
197
+ User-agent: *
198
+ User-agent: Googlebot
199
+ User-agent: OAI-SearchBot
200
+ User-agent: ChatGPT-User
201
+ Allow: /.well-known/
202
+ Disallow: /admin
203
+ Content-Signal: search=yes, ai-input=yes, ai-train=no
204
+
205
+ User-agent: GPTBot
206
+ User-agent: ClaudeBot
207
+ User-agent: CCBot
208
+ Allow: /.well-known/
209
+ Disallow: /
210
+ Disallow: /admin
211
+
212
+ Sitemap: https://www.example.com/sitemap.xml
213
+ ```
214
+
215
+ A host you did not declare fails closed:
216
+
217
+ <!-- output: quickstart GET /robots.txt Host=unknown.example.net -->
218
+ ```text
219
+ User-agent: *
220
+ Allow: /.well-known/
221
+ Disallow: /
222
+ ```
223
+
224
+ `GET /llms.txt` links the Markdown form of each listed page:
225
+
226
+ <!-- output: quickstart GET /llms.txt Host=www.example.com -->
227
+ ```markdown
228
+ # Example
229
+
230
+ > Example helps small teams plan their week.
231
+
232
+ ## Pages
233
+
234
+ - [Plan your week in five minutes](https://www.example.com/index.md): Example turns a messy task list into a weekly plan your whole team can see, in five minutes.
235
+ ```
236
+
237
+ An agent asking for Markdown gets frontmatter, the main content and `Vary: Accept`:
238
+
239
+ <!-- curl: quickstart -->
240
+ ```sh
241
+ curl -s -H "Host: www.example.com" -H "Accept: text/markdown" http://localhost:3000/pricing
242
+ curl -s -H "Host: www.example.com" http://localhost:3000/pricing.md
243
+ ```
244
+
245
+ <!-- output: quickstart GET /pricing Host=www.example.com Accept=text/markdown -->
246
+ ```text
247
+ content-type: text/markdown; charset=utf-8
248
+ vary: Accept
249
+ content-signal: search=yes, ai-input=yes, ai-train=no
250
+ ---
251
+ title: Pricing | Example
252
+ description: Free for up to three people; paid plans for larger teams, billed monthly.
253
+ canonical: https://www.example.com/pricing
254
+ locale: en
255
+ ---
256
+ # Pricing
257
+ Free for up to three people. Paid plans start at $8 per user per month.
258
+ ```
259
+
260
+ Then check the whole site. The audit requests every host in-process, as production would serve it, even in
261
+ development: it switches seo_geo to the first indexable environment and, outside production, adds the
262
+ configured hosts to `config.hosts` for that run only.
263
+
264
+ ```sh
265
+ bin/rails seo_geo:audit # FORMAT=json HOSTS=www.example.com MAX_PAGES=200
266
+ ```
267
+
268
+ <!-- output: quickstart audit env=development -->
269
+ ```text
270
+ seo_geo audit: temporarily allowing www.example.com, example.com in config.hosts
271
+ seo_geo audit: 0 error(s), 0 warning(s) (1 audited as production, 3 pages, 1 llms_links)
272
+ ```
273
+
274
+ On a real app warnings appear in the same line, for example
275
+ `seo_geo audit: 0 error(s), 2 warning(s) (1 audited as production, 7 pages, 6 llms_links)`, followed by one
276
+ line per finding. Calling `SeoGeo::Audit::Runner` from `bin/rails runner` needs `SEO_GEO_AUDIT=1` set before
277
+ boot for the same host allowance.
278
+
279
+ ## Inertia (React, Vue, Svelte)
280
+
281
+ Inertia sends an empty `<div id="app">`, and crawlers that do not run JavaScript (GPTBot, ClaudeBot,
282
+ PerplexityBot, Yeti) would see nothing. Pass the page copy as props for people, and render the same copy as a
283
+ fallback body for crawlers:
284
+
285
+ <!-- example: inertia/app/controllers/pages_controller.rb -->
286
+ ```ruby
287
+ # app/controllers/pages_controller.rb (inertia_rails)
288
+ class PagesController < ApplicationController
289
+ include SeoGeo::Markdownable
290
+ seo_geo_markdown only: :home, page: :home # Markdown from the page copy, not the empty #app div
291
+
292
+ def home
293
+ render inertia: "Home", props: { seo: seo_geo_page(:home).props }
294
+ end
295
+ end
296
+ ```
297
+
298
+ <!-- example: inertia/app/views/layouts/application.html.erb -->
299
+ ```erb
300
+ <%# app/views/layouts/application.html.erb (Inertia) %>
301
+ <!DOCTYPE html>
302
+ <html lang="<%= seo_geo_html_lang %>">
303
+ <head>
304
+ <meta charset="utf-8">
305
+ <%= seo_geo_head %>
306
+ </head>
307
+ <body>
308
+ <%= yield %>
309
+ <% if (page = seo_geo_state.page) %>
310
+ <%= seo_geo_fallback_body(page) %>
311
+ <% end %>
312
+ </body>
313
+ </html>
314
+ ```
315
+
316
+ <!-- example: inertia/app/frontend/entrypoints/application.js -->
317
+ ```js
318
+ // app/frontend/entrypoints/application.js — after createInertiaApp(...) has mounted:
319
+ window.SeoGeo?.mounted() // hides the crawler fallback body for people running JavaScript
320
+ ```
321
+
322
+ The server HTML then carries the copy inside `<main data-seo-fallback>`, next to the props:
323
+
324
+ <!-- output: inertia GET / Host=www.example.com -->
325
+ ```html
326
+ <main data-seo-fallback>
327
+ <h1>Plan your week in five minutes</h1>
328
+ <p>Example turns a messy task list into a weekly plan your whole team can see.</p>
329
+ <h3>Is Example free?</h3>
330
+ <p>Teams of up to three people use Example for free.</p>
331
+ ```
332
+
333
+ The `seo` prop is `Page#props`: `key`, `locale`, `title`, `description`, `heading`, `intro`, `features`
334
+ (`[{title:, description:}]`), `faq` (`[{question:, answer:}]`) and any extra YAML keys. Render the visible
335
+ FAQ from `seo.faq`; that keeps it identical to the FAQPage JSON-LD. Extra crawler-only HTML (links to sign-up or legal pages) goes in a block,
336
+ `seo_geo_fallback_body(page) { ... }`, or in `content_for(:seo_geo_fallback_extra)`; both land at the end of
337
+ `<main>`. The head snippet hides the fallback while
338
+ JavaScript runs and shows it again if `mounted()` is not called within five seconds. The tests run this
339
+ example with a stand-in for inertia_rails' `render inertia:`; seo_geo does not depend on inertia_rails.
340
+
341
+ ## Configuration reference
342
+
343
+ Every key, default and validation rule is in [docs/DESIGN.md § Configuration](docs/DESIGN.md#shared-contract).
344
+ The ones most apps touch:
345
+
346
+ | Key | Default | What it does |
347
+ |---|---|---|
348
+ | `canonical_origin` | nil, **required** | `"https://www.example.com"`; aliases and undeclared hosts fold their canonical URLs onto it, and sitemap `<loc>` and llms.txt links use it |
349
+ | `canonical_aliases` | `[]` | hosts folded onto the canonical origin; they inherit its robots rules and serve no sitemap |
350
+ | `host(name, indexable:, disallow:, allow:, sitemap:)` | — | per-host robots policy; undeclared hosts get `Disallow: /` |
351
+ | `crawlers.training` / `.search` / `.user` | `:allow` | `:allow` or `:deny` per purpose; `crawlers.override "Bytespider", :deny` per token |
352
+ | `crawlers.override ua, :full_access` | — | opt-in only: that token gets `Allow: /` and ignores every host `disallow` (e.g. `"Mediapartners-Google"` for AdSense); closed hosts still get `Disallow: /` |
353
+ | `site.name` / `.description` / `.default_locale` | nil / nil / `:en` | title suffix, JSON-LD, llms.txt header, `<html lang>`, `og:locale` |
354
+ | `title_template` | `"%{title} \| %{site}"` | must contain `%{title}` |
355
+ | `indexable_environments` | `["production"]` | everything else is noindex + `Disallow: /` |
356
+ | `default_noindex` | false | true: pages opt in with `seo index: true` or `sitemap.add ..., index: true` |
357
+ | `noindex_paths` | `[]` | Strings or Regexps; one declaration keeps a path out of sitemap.xml and llms.txt and renders noindex |
358
+ | `locales` / `x_default_locale` | `[]` / nil | hreflang alternates; `I18n.locale` is used only when listed |
359
+ | `verification.google` / `.bing` / `.naver` / `.daum_pin` | nil | search-console verification |
360
+ | `markdown.enabled` / `.selector` | true / `"main, [role=main]"` | Markdown negotiation and the content selector |
361
+ | `llms_txt.page` / `.link` / `.details` | — | llms.txt sections; `markdown: true` links the `.md` form |
362
+ | `indexnow.enabled` / `.key` | false / nil | IndexNow submission and the `/<key>.txt` key file |
363
+ | `og_image.backend` / `.fallback_url` / `.permitted_params` | nil / nil / `[]` | OG images; every permitted param needs a `param_constraints` entry |
364
+ | `og_image.fallback_width` / `.fallback_height` | nil / nil | `og:image:width/height` for `fallback_url`, emitted only when both are set |
365
+
366
+ Sitemap sources: `SeoGeo.sitemap.draw { add "/pricing"; add -> { Post.published }, path: ->(post) { "/posts/#{post.slug}" }, lastmod: :updated_at }`.
367
+ Pass collections as a lambda, so boot loads no model and a development reload never keeps a stale class. Records
368
+ are read with `find_each` (`select` must include `id`, `order` is ignored); `batch: false` keeps the scope's order.
369
+
370
+ ## What it deliberately does not do
371
+
372
+ - **No rich-result promises.** It emits FAQPage JSON-LD because AI answers and search engines read it, but
373
+ since 2023 Google shows FAQ rich results only for a few authoritative sites and no longer shows HowTo rich results.
374
+ The gem never adds markup for content that is not on the page.
375
+ - **The User-Agent is never identity.** robots.txt is the only place the gem reads crawler names, Markdown is
376
+ chosen by the `Accept` header, and every client gets the same public content. The audit warns when app code
377
+ branches on `request.user_agent`. When a bot must be trusted, use `SeoGeo::BotVerifier` and require
378
+ `verified == true`.
379
+ - **llms.txt is generated, not promoted.** Its effect is unproven. Evil Martians measured ~268,000 agent
380
+ requests over two months (2026): `/llms.txt` was fetched ~660 times and `/llms-full.txt` ~110 times, and only
381
+ ~37 of those fetches came from named AI assistants
382
+ ([source](https://evilmartians.com/chronicles/which-ai-actually-reads-your-site-two-months-of-llm-traffic-measured)).
383
+ In the same data, ChatGPT-User made 73% of agent requests and read HTML, while Claude Code asked for
384
+ `Accept: text/markdown` 76% of the time. seo_geo therefore puts server HTML first and Markdown
385
+ negotiation second, and keeps llms.txt cheap.
386
+ - **No invented data.** No `Time.now` as `lastmod`, and no prices, ratings or counts it was not given.
387
+ - **No geolocation.** GEO here is Generative Engine Optimization.
388
+
389
+ ## Troubleshooting
390
+
391
+ | Symptom (exact text) | Cause | Fix |
392
+ |---|---|---|
393
+ | Boot fails: `SeoGeo::ConfigurationError: invalid seo_geo configuration:` / `- canonical_origin is required (e.g. "https://www.example.com")` | Production boot validates the configuration and `canonical_origin` is unset | Set `seo.canonical_origin` or run `bin/rails g seo_geo:install https://www.example.com` |
394
+ | Warning: `seo_geo: canonical_origin is not set, so every host is fail-closed (Disallow: /, noindex).` | Same, outside production: boot continues with every host closed | Same fix |
395
+ | `canonical_origin must not contain a path, query or fragment (got "https://www.example.com/en")` | The origin has a path | Use the bare origin; localized paths go in `hreflang_url` |
396
+ | `title_template must contain %{title}` | A template such as `"%{site}"` | Include `%{title}`, or use `"%{title}"` to drop the suffix |
397
+ | `CANONICAL_ORIGIN must look like https://www.example.com (got "example.com")` | Generator argument without a scheme | `bin/rails g seo_geo:install https://www.example.com` |
398
+ | `SeoGeo::JsonLd::InvalidNode: Product "Widget" needs offers, review or aggregate_rating` | A typed JSON-LD node lacks a property Google requires | Pass the property, or leave the node out; never invent one |
399
+ | `unknown seo option(s): titel (known: title, description, image, noindex, index, canonical, page, type, markdown, json_ld)` | Typo in a controller `seo(...)` call | Use one of the listed keys |
400
+ | `KeyError: seo_geo page :pricing is not defined` | No `config/seo_geo/pages/pricing.yml`, I18n scope or `SeoGeo.pages.register` | Add the page copy, or check the key and the YAML locale keys |
401
+ | `collections need path: (a callable or a record method name)` | `sitemap.add -> { Post.published }` without `path:` | `add -> { Post.published }, path: ->(post) { "/posts/#{post.slug}" }` |
402
+ | `sitemap path "pricing" must start with /` | Relative sitemap path | `add "/pricing"` |
403
+ | `.md` URL answers 406 `Not Acceptable: no Markdown version of this page` | The action does not include `SeoGeo::Markdownable` or is outside its `only:`/`except:` | `include SeoGeo::Markdownable` (with `seo_geo_markdown only: ...`), or stop linking the `.md` form (`llms_txt.page ..., markdown: false`) |
404
+ | Audit error `host_authorization`: `` Rails HostAuthorization blocked Host: www.example.com; run it through `bin/rails seo_geo:audit` (or set SEO_GEO_AUDIT=1 before boot, e.g. for bin/rails runner) so seo_geo allows its hosts outside production, or add "www.example.com" to config.hosts `` | The audit ran without `SEO_GEO_AUDIT=1` (for example from `bin/rails runner`), or in production, where `config.hosts` is never widened | Use `bin/rails seo_geo:audit`, export `SEO_GEO_AUDIT=1` before booting a runner, or add the host to `config.hosts` |
405
+ | Audit error `static.shadowing`: `public/robots.txt is served before the seo_geo route; delete it` | A static file wins over the engine route (the generator warns too) | Delete `public/robots.txt` (or `sitemap.xml`, `llms.txt`, `llms-full.txt`) |
406
+ | Audit error `sitemap.noindex`: `listed in sitemap.xml but the page is noindex` | The page sets `seo noindex: true` at request time, which sitemap.xml cannot see | Declare it with `noindex_paths` or `sitemap.add path, noindex: true` |
407
+ | Audit warning `code.user_agent_branch`: `branches on the User-Agent header; never use it for access, content or rate-limit decisions (it is spoofable)` | App code branches on `request.user_agent` | Serve everyone the same content; use `SeoGeo::BotVerifier` when a bot must be trusted |
408
+ | `og_image.permitted_params "theme" needs an og_image.param_constraints entry` | Every query param that reaches the OG backend must be constrained | `seo.og_image.param_constraints = { "theme" => %w[light dark] }` (or a Regexp) |
409
+ | `indexnow.key "robots" collides with another /<name>.txt route` | The key file would shadow `/robots.txt` | Use a random key: `SecureRandom.hex(16)` |
410
+ | Warning: `seo_geo: host "127.0.0.1" is not declared (canonical_origin/canonical_aliases/host); serving noindex + Disallow: /. Check proxy Host headers.` | A request reached the app with a host seo_geo does not know: a proxy that drops or rewrites `Host` (here `127.0.0.1`), or an apex/www mismatch. The page is served noindex and robots.txt says `Disallow: /`, which deindexes the site | Forward the original `Host` from the proxy (e.g. nginx `proxy_set_header Host $host;`), or declare the host with `canonical_aliases` / `seo.host` |
411
+ | Warning: `seo_geo: invalid configuration ignored while a generator or rake task runs in production (a production server boot raises):` | A production rake task (`assets:precompile`, `db:migrate` in a Docker build) booted without the runtime ENV the configuration reads, e.g. `canonical_origin` | Nothing for the build step; make sure the variable is set where the server boots, which still raises |
412
+ | A `GET /<path>.md` route of your own answers 406 `Not Acceptable: no Markdown version of this page` | `SeoGeo::Markdown::PathSuffix` rewrites every `GET *.md` request app-wide | Set `seo.markdown.path_suffix = false` (Markdown stays available with `Accept: text/markdown`) or `seo.markdown.enabled = false`; otherwise keep your `.md` routes out of the app |
413
+ | Browsers get Markdown from a CDN, or agents get HTML | Markdownable actions answer `Vary: Accept`; a CDN that ignores `Vary` caches one representation for both | Add `Accept` (or a Markdown/HTML flag derived from it) to the CDN cache key, or bypass the cache for Markdownable paths |
414
+ | `GET /og/<key>.png` never reaches the gem's OG renderer | The engine routes `/og/:key.png` only once `og_image.backend` or `.template` is set, and only for known keys (page keys, `og_image.keys`, `key_resolver`); an app route for the same path, drawn above the mount, wins | Set a backend and register the key; remove the overlapping app route, or keep serving those images from your app |
415
+
416
+ ## Development
417
+
418
+ ```sh
419
+ bundle exec rake test # unit, docs, and app tests against both dummy apps
420
+ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake test
421
+ ```
422
+
423
+ Design and module boundaries: [docs/DESIGN.md](docs/DESIGN.md). Changes: [CHANGELOG.md](CHANGELOG.md).
424
+ Guidance for coding agents: [AGENTS.md](AGENTS.md).
425
+
426
+ ## License
427
+
428
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ # Serves the IndexNow key file at +/<key>.txt+. The route only matches the configured key, so any
5
+ # other +.txt+ path falls through to the application ({SeoGeo::IndexNow::KeyConstraint}).
6
+ class IndexNowController < ActionController::API
7
+ # GET /<key>.txt
8
+ def key
9
+ expires_in SeoGeo.config.cache_control_seconds, public: true
10
+ render plain: SeoGeo.config.indexnow.key, content_type: "text/plain"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ # GET /llms.txt and /llms-full.txt — per request host; 404 where the host has no sitemap.
5
+ class LlmsController < ActionController::API
6
+ # @return [void]
7
+ def show
8
+ respond_with_text(builder.to_s)
9
+ end
10
+
11
+ # @return [void]
12
+ def full
13
+ respond_with_text(builder.full)
14
+ end
15
+
16
+ private
17
+
18
+ def builder
19
+ SeoGeo::LlmsTxt.new(SeoGeo.config, request.host)
20
+ end
21
+
22
+ def respond_with_text(text)
23
+ return head(:not_found) if text.nil?
24
+
25
+ expires_in SeoGeo.config.cache_control_seconds, public: true
26
+ render plain: text, content_type: "text/plain; charset=utf-8"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ # GET /og/:key.png — Open Graph image for a page key. Link-preview bots hit this publicly, so a
5
+ # rendering failure redirects to +og_image.fallback_url+ (briefly cached) instead of a 500.
6
+ #
7
+ # Only known keys render ({SeoGeo::OgImage.resolve_key}, else 404) and only constrained parameter
8
+ # values reach the backend ({SeoGeo::OgImage.validated_params}, else 400), so a crawler cannot mint
9
+ # unbounded cache entries or Chrome launches. When every render slot is busy the request gets the
10
+ # fallback redirect, or a 503 with +Retry-After+, instead of waiting.
11
+ class OgImagesController < ActionController::API
12
+ FALLBACK_MAX_AGE = 300
13
+ BUSY_RETRY_AFTER = 5
14
+
15
+ def show
16
+ config = SeoGeo.config
17
+ key = params[:key].to_s
18
+ return head(:not_found) unless SeoGeo::OgImage::KEY_FORMAT.match?(key)
19
+
20
+ error = request.env[SeoGeo::OgImage::RouteConstraint::ERROR_KEY]
21
+ raise error if error # key_resolver failed during routing: same fallback as a render failure
22
+
23
+ resolved = request.env[SeoGeo::OgImage::RouteConstraint::ENV_KEY] || SeoGeo::OgImage.resolve_key(key, config)
24
+ return head(:not_found) unless resolved
25
+
26
+ og_params = SeoGeo::OgImage.validated_params(request.query_parameters, config)
27
+ return head(:bad_request) if og_params.nil?
28
+
29
+ page = find_page(key, config) || (resolved if resolved.is_a?(SeoGeo::Page))
30
+ png = SeoGeo::OgImage.render(key, page: page, params: og_params, config: config)
31
+ expires_in config.cache_control_seconds, public: true
32
+ send_data png, type: "image/png", disposition: "inline"
33
+ rescue SeoGeo::OgImage::Busy
34
+ busy(config || SeoGeo.config)
35
+ rescue StandardError => e
36
+ fallback(e, config || SeoGeo.config)
37
+ end
38
+
39
+ private
40
+
41
+ def find_page(key, config)
42
+ locale = params[:locale].to_s
43
+ locale = nil unless config.locales.map(&:to_s).include?(locale)
44
+ SeoGeo.page(key, locale: locale)
45
+ rescue KeyError
46
+ nil
47
+ end
48
+
49
+ def busy(config)
50
+ response.headers["Retry-After"] = BUSY_RETRY_AFTER.to_s
51
+ response.headers["Cache-Control"] = "no-store"
52
+ url = config.og_image.fallback_url
53
+ return head(:service_unavailable) if url.nil?
54
+
55
+ redirect_to url, allow_other_host: true, status: :found
56
+ end
57
+
58
+ def fallback(error, config)
59
+ logger&.error("[seo_geo] OG image #{params[:key].inspect} failed: #{error.class}: #{error.message}")
60
+ url = config.og_image.fallback_url
61
+ return head(:not_found) if url.nil?
62
+
63
+ expires_in FALLBACK_MAX_AGE, public: true
64
+ redirect_to url, allow_other_host: true, status: :found
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ # GET /robots.txt — per request host. ActionController::API so +allow_browser+ and auth filters in the
5
+ # host app's ApplicationController never answer a crawler with 406 or a login redirect.
6
+ class RobotsController < ActionController::API
7
+ # @return [void]
8
+ def show
9
+ SeoGeo::UndeclaredHost.warn_once(request.host)
10
+ expires_in SeoGeo.config.cache_control_seconds, public: true
11
+ render plain: SeoGeo::Robots.new(SeoGeo.config, request.host).to_s, content_type: "text/plain; charset=utf-8"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ # GET /sitemap.xml and /sitemap-:page.xml — per request host; 404 when the host has nothing to index.
5
+ class SitemapsController < ActionController::API
6
+ # @return [void]
7
+ def show
8
+ page = params[:page]&.to_i
9
+ xml = SeoGeo::Sitemap::Xml.new(SeoGeo.config, SeoGeo.sitemap, request.host).render(page)
10
+ return head(:not_found) if xml.nil?
11
+
12
+ expires_in SeoGeo.config.cache_control_seconds, public: true
13
+ render body: xml, content_type: "application/xml; charset=utf-8"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ # Delivers one IndexNow batch. Enqueued by {SeoGeo::IndexNow.submit}.
5
+ #
6
+ # 429, 5xx and network errors retry with polynomial backoff (5 attempts); 400/403/422 are logged
7
+ # and discarded because the same request would fail again.
8
+ class IndexNowJob < ActiveJob::Base
9
+ queue_as :default
10
+
11
+ retry_on SeoGeo::IndexNow::RetryableError, wait: :polynomially_longer, attempts: 5
12
+ discard_on SeoGeo::IndexNow::RejectedError
13
+
14
+ # @param host [String]
15
+ # @param urls [Array<String>]
16
+ # @return [SeoGeo::IndexNow::Result]
17
+ def perform(host, urls)
18
+ SeoGeo::IndexNow.deliver(host, urls)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ <%# Crawler fallback body (seo_geo). Hidden by the seo_geo_head snippet while JavaScript runs. %>
2
+ <main data-seo-fallback>
3
+ <% if (heading = page.heading || page.title) %>
4
+ <h1><%= heading %></h1>
5
+ <% end %>
6
+ <% page.intro_paragraphs.each do |line| %>
7
+ <p><%= line %></p>
8
+ <% end %>
9
+ <% unless page.features.empty? %>
10
+ <section data-seo-geo-features>
11
+ <h2><%= page.extra[:features_heading] || t("seo_geo.fallback.features", default: "Features") %></h2>
12
+ <ul>
13
+ <% page.features.each do |feature| %>
14
+ <li><strong><%= feature[:title] %></strong><% if feature[:description] %> — <%= feature[:description] %><% end %></li>
15
+ <% end %>
16
+ </ul>
17
+ </section>
18
+ <% end %>
19
+ <%= render partial: "seo_geo/fallback/faq", locals: { page: page } %>
20
+ <%= local_assigns[:extra] %>
21
+ </main>
@@ -0,0 +1,12 @@
1
+ <%# FAQ section (seo_geo). Same text as the FAQPage JSON-LD built from the same page copy. %>
2
+ <% unless page.faq.empty? %>
3
+ <section data-seo-geo-faq>
4
+ <h2><%= page.extra[:faq_heading] || t("seo_geo.fallback.faq", default: "FAQ") %></h2>
5
+ <% page.faq.each do |item| %>
6
+ <div data-seo-geo-faq-item>
7
+ <h3><%= item[:question] %></h3>
8
+ <p><%= item[:answer] %></p>
9
+ </div>
10
+ <% end %>
11
+ </section>
12
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ SeoGeo::Engine.routes.draw do
4
+ # robots, sitemap, llms.txt
5
+ get "/robots.txt", to: "robots#show", format: false
6
+ get "/sitemap.xml", to: "sitemaps#show", format: false
7
+ get "/sitemap-:page.xml", to: "sitemaps#show", format: false, constraints: { page: /[1-9][0-9]*/ }
8
+ get "/llms.txt", to: "llms#show", format: false
9
+ get "/llms-full.txt", to: "llms#full", format: false
10
+
11
+ # IndexNow key file, OG images
12
+ get "/:indexnow_key.txt", to: "index_now#key", format: false,
13
+ constraints: SeoGeo::IndexNow::KeyConstraint
14
+ # Matches only when og_image is enabled and the key is known (OgImage::RouteConstraint); anything else falls
15
+ # through to the application's routes (e.g. its own /og/:username.png).
16
+ get "/og/:key.png", to: "og_images#show", format: false, constraints: SeoGeo::OgImage::RouteConstraint
17
+ end