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
data/README.md
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# seo_geo
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/seo_geo) [](https://github.com/seunghan91/seo_geo/actions/workflows/ci.yml) [](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
|