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 (default index, follow; noindex, nofollow for 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 configure same_as.
  • Publisher — reuses the Person node (publisher_type: :person) or emits an Organization node (:organization).
  • BlogPostingheadline, 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.
  • BreadcrumbList — Home → (optional Blog) → Post.
  • FAQPage — emitted only when faq_resolver returns {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.author = {
    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.breadcrumb_blog = { 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> blockapp/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's show view: <% 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.txt controller and route.

License

MIT License. See MIT-LICENSE for details.