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,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ # Base class for all NotPressed plugins.
5
+ #
6
+ # Provides a class-level DSL for declaring metadata, hooks, filters,
7
+ # block types, admin menus, settings, dependencies, and routes.
8
+ # Instance methods handle activation/deactivation lifecycle and
9
+ # namespaced settings access.
10
+ #
11
+ # @example Defining a plugin
12
+ # class MyPlugin < NotPressed::Plugin
13
+ # name "my_plugin"
14
+ # version "1.0.0"
15
+ # description "Does useful things"
16
+ #
17
+ # on :after_page_save do |page|
18
+ # Rails.logger.info "Saved: #{page.title}"
19
+ # end
20
+ #
21
+ # settings do
22
+ # string :api_key, label: "API Key"
23
+ # end
24
+ # end
25
+ class Plugin
26
+ class << self
27
+ # @api private
28
+ def inherited(subclass)
29
+ subclass.instance_variable_set(:@plugin_hooks, [])
30
+ subclass.instance_variable_set(:@plugin_filters, [])
31
+ subclass.instance_variable_set(:@plugin_block_types, [])
32
+ subclass.instance_variable_set(:@plugin_admin_menus, [])
33
+ subclass.instance_variable_set(:@plugin_settings_schema, [])
34
+ subclass.instance_variable_set(:@plugin_dependencies, [])
35
+ subclass.instance_variable_set(:@plugin_routes, [])
36
+ super
37
+ end
38
+
39
+ # Sets or gets the plugin name. Required for registration.
40
+ #
41
+ # @param val [String, nil] the plugin name to set, or nil to read
42
+ # @return [String, nil] the current plugin name
43
+ def name(val = nil)
44
+ val ? @plugin_name = val : @plugin_name
45
+ end
46
+
47
+ # Sets or gets the plugin version.
48
+ #
49
+ # @param val [String, nil] the version string to set, or nil to read
50
+ # @return [String, nil] the current version
51
+ def version(val = nil)
52
+ val ? @plugin_version = val : @plugin_version
53
+ end
54
+
55
+ # Sets or gets the plugin description.
56
+ #
57
+ # @param val [String, nil] the description to set, or nil to read
58
+ # @return [String, nil] the current description
59
+ def description(val = nil)
60
+ val ? @plugin_description = val : @plugin_description
61
+ end
62
+
63
+ # Sets or gets the plugin author.
64
+ #
65
+ # @param val [String, nil] the author to set, or nil to read
66
+ # @return [String, nil] the current author
67
+ def author(val = nil)
68
+ val ? @plugin_author = val : @plugin_author
69
+ end
70
+
71
+ # Sets or gets the plugin URL.
72
+ #
73
+ # @param val [String, nil] the URL to set, or nil to read
74
+ # @return [String, nil] the current URL
75
+ def url(val = nil)
76
+ val ? @plugin_url = val : @plugin_url
77
+ end
78
+
79
+ # @return [String, nil] the declared plugin name
80
+ def plugin_name
81
+ @plugin_name
82
+ end
83
+
84
+ # @return [String, nil] the declared plugin version
85
+ def plugin_version
86
+ @plugin_version
87
+ end
88
+
89
+ # @return [String, nil] the declared plugin description
90
+ def plugin_description
91
+ @plugin_description
92
+ end
93
+
94
+ # @return [String, nil] the declared plugin author
95
+ def plugin_author
96
+ @plugin_author
97
+ end
98
+
99
+ # @return [String, nil] the declared plugin URL
100
+ def plugin_url
101
+ @plugin_url
102
+ end
103
+
104
+ # Registers a hook callback for the given event.
105
+ #
106
+ # @param event [Symbol] the hook event name
107
+ # @yield the callback block invoked when the event fires
108
+ # @return [void]
109
+ def on(event, &block)
110
+ @plugin_hooks << { event: event, callback: block }
111
+ end
112
+
113
+ # @return [Array<Hash>] registered hook entries with :event and :callback keys
114
+ def plugin_hooks
115
+ @plugin_hooks
116
+ end
117
+
118
+ # Registers a filter callback that transforms values through a chain.
119
+ #
120
+ # @param name [Symbol] the filter name
121
+ # @yield [value, *args] receives the current value and extra arguments
122
+ # @return [void]
123
+ def filter(name, &block)
124
+ @plugin_filters << { name: name, callback: block }
125
+ end
126
+
127
+ # @return [Array<Hash>] registered filter entries with :name and :callback keys
128
+ def plugin_filters
129
+ @plugin_filters
130
+ end
131
+
132
+ # Declares a custom content block type provided by this plugin.
133
+ #
134
+ # @param name [Symbol, String] block type identifier
135
+ # @param options [Hash] additional options for the block type
136
+ # @return [void]
137
+ def block_type(name, **options)
138
+ @plugin_block_types << { name: name, options: options }
139
+ end
140
+
141
+ # @return [Array<Hash>] registered block type entries
142
+ def plugin_block_types
143
+ @plugin_block_types
144
+ end
145
+
146
+ # Adds an entry to the admin sidebar when the plugin is active.
147
+ #
148
+ # @param label [String] display label for the menu item
149
+ # @param path [Symbol, String] route helper name or URL path
150
+ # @param icon [String, nil] icon identifier
151
+ # @return [void]
152
+ def admin_menu(label, path:, icon: nil)
153
+ @plugin_admin_menus << { label: label, path: path, icon: icon }
154
+ end
155
+
156
+ # @return [Array<Hash>] registered admin menu entries
157
+ def plugin_admin_menus
158
+ @plugin_admin_menus
159
+ end
160
+
161
+ # Defines a settings schema using the SettingsBuilder DSL.
162
+ #
163
+ # @yield block evaluated in the context of a {NotPressed::SettingsBuilder}
164
+ # @return [void]
165
+ def settings(&block)
166
+ builder = SettingsBuilder.new
167
+ builder.instance_eval(&block)
168
+ @plugin_settings_schema = builder.schema
169
+ end
170
+
171
+ # @return [Array<Hash>] the settings schema fields
172
+ def plugin_settings_schema
173
+ @plugin_settings_schema
174
+ end
175
+
176
+ # Declares a dependency on another plugin that must be active first.
177
+ #
178
+ # @param name [String] name of the required plugin
179
+ # @param constraint [String, nil] version constraint (currently unused)
180
+ # @return [void]
181
+ def depends_on(name, constraint = nil)
182
+ @plugin_dependencies << { name: name, constraint: constraint }
183
+ end
184
+
185
+ # @return [Array<Hash>] declared dependency entries
186
+ def plugin_dependencies
187
+ @plugin_dependencies
188
+ end
189
+
190
+ # Stores a routing block for the plugin.
191
+ #
192
+ # @yield routing DSL block
193
+ # @return [void]
194
+ def routes(&block)
195
+ @plugin_routes << block
196
+ end
197
+
198
+ # @return [Array<Proc>] stored route blocks
199
+ def plugin_routes
200
+ @plugin_routes
201
+ end
202
+ end
203
+
204
+ # Instance methods — settings
205
+
206
+ # Reads a namespaced setting value from the database.
207
+ #
208
+ # @param key [Symbol, String] the setting key (auto-prefixed with +plugin.<name>.+)
209
+ # @return [String, nil] the stored value
210
+ def get_setting(key)
211
+ namespaced_key = "plugin.#{self.class.plugin_name}.#{key}"
212
+ NotPressed::Setting.get(namespaced_key)
213
+ end
214
+
215
+ # Writes a namespaced setting value to the database.
216
+ #
217
+ # @param key [Symbol, String] the setting key
218
+ # @param value [Object] the value to store (converted to string)
219
+ # @return [NotPressed::Setting] the saved setting record
220
+ def set_setting(key, value)
221
+ namespaced_key = "plugin.#{self.class.plugin_name}.#{key}"
222
+ NotPressed::Setting.set(namespaced_key, value.to_s)
223
+ end
224
+
225
+ # Returns a hash of all settings defined in the schema with their current values.
226
+ #
227
+ # @return [Hash{String => String, nil}] setting keys mapped to stored values
228
+ def settings_values
229
+ schema = self.class.plugin_settings_schema || []
230
+ schema.each_with_object({}) do |field, hash|
231
+ hash[field[:key].to_s] = get_setting(field[:key])
232
+ end
233
+ end
234
+
235
+ # Instance methods (lifecycle)
236
+
237
+ # Activates the plugin by registering all hooks, filters, block types,
238
+ # and admin menus with the engine's global registries.
239
+ #
240
+ # @return [true]
241
+ def activate!
242
+ self.class.plugin_hooks.each do |hook|
243
+ NotPressed::Hooks.register(hook[:event], &hook[:callback])
244
+ end
245
+ self.class.plugin_filters.each do |filter|
246
+ NotPressed::Hooks.register_filter(filter[:name], &filter[:callback])
247
+ end
248
+ self.class.plugin_block_types.each do |bt|
249
+ NotPressed::ContentBlock.register_type(bt[:name].to_s)
250
+ end
251
+ self.class.plugin_admin_menus.each do |menu|
252
+ NotPressed::Admin::MenuRegistry.register(
253
+ menu[:label],
254
+ path: menu[:path],
255
+ icon: menu[:icon],
256
+ section: :main,
257
+ position: 100
258
+ )
259
+ end
260
+ @active = true
261
+ end
262
+
263
+ # Deactivates the plugin by unregistering all hooks, filters, block types,
264
+ # and admin menus from the engine's global registries.
265
+ #
266
+ # @return [false]
267
+ def deactivate!
268
+ self.class.plugin_hooks.each do |hook|
269
+ NotPressed::Hooks.unregister(hook[:event], hook[:callback])
270
+ end
271
+ self.class.plugin_filters.each do |filter|
272
+ NotPressed::Hooks.unregister_filter(filter[:name], filter[:callback])
273
+ end
274
+ self.class.plugin_block_types.each do |bt|
275
+ NotPressed::ContentBlock.custom_types.delete(bt[:name].to_s)
276
+ end
277
+ self.class.plugin_admin_menus.each do |menu|
278
+ NotPressed::Admin::MenuRegistry.unregister(menu[:label], path: menu[:path])
279
+ end
280
+ @active = false
281
+ end
282
+
283
+ # Returns whether this plugin instance is currently active.
284
+ #
285
+ # @return [Boolean]
286
+ def active?
287
+ @active || false
288
+ end
289
+ end
290
+
291
+ # DSL builder for defining plugin settings schemas.
292
+ #
293
+ # Used inside a +Plugin.settings+ block to declare typed fields.
294
+ #
295
+ # @example
296
+ # settings do
297
+ # string :api_key, label: "API Key"
298
+ # boolean :enabled, label: "Enabled", default: true
299
+ # integer :max_items, label: "Max Items"
300
+ # end
301
+ class SettingsBuilder
302
+ # @return [Array<Hash>] the accumulated schema fields
303
+ attr_reader :schema
304
+
305
+ def initialize
306
+ @schema = []
307
+ end
308
+
309
+ # Declares a string setting field.
310
+ #
311
+ # @param key [Symbol] field identifier
312
+ # @param options [Hash] additional options (e.g., :label, :default)
313
+ # @return [void]
314
+ def string(key, **options)
315
+ @schema << { type: :string, key: key }.merge(options)
316
+ end
317
+
318
+ # Declares a text (multiline) setting field.
319
+ #
320
+ # @param key [Symbol] field identifier
321
+ # @param options [Hash] additional options
322
+ # @return [void]
323
+ def text(key, **options)
324
+ @schema << { type: :text, key: key }.merge(options)
325
+ end
326
+
327
+ # Declares a boolean setting field.
328
+ #
329
+ # @param key [Symbol] field identifier
330
+ # @param options [Hash] additional options
331
+ # @return [void]
332
+ def boolean(key, **options)
333
+ @schema << { type: :boolean, key: key }.merge(options)
334
+ end
335
+
336
+ # Declares a select (dropdown) setting field.
337
+ #
338
+ # @param key [Symbol] field identifier
339
+ # @param options [Hash] additional options (should include :choices)
340
+ # @return [void]
341
+ def select(key, **options)
342
+ @schema << { type: :select, key: key }.merge(options)
343
+ end
344
+
345
+ # Declares an integer setting field.
346
+ #
347
+ # @param key [Symbol] field identifier
348
+ # @param options [Hash] additional options
349
+ # @return [void]
350
+ def integer(key, **options)
351
+ @schema << { type: :integer, key: key }.merge(options)
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+ require "json"
5
+
6
+ module NotPressed
7
+ # Imports a plugin from an exported zip archive.
8
+ #
9
+ # Validates the archive structure, checks for duplicates, prevents
10
+ # path traversal attacks, and extracts files to the Rails root.
11
+ #
12
+ # @example
13
+ # result = NotPressed::PluginImporter.new("/tmp/analytics-1.0.0.zip").import!
14
+ # result[:name] #=> "Analytics"
15
+ # result[:files_installed] #=> ["app/plugins/not_pressed/analytics_plugin.rb"]
16
+ class PluginImporter
17
+ # Raised when the zip archive is invalid, missing manifest, or contains
18
+ # dangerous paths.
19
+ class InvalidArchive < StandardError; end
20
+
21
+ # Raised when a plugin with the same name is already registered.
22
+ class DuplicatePlugin < StandardError; end
23
+
24
+ # @param zip_path [String] path to the plugin zip archive
25
+ # @param force [Boolean] when true, overwrites an existing plugin with the same name
26
+ def initialize(zip_path, force: false)
27
+ @zip_path = zip_path
28
+ @force = force
29
+ end
30
+
31
+ # Validates and extracts the plugin archive.
32
+ #
33
+ # @return [Hash] result with :name, :version, :files_installed, :path keys
34
+ # @raise [ArgumentError] if the zip file does not exist
35
+ # @raise [InvalidArchive] if the archive is corrupt, missing manifest, or contains path traversal
36
+ # @raise [DuplicatePlugin] if a plugin with the same name is already registered (unless force: true)
37
+ def import!
38
+ validate_file_exists!
39
+ manifest = read_manifest
40
+ validate_manifest!(manifest)
41
+ check_duplicate!(manifest["name"])
42
+ files = validate_paths!(manifest["files"])
43
+ extract_files!(files)
44
+
45
+ {
46
+ name: manifest["name"],
47
+ version: manifest["version"],
48
+ files_installed: files,
49
+ path: @zip_path
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ # Raises ArgumentError if the zip file path does not exist.
56
+ def validate_file_exists!
57
+ raise ArgumentError, "Zip file does not exist: #{@zip_path}" unless File.exist?(@zip_path)
58
+ end
59
+
60
+ # Opens the zip and reads/parses manifest.json.
61
+ #
62
+ # @return [Hash] parsed manifest data
63
+ # @raise [InvalidArchive] if the zip is corrupt or missing manifest.json
64
+ def read_manifest
65
+ Zip::File.open(@zip_path) do |zip|
66
+ entry = zip.find_entry("manifest.json")
67
+ raise InvalidArchive, "Archive is missing manifest.json" unless entry
68
+
69
+ JSON.parse(entry.get_input_stream.read)
70
+ end
71
+ rescue Zip::Error
72
+ raise InvalidArchive, "Archive is not a valid zip file"
73
+ end
74
+
75
+ # Validates that the manifest contains required fields.
76
+ #
77
+ # @param manifest [Hash] parsed manifest data
78
+ # @raise [InvalidArchive] if required fields are missing
79
+ def validate_manifest!(manifest)
80
+ raise InvalidArchive, "Manifest is missing required field: name" unless manifest["name"].is_a?(String) && !manifest["name"].empty?
81
+ raise InvalidArchive, "Manifest is missing required field: version" unless manifest["version"]
82
+ raise InvalidArchive, "Manifest is missing required field: files" unless manifest["files"].is_a?(Array)
83
+ end
84
+
85
+ # Checks whether a plugin with the given name is already registered.
86
+ #
87
+ # @param name [String] the plugin name from the manifest
88
+ # @raise [DuplicatePlugin] if the name exists and force is false
89
+ def check_duplicate!(name)
90
+ return if @force
91
+
92
+ if PluginManager.registered.key?(name)
93
+ raise DuplicatePlugin, "Plugin '#{name}' is already registered. Use force: true to overwrite."
94
+ end
95
+ end
96
+
97
+ # Validates that all file paths are safe (no path traversal).
98
+ #
99
+ # @param files [Array<String>] relative file paths from manifest
100
+ # @return [Array<String>] the validated file paths
101
+ # @raise [InvalidArchive] if any path escapes the Rails root
102
+ def validate_paths!(files)
103
+ root = Rails.root.to_s
104
+
105
+ files.each do |relative_path|
106
+ absolute = File.expand_path(File.join(root, relative_path))
107
+ unless absolute.start_with?(root)
108
+ raise InvalidArchive, "Path traversal detected in archive entry: #{relative_path}"
109
+ end
110
+ end
111
+
112
+ files
113
+ end
114
+
115
+ # Extracts the listed files from the zip into the Rails root.
116
+ #
117
+ # @param files [Array<String>] relative file paths to extract
118
+ def extract_files!(files)
119
+ root = Rails.root.to_s
120
+
121
+ Zip::File.open(@zip_path) do |zip|
122
+ files.each do |relative_path|
123
+ entry = zip.find_entry(relative_path)
124
+ next unless entry
125
+
126
+ dest = File.join(root, relative_path)
127
+ FileUtils.mkdir_p(File.dirname(dest))
128
+ File.write(dest, entry.get_input_stream.read)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ # Thread-safe module for registering, enabling, disabling, and discovering plugins.
5
+ #
6
+ # Manages the full plugin lifecycle including dependency resolution.
7
+ # All public methods are synchronized via an internal mutex.
8
+ module PluginManager
9
+ # Raised when a plugin name is not found in the registry.
10
+ class NotFound < StandardError; end
11
+
12
+ # Raised when a plugin has unmet dependencies that prevent activation.
13
+ class DependencyError < StandardError; end
14
+
15
+ class << self
16
+ # Registration
17
+
18
+ # Registers a plugin class in the registry.
19
+ #
20
+ # @param plugin_class [Class] a subclass of {NotPressed::Plugin} with a declared name
21
+ # @raise [ArgumentError] if the class is not a valid Plugin subclass or has no name
22
+ # @return [void]
23
+ def register(plugin_class)
24
+ unless plugin_class.is_a?(Class) && plugin_class < NotPressed::Plugin
25
+ raise ArgumentError, "#{plugin_class} must be a subclass of NotPressed::Plugin"
26
+ end
27
+
28
+ name = plugin_class.plugin_name
29
+ raise ArgumentError, "Plugin must declare a name using the `name` DSL" unless name
30
+
31
+ mutex.synchronize do
32
+ registry[name] = { klass: plugin_class, instance: nil, status: :registered }
33
+ end
34
+ end
35
+
36
+ # Returns a snapshot of all registered plugins.
37
+ #
38
+ # @return [Hash{String => Hash}] keyed by plugin name; values contain :klass, :instance, :status
39
+ def registered
40
+ mutex.synchronize { registry.dup }
41
+ end
42
+
43
+ # Looks up a plugin by name.
44
+ #
45
+ # @param name [String] the plugin name
46
+ # @return [Hash] entry with :klass, :instance, and :status keys
47
+ # @raise [NotFound] if the plugin is not registered
48
+ def find(name)
49
+ mutex.synchronize do
50
+ registry[name] || raise(NotFound, "Plugin '#{name}' not found")
51
+ end
52
+ end
53
+
54
+ # Lifecycle
55
+
56
+ # Creates a plugin instance, checks dependencies, activates it, and marks it active.
57
+ #
58
+ # @param name [String] the plugin name
59
+ # @raise [NotFound] if the plugin is not registered
60
+ # @raise [DependencyError] if the plugin has unmet dependencies
61
+ # @return [void]
62
+ def enable(name)
63
+ info = find(name)
64
+ plugin_class = info[:klass]
65
+
66
+ unmet = check_dependencies(plugin_class)
67
+ unless unmet.empty?
68
+ raise DependencyError,
69
+ "Plugin '#{name}' has unmet dependencies: #{unmet.join(', ')}"
70
+ end
71
+
72
+ instance = plugin_class.new
73
+ instance.activate!
74
+
75
+ mutex.synchronize do
76
+ registry[name] = { klass: plugin_class, instance: instance, status: :active }
77
+ end
78
+ end
79
+
80
+ # Deactivates a plugin instance and marks it inactive.
81
+ #
82
+ # @param name [String] the plugin name
83
+ # @raise [NotFound] if the plugin is not registered
84
+ # @return [void]
85
+ def disable(name)
86
+ info = find(name)
87
+
88
+ if info[:instance]
89
+ info[:instance].deactivate!
90
+ end
91
+
92
+ mutex.synchronize do
93
+ registry[name] = { klass: info[:klass], instance: nil, status: :inactive }
94
+ end
95
+ end
96
+
97
+ # Checks whether a plugin is currently active.
98
+ #
99
+ # @param name [String] the plugin name
100
+ # @return [Boolean]
101
+ def enabled?(name)
102
+ mutex.synchronize do
103
+ registry.key?(name) && registry[name][:status] == :active
104
+ end
105
+ end
106
+
107
+ # Dependency resolution
108
+
109
+ # Returns a list of unmet dependency names for the given plugin class.
110
+ #
111
+ # @param plugin_class [Class] a subclass of {NotPressed::Plugin}
112
+ # @return [Array<String>] names of inactive or unregistered dependencies
113
+ def check_dependencies(plugin_class)
114
+ deps = plugin_class.plugin_dependencies || []
115
+ deps.each_with_object([]) do |dep, unmet|
116
+ dep_name = dep[:name]
117
+ unless mutex.synchronize { registry.key?(dep_name) && registry[dep_name][:status] == :active }
118
+ unmet << dep_name
119
+ end
120
+ end
121
+ end
122
+
123
+ # Bulk operations
124
+
125
+ # Attempts to enable all registered plugins in dependency order.
126
+ #
127
+ # Uses multiple passes to resolve dependencies; stops when no further
128
+ # progress is possible.
129
+ #
130
+ # @return [void]
131
+ def enable_all
132
+ # Simple topological approach: try to enable each plugin,
133
+ # retry those with unmet deps after others are enabled
134
+ remaining = mutex.synchronize { registry.keys.select { |n| registry[n][:status] != :active } }
135
+ max_passes = remaining.size + 1
136
+ pass = 0
137
+
138
+ while remaining.any? && pass < max_passes
139
+ pass += 1
140
+ still_remaining = []
141
+ remaining.each do |name|
142
+ begin
143
+ enable(name)
144
+ rescue DependencyError
145
+ still_remaining << name
146
+ end
147
+ end
148
+ break if still_remaining.size == remaining.size # no progress
149
+ remaining = still_remaining
150
+ end
151
+ end
152
+
153
+ # Disables all currently active plugins.
154
+ #
155
+ # @return [void]
156
+ def disable_all
157
+ active_names = mutex.synchronize { registry.keys.select { |n| registry[n][:status] == :active } }
158
+ active_names.each { |name| disable(name) }
159
+ end
160
+
161
+ # Clears the entire plugin registry.
162
+ #
163
+ # @return [void]
164
+ def reset!
165
+ mutex.synchronize do
166
+ @registry = {}
167
+ end
168
+ end
169
+
170
+ # Discovery
171
+
172
+ # Scans all subclasses of {NotPressed::Plugin} and registers any
173
+ # that have a declared name but are not yet registered.
174
+ #
175
+ # @return [void]
176
+ def discover
177
+ NotPressed::Plugin.subclasses.each do |klass|
178
+ next unless klass.plugin_name
179
+ next if mutex.synchronize { registry.key?(klass.plugin_name) }
180
+
181
+ register(klass)
182
+ end
183
+ end
184
+
185
+ private
186
+
187
+ def mutex
188
+ @mutex ||= Mutex.new
189
+ end
190
+
191
+ def registry
192
+ @registry ||= {}
193
+ end
194
+ end
195
+ end
196
+ end