helios-seo 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/MIT-LICENSE +20 -0
- data/README.md +229 -0
- data/Rakefile +13 -0
- data/app/assets/stylesheets/helios/seo/application.css +15 -0
- data/app/controllers/helios/seo/application_controller.rb +6 -0
- data/app/controllers/helios/seo/llms_controller.rb +31 -0
- data/app/helpers/helios/seo/application_helper.rb +6 -0
- data/app/helpers/helios/seo/tags_helper.rb +71 -0
- data/app/jobs/helios/seo/application_job.rb +6 -0
- data/app/mailers/helios/seo/application_mailer.rb +8 -0
- data/app/models/helios/seo/application_record.rb +7 -0
- data/app/services/helios/seo/resource_presenter.rb +113 -0
- data/app/services/helios/seo/schema_builder.rb +218 -0
- data/app/views/helios/seo/llms/show.text.erb +12 -0
- data/app/views/layouts/helios/seo/application.html.erb +17 -0
- data/config/routes.rb +5 -0
- data/lib/helios/seo/configuration.rb +77 -0
- data/lib/helios/seo/engine.rb +19 -0
- data/lib/helios/seo/version.rb +5 -0
- data/lib/helios/seo.rb +17 -0
- data/lib/helios-seo.rb +1 -0
- data/lib/tasks/helios/seo_tasks.rake +4 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8a92c25a448250fe41b8abdb1a3f57ddb3ea3a466f40e8bd0f8bf50bce569672
|
|
4
|
+
data.tar.gz: 4e92ca098ee10df3cd8497f0d9508de4f667caed455ca73feda5a403ec64bd16
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '0906a285d987cba1bf32ddbe6ea1e7d6216e81658000e5f5b73cf7b0672fe94a47a882d838a79246b27dea76c25a3398d833de0df5a4d23f8f049e8a1b4f17b6'
|
|
7
|
+
data.tar.gz: 5645919fc5c38228cbffb93903b49b870e25601fb6ce6b68e81f65d1f32f758768538fa5c78c6f89a31e6953c0be19d388e8d445ff985020af5c0695823be945
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2026 Jason Fleetwood-Boldt
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Helios::Seo
|
|
2
|
+
|
|
3
|
+
The machine-readable `<head>` layer for Helios content pages: `<title>`, meta
|
|
4
|
+
description, canonical, robots, Open Graph, Twitter/X cards, and a JSON-LD
|
|
5
|
+
`@graph` — plus a site-level `/llms.txt`.
|
|
6
|
+
|
|
7
|
+
`helios-seo` is **output only**. It reads a content resource (typically a
|
|
8
|
+
`Helios::Press::Post`) plus configuration and renders markup. It owns no content
|
|
9
|
+
data and adds no tables. It is the single source of truth for the head layer, so
|
|
10
|
+
Open Graph and Twitter tags are never emitted twice.
|
|
11
|
+
|
|
12
|
+
## What it renders
|
|
13
|
+
|
|
14
|
+
`helios_seo_tags(resource)` emits, into `<head>`:
|
|
15
|
+
|
|
16
|
+
- `<title>` from a configurable template (default `"%{title} — %{site_name}"`), with a per-resource override hook.
|
|
17
|
+
- `<meta name="description">` — the top-priority fix over the ad-hoc tags in helios-press.
|
|
18
|
+
- `<meta name="keywords">` when present.
|
|
19
|
+
- `<link rel="canonical">` via a configurable URL builder.
|
|
20
|
+
- `<meta name="robots">` via a configurable resolver (default `index, follow`; `noindex, nofollow` for unpublished pages).
|
|
21
|
+
- Open Graph: `og:title`, `og:type`, `og:url`, `og:description`, `og:image`, `og:site_name`, `article:published_time`, `article:modified_time`.
|
|
22
|
+
- Twitter/X: `twitter:card` (`summary_large_image`), `twitter:title`, `twitter:description`, `twitter:image`, `twitter:site`, `twitter:creator`.
|
|
23
|
+
- Exactly one `<script type="application/ld+json">` `@graph` (see below).
|
|
24
|
+
|
|
25
|
+
### JSON-LD graph
|
|
26
|
+
|
|
27
|
+
`Helios::Seo::SchemaBuilder` returns a single `@graph` whose nodes cross-reference
|
|
28
|
+
each other by stable `@id`:
|
|
29
|
+
|
|
30
|
+
- **WebSite** — `{site_url}/#website`, with a publisher ref.
|
|
31
|
+
- **Person** (author) — `name`, `url`, `sameAs`, `jobTitle`, `image`, `description`. This is the E-E-A-T handshake; always configure `same_as`.
|
|
32
|
+
- **Publisher** — reuses the Person node (`publisher_type: :person`) or emits an **Organization** node (`:organization`).
|
|
33
|
+
- **BlogPosting** — `headline`, `description`, `datePublished`/`dateModified` (published only, ISO-8601), `author`/`publisher`/`isPartOf` refs, canonical `url`/`mainEntityOfPage`, `image`, and optional `wordCount`/`articleBody` computed from the resource's rich-text blocks.
|
|
34
|
+
- **BreadcrumbList** — Home → (optional Blog) → Post.
|
|
35
|
+
- **FAQPage** — emitted only when `faq_resolver` returns `{question:, answer:}` pairs.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# Gemfile
|
|
41
|
+
gem "helios-seo"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bundle install
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# config/initializers/helios_seo.rb
|
|
52
|
+
Helios::Seo.configure do |config|
|
|
53
|
+
config.site_name = "jfb.dev"
|
|
54
|
+
config.site_url = "https://jfb.dev"
|
|
55
|
+
config.title_template = "%{title} — %{site_name}"
|
|
56
|
+
config.default_description = "Practical deliverability, DNS, and email-infra guidance."
|
|
57
|
+
config.default_og_image = "https://jfb.dev/og-default.png"
|
|
58
|
+
|
|
59
|
+
config.twitter_site = "@jasonfb"
|
|
60
|
+
config.twitter_creator = "@jasonfb"
|
|
61
|
+
|
|
62
|
+
# :person reuses the author node as publisher; :organization emits its own node.
|
|
63
|
+
config.publisher_type = :person
|
|
64
|
+
|
|
65
|
+
config.author = {
|
|
66
|
+
name: "Jason Fleetwood-Boldt",
|
|
67
|
+
url: "https://jfb.dev/about",
|
|
68
|
+
same_as: [ "https://www.linkedin.com/in/jasonfb", "https://github.com/jasonfb" ],
|
|
69
|
+
job_title: "Principal Engineer",
|
|
70
|
+
image: "https://jfb.dev/jason.jpg",
|
|
71
|
+
description: "20 years building DTC and email infrastructure."
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Optional middle breadcrumb between Home and the post.
|
|
75
|
+
config.breadcrumb_blog = { name: "Blog", url: "https://jfb.dev/blog" }
|
|
76
|
+
|
|
77
|
+
# Resolvers — each receives the resource; each has a safe default and is
|
|
78
|
+
# individually overridable.
|
|
79
|
+
config.canonical_url = ->(post) { "https://jfb.dev/#{post.slug}" }
|
|
80
|
+
config.robots_resolver = ->(post) { post.published? ? "index, follow" : "noindex, nofollow" }
|
|
81
|
+
config.title_resolver = ->(post) { nil } # return a String to override the <title> template
|
|
82
|
+
config.image_resolver = ->(post) { nil } # return an image URL; falls back to default_og_image
|
|
83
|
+
config.faq_resolver = ->(post) { [] } # return [{ question:, answer: }] to emit FAQPage
|
|
84
|
+
|
|
85
|
+
# Resolvers may optionally take a second argument, the ActionView context, for
|
|
86
|
+
# building URLs (e.g. an ActiveStorage proxy URL):
|
|
87
|
+
# config.image_resolver = ->(post, view) { view.polymorphic_url(post.share_image) }
|
|
88
|
+
|
|
89
|
+
# /llms.txt
|
|
90
|
+
config.llms_description = nil # falls back to default_description
|
|
91
|
+
config.llms_posts = -> { Helios::Press::Post.published.reverse_sorted }
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Resource contract
|
|
96
|
+
|
|
97
|
+
The renderer is duck-typed. Any resource that responds to `name`, `slug`,
|
|
98
|
+
`description`, `keywords`, `created_at`, `updated_at`, and `published?` works.
|
|
99
|
+
`blocks` (with `has_rich_text :content`) are used opportunistically for
|
|
100
|
+
`wordCount`/`articleBody` and degrade gracefully when absent.
|
|
101
|
+
|
|
102
|
+
## Rendering the head tags
|
|
103
|
+
|
|
104
|
+
Call the helper **once, in your application layout, inside the `<head>` block** —
|
|
105
|
+
`app/views/layouts/application.html.erb` (or whichever layout your content pages
|
|
106
|
+
render under). It emits the whole machine-readable head layer at that spot.
|
|
107
|
+
|
|
108
|
+
```erb
|
|
109
|
+
<%# app/views/layouts/application.html.erb %>
|
|
110
|
+
<!DOCTYPE html>
|
|
111
|
+
<html>
|
|
112
|
+
<head>
|
|
113
|
+
<meta charset="utf-8">
|
|
114
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
115
|
+
<%= csrf_meta_tags %>
|
|
116
|
+
<%= csp_meta_tag %>
|
|
117
|
+
|
|
118
|
+
<%# helios-seo: renders title, description, canonical, robots, OG,
|
|
119
|
+
Twitter, and the JSON-LD block. Guard on the resource so it only
|
|
120
|
+
fires on pages that expose one (e.g. a post show page). %>
|
|
121
|
+
<%= helios_seo_tags(@post) if @post %>
|
|
122
|
+
|
|
123
|
+
<%= stylesheet_link_tag "application" %>
|
|
124
|
+
<%= javascript_importmap_tags %>
|
|
125
|
+
</head>
|
|
126
|
+
<body>
|
|
127
|
+
<%= yield %>
|
|
128
|
+
</body>
|
|
129
|
+
</html>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The `@post` (or any resource matching the contract above) must be set by the
|
|
133
|
+
controller action for that page. On pages with no such resource, the guard skips
|
|
134
|
+
the helper and nothing is emitted.
|
|
135
|
+
|
|
136
|
+
> **Alternative — `content_for :head`.** If your layout already does
|
|
137
|
+
> `<%= yield :head %>` inside `<head>`, you can instead call the helper from the
|
|
138
|
+
> post's `show` view: `<% content_for :head do %><%= helios_seo_tags(@post) %><% end %>`.
|
|
139
|
+
> The markup still lands in the layout's `<head>`. Use one approach or the other,
|
|
140
|
+
> never both.
|
|
141
|
+
|
|
142
|
+
Note: `title_resolver` overrides only the `<title>` element; `og:title`,
|
|
143
|
+
`twitter:title`, and the schema `headline` always use the resource's `name`
|
|
144
|
+
(with `og:site_name` carrying the site identity).
|
|
145
|
+
|
|
146
|
+
### Using with helios-press
|
|
147
|
+
|
|
148
|
+
helios-seo is content-agnostic and knows nothing about helios-press. The
|
|
149
|
+
dependency runs the other way: **`helios-press` depends on `helios-seo`** and
|
|
150
|
+
adapts its `Post` model to it, so in a Press app the head tags "just work."
|
|
151
|
+
|
|
152
|
+
Press registers the one resolver that needs Press-specific knowledge — the
|
|
153
|
+
per-post social-share image — in an engine initializer:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# Registered by helios-press when helios-seo is loaded:
|
|
157
|
+
config.image_resolver = ->(post, view) { post.og_image_url(view: view) }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`Post#og_image_url` returns the explicit `og_image` override if set, else the
|
|
161
|
+
post's banner (its first image block), else nil (so helios-seo falls back to the
|
|
162
|
+
site-wide `default_og_image`). It uses the passed-in `view` to build a stable,
|
|
163
|
+
absolute ActiveStorage **proxy** URL (`view.polymorphic_url(variant)`) rather
|
|
164
|
+
than a short-lived signed service URL.
|
|
165
|
+
|
|
166
|
+
**Canonical URLs are left to helios-seo's default** — `"{site_url}/{slug}"` —
|
|
167
|
+
which matches Press's public `get ":slug"` route when the Public engine is
|
|
168
|
+
mounted at root. If you mount it on a subpath, set your own `canonical_url`.
|
|
169
|
+
|
|
170
|
+
Press calls the helper from `posts/show.html.erb` with `title: false` (the Press
|
|
171
|
+
layout owns the `<title>`):
|
|
172
|
+
|
|
173
|
+
```erb
|
|
174
|
+
<% content_for :head do %>
|
|
175
|
+
<%= helios_seo_tags(@post, title: false) %>
|
|
176
|
+
<% end %>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The host app still provides the site identity — `site_name`, `site_url`,
|
|
180
|
+
`author`, `twitter_*` — in its own `Helios::Seo.configure` block, and can
|
|
181
|
+
override any resolver. Because helios-seo is the **sole** head renderer, Press no
|
|
182
|
+
longer ships a `_meta_tags` partial, so tags are never emitted twice.
|
|
183
|
+
|
|
184
|
+
## /llms.txt
|
|
185
|
+
|
|
186
|
+
Mount the engine at root so the route resolves to `/llms.txt`:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# config/routes.rb
|
|
190
|
+
Rails.application.routes.draw do
|
|
191
|
+
mount Helios::Seo::Engine, at: "/"
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
It serves markdown (`text/plain`): the site name, a description
|
|
196
|
+
(`llms_description` or `default_description`), and a list of published posts
|
|
197
|
+
(title + canonical URL) drawn from `config.llms_posts`.
|
|
198
|
+
|
|
199
|
+
## Non-goals
|
|
200
|
+
|
|
201
|
+
Sitemaps (owned by `helios-sitemap`), taxonomy/categories (helios-press), author
|
|
202
|
+
storage/migrations, redirect management, readability scoring, an admin UI, and
|
|
203
|
+
schema types beyond BlogPosting/Person/Organization/WebSite/BreadcrumbList/FAQPage
|
|
204
|
+
are all out of scope.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
bin/rubocop # lint
|
|
210
|
+
rake test # run the unit tests (tag helper + SchemaBuilder, fixture doubles)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Releases
|
|
214
|
+
|
|
215
|
+
### 0.1.0
|
|
216
|
+
|
|
217
|
+
Initial release. Content-agnostic `<head>` renderer:
|
|
218
|
+
|
|
219
|
+
- `helios_seo_tags(resource)` — title, meta description, canonical, robots,
|
|
220
|
+
Open Graph, Twitter/X cards, and a single JSON-LD block.
|
|
221
|
+
- `Helios::Seo::SchemaBuilder` — a cross-referenced `@graph` (WebSite, Person,
|
|
222
|
+
Person/Organization publisher, BlogPosting, BreadcrumbList, conditional
|
|
223
|
+
FAQPage).
|
|
224
|
+
- Configurable, view-aware resolvers (canonical, robots, title, image, FAQ).
|
|
225
|
+
- `/llms.txt` controller and route.
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT License. See [MIT-LICENSE](MIT-LICENSE) for details.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Helios
|
|
2
|
+
module Seo
|
|
3
|
+
# Serves /llms.txt — a markdown site summary plus a list of published posts
|
|
4
|
+
# with canonical URLs, generated from the resource the host configures via
|
|
5
|
+
# `config.llms_posts`.
|
|
6
|
+
class LlmsController < ApplicationController
|
|
7
|
+
def show
|
|
8
|
+
@config = Helios::Seo.configuration
|
|
9
|
+
@entries = posts.map do |post|
|
|
10
|
+
presenter = ResourcePresenter.new(post, @config)
|
|
11
|
+
{ title: presenter.name, url: presenter.canonical_url }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
render layout: false, content_type: "text/plain"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def posts
|
|
20
|
+
source = @config.llms_posts
|
|
21
|
+
list =
|
|
22
|
+
if source.respond_to?(:call)
|
|
23
|
+
source.arity.zero? ? source.call : source.call
|
|
24
|
+
else
|
|
25
|
+
source
|
|
26
|
+
end
|
|
27
|
+
Array(list)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Helios
|
|
2
|
+
module Seo
|
|
3
|
+
# The single source of truth for a content page's <head> machine layer.
|
|
4
|
+
# Renders title, description, canonical, robots, Open Graph, Twitter card,
|
|
5
|
+
# and exactly one JSON-LD block. Supersedes any ad-hoc content_for :head
|
|
6
|
+
# markup so social/OG tags are never emitted twice.
|
|
7
|
+
module TagsHelper
|
|
8
|
+
# Usage (in a layout or view <head>):
|
|
9
|
+
# <%= helios_seo_tags(@post) %>
|
|
10
|
+
#
|
|
11
|
+
# Pass `title: false` when the surrounding layout already renders its own
|
|
12
|
+
# <title> element, to avoid emitting two of them.
|
|
13
|
+
def helios_seo_tags(resource, config: Helios::Seo.configuration, title: true)
|
|
14
|
+
presenter = ResourcePresenter.new(resource, config, view: self)
|
|
15
|
+
tags = []
|
|
16
|
+
|
|
17
|
+
# Core
|
|
18
|
+
tags << tag.title(presenter.document_title) if title
|
|
19
|
+
tags << meta_named("description", presenter.description)
|
|
20
|
+
tags << meta_named("keywords", presenter.keywords)
|
|
21
|
+
tags << tag.link(rel: "canonical", href: presenter.canonical_url)
|
|
22
|
+
tags << meta_named("robots", presenter.robots)
|
|
23
|
+
|
|
24
|
+
# Open Graph
|
|
25
|
+
tags << meta_property("og:title", presenter.headline)
|
|
26
|
+
tags << meta_property("og:type", "article")
|
|
27
|
+
tags << meta_property("og:url", presenter.canonical_url)
|
|
28
|
+
tags << meta_property("og:description", presenter.description)
|
|
29
|
+
tags << meta_property("og:image", presenter.image_url)
|
|
30
|
+
tags << meta_property("og:site_name", config.site_name)
|
|
31
|
+
tags << meta_property("article:published_time", presenter.published_time)
|
|
32
|
+
tags << meta_property("article:modified_time", presenter.modified_time)
|
|
33
|
+
|
|
34
|
+
# Twitter / X
|
|
35
|
+
tags << meta_named("twitter:card", "summary_large_image")
|
|
36
|
+
tags << meta_named("twitter:title", presenter.headline)
|
|
37
|
+
tags << meta_named("twitter:description", presenter.description)
|
|
38
|
+
tags << meta_named("twitter:image", presenter.image_url)
|
|
39
|
+
tags << meta_named("twitter:site", config.twitter_site)
|
|
40
|
+
tags << meta_named("twitter:creator", config.twitter_creator)
|
|
41
|
+
|
|
42
|
+
# JSON-LD (exactly one block)
|
|
43
|
+
tags << json_ld_tag(resource, config)
|
|
44
|
+
|
|
45
|
+
safe_join(tags.compact, "\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def meta_named(name, content)
|
|
51
|
+
return nil if content.blank?
|
|
52
|
+
|
|
53
|
+
tag.meta(name: name, content: content)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def meta_property(property, content)
|
|
57
|
+
return nil if content.blank?
|
|
58
|
+
|
|
59
|
+
tag.meta(property: property, content: content)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def json_ld_tag(resource, config)
|
|
63
|
+
graph = SchemaBuilder.new(resource, config, view: self).as_json
|
|
64
|
+
# Escape "</" so the JSON can never break out of the <script> element.
|
|
65
|
+
json = JSON.generate(graph).gsub("</", '<\/')
|
|
66
|
+
|
|
67
|
+
content_tag(:script, raw(json), type: "application/ld+json")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module Helios
|
|
2
|
+
module Seo
|
|
3
|
+
# Wraps a content resource + configuration and exposes the resolved,
|
|
4
|
+
# render-ready SEO values. Shared by TagsHelper and SchemaBuilder so the
|
|
5
|
+
# two never disagree. Targets the resource contract:
|
|
6
|
+
# name, slug, description, keywords, created_at, updated_at, published?
|
|
7
|
+
# and degrades gracefully when optional methods are absent.
|
|
8
|
+
class ResourcePresenter
|
|
9
|
+
attr_reader :resource, :config
|
|
10
|
+
|
|
11
|
+
# `view` is the ActionView context (passed by TagsHelper). Resolvers that
|
|
12
|
+
# need it to build URLs — e.g. an image_resolver generating an ActiveStorage
|
|
13
|
+
# proxy URL — can declare a second argument and receive it.
|
|
14
|
+
def initialize(resource, config = Helios::Seo.configuration, view: nil)
|
|
15
|
+
@resource = resource
|
|
16
|
+
@config = config
|
|
17
|
+
@view = view
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The content title (used for og:title, twitter:title, schema headline).
|
|
21
|
+
def name
|
|
22
|
+
value(:name)
|
|
23
|
+
end
|
|
24
|
+
alias headline name
|
|
25
|
+
|
|
26
|
+
# The <title> tag: a resolver override, else the configured template.
|
|
27
|
+
def document_title
|
|
28
|
+
override = resolve(config.title_resolver)
|
|
29
|
+
return override.to_s if override.present?
|
|
30
|
+
|
|
31
|
+
format(config.title_template.to_s,
|
|
32
|
+
title: name.to_s,
|
|
33
|
+
site_name: config.site_name.to_s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# SERP-friendly plain-text description, falling back to the default.
|
|
37
|
+
def description
|
|
38
|
+
text = value(:description).to_s.squish
|
|
39
|
+
text = config.default_description.to_s if text.blank?
|
|
40
|
+
return nil if text.blank?
|
|
41
|
+
|
|
42
|
+
text.truncate(160)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def keywords
|
|
46
|
+
value(:keywords).presence
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def canonical_url
|
|
50
|
+
resolve(config.canonical_url).presence || config.default_canonical_url(resource)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def robots
|
|
54
|
+
resolve(config.robots_resolver).presence || config.default_robots(resource)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Social-share image: resolver first, then the configured default.
|
|
58
|
+
def image_url
|
|
59
|
+
resolve(config.image_resolver).presence || config.default_og_image.presence
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def published?
|
|
63
|
+
resource.respond_to?(:published?) ? !!resource.published? : true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# ISO-8601 timestamps, emitted only for published resources.
|
|
67
|
+
def published_time
|
|
68
|
+
published? ? iso8601(:created_at) : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def modified_time
|
|
72
|
+
published? ? iso8601(:updated_at) : nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Normalized FAQ pairs; empty unless the host's resolver returns items.
|
|
76
|
+
def faqs
|
|
77
|
+
Array(resolve(config.faq_resolver)).filter_map do |item|
|
|
78
|
+
next unless item.respond_to?(:[])
|
|
79
|
+
|
|
80
|
+
question = item[:question] || item["question"]
|
|
81
|
+
answer = item[:answer] || item["answer"]
|
|
82
|
+
next if question.blank? || answer.blank?
|
|
83
|
+
|
|
84
|
+
{ question: question.to_s, answer: answer.to_s }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def value(attr)
|
|
91
|
+
resource.respond_to?(attr) ? resource.public_send(attr) : nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def iso8601(attr)
|
|
95
|
+
val = value(attr)
|
|
96
|
+
val.respond_to?(:iso8601) ? val.iso8601 : nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Call a resolver with as many of (resource, view) as it declares, so
|
|
100
|
+
# simple resolvers stay `->(resource) {}` while URL-building ones can opt
|
|
101
|
+
# into `->(resource, view) {}`.
|
|
102
|
+
def resolve(resolver)
|
|
103
|
+
return nil unless resolver.respond_to?(:call)
|
|
104
|
+
|
|
105
|
+
case resolver.arity
|
|
106
|
+
when 0 then resolver.call
|
|
107
|
+
when 1 then resolver.call(resource)
|
|
108
|
+
else resolver.call(resource, @view)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
module Helios
|
|
2
|
+
module Seo
|
|
3
|
+
# Builds the JSON-LD @graph for a content resource. Pure Ruby: takes a
|
|
4
|
+
# resource + config, returns a Hash ready to serialize into a single
|
|
5
|
+
# <script type="application/ld+json"> block. Every node carries a stable
|
|
6
|
+
# @id so nodes cross-reference each other, and every referenced @id is
|
|
7
|
+
# emitted by this builder.
|
|
8
|
+
class SchemaBuilder
|
|
9
|
+
def initialize(resource, config = Helios::Seo.configuration, view: nil)
|
|
10
|
+
@resource = resource
|
|
11
|
+
@config = config
|
|
12
|
+
@presenter = ResourcePresenter.new(resource, config, view: view)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The full document: { "@context" => ..., "@graph" => [...] }.
|
|
16
|
+
def as_json
|
|
17
|
+
{
|
|
18
|
+
"@context" => "https://schema.org",
|
|
19
|
+
"@graph" => graph
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def graph
|
|
24
|
+
nodes = [ website_node, person_node ]
|
|
25
|
+
nodes << organization_node if organization?
|
|
26
|
+
nodes << blog_posting_node
|
|
27
|
+
nodes << breadcrumb_node
|
|
28
|
+
faq = faq_node
|
|
29
|
+
nodes << faq if faq
|
|
30
|
+
nodes
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :resource, :config, :presenter
|
|
36
|
+
|
|
37
|
+
# --- @id helpers -------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def base_url
|
|
40
|
+
config.site_url.to_s.chomp("/")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def website_id
|
|
44
|
+
"#{base_url}/#website"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def person_id
|
|
48
|
+
"#{base_url}/#person"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def organization_id
|
|
52
|
+
"#{base_url}/#organization"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def publisher_id
|
|
56
|
+
organization? ? organization_id : person_id
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def canonical
|
|
60
|
+
presenter.canonical_url
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def organization?
|
|
64
|
+
config.publisher_type.to_s == "organization"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# --- Nodes -------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def website_node
|
|
70
|
+
compact(
|
|
71
|
+
"@type" => "WebSite",
|
|
72
|
+
"@id" => website_id,
|
|
73
|
+
"url" => base_url.presence,
|
|
74
|
+
"name" => config.site_name,
|
|
75
|
+
"publisher" => { "@id" => publisher_id }
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def person_node
|
|
80
|
+
author = config.author || {}
|
|
81
|
+
compact(
|
|
82
|
+
"@type" => "Person",
|
|
83
|
+
"@id" => person_id,
|
|
84
|
+
"name" => author[:name],
|
|
85
|
+
"url" => author[:url].presence || base_url.presence,
|
|
86
|
+
"sameAs" => Array(author[:same_as]).compact.presence,
|
|
87
|
+
"jobTitle" => author[:job_title],
|
|
88
|
+
"image" => author[:image],
|
|
89
|
+
"description" => author[:description]
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def organization_node
|
|
94
|
+
compact(
|
|
95
|
+
"@type" => "Organization",
|
|
96
|
+
"@id" => organization_id,
|
|
97
|
+
"name" => config.site_name,
|
|
98
|
+
"url" => base_url.presence,
|
|
99
|
+
"logo" => config.default_og_image
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def blog_posting_node
|
|
104
|
+
compact(
|
|
105
|
+
"@type" => "BlogPosting",
|
|
106
|
+
"@id" => "#{canonical}#article",
|
|
107
|
+
"headline" => presenter.headline,
|
|
108
|
+
"description" => presenter.description,
|
|
109
|
+
"datePublished" => presenter.published_time,
|
|
110
|
+
"dateModified" => presenter.modified_time,
|
|
111
|
+
"author" => { "@id" => person_id },
|
|
112
|
+
"publisher" => { "@id" => publisher_id },
|
|
113
|
+
"mainEntityOfPage" => { "@type" => "WebPage", "@id" => canonical },
|
|
114
|
+
"url" => canonical,
|
|
115
|
+
"image" => presenter.image_url,
|
|
116
|
+
"isPartOf" => { "@id" => website_id },
|
|
117
|
+
"wordCount" => word_count,
|
|
118
|
+
"articleBody" => article_body
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def breadcrumb_node
|
|
123
|
+
items = []
|
|
124
|
+
position = 1
|
|
125
|
+
|
|
126
|
+
items << list_item(position, "Home", base_url)
|
|
127
|
+
position += 1
|
|
128
|
+
|
|
129
|
+
if (blog = config.breadcrumb_blog) && blog[:name].present? && blog[:url].present?
|
|
130
|
+
items << list_item(position, blog[:name], blog[:url])
|
|
131
|
+
position += 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
items << list_item(position, presenter.headline, canonical)
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
"@type" => "BreadcrumbList",
|
|
138
|
+
"@id" => "#{canonical}#breadcrumb",
|
|
139
|
+
"itemListElement" => items
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def faq_node
|
|
144
|
+
faqs = presenter.faqs
|
|
145
|
+
return nil if faqs.empty?
|
|
146
|
+
|
|
147
|
+
{
|
|
148
|
+
"@type" => "FAQPage",
|
|
149
|
+
"@id" => "#{canonical}#faq",
|
|
150
|
+
"mainEntity" => faqs.map do |faq|
|
|
151
|
+
{
|
|
152
|
+
"@type" => "Question",
|
|
153
|
+
"name" => faq[:question],
|
|
154
|
+
"acceptedAnswer" => {
|
|
155
|
+
"@type" => "Answer",
|
|
156
|
+
"text" => faq[:answer]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# --- Optional article body / word count -------------------------------
|
|
164
|
+
|
|
165
|
+
def article_body
|
|
166
|
+
text = plain_text_body
|
|
167
|
+
text.presence
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def word_count
|
|
171
|
+
text = plain_text_body
|
|
172
|
+
return nil if text.blank?
|
|
173
|
+
|
|
174
|
+
text.split(/\s+/).reject(&:empty?).size
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Best-effort extraction from the resource's rich-text blocks; returns nil
|
|
178
|
+
# when the resource exposes no blocks or anything goes wrong.
|
|
179
|
+
def plain_text_body
|
|
180
|
+
return @plain_text_body if defined?(@plain_text_body)
|
|
181
|
+
|
|
182
|
+
@plain_text_body =
|
|
183
|
+
begin
|
|
184
|
+
return @plain_text_body = nil unless resource.respond_to?(:blocks)
|
|
185
|
+
|
|
186
|
+
parts = Array(resource.blocks).filter_map do |block|
|
|
187
|
+
next unless block.respond_to?(:content)
|
|
188
|
+
|
|
189
|
+
content = block.content
|
|
190
|
+
next if content.nil?
|
|
191
|
+
|
|
192
|
+
content.respond_to?(:to_plain_text) ? content.to_plain_text : content.to_s
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
parts.join("\n\n").strip
|
|
196
|
+
rescue StandardError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# --- Helpers -----------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def list_item(position, name, url)
|
|
204
|
+
{
|
|
205
|
+
"@type" => "ListItem",
|
|
206
|
+
"position" => position,
|
|
207
|
+
"name" => name,
|
|
208
|
+
"item" => url
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Drop nil / blank / empty-collection values so nodes stay clean.
|
|
213
|
+
def compact(hash)
|
|
214
|
+
hash.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# <%= @config.site_name %>
|
|
2
|
+
<%= "\n" %>
|
|
3
|
+
<%- description = @config.llms_description.presence || @config.default_description -%>
|
|
4
|
+
<%- if description.present? -%>
|
|
5
|
+
<%= description %>
|
|
6
|
+
<%= "\n" %>
|
|
7
|
+
<%- end -%>
|
|
8
|
+
## Posts
|
|
9
|
+
<%= "\n" %>
|
|
10
|
+
<%- @entries.each do |entry| -%>
|
|
11
|
+
- [<%= entry[:title] %>](<%= entry[:url] %>)
|
|
12
|
+
<%- end -%>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Helios seo</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
|
|
10
|
+
<%= stylesheet_link_tag "helios/seo/application", media: "all" %>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
|
|
14
|
+
<%= yield %>
|
|
15
|
+
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Helios
|
|
2
|
+
module Seo
|
|
3
|
+
# Central configuration for helios-seo. Every value has a safe default and
|
|
4
|
+
# every resolver is an individually-overridable callable that receives the
|
|
5
|
+
# content resource, so the host controls behavior without schema changes.
|
|
6
|
+
class Configuration
|
|
7
|
+
# --- Site identity -----------------------------------------------------
|
|
8
|
+
attr_accessor :site_name,
|
|
9
|
+
:site_url,
|
|
10
|
+
:title_template,
|
|
11
|
+
:default_description,
|
|
12
|
+
:default_og_image
|
|
13
|
+
|
|
14
|
+
# --- Social handles ----------------------------------------------------
|
|
15
|
+
attr_accessor :twitter_site,
|
|
16
|
+
:twitter_creator
|
|
17
|
+
|
|
18
|
+
# --- Publisher / author (E-E-A-T) -------------------------------------
|
|
19
|
+
# publisher_type: :person reuses the author node as publisher;
|
|
20
|
+
# :organization emits a separate Organization node.
|
|
21
|
+
attr_accessor :publisher_type,
|
|
22
|
+
:author,
|
|
23
|
+
:breadcrumb_blog
|
|
24
|
+
|
|
25
|
+
# --- Resolvers (callables receiving the resource) ----------------------
|
|
26
|
+
attr_accessor :canonical_url,
|
|
27
|
+
:robots_resolver,
|
|
28
|
+
:title_resolver,
|
|
29
|
+
:image_resolver,
|
|
30
|
+
:faq_resolver
|
|
31
|
+
|
|
32
|
+
# --- llms.txt ----------------------------------------------------------
|
|
33
|
+
attr_accessor :llms_description,
|
|
34
|
+
:llms_posts
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
@site_name = nil
|
|
38
|
+
@site_url = nil
|
|
39
|
+
@title_template = "%{title} — %{site_name}"
|
|
40
|
+
@default_description = nil
|
|
41
|
+
@default_og_image = nil
|
|
42
|
+
|
|
43
|
+
@twitter_site = nil
|
|
44
|
+
@twitter_creator = nil
|
|
45
|
+
|
|
46
|
+
@publisher_type = :person
|
|
47
|
+
@author = {}
|
|
48
|
+
@breadcrumb_blog = nil # e.g. { name: "Blog", url: "https://site/blog" }
|
|
49
|
+
|
|
50
|
+
# Resolvers — all have safe defaults, all overridable.
|
|
51
|
+
@canonical_url = ->(resource) { default_canonical_url(resource) }
|
|
52
|
+
@robots_resolver = ->(resource) { default_robots(resource) }
|
|
53
|
+
@title_resolver = ->(_resource) { nil } # return a String to override the template
|
|
54
|
+
@image_resolver = ->(_resource) { nil } # return an image URL; falls back to default_og_image
|
|
55
|
+
@faq_resolver = ->(_resource) { [] } # return [{ question:, answer: }] to emit FAQPage
|
|
56
|
+
|
|
57
|
+
# llms.txt
|
|
58
|
+
@llms_description = nil # falls back to default_description
|
|
59
|
+
@llms_posts = -> { [] } # return an enumerable of published resources
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Default canonical: "{site_url}/{slug}". Kept public so resolvers/
|
|
63
|
+
# presenters can fall back to it when a custom builder returns nil.
|
|
64
|
+
def default_canonical_url(resource)
|
|
65
|
+
base = site_url.to_s.chomp("/")
|
|
66
|
+
slug = resource.respond_to?(:slug) ? resource.slug : nil
|
|
67
|
+
slug.present? ? "#{base}/#{slug}" : base
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Default robots: index published resources, hide everything else.
|
|
71
|
+
def default_robots(resource)
|
|
72
|
+
published = resource.respond_to?(:published?) ? resource.published? : true
|
|
73
|
+
published ? "index, follow" : "noindex, nofollow"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Helios
|
|
2
|
+
module Seo
|
|
3
|
+
# Mountable, isolated engine. helios-seo is output-only: it reads a
|
|
4
|
+
# duck-typed content resource plus configuration and renders head markup
|
|
5
|
+
# and /llms.txt. It owns no content data and has no runtime dependency on
|
|
6
|
+
# the other Helios gems.
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace Helios::Seo
|
|
9
|
+
|
|
10
|
+
# Make helios_seo_tags available in every controller/view of the host app
|
|
11
|
+
# (and other engines), so it can be called from any layout or content view.
|
|
12
|
+
initializer "helios_seo.helpers" do
|
|
13
|
+
ActiveSupport.on_load(:action_controller) do
|
|
14
|
+
helper Helios::Seo::TagsHelper
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/helios/seo.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "helios/seo/version"
|
|
2
|
+
require "helios/seo/engine"
|
|
3
|
+
require "helios/seo/configuration"
|
|
4
|
+
|
|
5
|
+
module Helios
|
|
6
|
+
module Seo
|
|
7
|
+
class << self
|
|
8
|
+
def configuration
|
|
9
|
+
@configuration ||= Configuration.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def configure
|
|
13
|
+
yield(configuration)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/helios-seo.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require "helios/seo"
|
metadata
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: helios-seo
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jason Fleetwood-Boldt
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-30 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rails
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '8.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '8.0'
|
|
27
|
+
description: A content-agnostic Rails engine that renders SEO metadata, Open Graph/Twitter
|
|
28
|
+
cards, and a JSON-LD graph for any resource matching a small duck-typed contract,
|
|
29
|
+
plus a site-level /llms.txt.
|
|
30
|
+
email:
|
|
31
|
+
- jason@heliosflow.ai
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- MIT-LICENSE
|
|
37
|
+
- README.md
|
|
38
|
+
- Rakefile
|
|
39
|
+
- app/assets/stylesheets/helios/seo/application.css
|
|
40
|
+
- app/controllers/helios/seo/application_controller.rb
|
|
41
|
+
- app/controllers/helios/seo/llms_controller.rb
|
|
42
|
+
- app/helpers/helios/seo/application_helper.rb
|
|
43
|
+
- app/helpers/helios/seo/tags_helper.rb
|
|
44
|
+
- app/jobs/helios/seo/application_job.rb
|
|
45
|
+
- app/mailers/helios/seo/application_mailer.rb
|
|
46
|
+
- app/models/helios/seo/application_record.rb
|
|
47
|
+
- app/services/helios/seo/resource_presenter.rb
|
|
48
|
+
- app/services/helios/seo/schema_builder.rb
|
|
49
|
+
- app/views/helios/seo/llms/show.text.erb
|
|
50
|
+
- app/views/layouts/helios/seo/application.html.erb
|
|
51
|
+
- config/routes.rb
|
|
52
|
+
- lib/helios-seo.rb
|
|
53
|
+
- lib/helios/seo.rb
|
|
54
|
+
- lib/helios/seo/configuration.rb
|
|
55
|
+
- lib/helios/seo/engine.rb
|
|
56
|
+
- lib/helios/seo/version.rb
|
|
57
|
+
- lib/tasks/helios/seo_tasks.rake
|
|
58
|
+
homepage: https://github.com/heliosdev-shop/helios-seo
|
|
59
|
+
licenses:
|
|
60
|
+
- MIT
|
|
61
|
+
metadata:
|
|
62
|
+
homepage_uri: https://github.com/heliosdev-shop/helios-seo
|
|
63
|
+
source_code_uri: https://github.com/heliosdev-shop/helios-seo
|
|
64
|
+
post_install_message:
|
|
65
|
+
rdoc_options: []
|
|
66
|
+
require_paths:
|
|
67
|
+
- lib
|
|
68
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '0'
|
|
78
|
+
requirements: []
|
|
79
|
+
rubygems_version: 3.5.22
|
|
80
|
+
signing_key:
|
|
81
|
+
specification_version: 4
|
|
82
|
+
summary: SEO metadata, Open Graph, and structured data for Rails
|
|
83
|
+
test_files: []
|