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,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotPressed
|
|
4
|
+
# Base class for NotPressed themes. Inherits from {Plugin} and adds
|
|
5
|
+
# DSL methods for layouts, stylesheets, color schemes, templates, and assets.
|
|
6
|
+
#
|
|
7
|
+
# Only one theme can be active at a time (managed by {ThemeRegistry}).
|
|
8
|
+
#
|
|
9
|
+
# @example Defining a theme
|
|
10
|
+
# class MyTheme < NotPressed::Theme
|
|
11
|
+
# name "my_theme"
|
|
12
|
+
# version "1.0"
|
|
13
|
+
#
|
|
14
|
+
# layout :default, label: "Default", primary: true
|
|
15
|
+
# layout :sidebar, label: "With Sidebar"
|
|
16
|
+
#
|
|
17
|
+
# stylesheet "my_theme/application"
|
|
18
|
+
# templates_path "app/views/themes/my_theme"
|
|
19
|
+
#
|
|
20
|
+
# color_scheme do
|
|
21
|
+
# color :primary, "#3366cc", label: "Primary"
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
class Theme < Plugin
|
|
25
|
+
class << self
|
|
26
|
+
# @api private
|
|
27
|
+
def inherited(subclass)
|
|
28
|
+
super
|
|
29
|
+
subclass.instance_variable_set(:@theme_layouts, [])
|
|
30
|
+
subclass.instance_variable_set(:@theme_stylesheet, nil)
|
|
31
|
+
subclass.instance_variable_set(:@theme_color_scheme, [])
|
|
32
|
+
subclass.instance_variable_set(:@theme_templates_path, nil)
|
|
33
|
+
subclass.instance_variable_set(:@theme_assets_path, nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Declares a layout provided by this theme.
|
|
37
|
+
#
|
|
38
|
+
# @param name [Symbol, String] layout identifier
|
|
39
|
+
# @param label [String] human-readable label
|
|
40
|
+
# @param primary [Boolean] whether this is the default layout
|
|
41
|
+
# @return [void]
|
|
42
|
+
def layout(name, label:, primary: false)
|
|
43
|
+
@theme_layouts << { name: name, label: label, primary: primary }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Array<Hash>] declared layout entries
|
|
47
|
+
def theme_layouts
|
|
48
|
+
@theme_layouts
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the primary layout, or the first declared layout if none is marked primary.
|
|
52
|
+
#
|
|
53
|
+
# @return [Hash, nil] the primary layout entry
|
|
54
|
+
def primary_layout
|
|
55
|
+
@theme_layouts.find { |l| l[:primary] } || @theme_layouts.first
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Sets or gets the theme stylesheet path.
|
|
59
|
+
#
|
|
60
|
+
# @param path [String, nil] stylesheet path to set, or nil to read
|
|
61
|
+
# @return [String, nil] the current stylesheet path
|
|
62
|
+
def stylesheet(path = nil)
|
|
63
|
+
path ? @theme_stylesheet = path : @theme_stylesheet
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [String, nil] the declared stylesheet path
|
|
67
|
+
def theme_stylesheet
|
|
68
|
+
@theme_stylesheet
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Defines customizable colors using the {ColorSchemeBuilder} DSL.
|
|
72
|
+
#
|
|
73
|
+
# @yield block evaluated in the context of a {ColorSchemeBuilder}
|
|
74
|
+
# @return [void]
|
|
75
|
+
def color_scheme(&block)
|
|
76
|
+
builder = ColorSchemeBuilder.new
|
|
77
|
+
builder.instance_eval(&block)
|
|
78
|
+
@theme_color_scheme = builder.colors
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Array<Hash>] declared color definitions
|
|
82
|
+
def theme_color_scheme
|
|
83
|
+
@theme_color_scheme
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Sets or gets the path to theme-specific view templates.
|
|
87
|
+
#
|
|
88
|
+
# @param path [String, nil] templates directory path to set, or nil to read
|
|
89
|
+
# @return [String, nil] the current templates path
|
|
90
|
+
def templates_path(path = nil)
|
|
91
|
+
path ? @theme_templates_path = path : @theme_templates_path
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [String, nil] the declared templates path
|
|
95
|
+
def theme_templates_path
|
|
96
|
+
@theme_templates_path
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Sets or gets the path to theme asset files.
|
|
100
|
+
#
|
|
101
|
+
# @param path [String, nil] assets directory path to set, or nil to read
|
|
102
|
+
# @return [String, nil] the current assets path
|
|
103
|
+
def assets_path(path = nil)
|
|
104
|
+
path ? @theme_assets_path = path : @theme_assets_path
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [String, nil] the declared assets path
|
|
108
|
+
def theme_assets_path
|
|
109
|
+
@theme_assets_path
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Instance methods — view path management
|
|
114
|
+
|
|
115
|
+
# Activates the theme by calling {Plugin#activate!}, then prepends
|
|
116
|
+
# template paths and registers stylesheet/assets with the Rails asset pipeline.
|
|
117
|
+
#
|
|
118
|
+
# @return [true]
|
|
119
|
+
def activate!
|
|
120
|
+
super
|
|
121
|
+
theme_class = self.class
|
|
122
|
+
if theme_class.theme_templates_path
|
|
123
|
+
path = resolve_templates_path(theme_class.theme_templates_path)
|
|
124
|
+
ActionController::Base.prepend_view_path(path)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if theme_class.theme_stylesheet
|
|
128
|
+
if defined?(Rails) && Rails.application&.config&.respond_to?(:assets)
|
|
129
|
+
assets_config = Rails.application.config.assets
|
|
130
|
+
unless assets_config.precompile.include?(theme_class.theme_stylesheet)
|
|
131
|
+
assets_config.precompile << theme_class.theme_stylesheet
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if theme_class.theme_assets_path
|
|
137
|
+
path = resolve_templates_path(theme_class.theme_assets_path)
|
|
138
|
+
if defined?(Rails) && Rails.application&.config&.respond_to?(:assets)
|
|
139
|
+
unless Rails.application.config.assets.paths.include?(path)
|
|
140
|
+
Rails.application.config.assets.paths << path
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Deactivates the theme by calling {Plugin#deactivate!}, then removes
|
|
147
|
+
# the theme's templates path from the view paths.
|
|
148
|
+
#
|
|
149
|
+
# @return [false]
|
|
150
|
+
def deactivate!
|
|
151
|
+
super
|
|
152
|
+
theme_class = self.class
|
|
153
|
+
if theme_class.theme_templates_path
|
|
154
|
+
path = resolve_templates_path(theme_class.theme_templates_path)
|
|
155
|
+
ActionController::Base.view_paths = ActionController::Base.view_paths.reject { |vp| vp.to_s == path.to_s }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def resolve_templates_path(path)
|
|
162
|
+
if Pathname.new(path).absolute?
|
|
163
|
+
path
|
|
164
|
+
else
|
|
165
|
+
Rails.root.join(path).to_s
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# DSL builder for defining theme color schemes.
|
|
171
|
+
#
|
|
172
|
+
# Used inside a +Theme.color_scheme+ block.
|
|
173
|
+
class ColorSchemeBuilder
|
|
174
|
+
# @return [Array<Hash>] the accumulated color definitions
|
|
175
|
+
attr_reader :colors
|
|
176
|
+
|
|
177
|
+
def initialize
|
|
178
|
+
@colors = []
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Declares a customizable color.
|
|
182
|
+
#
|
|
183
|
+
# @param key [Symbol] color identifier (used as CSS custom property name)
|
|
184
|
+
# @param default [String] default color value (e.g., hex code)
|
|
185
|
+
# @param label [String, nil] human-readable label
|
|
186
|
+
# @return [void]
|
|
187
|
+
def color(key, default, label: nil)
|
|
188
|
+
@colors << { key: key, default: default, label: label }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module NotPressed
|
|
7
|
+
# Imports a theme 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::ThemeImporter.new("/tmp/starter-1.0.0.zip").import!
|
|
14
|
+
# result[:name] #=> "Starter"
|
|
15
|
+
# result[:files_installed] #=> ["app/themes/not_pressed/starter_theme.rb"]
|
|
16
|
+
class ThemeImporter
|
|
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 theme with the same name is already registered.
|
|
22
|
+
class DuplicateTheme < StandardError; end
|
|
23
|
+
|
|
24
|
+
# @param zip_path [String] path to the theme zip archive
|
|
25
|
+
# @param force [Boolean] when true, overwrites an existing theme 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 theme 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 [DuplicateTheme] if a theme 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 theme with the given name is already registered.
|
|
86
|
+
#
|
|
87
|
+
# @param name [String] the theme name from the manifest
|
|
88
|
+
# @raise [DuplicateTheme] if the name exists and force is false
|
|
89
|
+
def check_duplicate!(name)
|
|
90
|
+
return if @force
|
|
91
|
+
|
|
92
|
+
if ThemeRegistry.available.include?(name)
|
|
93
|
+
raise DuplicateTheme, "Theme '#{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,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zip"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module NotPressed
|
|
7
|
+
# Exports a registered theme into a distributable zip archive.
|
|
8
|
+
#
|
|
9
|
+
# The archive contains a manifest.json with theme metadata and all
|
|
10
|
+
# associated source files (theme class file, layout templates, and stylesheet).
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# packager = NotPressed::ThemePackager.new("Starter")
|
|
14
|
+
# zip_path = packager.package("/tmp/exports")
|
|
15
|
+
class ThemePackager
|
|
16
|
+
# @param theme_name [String] the registered theme name
|
|
17
|
+
def initialize(theme_name)
|
|
18
|
+
@theme_name = theme_name
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Creates a zip archive of the theme at the given output directory.
|
|
22
|
+
#
|
|
23
|
+
# @param output_path [String] directory where the zip will be written
|
|
24
|
+
# @return [String] absolute path to the created zip file
|
|
25
|
+
# @raise [ThemeRegistry::NotFound] if the theme is not registered
|
|
26
|
+
# @raise [ArgumentError] if output_path does not exist
|
|
27
|
+
def package(output_path)
|
|
28
|
+
entry = ThemeRegistry.find(@theme_name)
|
|
29
|
+
klass = entry[:klass]
|
|
30
|
+
|
|
31
|
+
raise ArgumentError, "Output directory does not exist: #{output_path}" unless Dir.exist?(output_path)
|
|
32
|
+
|
|
33
|
+
files = collect_files(klass)
|
|
34
|
+
manifest = build_manifest(klass, files)
|
|
35
|
+
|
|
36
|
+
zip_filename = "#{snake_name(klass)}-#{klass.plugin_version || '0.0.0'}.zip"
|
|
37
|
+
zip_path = File.join(output_path, zip_filename)
|
|
38
|
+
|
|
39
|
+
root = engine_root
|
|
40
|
+
|
|
41
|
+
Zip::File.open(zip_path, create: true) do |zip|
|
|
42
|
+
zip.get_output_stream("manifest.json") { |io| io.write(JSON.pretty_generate(manifest)) }
|
|
43
|
+
|
|
44
|
+
files.each do |relative_path|
|
|
45
|
+
absolute = File.join(root, relative_path)
|
|
46
|
+
zip.add(relative_path, absolute) if File.exist?(absolute)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
zip_path
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Collects all files that belong to the theme.
|
|
56
|
+
#
|
|
57
|
+
# @param klass [Class] the theme class
|
|
58
|
+
# @return [Array<String>] relative file paths from engine root
|
|
59
|
+
def collect_files(klass)
|
|
60
|
+
files = []
|
|
61
|
+
root = engine_root
|
|
62
|
+
|
|
63
|
+
# Theme class file
|
|
64
|
+
theme_file = "app/themes/not_pressed/#{snake_name(klass)}_theme.rb"
|
|
65
|
+
files << theme_file if File.exist?(File.join(root, theme_file))
|
|
66
|
+
|
|
67
|
+
# Layout templates — theme_templates_path may be absolute.
|
|
68
|
+
# When the resolved path doesn't exist (e.g., expand_path resolved
|
|
69
|
+
# relative to the source file), fall back to app/views/themes/<name>.
|
|
70
|
+
if klass.theme_templates_path
|
|
71
|
+
templates_dir = resolve_templates_dir(klass.theme_templates_path, root, snake_name(klass))
|
|
72
|
+
if templates_dir
|
|
73
|
+
pattern = File.join(templates_dir, "**", "*.html.erb")
|
|
74
|
+
Dir.glob(pattern).each do |path|
|
|
75
|
+
relative = absolute_to_relative(path, root)
|
|
76
|
+
files << relative
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Stylesheet
|
|
82
|
+
if klass.theme_stylesheet
|
|
83
|
+
stylesheet_path = resolve_stylesheet_path(klass.theme_stylesheet, root)
|
|
84
|
+
if stylesheet_path
|
|
85
|
+
relative = absolute_to_relative(stylesheet_path, root)
|
|
86
|
+
files << relative
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
files
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Builds the manifest hash from theme DSL metadata.
|
|
94
|
+
#
|
|
95
|
+
# @param klass [Class] the theme class
|
|
96
|
+
# @param files [Array<String>] list of files included in the archive
|
|
97
|
+
# @return [Hash] manifest data
|
|
98
|
+
def build_manifest(klass, files)
|
|
99
|
+
{
|
|
100
|
+
name: klass.plugin_name,
|
|
101
|
+
version: klass.plugin_version,
|
|
102
|
+
description: klass.plugin_description,
|
|
103
|
+
author: klass.plugin_author,
|
|
104
|
+
layouts: (klass.theme_layouts || []).map { |l| stringify_keys(l) },
|
|
105
|
+
color_scheme: (klass.theme_color_scheme || []).map { |c| stringify_keys(c) },
|
|
106
|
+
stylesheet: klass.theme_stylesheet,
|
|
107
|
+
files: files
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Converts a theme class to its snake_case file name stem.
|
|
112
|
+
#
|
|
113
|
+
# @param klass [Class] the theme class
|
|
114
|
+
# @return [String] snake_case name
|
|
115
|
+
def snake_name(klass)
|
|
116
|
+
Module.instance_method(:name).bind_call(klass).split("::").last
|
|
117
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
118
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
119
|
+
.downcase
|
|
120
|
+
.sub(/_theme$/, "")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns the engine root directory.
|
|
124
|
+
#
|
|
125
|
+
# @return [String]
|
|
126
|
+
def engine_root
|
|
127
|
+
NotPressed::Engine.root.to_s
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Converts an absolute path to a relative path from engine root.
|
|
131
|
+
# Handles both forward and back slashes for cross-platform compatibility.
|
|
132
|
+
#
|
|
133
|
+
# @param path [String] absolute file path
|
|
134
|
+
# @param root [String] engine root directory
|
|
135
|
+
# @return [String] relative path with forward slashes
|
|
136
|
+
def absolute_to_relative(path, root)
|
|
137
|
+
normalized_path = path.tr("\\", "/")
|
|
138
|
+
normalized_root = root.tr("\\", "/")
|
|
139
|
+
normalized_path.sub("#{normalized_root}/", "")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Resolves the templates directory, falling back to the conventional
|
|
143
|
+
# app/views/themes/<name> path when the DSL-declared path doesn't exist.
|
|
144
|
+
#
|
|
145
|
+
# @param declared_path [String] the templates_path from the DSL
|
|
146
|
+
# @param root [String] engine root directory
|
|
147
|
+
# @param name [String] snake_case theme name
|
|
148
|
+
# @return [String, nil] absolute path to the templates directory
|
|
149
|
+
def resolve_templates_dir(declared_path, root, name)
|
|
150
|
+
return declared_path if Dir.exist?(declared_path)
|
|
151
|
+
|
|
152
|
+
# Fall back to conventional location under app/views/themes/<name>
|
|
153
|
+
conventional = File.join(root, "app/views/themes", name)
|
|
154
|
+
return conventional if Dir.exist?(conventional)
|
|
155
|
+
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Resolves the stylesheet DSL value to an absolute file path.
|
|
160
|
+
#
|
|
161
|
+
# @param stylesheet [String] stylesheet path from the DSL (e.g., "not_pressed/themes/starter.css")
|
|
162
|
+
# @param root [String] engine root directory
|
|
163
|
+
# @return [String, nil] absolute path to the stylesheet file, or nil if not found
|
|
164
|
+
def resolve_stylesheet_path(stylesheet, root)
|
|
165
|
+
candidate = File.join(root, "app/assets/stylesheets", stylesheet)
|
|
166
|
+
return candidate if File.exist?(candidate)
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Deep-converts symbol keys to strings for JSON serialization.
|
|
171
|
+
#
|
|
172
|
+
# @param hash [Hash] input hash
|
|
173
|
+
# @return [Hash] hash with string keys
|
|
174
|
+
def stringify_keys(hash)
|
|
175
|
+
hash.each_with_object({}) do |(k, v), h|
|
|
176
|
+
h[k.to_s] = v.is_a?(Hash) ? stringify_keys(v) : v
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotPressed
|
|
4
|
+
# Thread-safe registry for themes. Only one theme can be active at a time.
|
|
5
|
+
#
|
|
6
|
+
# Manages theme registration, activation, and deactivation. When a new theme
|
|
7
|
+
# is activated, the previously active theme is automatically deactivated.
|
|
8
|
+
module ThemeRegistry
|
|
9
|
+
# Raised when a theme name is not found in the registry.
|
|
10
|
+
class NotFound < StandardError; end
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Registers a theme class in the registry.
|
|
14
|
+
#
|
|
15
|
+
# @param theme_class [Class] a subclass of {NotPressed::Theme} with a declared name
|
|
16
|
+
# @raise [ArgumentError] if the class is not a valid Theme subclass or has no name
|
|
17
|
+
# @return [void]
|
|
18
|
+
def register(theme_class)
|
|
19
|
+
unless theme_class.is_a?(Class) && theme_class < NotPressed::Theme
|
|
20
|
+
raise ArgumentError, "#{theme_class} must be a subclass of NotPressed::Theme"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
name = theme_class.plugin_name
|
|
24
|
+
raise ArgumentError, "Theme must declare a name using the `name` DSL" unless name
|
|
25
|
+
|
|
26
|
+
mutex.synchronize do
|
|
27
|
+
registry[name] = { klass: theme_class, status: :registered }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the currently active theme class.
|
|
32
|
+
#
|
|
33
|
+
# @return [Class, nil] the active theme class, or nil if no theme is active
|
|
34
|
+
def active
|
|
35
|
+
mutex.synchronize { @active_theme }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the currently active theme instance.
|
|
39
|
+
#
|
|
40
|
+
# @return [NotPressed::Theme, nil] the active theme instance
|
|
41
|
+
def active_instance
|
|
42
|
+
mutex.synchronize { @active_instance }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Deactivates any current theme and activates the named theme.
|
|
46
|
+
#
|
|
47
|
+
# @param name [String] the theme name to activate
|
|
48
|
+
# @raise [NotFound] if the theme is not registered
|
|
49
|
+
# @return [void]
|
|
50
|
+
def activate(name)
|
|
51
|
+
info = find(name)
|
|
52
|
+
|
|
53
|
+
mutex.synchronize do
|
|
54
|
+
if @active_instance
|
|
55
|
+
@active_instance.deactivate!
|
|
56
|
+
active_name = @active_theme.plugin_name
|
|
57
|
+
registry[active_name][:status] = :inactive if registry.key?(active_name)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
instance = info[:klass].new
|
|
61
|
+
instance.activate!
|
|
62
|
+
@active_theme = info[:klass]
|
|
63
|
+
@active_instance = instance
|
|
64
|
+
registry[name][:status] = :active
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Deactivates the current theme without activating a replacement.
|
|
69
|
+
#
|
|
70
|
+
# @return [void]
|
|
71
|
+
def deactivate
|
|
72
|
+
mutex.synchronize do
|
|
73
|
+
if @active_instance
|
|
74
|
+
@active_instance.deactivate!
|
|
75
|
+
active_name = @active_theme.plugin_name
|
|
76
|
+
registry[active_name][:status] = :inactive if registry.key?(active_name)
|
|
77
|
+
end
|
|
78
|
+
@active_theme = nil
|
|
79
|
+
@active_instance = nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the names of all registered themes.
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<String>]
|
|
86
|
+
def available
|
|
87
|
+
mutex.synchronize { registry.keys }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Looks up a theme by name.
|
|
91
|
+
#
|
|
92
|
+
# @param name [String] the theme name
|
|
93
|
+
# @return [Hash] entry with :klass and :status keys
|
|
94
|
+
# @raise [NotFound] if the theme is not registered
|
|
95
|
+
def find(name)
|
|
96
|
+
mutex.synchronize do
|
|
97
|
+
registry[name] || raise(NotFound, "Theme '#{name}' not found")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Clears the registry and deactivates any active theme.
|
|
102
|
+
#
|
|
103
|
+
# @return [void]
|
|
104
|
+
def reset!
|
|
105
|
+
mutex.synchronize do
|
|
106
|
+
@registry = {}
|
|
107
|
+
@active_theme = nil
|
|
108
|
+
@active_instance = nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def mutex
|
|
115
|
+
@mutex ||= Mutex.new
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def registry
|
|
119
|
+
@registry ||= {}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/not_pressed.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "not_pressed/version"
|
|
4
|
+
require "not_pressed/configuration"
|
|
5
|
+
require "not_pressed/content_type"
|
|
6
|
+
require "not_pressed/content_type_registry"
|
|
7
|
+
require "not_pressed/content_type_builder"
|
|
8
|
+
require "not_pressed/navigation"
|
|
9
|
+
require "not_pressed/navigation/route_inspector"
|
|
10
|
+
require "not_pressed/navigation/partial_parser"
|
|
11
|
+
require "not_pressed/navigation/menu_item"
|
|
12
|
+
require "not_pressed/navigation/menu"
|
|
13
|
+
require "not_pressed/navigation/node"
|
|
14
|
+
require "not_pressed/navigation/builder"
|
|
15
|
+
require "not_pressed/hooks"
|
|
16
|
+
require "not_pressed/plugin"
|
|
17
|
+
require "not_pressed/plugin_manager"
|
|
18
|
+
require "not_pressed/plugin_packager"
|
|
19
|
+
require "not_pressed/plugin_importer"
|
|
20
|
+
require "not_pressed/theme"
|
|
21
|
+
require "not_pressed/theme_registry"
|
|
22
|
+
require "not_pressed/theme_packager"
|
|
23
|
+
require "not_pressed/theme_importer"
|
|
24
|
+
require "not_pressed/rendering"
|
|
25
|
+
require "not_pressed/rendering/block_renderer"
|
|
26
|
+
require "not_pressed/seo/sitemap_builder"
|
|
27
|
+
require "not_pressed/admin/authentication"
|
|
28
|
+
require "not_pressed/admin/menu_registry"
|
|
29
|
+
require "not_pressed/engine"
|
|
30
|
+
|
|
31
|
+
module NotPressed
|
|
32
|
+
class << self
|
|
33
|
+
def configuration
|
|
34
|
+
@configuration ||= Configuration.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def configure
|
|
38
|
+
yield(configuration)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reset_configuration!
|
|
42
|
+
@configuration = Configuration.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def navigation(menu_name = "main")
|
|
46
|
+
@navigation_builder ||= Navigation::Builder.new
|
|
47
|
+
@navigation_builder.cached_build(menu_name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def invalidate_navigation!
|
|
51
|
+
@navigation_builder&.invalidate_cache!
|
|
52
|
+
@navigation_builder = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def plugins
|
|
56
|
+
PluginManager.registered
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def define_content_type(name, &block)
|
|
60
|
+
builder = ContentTypeBuilder.new(name)
|
|
61
|
+
builder.instance_eval(&block)
|
|
62
|
+
builder.register!
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|