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.
Files changed (157) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +41 -0
  3. data/README.md +285 -0
  4. data/app/assets/javascripts/not_pressed/lightbox.js +110 -0
  5. data/app/assets/stylesheets/not_pressed/admin.css +1161 -0
  6. data/app/assets/stylesheets/not_pressed/content.css +193 -0
  7. data/app/assets/stylesheets/not_pressed/gallery.css +117 -0
  8. data/app/assets/stylesheets/not_pressed/themes/starter.css +118 -0
  9. data/app/controllers/not_pressed/admin/base_controller.rb +21 -0
  10. data/app/controllers/not_pressed/admin/categories_controller.rb +53 -0
  11. data/app/controllers/not_pressed/admin/content_blocks_controller.rb +73 -0
  12. data/app/controllers/not_pressed/admin/dashboard_controller.rb +19 -0
  13. data/app/controllers/not_pressed/admin/forms_controller.rb +86 -0
  14. data/app/controllers/not_pressed/admin/media_attachments_controller.rb +94 -0
  15. data/app/controllers/not_pressed/admin/pages_controller.rb +122 -0
  16. data/app/controllers/not_pressed/admin/plugins_controller.rb +121 -0
  17. data/app/controllers/not_pressed/admin/settings_controller.rb +19 -0
  18. data/app/controllers/not_pressed/admin/tags_controller.rb +37 -0
  19. data/app/controllers/not_pressed/admin/themes_controller.rb +104 -0
  20. data/app/controllers/not_pressed/application_controller.rb +6 -0
  21. data/app/controllers/not_pressed/blog_controller.rb +83 -0
  22. data/app/controllers/not_pressed/form_submissions_controller.rb +70 -0
  23. data/app/controllers/not_pressed/pages_controller.rb +36 -0
  24. data/app/controllers/not_pressed/robots_controller.rb +34 -0
  25. data/app/controllers/not_pressed/sitemaps_controller.rb +12 -0
  26. data/app/helpers/not_pressed/admin_helper.rb +41 -0
  27. data/app/helpers/not_pressed/application_helper.rb +6 -0
  28. data/app/helpers/not_pressed/code_injection_helper.rb +29 -0
  29. data/app/helpers/not_pressed/content_helper.rb +13 -0
  30. data/app/helpers/not_pressed/form_helper.rb +80 -0
  31. data/app/helpers/not_pressed/media_helper.rb +28 -0
  32. data/app/helpers/not_pressed/seo_helper.rb +69 -0
  33. data/app/helpers/not_pressed/theme_helper.rb +42 -0
  34. data/app/mailers/not_pressed/application_mailer.rb +10 -0
  35. data/app/mailers/not_pressed/form_mailer.rb +15 -0
  36. data/app/models/concerns/not_pressed/sluggable.rb +43 -0
  37. data/app/models/not_pressed/category.rb +16 -0
  38. data/app/models/not_pressed/content_block.rb +46 -0
  39. data/app/models/not_pressed/form.rb +55 -0
  40. data/app/models/not_pressed/form_field.rb +23 -0
  41. data/app/models/not_pressed/form_submission.rb +19 -0
  42. data/app/models/not_pressed/media_attachment.rb +68 -0
  43. data/app/models/not_pressed/page.rb +182 -0
  44. data/app/models/not_pressed/page_version.rb +15 -0
  45. data/app/models/not_pressed/setting.rb +20 -0
  46. data/app/models/not_pressed/tag.rb +15 -0
  47. data/app/models/not_pressed/tagging.rb +12 -0
  48. data/app/plugins/not_pressed/analytics_plugin.rb +106 -0
  49. data/app/plugins/not_pressed/callout_block_plugin.rb +43 -0
  50. data/app/themes/not_pressed/starter_theme.rb +26 -0
  51. data/app/views/layouts/not_pressed/admin.html.erb +745 -0
  52. data/app/views/layouts/not_pressed/application.html.erb +12 -0
  53. data/app/views/layouts/not_pressed/page.html.erb +22 -0
  54. data/app/views/not_pressed/admin/categories/index.html.erb +58 -0
  55. data/app/views/not_pressed/admin/content_blocks/_block.html.erb +18 -0
  56. data/app/views/not_pressed/admin/content_blocks/_block_picker.html.erb +32 -0
  57. data/app/views/not_pressed/admin/content_blocks/create.turbo_stream.erb +3 -0
  58. data/app/views/not_pressed/admin/content_blocks/destroy.turbo_stream.erb +1 -0
  59. data/app/views/not_pressed/admin/content_blocks/editors/_callout.html.erb +38 -0
  60. data/app/views/not_pressed/admin/content_blocks/editors/_code.html.erb +26 -0
  61. data/app/views/not_pressed/admin/content_blocks/editors/_divider.html.erb +4 -0
  62. data/app/views/not_pressed/admin/content_blocks/editors/_form.html.erb +16 -0
  63. data/app/views/not_pressed/admin/content_blocks/editors/_gallery.html.erb +75 -0
  64. data/app/views/not_pressed/admin/content_blocks/editors/_heading.html.erb +26 -0
  65. data/app/views/not_pressed/admin/content_blocks/editors/_html.html.erb +13 -0
  66. data/app/views/not_pressed/admin/content_blocks/editors/_image.html.erb +56 -0
  67. data/app/views/not_pressed/admin/content_blocks/editors/_quote.html.erb +24 -0
  68. data/app/views/not_pressed/admin/content_blocks/editors/_text.html.erb +28 -0
  69. data/app/views/not_pressed/admin/content_blocks/editors/_video.html.erb +25 -0
  70. data/app/views/not_pressed/admin/dashboard/index.html.erb +60 -0
  71. data/app/views/not_pressed/admin/forms/_field_row.html.erb +33 -0
  72. data/app/views/not_pressed/admin/forms/_form.html.erb +75 -0
  73. data/app/views/not_pressed/admin/forms/edit.html.erb +1 -0
  74. data/app/views/not_pressed/admin/forms/index.html.erb +32 -0
  75. data/app/views/not_pressed/admin/forms/new.html.erb +1 -0
  76. data/app/views/not_pressed/admin/forms/submissions.html.erb +34 -0
  77. data/app/views/not_pressed/admin/media_attachments/_media_card.html.erb +21 -0
  78. data/app/views/not_pressed/admin/media_attachments/_picker.html.erb +19 -0
  79. data/app/views/not_pressed/admin/media_attachments/edit.html.erb +57 -0
  80. data/app/views/not_pressed/admin/media_attachments/index.html.erb +48 -0
  81. data/app/views/not_pressed/admin/pages/_form.html.erb +177 -0
  82. data/app/views/not_pressed/admin/pages/_page_tree_node.html.erb +32 -0
  83. data/app/views/not_pressed/admin/pages/edit.html.erb +69 -0
  84. data/app/views/not_pressed/admin/pages/index.html.erb +21 -0
  85. data/app/views/not_pressed/admin/pages/new.html.erb +1 -0
  86. data/app/views/not_pressed/admin/pages/preview.html.erb +17 -0
  87. data/app/views/not_pressed/admin/plugins/_settings_form.html.erb +59 -0
  88. data/app/views/not_pressed/admin/plugins/index.html.erb +48 -0
  89. data/app/views/not_pressed/admin/plugins/show.html.erb +54 -0
  90. data/app/views/not_pressed/admin/settings/code_injection.html.erb +21 -0
  91. data/app/views/not_pressed/admin/shared/_breadcrumbs.html.erb +14 -0
  92. data/app/views/not_pressed/admin/shared/_flash.html.erb +7 -0
  93. data/app/views/not_pressed/admin/shared/_modal.html.erb +9 -0
  94. data/app/views/not_pressed/admin/shared/_sidebar.html.erb +18 -0
  95. data/app/views/not_pressed/admin/tags/index.html.erb +52 -0
  96. data/app/views/not_pressed/admin/themes/index.html.erb +60 -0
  97. data/app/views/not_pressed/admin/themes/show.html.erb +66 -0
  98. data/app/views/not_pressed/blog/_post_card.html.erb +24 -0
  99. data/app/views/not_pressed/blog/feed.rss.builder +22 -0
  100. data/app/views/not_pressed/blog/index.html.erb +56 -0
  101. data/app/views/not_pressed/blog/show.html.erb +41 -0
  102. data/app/views/not_pressed/form_mailer/submission_notification.text.erb +8 -0
  103. data/app/views/not_pressed/pages/show.html.erb +4 -0
  104. data/app/views/themes/starter/layouts/not_pressed/default.html.erb +36 -0
  105. data/app/views/themes/starter/layouts/not_pressed/full_width.html.erb +36 -0
  106. data/app/views/themes/starter/layouts/not_pressed/sidebar.html.erb +41 -0
  107. data/config/routes.rb +81 -0
  108. data/db/migrate/20260310000001_create_not_pressed_pages.rb +20 -0
  109. data/db/migrate/20260310000002_create_not_pressed_content_blocks.rb +17 -0
  110. data/db/migrate/20260310000003_create_not_pressed_media_attachments.rb +14 -0
  111. data/db/migrate/20260310000004_add_content_type_to_not_pressed_pages.rb +8 -0
  112. data/db/migrate/20260310000005_add_publishing_fields_to_not_pressed_pages.rb +8 -0
  113. data/db/migrate/20260310000006_create_not_pressed_page_versions.rb +16 -0
  114. data/db/migrate/20260310000007_add_settings_fields_to_not_pressed_pages.rb +11 -0
  115. data/db/migrate/20260311000001_create_not_pressed_forms.rb +42 -0
  116. data/db/migrate/20260311000002_add_seo_fields_to_not_pressed_pages.rb +10 -0
  117. data/db/migrate/20260311000003_add_code_injection_to_not_pressed_pages.rb +8 -0
  118. data/db/migrate/20260311000004_create_not_pressed_settings.rb +14 -0
  119. data/db/migrate/20260312000001_create_not_pressed_categories.rb +16 -0
  120. data/db/migrate/20260312000002_create_not_pressed_tags.rb +15 -0
  121. data/db/migrate/20260312000003_create_not_pressed_taggings.rb +14 -0
  122. data/db/migrate/20260312000004_add_category_id_to_not_pressed_pages.rb +7 -0
  123. data/lib/generators/not_pressed/install/install_generator.rb +52 -0
  124. data/lib/generators/not_pressed/install/templates/initializer.rb.tt +89 -0
  125. data/lib/generators/not_pressed/install/templates/seeds.rb.tt +131 -0
  126. data/lib/generators/not_pressed/plugin/plugin_generator.rb +37 -0
  127. data/lib/generators/not_pressed/plugin/templates/plugin.rb.tt +23 -0
  128. data/lib/not_pressed/admin/authentication.rb +48 -0
  129. data/lib/not_pressed/admin/menu_registry.rb +100 -0
  130. data/lib/not_pressed/configuration.rb +77 -0
  131. data/lib/not_pressed/content_type.rb +23 -0
  132. data/lib/not_pressed/content_type_builder.rb +51 -0
  133. data/lib/not_pressed/content_type_registry.rb +45 -0
  134. data/lib/not_pressed/engine.rb +132 -0
  135. data/lib/not_pressed/hooks.rb +166 -0
  136. data/lib/not_pressed/navigation/builder.rb +148 -0
  137. data/lib/not_pressed/navigation/menu.rb +54 -0
  138. data/lib/not_pressed/navigation/menu_item.rb +33 -0
  139. data/lib/not_pressed/navigation/node.rb +45 -0
  140. data/lib/not_pressed/navigation/partial_parser.rb +96 -0
  141. data/lib/not_pressed/navigation/route_inspector.rb +98 -0
  142. data/lib/not_pressed/navigation.rb +6 -0
  143. data/lib/not_pressed/plugin.rb +354 -0
  144. data/lib/not_pressed/plugin_importer.rb +133 -0
  145. data/lib/not_pressed/plugin_manager.rb +196 -0
  146. data/lib/not_pressed/plugin_packager.rb +129 -0
  147. data/lib/not_pressed/rendering/block_renderer.rb +222 -0
  148. data/lib/not_pressed/rendering/renderer_registry.rb +154 -0
  149. data/lib/not_pressed/rendering.rb +8 -0
  150. data/lib/not_pressed/seo/sitemap_builder.rb +61 -0
  151. data/lib/not_pressed/theme.rb +191 -0
  152. data/lib/not_pressed/theme_importer.rb +133 -0
  153. data/lib/not_pressed/theme_packager.rb +180 -0
  154. data/lib/not_pressed/theme_registry.rb +123 -0
  155. data/lib/not_pressed/version.rb +5 -0
  156. data/lib/not_pressed.rb +65 -0
  157. 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ module Navigation
5
+ end
6
+ end