plum-cms 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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +252 -0
  5. data/app/assets/builds/tailwind.css +2 -0
  6. data/app/assets/images/plum-mark.svg +7 -0
  7. data/app/assets/images/table-needs-logo.svg +83 -0
  8. data/app/assets/javascripts/plum/application.js +15 -0
  9. data/app/assets/stylesheets/application.css +10 -0
  10. data/app/assets/stylesheets/plum/control_panel.css +2 -0
  11. data/app/assets/tailwind/application.css +47 -0
  12. data/app/controllers/plum/application_controller.rb +34 -0
  13. data/app/controllers/plum/cp/assets_controller.rb +84 -0
  14. data/app/controllers/plum/cp/base_controller.rb +45 -0
  15. data/app/controllers/plum/cp/content_types_controller.rb +59 -0
  16. data/app/controllers/plum/cp/dashboard_controller.rb +10 -0
  17. data/app/controllers/plum/cp/entries_controller.rb +240 -0
  18. data/app/controllers/plum/cp/form_definitions_controller.rb +105 -0
  19. data/app/controllers/plum/cp/form_submissions_controller.rb +26 -0
  20. data/app/controllers/plum/cp/globals_controller.rb +84 -0
  21. data/app/controllers/plum/cp/nav_items_controller.rb +68 -0
  22. data/app/controllers/plum/cp/nav_menus_controller.rb +56 -0
  23. data/app/controllers/plum/cp/site_settings_controller.rb +87 -0
  24. data/app/controllers/plum/cp/taxonomies_controller.rb +56 -0
  25. data/app/controllers/plum/cp/terms_controller.rb +55 -0
  26. data/app/controllers/plum/cp/theme_previews_controller.rb +27 -0
  27. data/app/controllers/plum/cp/themes_controller.rb +98 -0
  28. data/app/controllers/plum/form_submissions_controller.rb +44 -0
  29. data/app/controllers/plum/pages_controller.rb +161 -0
  30. data/app/controllers/plum/sessions_controller.rb +25 -0
  31. data/app/controllers/plum/theme_assets_controller.rb +14 -0
  32. data/app/helpers/application_helper.rb +2 -0
  33. data/app/helpers/markdown_helper.rb +21 -0
  34. data/app/helpers/plum/cp/assets_helper.rb +21 -0
  35. data/app/helpers/plum/cp/theme_settings_helper.rb +26 -0
  36. data/app/javascript/application.js +18 -0
  37. data/app/javascript/controllers/application.js +9 -0
  38. data/app/javascript/controllers/index.js +4 -0
  39. data/app/javascript/controllers/plum/blocks_editor_controller.js +275 -0
  40. data/app/javascript/controllers/plum/blueprint_controller.js +120 -0
  41. data/app/javascript/controllers/plum/form_fields_controller.js +84 -0
  42. data/app/javascript/controllers/plum/image_picker_controller.js +143 -0
  43. data/app/javascript/controllers/plum/theme_settings_controller.js +17 -0
  44. data/app/models/plum/application_record.rb +5 -0
  45. data/app/models/plum/asset.rb +69 -0
  46. data/app/models/plum/content_type.rb +35 -0
  47. data/app/models/plum/entry.rb +67 -0
  48. data/app/models/plum/entry_term.rb +8 -0
  49. data/app/models/plum/form_definition.rb +68 -0
  50. data/app/models/plum/form_submission.rb +76 -0
  51. data/app/models/plum/global.rb +24 -0
  52. data/app/models/plum/nav_item.rb +58 -0
  53. data/app/models/plum/nav_menu.rb +24 -0
  54. data/app/models/plum/site.rb +112 -0
  55. data/app/models/plum/site_scoped.rb +17 -0
  56. data/app/models/plum/site_setting.rb +35 -0
  57. data/app/models/plum/taxonomy.rb +24 -0
  58. data/app/models/plum/term.rb +23 -0
  59. data/app/models/plum/user.rb +16 -0
  60. data/app/services/plum/base_blocks.rb +161 -0
  61. data/app/services/plum/block_editor_config.rb +65 -0
  62. data/app/services/plum/block_library.rb +31 -0
  63. data/app/services/plum/builder_renderer.rb +63 -0
  64. data/app/services/plum/field_expander.rb +78 -0
  65. data/app/services/plum/form_renderer.rb +110 -0
  66. data/app/services/plum/liquid_context.rb +335 -0
  67. data/app/services/plum/liquid_filters.rb +58 -0
  68. data/app/services/plum/liquid_renderer.rb +49 -0
  69. data/app/services/plum/theme.rb +160 -0
  70. data/app/services/plum/theme_asset_path.rb +31 -0
  71. data/app/services/plum/theme_package_installer.rb +287 -0
  72. data/app/services/plum/theme_registry.rb +63 -0
  73. data/app/services/plum/theme_resolver.rb +55 -0
  74. data/app/services/plum/theme_settings_params.rb +54 -0
  75. data/app/themes/bagel-boy/assets/screenshot.svg +92 -0
  76. data/app/themes/bagel-boy/assets/theme.css +184 -0
  77. data/app/themes/bagel-boy/blocks/hours.liquid +4 -0
  78. data/app/themes/bagel-boy/blocks/menu_item.liquid +7 -0
  79. data/app/themes/bagel-boy/layouts/base.liquid +64 -0
  80. data/app/themes/bagel-boy/templates/collections/posts.liquid +32 -0
  81. data/app/themes/bagel-boy/templates/entries/_default.liquid +13 -0
  82. data/app/themes/bagel-boy/templates/entries/landing.liquid +4 -0
  83. data/app/themes/bagel-boy/templates/entries/pages.liquid +18 -0
  84. data/app/themes/bagel-boy/templates/entries/posts.liquid +8 -0
  85. data/app/themes/bagel-boy/templates/index.liquid +10 -0
  86. data/app/themes/bagel-boy/templates/search.liquid +28 -0
  87. data/app/themes/bagel-boy/theme.yml +47 -0
  88. data/app/themes/bagel-shop/assets/screenshot.svg +28 -0
  89. data/app/themes/bagel-shop/assets/theme.css +7 -0
  90. data/app/themes/bagel-shop/layouts/base.liquid +507 -0
  91. data/app/themes/bagel-shop/templates/entries/_default.liquid +15 -0
  92. data/app/themes/bagel-shop/templates/entries/pages.liquid +12 -0
  93. data/app/themes/bagel-shop/templates/entries/posts.liquid +19 -0
  94. data/app/themes/bagel-shop/templates/index.liquid +55 -0
  95. data/app/themes/bagel-shop/theme.yml +30 -0
  96. data/app/themes/default/assets/screenshot.svg +24 -0
  97. data/app/themes/default/assets/theme.css +89 -0
  98. data/app/themes/default/layouts/base.liquid +117 -0
  99. data/app/themes/default/templates/collections/_default.liquid +16 -0
  100. data/app/themes/default/templates/collections/posts.liquid +27 -0
  101. data/app/themes/default/templates/entries/_default.liquid +26 -0
  102. data/app/themes/default/templates/entries/landing.liquid +5 -0
  103. data/app/themes/default/templates/entries/pages.liquid +17 -0
  104. data/app/themes/default/templates/entries/posts.liquid +20 -0
  105. data/app/themes/default/templates/index.liquid +22 -0
  106. data/app/themes/default/templates/search.liquid +28 -0
  107. data/app/themes/default/templates/taxonomies/_default.liquid +25 -0
  108. data/app/themes/default/templates/taxonomies/_default_index.liquid +14 -0
  109. data/app/themes/default/theme.yml +23 -0
  110. data/app/views/layouts/plum/cp.html.erb +138 -0
  111. data/app/views/layouts/plum/session.html.erb +43 -0
  112. data/app/views/plum/cp/assets/_form.html.erb +44 -0
  113. data/app/views/plum/cp/assets/edit.html.erb +14 -0
  114. data/app/views/plum/cp/assets/index.html.erb +52 -0
  115. data/app/views/plum/cp/assets/new.html.erb +9 -0
  116. data/app/views/plum/cp/content_types/_form.html.erb +113 -0
  117. data/app/views/plum/cp/content_types/edit.html.erb +8 -0
  118. data/app/views/plum/cp/content_types/index.html.erb +48 -0
  119. data/app/views/plum/cp/content_types/new.html.erb +5 -0
  120. data/app/views/plum/cp/content_types/show.html.erb +59 -0
  121. data/app/views/plum/cp/dashboard/show.html.erb +50 -0
  122. data/app/views/plum/cp/entries/_form.html.erb +196 -0
  123. data/app/views/plum/cp/entries/edit.html.erb +9 -0
  124. data/app/views/plum/cp/entries/index.html.erb +51 -0
  125. data/app/views/plum/cp/entries/new.html.erb +5 -0
  126. data/app/views/plum/cp/form_definitions/_form.html.erb +90 -0
  127. data/app/views/plum/cp/form_definitions/edit.html.erb +11 -0
  128. data/app/views/plum/cp/form_definitions/index.html.erb +43 -0
  129. data/app/views/plum/cp/form_definitions/new.html.erb +5 -0
  130. data/app/views/plum/cp/form_definitions/show.html.erb +70 -0
  131. data/app/views/plum/cp/form_submissions/show.html.erb +20 -0
  132. data/app/views/plum/cp/globals/_form.html.erb +44 -0
  133. data/app/views/plum/cp/globals/edit.html.erb +11 -0
  134. data/app/views/plum/cp/globals/index.html.erb +41 -0
  135. data/app/views/plum/cp/globals/new.html.erb +5 -0
  136. data/app/views/plum/cp/globals/show.html.erb +14 -0
  137. data/app/views/plum/cp/nav_items/_form.html.erb +56 -0
  138. data/app/views/plum/cp/nav_items/edit.html.erb +11 -0
  139. data/app/views/plum/cp/nav_items/new.html.erb +6 -0
  140. data/app/views/plum/cp/nav_menus/_form.html.erb +33 -0
  141. data/app/views/plum/cp/nav_menus/_nav_item.html.erb +24 -0
  142. data/app/views/plum/cp/nav_menus/edit.html.erb +11 -0
  143. data/app/views/plum/cp/nav_menus/index.html.erb +41 -0
  144. data/app/views/plum/cp/nav_menus/new.html.erb +5 -0
  145. data/app/views/plum/cp/nav_menus/show.html.erb +24 -0
  146. data/app/views/plum/cp/shared/_icon.html.erb +39 -0
  147. data/app/views/plum/cp/shared/_image_picker.html.erb +38 -0
  148. data/app/views/plum/cp/site_settings/edit.html.erb +145 -0
  149. data/app/views/plum/cp/site_settings/show.html.erb +62 -0
  150. data/app/views/plum/cp/taxonomies/_form.html.erb +38 -0
  151. data/app/views/plum/cp/taxonomies/edit.html.erb +2 -0
  152. data/app/views/plum/cp/taxonomies/index.html.erb +38 -0
  153. data/app/views/plum/cp/taxonomies/new.html.erb +2 -0
  154. data/app/views/plum/cp/taxonomies/show.html.erb +46 -0
  155. data/app/views/plum/cp/terms/_form.html.erb +30 -0
  156. data/app/views/plum/cp/terms/edit.html.erb +2 -0
  157. data/app/views/plum/cp/terms/new.html.erb +2 -0
  158. data/app/views/plum/cp/themes/index.html.erb +160 -0
  159. data/app/views/plum/form_mailer/submission_notification.html.erb +13 -0
  160. data/app/views/plum/form_mailer/submission_notification.text.erb +7 -0
  161. data/app/views/plum/sessions/new.html.erb +20 -0
  162. data/compat/plum-cms.rb +1 -0
  163. data/config/locales/en.yml +31 -0
  164. data/config/plum_importmap.rb +4 -0
  165. data/config/plum_routes.rb +34 -0
  166. data/db/engine_migrate/20260101000001_create_plum_tables.rb +154 -0
  167. data/lib/generators/plum/install/install_generator.rb +54 -0
  168. data/lib/generators/plum/install/templates/plum_initializer.rb +37 -0
  169. data/lib/plum/configuration.rb +88 -0
  170. data/lib/plum/content_source.rb +45 -0
  171. data/lib/plum/content_source_context.rb +30 -0
  172. data/lib/plum/content_source_registry.rb +120 -0
  173. data/lib/plum/engine.rb +30 -0
  174. data/lib/plum/liquid_tags/form_tag.rb +17 -0
  175. data/lib/plum/version.rb +7 -0
  176. data/lib/plum.rb +11 -0
  177. data/vendor/javascript/lexxy.js +11859 -0
  178. metadata +405 -0
