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,143 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Reusable image picker. Stores the chosen asset's id in a hidden input and
4
+ // shows a thumbnail preview. "Choose image" opens an inline panel with a grid
5
+ // of the site's assets plus an inline uploader. Works both when rendered from
6
+ // ERB (entry image fields) and when injected by the block editor's JS.
7
+ //
8
+ // data values:
9
+ // assetsUrl - GET (json) list of assets [{id,url,filename,folder,alt_text,label}]
10
+ // uploadUrl - POST (multipart, json) to create an asset, returns the asset json
11
+ // value - initially selected asset id (optional)
12
+ export default class extends Controller {
13
+ static targets = ["input", "preview", "panel", "grid", "file", "status"]
14
+ static values = { assetsUrl: String, uploadUrl: String }
15
+
16
+ connect() {
17
+ this.assets = null
18
+ if (this.inputTarget.value) this.refreshPreviewFromList()
19
+ }
20
+
21
+ toggle(event) {
22
+ event.preventDefault()
23
+ this.panelTarget.classList.toggle("hidden")
24
+ if (!this.panelTarget.classList.contains("hidden")) this.load()
25
+ }
26
+
27
+ close(event) {
28
+ if (event) event.preventDefault()
29
+ this.panelTarget.classList.add("hidden")
30
+ }
31
+
32
+ async load() {
33
+ if (this.assets) return
34
+ this.setStatus("Loading…")
35
+ try {
36
+ const res = await fetch(this.assetsUrlValue, { headers: { Accept: "application/json" } })
37
+ this.assets = await res.json()
38
+ this.renderGrid()
39
+ this.setStatus("")
40
+ } catch (_e) {
41
+ this.setStatus("Could not load images.")
42
+ }
43
+ }
44
+
45
+ renderGrid() {
46
+ this.gridTarget.innerHTML = ""
47
+ if (!this.assets.length) {
48
+ this.setStatus("No images yet — upload one below.")
49
+ return
50
+ }
51
+ this.assets.forEach((asset) => this.gridTarget.appendChild(this.thumb(asset)))
52
+ }
53
+
54
+ thumb(asset) {
55
+ const btn = document.createElement("button")
56
+ btn.type = "button"
57
+ btn.className = "group relative overflow-hidden rounded-md border border-gray-200 hover:border-purple-500 focus:border-purple-500 focus:outline-none"
58
+ btn.title = asset.label || asset.filename
59
+ btn.addEventListener("click", () => this.select(asset))
60
+
61
+ const img = document.createElement("img")
62
+ img.src = asset.url
63
+ img.alt = asset.alt_text || ""
64
+ img.className = "h-24 w-full object-cover"
65
+ btn.appendChild(img)
66
+ return btn
67
+ }
68
+
69
+ select(asset) {
70
+ this.inputTarget.value = asset.id
71
+ this.setPreview(asset.url, asset.alt_text)
72
+ this.inputTarget.dispatchEvent(new Event("input", { bubbles: true }))
73
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
74
+ this.close()
75
+ }
76
+
77
+ clear(event) {
78
+ if (event) event.preventDefault()
79
+ this.inputTarget.value = ""
80
+ this.setPreview(null)
81
+ this.inputTarget.dispatchEvent(new Event("input", { bubbles: true }))
82
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
83
+ }
84
+
85
+ async upload(event) {
86
+ const file = event.target.files && event.target.files[0]
87
+ if (!file) return
88
+ this.setStatus("Uploading…")
89
+ const body = new FormData()
90
+ body.append("asset[file]", file)
91
+ try {
92
+ const res = await fetch(this.uploadUrlValue, {
93
+ method: "POST",
94
+ headers: { Accept: "application/json", "X-CSRF-Token": this.csrfToken() },
95
+ body
96
+ })
97
+ if (!res.ok) throw new Error("upload failed")
98
+ const asset = await res.json()
99
+ if (this.assets) this.assets.unshift(asset)
100
+ this.select(asset)
101
+ this.setStatus("")
102
+ event.target.value = ""
103
+ } catch (_e) {
104
+ this.setStatus("Upload failed. Make sure it's an image.")
105
+ }
106
+ }
107
+
108
+ refreshPreviewFromList() {
109
+ // We have an id but not the URL (e.g. block editor restore). Fetch the list
110
+ // once to resolve the preview.
111
+ fetch(this.assetsUrlValue, { headers: { Accept: "application/json" } })
112
+ .then((res) => res.json())
113
+ .then((assets) => {
114
+ this.assets = assets
115
+ const current = assets.find((a) => String(a.id) === String(this.inputTarget.value))
116
+ if (current) this.setPreview(current.url, current.alt_text)
117
+ })
118
+ .catch(() => {})
119
+ }
120
+
121
+ setPreview(url, alt = "") {
122
+ if (!this.hasPreviewTarget) return
123
+ if (url) {
124
+ this.previewTarget.innerHTML = ""
125
+ const img = document.createElement("img")
126
+ img.src = url
127
+ img.alt = alt || ""
128
+ img.className = "rounded-md object-contain"
129
+ img.style.cssText = "max-height: 8rem; max-width: 16rem;"
130
+ this.previewTarget.appendChild(img)
131
+ } else {
132
+ this.previewTarget.innerHTML = '<div class="flex h-32 w-full items-center justify-center rounded-md border border-dashed border-gray-300 text-sm text-gray-400">No image selected</div>'
133
+ }
134
+ }
135
+
136
+ setStatus(text) {
137
+ if (this.hasStatusTarget) this.statusTarget.textContent = text
138
+ }
139
+
140
+ csrfToken() {
141
+ return document.querySelector('meta[name="csrf-token"]')?.content || ""
142
+ }
143
+ }
@@ -0,0 +1,17 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["select", "panel"]
5
+
6
+ connect() {
7
+ this.update()
8
+ }
9
+
10
+ update() {
11
+ const selectedHandle = this.selectTarget.value
12
+
13
+ this.panelTargets.forEach((panel) => {
14
+ panel.hidden = panel.getAttribute("data-plum--theme-settings-handle") !== selectedHandle
15
+ })
16
+ }
17
+ }
@@ -0,0 +1,5 @@
1
+ module Plum
2
+ class ApplicationRecord < ::ApplicationRecord
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,69 @@
1
+ module Plum
2
+ class Asset < ApplicationRecord
3
+ include SiteScoped
4
+
5
+ has_one_attached :file
6
+
7
+ validates :file, presence: true
8
+ validate :file_must_be_image
9
+
10
+ before_validation :normalize_folder
11
+
12
+ def filename
13
+ file.attached? ? file.filename.to_s : ""
14
+ end
15
+
16
+ def content_type
17
+ file.content_type if file.attached?
18
+ end
19
+
20
+ def image?
21
+ content_type.to_s.start_with?("image/")
22
+ end
23
+
24
+ def url
25
+ return "" unless file.attached?
26
+
27
+ Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true)
28
+ end
29
+
30
+ def variant_url(transformations)
31
+ return url unless file.attached? && file.variable?
32
+
33
+ variant = file.variant(transformations)
34
+ Rails.application.routes.url_helpers.rails_blob_path(variant.processed, only_path: true)
35
+ rescue StandardError, LoadError
36
+ url
37
+ end
38
+
39
+ def to_liquid
40
+ {
41
+ "id" => id,
42
+ "url" => url,
43
+ "thumb" => variant_url(resize_to_fill: [ 300, 300 ]),
44
+ "small" => variant_url(resize_to_limit: [ 640, 640 ]),
45
+ "medium" => variant_url(resize_to_limit: [ 1200, 1200 ]),
46
+ "large" => variant_url(resize_to_limit: [ 2000, 2000 ]),
47
+ "alt_text" => alt_text.to_s,
48
+ "caption" => caption.to_s,
49
+ "filename" => filename,
50
+ "content_type" => content_type.to_s,
51
+ "byte_size" => file.attached? ? file.byte_size : nil,
52
+ "folder" => folder.to_s
53
+ }
54
+ end
55
+
56
+ private
57
+
58
+ def normalize_folder
59
+ self.folder = folder.to_s.strip.presence
60
+ end
61
+
62
+ def file_must_be_image
63
+ return unless file.attached?
64
+ return if image?
65
+
66
+ errors.add(:file, "must be an image")
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,35 @@
1
+ module Plum
2
+ class ContentType < ApplicationRecord
3
+ include SiteScoped
4
+
5
+ FIELD_TYPES = %w[text textarea rich_text image boolean select date relationship blocks].freeze
6
+
7
+ has_many :entries, dependent: :destroy
8
+
9
+ validates :name, presence: true
10
+ validates :handle, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z][a-z0-9_]*\z/ }
11
+ validate :route_prefix_format
12
+
13
+ before_validation :generate_handle, on: :create
14
+
15
+ def fields
16
+ blueprint&.dig("fields") || []
17
+ end
18
+
19
+ def route_prefix
20
+ blueprint&.dig("route_prefix").presence
21
+ end
22
+
23
+ private
24
+
25
+ def generate_handle
26
+ self.handle = name&.parameterize(separator: "_") if handle.blank?
27
+ end
28
+
29
+ def route_prefix_format
30
+ return if route_prefix.blank? || route_prefix.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
31
+
32
+ errors.add(:blueprint, "route prefix must contain lowercase letters, numbers, and hyphens")
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,67 @@
1
+ module Plum
2
+ class Entry < ApplicationRecord
3
+ include SiteScoped
4
+
5
+ belongs_to :content_type
6
+ belongs_to :author, class_name: "Plum::User", optional: true
7
+
8
+ has_many :entry_terms, dependent: :destroy
9
+ has_many :terms, through: :entry_terms
10
+
11
+ # The page served at "/" — Plum resolves the homepage by this slug
12
+ # (convention over configuration). Its slug is locked and it can't be
13
+ # deleted while published, so a non-technical editor can't orphan the home
14
+ # page by renaming or removing it.
15
+ HOMEPAGE_SLUG = "home".freeze
16
+
17
+ enum :status, { draft: 0, published: 1, scheduled: 2 }
18
+
19
+ validates :title, presence: true
20
+ validates :slug, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ }
21
+ validates :status, presence: true
22
+ validate :homepage_slug_is_unchanged
23
+
24
+ before_validation :generate_slug
25
+ before_validation :set_published_at, if: :published?
26
+ before_destroy :prevent_homepage_destroy
27
+
28
+ scope :live, -> { published.where("published_at <= ?", Time.current) }
29
+ scope :search, ->(query) {
30
+ return none if query.blank?
31
+
32
+ term = "%#{query}%"
33
+ where("title LIKE ? OR slug LIKE ?", term, term)
34
+ }
35
+
36
+ def field_value(handle)
37
+ data&.dig(handle)
38
+ end
39
+
40
+ def homepage?
41
+ slug == HOMEPAGE_SLUG
42
+ end
43
+
44
+ private
45
+
46
+ def generate_slug
47
+ self.slug = title&.parameterize if slug.blank?
48
+ end
49
+
50
+ def homepage_slug_is_unchanged
51
+ return unless persisted? && slug_changed? && slug_was == HOMEPAGE_SLUG
52
+
53
+ errors.add(:slug, "can't be changed — this is the homepage")
54
+ end
55
+
56
+ def prevent_homepage_destroy
57
+ return unless homepage?
58
+
59
+ errors.add(:base, "The homepage can't be deleted")
60
+ throw :abort
61
+ end
62
+
63
+ def set_published_at
64
+ self.published_at ||= Time.current
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,8 @@
1
+ module Plum
2
+ class EntryTerm < ApplicationRecord
3
+ belongs_to :entry
4
+ belongs_to :term
5
+
6
+ validates :term_id, uniqueness: { scope: :entry_id }
7
+ end
8
+ end
@@ -0,0 +1,68 @@
1
+ module Plum
2
+ class FormDefinition < ApplicationRecord
3
+ include SiteScoped
4
+
5
+ FIELD_TYPES = %w[text email textarea select checkbox].freeze
6
+ HANDLE_PATTERN = /\A[a-z][a-z0-9_]*\z/
7
+
8
+ has_many :form_submissions, dependent: :destroy
9
+
10
+ validates :name, presence: true
11
+ validates :handle, presence: true, uniqueness: { scope: :site_id }, format: { with: HANDLE_PATTERN }
12
+ validate :fields_are_valid
13
+
14
+ before_validation :generate_handle, on: :create
15
+
16
+ def form_fields
17
+ fields || []
18
+ end
19
+
20
+ def field_handles
21
+ form_fields.filter_map { |field| field["handle"].presence }
22
+ end
23
+
24
+ def to_liquid
25
+ {
26
+ "name" => name,
27
+ "handle" => handle,
28
+ "fields" => form_fields
29
+ }
30
+ end
31
+
32
+ private
33
+
34
+ def generate_handle
35
+ self.handle = name&.parameterize(separator: "_") if handle.blank?
36
+ end
37
+
38
+ def fields_are_valid
39
+ unless form_fields.is_a?(Array)
40
+ errors.add(:fields, "must be an array")
41
+ return
42
+ end
43
+
44
+ seen_handles = []
45
+
46
+ form_fields.each_with_index do |field, index|
47
+ validate_field(field, index, seen_handles)
48
+ end
49
+ end
50
+
51
+ def validate_field(field, index, seen_handles)
52
+ unless field.is_a?(Hash)
53
+ errors.add(:fields, "field #{index + 1} must be an object")
54
+ return
55
+ end
56
+
57
+ handle = field["handle"].to_s
58
+ type = field["type"].to_s
59
+
60
+ errors.add(:fields, "field #{index + 1} handle is required") if handle.blank?
61
+ errors.add(:fields, "#{handle} handle is invalid") if handle.present? && !handle.match?(HANDLE_PATTERN)
62
+ errors.add(:fields, "#{handle} handle is duplicated") if handle.present? && seen_handles.include?(handle)
63
+ errors.add(:fields, "#{handle} type is invalid") unless FIELD_TYPES.include?(type)
64
+
65
+ seen_handles << handle if handle.present?
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,76 @@
1
+ module Plum
2
+ class FormSubmission < ApplicationRecord
3
+ include SiteScoped
4
+
5
+ belongs_to :form_definition
6
+
7
+ validates :data, presence: true
8
+ validate :required_fields_are_present
9
+ validate :email_fields_are_valid
10
+ validate :select_values_are_valid
11
+
12
+ before_validation :normalize_data
13
+
14
+ def data
15
+ super || {}
16
+ end
17
+
18
+ def value(key)
19
+ data&.dig(key)
20
+ end
21
+
22
+ private
23
+
24
+ def normalize_data
25
+ self.data = form_definition.form_fields.each_with_object({}) do |field, normalized|
26
+ handle = field["handle"].to_s
27
+ value = data[handle]
28
+
29
+ normalized[handle] = normalized_value(field, value)
30
+ end
31
+ end
32
+
33
+ def normalized_value(field, value)
34
+ case field["type"]
35
+ when "checkbox"
36
+ ActiveModel::Type::Boolean.new.cast(value)
37
+ else
38
+ value.to_s.strip
39
+ end
40
+ end
41
+
42
+ def required_fields_are_present
43
+ form_definition.form_fields.each do |field|
44
+ next unless ActiveModel::Type::Boolean.new.cast(field["required"])
45
+
46
+ value = data[field["handle"].to_s]
47
+ next if field["type"] == "checkbox" ? value == true : value.present?
48
+
49
+ errors.add(:data, "#{field_label(field)} is required")
50
+ end
51
+ end
52
+
53
+ def email_fields_are_valid
54
+ form_definition.form_fields.select { |field| field["type"] == "email" }.each do |field|
55
+ value = data[field["handle"].to_s]
56
+ next if value.blank? || URI::MailTo::EMAIL_REGEXP.match?(value)
57
+
58
+ errors.add(:data, "#{field_label(field)} must be a valid email")
59
+ end
60
+ end
61
+
62
+ def select_values_are_valid
63
+ form_definition.form_fields.select { |field| field["type"] == "select" }.each do |field|
64
+ value = data[field["handle"].to_s]
65
+ options = Array(field["options"]).map(&:to_s)
66
+ next if value.blank? || options.empty? || options.include?(value)
67
+
68
+ errors.add(:data, "#{field_label(field)} is invalid")
69
+ end
70
+ end
71
+
72
+ def field_label(field)
73
+ field["label"].presence || field["handle"].to_s.humanize
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,24 @@
1
+ module Plum
2
+ class Global < ApplicationRecord
3
+ include SiteScoped
4
+
5
+ validates :name, presence: true
6
+ validates :handle, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z][a-z0-9_]*\z/ }
7
+
8
+ before_validation :generate_handle, on: :create
9
+
10
+ def data
11
+ super || {}
12
+ end
13
+
14
+ def value(key)
15
+ data&.dig(key)
16
+ end
17
+
18
+ private
19
+
20
+ def generate_handle
21
+ self.handle = name&.parameterize(separator: "_") if handle.blank?
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,58 @@
1
+ module Plum
2
+ class NavItem < ApplicationRecord
3
+ include SiteScoped
4
+
5
+ belongs_to :nav_menu
6
+ belongs_to :parent, class_name: "Plum::NavItem", optional: true
7
+ belongs_to :entry, optional: true
8
+ has_many :children, class_name: "Plum::NavItem", foreign_key: :parent_id, dependent: :destroy
9
+
10
+ validates :label, presence: true
11
+ validates :url, presence: true, unless: :entry_id?
12
+ validate :nav_menu_belongs_to_site
13
+ validate :entry_belongs_to_site
14
+ validate :parent_belongs_to_menu
15
+ validate :parent_does_not_create_cycle
16
+
17
+ default_scope { order(:position) }
18
+
19
+ def href
20
+ entry&.slug ? "/#{entry.slug}" : url
21
+ end
22
+
23
+ private
24
+
25
+ def nav_menu_belongs_to_site
26
+ return if nav_menu.blank? || site.blank? || nav_menu.site_id == site_id
27
+
28
+ errors.add(:nav_menu, "must belong to the same site")
29
+ end
30
+
31
+ def entry_belongs_to_site
32
+ return if entry.blank? || site.blank? || entry.site_id == site_id
33
+
34
+ errors.add(:entry, "must belong to the same site")
35
+ end
36
+
37
+ def parent_belongs_to_menu
38
+ return if parent.blank?
39
+
40
+ errors.add(:parent, "must belong to the same menu") if parent.nav_menu_id != nav_menu_id
41
+ errors.add(:parent, "must belong to the same site") if site_id.present? && parent.site_id != site_id
42
+ errors.add(:parent, "cannot be itself") if parent_id.present? && parent_id == id
43
+ end
44
+
45
+ def parent_does_not_create_cycle
46
+ ancestor = parent
47
+
48
+ while ancestor
49
+ if ancestor == self
50
+ errors.add(:parent, "cannot be a descendant")
51
+ break
52
+ end
53
+
54
+ ancestor = ancestor.parent
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,24 @@
1
+ module Plum
2
+ class NavMenu < ApplicationRecord
3
+ include SiteScoped
4
+
5
+ has_many :nav_items, 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
+
10
+ before_validation :generate_handle, on: :create
11
+
12
+ def items
13
+ nav_items.where(parent_id: nil).order(:position)
14
+ end
15
+
16
+ alias root_items items
17
+
18
+ private
19
+
20
+ def generate_handle
21
+ self.handle = name&.parameterize(separator: "_") if handle.blank?
22
+ end
23
+ end
24
+ end