Migration

Each of these gems names its view helpers like the others, so an app switches in one go: there is no compatibility layer, and the old gem leaves the Gemfile when the migration lands.

From gretel

The shape stays the same, a crumb that names its parent, and view call sites barely change: breadcrumb :order, @order and breadcrumb @order work as they did. What moves is config/breadcrumbs.rb, split into one class per namespace, and a codemod writes that class.

An app switches in one go: there is no compatibility layer, and the two gems do not run side by side in production. gretel stays in the Gemfile of the migration branch only, for the comparison test below.

1. Convert the breadcrumbs file

bundle exec gretelx-convert config/breadcrumbs.rb --namespace Admin \
  --output app/breadcrumbs/admin/breadcrumbs.rb --views app/views/admin

It writes Admin::Breadcrumbs and prints, with their line, what it left out or converted with a loss. Several files (config/breadcrumbs/*.rb) make one class, the later definition of a crumb winning as in gretel.

gretelGretelX
crumb :key do |order| ... enddef key(order) ... end, a parameter tested for nil getting = nil
link label, urlcrumb label, url; link label or link label, nil give a level without a URL
a second linklinks: [link(label, url)] on the same level
parent :other, orderparent: other(order)
parent recordparent: crumb_for(record)
parent :a and parent :b in an if/elseparent: condition ? a : b
link and parent inside a final if, elsif, unlessone crumb call per branch
t(".x"), current_user: view helpershelpers.t(".x"), helpers.current_user; route helpers stay bare
%i[a b].each { |name| crumb name do ... end }, {a: "A"}.eachone method per element

It lists, without converting them: a loop over anything but a literal array or hash, link or parent anywhere else than at the top of the crumb or in its final conditional, link options (title:, class:), crumbs whose name is a Ruby keyword or an existing method (display, test), and any statement that is not a crumb. Comments are kept above the method they precede.

The codemod is built on Prism 1.2 or later: Ruby 3.4 ships it, and on Ruby 3.3 add gem "prism" to the development group.

2. Compare the trails

While gretel is still bundled, a test draws each trail with both gems and lists the differences in labels and URLs. gretel shows a crumb’s extra links as levels of their own: the comparison does the same with links.

require "gretelx/oracle"

class BreadcrumbsMigrationTest < ActionView::TestCase
  test "GretelX draws the trails gretel drew" do
    differences = GretelX::Oracle.compare(view, Admin::Breadcrumbs, [
      [:orders], [:order, orders(:alice)], [orders(:alice)], [:returns], [:returns, customers(:alice)]
    ])

    assert_empty differences, differences.join("\n")
  end
end

3. Review the call sites

--views lists the breadcrumb calls that a derived trail may now cover: :generic_form (or :new, :edit) in new and edit views, breadcrumb @order in a show view, breadcrumb :orders in an index view. Nothing is deleted: a call goes once its view shows the same trail without it. Area prefixes in the keys (:admin_order) can go too, since the namespace does that job.

4. Remove gretel

Drop gretel and config/breadcrumbs.rb, and render breadcrumb_trail in the layout where breadcrumbs was; the guide has a loop. gretel’s rendering options (style: :bootstrap5, separator, link_current, semantic markup) become choices in that markup, and its structured_data becomes breadcrumb_json_ld.

Then add the breadcrumbs test: it catches the parents, route helpers and view calls the conversion got wrong, and the crumbs nothing reaches any more.

From loaf

loaf appends crumbs with breadcrumb: at controller class level (with only: and except:), inside actions, or in views. Its breadcrumb_trail yields each crumb, with name, url and current?, to a block, and decides current? by comparing the crumb’s URL with the request (the match: option).

GretelX uses the same two helper names, so loaf must go first. Its breadcrumb names one crumb instead of appending one, its breadcrumb_trail returns an array instead of taking a block, and the current level is simply the last.

loaf

class Admin::CategoriesController < Admin::BaseController
  breadcrumb "Home", :admin_root_path
  breadcrumb "Categories", :admin_categories_path

  def show
    @category = Category.find(params[:id])
    @category.ancestors.each do |ancestor|
      breadcrumb ancestor.name, admin_category_path(ancestor)
    end
    breadcrumb @category.name, admin_category_path(@category)
  end
end
<% breadcrumb_trail do |crumb| %>
  <li><%= link_to crumb.name, crumb.url %></li>
<% end %>

GretelX

module Admin
  class Breadcrumbs < GretelX::Trail
    def root
      crumb "Home", admin_root_path
    end

    def categories
      crumb "Categories", admin_categories_path
    end

    def category(category, ancestors = category.ancestors.to_a)
      *grandparents, parent_category = ancestors
      crumb category.name, admin_category_path(category),
        parent: parent_category ? category(parent_category, grandparents) : categories
    end
  end
end
<% breadcrumb_trail.each do |level| %>
  <li><%= link_to_unless level.current?, level.label, level.url %></li>
<% end %>

show finds the category crumb on its own: the view names nothing

Steps

  1. Replace gem "loaf" with gem "gretelx".
  2. Move the controllers’ class-level crumbs into each namespace’s trail class: "Home" becomes root, a section becomes a crumb named after the controller.
  3. Delete the crumbs appended in index, show, new and edit, and turn the others into crumbs that name their parent.
  4. In views, a run of breadcrumb "Name", path calls becomes one crumb method, named with breadcrumb :name.
  5. In the layout, breadcrumb_trail do |crumb| becomes breadcrumb_trail.each do |level|, and crumb.name becomes level.label.