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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotPressed
4
+ VERSION = "0.1.0"
5
+ end
@@ -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