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,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