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 +7 -0
- data/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +455 -0
- data/exe/crumbly-convert +38 -0
- data/lib/crumbly/controller.rb +17 -0
- data/lib/crumbly/converter/crumb_definition.rb +476 -0
- data/lib/crumbly/converter/source.rb +70 -0
- data/lib/crumbly/converter.rb +288 -0
- data/lib/crumbly/crumb.rb +7 -0
- data/lib/crumbly/derived_call_sites.rb +50 -0
- data/lib/crumbly/errors.rb +15 -0
- data/lib/crumbly/json_ld.rb +51 -0
- data/lib/crumbly/level.rb +9 -0
- data/lib/crumbly/oracle.rb +147 -0
- data/lib/crumbly/railtie.rb +10 -0
- data/lib/crumbly/test_helper.rb +16 -0
- data/lib/crumbly/trail.rb +232 -0
- data/lib/crumbly/verifier.rb +201 -0
- data/lib/crumbly/version.rb +5 -0
- data/lib/crumbly/view_helpers.rb +30 -0
- data/lib/crumbly.rb +78 -0
- metadata +110 -0
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.
|
data/exe/crumbly-convert
ADDED
|
@@ -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
|