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,112 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
class Site < ApplicationRecord
|
|
3
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
4
|
+
|
|
5
|
+
has_one :site_setting, dependent: :destroy
|
|
6
|
+
has_many :content_types, dependent: :destroy
|
|
7
|
+
has_many :entries, dependent: :destroy
|
|
8
|
+
has_many :globals, dependent: :destroy
|
|
9
|
+
has_many :nav_menus, dependent: :destroy
|
|
10
|
+
has_many :nav_items, dependent: :destroy
|
|
11
|
+
has_many :assets, dependent: :destroy
|
|
12
|
+
has_many :form_definitions, dependent: :destroy
|
|
13
|
+
has_many :form_submissions, dependent: :destroy
|
|
14
|
+
has_many :taxonomies, dependent: :destroy
|
|
15
|
+
has_many :terms, dependent: :destroy
|
|
16
|
+
|
|
17
|
+
attribute :skip_defaults, :boolean, default: false
|
|
18
|
+
|
|
19
|
+
before_validation :set_theme_defaults
|
|
20
|
+
after_create :seed_defaults, unless: :skip_defaults
|
|
21
|
+
|
|
22
|
+
validates :name, presence: true
|
|
23
|
+
validates :theme_name, presence: true
|
|
24
|
+
validates :owner_id, uniqueness: { scope: :owner_type }, if: :owner_present?
|
|
25
|
+
|
|
26
|
+
def self.first_or_create_standalone!(skip_defaults: false)
|
|
27
|
+
first_or_create!(name: "My Site", theme_name: "default", skip_defaults: skip_defaults)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.for_owner!(owner, attributes = {})
|
|
31
|
+
unless owner.respond_to?(:persisted?) && owner.persisted?
|
|
32
|
+
raise ArgumentError, "Owner must be a persisted Active Record model"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
find_or_create_by!(owner: owner) do |site|
|
|
36
|
+
site.name = attributes.fetch(:name) { owner_default_name(owner) }
|
|
37
|
+
site.theme_name = attributes.fetch(:theme_name, "default")
|
|
38
|
+
site.skip_defaults = attributes.fetch(:skip_defaults, false)
|
|
39
|
+
site.domain = attributes[:domain] if attributes.key?(:domain)
|
|
40
|
+
site.settings = attributes[:settings] if attributes.key?(:settings)
|
|
41
|
+
site.theme_settings = attributes[:theme_settings] if attributes.key?(:theme_settings)
|
|
42
|
+
site.custom_css = attributes[:custom_css] if attributes.key?(:custom_css)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def theme
|
|
47
|
+
ThemeRegistry.new.fetch(theme_name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def theme_settings
|
|
51
|
+
super || {}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def self.owner_default_name(owner)
|
|
57
|
+
return owner.name if owner.respond_to?(:name) && owner.name.present?
|
|
58
|
+
|
|
59
|
+
owner.to_s.presence || "Site"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private_class_method :owner_default_name
|
|
63
|
+
|
|
64
|
+
def owner_present?
|
|
65
|
+
owner_type.present? && owner_id.present?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def set_theme_defaults
|
|
69
|
+
self.theme_name = "default" if theme_name.blank?
|
|
70
|
+
self.theme_settings = {} if has_attribute?(:theme_settings) && theme_settings.blank?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def seed_defaults
|
|
74
|
+
return if content_types.exists?
|
|
75
|
+
|
|
76
|
+
pages = content_types.find_or_create_by!(handle: "pages") do |ct|
|
|
77
|
+
ct.name = "Pages"
|
|
78
|
+
ct.blueprint = { "fields" => [
|
|
79
|
+
{ "handle" => "body", "type" => "rich_text", "label" => "Body" }
|
|
80
|
+
] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
entries.find_or_create_by!(slug: "home") do |e|
|
|
84
|
+
e.content_type = pages
|
|
85
|
+
e.title = "Home"
|
|
86
|
+
e.status = :published
|
|
87
|
+
e.published_at = Time.current
|
|
88
|
+
e.data = { "body" => "<p>Welcome to your new website.</p>" }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
content_types.find_or_create_by!(handle: "posts") do |ct|
|
|
92
|
+
ct.name = "Blog Posts"
|
|
93
|
+
ct.blueprint = { "fields" => [
|
|
94
|
+
{ "handle" => "hero_image", "type" => "image", "label" => "Hero Image" },
|
|
95
|
+
{ "handle" => "body", "type" => "rich_text", "label" => "Body" },
|
|
96
|
+
{ "handle" => "excerpt", "type" => "textarea", "label" => "Excerpt" }
|
|
97
|
+
] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
nav = nav_menus.find_or_create_by!(handle: "main") do |n|
|
|
101
|
+
n.name = "Main"
|
|
102
|
+
end
|
|
103
|
+
home = entries.find_by(slug: "home")
|
|
104
|
+
nav.nav_items.find_or_create_by!(entry: home, site: self) do |item|
|
|
105
|
+
item.label = "Home"
|
|
106
|
+
item.position = 0
|
|
107
|
+
end if home
|
|
108
|
+
|
|
109
|
+
SiteSetting.instance(self)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
module SiteScoped
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
belongs_to :site, class_name: "Plum::Site"
|
|
7
|
+
before_validation :assign_default_site, on: :create
|
|
8
|
+
scope :for_site, ->(site) { where(site: site) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def assign_default_site
|
|
14
|
+
self.site ||= Plum::Site.first_or_create_standalone!
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
class SiteSetting < ApplicationRecord
|
|
3
|
+
include SiteScoped
|
|
4
|
+
|
|
5
|
+
before_validation :set_defaults
|
|
6
|
+
after_save :sync_site_summary
|
|
7
|
+
|
|
8
|
+
validates :name, presence: true
|
|
9
|
+
validates :site_id, uniqueness: true
|
|
10
|
+
validates :theme_name, presence: true
|
|
11
|
+
validates :primary_color, format: { with: /\A#[0-9a-fA-F]{6}\z/ }, allow_blank: true
|
|
12
|
+
validates :support_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
|
|
13
|
+
|
|
14
|
+
def self.instance(site = Plum::Site.first_or_create_standalone!)
|
|
15
|
+
find_or_create_by!(site: site) do |settings|
|
|
16
|
+
settings.name = site.name.presence || "My Site"
|
|
17
|
+
settings.theme_name = site.theme_name.presence || "default"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def set_defaults
|
|
24
|
+
self.theme_name = "default" if theme_name.blank?
|
|
25
|
+
self.primary_color = "#7c3aed" if primary_color.blank?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def sync_site_summary
|
|
29
|
+
return unless site
|
|
30
|
+
return if site.name == name && site.theme_name == theme_name
|
|
31
|
+
|
|
32
|
+
site.update!(name: name, theme_name: theme_name)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
class Taxonomy < ApplicationRecord
|
|
3
|
+
include SiteScoped
|
|
4
|
+
|
|
5
|
+
has_many :terms, dependent: :destroy
|
|
6
|
+
|
|
7
|
+
validates :name, presence: true
|
|
8
|
+
validates :handle, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z][a-z0-9_]*\z/ }
|
|
9
|
+
validates :slug, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ }
|
|
10
|
+
|
|
11
|
+
before_validation :generate_handle
|
|
12
|
+
before_validation :generate_slug
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def generate_handle
|
|
17
|
+
self.handle = name&.parameterize(separator: "_") if handle.blank?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def generate_slug
|
|
21
|
+
self.slug = name&.parameterize if slug.blank?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
class Term < ApplicationRecord
|
|
3
|
+
include SiteScoped
|
|
4
|
+
|
|
5
|
+
belongs_to :taxonomy
|
|
6
|
+
|
|
7
|
+
has_many :entry_terms, dependent: :destroy
|
|
8
|
+
has_many :entries, through: :entry_terms
|
|
9
|
+
|
|
10
|
+
validates :name, presence: true
|
|
11
|
+
validates :slug, presence: true, uniqueness: { scope: :taxonomy_id }, format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ }
|
|
12
|
+
|
|
13
|
+
before_validation :generate_slug
|
|
14
|
+
|
|
15
|
+
scope :ordered, -> { order(:position, :name) }
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def generate_slug
|
|
20
|
+
self.slug = name&.parameterize if slug.blank?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
class User < ApplicationRecord
|
|
3
|
+
has_secure_password
|
|
4
|
+
|
|
5
|
+
has_many :entries, foreign_key: :author_id, dependent: :nullify
|
|
6
|
+
|
|
7
|
+
enum :role, { viewer: 0, editor: 1, admin: 2 }
|
|
8
|
+
|
|
9
|
+
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
10
|
+
validates :role, presence: true
|
|
11
|
+
|
|
12
|
+
def admin?
|
|
13
|
+
role == "admin"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
# Engine-level "base" blocks: a small universal palette that exists on every
|
|
3
|
+
# site regardless of theme. Themes can add their own blocks and can override a
|
|
4
|
+
# base block by declaring the same handle (see Plum::BlockLibrary for the
|
|
5
|
+
# merge/override rules). Keeping these in the engine means switching themes
|
|
6
|
+
# never orphans content built from universal sections, and a bare theme still
|
|
7
|
+
# gives owners a usable palette.
|
|
8
|
+
#
|
|
9
|
+
# Each definition matches the theme block shape (handle/label/fields), and each
|
|
10
|
+
# has a matching Liquid partial keyed by handle. Field values are exposed to the
|
|
11
|
+
# partial under `block` exactly like theme blocks.
|
|
12
|
+
module BaseBlocks
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
DEFINITIONS = [
|
|
16
|
+
{
|
|
17
|
+
"handle" => "hero", "label" => "Hero",
|
|
18
|
+
"fields" => [
|
|
19
|
+
{ "handle" => "heading", "type" => "text", "label" => "Heading" },
|
|
20
|
+
{ "handle" => "subheading", "type" => "textarea", "label" => "Subheading" },
|
|
21
|
+
{ "handle" => "image", "type" => "image", "label" => "Image" },
|
|
22
|
+
{ "handle" => "button_label", "type" => "text", "label" => "Button Label" },
|
|
23
|
+
{ "handle" => "button_url", "type" => "text", "label" => "Button URL" }
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"handle" => "rich_text", "label" => "Rich Text",
|
|
28
|
+
"fields" => [
|
|
29
|
+
{ "handle" => "body", "type" => "rich_text", "label" => "Body" }
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"handle" => "image", "label" => "Image",
|
|
34
|
+
"fields" => [
|
|
35
|
+
{ "handle" => "image", "type" => "image", "label" => "Image" },
|
|
36
|
+
{ "handle" => "caption", "type" => "text", "label" => "Caption" }
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"handle" => "image_text", "label" => "Image + Text",
|
|
41
|
+
"fields" => [
|
|
42
|
+
{ "handle" => "heading", "type" => "text", "label" => "Heading" },
|
|
43
|
+
{ "handle" => "body", "type" => "rich_text", "label" => "Body" },
|
|
44
|
+
{ "handle" => "image", "type" => "image", "label" => "Image" },
|
|
45
|
+
{ "handle" => "image_position", "type" => "select", "label" => "Image Position",
|
|
46
|
+
"options" => [ { "label" => "Left", "value" => "left" }, { "label" => "Right", "value" => "right" } ] }
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"handle" => "gallery", "label" => "Gallery",
|
|
51
|
+
"fields" => [
|
|
52
|
+
{ "handle" => "heading", "type" => "text", "label" => "Heading" },
|
|
53
|
+
{ "handle" => "image_one", "type" => "image", "label" => "Image One" },
|
|
54
|
+
{ "handle" => "image_two", "type" => "image", "label" => "Image Two" },
|
|
55
|
+
{ "handle" => "image_three", "type" => "image", "label" => "Image Three" }
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"handle" => "cta", "label" => "Call to Action",
|
|
60
|
+
"fields" => [
|
|
61
|
+
{ "handle" => "heading", "type" => "text", "label" => "Heading" },
|
|
62
|
+
{ "handle" => "text", "type" => "textarea", "label" => "Text" },
|
|
63
|
+
{ "handle" => "button_label", "type" => "text", "label" => "Button Label" },
|
|
64
|
+
{ "handle" => "button_url", "type" => "text", "label" => "Button URL" }
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"handle" => "button", "label" => "Button",
|
|
69
|
+
"fields" => [
|
|
70
|
+
{ "handle" => "label", "type" => "text", "label" => "Label" },
|
|
71
|
+
{ "handle" => "url", "type" => "text", "label" => "URL" }
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"handle" => "spacer", "label" => "Spacer",
|
|
76
|
+
"fields" => [
|
|
77
|
+
{ "handle" => "size", "type" => "select", "label" => "Size",
|
|
78
|
+
"options" => [ { "label" => "Small", "value" => "small" }, { "label" => "Medium", "value" => "medium" }, { "label" => "Large", "value" => "large" } ] }
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
TEMPLATES = {
|
|
84
|
+
"hero" => <<~LIQUID,
|
|
85
|
+
<section class="plum-block plum-block-hero">
|
|
86
|
+
{% if block.heading %}<h2>{{ block.heading }}</h2>{% endif %}
|
|
87
|
+
{% if block.subheading %}<p>{{ block.subheading }}</p>{% endif %}
|
|
88
|
+
{% if block.image.url %}
|
|
89
|
+
<img class="plum-block-hero-image" src="{{ block.image.url }}" alt="{{ block.image.alt_text | default: block.heading }}">
|
|
90
|
+
{% endif %}
|
|
91
|
+
{% if block.button_label and block.button_url %}
|
|
92
|
+
<a class="plum-block-cta-button" href="{{ block.button_url }}">{{ block.button_label }}</a>
|
|
93
|
+
{% endif %}
|
|
94
|
+
</section>
|
|
95
|
+
LIQUID
|
|
96
|
+
"rich_text" => <<~LIQUID,
|
|
97
|
+
<div class="plum-block plum-block-rich-text prose">
|
|
98
|
+
{{ block.body | markdown }}
|
|
99
|
+
</div>
|
|
100
|
+
LIQUID
|
|
101
|
+
"image" => <<~LIQUID,
|
|
102
|
+
<figure class="plum-block plum-block-image">
|
|
103
|
+
{% if block.image.url %}<img src="{{ block.image.url }}" alt="{{ block.image.alt_text | default: block.caption }}">{% endif %}
|
|
104
|
+
{% if block.caption %}<figcaption>{{ block.caption }}</figcaption>{% endif %}
|
|
105
|
+
</figure>
|
|
106
|
+
LIQUID
|
|
107
|
+
"image_text" => <<~LIQUID,
|
|
108
|
+
<section class="plum-block plum-block-image-text plum-image-{{ block.image_position | default: 'left' }}">
|
|
109
|
+
{% if block.image.url %}
|
|
110
|
+
<div class="plum-block-image-text-media">
|
|
111
|
+
<img src="{{ block.image.url }}" alt="{{ block.image.alt_text | default: block.heading }}">
|
|
112
|
+
</div>
|
|
113
|
+
{% endif %}
|
|
114
|
+
<div class="plum-block-image-text-body">
|
|
115
|
+
{% if block.heading %}<h2>{{ block.heading }}</h2>{% endif %}
|
|
116
|
+
<div class="prose">{{ block.body | markdown }}</div>
|
|
117
|
+
</div>
|
|
118
|
+
</section>
|
|
119
|
+
LIQUID
|
|
120
|
+
"gallery" => <<~LIQUID,
|
|
121
|
+
<section class="plum-block plum-block-gallery">
|
|
122
|
+
{% if block.heading %}<h2>{{ block.heading }}</h2>{% endif %}
|
|
123
|
+
<div class="plum-block-gallery-grid">
|
|
124
|
+
{% if block.image_one.url %}<img src="{{ block.image_one.url }}" alt="{{ block.image_one.alt_text }}">{% endif %}
|
|
125
|
+
{% if block.image_two.url %}<img src="{{ block.image_two.url }}" alt="{{ block.image_two.alt_text }}">{% endif %}
|
|
126
|
+
{% if block.image_three.url %}<img src="{{ block.image_three.url }}" alt="{{ block.image_three.alt_text }}">{% endif %}
|
|
127
|
+
</div>
|
|
128
|
+
</section>
|
|
129
|
+
LIQUID
|
|
130
|
+
"cta" => <<~LIQUID,
|
|
131
|
+
<section class="plum-block plum-block-cta">
|
|
132
|
+
{% if block.heading %}<h2>{{ block.heading }}</h2>{% endif %}
|
|
133
|
+
{% if block.text %}<p>{{ block.text }}</p>{% endif %}
|
|
134
|
+
{% if block.button_label and block.button_url %}
|
|
135
|
+
<a class="plum-block-cta-button" href="{{ block.button_url }}">{{ block.button_label }}</a>
|
|
136
|
+
{% endif %}
|
|
137
|
+
</section>
|
|
138
|
+
LIQUID
|
|
139
|
+
"button" => <<~LIQUID,
|
|
140
|
+
<div class="plum-block plum-block-button">
|
|
141
|
+
{% if block.label and block.url %}<a class="plum-block-cta-button" href="{{ block.url }}">{{ block.label }}</a>{% endif %}
|
|
142
|
+
</div>
|
|
143
|
+
LIQUID
|
|
144
|
+
"spacer" => <<~LIQUID
|
|
145
|
+
<div class="plum-block plum-block-spacer plum-spacer-{{ block.size | default: 'medium' }}"></div>
|
|
146
|
+
LIQUID
|
|
147
|
+
}.freeze
|
|
148
|
+
|
|
149
|
+
def definitions
|
|
150
|
+
DEFINITIONS
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def definition(handle)
|
|
154
|
+
DEFINITIONS.find { |definition| definition["handle"] == handle.to_s }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def template(handle)
|
|
158
|
+
TEMPLATES[handle.to_s]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
# Translates a theme's declared blocks (theme.yml `blocks:`) into a
|
|
3
|
+
# JSON-serializable config our own block editor consumes to render the block
|
|
4
|
+
# picker and per-block field inputs. This is the seam that keeps the editor in
|
|
5
|
+
# the engine and the block library in themes: the editor never reads a theme
|
|
6
|
+
# directly — it reads this generated config. Theme authors write zero JS.
|
|
7
|
+
#
|
|
8
|
+
# Shape:
|
|
9
|
+
# {
|
|
10
|
+
# "blocks" => [
|
|
11
|
+
# { "id" => "hero", "label" => "Hero", "category" => "Blocks",
|
|
12
|
+
# "fields" => [ { "handle" => "heading", "type" => "text", "label" => "Heading" }, ... ] }
|
|
13
|
+
# ]
|
|
14
|
+
# }
|
|
15
|
+
#
|
|
16
|
+
# An optional `allowed` list (the blueprint field's `blocks:` whitelist) limits
|
|
17
|
+
# which theme blocks are offered for a given field.
|
|
18
|
+
class BlockEditorConfig
|
|
19
|
+
DEFAULT_CATEGORY = "Blocks".freeze
|
|
20
|
+
|
|
21
|
+
def initialize(theme, allowed: nil)
|
|
22
|
+
@theme = theme
|
|
23
|
+
@allowed = allowed.nil? ? nil : Array(allowed).map(&:to_s)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def blocks
|
|
27
|
+
definitions.map do |definition|
|
|
28
|
+
{
|
|
29
|
+
"id" => definition["handle"],
|
|
30
|
+
"label" => definition["label"],
|
|
31
|
+
"category" => definition["category"].presence || DEFAULT_CATEGORY,
|
|
32
|
+
"fields" => Array(definition["fields"]).map { |field| field_config(field) }
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
{ "blocks" => blocks }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_json(*args)
|
|
42
|
+
to_h.to_json(*args)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
attr_reader :theme, :allowed
|
|
48
|
+
|
|
49
|
+
def definitions
|
|
50
|
+
list = BlockLibrary.new(theme).definitions
|
|
51
|
+
return list if allowed.nil?
|
|
52
|
+
|
|
53
|
+
list.select { |definition| allowed.include?(definition["handle"]) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def field_config(field)
|
|
57
|
+
{
|
|
58
|
+
"handle" => field["handle"],
|
|
59
|
+
"type" => field["type"].presence || "text",
|
|
60
|
+
"label" => field["label"].presence || field["handle"].to_s.humanize,
|
|
61
|
+
"options" => Array(field["options"])
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
# The merged block palette available to an entry: engine-level base blocks
|
|
3
|
+
# (Plum::BaseBlocks) plus the active theme's own blocks. A theme block overrides
|
|
4
|
+
# a base block with the same handle, so a theme can both add new sections and
|
|
5
|
+
# customize universal ones. This is the single resolution point used by the
|
|
6
|
+
# block editor (Plum::BlockEditorConfig) and the renderer (Plum::BuilderRenderer).
|
|
7
|
+
class BlockLibrary
|
|
8
|
+
def initialize(theme = nil)
|
|
9
|
+
@theme = theme
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Base blocks first, then theme blocks override (by handle) or append.
|
|
13
|
+
def definitions
|
|
14
|
+
merged = {}
|
|
15
|
+
BaseBlocks.definitions.each { |definition| merged[definition["handle"]] = definition }
|
|
16
|
+
Array(@theme&.blocks).each { |definition| merged[definition["handle"]] = definition }
|
|
17
|
+
merged.values
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def definition(handle)
|
|
21
|
+
handle = handle.to_s
|
|
22
|
+
(@theme && @theme.block_definition(handle)) || BaseBlocks.definition(handle)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The Liquid source for a block. Theme partial wins; otherwise the base block.
|
|
26
|
+
def template(handle)
|
|
27
|
+
handle = handle.to_s
|
|
28
|
+
(@theme && @theme.block_template(handle)) || BaseBlocks.template(handle)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require "liquid"
|
|
2
|
+
|
|
3
|
+
module Plum
|
|
4
|
+
# Renders an array of block instances (stored on an entry's blocks field) into
|
|
5
|
+
# HTML using theme-provided Liquid block partials.
|
|
6
|
+
#
|
|
7
|
+
# Each block instance has the shape:
|
|
8
|
+
#
|
|
9
|
+
# { "id" => "<uuid>", "type" => "hero", "fields" => { "heading" => "Hi", "image" => 42 } }
|
|
10
|
+
#
|
|
11
|
+
# A block's own field values are exposed to its partial under the `block`
|
|
12
|
+
# variable, and the full shared site context (site, globals, nav, theme
|
|
13
|
+
# settings, registered content sources, ...) is merged in so block partials see
|
|
14
|
+
# exactly what a normal template sees. Image and relationship block fields are
|
|
15
|
+
# expanded with the same rules as entry fields via FieldExpander.
|
|
16
|
+
class BuilderRenderer
|
|
17
|
+
def initialize(blocks:, base_assigns:, site:, theme:, field_expander: nil, registers: {})
|
|
18
|
+
@blocks = Array(blocks)
|
|
19
|
+
@base_assigns = base_assigns || {}
|
|
20
|
+
@site = site
|
|
21
|
+
@theme = theme
|
|
22
|
+
@library = BlockLibrary.new(theme)
|
|
23
|
+
@field_expander = field_expander || FieldExpander.new(site: site)
|
|
24
|
+
@registers = registers
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render
|
|
28
|
+
return "".html_safe if blocks.empty?
|
|
29
|
+
|
|
30
|
+
rendered = blocks.filter_map { |block| render_block(block) }
|
|
31
|
+
rendered.join("\n").html_safe
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :blocks, :base_assigns, :site, :theme, :library, :field_expander, :registers
|
|
37
|
+
|
|
38
|
+
def render_block(block)
|
|
39
|
+
return unless block.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
handle = block["type"].to_s
|
|
42
|
+
definition = library.definition(handle)
|
|
43
|
+
return debug_comment("unknown block type: #{handle}") if definition.nil?
|
|
44
|
+
|
|
45
|
+
template_source = library.template(handle)
|
|
46
|
+
return debug_comment("missing block template: #{handle}") if template_source.nil?
|
|
47
|
+
|
|
48
|
+
template = Liquid::Template.parse(template_source)
|
|
49
|
+
assigns = base_assigns.merge("block" => block_assigns(block, definition))
|
|
50
|
+
template.render(assigns, filters: [ LiquidFilters ], registers: registers)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def block_assigns(block, definition)
|
|
54
|
+
field_expander.expand(values: block["fields"], fields: definition["fields"])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def debug_comment(message)
|
|
58
|
+
return "" unless defined?(Rails) && Rails.env.development?
|
|
59
|
+
|
|
60
|
+
"<!-- plum block: #{ERB::Util.html_escape(message)} -->"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
# Expands raw stored field values (as kept in entry.data or a block's fields
|
|
3
|
+
# hash) into the richer structures themes expect in Liquid: image ids become
|
|
4
|
+
# asset hashes, relationship ids become published entry hashes. The same rules
|
|
5
|
+
# are reused for entry fields and block fields so blocks behave identically.
|
|
6
|
+
class FieldExpander
|
|
7
|
+
MAX_RELATIONSHIP_DEPTH = 2
|
|
8
|
+
|
|
9
|
+
def initialize(site:, url_builder: nil)
|
|
10
|
+
@site = site
|
|
11
|
+
@url_builder = url_builder
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# values: hash of stored field values (handle => raw value)
|
|
15
|
+
# fields: array of blueprint/block field definitions ({ "handle", "type" })
|
|
16
|
+
def expand(values:, fields:, relationship_depth: MAX_RELATIONSHIP_DEPTH)
|
|
17
|
+
data = (values || {}).deep_dup
|
|
18
|
+
data = {} unless data.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
Array(fields).each do |field|
|
|
21
|
+
handle = field["handle"].to_s
|
|
22
|
+
next if handle.blank?
|
|
23
|
+
|
|
24
|
+
case field["type"]
|
|
25
|
+
when "image"
|
|
26
|
+
data[handle] = image_asset_context(data[handle])
|
|
27
|
+
when "relationship"
|
|
28
|
+
data[handle] = relationship_entry_context(data[handle], relationship_depth: relationship_depth)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
data
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :site, :url_builder
|
|
38
|
+
|
|
39
|
+
def image_asset_context(value)
|
|
40
|
+
return if value.blank?
|
|
41
|
+
|
|
42
|
+
asset_cache[value.to_i]&.to_liquid
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def relationship_entry_context(value, relationship_depth:)
|
|
46
|
+
return if value.blank? || relationship_depth <= 0
|
|
47
|
+
|
|
48
|
+
related_entry = relationship_entry_cache[value.to_i]
|
|
49
|
+
return unless related_entry
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
"title" => related_entry.title,
|
|
53
|
+
"slug" => related_entry.slug,
|
|
54
|
+
"published_at" => related_entry.published_at,
|
|
55
|
+
"url" => entry_url(related_entry),
|
|
56
|
+
"data" => expand(
|
|
57
|
+
values: related_entry.data,
|
|
58
|
+
fields: related_entry.content_type.fields,
|
|
59
|
+
relationship_depth: relationship_depth - 1
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def entry_url(entry)
|
|
65
|
+
return url_builder.call(entry) if url_builder.respond_to?(:call)
|
|
66
|
+
|
|
67
|
+
"/#{entry.slug}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def asset_cache
|
|
71
|
+
@asset_cache ||= Asset.for_site(site).with_attached_file.index_by(&:id)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def relationship_entry_cache
|
|
75
|
+
@relationship_entry_cache ||= Entry.for_site(site).live.includes(:content_type).index_by(&:id)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|