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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crumbly
4
+ VERSION = "2.0.0"
5
+ 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: []