Helios::Seo
The machine-readable <head> layer for Helios content pages: <title>, meta
description, canonical, robots, Open Graph, Twitter/X cards, and a JSON-LD
@graph — plus a site-level /llms.txt.
helios-seo is output only. It reads a content resource (typically a
Helios::Press::Post) plus configuration and renders markup. It owns no content
data and adds no tables. It is the single source of truth for the head layer, so
Open Graph and Twitter tags are never emitted twice.
What it renders
helios_seo_tags(resource) emits, into <head>:
<title>from a configurable template (default"%{title} — %{site_name}"), with a per-resource override hook.<meta name="description">— the top-priority fix over the ad-hoc tags in helios-press.<meta name="keywords">when present.<link rel="canonical">via a configurable URL builder.<meta name="robots">via a configurable resolver (defaultindex, follow;noindex, nofollowfor unpublished pages).- Open Graph:
og:title,og:type,og:url,og:description,og:image,og:site_name,article:published_time,article:modified_time. - Twitter/X:
twitter:card(summary_large_image),twitter:title,twitter:description,twitter:image,twitter:site,twitter:creator. - Exactly one
<script type="application/ld+json">@graph(see below).
JSON-LD graph
Helios::Seo::SchemaBuilder returns a single @graph whose nodes cross-reference
each other by stable @id:
- WebSite —
{site_url}/#website, with a publisher ref. - Person (author) —
name,url,sameAs,jobTitle,image,description. This is the E-E-A-T handshake; always configuresame_as. - Publisher — reuses the Person node (
publisher_type: :person) or emits an Organization node (:organization). - BlogPosting —
headline,description,datePublished/dateModified(published only, ISO-8601),author/publisher/isPartOfrefs, canonicalurl/mainEntityOfPage,image, and optionalwordCount/articleBodycomputed from the resource's rich-text blocks. - BreadcrumbList — Home → (optional Blog) → Post.
- FAQPage — emitted only when
faq_resolverreturns{question:, answer:}pairs.
Installation
# Gemfile
gem "helios-seo"
bundle install
Configuration
# config/initializers/helios_seo.rb
Helios::Seo.configure do |config|
config.site_name = "jfb.dev"
config.site_url = "https://jfb.dev"
config.title_template = "%{title} — %{site_name}"
config.default_description = "Practical deliverability, DNS, and email-infra guidance."
config.default_og_image = "https://jfb.dev/og-default.png"
config.twitter_site = "@jasonfb"
config.twitter_creator = "@jasonfb"
# :person reuses the author node as publisher; :organization emits its own node.
config.publisher_type = :person
config. = {
name: "Jason Fleetwood-Boldt",
url: "https://jfb.dev/about",
same_as: [ "https://www.linkedin.com/in/jasonfb", "https://github.com/jasonfb" ],
job_title: "Principal Engineer",
image: "https://jfb.dev/jason.jpg",
description: "20 years building DTC and email infrastructure."
}
# Optional middle breadcrumb between Home and the post.
config. = { name: "Blog", url: "https://jfb.dev/blog" }
# Resolvers — each receives the resource; each has a safe default and is
# individually overridable.
config.canonical_url = ->(post) { "https://jfb.dev/#{post.slug}" }
config.robots_resolver = ->(post) { post.published? ? "index, follow" : "noindex, nofollow" }
config.title_resolver = ->(post) { nil } # return a String to override the <title> template
config.image_resolver = ->(post) { nil } # return an image URL; falls back to default_og_image
config.faq_resolver = ->(post) { [] } # return [{ question:, answer: }] to emit FAQPage
# Resolvers may optionally take a second argument, the ActionView context, for
# building URLs (e.g. an ActiveStorage proxy URL):
# config.image_resolver = ->(post, view) { view.polymorphic_url(post.share_image) }
# /llms.txt
config.llms_description = nil # falls back to default_description
config.llms_posts = -> { Helios::Press::Post.published.reverse_sorted }
end
Resource contract
The renderer is duck-typed. Any resource that responds to name, slug,
description, keywords, created_at, updated_at, and published? works.
blocks (with has_rich_text :content) are used opportunistically for
wordCount/articleBody and degrade gracefully when absent.
Rendering the head tags
Call the helper once, in your application layout, inside the <head> block —
app/views/layouts/application.html.erb (or whichever layout your content pages
render under). It emits the whole machine-readable head layer at that spot.
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%# helios-seo: renders title, description, canonical, robots, OG,
Twitter, and the JSON-LD block. Guard on the resource so it only
fires on pages that expose one (e.g. a post show page). %>
<%= helios_seo_tags(@post) if @post %>
<%= stylesheet_link_tag "application" %>
<%= javascript_importmap_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
The @post (or any resource matching the contract above) must be set by the
controller action for that page. On pages with no such resource, the guard skips
the helper and nothing is emitted.
Alternative —
content_for :head. If your layout already does<%= yield :head %>inside<head>, you can instead call the helper from the post'sshowview:<% content_for :head do %><%= helios_seo_tags(@post) %><% end %>. The markup still lands in the layout's<head>. Use one approach or the other, never both.
Note: title_resolver overrides only the <title> element; og:title,
twitter:title, and the schema headline always use the resource's name
(with og:site_name carrying the site identity).
Using with helios-press
helios-seo is content-agnostic and knows nothing about helios-press. The
dependency runs the other way: helios-press depends on helios-seo and
adapts its Post model to it, so in a Press app the head tags "just work."
Press registers the one resolver that needs Press-specific knowledge — the per-post social-share image — in an engine initializer:
# Registered by helios-press when helios-seo is loaded:
config.image_resolver = ->(post, view) { post.og_image_url(view: view) }
Post#og_image_url returns the explicit og_image override if set, else the
post's banner (its first image block), else nil (so helios-seo falls back to the
site-wide default_og_image). It uses the passed-in view to build a stable,
absolute ActiveStorage proxy URL (view.polymorphic_url(variant)) rather
than a short-lived signed service URL.
Canonical URLs are left to helios-seo's default — "{site_url}/{slug}" —
which matches Press's public get ":slug" route when the Public engine is
mounted at root. If you mount it on a subpath, set your own canonical_url.
Press calls the helper from posts/show.html.erb with title: false (the Press
layout owns the <title>):
<% content_for :head do %>
<%= helios_seo_tags(@post, title: false) %>
<% end %>
The host app still provides the site identity — site_name, site_url,
author, twitter_* — in its own Helios::Seo.configure block, and can
override any resolver. Because helios-seo is the sole head renderer, Press no
longer ships a _meta_tags partial, so tags are never emitted twice.
/llms.txt
Mount the engine at root so the route resolves to /llms.txt:
# config/routes.rb
Rails.application.routes.draw do
mount Helios::Seo::Engine, at: "/"
end
It serves markdown (text/plain): the site name, a description
(llms_description or default_description), and a list of published posts
(title + canonical URL) drawn from config.llms_posts.
Non-goals
Sitemaps (owned by helios-sitemap), taxonomy/categories (helios-press), author
storage/migrations, redirect management, readability scoring, an admin UI, and
schema types beyond BlogPosting/Person/Organization/WebSite/BreadcrumbList/FAQPage
are all out of scope.
Development
bin/rubocop # lint
rake test # run the unit tests (tag helper + SchemaBuilder, fixture doubles)
Releases
0.1.0
Initial release. Content-agnostic <head> renderer:
helios_seo_tags(resource)— title, meta description, canonical, robots, Open Graph, Twitter/X cards, and a single JSON-LD block.Helios::Seo::SchemaBuilder— a cross-referenced@graph(WebSite, Person, Person/Organization publisher, BlogPosting, BreadcrumbList, conditional FAQPage).- Configurable, view-aware resolvers (canonical, robots, title, image, FAQ).
/llms.txtcontroller and route.
License
MIT License. See MIT-LICENSE for details.