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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +252 -0
- data/app/assets/builds/tailwind.css +2 -0
- data/app/assets/images/plum-mark.svg +7 -0
- data/app/assets/images/table-needs-logo.svg +83 -0
- data/app/assets/javascripts/plum/application.js +15 -0
- data/app/assets/stylesheets/application.css +10 -0
- data/app/assets/stylesheets/plum/control_panel.css +2 -0
- data/app/assets/tailwind/application.css +47 -0
- data/app/controllers/plum/application_controller.rb +34 -0
- data/app/controllers/plum/cp/assets_controller.rb +84 -0
- data/app/controllers/plum/cp/base_controller.rb +45 -0
- data/app/controllers/plum/cp/content_types_controller.rb +59 -0
- data/app/controllers/plum/cp/dashboard_controller.rb +10 -0
- data/app/controllers/plum/cp/entries_controller.rb +240 -0
- data/app/controllers/plum/cp/form_definitions_controller.rb +105 -0
- data/app/controllers/plum/cp/form_submissions_controller.rb +26 -0
- data/app/controllers/plum/cp/globals_controller.rb +84 -0
- data/app/controllers/plum/cp/nav_items_controller.rb +68 -0
- data/app/controllers/plum/cp/nav_menus_controller.rb +56 -0
- data/app/controllers/plum/cp/site_settings_controller.rb +87 -0
- data/app/controllers/plum/cp/taxonomies_controller.rb +56 -0
- data/app/controllers/plum/cp/terms_controller.rb +55 -0
- data/app/controllers/plum/cp/theme_previews_controller.rb +27 -0
- data/app/controllers/plum/cp/themes_controller.rb +98 -0
- data/app/controllers/plum/form_submissions_controller.rb +44 -0
- data/app/controllers/plum/pages_controller.rb +161 -0
- data/app/controllers/plum/sessions_controller.rb +25 -0
- data/app/controllers/plum/theme_assets_controller.rb +14 -0
- data/app/helpers/application_helper.rb +2 -0
- data/app/helpers/markdown_helper.rb +21 -0
- data/app/helpers/plum/cp/assets_helper.rb +21 -0
- data/app/helpers/plum/cp/theme_settings_helper.rb +26 -0
- data/app/javascript/application.js +18 -0
- data/app/javascript/controllers/application.js +9 -0
- data/app/javascript/controllers/index.js +4 -0
- data/app/javascript/controllers/plum/blocks_editor_controller.js +275 -0
- data/app/javascript/controllers/plum/blueprint_controller.js +120 -0
- data/app/javascript/controllers/plum/form_fields_controller.js +84 -0
- data/app/javascript/controllers/plum/image_picker_controller.js +143 -0
- data/app/javascript/controllers/plum/theme_settings_controller.js +17 -0
- data/app/models/plum/application_record.rb +5 -0
- data/app/models/plum/asset.rb +69 -0
- data/app/models/plum/content_type.rb +35 -0
- data/app/models/plum/entry.rb +67 -0
- data/app/models/plum/entry_term.rb +8 -0
- data/app/models/plum/form_definition.rb +68 -0
- data/app/models/plum/form_submission.rb +76 -0
- data/app/models/plum/global.rb +24 -0
- data/app/models/plum/nav_item.rb +58 -0
- data/app/models/plum/nav_menu.rb +24 -0
- data/app/models/plum/site.rb +112 -0
- data/app/models/plum/site_scoped.rb +17 -0
- data/app/models/plum/site_setting.rb +35 -0
- data/app/models/plum/taxonomy.rb +24 -0
- data/app/models/plum/term.rb +23 -0
- data/app/models/plum/user.rb +16 -0
- data/app/services/plum/base_blocks.rb +161 -0
- data/app/services/plum/block_editor_config.rb +65 -0
- data/app/services/plum/block_library.rb +31 -0
- data/app/services/plum/builder_renderer.rb +63 -0
- data/app/services/plum/field_expander.rb +78 -0
- data/app/services/plum/form_renderer.rb +110 -0
- data/app/services/plum/liquid_context.rb +335 -0
- data/app/services/plum/liquid_filters.rb +58 -0
- data/app/services/plum/liquid_renderer.rb +49 -0
- data/app/services/plum/theme.rb +160 -0
- data/app/services/plum/theme_asset_path.rb +31 -0
- data/app/services/plum/theme_package_installer.rb +287 -0
- data/app/services/plum/theme_registry.rb +63 -0
- data/app/services/plum/theme_resolver.rb +55 -0
- data/app/services/plum/theme_settings_params.rb +54 -0
- data/app/themes/bagel-boy/assets/screenshot.svg +92 -0
- data/app/themes/bagel-boy/assets/theme.css +184 -0
- data/app/themes/bagel-boy/blocks/hours.liquid +4 -0
- data/app/themes/bagel-boy/blocks/menu_item.liquid +7 -0
- data/app/themes/bagel-boy/layouts/base.liquid +64 -0
- data/app/themes/bagel-boy/templates/collections/posts.liquid +32 -0
- data/app/themes/bagel-boy/templates/entries/_default.liquid +13 -0
- data/app/themes/bagel-boy/templates/entries/landing.liquid +4 -0
- data/app/themes/bagel-boy/templates/entries/pages.liquid +18 -0
- data/app/themes/bagel-boy/templates/entries/posts.liquid +8 -0
- data/app/themes/bagel-boy/templates/index.liquid +10 -0
- data/app/themes/bagel-boy/templates/search.liquid +28 -0
- data/app/themes/bagel-boy/theme.yml +47 -0
- data/app/themes/bagel-shop/assets/screenshot.svg +28 -0
- data/app/themes/bagel-shop/assets/theme.css +7 -0
- data/app/themes/bagel-shop/layouts/base.liquid +507 -0
- data/app/themes/bagel-shop/templates/entries/_default.liquid +15 -0
- data/app/themes/bagel-shop/templates/entries/pages.liquid +12 -0
- data/app/themes/bagel-shop/templates/entries/posts.liquid +19 -0
- data/app/themes/bagel-shop/templates/index.liquid +55 -0
- data/app/themes/bagel-shop/theme.yml +30 -0
- data/app/themes/default/assets/screenshot.svg +24 -0
- data/app/themes/default/assets/theme.css +89 -0
- data/app/themes/default/layouts/base.liquid +117 -0
- data/app/themes/default/templates/collections/_default.liquid +16 -0
- data/app/themes/default/templates/collections/posts.liquid +27 -0
- data/app/themes/default/templates/entries/_default.liquid +26 -0
- data/app/themes/default/templates/entries/landing.liquid +5 -0
- data/app/themes/default/templates/entries/pages.liquid +17 -0
- data/app/themes/default/templates/entries/posts.liquid +20 -0
- data/app/themes/default/templates/index.liquid +22 -0
- data/app/themes/default/templates/search.liquid +28 -0
- data/app/themes/default/templates/taxonomies/_default.liquid +25 -0
- data/app/themes/default/templates/taxonomies/_default_index.liquid +14 -0
- data/app/themes/default/theme.yml +23 -0
- data/app/views/layouts/plum/cp.html.erb +138 -0
- data/app/views/layouts/plum/session.html.erb +43 -0
- data/app/views/plum/cp/assets/_form.html.erb +44 -0
- data/app/views/plum/cp/assets/edit.html.erb +14 -0
- data/app/views/plum/cp/assets/index.html.erb +52 -0
- data/app/views/plum/cp/assets/new.html.erb +9 -0
- data/app/views/plum/cp/content_types/_form.html.erb +113 -0
- data/app/views/plum/cp/content_types/edit.html.erb +8 -0
- data/app/views/plum/cp/content_types/index.html.erb +48 -0
- data/app/views/plum/cp/content_types/new.html.erb +5 -0
- data/app/views/plum/cp/content_types/show.html.erb +59 -0
- data/app/views/plum/cp/dashboard/show.html.erb +50 -0
- data/app/views/plum/cp/entries/_form.html.erb +196 -0
- data/app/views/plum/cp/entries/edit.html.erb +9 -0
- data/app/views/plum/cp/entries/index.html.erb +51 -0
- data/app/views/plum/cp/entries/new.html.erb +5 -0
- data/app/views/plum/cp/form_definitions/_form.html.erb +90 -0
- data/app/views/plum/cp/form_definitions/edit.html.erb +11 -0
- data/app/views/plum/cp/form_definitions/index.html.erb +43 -0
- data/app/views/plum/cp/form_definitions/new.html.erb +5 -0
- data/app/views/plum/cp/form_definitions/show.html.erb +70 -0
- data/app/views/plum/cp/form_submissions/show.html.erb +20 -0
- data/app/views/plum/cp/globals/_form.html.erb +44 -0
- data/app/views/plum/cp/globals/edit.html.erb +11 -0
- data/app/views/plum/cp/globals/index.html.erb +41 -0
- data/app/views/plum/cp/globals/new.html.erb +5 -0
- data/app/views/plum/cp/globals/show.html.erb +14 -0
- data/app/views/plum/cp/nav_items/_form.html.erb +56 -0
- data/app/views/plum/cp/nav_items/edit.html.erb +11 -0
- data/app/views/plum/cp/nav_items/new.html.erb +6 -0
- data/app/views/plum/cp/nav_menus/_form.html.erb +33 -0
- data/app/views/plum/cp/nav_menus/_nav_item.html.erb +24 -0
- data/app/views/plum/cp/nav_menus/edit.html.erb +11 -0
- data/app/views/plum/cp/nav_menus/index.html.erb +41 -0
- data/app/views/plum/cp/nav_menus/new.html.erb +5 -0
- data/app/views/plum/cp/nav_menus/show.html.erb +24 -0
- data/app/views/plum/cp/shared/_icon.html.erb +39 -0
- data/app/views/plum/cp/shared/_image_picker.html.erb +38 -0
- data/app/views/plum/cp/site_settings/edit.html.erb +145 -0
- data/app/views/plum/cp/site_settings/show.html.erb +62 -0
- data/app/views/plum/cp/taxonomies/_form.html.erb +38 -0
- data/app/views/plum/cp/taxonomies/edit.html.erb +2 -0
- data/app/views/plum/cp/taxonomies/index.html.erb +38 -0
- data/app/views/plum/cp/taxonomies/new.html.erb +2 -0
- data/app/views/plum/cp/taxonomies/show.html.erb +46 -0
- data/app/views/plum/cp/terms/_form.html.erb +30 -0
- data/app/views/plum/cp/terms/edit.html.erb +2 -0
- data/app/views/plum/cp/terms/new.html.erb +2 -0
- data/app/views/plum/cp/themes/index.html.erb +160 -0
- data/app/views/plum/form_mailer/submission_notification.html.erb +13 -0
- data/app/views/plum/form_mailer/submission_notification.text.erb +7 -0
- data/app/views/plum/sessions/new.html.erb +20 -0
- data/compat/plum-cms.rb +1 -0
- data/config/locales/en.yml +31 -0
- data/config/plum_importmap.rb +4 -0
- data/config/plum_routes.rb +34 -0
- data/db/engine_migrate/20260101000001_create_plum_tables.rb +154 -0
- data/lib/generators/plum/install/install_generator.rb +54 -0
- data/lib/generators/plum/install/templates/plum_initializer.rb +37 -0
- data/lib/plum/configuration.rb +88 -0
- data/lib/plum/content_source.rb +45 -0
- data/lib/plum/content_source_context.rb +30 -0
- data/lib/plum/content_source_registry.rb +120 -0
- data/lib/plum/engine.rb +30 -0
- data/lib/plum/liquid_tags/form_tag.rb +17 -0
- data/lib/plum/version.rb +7 -0
- data/lib/plum.rb +11 -0
- data/vendor/javascript/lexxy.js +11859 -0
- 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
|