@@ -0,0 +1,160 @@
1
+ module Plum
2
+ class Theme
3
+ SUPPORTED_SETTING_TYPES = %w[text textarea color boolean select].freeze
4
+
5
+ attr_reader :handle, :root, :manifest
6
+
7
+ def initialize(root:, manifest: {})
8
+ @root = Pathname(root)
9
+ @manifest = manifest.to_h.deep_stringify_keys
10
+ @handle = @manifest["handle"].presence || @root.basename.to_s
11
+ end
12
+
13
+ def name
14
+ manifest["name"].presence || handle.titleize
15
+ end
16
+
17
+ def version
18
+ manifest["version"].presence
19
+ end
20
+
21
+ def author
22
+ manifest["author"].presence
23
+ end
24
+
25
+ def description
26
+ manifest["description"].presence
27
+ end
28
+
29
+ def category
30
+ manifest["category"].presence
31
+ end
32
+
33
+ def screenshot_path
34
+ path = normalize_asset_reference(manifest["screenshot"])
35
+ return if path.blank?
36
+
37
+ asset_path(path)&.file? ? path : nil
38
+ end
39
+
40
+ def parent_handle
41
+ manifest["extends"].presence || manifest["parent"].presence
42
+ end
43
+
44
+ def settings_fields
45
+ Array(manifest.dig("settings", "fields")).filter_map do |field|
46
+ normalize_setting_field(field)
47
+ end
48
+ end
49
+
50
+ def blocks
51
+ Array(manifest["blocks"]).filter_map do |block|
52
+ normalize_block_definition(block)
53
+ end
54
+ end
55
+
56
+ def block_definition(handle)
57
+ handle = handle.to_s
58
+ return if handle.blank?
59
+
60
+ blocks.find { |block| block["handle"] == handle }
61
+ end
62
+
63
+ def block_template(handle, variant: :web)
64
+ path = block_template_path(handle, variant: variant)
65
+ return unless path&.file?
66
+
67
+ path.read
68
+ end
69
+
70
+ def block_template_path(handle, variant: :web)
71
+ return unless variant.to_sym == :web
72
+
73
+ normalized_handle = handle.to_s
74
+ return if normalized_handle.blank? || !normalized_handle.match?(/\A[a-z][a-z0-9_]*\z/)
75
+
76
+ root.join("blocks", "#{normalized_handle}.liquid")
77
+ end
78
+
79
+ def asset_root
80
+ root.join("assets")
81
+ end
82
+
83
+ def asset_path(asset_name)
84
+ normalized_name = ThemeAssetPath.normalize(asset_name)
85
+ candidate = asset_root.join(normalized_name).expand_path
86
+ expanded_asset_root = asset_root.expand_path
87
+
88
+ return unless candidate.to_s.start_with?("#{expanded_asset_root}/")
89
+
90
+ candidate
91
+ rescue ThemeAssetPath::UnsafePathError
92
+ nil
93
+ end
94
+
95
+ def template_path(template_name)
96
+ root.join("templates", "#{template_name}.liquid")
97
+ end
98
+
99
+ def layout_path(layout_name = "base")
100
+ root.join("layouts", "#{layout_name}.liquid")
101
+ end
102
+
103
+ private
104
+
105
+ def normalize_asset_reference(asset_name)
106
+ raw_name = asset_name.to_s
107
+ return if raw_name.blank?
108
+
109
+ raw_name = raw_name.delete_prefix("assets/")
110
+ ThemeAssetPath.normalize(raw_name)
111
+ rescue ThemeAssetPath::UnsafePathError
112
+ nil
113
+ end
114
+
115
+ def normalize_setting_field(field)
116
+ return unless field.respond_to?(:to_h)
117
+
118
+ normalized = field.to_h.deep_stringify_keys
119
+ handle = normalized["handle"].to_s
120
+ return if handle.blank?
121
+
122
+ type = normalized["type"].presence || "text"
123
+ type = "text" unless SUPPORTED_SETTING_TYPES.include?(type)
124
+
125
+ normalized.merge(
126
+ "handle" => handle,
127
+ "type" => type,
128
+ "label" => normalized["label"].presence || handle.humanize
129
+ )
130
+ end
131
+
132
+ def normalize_block_definition(block)
133
+ return unless block.respond_to?(:to_h)
134
+
135
+ normalized = block.to_h.deep_stringify_keys
136
+ handle = normalized["handle"].to_s
137
+ return if handle.blank?
138
+
139
+ normalized.merge(
140
+ "handle" => handle,
141
+ "label" => normalized["label"].presence || handle.humanize,
142
+ "fields" => Array(normalized["fields"]).filter_map { |field| normalize_block_field(field) }
143
+ )
144
+ end
145
+
146
+ def normalize_block_field(field)
147
+ return unless field.respond_to?(:to_h)
148
+
149
+ normalized = field.to_h.deep_stringify_keys
150
+ handle = normalized["handle"].to_s
151
+ return if handle.blank?
152
+
153
+ normalized.merge(
154
+ "handle" => handle,
155
+ "type" => normalized["type"].presence || "text",
156
+ "label" => normalized["label"].presence || handle.humanize
157
+ )
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,31 @@
1
+ require "erb"
2
+
3
+ module Plum
4
+ class ThemeAssetPath
5
+ class UnsafePathError < StandardError; end
6
+
7
+ class << self
8
+ def normalize(path)
9
+ raw_path = path.to_s
10
+ raise UnsafePathError, "Theme asset path cannot be blank" if raw_path.blank?
11
+ raise UnsafePathError, "Theme asset path cannot contain null bytes" if raw_path.include?("\0")
12
+
13
+ parts = raw_path.split("/").reject(&:blank?)
14
+ if Pathname(raw_path).absolute? || parts.any? { |part| part == "." || part == ".." }
15
+ raise UnsafePathError, "Theme asset path must stay inside the theme assets directory"
16
+ end
17
+ raise UnsafePathError, "Theme asset path cannot be blank" if parts.blank?
18
+
19
+ parts.join("/")
20
+ end
21
+
22
+ def url(base_url:, path:)
23
+ encoded_path = normalize(path).split("/").map { |part| ERB::Util.url_encode(part) }.join("/")
24
+
25
+ "#{base_url.to_s.chomp("/")}/#{encoded_path}"
26
+ rescue UnsafePathError
27
+ ""
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,287 @@
1
+ require "fileutils"
2
+ require "tmpdir"
3
+ require "yaml"
4
+ require "zip"
5
+
6
+ module Plum
7
+ class ThemePackageInstaller
8
+ HANDLE_PATTERN = /\A[a-z0-9][a-z0-9-]*\z/
9
+ SETTING_HANDLE_PATTERN = /\A[a-z][a-z0-9_]*\z/
10
+
11
+ PackageEntry = Struct.new(:zip_entry, :path, :directory?, keyword_init: true)
12
+
13
+ attr_reader :errors, :theme
14
+
15
+ def initialize(package, install_root: nil)
16
+ @package = package
17
+ @install_root = Pathname(install_root || default_install_root)
18
+ @errors = []
19
+ @theme = nil
20
+ end
21
+
22
+ def install
23
+ errors.clear
24
+
25
+ with_zip_entries do |entries|
26
+ manifest = load_manifest(entries)
27
+ next false if errors.any?
28
+
29
+ handle = validate_manifest(manifest)
30
+ validate_renderable_files(entries)
31
+ validate_screenshot(manifest, entries)
32
+ validate_settings(manifest)
33
+ next false if errors.any?
34
+
35
+ target_root = validate_target(handle)
36
+ next false if errors.any?
37
+
38
+ extract_entries(entries, target_root)
39
+ @theme = Theme.new(root: target_root, manifest: manifest)
40
+ true
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :package, :install_root
47
+
48
+ def default_install_root
49
+ Plum.configuration.theme_paths.first || Rails.root.join("app/themes")
50
+ end
51
+
52
+ def with_zip_entries
53
+ Zip::File.open(package_path) do |zip|
54
+ entries = normalize_entries(zip)
55
+ yield entries
56
+ end
57
+ rescue Zip::Error
58
+ errors << "Theme package must be a valid zip file"
59
+ false
60
+ rescue Errno::ENOENT
61
+ errors << "Theme package could not be found"
62
+ false
63
+ end
64
+
65
+ def package_path
66
+ if package.respond_to?(:path)
67
+ package.path
68
+ elsif package.respond_to?(:tempfile) && package.tempfile.respond_to?(:path)
69
+ package.tempfile.path
70
+ else
71
+ package.to_s
72
+ end
73
+ end
74
+
75
+ def normalize_entries(zip)
76
+ raw_entries = zip.reject { |entry| ignored_entry?(entry.name) }
77
+ package_root = detect_package_root(raw_entries.reject(&:directory?))
78
+
79
+ return [] if errors.any?
80
+
81
+ raw_entries.filter_map do |entry|
82
+ relative_path = entry.name.delete_prefix(package_root)
83
+ next if relative_path.blank?
84
+
85
+ PackageEntry.new(
86
+ zip_entry: entry,
87
+ path: normalize_entry_path(relative_path),
88
+ directory?: entry.directory?
89
+ )
90
+ rescue ThemeAssetPath::UnsafePathError => e
91
+ errors << e.message
92
+ nil
93
+ end
94
+ end
95
+
96
+ def detect_package_root(entries)
97
+ names = entries.map { |entry| normalize_entry_name(entry.name) }.compact
98
+
99
+ return "" if names.include?("theme.yml")
100
+
101
+ roots = names.filter_map { |name| name.split("/", 2).first if name.include?("/") }.uniq
102
+ root = roots.one? ? "#{roots.first}/" : nil
103
+
104
+ if root && names.all? { |name| name.start_with?(root) } && names.include?("#{root}theme.yml")
105
+ root
106
+ else
107
+ errors << "Theme package must contain theme.yml at the root"
108
+ ""
109
+ end
110
+ end
111
+
112
+ def normalize_entry_name(name)
113
+ normalize_entry_path(name)
114
+ rescue ThemeAssetPath::UnsafePathError => e
115
+ errors << e.message
116
+ nil
117
+ end
118
+
119
+ def normalize_entry_path(path)
120
+ raw_path = path.to_s
121
+ raise ThemeAssetPath::UnsafePathError, "Theme package paths cannot contain backslashes" if raw_path.include?("\\")
122
+
123
+ ThemeAssetPath.normalize(raw_path)
124
+ end
125
+
126
+ def ignored_entry?(name)
127
+ normalized_name = name.to_s
128
+
129
+ normalized_name.start_with?("__MACOSX/") || File.basename(normalized_name) == ".DS_Store"
130
+ end
131
+
132
+ def load_manifest(entries)
133
+ manifest_entry = entries.find { |entry| entry.path == "theme.yml" && !entry.directory? }
134
+
135
+ unless manifest_entry
136
+ errors << "Theme package must contain theme.yml at the root"
137
+ return {}
138
+ end
139
+
140
+ manifest = YAML.safe_load(manifest_entry.zip_entry.get_input_stream.read, aliases: true) || {}
141
+ return manifest.deep_stringify_keys if manifest.is_a?(Hash)
142
+
143
+ errors << "theme.yml must contain a mapping"
144
+ {}
145
+ rescue Psych::SyntaxError => e
146
+ errors << "theme.yml is invalid: #{e.message}"
147
+ {}
148
+ end
149
+
150
+ def validate_manifest(manifest)
151
+ handle = manifest["handle"].to_s
152
+ name = manifest["name"].to_s
153
+
154
+ errors << "theme.yml must define a name" if name.blank?
155
+ if handle.blank?
156
+ errors << "theme.yml must define a handle"
157
+ elsif !handle.match?(HANDLE_PATTERN)
158
+ errors << "theme.yml handle must use lowercase letters, numbers, and hyphens"
159
+ end
160
+
161
+ handle
162
+ end
163
+
164
+ def validate_screenshot(manifest, entries)
165
+ screenshot = manifest_asset_path(manifest["screenshot"])
166
+ return if screenshot.blank?
167
+
168
+ expected_entry_path = "assets/#{screenshot}"
169
+ has_screenshot = entries.any? do |entry|
170
+ !entry.directory? && entry.path == expected_entry_path
171
+ end
172
+
173
+ errors << "theme.yml screenshot must point to a file inside assets" unless has_screenshot
174
+ end
175
+
176
+ def validate_settings(manifest)
177
+ fields = manifest.dig("settings", "fields")
178
+ return if fields.blank?
179
+
180
+ unless fields.is_a?(Array)
181
+ errors << "theme.yml settings.fields must be an array"
182
+ return
183
+ end
184
+
185
+ fields.each_with_index do |field, index|
186
+ validate_setting_field(field, index + 1)
187
+ end
188
+ end
189
+
190
+ def validate_setting_field(field, position)
191
+ unless field.is_a?(Hash)
192
+ errors << "theme.yml settings field #{position} must be a mapping"
193
+ return
194
+ end
195
+
196
+ field = field.deep_stringify_keys
197
+ handle = field["handle"].to_s
198
+ type = field["type"].presence || "text"
199
+
200
+ if handle.blank?
201
+ errors << "theme.yml settings field #{position} must define a handle"
202
+ elsif !handle.match?(SETTING_HANDLE_PATTERN)
203
+ errors << "theme.yml settings field #{handle} must use lowercase letters, numbers, and underscores"
204
+ end
205
+
206
+ unless Theme::SUPPORTED_SETTING_TYPES.include?(type)
207
+ errors << "theme.yml settings field #{handle.presence || position} has unsupported type #{type}"
208
+ return
209
+ end
210
+
211
+ validate_select_setting(field, handle.presence || position) if type == "select"
212
+ end
213
+
214
+ def validate_select_setting(field, identifier)
215
+ values = select_option_values(field["options"])
216
+
217
+ if values.blank?
218
+ errors << "theme.yml settings field #{identifier} must define select options"
219
+ return
220
+ end
221
+
222
+ default_value = field["default"].to_s
223
+ if field.key?("default") && values.exclude?(default_value)
224
+ errors << "theme.yml settings field #{identifier} default must match one of its options"
225
+ end
226
+ end
227
+
228
+ def select_option_values(options)
229
+ return [] unless options.is_a?(Array)
230
+
231
+ options.filter_map do |option|
232
+ if option.is_a?(Hash)
233
+ option.deep_stringify_keys["value"].to_s.presence
234
+ else
235
+ option.to_s.presence
236
+ end
237
+ end
238
+ end
239
+
240
+ def manifest_asset_path(path)
241
+ raw_path = path.to_s
242
+ return if raw_path.blank?
243
+
244
+ raw_path = raw_path.delete_prefix("assets/")
245
+ normalize_entry_path(raw_path)
246
+ rescue ThemeAssetPath::UnsafePathError
247
+ errors << "theme.yml screenshot path must be a safe asset path"
248
+ nil
249
+ end
250
+
251
+ def validate_renderable_files(entries)
252
+ has_renderable_file = entries.any? do |entry|
253
+ !entry.directory? && entry.path.match?(%r{\A(layouts|templates)/.+\.liquid\z})
254
+ end
255
+
256
+ errors << "Theme package must include at least one Liquid layout or template" unless has_renderable_file
257
+ end
258
+
259
+ def validate_target(handle)
260
+ target_root = install_root.expand_path.join(handle)
261
+
262
+ errors << "Theme #{handle} is already installed" if target_root.exist?
263
+
264
+ target_root
265
+ end
266
+
267
+ def extract_entries(entries, target_root)
268
+ FileUtils.mkdir_p(install_root)
269
+
270
+ Dir.mktmpdir("plum-theme-package-") do |dir|
271
+ staging_root = Pathname(dir).join(target_root.basename.to_s)
272
+
273
+ entries.each do |entry|
274
+ destination = staging_root.join(entry.path)
275
+ if entry.directory?
276
+ FileUtils.mkdir_p(destination)
277
+ else
278
+ FileUtils.mkdir_p(destination.dirname)
279
+ destination.binwrite(entry.zip_entry.get_input_stream.read)
280
+ end
281
+ end
282
+
283
+ FileUtils.mv(staging_root, target_root)
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,63 @@
1
+ require "yaml"
2
+
3
+ module Plum
4
+ class ThemeManifestError < StandardError; end
5
+
6
+ class ThemeRegistry
7
+ DEFAULT_THEME_HANDLE = "default"
8
+
9
+ attr_reader :theme_paths
10
+
11
+ def initialize(theme_paths: Plum.configuration.theme_paths)
12
+ @theme_paths = Array(theme_paths).compact.map { |path| Pathname(path).expand_path }.uniq(&:to_s)
13
+ end
14
+
15
+ def all
16
+ themes_by_handle.values
17
+ end
18
+
19
+ def find(handle)
20
+ themes_by_handle[handle.to_s] if handle.present?
21
+ end
22
+
23
+ def fetch(handle)
24
+ find(handle) || default_theme
25
+ end
26
+
27
+ def default_theme
28
+ find(DEFAULT_THEME_HANDLE)
29
+ end
30
+
31
+ private
32
+
33
+ def themes_by_handle
34
+ @themes_by_handle ||= discover_themes
35
+ end
36
+
37
+ def discover_themes
38
+ theme_paths.each_with_object({}) do |theme_path, themes|
39
+ next unless theme_path.directory?
40
+
41
+ theme_path.children.select(&:directory?).sort.each do |theme_root|
42
+ theme = build_theme(theme_root)
43
+ themes[theme.handle] ||= theme
44
+ end
45
+ end
46
+ end
47
+
48
+ def build_theme(theme_root)
49
+ Theme.new(root: theme_root, manifest: load_manifest(theme_root.join("theme.yml")))
50
+ end
51
+
52
+ def load_manifest(path)
53
+ return {} unless path.file?
54
+
55
+ manifest = YAML.safe_load(path.read, aliases: true) || {}
56
+ return manifest if manifest.is_a?(Hash)
57
+
58
+ {}
59
+ rescue Psych::SyntaxError => e
60
+ raise ThemeManifestError, "#{path}: #{e.message}"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,55 @@
1
+ module Plum
2
+ class ThemeResolver
3
+ def initialize(registry: ThemeRegistry.new, fallback_handle: ThemeRegistry::DEFAULT_THEME_HANDLE)
4
+ @registry = registry
5
+ @fallback_handle = fallback_handle
6
+ end
7
+
8
+ def theme(handle)
9
+ registry.fetch(handle)
10
+ end
11
+
12
+ def find_template(theme_handle, template_name)
13
+ template_candidates(theme_handle, template_name).find(&:file?)
14
+ end
15
+
16
+ def find_layout(theme_handle, layout_name = "base")
17
+ themes_for(theme_handle).map { |theme| theme.layout_path(layout_name) }.find(&:file?)
18
+ end
19
+
20
+ def find_asset(theme_handle, asset_name)
21
+ themes_for(theme_handle).filter_map { |theme| theme.asset_path(asset_name) }.find(&:file?)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :registry, :fallback_handle
27
+
28
+ def template_candidates(theme_handle, template_name)
29
+ themes_for(theme_handle).flat_map do |theme|
30
+ paths = [ theme.template_path(template_name) ]
31
+ paths << theme.template_path("entries/_default") if template_name.to_s.start_with?("entries/")
32
+ paths << theme.template_path("collections/_default") if template_name.to_s.start_with?("collections/")
33
+ paths << theme.template_path("taxonomies/_default") if template_name.to_s.start_with?("taxonomies/")
34
+ paths
35
+ end
36
+ end
37
+
38
+ def themes_for(theme_handle)
39
+ theme_chain(theme_handle).uniq { |theme| theme.root.to_s }
40
+ end
41
+
42
+ def theme_chain(theme_handle)
43
+ themes = []
44
+ current_theme = registry.find(theme_handle) || registry.default_theme
45
+
46
+ while current_theme && themes.none? { |theme| theme.handle == current_theme.handle }
47
+ themes << current_theme
48
+ current_theme = registry.find(current_theme.parent_handle)
49
+ end
50
+
51
+ themes << registry.find(fallback_handle)
52
+ themes.compact
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,54 @@
1
+ module Plum
2
+ class ThemeSettingsParams
3
+ def initialize(theme)
4
+ @theme = theme
5
+ end
6
+
7
+ def normalize(raw_settings)
8
+ raw_settings = raw_settings.to_h.stringify_keys
9
+
10
+ theme.settings_fields.each_with_object({}) do |field, settings|
11
+ handle = field["handle"].to_s
12
+ next if handle.blank?
13
+
14
+ value = raw_settings.key?(handle) ? raw_settings[handle] : field["default"]
15
+ settings[handle] = normalize_value(field, value)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :theme
22
+
23
+ def normalize_value(field, value)
24
+ case field["type"].to_s
25
+ when "boolean"
26
+ value.nil? ? false : ActiveModel::Type::Boolean.new.cast(value)
27
+ when "select"
28
+ normalize_select_value(field, value)
29
+ else
30
+ value.to_s
31
+ end
32
+ end
33
+
34
+ def normalize_select_value(field, value)
35
+ values = select_option_values(field)
36
+ normalized_value = value.to_s
37
+
38
+ return normalized_value if values.blank? || values.include?(normalized_value)
39
+
40
+ default_value = field["default"].to_s
41
+ values.include?(default_value) ? default_value : values.first
42
+ end
43
+
44
+ def select_option_values(field)
45
+ Array(field["options"]).filter_map do |option|
46
+ if option.is_a?(Hash)
47
+ option.stringify_keys["value"].to_s.presence
48
+ else
49
+ option.to_s.presence
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end