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,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
|