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 crumbly-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.
| gretel | Crumbly |
|---|---|
crumb :key do |order| ... end | def key(order) ... end, a parameter tested for nil getting = nil |
link label, url | crumb label, url; link label or link label, nil give a level without a URL |
a second link | links: [link(label, url)] on the same level |
parent :other, order | parent: other(order) |
parent record | parent: crumb_for(record) |
parent :a and parent :b in an if/else | parent: condition ? a : b |
parent :a, then parent :b if condition | parent: condition ? b : a |
link and parent inside an if, elsif, unless or case, modifiers included | one crumb call per branch, the statements after the conditional copied into each |
t(".x"), current_user: view helpers | helpers.t(".x"), helpers.current_user; route helpers stay bare |
%i[a b].each { |name| crumb name do ... end }, {a: [:post, Comment]}.each { |name, (parent, model)| ... } | one method per element |
a local assigned before a crumb reads it: path = :"admin_#{name}_path" | copied into each crumb reading it, a literal written in place |
label = ->(record) { ... } at the top of the file, reading no other local | a private method, label.call(post) becoming label(post) |
It lists, without converting them: a loop over anything but a literal array or hash (of symbols, strings, numbers and constants), link or parent inside anything but a conditional (a loop, a block), 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, a loop or a local. 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, the tests draw each trail with both gems and list 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. Keep gretel in the test group with require: false: crumbly/oracle loads it, without the view helpers whose breadcrumb would replace Crumbly’s.
# Gemfile, on the migration branch
gem "gretel", require: false, group: :test
The views already call breadcrumb the way Crumbly expects, so the app’s own controller, integration and system tests can do the comparing. With Crumbly::Oracle.watch in the test helper, every page they render is drawn by gretel too, from the same breadcrumb call:
# test/test_helper.rb, once the app is loaded
require "crumbly/oracle"
Crumbly::Oracle.watch
Minitest.after_run do
puts Crumbly::Oracle.differences.to_a, Crumbly::Oracle.redundant_calls.to_a
end
differences holds each page whose trail changed, gretel’s and Crumbly’s side by side, including the pages without a breadcrumb call, which gretel left without a trail and which now derive one. redundant_calls holds the calls whose trail the action derives anyway. watch { ... } watches a block only. Parallel test workers each fill their own lists, which after_run in the parent process never sees: run the watched suite with PARALLEL_WORKERS=1. A call gretel cannot draw shows as a difference, with gretel’s error.
For the crumbs no test renders, compare takes the calls to check:
class BreadcrumbsMigrationTest < ActionView::TestCase
test "Crumbly draws the trails gretel drew" do
differences = Crumbly::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, which is what Crumbly::Oracle.redundant_calls lists for the pages the tests render. 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).
Crumbly 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 %>
Crumbly
module Admin
class Breadcrumbs < Crumbly::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
- Replace
gem "loaf"withgem "crumbly". - Move the controllers’ class-level crumbs into each namespace’s trail class: "Home" becomes
root, a section becomes a crumb named after the controller. - Delete the crumbs appended in
index,show,newandedit, and turn the others into crumbs that name their parent. - In views, a run of
breadcrumb "Name", pathcalls becomes one crumb method, named withbreadcrumb :name. - In the layout,
breadcrumb_trail do |crumb|becomesbreadcrumb_trail.each do |level|, andcrumb.namebecomeslevel.label.