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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crumbly
|
|
4
|
+
# One subclass per namespace (Admin::Breadcrumbs < Crumbly::Trail): each public method defined there is a crumb.
|
|
5
|
+
class Trail
|
|
6
|
+
RouteParent = Data.define(:name, :id, :record)
|
|
7
|
+
|
|
8
|
+
# Included after the routes' url_helpers, whose own url_options would otherwise win.
|
|
9
|
+
module UrlOptionsFromView
|
|
10
|
+
def url_options
|
|
11
|
+
helpers.url_options
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
attr_reader :own_crumb_names
|
|
17
|
+
|
|
18
|
+
def inherited(subclass)
|
|
19
|
+
super
|
|
20
|
+
routes = Crumbly.routes
|
|
21
|
+
if routes
|
|
22
|
+
subclass.include(routes.url_helpers)
|
|
23
|
+
subclass.include(UrlOptionsFromView)
|
|
24
|
+
end
|
|
25
|
+
# Set last: including url_helpers defines methods in the subclass, which are not crumbs.
|
|
26
|
+
subclass.instance_variable_set(:@own_crumb_names, Set.new)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def method_added(name)
|
|
30
|
+
super
|
|
31
|
+
return unless own_crumb_names && public_method_defined?(name, false)
|
|
32
|
+
return if own_crumb_names.add?(name)
|
|
33
|
+
|
|
34
|
+
raise DuplicateCrumb, "#{self}##{name} is defined twice: the second definition would replace the first"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def crumb?(name)
|
|
38
|
+
trail_class = self
|
|
39
|
+
while trail_class < Trail
|
|
40
|
+
return trail_class.public_method_defined?(name) if trail_class.own_crumb_names.include?(name)
|
|
41
|
+
|
|
42
|
+
trail_class = trail_class.superclass
|
|
43
|
+
end
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resolve(helpers, target, *arguments)
|
|
48
|
+
new(helpers).send(:levels_for, target, arguments)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def derive(helpers)
|
|
52
|
+
new(helpers).send(:derived_levels)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def initialize(helpers)
|
|
57
|
+
@helpers = helpers
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader :helpers
|
|
63
|
+
|
|
64
|
+
def crumb(label, url = nil, parent: nil, links: [])
|
|
65
|
+
Crumb.new(label:, url: path_for(url), links:, parent:)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def link(label, url)
|
|
69
|
+
Link.new(label:, url: path_for(url))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The crumb of a record, found by its model name: `parent: crumb_for(owner)` for a polymorphic parent.
|
|
73
|
+
def crumb_for(record, *)
|
|
74
|
+
name = crumb_name_for(record)
|
|
75
|
+
return public_send(name, record, *) if name
|
|
76
|
+
|
|
77
|
+
unknown_crumb("#{self.class} has no crumb for #{record.class}: define ##{record.model_name.singular}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def path_for(url)
|
|
81
|
+
case url
|
|
82
|
+
when nil, String then url
|
|
83
|
+
else polymorphic_path(url)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def levels_for(target, arguments)
|
|
88
|
+
levels(target_crumb(target, arguments))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def target_crumb(target, arguments)
|
|
92
|
+
return crumb_for(target, *arguments) unless target in Symbol | String
|
|
93
|
+
|
|
94
|
+
name = target.to_sym
|
|
95
|
+
return public_send(name, *arguments) if self.class.crumb?(name)
|
|
96
|
+
|
|
97
|
+
unknown_crumb("#{self.class} has no crumb named #{name}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def levels(current_crumb)
|
|
101
|
+
return [] unless current_crumb
|
|
102
|
+
|
|
103
|
+
crumbs = []
|
|
104
|
+
crumb = current_crumb
|
|
105
|
+
while crumb
|
|
106
|
+
crumbs << crumb
|
|
107
|
+
crumb = crumb.parent
|
|
108
|
+
end
|
|
109
|
+
crumbs.reverse!
|
|
110
|
+
if self.class.crumb?(:root)
|
|
111
|
+
root_crumb = root
|
|
112
|
+
crumbs.unshift(root_crumb) unless crumbs.first == root_crumb
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
last_index = crumbs.size - 1
|
|
116
|
+
crumbs.each_with_index.map do |level_crumb, index|
|
|
117
|
+
Level.new(label: level_crumb.label, url: level_crumb.url, links: level_crumb.links, current: index == last_index)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# STI: a subclass without a crumb of its own falls back on its parent class's.
|
|
122
|
+
def crumb_name_for(record)
|
|
123
|
+
model_class = record.class
|
|
124
|
+
loop do
|
|
125
|
+
name = model_class.model_name.singular.to_sym
|
|
126
|
+
return name if self.class.crumb?(name)
|
|
127
|
+
return unless model_class.respond_to?(:base_class) && model_class != model_class.base_class
|
|
128
|
+
|
|
129
|
+
model_class = model_class.superclass
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def unknown_crumb(message)
|
|
134
|
+
exception = UnknownCrumb.new(message)
|
|
135
|
+
raise exception if Crumbly.raise_on_unknown_crumb?
|
|
136
|
+
|
|
137
|
+
ActiveSupport.error_reporter.report(exception, handled: true)
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def controller
|
|
142
|
+
helpers.controller
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def derived_levels
|
|
146
|
+
levels(derived_crumb)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def derived_crumb
|
|
150
|
+
record = controller.instance_variable_get(:"@#{controller.controller_name.singularize}")
|
|
151
|
+
case controller.action_name
|
|
152
|
+
when "index" then collection_crumb
|
|
153
|
+
when "show" then record && record_crumb(record)
|
|
154
|
+
when "new", "create" then new_record_crumb
|
|
155
|
+
when "edit", "update" then record && edit_record_crumb(record)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The four crumbs below build a derived trail; override any of them as a private method.
|
|
160
|
+
def collection_crumb
|
|
161
|
+
nested_collection_crumb(controller.controller_name, route_parents)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def record_crumb(record)
|
|
165
|
+
return crumb_for(record) if crumb_name_for(record)
|
|
166
|
+
|
|
167
|
+
crumb(record.to_s, route_path(action: "show", id: record), parent: collection_crumb)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def new_record_crumb
|
|
171
|
+
crumb(I18n.t("crumbly.new", default: "New"), parent: collection_crumb)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def edit_record_crumb(record)
|
|
175
|
+
crumb(I18n.t("crumbly.edit", default: "Edit"), parent: record_crumb(record))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Under a nested route (/orders/42/messages), a crumb named after the whole nesting (order_messages) is
|
|
179
|
+
# called with the parents' records; otherwise the collection is derived under its parent record.
|
|
180
|
+
def nested_collection_crumb(name, parents)
|
|
181
|
+
loaded_parents = parents.take_while(&:record)
|
|
182
|
+
if loaded_parents.size < parents.size
|
|
183
|
+
missing_parent = parents[loaded_parents.size]
|
|
184
|
+
parent = nested_collection_crumb(missing_parent.name.pluralize, loaded_parents)
|
|
185
|
+
return derived_collection_crumb(name, parents, parent:)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
crumb_name = [*parents.map(&:name), name].join("_").to_sym
|
|
189
|
+
return public_send(crumb_name, *parents.map(&:record)) if self.class.crumb?(crumb_name)
|
|
190
|
+
|
|
191
|
+
derived_collection_crumb(name, parents, parent: parents.empty? ? nil : parent_record_crumb(parents))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def derived_collection_crumb(name, parents, parent:)
|
|
195
|
+
model_class = name.classify.safe_constantize
|
|
196
|
+
label = name.humanize
|
|
197
|
+
label = model_class.model_name.human(count: 2, default: label) if model_class.respond_to?(:model_name)
|
|
198
|
+
crumb(label, route_path(resources: name, action: "index", **route_parent_ids(parents)), parent:)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parent_record_crumb(parents)
|
|
202
|
+
*ancestors, parent = parents
|
|
203
|
+
return crumb_for(parent.record) if crumb_name_for(parent.record)
|
|
204
|
+
|
|
205
|
+
name = parent.name.pluralize
|
|
206
|
+
crumb(parent.record.to_s, route_path(resources: name, action: "show", id: parent.id, **route_parent_ids(ancestors)),
|
|
207
|
+
parent: nested_collection_crumb(name, ancestors))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The record of each :<name>_id path parameter, outermost first, as the controller loaded it (@<name>).
|
|
211
|
+
def route_parents
|
|
212
|
+
controller.request.path_parameters.filter_map do |key, value|
|
|
213
|
+
next unless key.end_with?("_id")
|
|
214
|
+
|
|
215
|
+
name = key.to_s.delete_suffix("_id")
|
|
216
|
+
RouteParent.new(name:, id: value, record: controller.instance_variable_get(:"@#{name}"))
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def route_parent_ids(parents)
|
|
221
|
+
parents.to_h { |parent| [:"#{parent.name}_id", parent.id] }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# A controller without an index or show route still gets its level, without a link.
|
|
225
|
+
def route_path(resources: controller.controller_name, **options)
|
|
226
|
+
namespace = controller.controller_path.delete_suffix(controller.controller_name)
|
|
227
|
+
helpers.url_for(controller: "/#{namespace}#{resources}", only_path: true, **options)
|
|
228
|
+
rescue ActionController::UrlGenerationError
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Crumbly
|
|
6
|
+
class Verifier
|
|
7
|
+
BREADCRUMB_CALL = /\bbreadcrumb[\s(]+(?::(\w+)|["'](\w+)["']|@(\w+))/
|
|
8
|
+
ROUTE_HELPER = /_(?:path|url)\z/
|
|
9
|
+
|
|
10
|
+
def initialize(trail_classes:, routes:, sources:)
|
|
11
|
+
@trail_classes = trail_classes
|
|
12
|
+
@routes = routes
|
|
13
|
+
@sources = sources
|
|
14
|
+
@problems = []
|
|
15
|
+
@called_names = Set[:root]
|
|
16
|
+
@definitions_by_path = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
trail_classes.each { |trail_class| verify_crumbs(trail_class) }
|
|
21
|
+
verify_sources
|
|
22
|
+
raise InvalidTrail, "#{problems.size} breadcrumb problem(s):\n- #{problems.join("\n- ")}" if problems.any?
|
|
23
|
+
|
|
24
|
+
dead_crumbs
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :trail_classes, :routes, :sources, :problems, :called_names, :definitions_by_path
|
|
30
|
+
|
|
31
|
+
def verify_crumbs(trail_class)
|
|
32
|
+
crumb_methods = crumb_names(trail_class).map { |name| trail_class.instance_method(name) }
|
|
33
|
+
crumb_paths = crumb_methods.filter_map { |crumb_method| crumb_method.source_location&.first }
|
|
34
|
+
helper_methods = trail_class.private_instance_methods(false).map { |name| trail_class.instance_method(name) }
|
|
35
|
+
helper_methods.select! { |helper_method| crumb_paths.include?(helper_method.source_location&.first) }
|
|
36
|
+
|
|
37
|
+
(crumb_methods + helper_methods).each { |method| verify_calls(trail_class, method) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def verify_calls(trail_class, method)
|
|
41
|
+
definition = definition_of(method)
|
|
42
|
+
return unless definition
|
|
43
|
+
|
|
44
|
+
receiverless_calls(definition).each do |call|
|
|
45
|
+
name = call.name
|
|
46
|
+
location = "#{method.source_location.first}:#{call.location.start_line}"
|
|
47
|
+
if trail_class.crumb?(name)
|
|
48
|
+
arity_problem = arity_problem(trail_class, name, call)
|
|
49
|
+
problems << "#{trail_class}##{method.name} calls #{name} #{arity_problem} (#{location})" if arity_problem
|
|
50
|
+
end
|
|
51
|
+
next if name == method.name
|
|
52
|
+
|
|
53
|
+
called_names << name
|
|
54
|
+
next if trail_class.method_defined?(name) || trail_class.private_method_defined?(name)
|
|
55
|
+
|
|
56
|
+
kind = ROUTE_HELPER.match?(name) ? "a route helper" : "a crumb"
|
|
57
|
+
problems << "#{trail_class}##{method.name} calls #{name}, which is not #{kind} (#{location})"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def definition_of(method)
|
|
62
|
+
path, line = method.source_location
|
|
63
|
+
return unless path && File.file?(path)
|
|
64
|
+
|
|
65
|
+
definitions_by_path[path] ||= definitions_in(Prism.parse_file(path).value)
|
|
66
|
+
definitions_by_path[path][[method.name, line]]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def definitions_in(tree)
|
|
70
|
+
definitions = {}
|
|
71
|
+
each_node(tree) do |node|
|
|
72
|
+
definitions[[node.name, node.location.start_line]] = node if node.is_a?(Prism::DefNode)
|
|
73
|
+
end
|
|
74
|
+
definitions
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def receiverless_calls(definition)
|
|
78
|
+
calls = []
|
|
79
|
+
each_node(definition) { |node| calls << node if node in Prism::CallNode[receiver: nil] }
|
|
80
|
+
calls
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def each_node(node, &block)
|
|
84
|
+
yield node
|
|
85
|
+
node.compact_child_nodes.each { |child| each_node(child, &block) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def verify_sources
|
|
89
|
+
defined_names = trail_classes.flat_map { |trail_class| crumb_names(trail_class) }.to_set
|
|
90
|
+
source_files.each do |path|
|
|
91
|
+
File.foreach(path).with_index(1) do |line, number|
|
|
92
|
+
line.to_enum(:scan, BREADCRUMB_CALL).each do
|
|
93
|
+
match = Regexp.last_match
|
|
94
|
+
symbol, string, instance_variable = match.captures
|
|
95
|
+
name = (symbol || string || instance_variable).to_sym
|
|
96
|
+
called_names << name
|
|
97
|
+
next if instance_variable
|
|
98
|
+
|
|
99
|
+
unless defined_names.include?(name)
|
|
100
|
+
problems << "#{path}:#{number} calls breadcrumb :#{name}, which no trail defines"
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
call = breadcrumb_call(line[match.begin(0)..])
|
|
104
|
+
arity_problem = call && arity_problem(trail_classes, name, call)
|
|
105
|
+
problems << "#{path}:#{number} calls breadcrumb :#{name} #{arity_problem}" if arity_problem
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The call as Ruby, when the rest of its line parses: `breadcrumb :order, @order %>` in ERB, but not a
|
|
112
|
+
# call spread over several lines, whose arguments are left unchecked.
|
|
113
|
+
def breadcrumb_call(source)
|
|
114
|
+
parse_result = Prism.parse(source.sub(/\s*-?%>.*\z/m, ""))
|
|
115
|
+
return if parse_result.failure?
|
|
116
|
+
|
|
117
|
+
each_node(parse_result.value) do |node|
|
|
118
|
+
return node if node in Prism::CallNode[receiver: nil, name: :breadcrumb]
|
|
119
|
+
end
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# gretel ran a crumb as a block, which took any number of arguments; a crumb method raises ArgumentError.
|
|
124
|
+
def arity_problem(trail_classes, name, call)
|
|
125
|
+
arguments = call.arguments&.arguments || []
|
|
126
|
+
skipped_nodes = [Prism::SplatNode, Prism::ForwardingArgumentsNode]
|
|
127
|
+
# `breadcrumb(target, *arguments)` takes no keywords: a view's `key: value` reaches the crumb as one Hash.
|
|
128
|
+
if call.name == :breadcrumb
|
|
129
|
+
arguments = arguments.drop(1)
|
|
130
|
+
else
|
|
131
|
+
skipped_nodes << Prism::KeywordHashNode
|
|
132
|
+
end
|
|
133
|
+
return if arguments.any? { |argument| skipped_nodes.any? { |node_class| argument.is_a?(node_class) } }
|
|
134
|
+
|
|
135
|
+
crumb_methods = Array(trail_classes).filter_map do |trail_class|
|
|
136
|
+
trail_class.instance_method(name) if trail_class.crumb?(name)
|
|
137
|
+
end
|
|
138
|
+
return if crumb_methods.any? { |crumb_method| accepts?(crumb_method, arguments.size) }
|
|
139
|
+
|
|
140
|
+
"with #{arguments.size} #{"argument".pluralize(arguments.size)}, which takes #{arity(crumb_methods.first)}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def accepts?(crumb_method, count)
|
|
144
|
+
required, optional, rest = parameter_counts(crumb_method)
|
|
145
|
+
count >= required && (rest || count <= required + optional)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def arity(crumb_method)
|
|
149
|
+
required, optional, rest = parameter_counts(crumb_method)
|
|
150
|
+
if rest then "#{required} or more"
|
|
151
|
+
elsif optional.zero? then required.to_s
|
|
152
|
+
else "#{required} to #{required + optional}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def parameter_counts(crumb_method)
|
|
157
|
+
kinds = crumb_method.parameters.map(&:first)
|
|
158
|
+
[kinds.count(:req), kinds.count(:opt), kinds.include?(:rest)]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def source_files
|
|
162
|
+
sources.flat_map { |source| Dir.glob(File.join(source, "**", "*")) }.select { |path| File.file?(path) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def dead_crumbs
|
|
166
|
+
reachable_names = called_names + derived_names
|
|
167
|
+
trail_classes.flat_map do |trail_class|
|
|
168
|
+
dead_names = crumb_names(trail_class).reject do |name|
|
|
169
|
+
reachable_names.include?(name) || model_name?(name)
|
|
170
|
+
end
|
|
171
|
+
dead_names.map { |name| "#{trail_class}##{name}" }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The crumbs a derived trail reads: the collection and the record of each routed controller, and under a
|
|
176
|
+
# nested route the collection named after its nesting (order_messages for /orders/:order_id/messages).
|
|
177
|
+
def derived_names
|
|
178
|
+
return [] unless routes
|
|
179
|
+
|
|
180
|
+
routes.routes.flat_map { |route| derived_names_for(route) }.uniq
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def derived_names_for(route)
|
|
184
|
+
controller_name = route.defaults[:controller]&.split("/")&.last
|
|
185
|
+
return [] unless controller_name
|
|
186
|
+
|
|
187
|
+
parent_names = route.required_parts.filter_map { |part| part.to_s.delete_suffix("_id") if part.end_with?("_id") }
|
|
188
|
+
nesting_names = (0..parent_names.size).map { |count| [*parent_names.first(count), controller_name].join("_").to_sym }
|
|
189
|
+
[*nesting_names, controller_name.singularize.to_sym]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# A crumb named after a model is reachable from any `breadcrumb @record` or `crumb_for(record)`.
|
|
193
|
+
def model_name?(name)
|
|
194
|
+
name.to_s.camelize.safe_constantize.respond_to?(:model_name)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def crumb_names(trail_class)
|
|
198
|
+
trail_class.own_crumb_names.select { |name| trail_class.public_method_defined?(name, false) }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crumbly
|
|
4
|
+
module ViewHelpers
|
|
5
|
+
# The view names its crumb; a view that says nothing gets the trail derived from its controller and action.
|
|
6
|
+
def breadcrumb(target, *arguments)
|
|
7
|
+
@crumbly_breadcrumb = [target, arguments]
|
|
8
|
+
@crumbly_trail = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def breadcrumb_trail
|
|
12
|
+
@crumbly_trail ||= begin
|
|
13
|
+
trail_class = Crumbly.trail_class_for(controller.class)
|
|
14
|
+
if @crumbly_breadcrumb
|
|
15
|
+
target, arguments = @crumbly_breadcrumb
|
|
16
|
+
trail_class.resolve(self, target, *arguments)
|
|
17
|
+
else
|
|
18
|
+
trail_class.derive(self)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def breadcrumb_json_ld
|
|
24
|
+
breadcrumb_list = JsonLd.call(breadcrumb_trail)
|
|
25
|
+
return unless breadcrumb_list
|
|
26
|
+
|
|
27
|
+
content_tag(:script, ERB::Util.json_escape(JSON.generate(breadcrumb_list)).html_safe, type: "application/ld+json")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/crumbly.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext/class/attribute"
|
|
5
|
+
require "active_support/core_ext/class/subclasses"
|
|
6
|
+
require "active_support/core_ext/module/introspection"
|
|
7
|
+
require "active_support/core_ext/string/inflections"
|
|
8
|
+
require "i18n"
|
|
9
|
+
|
|
10
|
+
require_relative "crumbly/version"
|
|
11
|
+
require_relative "crumbly/errors"
|
|
12
|
+
require_relative "crumbly/crumb"
|
|
13
|
+
require_relative "crumbly/level"
|
|
14
|
+
require_relative "crumbly/trail"
|
|
15
|
+
require_relative "crumbly/json_ld"
|
|
16
|
+
require_relative "crumbly/controller"
|
|
17
|
+
require_relative "crumbly/view_helpers"
|
|
18
|
+
require_relative "crumbly/verifier"
|
|
19
|
+
require_relative "crumbly/railtie" if defined?(Rails::Railtie)
|
|
20
|
+
|
|
21
|
+
module Crumbly
|
|
22
|
+
class << self
|
|
23
|
+
attr_writer :routes, :raise_on_unknown_crumb, :raise_on_relative_url
|
|
24
|
+
|
|
25
|
+
def routes
|
|
26
|
+
@routes || (Rails.application&.routes if defined?(Rails.application))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def raise_on_unknown_crumb?
|
|
30
|
+
return @raise_on_unknown_crumb unless @raise_on_unknown_crumb.nil?
|
|
31
|
+
|
|
32
|
+
!production?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def raise_on_relative_url?
|
|
36
|
+
return @raise_on_relative_url unless @raise_on_relative_url.nil?
|
|
37
|
+
|
|
38
|
+
!production?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Raises InvalidTrail with every problem found, and returns the dead crumbs ("Admin::Breadcrumbs#drafts").
|
|
42
|
+
def verify!(trail_classes: nil, routes: Crumbly.routes, sources: default_sources)
|
|
43
|
+
Rails.application.eager_load! if trail_classes.nil? && defined?(Rails.application) && Rails.application
|
|
44
|
+
Verifier.new(trail_classes: trail_classes || Trail.descendants, routes:, sources:).call
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Admin::OrdersController reads Admin::Breadcrumbs, then ::Breadcrumbs, unless it calls breadcrumbs_with.
|
|
48
|
+
def trail_class_for(controller_class)
|
|
49
|
+
explicit_trail_class = controller_class.try(:breadcrumbs_class)
|
|
50
|
+
return explicit_trail_class if explicit_trail_class
|
|
51
|
+
|
|
52
|
+
namespace = controller_class.module_parent
|
|
53
|
+
loop do
|
|
54
|
+
trail_class = namespace.const_get(:Breadcrumbs, false) if namespace.const_defined?(:Breadcrumbs, false)
|
|
55
|
+
return trail_class if trail_class.is_a?(Class) && trail_class < Trail
|
|
56
|
+
break if namespace == Object
|
|
57
|
+
|
|
58
|
+
namespace = namespace.module_parent
|
|
59
|
+
end
|
|
60
|
+
namespace = controller_class.module_parent
|
|
61
|
+
trail_class_name = (namespace == Object) ? "Breadcrumbs" : "#{namespace}::Breadcrumbs"
|
|
62
|
+
raise MissingTrail, "#{controller_class} has no breadcrumbs: define #{trail_class_name} " \
|
|
63
|
+
"(a Crumbly::Trail) or call breadcrumbs_with in the controller"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def default_sources
|
|
69
|
+
return [] unless defined?(Rails.root) && Rails.root
|
|
70
|
+
|
|
71
|
+
[Rails.root.join("app/views"), Rails.root.join("app/controllers")]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def production?
|
|
75
|
+
defined?(Rails.env) && Rails.env.production?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: crumbly
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 2.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Level UP Solutions
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionpack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '8.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '8.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: loofah
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.21'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.21'
|
|
54
|
+
description: One class of plain Ruby methods per namespace, a trail derived from the
|
|
55
|
+
controller and the routes when a view says nothing, mistakes raised when the class
|
|
56
|
+
loads, and the trail returned as data for your own components.
|
|
57
|
+
executables:
|
|
58
|
+
- crumbly-convert
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- CHANGELOG.md
|
|
63
|
+
- LICENSE
|
|
64
|
+
- README.md
|
|
65
|
+
- exe/crumbly-convert
|
|
66
|
+
- lib/crumbly.rb
|
|
67
|
+
- lib/crumbly/controller.rb
|
|
68
|
+
- lib/crumbly/converter.rb
|
|
69
|
+
- lib/crumbly/converter/crumb_definition.rb
|
|
70
|
+
- lib/crumbly/converter/source.rb
|
|
71
|
+
- lib/crumbly/crumb.rb
|
|
72
|
+
- lib/crumbly/derived_call_sites.rb
|
|
73
|
+
- lib/crumbly/errors.rb
|
|
74
|
+
- lib/crumbly/json_ld.rb
|
|
75
|
+
- lib/crumbly/level.rb
|
|
76
|
+
- lib/crumbly/oracle.rb
|
|
77
|
+
- lib/crumbly/railtie.rb
|
|
78
|
+
- lib/crumbly/test_helper.rb
|
|
79
|
+
- lib/crumbly/trail.rb
|
|
80
|
+
- lib/crumbly/verifier.rb
|
|
81
|
+
- lib/crumbly/version.rb
|
|
82
|
+
- lib/crumbly/view_helpers.rb
|
|
83
|
+
homepage: https://github.com/levups/crumbly
|
|
84
|
+
licenses:
|
|
85
|
+
- MIT
|
|
86
|
+
metadata:
|
|
87
|
+
source_code_uri: https://github.com/levups/crumbly
|
|
88
|
+
changelog_uri: https://github.com/levups/crumbly/blob/main/CHANGELOG.md
|
|
89
|
+
documentation_uri: https://levups.github.io/crumbly/
|
|
90
|
+
bug_tracker_uri: https://github.com/levups/crumbly/issues
|
|
91
|
+
rubygems_mfa_required: 'true'
|
|
92
|
+
rdoc_options: []
|
|
93
|
+
require_paths:
|
|
94
|
+
- lib
|
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '3.3'
|
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '0'
|
|
105
|
+
requirements: []
|
|
106
|
+
rubygems_version: 4.0.24
|
|
107
|
+
specification_version: 4
|
|
108
|
+
summary: 'Breadcrumbs for Rails: namespaced, derived from your routes, checked in
|
|
109
|
+
tests'
|
|
110
|
+
test_files: []
|