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 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,13 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.pattern = "test/**/*_test.rb"
10
+ t.verbose = false
11
+ end
12
+
13
+ task default: :test
@@ -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,6 @@
1
+ module Helios
2
+ module Seo
3
+ class ApplicationController < ActionController::Base
4
+ end
5
+ end
6
+ end
@@ -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,6 @@
1
+ module Helios
2
+ module Seo
3
+ module ApplicationHelper
4
+ end
5
+ end
6
+ 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,6 @@
1
+ module Helios
2
+ module Seo
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ module Helios
2
+ module Seo
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: "from@example.com"
5
+ layout "mailer"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module Helios
2
+ module Seo
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ 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,5 @@
1
+ Helios::Seo::Engine.routes.draw do
2
+ # Serve /llms.txt. Mount the engine at root in the host app so the path
3
+ # resolves to "/llms.txt" (see README).
4
+ get "/llms.txt", to: "llms#show", format: false, defaults: { format: :text }, as: :llms
5
+ end
@@ -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
@@ -0,0 +1,5 @@
1
+ module Helios
2
+ module Seo
3
+ VERSION = "0.1.0"
4
+ end
5
+ 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"
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :helios_seo do
3
+ # # Task goes here
4
+ # end
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: []