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