crumbly 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ed17ee652b5921eb589230281536a0276637edbd773fc702efa7aafd4047de6b
4
+ data.tar.gz: d0ba23f23a3d717a272a84d2170766153bf2d8f47e470c41f75ad3851a6d9f52
5
+ SHA512:
6
+ metadata.gz: 4d56f2fe77659476bea73d87c4afd3135d110cc0c2fe5595afd7b8a8c08c3c1e46040c955baaaf0f314db2b4605f19463e5e40415089bcfa699940af9022289d
7
+ data.tar.gz: bd6b976d63c096d029fd3a9e78c0101f031d160a5f1348dee55eabff5778098d6bee5ee2d971d3c7e44d8fea27b62195361426953e84d23e090cc321a0fe1fd3
data/CHANGELOG.md ADDED
@@ -0,0 +1,68 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [2.0.0] — 2026-11-05
6
+
7
+ GretelX is now Crumbly, in the levups organization: same breadcrumbs, new names.
8
+
9
+ **Breaking**
10
+ - The gem is `gem "crumbly"`: `require "crumbly"`, `require "crumbly/test_helper"` and `require "crumbly/oracle"` replace their gretelx versions.
11
+ - The module `GretelX` becomes `Crumbly` (`Crumbly::Trail`, `Crumbly.verify!`, `Crumbly::Oracle`, `Crumbly::InvalidTrail`, `Crumbly::RelativeUrl`), and its i18n keys `gretelx.new` / `gretelx.edit` become `crumbly.new` / `crumbly.edit`.
12
+ - The executable `gretelx-convert` becomes `crumbly-convert`.
13
+
14
+ ## [1.1.0] — 2026-11-04
15
+
16
+ The app's own test suite becomes the migration oracle, `verify!` catches crumbs called with the wrong number of
17
+ arguments, and `crumbly-convert` converts the conditionals and loops it used to leave out or get wrong.
18
+
19
+ **New**
20
+ - `Crumbly::Oracle.watch`, in the test helper of a migrating app, compares with gretel every trail the tests
21
+ render: changed trails go to `Oracle.differences` with their page, including pages without a `breadcrumb` call
22
+ that now derive a trail, and calls the action would derive anyway go to `Oracle.redundant_calls`. `watch { ... }`
23
+ limits it to a block; run the watched suite with `PARALLEL_WORKERS=1`. `crumbly/oracle` now requires gretel, so
24
+ the app can bundle it with `require: false`.
25
+ - `Crumbly.verify!` reports crumbs called with the wrong number of arguments, by another crumb or by a
26
+ `breadcrumb :name, ...` in a view (`app/views/orders/index.html.erb:1 calls breadcrumb :orders with 1
27
+ argument, which takes 0`): gretel dropped or defaulted them to `nil`, a converted crumb raises `ArgumentError`.
28
+
29
+ **Fixed**
30
+ - `crumbly-convert` converts a `link` or `parent` in any conditional, `case`/`when` included, followed by more
31
+ statements: each branch ends with its own crumb call. A `parent` set under a condition keeps the one set before
32
+ it, and a condition that assigns a local no longer raises `NameError` on the first render.
33
+ - `crumbly-convert` expands loops that destructure their pairs or list constants, copies into each crumb the
34
+ locals it reads (a literal `path` is written in place, so `verify!` can check it), and turns a lambda assigned
35
+ at the top of the file into a private method of the Trail. `parent parent_name` with a literal local now calls
36
+ the crumb (`posts`) instead of `crumb_for(:posts)`.
37
+
38
+ ## [1.0.0] — 2026-11-04
39
+
40
+ First release: breadcrumbs for Rails, trails defined in Ruby classes or derived from the routes, checked in
41
+ tests. Replaces gretel: `crumbly-convert` turns its breadcrumbs files into Crumbly classes.
42
+
43
+ **New**
44
+ - One `Crumbly::Trail` subclass per namespace (`Admin::Breadcrumbs`, `Storefront::Breadcrumbs`), found from
45
+ the controller's module; one public method per crumb, its parent a plain method call.
46
+ - A view that names no crumb gets the trail of its action, derived from the routes and the controller's record.
47
+ - Derived trails follow nested routes: each `:<name>_id` path parameter is a parent level, built from the record
48
+ the controller loaded (`@order`) and never queried. A namespace crumb named after the nesting
49
+ (`order_messages`) takes precedence; the order of precedence (view, namespace crumb, derived) is documented.
50
+ - A crumb defined twice raises when the class loads; an unknown crumb raises outside production and is reported
51
+ through `ActiveSupport.error_reporter` in production.
52
+ - `breadcrumb_trail` returns data (`label`, `url`, `links`, `current?`) for the app's own components.
53
+ - `Crumbly.verify!` and `assert_breadcrumbs_valid` (`require "crumbly/test_helper"`) check every trail
54
+ from its source: unknown parents, unknown route helpers and views calling an undefined crumb raise together in
55
+ `Crumbly::InvalidTrail`; dead crumbs are returned, and fail the assertion.
56
+ - `breadcrumb_json_ld` renders the trail as a schema.org `BreadcrumbList` for Google's rich results (plain-text
57
+ names, JSON escaped for a `<script>` tag); a relative URL raises `Crumbly::RelativeUrl` outside production
58
+ and is reported in production. `Crumbly::JsonLd.call(levels)` returns it as a hash.
59
+ - `bench/render.rb` measures a render against gretel on the same trail.
60
+ - `crumbly-convert` turns gretel's breadcrumbs files into a `Crumbly::Trail` class, expanding crumbs
61
+ defined in loops over literals and listing, with their line, what it does not convert. `--views` lists the
62
+ `breadcrumb` calls a derived trail may now cover.
63
+ - `Crumbly::Oracle.compare` draws trails with gretel and with Crumbly, from a test of an app migrating.
64
+
65
+ [Unreleased]: https://github.com/levups/crumbly/compare/v2.0.0...HEAD
66
+ [2.0.0]: https://github.com/levups/crumbly/compare/v1.1.0...v2.0.0
67
+ [1.1.0]: https://github.com/levups/crumbly/compare/v1.0.0...v1.1.0
68
+ [1.0.0]: https://github.com/levups/crumbly/releases/tag/v1.0.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License
2
+
3
+ Copyright (c) 2026 Level UP Solutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,455 @@
1
+ # Crumbly
2
+
3
+ Breadcrumbs for Rails: namespaced, derived from your routes, checked in tests.
4
+
5
+ <https://levups.github.io/crumbly/>: guide, migration from gretel, breadcrumbs_on_rails and loaf, benchmarks.
6
+
7
+ [gretel](https://github.com/kzkn/gretel) works, but on a real back office (one file of about 1,000 lines,
8
+ 265 crumbs, 380 call sites), its shape is what hurts:
9
+
10
+ - one global file and one flat namespace, where a crumb defined twice silently replaces the first, and an
11
+ admin area cannot keep its breadcrumbs apart from a storefront's;
12
+ - mistakes that only show when someone opens the page;
13
+ - blocks run through `instance_exec` and helpers reached through `method_missing`;
14
+ - every trail written by hand, even when the routes already describe it.
15
+
16
+ Crumbly keeps gretel's good idea (a crumb names its parent) and changes the rest:
17
+
18
+ - **One class per namespace.** `Admin::Breadcrumbs` and `Storefront::Breadcrumbs` each hold their own crumbs,
19
+ under the same names if they like. Zeitwerk loads them, so they load before a prefork server forks.
20
+ - **One crumb per Ruby method.** The parent is a plain method call, and the URL helpers come from your routes.
21
+ There is no DSL to learn and no `method_missing`, and a backtrace points at the method.
22
+ - **Derived by default.** A view that names no crumb gets the trail of its action, built from the routes and
23
+ the record its controller loaded.
24
+ - **Mistakes raise early.** A crumb defined twice raises when the class loads, and an unknown crumb raises in
25
+ development and test. In production it is reported and the page renders without a trail.
26
+ - **Data, not HTML.** `breadcrumb_trail` returns levels (label, URL, extra links, `current?`), and your own
27
+ component renders them.
28
+
29
+ ## Installation
30
+
31
+ ```ruby
32
+ gem "crumbly"
33
+ ```
34
+
35
+ The railtie adds `breadcrumb`, `breadcrumb_trail` and `breadcrumb_json_ld` to your views, and `breadcrumbs_with` to your controllers.
36
+
37
+ ## Usage
38
+
39
+ ```ruby
40
+ # app/breadcrumbs/admin/breadcrumbs.rb
41
+ module Admin
42
+ class Breadcrumbs < Crumbly::Trail
43
+ def root
44
+ crumb "Home", admin_root_path
45
+ end
46
+
47
+ def orders
48
+ crumb "Orders", admin_orders_path
49
+ end
50
+
51
+ def order(order)
52
+ crumb order.to_s, admin_order_path(order), parent: orders
53
+ end
54
+ end
55
+ end
56
+ ```
57
+
58
+ ```erb
59
+ <%# app/views/admin/orders/show.html.erb: optional, this is what the trail derives on its own %>
60
+ <% breadcrumb @order %>
61
+
62
+ <%# app/views/layouts/admin.html.erb %>
63
+ <nav aria-label="Breadcrumb">
64
+ <ol>
65
+ <% breadcrumb_trail.each do |level| %>
66
+ <li><%= link_to_unless level.current?, level.label, level.url %></li>
67
+ <% end %>
68
+ </ol>
69
+ </nav>
70
+ ```
71
+
72
+ - `breadcrumb :name, *arguments` calls the crumb named `name`, and `breadcrumb @record` calls the crumb named
73
+ after the record's model (`order` for an `Order`). For a single-table inheritance subclass with no crumb of
74
+ its own, it falls back on its parent class's.
75
+ - `root`, when you define it, starts every trail.
76
+ - `crumb label, url = nil, parent: nil, links: []`. `url` takes a string, a record or a polymorphic array
77
+ (`[:admin, owner, :returns]`), or nothing for a level without a link. `links:` holds extra
78
+ `link(label, url)`s for a level that points to more than one page.
79
+ - `crumb_for(record)` is the crumb of a record found by its model, which is how you write a polymorphic parent.
80
+ - Private methods are helpers, not crumbs. View helpers are reached through `helpers` (`helpers.t(".orders")`).
81
+ - Every name that works in a view is a crumb, so `Crumbly::Trail` keeps its own methods private. The ones
82
+ your crumbs must not be named after are `crumb`, `link`, `crumb_for` and `helpers`.
83
+
84
+ ### Namespaces
85
+
86
+ `Admin::OrdersController` reads `Admin::Breadcrumbs`, falling back on enclosing modules and then on a
87
+ top-level `Breadcrumbs`. A controller elsewhere picks one explicitly:
88
+
89
+ ```ruby
90
+ class CheckoutController < ApplicationController
91
+ breadcrumbs_with Storefront::Breadcrumbs
92
+ end
93
+ ```
94
+
95
+ ### Derived trails
96
+
97
+ When a view does not call `breadcrumb`, the trail follows the action:
98
+
99
+ | Action | Trail |
100
+ |---|---|
101
+ | `index` | the collection: your `posts` crumb, or else the model's plural name linked to the `index` route |
102
+ | `show` | the record: your `post` crumb, or else `to_s` linked to the `show` route, under the collection |
103
+ | `new`, `create` | "New" (`crumbly.new`) under the collection |
104
+ | `edit`, `update` | "Edit" (`crumbly.edit`) under the record |
105
+
106
+ The record is the one the controller already loaded (`@post` in `PostsController`): it is never looked up
107
+ again. To change how a trail is derived, override the private methods `collection_crumb`, `record_crumb`,
108
+ `new_record_crumb` or `edit_record_crumb`.
109
+
110
+ Under a nested route, each `:<name>_id` path parameter is a parent level, outermost first.
111
+ `/admin/posts/5/comments` gives Comments under Post 5 under Posts, the post being `@post`, as the controller
112
+ loaded it: your `post` crumb, or else its `to_s` linked to its `show` route, under the posts collection. A
113
+ parent the controller did not load is never queried: the trail skips the nesting's crumb, keeps that parent's
114
+ collection (Posts) and goes straight to the current level.
115
+
116
+ ### Order of precedence
117
+
118
+ For any page, the first of these that applies builds the trail:
119
+
120
+ 1. The view's `breadcrumb`.
121
+ 2. The namespace's crumb named after the controller: `posts` in `Admin::PostsController#index`, and under a
122
+ nested route the whole nesting, called with the parents' records (`order_messages(order)` for
123
+ `/admin/orders/42/messages`). On `show` and `edit`, the crumb named after the record's model (`post`).
124
+ 3. The derived trail, whose parent levels follow the same order: your crumb for the parent's model, or else
125
+ the derived level.
126
+
127
+ ## Five real-world cases, side by side
128
+
129
+ ### A nested resource
130
+
131
+ ```ruby
132
+ # gretel, config/breadcrumbs.rb
133
+ crumb :order_messages do |order|
134
+ link "Messages", admin_order_messages_path(order)
135
+ parent :order, order
136
+ end
137
+ ```
138
+
139
+ ```ruby
140
+ # Crumbly, app/breadcrumbs/admin/breadcrumbs.rb: optional, the nested route derives the same trail
141
+ def order_messages(order)
142
+ crumb "Messages", admin_order_messages_path(order), parent: order(order)
143
+ end
144
+ ```
145
+
146
+ ### A polymorphic parent
147
+
148
+ ```ruby
149
+ # gretel
150
+ crumb :returns do |owner|
151
+ if owner
152
+ link "Returns", [:admin, owner, :returns]
153
+ parent owner
154
+ else
155
+ link "Returns", admin_returns_path
156
+ end
157
+ end
158
+ ```
159
+
160
+ ```ruby
161
+ # Crumbly
162
+ def returns(owner = nil)
163
+ return crumb("Returns", admin_returns_path) unless owner
164
+
165
+ crumb "Returns", [:admin, owner, :returns], parent: crumb_for(owner)
166
+ end
167
+ ```
168
+
169
+ ### A label computed from the record
170
+
171
+ ```ruby
172
+ # gretel
173
+ crumb :order do |order|
174
+ link "Order #{order.number}", admin_order_path(order)
175
+ if order.customer
176
+ parent :customer_orders, order.customer
177
+ else
178
+ parent :orders
179
+ end
180
+ end
181
+ ```
182
+
183
+ ```ruby
184
+ # Crumbly
185
+ def order(order)
186
+ crumb "Order #{order.number}", admin_order_path(order),
187
+ parent: order.customer ? customer_orders(order.customer) : orders
188
+ end
189
+ ```
190
+
191
+ ### A recursive tree
192
+
193
+ The ancestors are loaded once, in one query, and handed down the recursion:
194
+
195
+ ```ruby
196
+ # gretel
197
+ crumb :category do |category, ancestors = category.ancestors.to_a|
198
+ link category.name, admin_category_path(category)
199
+ *grandparents, parent_category = ancestors
200
+ parent_category ? parent(:category, parent_category, grandparents) : parent(:categories)
201
+ end
202
+ ```
203
+
204
+ ```ruby
205
+ # Crumbly
206
+ def category(category, ancestors = category.ancestors.to_a)
207
+ *grandparents, parent_category = ancestors
208
+ crumb category.name, admin_category_path(category),
209
+ parent: parent_category ? category(parent_category, grandparents) : categories
210
+ end
211
+ ```
212
+
213
+ ### A form
214
+
215
+ ```ruby
216
+ # gretel: one generic crumb, called from each new and edit view
217
+ crumb :generic_form do |model|
218
+ if model.new_record?
219
+ link "New", nil
220
+ parent model.class.model_name.plural.to_sym
221
+ else
222
+ link "Edit", nil
223
+ parent model
224
+ end
225
+ end
226
+ ```
227
+
228
+ ```erb
229
+ <%# app/views/admin/posts/edit.html.erb %>
230
+ <% breadcrumb :generic_form, @post %>
231
+ ```
232
+
233
+ ```ruby
234
+ # Crumbly: nothing to write. new and edit derive "New" under the collection, "Edit" under the record.
235
+ ```
236
+
237
+ ## Checked in tests
238
+
239
+ A crumb defined twice already raises when its class loads. One test checks the rest, without running a crumb
240
+ or loading a record:
241
+
242
+ ```ruby
243
+ # test/breadcrumbs_test.rb
244
+ require "test_helper"
245
+ require "crumbly/test_helper"
246
+
247
+ class BreadcrumbsTest < ActiveSupport::TestCase
248
+ test "breadcrumbs" do
249
+ assert_breadcrumbs_valid
250
+ end
251
+ end
252
+ ```
253
+
254
+ It eager-loads the app and reads the source of every crumb (and of the private helpers next to it) with Prism:
255
+
256
+ - every parent a crumb calls is a crumb, and every `_path` or `_url` it calls is a route helper;
257
+ - every `breadcrumb :name` in `app/views` and `app/controllers` names a crumb some trail defines;
258
+ - every crumb is called with as many arguments as it takes, by its views and by other crumbs: gretel ignored
259
+ extra arguments and passed `nil` for missing ones, a method raises `ArgumentError` (a view call spread over
260
+ several lines, or with a splat, is not counted);
261
+ - every crumb is reachable: called by another crumb, by a view or controller (`breadcrumb :name`,
262
+ `breadcrumb @record`), by a derived trail (the collection and record of a routed controller), or named after
263
+ a model. The others are dead, and fail the test.
264
+
265
+ `Crumbly.verify!` does the same outside Minitest: it raises `Crumbly::InvalidTrail` with every problem
266
+ in one message, and returns the dead crumbs (`["Admin::Breadcrumbs#drafts"]`). Both take `trail_classes:`,
267
+ `routes:` and `sources:` (the directories scanned for `breadcrumb` calls).
268
+
269
+ ## Storefronts
270
+
271
+ The trail is data, so absolute URLs are just `_url` helpers in that namespace's crumbs:
272
+
273
+ ```ruby
274
+ module Storefront
275
+ class Breadcrumbs < Crumbly::Trail
276
+ def category(category)
277
+ crumb category.name, storefront_category_url(category),
278
+ parent: category.parent ? category(category.parent) : shop
279
+ end
280
+ end
281
+ end
282
+ ```
283
+
284
+ `breadcrumb_json_ld` renders the same trail as a schema.org `BreadcrumbList`, the structured data Google reads
285
+ for its breadcrumb rich result:
286
+
287
+ ```erb
288
+ <%# app/views/layouts/storefront.html.erb, in <head> %>
289
+ <%= breadcrumb_json_ld %>
290
+ ```
291
+
292
+ - Positions start at 1, and the current level may have no URL. A level above it without a URL is not a page,
293
+ so it is left out.
294
+ - A name is plain text: an `html_safe` label (an icon, a `<b>`) has its markup stripped and its entities decoded.
295
+ - The JSON is escaped for a `<script>` tag, so a label cannot close it.
296
+ - Google only takes absolute URLs: a relative one raises `Crumbly::RelativeUrl` in development and test.
297
+ In production it is reported and the page renders without a `BreadcrumbList`. Set
298
+ `Crumbly.raise_on_relative_url` to choose.
299
+ - `Crumbly::JsonLd.call(levels)` returns the `BreadcrumbList` as a hash, for a page that builds its own
300
+ structured data.
301
+
302
+ ## Migrating from gretel
303
+
304
+ An app switches in one go: there is no compatibility layer, and the two gems do not run side by side in
305
+ production. gretel stays in the Gemfile of the migration branch only, for the comparison test below.
306
+
307
+ ### 1. Convert the breadcrumbs file
308
+
309
+ ```sh
310
+ bundle exec crumbly-convert config/breadcrumbs.rb --namespace Admin \
311
+ --output app/breadcrumbs/admin/breadcrumbs.rb --views app/views/admin
312
+ ```
313
+
314
+ It writes `Admin::Breadcrumbs` and prints, with their line, what it left out or converted with a loss. Several
315
+ files (`config/breadcrumbs/*.rb`) make one class, the later definition of a crumb winning as in gretel.
316
+
317
+ | gretel | Crumbly |
318
+ |---|---|
319
+ | `crumb :key do \|order\| ... end` | `def key(order) ... end`, a parameter tested for nil getting `= nil` |
320
+ | `link label, url` | `crumb label, url`; `link label` or `link label, nil` give a level without a URL |
321
+ | a second `link` | `links: [link(label, url)]` on the same level |
322
+ | `parent :other, order` | `parent: other(order)` |
323
+ | `parent record` | `parent: crumb_for(record)` |
324
+ | `parent :a` and `parent :b` in an `if`/`else` | `parent: condition ? a : b` |
325
+ | `parent :a`, then `parent :b if condition` | `parent: condition ? b : a` |
326
+ | `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 |
327
+ | `t(".x")`, `current_user`: view helpers | `helpers.t(".x")`, `helpers.current_user`; route helpers stay bare |
328
+ | `%i[a b].each { \|name\| crumb name do ... end }`, `{a: [:post, Comment]}.each { \|name, (parent, model)\| ... }` | one method per element |
329
+ | a local assigned before a crumb reads it: `path = :"admin_#{name}_path"` | copied into each crumb reading it, a literal written in place |
330
+ | `label = ->(record) { ... }` at the top of the file, reading no other local | a private method, `label.call(post)` becoming `label(post)` |
331
+
332
+ It lists, without converting them: a loop over anything but a literal array or hash (of symbols, strings,
333
+ numbers and constants), `link` or `parent` inside anything but a conditional (a loop, a block), `link` options
334
+ (`title:`, `class:`), crumbs whose name is a Ruby keyword or an existing method (`display`, `test`), and any
335
+ statement that is not a crumb, a loop or a local. Comments are kept above the method they precede.
336
+
337
+ 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
338
+ development group.
339
+
340
+ ### 2. Compare the trails
341
+
342
+ While gretel is still bundled, the tests draw each trail with both gems and list the differences in labels and
343
+ URLs. gretel shows a crumb's extra links as levels of their own: the comparison does the same with `links`. Keep
344
+ gretel in the test group with `require: false`: `crumbly/oracle` loads it, without the view helpers whose
345
+ `breadcrumb` would replace Crumbly's.
346
+
347
+ ```ruby
348
+ # Gemfile, on the migration branch
349
+ gem "gretel", require: false, group: :test
350
+ ```
351
+
352
+ The views already call `breadcrumb` the way Crumbly expects, so the app's own controller, integration and system
353
+ tests can do the comparing. With `Crumbly::Oracle.watch` in the test helper, every page they render is drawn by
354
+ gretel too, from the same `breadcrumb` call:
355
+
356
+ ```ruby
357
+ # test/test_helper.rb, once the app is loaded
358
+ require "crumbly/oracle"
359
+
360
+ Crumbly::Oracle.watch
361
+ Minitest.after_run do
362
+ puts Crumbly::Oracle.differences.to_a, Crumbly::Oracle.redundant_calls.to_a
363
+ end
364
+ ```
365
+
366
+ `differences` holds each page whose trail changed, gretel's and Crumbly's side by side, including the pages
367
+ without a `breadcrumb` call, which gretel left without a trail and which now derive one. `redundant_calls`
368
+ holds the calls whose trail the action derives anyway. `watch { ... }` watches a block only. Parallel test
369
+ workers each fill their own lists, which `after_run` in the parent process never sees: run the watched suite
370
+ with `PARALLEL_WORKERS=1`. A call gretel cannot draw shows as a difference, with gretel's error.
371
+
372
+ For the crumbs no test renders, `compare` takes the calls to check:
373
+
374
+ ```ruby
375
+ class BreadcrumbsMigrationTest < ActionView::TestCase
376
+ test "Crumbly draws the trails gretel drew" do
377
+ differences = Crumbly::Oracle.compare(view, Admin::Breadcrumbs, [
378
+ [:orders], [:order, orders(:alice)], [orders(:alice)], [:returns], [:returns, customers(:alice)]
379
+ ])
380
+
381
+ assert_empty differences, differences.join("\n")
382
+ end
383
+ end
384
+ ```
385
+
386
+ ### 3. Review the call sites
387
+
388
+ `--views` lists the `breadcrumb` calls that a derived trail may now cover: `:generic_form` (or `:new`,
389
+ `:edit`) in new and edit views, `breadcrumb @order` in a show view, `breadcrumb :orders` in an index view.
390
+ Nothing is deleted: a call goes once its view shows the same trail without it, which is what
391
+ `Crumbly::Oracle.redundant_calls` lists for the pages the tests render.
392
+
393
+ ### 4. Remove gretel
394
+
395
+ Drop gretel and `config/breadcrumbs.rb`, and render `breadcrumb_trail` in the layout where `breadcrumbs` was.
396
+
397
+ ## Requirements
398
+
399
+ Ruby 3.3 or later, and Rails 8.0 or later (Action Pack and Active Support).
400
+
401
+ ## Performance
402
+
403
+ `bench/render.rb` renders the same five-level trail (Home > Customers > Alice > Orders > Order 2026-0042) with
404
+ gretel and with Crumbly, and checks that both produce the same output. The `<ol>` is gretel's
405
+ `breadcrumbs(style: :ol)` against a ten-line helper over `breadcrumb_trail`. The JSON-LD is gretel's
406
+ `structured_data(url_base:)` (paths behind a prefix) against `breadcrumb_json_ld` (`_url` helpers), both
407
+ encoded and escaped the same way.
408
+
409
+ Ruby 4.0.9, Rails 8.1.4, gretel 5.1.0, Apple M5 (arm64 macOS), median of three runs of benchmark-ips:
410
+
411
+ | Render | Allocations | Time, YJIT off | Time, YJIT on |
412
+ |---|---|---|---|
413
+ | gretel `<ol>` | 345 | 57.6 µs | 35.1 µs |
414
+ | Crumbly `<ol>` | 274 | 45.4 µs | 29.8 µs |
415
+ | gretel JSON-LD | 268 | 41.3 µs | 25.4 µs |
416
+ | Crumbly JSON-LD | 287 | 43.3 µs | 29.5 µs |
417
+
418
+ Most of the JSON-LD gap comes from Rails' `_url` helpers, which are slower than `_path` ones: building the
419
+ `BreadcrumbList` from the levels accounts for 1.7 µs of it.
420
+
421
+ ```sh
422
+ bundle exec ruby bench/render.rb
423
+ bundle exec ruby --yjit bench/render.rb
424
+ ```
425
+
426
+ ## Development
427
+
428
+ ```sh
429
+ bundle install
430
+ bundle exec rake test # latest Rails
431
+ BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile bundle exec rake test # Rails 8.0
432
+ bundle exec rubocop
433
+ ```
434
+
435
+ ### Releasing
436
+
437
+ Pull requests leave `CHANGELOG.md` alone: a release starts by writing its notes under `## [Unreleased]`, from the
438
+ pull requests merged since the last tag, without committing them. Then, from an up-to-date `main` whose CI is
439
+ green:
440
+
441
+ ```sh
442
+ bin/release minor --dry-run # the notes and the changes, nothing written
443
+ bin/release minor # or patch, major, or an exact version such as 1.2.0
444
+ ```
445
+
446
+ `main` takes no direct push, so `bin/release` dates the Unreleased section and bumps the version on a branch
447
+ `release/vX.Y.Z`, opens its pull request, which merges itself once CI is green, and waits for it. It then pushes a
448
+ signed tag `vX.Y.Z` on the merge commit (`bin/release --tag X.Y.Z` does it if you stopped waiting). The tag starts
449
+ the Release workflow, which publishes the gem to RubyGems through trusted publishing and creates the GitHub release
450
+ with the version's notes and the `.gem`.
451
+
452
+ ## License
453
+
454
+ MIT, by Level UP Solutions. Crumbly keeps the idea of [gretel](https://github.com/kzkn/gretel), by Lasse Bunk and
455
+ Kazuki Nishikawa: a crumb names its parent.
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "crumbly/converter"
6
+ require "crumbly/derived_call_sites"
7
+
8
+ namespace = nil
9
+ output_path = nil
10
+ view_directories = []
11
+ parser = OptionParser.new do |options|
12
+ options.banner = "Usage: crumbly-convert [BREADCRUMBS_FILE...] [--namespace Admin] [--output PATH] [--views DIR]"
13
+ options.on("--namespace NAME", "Module of the Breadcrumbs class: Admin writes Admin::Breadcrumbs") do |name|
14
+ namespace = name
15
+ end
16
+ options.on("--output PATH", "Where to write the class (default: standard output)") { |path| output_path = path }
17
+ options.on("--views DIR", "List the breadcrumb calls of these views that derived trails cover") do |directory|
18
+ view_directories << directory
19
+ end
20
+ end
21
+
22
+ begin
23
+ paths = parser.parse(ARGV)
24
+ abort parser.help if paths.empty? && view_directories.empty?
25
+
26
+ if paths.any?
27
+ result = Crumbly::Converter.call(paths.to_h { |path| [path, File.read(path)] }, namespace:)
28
+ output_path ? File.write(output_path, result.ruby) : $stdout.write(result.ruby)
29
+ warn "Not converted, or converted with a loss:" if result.notes.any?
30
+ result.notes.each { |note| warn " #{note}" }
31
+ end
32
+
33
+ call_sites = Crumbly::DerivedCallSites.call(*view_directories)
34
+ warn "Calls a derived trail may now cover, to review by hand:" if call_sites.any?
35
+ call_sites.each { |call_site| warn " #{call_site}" }
36
+ rescue OptionParser::ParseError, ArgumentError, SystemCallError => exception
37
+ abort "crumbly-convert: #{exception.message}"
38
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crumbly
4
+ module Controller
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :breadcrumbs_class, instance_accessor: false, default: nil
9
+ end
10
+
11
+ class_methods do
12
+ def breadcrumbs_with(trail_class)
13
+ self.breadcrumbs_class = trail_class
14
+ end
15
+ end
16
+ end
17
+ end