not_pressed-core 0.1.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/LICENSE.txt +41 -0
- data/README.md +285 -0
- data/app/assets/javascripts/not_pressed/lightbox.js +110 -0
- data/app/assets/stylesheets/not_pressed/admin.css +1161 -0
- data/app/assets/stylesheets/not_pressed/content.css +193 -0
- data/app/assets/stylesheets/not_pressed/gallery.css +117 -0
- data/app/assets/stylesheets/not_pressed/themes/starter.css +118 -0
- data/app/controllers/not_pressed/admin/base_controller.rb +21 -0
- data/app/controllers/not_pressed/admin/categories_controller.rb +53 -0
- data/app/controllers/not_pressed/admin/content_blocks_controller.rb +73 -0
- data/app/controllers/not_pressed/admin/dashboard_controller.rb +19 -0
- data/app/controllers/not_pressed/admin/forms_controller.rb +86 -0
- data/app/controllers/not_pressed/admin/media_attachments_controller.rb +94 -0
- data/app/controllers/not_pressed/admin/pages_controller.rb +122 -0
- data/app/controllers/not_pressed/admin/plugins_controller.rb +121 -0
- data/app/controllers/not_pressed/admin/settings_controller.rb +19 -0
- data/app/controllers/not_pressed/admin/tags_controller.rb +37 -0
- data/app/controllers/not_pressed/admin/themes_controller.rb +104 -0
- data/app/controllers/not_pressed/application_controller.rb +6 -0
- data/app/controllers/not_pressed/blog_controller.rb +83 -0
- data/app/controllers/not_pressed/form_submissions_controller.rb +70 -0
- data/app/controllers/not_pressed/pages_controller.rb +36 -0
- data/app/controllers/not_pressed/robots_controller.rb +34 -0
- data/app/controllers/not_pressed/sitemaps_controller.rb +12 -0
- data/app/helpers/not_pressed/admin_helper.rb +41 -0
- data/app/helpers/not_pressed/application_helper.rb +6 -0
- data/app/helpers/not_pressed/code_injection_helper.rb +29 -0
- data/app/helpers/not_pressed/content_helper.rb +13 -0
- data/app/helpers/not_pressed/form_helper.rb +80 -0
- data/app/helpers/not_pressed/media_helper.rb +28 -0
- data/app/helpers/not_pressed/seo_helper.rb +69 -0
- data/app/helpers/not_pressed/theme_helper.rb +42 -0
- data/app/mailers/not_pressed/application_mailer.rb +10 -0
- data/app/mailers/not_pressed/form_mailer.rb +15 -0
- data/app/models/concerns/not_pressed/sluggable.rb +43 -0
- data/app/models/not_pressed/category.rb +16 -0
- data/app/models/not_pressed/content_block.rb +46 -0
- data/app/models/not_pressed/form.rb +55 -0
- data/app/models/not_pressed/form_field.rb +23 -0
- data/app/models/not_pressed/form_submission.rb +19 -0
- data/app/models/not_pressed/media_attachment.rb +68 -0
- data/app/models/not_pressed/page.rb +182 -0
- data/app/models/not_pressed/page_version.rb +15 -0
- data/app/models/not_pressed/setting.rb +20 -0
- data/app/models/not_pressed/tag.rb +15 -0
- data/app/models/not_pressed/tagging.rb +12 -0
- data/app/plugins/not_pressed/analytics_plugin.rb +106 -0
- data/app/plugins/not_pressed/callout_block_plugin.rb +43 -0
- data/app/themes/not_pressed/starter_theme.rb +26 -0
- data/app/views/layouts/not_pressed/admin.html.erb +745 -0
- data/app/views/layouts/not_pressed/application.html.erb +12 -0
- data/app/views/layouts/not_pressed/page.html.erb +22 -0
- data/app/views/not_pressed/admin/categories/index.html.erb +58 -0
- data/app/views/not_pressed/admin/content_blocks/_block.html.erb +18 -0
- data/app/views/not_pressed/admin/content_blocks/_block_picker.html.erb +32 -0
- data/app/views/not_pressed/admin/content_blocks/create.turbo_stream.erb +3 -0
- data/app/views/not_pressed/admin/content_blocks/destroy.turbo_stream.erb +1 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_callout.html.erb +38 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_code.html.erb +26 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_divider.html.erb +4 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_form.html.erb +16 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_gallery.html.erb +75 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_heading.html.erb +26 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_html.html.erb +13 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_image.html.erb +56 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_quote.html.erb +24 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_text.html.erb +28 -0
- data/app/views/not_pressed/admin/content_blocks/editors/_video.html.erb +25 -0
- data/app/views/not_pressed/admin/dashboard/index.html.erb +60 -0
- data/app/views/not_pressed/admin/forms/_field_row.html.erb +33 -0
- data/app/views/not_pressed/admin/forms/_form.html.erb +75 -0
- data/app/views/not_pressed/admin/forms/edit.html.erb +1 -0
- data/app/views/not_pressed/admin/forms/index.html.erb +32 -0
- data/app/views/not_pressed/admin/forms/new.html.erb +1 -0
- data/app/views/not_pressed/admin/forms/submissions.html.erb +34 -0
- data/app/views/not_pressed/admin/media_attachments/_media_card.html.erb +21 -0
- data/app/views/not_pressed/admin/media_attachments/_picker.html.erb +19 -0
- data/app/views/not_pressed/admin/media_attachments/edit.html.erb +57 -0
- data/app/views/not_pressed/admin/media_attachments/index.html.erb +48 -0
- data/app/views/not_pressed/admin/pages/_form.html.erb +177 -0
- data/app/views/not_pressed/admin/pages/_page_tree_node.html.erb +32 -0
- data/app/views/not_pressed/admin/pages/edit.html.erb +69 -0
- data/app/views/not_pressed/admin/pages/index.html.erb +21 -0
- data/app/views/not_pressed/admin/pages/new.html.erb +1 -0
- data/app/views/not_pressed/admin/pages/preview.html.erb +17 -0
- data/app/views/not_pressed/admin/plugins/_settings_form.html.erb +59 -0
- data/app/views/not_pressed/admin/plugins/index.html.erb +48 -0
- data/app/views/not_pressed/admin/plugins/show.html.erb +54 -0
- data/app/views/not_pressed/admin/settings/code_injection.html.erb +21 -0
- data/app/views/not_pressed/admin/shared/_breadcrumbs.html.erb +14 -0
- data/app/views/not_pressed/admin/shared/_flash.html.erb +7 -0
- data/app/views/not_pressed/admin/shared/_modal.html.erb +9 -0
- data/app/views/not_pressed/admin/shared/_sidebar.html.erb +18 -0
- data/app/views/not_pressed/admin/tags/index.html.erb +52 -0
- data/app/views/not_pressed/admin/themes/index.html.erb +60 -0
- data/app/views/not_pressed/admin/themes/show.html.erb +66 -0
- data/app/views/not_pressed/blog/_post_card.html.erb +24 -0
- data/app/views/not_pressed/blog/feed.rss.builder +22 -0
- data/app/views/not_pressed/blog/index.html.erb +56 -0
- data/app/views/not_pressed/blog/show.html.erb +41 -0
- data/app/views/not_pressed/form_mailer/submission_notification.text.erb +8 -0
- data/app/views/not_pressed/pages/show.html.erb +4 -0
- data/app/views/themes/starter/layouts/not_pressed/default.html.erb +36 -0
- data/app/views/themes/starter/layouts/not_pressed/full_width.html.erb +36 -0
- data/app/views/themes/starter/layouts/not_pressed/sidebar.html.erb +41 -0
- data/config/routes.rb +81 -0
- data/db/migrate/20260310000001_create_not_pressed_pages.rb +20 -0
- data/db/migrate/20260310000002_create_not_pressed_content_blocks.rb +17 -0
- data/db/migrate/20260310000003_create_not_pressed_media_attachments.rb +14 -0
- data/db/migrate/20260310000004_add_content_type_to_not_pressed_pages.rb +8 -0
- data/db/migrate/20260310000005_add_publishing_fields_to_not_pressed_pages.rb +8 -0
- data/db/migrate/20260310000006_create_not_pressed_page_versions.rb +16 -0
- data/db/migrate/20260310000007_add_settings_fields_to_not_pressed_pages.rb +11 -0
- data/db/migrate/20260311000001_create_not_pressed_forms.rb +42 -0
- data/db/migrate/20260311000002_add_seo_fields_to_not_pressed_pages.rb +10 -0
- data/db/migrate/20260311000003_add_code_injection_to_not_pressed_pages.rb +8 -0
- data/db/migrate/20260311000004_create_not_pressed_settings.rb +14 -0
- data/db/migrate/20260312000001_create_not_pressed_categories.rb +16 -0
- data/db/migrate/20260312000002_create_not_pressed_tags.rb +15 -0
- data/db/migrate/20260312000003_create_not_pressed_taggings.rb +14 -0
- data/db/migrate/20260312000004_add_category_id_to_not_pressed_pages.rb +7 -0
- data/lib/generators/not_pressed/install/install_generator.rb +52 -0
- data/lib/generators/not_pressed/install/templates/initializer.rb.tt +89 -0
- data/lib/generators/not_pressed/install/templates/seeds.rb.tt +131 -0
- data/lib/generators/not_pressed/plugin/plugin_generator.rb +37 -0
- data/lib/generators/not_pressed/plugin/templates/plugin.rb.tt +23 -0
- data/lib/not_pressed/admin/authentication.rb +48 -0
- data/lib/not_pressed/admin/menu_registry.rb +100 -0
- data/lib/not_pressed/configuration.rb +77 -0
- data/lib/not_pressed/content_type.rb +23 -0
- data/lib/not_pressed/content_type_builder.rb +51 -0
- data/lib/not_pressed/content_type_registry.rb +45 -0
- data/lib/not_pressed/engine.rb +132 -0
- data/lib/not_pressed/hooks.rb +166 -0
- data/lib/not_pressed/navigation/builder.rb +148 -0
- data/lib/not_pressed/navigation/menu.rb +54 -0
- data/lib/not_pressed/navigation/menu_item.rb +33 -0
- data/lib/not_pressed/navigation/node.rb +45 -0
- data/lib/not_pressed/navigation/partial_parser.rb +96 -0
- data/lib/not_pressed/navigation/route_inspector.rb +98 -0
- data/lib/not_pressed/navigation.rb +6 -0
- data/lib/not_pressed/plugin.rb +354 -0
- data/lib/not_pressed/plugin_importer.rb +133 -0
- data/lib/not_pressed/plugin_manager.rb +196 -0
- data/lib/not_pressed/plugin_packager.rb +129 -0
- data/lib/not_pressed/rendering/block_renderer.rb +222 -0
- data/lib/not_pressed/rendering/renderer_registry.rb +154 -0
- data/lib/not_pressed/rendering.rb +8 -0
- data/lib/not_pressed/seo/sitemap_builder.rb +61 -0
- data/lib/not_pressed/theme.rb +191 -0
- data/lib/not_pressed/theme_importer.rb +133 -0
- data/lib/not_pressed/theme_packager.rb +180 -0
- data/lib/not_pressed/theme_registry.rb +123 -0
- data/lib/not_pressed/version.rb +5 -0
- data/lib/not_pressed.rb +65 -0
- metadata +258 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotPressed
|
|
4
|
+
# Global event and filter system for the NotPressed engine.
|
|
5
|
+
#
|
|
6
|
+
# Hooks are one-shot event callbacks. Filters are value-transforming chains.
|
|
7
|
+
# All methods are thread-safe via an internal mutex.
|
|
8
|
+
#
|
|
9
|
+
# @example Registering and firing a hook
|
|
10
|
+
# Hooks.register(:after_page_save) { |page| log(page.title) }
|
|
11
|
+
# Hooks.fire(:after_page_save, page)
|
|
12
|
+
#
|
|
13
|
+
# @example Using filters
|
|
14
|
+
# Hooks.register_filter(:page_content) { |html, page| html + "<footer>...</footer>" }
|
|
15
|
+
# result = Hooks.apply_filter(:page_content, "<p>Hello</p>", page)
|
|
16
|
+
module Hooks
|
|
17
|
+
class << self
|
|
18
|
+
# Registers a callback for the given event.
|
|
19
|
+
#
|
|
20
|
+
# Callbacks are sorted by priority (lower values execute first).
|
|
21
|
+
#
|
|
22
|
+
# @param event [Symbol] event name
|
|
23
|
+
# @param priority [Integer] execution priority (default 10)
|
|
24
|
+
# @yield callback block invoked when the event fires
|
|
25
|
+
# @return [void]
|
|
26
|
+
def register(event, priority: 10, &callback)
|
|
27
|
+
mutex.synchronize do
|
|
28
|
+
hooks[event] ||= []
|
|
29
|
+
hooks[event] << { callback: callback, priority: priority }
|
|
30
|
+
hooks[event].sort_by! { |h| h[:priority] }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Invokes all callbacks registered for the event, in priority order.
|
|
35
|
+
#
|
|
36
|
+
# @param event [Symbol] event name
|
|
37
|
+
# @param args [Object] arguments passed to each callback
|
|
38
|
+
# @return [void]
|
|
39
|
+
def fire(event, *args)
|
|
40
|
+
callbacks_for(event).each { |entry| entry[:callback].call(*args) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Wraps a block with all callbacks for the event.
|
|
44
|
+
#
|
|
45
|
+
# Each callback receives the arguments plus a +&next_block+ to continue
|
|
46
|
+
# the chain. If no callbacks are registered, the core block is called directly.
|
|
47
|
+
#
|
|
48
|
+
# @param event [Symbol] event name
|
|
49
|
+
# @param args [Object] arguments passed to each callback
|
|
50
|
+
# @yield the core operation to wrap
|
|
51
|
+
# @return [Object] the return value of the innermost block
|
|
52
|
+
def around(event, *args, &block)
|
|
53
|
+
chain = callbacks_for(event)
|
|
54
|
+
if chain.empty?
|
|
55
|
+
block.call
|
|
56
|
+
else
|
|
57
|
+
build_around_chain(chain, args, block).call
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Registers a filter callback for the given name.
|
|
62
|
+
#
|
|
63
|
+
# Filters are chained: each receives the return value of the previous
|
|
64
|
+
# filter (or the initial value) plus any extra arguments.
|
|
65
|
+
#
|
|
66
|
+
# @param name [Symbol] filter name
|
|
67
|
+
# @param priority [Integer] execution priority (default 10)
|
|
68
|
+
# @yield [value, *args] receives the current value and extra arguments, must return the new value
|
|
69
|
+
# @return [void]
|
|
70
|
+
def register_filter(name, priority: 10, &callback)
|
|
71
|
+
mutex.synchronize do
|
|
72
|
+
filters[name] ||= []
|
|
73
|
+
filters[name] << { callback: callback, priority: priority }
|
|
74
|
+
filters[name].sort_by! { |f| f[:priority] }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Runs the value through all registered filters for the given name.
|
|
79
|
+
#
|
|
80
|
+
# @param name [Symbol] filter name
|
|
81
|
+
# @param value [Object] initial value to filter
|
|
82
|
+
# @param args [Object] extra arguments passed to each filter callback
|
|
83
|
+
# @return [Object] the final filtered value
|
|
84
|
+
def apply_filter(name, value, *args)
|
|
85
|
+
filters_for(name).reduce(value) do |val, entry|
|
|
86
|
+
entry[:callback].call(val, *args)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns a snapshot of all registered hooks.
|
|
91
|
+
#
|
|
92
|
+
# @return [Hash{Symbol => Array<Hash>}]
|
|
93
|
+
def registered
|
|
94
|
+
mutex.synchronize { hooks.dup }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns callbacks registered for a specific event.
|
|
98
|
+
#
|
|
99
|
+
# @param event [Symbol] event name
|
|
100
|
+
# @return [Array<Hash>] each hash has :callback and :priority keys
|
|
101
|
+
def registered_for(event)
|
|
102
|
+
mutex.synchronize { (hooks[event] || []).dup }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Removes a specific callback from an event.
|
|
106
|
+
#
|
|
107
|
+
# @param event [Symbol] event name
|
|
108
|
+
# @param callback [Proc] the exact callback object to remove
|
|
109
|
+
# @return [void]
|
|
110
|
+
def unregister(event, callback)
|
|
111
|
+
mutex.synchronize do
|
|
112
|
+
hooks[event]&.reject! { |entry| entry[:callback] == callback }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Removes a specific filter callback.
|
|
117
|
+
#
|
|
118
|
+
# @param name [Symbol] filter name
|
|
119
|
+
# @param callback [Proc] the exact callback object to remove
|
|
120
|
+
# @return [void]
|
|
121
|
+
def unregister_filter(name, callback)
|
|
122
|
+
mutex.synchronize do
|
|
123
|
+
filters[name]&.reject! { |entry| entry[:callback] == callback }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Clears all hooks and filters.
|
|
128
|
+
#
|
|
129
|
+
# @return [void]
|
|
130
|
+
def reset!
|
|
131
|
+
mutex.synchronize do
|
|
132
|
+
@hooks = {}
|
|
133
|
+
@filters = {}
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def mutex
|
|
140
|
+
@mutex ||= Mutex.new
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def hooks
|
|
144
|
+
@hooks ||= {}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def filters
|
|
148
|
+
@filters ||= {}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def callbacks_for(event)
|
|
152
|
+
mutex.synchronize { (hooks[event] || []).dup }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def filters_for(name)
|
|
156
|
+
mutex.synchronize { (filters[name] || []).dup }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_around_chain(chain, args, core_block)
|
|
160
|
+
chain.reverse.reduce(core_block) do |next_block, entry|
|
|
161
|
+
proc { entry[:callback].call(*args, &next_block) }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotPressed
|
|
4
|
+
module Navigation
|
|
5
|
+
class Builder
|
|
6
|
+
SOURCE_PRIORITY = { config: 0, partial: 1, route: 2, page: 3 }.freeze
|
|
7
|
+
|
|
8
|
+
def initialize(configuration: nil)
|
|
9
|
+
@configuration = configuration
|
|
10
|
+
@cache = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build(menu_name = "main")
|
|
14
|
+
menu_name = menu_name.to_s
|
|
15
|
+
root = Node.new(label: menu_name, path: "/", source: :config)
|
|
16
|
+
known_paths = {}
|
|
17
|
+
|
|
18
|
+
# a. DSL-defined menu items (highest priority)
|
|
19
|
+
add_dsl_items(root, menu_name, known_paths)
|
|
20
|
+
|
|
21
|
+
# b. Auto-discovered routes
|
|
22
|
+
if config.auto_discover_navigation
|
|
23
|
+
add_route_items(root, known_paths)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# c. CMS pages
|
|
27
|
+
add_page_items(root, known_paths)
|
|
28
|
+
|
|
29
|
+
# d. Sort all children by position
|
|
30
|
+
root.sort_children!
|
|
31
|
+
|
|
32
|
+
root
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_page_nodes(pages = nil)
|
|
36
|
+
pages ||= Page.visible.roots.ordered
|
|
37
|
+
pages.map do |page|
|
|
38
|
+
node = Node.new(
|
|
39
|
+
label: page.title,
|
|
40
|
+
path: "/#{page.slug}",
|
|
41
|
+
source: :page,
|
|
42
|
+
page_id: page.id,
|
|
43
|
+
position: page.position || 0
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
child_pages = page.children.visible.ordered
|
|
47
|
+
child_pages.each do |child_page|
|
|
48
|
+
child_nodes = build_page_nodes([child_page])
|
|
49
|
+
child_nodes.each { |cn| node.add_child(cn) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
node
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cached_build(menu_name = "main")
|
|
57
|
+
menu_name = menu_name.to_s
|
|
58
|
+
if config.cache_navigation && @cache.key?(menu_name)
|
|
59
|
+
return @cache[menu_name]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
result = build(menu_name)
|
|
63
|
+
@cache[menu_name] = result if config.cache_navigation
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def invalidate_cache!
|
|
68
|
+
@cache = {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def config
|
|
74
|
+
@configuration || NotPressed.configuration
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def add_dsl_items(root, menu_name, known_paths)
|
|
78
|
+
menu = config.menus[menu_name]
|
|
79
|
+
return unless menu
|
|
80
|
+
|
|
81
|
+
menu.items.each do |menu_item|
|
|
82
|
+
node = menu_item_to_node(menu_item)
|
|
83
|
+
known_paths[node.path] = :config
|
|
84
|
+
root.add_child(node)
|
|
85
|
+
collect_child_paths(node, known_paths, :config)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def add_route_items(root, known_paths)
|
|
90
|
+
inspector = RouteInspector.new
|
|
91
|
+
routes = inspector.navigable_routes
|
|
92
|
+
|
|
93
|
+
routes.each do |route_entry|
|
|
94
|
+
path = route_entry.path
|
|
95
|
+
next if known_paths.key?(path)
|
|
96
|
+
|
|
97
|
+
label = route_entry.name&.titleize || path
|
|
98
|
+
node = Node.new(label: label, path: path, source: :route, position: 100)
|
|
99
|
+
known_paths[path] = :route
|
|
100
|
+
root.add_child(node)
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError
|
|
103
|
+
# RouteInspector may fail in test environments without full Rails routing
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def add_page_items(root, known_paths)
|
|
108
|
+
page_nodes = build_page_nodes
|
|
109
|
+
|
|
110
|
+
page_nodes.each do |node|
|
|
111
|
+
next if known_paths.key?(node.path) && higher_priority?(known_paths[node.path], :page)
|
|
112
|
+
|
|
113
|
+
known_paths[node.path] = :page
|
|
114
|
+
root.add_child(node)
|
|
115
|
+
end
|
|
116
|
+
rescue StandardError
|
|
117
|
+
# Page model may not be available in all environments
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def menu_item_to_node(menu_item)
|
|
122
|
+
children = menu_item.children.map { |child| menu_item_to_node(child) }
|
|
123
|
+
|
|
124
|
+
Node.new(
|
|
125
|
+
label: menu_item.label,
|
|
126
|
+
path: menu_item.path,
|
|
127
|
+
source: :config,
|
|
128
|
+
icon: menu_item.icon,
|
|
129
|
+
position: menu_item.position,
|
|
130
|
+
css_class: menu_item.css_class,
|
|
131
|
+
visible: menu_item.visible,
|
|
132
|
+
children: children
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def collect_child_paths(node, known_paths, source)
|
|
137
|
+
node.children.each do |child|
|
|
138
|
+
known_paths[child.path] = source
|
|
139
|
+
collect_child_paths(child, known_paths, source)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def higher_priority?(existing_source, new_source)
|
|
144
|
+
(SOURCE_PRIORITY[existing_source] || 99) <= (SOURCE_PRIORITY[new_source] || 99)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotPressed
|
|
4
|
+
module Navigation
|
|
5
|
+
class Menu
|
|
6
|
+
attr_reader :name, :items
|
|
7
|
+
|
|
8
|
+
def initialize(name:, items: [])
|
|
9
|
+
@name = name
|
|
10
|
+
@items = items.dup.freeze
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.build(name, &block)
|
|
15
|
+
builder = Builder.new
|
|
16
|
+
block&.call(builder)
|
|
17
|
+
new(name: name, items: builder.items)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Builder
|
|
21
|
+
attr_reader :items
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@items = []
|
|
25
|
+
@position_counter = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def item(label, path, **options, &block)
|
|
29
|
+
children = if block
|
|
30
|
+
sub_builder = Builder.new
|
|
31
|
+
block.call(sub_builder)
|
|
32
|
+
sub_builder.items
|
|
33
|
+
else
|
|
34
|
+
[]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
position = options.fetch(:position, @position_counter)
|
|
38
|
+
@position_counter += 1
|
|
39
|
+
|
|
40
|
+
@items << MenuItem.new(
|
|
41
|
+
label: label,
|
|
42
|
+
path: path,
|
|
43
|
+
icon: options[:icon],
|
|
44
|
+
position: position,
|
|
45
|
+
visible: options.fetch(:visible, true),
|
|
46
|
+
children: children,
|
|
47
|
+
css_class: options[:css_class],
|
|
48
|
+
target: options[:target]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotPressed
|
|
4
|
+
module Navigation
|
|
5
|
+
class MenuItem
|
|
6
|
+
attr_reader :label, :path, :icon, :position, :visible, :children, :css_class, :target
|
|
7
|
+
|
|
8
|
+
def initialize(label:, path:, icon: nil, position: 0, visible: true, children: [], css_class: nil, target: nil)
|
|
9
|
+
@label = label
|
|
10
|
+
@path = path
|
|
11
|
+
@icon = icon
|
|
12
|
+
@position = position
|
|
13
|
+
@visible = visible
|
|
14
|
+
@children = children.dup.freeze
|
|
15
|
+
@css_class = css_class
|
|
16
|
+
@target = target
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def visible?
|
|
21
|
+
if visible.respond_to?(:call)
|
|
22
|
+
visible.call
|
|
23
|
+
else
|
|
24
|
+
!!visible
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def leaf?
|
|
29
|
+
children.empty?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotPressed
|
|
4
|
+
module Navigation
|
|
5
|
+
class Node
|
|
6
|
+
attr_reader :label, :path, :source, :icon, :position, :children, :page_id, :css_class
|
|
7
|
+
attr_accessor :visible
|
|
8
|
+
|
|
9
|
+
def initialize(label:, path:, source:, icon: nil, position: 0, children: [], page_id: nil, css_class: nil, visible: true)
|
|
10
|
+
@label = label
|
|
11
|
+
@path = path
|
|
12
|
+
@source = source
|
|
13
|
+
@icon = icon
|
|
14
|
+
@position = position
|
|
15
|
+
@children = children.dup
|
|
16
|
+
@page_id = page_id
|
|
17
|
+
@css_class = css_class
|
|
18
|
+
@visible = visible
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def visible?
|
|
22
|
+
if visible.respond_to?(:call)
|
|
23
|
+
visible.call
|
|
24
|
+
else
|
|
25
|
+
!!visible
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def page?
|
|
30
|
+
source == :page
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add_child(node)
|
|
34
|
+
@children << node
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def sort_children!
|
|
39
|
+
@children.sort_by!(&:position)
|
|
40
|
+
@children.each(&:sort_children!)
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotPressed
|
|
4
|
+
module Navigation
|
|
5
|
+
class PartialParser
|
|
6
|
+
LinkReference = Struct.new(:label, :path, :line, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
NAV_PARTIAL_GLOBS = %w[
|
|
9
|
+
**/_nav*.html.erb
|
|
10
|
+
**/_navigation*.html.erb
|
|
11
|
+
**/_menu*.html.erb
|
|
12
|
+
**/_header*.html.erb
|
|
13
|
+
**/_sidebar*.html.erb
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
LINK_TO_PATTERN = /<%=\s*link_to\s+"([^"]+)"\s*,\s*(.+?)\s*%>/
|
|
17
|
+
|
|
18
|
+
def initialize(view_paths: nil)
|
|
19
|
+
@view_paths = view_paths || Rails.application.paths["app/views"].to_a
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def discover_nav_partials
|
|
23
|
+
seen = Set.new
|
|
24
|
+
partials = []
|
|
25
|
+
|
|
26
|
+
@view_paths.each do |view_path|
|
|
27
|
+
NAV_PARTIAL_GLOBS.each do |glob|
|
|
28
|
+
Dir.glob(File.join(view_path, glob)).each do |file|
|
|
29
|
+
canonical = File.expand_path(file)
|
|
30
|
+
next if seen.include?(canonical)
|
|
31
|
+
|
|
32
|
+
seen.add(canonical)
|
|
33
|
+
partials << canonical
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
partials.sort
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def extract_links(partial_path)
|
|
42
|
+
return [] unless File.exist?(partial_path)
|
|
43
|
+
|
|
44
|
+
links = []
|
|
45
|
+
|
|
46
|
+
File.readlines(partial_path).each_with_index do |line_content, index|
|
|
47
|
+
next if line_content =~ /link_to\b.*\bdo\b/
|
|
48
|
+
|
|
49
|
+
if (match = line_content.match(LINK_TO_PATTERN))
|
|
50
|
+
label = match[1]
|
|
51
|
+
raw_path = match[2].strip
|
|
52
|
+
# Remove trailing content after the path argument (e.g., class: or html options)
|
|
53
|
+
raw_path = clean_path_argument(raw_path)
|
|
54
|
+
|
|
55
|
+
links << LinkReference.new(label: label, path: raw_path, line: index + 1)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
links
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def scan
|
|
63
|
+
results = {}
|
|
64
|
+
|
|
65
|
+
discover_nav_partials.each do |partial_path|
|
|
66
|
+
links = extract_links(partial_path)
|
|
67
|
+
results[partial_path] = links if links.any?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
results
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def clean_path_argument(raw)
|
|
76
|
+
# If the path is a quoted string, extract it
|
|
77
|
+
if raw.start_with?('"')
|
|
78
|
+
raw.match(/\A"([^"]*)"/)&.then { |m| return m[1] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# If it's a path helper with or without parens, take up to the first comma
|
|
82
|
+
# that's outside of parentheses (indicating html_options)
|
|
83
|
+
depth = 0
|
|
84
|
+
raw.each_char.with_index do |char, i|
|
|
85
|
+
depth += 1 if char == "("
|
|
86
|
+
depth -= 1 if char == ")"
|
|
87
|
+
if char == "," && depth.zero?
|
|
88
|
+
return raw[0...i].strip
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
raw.strip
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotPressed
|
|
4
|
+
module Navigation
|
|
5
|
+
class RouteInspector
|
|
6
|
+
RouteEntry = Struct.new(:path, :name, :controller, :action, :verb, :engine, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
EXCLUDED_PATH_PATTERNS = %r{/(rails|active_storage|action_mailbox|action_cable|turbo)/}.freeze
|
|
9
|
+
ID_SEGMENT_PATTERN = /:\w*id\b/.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(app: Rails.application)
|
|
12
|
+
@app = app
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def inspect_routes
|
|
16
|
+
@app.routes.routes.filter_map do |route|
|
|
17
|
+
defaults = route.defaults
|
|
18
|
+
next if defaults.blank? || defaults[:controller].blank?
|
|
19
|
+
|
|
20
|
+
RouteEntry.new(
|
|
21
|
+
path: route.path.spec.to_s.sub(/\(\.:\w+\)\z/, ""),
|
|
22
|
+
name: route.name,
|
|
23
|
+
controller: defaults[:controller],
|
|
24
|
+
action: defaults[:action],
|
|
25
|
+
verb: route.verb,
|
|
26
|
+
engine: nil
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def navigable_routes
|
|
32
|
+
engine_mount_path = engine_path
|
|
33
|
+
|
|
34
|
+
inspect_routes
|
|
35
|
+
.select { |entry| entry.verb == "GET" }
|
|
36
|
+
.select { |entry| entry.name.present? }
|
|
37
|
+
.reject { |entry| entry.path.match?(EXCLUDED_PATH_PATTERNS) }
|
|
38
|
+
.reject { |entry| entry.path.match?(ID_SEGMENT_PATTERN) }
|
|
39
|
+
.reject { |entry| engine_mount_path && entry.path.start_with?(engine_mount_path) }
|
|
40
|
+
.sort_by(&:path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def route_tree
|
|
44
|
+
routes = navigable_routes
|
|
45
|
+
root = { path: "/", name: "root", controller: nil, action: nil, children: [] }
|
|
46
|
+
|
|
47
|
+
routes.each do |entry|
|
|
48
|
+
segments = entry.path.split("/").reject(&:blank?)
|
|
49
|
+
insert_into_tree(root, segments, entry, "")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
root
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def engine_path
|
|
58
|
+
@app.routes.routes.each do |route|
|
|
59
|
+
if route.app.respond_to?(:app) && route.app.app == NotPressed::Engine
|
|
60
|
+
return route.path.spec.to_s.sub(/\(\.:\w+\)\z/, "")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def insert_into_tree(node, segments, entry, current_path)
|
|
67
|
+
return if segments.empty?
|
|
68
|
+
|
|
69
|
+
segment = segments.first
|
|
70
|
+
child_path = "#{current_path}/#{segment}"
|
|
71
|
+
|
|
72
|
+
existing = node[:children].find { |c| c[:path] == child_path }
|
|
73
|
+
|
|
74
|
+
if segments.length == 1
|
|
75
|
+
if existing
|
|
76
|
+
existing[:controller] = entry.controller
|
|
77
|
+
existing[:action] = entry.action
|
|
78
|
+
existing[:name] = entry.name
|
|
79
|
+
else
|
|
80
|
+
node[:children] << {
|
|
81
|
+
path: entry.path,
|
|
82
|
+
name: entry.name,
|
|
83
|
+
controller: entry.controller,
|
|
84
|
+
action: entry.action,
|
|
85
|
+
children: []
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
unless existing
|
|
90
|
+
existing = { path: child_path, name: nil, controller: nil, action: nil, children: [] }
|
|
91
|
+
node[:children] << existing
|
|
92
|
+
end
|
|
93
|
+
insert_into_tree(existing, segments[1..], entry, child_path)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|