Guide

A trail class per namespace, a method per crumb, and a view that names its crumb or lets its action derive one.

Installation

# Gemfile
gem "gretelx"

The railtie adds breadcrumb, breadcrumb_trail and breadcrumb_json_ld to your views, and breadcrumbs_with to your controllers. Ruby 3.3 or later, Rails 8.0 or later (Action Pack and Active Support).

Usage

A namespace’s crumbs live in one subclass of GretelX::Trail. Each public method is a crumb; its parent is a call to another one.

# app/breadcrumbs/admin/breadcrumbs.rb
module Admin
  class Breadcrumbs < GretelX::Trail
    def root
      crumb "Home", admin_root_path
    end

    def orders
      crumb "Orders", admin_orders_path
    end

    def order(order)
      crumb order.to_s, admin_order_path(order), parent: orders
    end

    def order_documents(order)
      crumb "Documents", nil, parent: order(order),
        links: [link("Invoices", admin_order_invoices_path(order)), link("Receipts", admin_order_receipts_path(order))]
    end
  end
end
<%# app/views/admin/orders/show.html.erb: optional, this is what the trail derives on its own %>
<% breadcrumb @order %>

In a view

  • breadcrumb :name, *arguments calls the crumb named name with those arguments.
  • breadcrumb @record calls the crumb named after the record’s model (order for an Order). A single-table inheritance subclass with no crumb of its own falls back on its parent class’s.
  • A view that calls neither gets a derived trail.

In a trail class

MethodWhat it does
crumb label, url = nil, parent: nil, links: []One level. url takes a string, a record or a polymorphic array ([:admin, owner, :returns]), or nothing for a level without a link.
link label, urlAn extra link, for links:, when a level points to more than one page.
crumb_for recordThe crumb of a record, found by its model: how you write a polymorphic parent.
helpersThe view context: helpers.t(".orders"), helpers.current_user.
rootNot provided: when you define it, it starts every trail.

Private methods are helpers, not crumbs. Every public method is callable from a view, so GretelX::Trail keeps its own methods private; your crumbs must not be named crumb, link, crumb_for or helpers.

Rendering

breadcrumb_trail returns an array of levels, root first. Each level has label, url (a path, or nil), links (each with label and url) and current?, true on the last one. The gem ships no HTML: whether the current page is a link, which separator to use, and how a level with extra links looks are your component’s choices.

<%# app/views/layouts/admin.html.erb %>
<nav aria-label="Breadcrumb">
  <ol>
    <% breadcrumb_trail.each do |level| %>
      <li>
        <%= link_to_unless level.current?, level.label, level.url %>
        <% level.links.each do |link| %>
          <%= link_to link.label, link.url %>
        <% end %>
      </li>
    <% end %>
  </ol>
</nav>

A view’s breadcrumb call must come before the layout reads breadcrumb_trail, which is the case for anything in the view itself, since Rails renders the view before its layout.

Namespaces

Admin::OrdersController reads Admin::Breadcrumbs, falling back on enclosing modules and then on a top-level Breadcrumbs. Two namespaces may both define order: they never see each other.

A controller outside the namespace picks a trail class explicitly. Subclasses inherit the choice:

class CheckoutController < ApplicationController
  breadcrumbs_with Storefront::Breadcrumbs
end

The classes live in app/breadcrumbs/, an autoloaded directory, so Zeitwerk loads them with the rest of the app and eager loading has them in memory before a prefork server forks.

Derived trails

When a view does not call breadcrumb, the trail follows the action:

ActionTrail
indexThe collection: your posts crumb, or else the model’s human plural name linked to the index route.
showThe record: your post crumb, or else its to_s linked to the show route, under the collection.
new, create"New" under the collection.
edit, update"Edit" under the record.

The record is the one the controller already loaded, @post in PostsController: it is never looked up again, and without it show and edit derive no trail. A controller with no index or show route still gets its level, without a link. Other actions derive nothing.

Labels

"New" and "Edit" are translated through gretelx.new and gretelx.edit:

# config/locales/es.yml
es:
  gretelx:
    new: Nuevo
    edit: Editar

Overrides

To change how a trail is derived in a namespace, override any of four private methods in its trail class: collection_crumb, record_crumb(record), new_record_crumb, edit_record_crumb(record). The controller they read is private too.

module Admin
  class Breadcrumbs < GretelX::Trail
    private

    def new_record_crumb
      crumb helpers.t("admin.breadcrumbs.new"), parent: collection_crumb
    end
  end
end

Nested routes

Under a nested route, each :<name>_id path parameter is a parent level, outermost first. /admin/posts/5/comments gives Comments under Post 5 under Posts, the post being @post, as the controller loaded it: your post crumb, or else its to_s linked to its show route, under the posts collection.

A parent the controller did not load is never queried: the trail skips the nesting’s crumb, keeps that parent’s collection (Posts) and goes straight to the current level.

Order of precedence

For any page, the first of these that applies builds the trail:

  1. The view’s breadcrumb.
  2. The namespace’s crumb named after the controller: posts in Admin::PostsController#index, and under a nested route the whole nesting, called with the parents’ records (order_messages(order) for /admin/orders/42/messages). On show and edit, the crumb named after the record’s model (post).
  3. The derived trail, whose parent levels follow the same order: your crumb for the parent’s model, or else the derived level.

Checked in tests

A crumb defined twice already raises when its class loads. One test checks the rest, without running a crumb or loading a record:

# test/breadcrumbs_test.rb
require "test_helper"
require "gretelx/test_helper"

class BreadcrumbsTest < ActiveSupport::TestCase
  test "breadcrumbs" do
    assert_breadcrumbs_valid
  end
end

It eager-loads the app and reads the source of every crumb (and of the private helpers next to it) with Prism:

  • every parent a crumb calls is a crumb, and every _path or _url it calls is a route helper;
  • every breadcrumb :name in app/views and app/controllers names a crumb some trail defines;
  • every crumb is reachable: called by another crumb, by a view or controller (breadcrumb :name, breadcrumb @record), by a derived trail (the collection and record of a routed controller), or named after a model. The others are dead, and fail the test.

GretelX.verify! does the same outside Minitest: it raises GretelX::InvalidTrail with every problem in one message, and returns the dead crumbs (["Admin::Breadcrumbs#drafts"]). Both take trail_classes:, routes: and sources: (the directories scanned for breadcrumb calls).

Storefronts

The trail is data, so absolute URLs are just _url helpers in that namespace’s crumbs. They read the host from the current request.

module Storefront
  class Breadcrumbs < GretelX::Trail
    def shop
      crumb "Shop", storefront_root_url
    end

    def category(category)
      crumb category.name, storefront_category_url(category),
        parent: category.parent ? category(category.parent) : shop
    end
  end
end

JSON-LD

breadcrumb_json_ld renders the same trail as a schema.org BreadcrumbList, the structured data Google reads for its breadcrumb rich result:

<%# app/views/layouts/storefront.html.erb, in <head> %>
<%= breadcrumb_json_ld %>
  • Positions start at 1, and the current level may have no URL. A level above it without a URL is not a page, so it is left out.
  • A name is plain text: an html_safe label (an icon, a <b>) has its markup stripped and its entities decoded.
  • The JSON is escaped for a <script> tag, so a label cannot close it.
  • Google only takes absolute URLs: a relative one raises GretelX::RelativeUrl in development and test. In production it is reported and the page renders without a BreadcrumbList. Set GretelX.raise_on_relative_url to choose.
  • GretelX::JsonLd.call(levels) returns the BreadcrumbList as a hash, for a page that builds its own structured data.

Errors

Every error is a GretelX::Error.

ErrorWhen
GretelX::DuplicateCrumbA trail class defines the same public method twice. Raised when the class loads, so at boot with eager loading, and in any test that loads it.
GretelX::UnknownCrumbA view names a crumb that does not exist, or breadcrumb @record / crumb_for finds no crumb for the record’s model. Raised in development and test; in production, reported through Rails.error and the page renders without a trail.
GretelX::MissingTrailA view reads the trail of a controller that has no trail class, neither found from its module nor set with breadcrumbs_with. The message names the class to define.
GretelX::InvalidTrailGretelX.verify! finds a crumb calling an unknown crumb or route helper, or a breadcrumb :name that no trail defines. The message lists every problem at once; assert_breadcrumbs_valid turns it into a test failure.
GretelX::RelativeUrlbreadcrumb_json_ld meets a level whose URL is not absolute. Raised in development and test; in production, reported through Rails.error and the page renders without a BreadcrumbList.

To change where an unknown crumb or a relative URL raises, set it in an initializer:

# config/initializers/gretelx.rb
GretelX.raise_on_unknown_crumb = true # on staging too
GretelX.raise_on_relative_url = true