plum-cms 0.2.1 → 0.2.2
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 +4 -4
- data/CHANGELOG.md +178 -0
- data/README.md +64 -0
- data/app/assets/builds/tailwind.css +1 -1
- data/app/controllers/plum/cp/entries_controller.rb +88 -5
- data/app/controllers/plum/cp/static_cache_controller.rb +12 -0
- data/app/controllers/plum/form_submissions_controller.rb +13 -0
- data/app/controllers/plum/pages_controller.rb +8 -0
- data/app/controllers/plum/theme_assets_controller.rb +2 -0
- data/app/javascript/controllers/plum/write_controller.js +174 -0
- data/app/models/plum/asset.rb +1 -0
- data/app/models/plum/content_type.rb +1 -0
- data/app/models/plum/entry.rb +57 -0
- data/app/models/plum/entry_term.rb +1 -0
- data/app/models/plum/form_definition.rb +1 -0
- data/app/models/plum/global.rb +1 -0
- data/app/models/plum/nav_item.rb +1 -0
- data/app/models/plum/nav_menu.rb +1 -0
- data/app/models/plum/site.rb +2 -0
- data/app/models/plum/site_setting.rb +1 -0
- data/app/models/plum/static_cache_invalidation.rb +30 -0
- data/app/models/plum/taxonomy.rb +1 -0
- data/app/models/plum/term.rb +1 -0
- data/app/services/plum/config_sync.rb +252 -0
- data/app/services/plum/draft_diff.rb +177 -0
- data/app/services/plum/form_renderer.rb +12 -1
- data/app/services/plum/liquid_context.rb +0 -2
- data/app/views/layouts/plum/write.html.erb +140 -0
- data/app/views/plum/cp/dashboard/show.html.erb +10 -3
- data/app/views/plum/cp/entries/_form.html.erb +2 -2
- data/app/views/plum/cp/entries/diff.html.erb +40 -0
- data/app/views/plum/cp/entries/edit.html.erb +24 -0
- data/app/views/plum/cp/entries/index.html.erb +3 -0
- data/app/views/plum/cp/entries/write.html.erb +60 -0
- data/config/plum_routes.rb +5 -0
- data/db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb +5 -0
- data/docs/config-as-code.md +103 -0
- data/docs/plum-cli.md +356 -0
- data/docs/static-caching.md +163 -0
- data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
- data/lib/plum/configuration.rb +12 -1
- data/lib/plum/engine.rb +7 -0
- data/lib/plum/static_cache/middleware.rb +61 -0
- data/lib/plum/static_cache.rb +103 -0
- data/lib/plum/version.rb +1 -1
- data/lib/tasks/plum_config.rake +48 -0
- data/lib/tasks/plum_portability.rake +54 -29
- data/lib/tasks/plum_styles.rake +14 -9
- metadata +21 -3
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
module Plum
|
|
2
|
+
# Field-by-field comparison between an entry's live content and its working
|
|
3
|
+
# draft, tokenized for git-style rendering (equal / deleted / inserted
|
|
4
|
+
# segments). Rich text is compared as readable text, structured fields as
|
|
5
|
+
# pretty-printed JSON.
|
|
6
|
+
class DraftDiff
|
|
7
|
+
# Beyond this many differing tokens, fall back to a wholesale
|
|
8
|
+
# replaced-block diff instead of an O(n*m) LCS table.
|
|
9
|
+
TOKEN_LIMIT = 1500
|
|
10
|
+
|
|
11
|
+
FieldChange = Struct.new(:handle, :label, :segments, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def initialize(entry)
|
|
14
|
+
@entry = entry
|
|
15
|
+
@content_type = entry.content_type
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def changes
|
|
19
|
+
@changes ||= build_changes
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def any?
|
|
23
|
+
changes.any?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Word-level diff as [[op, text], ...] with op in :eq/:del/:ins.
|
|
27
|
+
# Whitespace is kept as tokens so spacing survives reconstruction.
|
|
28
|
+
def self.word_diff(old_text, new_text)
|
|
29
|
+
a = tokenize(old_text)
|
|
30
|
+
b = tokenize(new_text)
|
|
31
|
+
|
|
32
|
+
prefix = common_prefix_length(a, b)
|
|
33
|
+
suffix = common_suffix_length(a, b, prefix)
|
|
34
|
+
middle_a = a[prefix...(a.length - suffix)]
|
|
35
|
+
middle_b = b[prefix...(b.length - suffix)]
|
|
36
|
+
|
|
37
|
+
segments = []
|
|
38
|
+
segments << [ :eq, a.first(prefix).join ] if prefix.positive?
|
|
39
|
+
segments.concat(diff_middle(middle_a, middle_b))
|
|
40
|
+
segments << [ :eq, a.last(suffix).join ] if suffix.positive?
|
|
41
|
+
merge_segments(segments)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :entry, :content_type
|
|
47
|
+
|
|
48
|
+
def build_changes
|
|
49
|
+
list = []
|
|
50
|
+
|
|
51
|
+
if entry.draft_title.to_s != entry.title.to_s
|
|
52
|
+
list << FieldChange.new(handle: "title", label: "Title",
|
|
53
|
+
segments: self.class.word_diff(entry.title.to_s, entry.draft_title.to_s))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
live_data = entry.data.to_h
|
|
57
|
+
draft_data = entry.draft_data.to_h["data"] || {}
|
|
58
|
+
|
|
59
|
+
(live_data.keys | draft_data.keys).each do |handle|
|
|
60
|
+
field = field_definition(handle)
|
|
61
|
+
live = normalize(live_data[handle], field)
|
|
62
|
+
draft = normalize(draft_data.key?(handle) ? draft_data[handle] : live_data[handle], field)
|
|
63
|
+
next if live == draft
|
|
64
|
+
|
|
65
|
+
list << FieldChange.new(handle: handle, label: label_for(handle, field),
|
|
66
|
+
segments: self.class.word_diff(live, draft))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
list
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def field_definition(handle)
|
|
73
|
+
content_type.fields.find { |field| field["handle"].to_s == handle }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def label_for(handle, field)
|
|
77
|
+
field&.dig("label").presence || handle.humanize
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def normalize(value, field)
|
|
81
|
+
case value
|
|
82
|
+
when nil then ""
|
|
83
|
+
when Hash, Array then JSON.pretty_generate(value)
|
|
84
|
+
when String
|
|
85
|
+
field&.dig("type") == "rich_text" ? html_to_text(value) : value
|
|
86
|
+
else value.to_s
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def html_to_text(html)
|
|
91
|
+
text = html.gsub(%r{<(br|/p|/h[1-6]|/li|/blockquote|/div|/tr|/figcaption)[^>]*>}i, "\n")
|
|
92
|
+
text = ActionController::Base.helpers.strip_tags(text).to_s
|
|
93
|
+
text.split("\n").map(&:strip).join("\n").gsub(/\n{3,}/, "\n\n").strip
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class << self
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def tokenize(text)
|
|
100
|
+
text.to_s.scan(/\S+|\s+/)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def common_prefix_length(a, b)
|
|
104
|
+
limit = [ a.length, b.length ].min
|
|
105
|
+
(0...limit).each { |i| return i if a[i] != b[i] }
|
|
106
|
+
limit
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def common_suffix_length(a, b, prefix)
|
|
110
|
+
limit = [ a.length, b.length ].min - prefix
|
|
111
|
+
(0...limit).each { |i| return i if a[a.length - 1 - i] != b[b.length - 1 - i] }
|
|
112
|
+
limit
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def diff_middle(a, b)
|
|
116
|
+
return [] if a.empty? && b.empty?
|
|
117
|
+
return [ [ :ins, b.join ] ] if a.empty?
|
|
118
|
+
return [ [ :del, a.join ] ] if b.empty?
|
|
119
|
+
if a.length > TOKEN_LIMIT || b.length > TOKEN_LIMIT
|
|
120
|
+
return [ [ :del, a.join ], [ :ins, b.join ] ]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
lcs_segments(a, b)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Standard LCS dynamic program with backtracking; inputs are bounded by
|
|
127
|
+
# TOKEN_LIMIT after prefix/suffix trimming.
|
|
128
|
+
def lcs_segments(a, b)
|
|
129
|
+
rows = a.length + 1
|
|
130
|
+
cols = b.length + 1
|
|
131
|
+
table = Array.new(rows) { Array.new(cols, 0) }
|
|
132
|
+
|
|
133
|
+
(a.length - 1).downto(0) do |i|
|
|
134
|
+
(b.length - 1).downto(0) do |j|
|
|
135
|
+
table[i][j] = if a[i] == b[j]
|
|
136
|
+
table[i + 1][j + 1] + 1
|
|
137
|
+
else
|
|
138
|
+
[ table[i + 1][j], table[i][j + 1] ].max
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
segments = []
|
|
144
|
+
i = 0
|
|
145
|
+
j = 0
|
|
146
|
+
while i < a.length && j < b.length
|
|
147
|
+
if a[i] == b[j]
|
|
148
|
+
segments << [ :eq, a[i] ]
|
|
149
|
+
i += 1
|
|
150
|
+
j += 1
|
|
151
|
+
elsif table[i + 1][j] >= table[i][j + 1]
|
|
152
|
+
segments << [ :del, a[i] ]
|
|
153
|
+
i += 1
|
|
154
|
+
else
|
|
155
|
+
segments << [ :ins, b[j] ]
|
|
156
|
+
j += 1
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
segments.concat(a[i..].map { |token| [ :del, token ] })
|
|
160
|
+
segments.concat(b[j..].map { |token| [ :ins, token ] })
|
|
161
|
+
segments
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def merge_segments(segments)
|
|
165
|
+
segments.each_with_object([]) do |(op, text), merged|
|
|
166
|
+
next if text.empty?
|
|
167
|
+
|
|
168
|
+
if merged.last && merged.last[0] == op
|
|
169
|
+
merged.last[1] += text
|
|
170
|
+
else
|
|
171
|
+
merged << [ op, text.dup ]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -24,11 +24,22 @@ module Plum
|
|
|
24
24
|
|
|
25
25
|
def hidden_inputs
|
|
26
26
|
[
|
|
27
|
-
|
|
27
|
+
honeypot_input,
|
|
28
28
|
hidden_input("return_to", form["return_to"])
|
|
29
29
|
].compact.join("\n ")
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Spam trap instead of a CSRF token: tokens are per-session, which would
|
|
33
|
+
# both break under static caching and force a session cookie onto every
|
|
34
|
+
# visitor. Bots fill this in; humans never see it.
|
|
35
|
+
def honeypot_input
|
|
36
|
+
<<~HTML.strip
|
|
37
|
+
<div style="position:absolute;left:-9999px;top:-9999px" aria-hidden="true">
|
|
38
|
+
<input type="text" name="form_submission[website]" tabindex="-1" autocomplete="off">
|
|
39
|
+
</div>
|
|
40
|
+
HTML
|
|
41
|
+
end
|
|
42
|
+
|
|
32
43
|
def fields_html
|
|
33
44
|
Array(form["fields"]).map { |field| field_html(field) }.join("\n ")
|
|
34
45
|
end
|
|
@@ -125,8 +125,6 @@ module Plum
|
|
|
125
125
|
"handle" => form.handle,
|
|
126
126
|
"fields" => form.form_fields,
|
|
127
127
|
"action" => public_form_path(form),
|
|
128
|
-
"csrf_token" => form_authenticity_token,
|
|
129
|
-
"csrf_param" => "authenticity_token",
|
|
130
128
|
"return_to" => controller.request.fullpath
|
|
131
129
|
}
|
|
132
130
|
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" class="h-full">
|
|
3
|
+
<head>
|
|
4
|
+
<title>Writing — <%= Plum.configuration.cp_name %></title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= csp_meta_tag %>
|
|
8
|
+
<%= favicon_link_tag "plum-mark.svg", type: "image/svg+xml" %>
|
|
9
|
+
<%= stylesheet_link_tag "plum/control_panel", "data-turbo-track": "reload" %>
|
|
10
|
+
<%= stylesheet_link_tag "lexxy", "data-turbo-track": "reload" %>
|
|
11
|
+
<%= javascript_importmap_tags %>
|
|
12
|
+
<script type="module">import "plum/application"</script>
|
|
13
|
+
<style>
|
|
14
|
+
:root { --plum-accent: <%= Plum.configuration.cp_accent_color %>; }
|
|
15
|
+
|
|
16
|
+
body.plum-write {
|
|
17
|
+
background: #faf9f7;
|
|
18
|
+
color: #1f2937;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.plum-write-bar {
|
|
22
|
+
position: fixed;
|
|
23
|
+
inset: 0 0 auto 0;
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
justify-content: space-between;
|
|
27
|
+
padding: 0.875rem 1.5rem;
|
|
28
|
+
background: linear-gradient(to bottom, rgba(250,249,247,0.97), rgba(250,249,247,0.85) 70%, rgba(250,249,247,0));
|
|
29
|
+
z-index: 10;
|
|
30
|
+
opacity: 0.35;
|
|
31
|
+
transition: opacity 0.25s ease;
|
|
32
|
+
}
|
|
33
|
+
.plum-write-bar:hover, .plum-write-bar:focus-within,
|
|
34
|
+
.plum-write-bar.plum-write-bar--active { opacity: 1; }
|
|
35
|
+
|
|
36
|
+
.plum-write-save-state {
|
|
37
|
+
display: inline-flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 0.45rem;
|
|
40
|
+
padding: 0.3rem 0.75rem;
|
|
41
|
+
border-radius: 9999px;
|
|
42
|
+
font-size: 0.8125rem;
|
|
43
|
+
font-weight: 500;
|
|
44
|
+
color: #6b7280;
|
|
45
|
+
background: rgba(0,0,0,0.04);
|
|
46
|
+
transition: color 0.2s ease, background 0.2s ease;
|
|
47
|
+
}
|
|
48
|
+
.plum-write-save-state::before {
|
|
49
|
+
content: "";
|
|
50
|
+
width: 0.5rem;
|
|
51
|
+
height: 0.5rem;
|
|
52
|
+
border-radius: 9999px;
|
|
53
|
+
background: #9ca3af;
|
|
54
|
+
flex-shrink: 0;
|
|
55
|
+
transition: background 0.2s ease;
|
|
56
|
+
}
|
|
57
|
+
.plum-write-save-state[data-state="dirty"]::before { background: #f59e0b; }
|
|
58
|
+
.plum-write-save-state[data-state="saving"]::before {
|
|
59
|
+
background: #f59e0b;
|
|
60
|
+
animation: plum-write-pulse 1s ease-in-out infinite;
|
|
61
|
+
}
|
|
62
|
+
.plum-write-save-state[data-state="saved"] { color: #047857; background: #ecfdf5; }
|
|
63
|
+
.plum-write-save-state[data-state="saved"]::before { background: #10b981; }
|
|
64
|
+
.plum-write-save-state[data-state="error"] { color: #b91c1c; background: #fef2f2; }
|
|
65
|
+
.plum-write-save-state[data-state="error"]::before { background: #ef4444; }
|
|
66
|
+
|
|
67
|
+
@keyframes plum-write-pulse {
|
|
68
|
+
50% { opacity: 0.3; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.plum-write-page {
|
|
72
|
+
max-width: 44rem;
|
|
73
|
+
margin: 0 auto;
|
|
74
|
+
padding: 6.5rem 1.5rem 40vh;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.plum-write-title {
|
|
78
|
+
width: 100%;
|
|
79
|
+
border: 0;
|
|
80
|
+
background: transparent;
|
|
81
|
+
resize: none;
|
|
82
|
+
overflow: hidden;
|
|
83
|
+
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
|
|
84
|
+
font-size: 2.75rem;
|
|
85
|
+
line-height: 1.15;
|
|
86
|
+
font-weight: 700;
|
|
87
|
+
color: #111827;
|
|
88
|
+
outline: none;
|
|
89
|
+
padding: 0;
|
|
90
|
+
}
|
|
91
|
+
.plum-write-title::placeholder { color: #d1d5db; }
|
|
92
|
+
|
|
93
|
+
.plum-write-body { margin-top: 2rem; }
|
|
94
|
+
.plum-write-body lexxy-editor {
|
|
95
|
+
display: block;
|
|
96
|
+
border: 0;
|
|
97
|
+
background: transparent;
|
|
98
|
+
box-shadow: none;
|
|
99
|
+
}
|
|
100
|
+
.plum-write-body .lexxy-content,
|
|
101
|
+
.plum-write-body lexxy-editor [contenteditable] {
|
|
102
|
+
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
|
|
103
|
+
font-size: 1.3125rem;
|
|
104
|
+
line-height: 1.8;
|
|
105
|
+
color: #1f2937;
|
|
106
|
+
min-height: 55vh;
|
|
107
|
+
border: 0 !important;
|
|
108
|
+
box-shadow: none !important;
|
|
109
|
+
background: transparent !important;
|
|
110
|
+
padding: 0;
|
|
111
|
+
outline: none;
|
|
112
|
+
}
|
|
113
|
+
.plum-write-body lexxy-toolbar {
|
|
114
|
+
position: sticky;
|
|
115
|
+
top: 0;
|
|
116
|
+
opacity: 0.25;
|
|
117
|
+
transition: opacity 0.25s ease;
|
|
118
|
+
background: transparent;
|
|
119
|
+
border: 0;
|
|
120
|
+
}
|
|
121
|
+
.plum-write-body:hover lexxy-toolbar,
|
|
122
|
+
.plum-write-body:focus-within lexxy-toolbar { opacity: 1; }
|
|
123
|
+
|
|
124
|
+
.plum-write-status {
|
|
125
|
+
position: fixed;
|
|
126
|
+
right: 1.5rem;
|
|
127
|
+
bottom: 1.25rem;
|
|
128
|
+
display: flex;
|
|
129
|
+
gap: 1rem;
|
|
130
|
+
font-size: 0.75rem;
|
|
131
|
+
letter-spacing: 0.02em;
|
|
132
|
+
color: #9ca3af;
|
|
133
|
+
z-index: 10;
|
|
134
|
+
}
|
|
135
|
+
</style>
|
|
136
|
+
</head>
|
|
137
|
+
<body class="plum-write h-full">
|
|
138
|
+
<%= yield %>
|
|
139
|
+
</body>
|
|
140
|
+
</html>
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
<div class="mb-8">
|
|
2
|
-
<
|
|
3
|
-
|
|
1
|
+
<div class="mb-8 flex items-start justify-between">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
|
|
4
|
+
<p class="mt-1 text-sm text-gray-500">Welcome back<%= ", #{current_user_label}" if current_user_label %></p>
|
|
5
|
+
</div>
|
|
6
|
+
<% if Plum::StaticCache.enabled? %>
|
|
7
|
+
<%= button_to "Clear page cache", cp_static_cache_path, method: :delete,
|
|
8
|
+
data: { turbo_confirm: "Clear the cached pages for this site? They re-render on the next visit." },
|
|
9
|
+
class: "px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 cursor-pointer" %>
|
|
10
|
+
<% end %>
|
|
4
11
|
</div>
|
|
5
12
|
|
|
6
13
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<div class="space-y-6">
|
|
18
18
|
<div>
|
|
19
19
|
<%= f.label :title, class: "block text-sm font-medium text-gray-700" %>
|
|
20
|
-
<%= f.text_field :title, class: "mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" %>
|
|
20
|
+
<%= f.text_field :title, value: entry.draft_title, class: "mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" %>
|
|
21
21
|
</div>
|
|
22
22
|
|
|
23
23
|
<div>
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
<% @content_type.fields.each do |field| %>
|
|
36
36
|
<% field_handle = field["handle"].to_s %>
|
|
37
37
|
<% field_id = "entry_data_#{field_handle.parameterize}" %>
|
|
38
|
-
<% field_value = entry.
|
|
38
|
+
<% field_value = entry.draft_field_value(field_handle) %>
|
|
39
39
|
<% field_value = field["default"] if entry.new_record? && field_value.nil? && field.key?("default") %>
|
|
40
40
|
<% condition = field["condition"].presence %>
|
|
41
41
|
<div class="<%= 'border-b border-gray-200 pb-2 pt-4' if field['type'] == 'section' %>" style="grid-column: span <%= field['type'] == 'section' ? 12 : (field['width'].presence || 12) %> / span <%= field['type'] == 'section' ? 12 : (field['width'].presence || 12) %>;"<% if condition %> data-field-condition="<%= condition.to_json %>"<% end %>>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<div class="mb-8 flex flex-wrap items-center justify-between gap-4">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 class="text-2xl font-bold text-gray-900">Review draft changes</h1>
|
|
4
|
+
<p class="mt-1 text-sm text-gray-500">
|
|
5
|
+
<%= @entry.title %> —
|
|
6
|
+
<span class="inline-flex items-center gap-3">
|
|
7
|
+
<span><span class="inline-block h-2.5 w-2.5 rounded-sm bg-red-200 align-middle"></span> removed from live</span>
|
|
8
|
+
<span><span class="inline-block h-2.5 w-2.5 rounded-sm bg-green-200 align-middle"></span> added in draft</span>
|
|
9
|
+
</span>
|
|
10
|
+
</p>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="flex items-center gap-3">
|
|
13
|
+
<%= link_to "Back to editor", edit_cp_content_type_entry_path(@content_type, @entry),
|
|
14
|
+
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
|
15
|
+
<%= link_to "Continue writing", write_cp_content_type_entry_path(@content_type, @entry),
|
|
16
|
+
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
|
17
|
+
<%= button_to "Discard draft", discard_draft_cp_content_type_entry_path(@content_type, @entry), method: :delete,
|
|
18
|
+
data: { turbo_confirm: "Discard the draft changes and go back to the published version?" },
|
|
19
|
+
class: "inline-flex items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50 cursor-pointer" %>
|
|
20
|
+
<%= button_to "Publish changes", publish_draft_cp_content_type_entry_path(@content_type, @entry),
|
|
21
|
+
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 cursor-pointer" %>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<% if @diff.any? %>
|
|
26
|
+
<div class="space-y-6">
|
|
27
|
+
<% @diff.changes.each do |change| %>
|
|
28
|
+
<div class="bg-white shadow rounded-lg overflow-hidden">
|
|
29
|
+
<div class="px-6 py-3 border-b border-gray-200 bg-gray-50">
|
|
30
|
+
<h2 class="text-sm font-semibold text-gray-700"><%= change.label %></h2>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="px-6 py-5 text-[0.9375rem] leading-7 text-gray-800" style="white-space: pre-wrap; overflow-wrap: anywhere;"><% change.segments.each do |op, text| %><% case op %><% when :eq %><%= text %><% when :del %><del class="rounded-sm bg-red-100 px-0.5 text-red-800 decoration-red-400"><%= text %></del><% when :ins %><ins class="rounded-sm bg-green-100 px-0.5 text-green-800 no-underline"><%= text %></ins><% end %><% end %></div>
|
|
33
|
+
</div>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
36
|
+
<% else %>
|
|
37
|
+
<div class="bg-white shadow rounded-lg p-10 text-center">
|
|
38
|
+
<p class="text-sm text-gray-500">The draft matches the live version — nothing to review.</p>
|
|
39
|
+
</div>
|
|
40
|
+
<% end %>
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
<div class="flex items-center justify-between mb-8">
|
|
2
2
|
<h1 class="text-2xl font-bold text-gray-900">Edit <%= @entry.title %></h1>
|
|
3
3
|
<div class="flex items-center gap-3">
|
|
4
|
+
<% if @content_type.fields.any? { |field| field["type"] == "rich_text" && field["handle"].present? } %>
|
|
5
|
+
<%= link_to write_cp_content_type_entry_path(@content_type, @entry),
|
|
6
|
+
class: "inline-flex items-center gap-1.5 px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" do %>
|
|
7
|
+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.862 4.487z"/></svg>
|
|
8
|
+
Write
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
4
11
|
<%= link_to "History (#{@entry.revisions.count})", cp_content_type_entry_revisions_path(@content_type, @entry),
|
|
5
12
|
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
|
6
13
|
<% if @entry.published? %>
|
|
@@ -10,6 +17,23 @@
|
|
|
10
17
|
</div>
|
|
11
18
|
</div>
|
|
12
19
|
|
|
20
|
+
<% if @entry.has_draft? %>
|
|
21
|
+
<div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 flex flex-wrap items-center justify-between gap-3">
|
|
22
|
+
<p class="text-sm text-amber-800">
|
|
23
|
+
You're seeing unpublished draft changes. The live site still shows the last published version — <strong>Save</strong> publishes what's below.
|
|
24
|
+
</p>
|
|
25
|
+
<div class="flex items-center gap-2">
|
|
26
|
+
<%= link_to "Review changes", diff_cp_content_type_entry_path(@content_type, @entry),
|
|
27
|
+
class: "px-3 py-1.5 rounded-md border border-amber-300 bg-white text-sm font-medium text-amber-800 hover:bg-amber-100" %>
|
|
28
|
+
<%= link_to "Continue writing", write_cp_content_type_entry_path(@content_type, @entry),
|
|
29
|
+
class: "px-3 py-1.5 rounded-md border border-amber-300 bg-white text-sm font-medium text-amber-800 hover:bg-amber-100" %>
|
|
30
|
+
<%= button_to "Discard draft", discard_draft_cp_content_type_entry_path(@content_type, @entry), method: :delete,
|
|
31
|
+
data: { turbo_confirm: "Discard the draft changes and go back to the published version?" },
|
|
32
|
+
class: "px-3 py-1.5 rounded-md border border-amber-300 bg-white text-sm font-medium text-amber-800 hover:bg-amber-100 cursor-pointer" %>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
13
37
|
<%= render "form", entry: @entry %>
|
|
14
38
|
|
|
15
39
|
<div class="mt-6 rounded-lg bg-white p-6 shadow">
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= entry.published? ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800' %>">
|
|
30
30
|
<%= entry.status %>
|
|
31
31
|
</span>
|
|
32
|
+
<% if entry.has_draft? %>
|
|
33
|
+
<span class="ml-1 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800" title="Has unpublished draft changes">draft edits</span>
|
|
34
|
+
<% end %>
|
|
32
35
|
</td>
|
|
33
36
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= entry.updated_at.strftime("%b %d, %Y") %></td>
|
|
34
37
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<% field_handle = @field["handle"].to_s %>
|
|
2
|
+
<% field_value = @entry.draft_field_value(field_handle) %>
|
|
3
|
+
<% drafting = @entry.published? %>
|
|
4
|
+
|
|
5
|
+
<div data-controller="plum--write"
|
|
6
|
+
data-plum--write-publish-url-value="<%= publish_draft_cp_content_type_entry_path(@content_type, @entry) %>">
|
|
7
|
+
<header class="plum-write-bar" data-plum--write-target="bar">
|
|
8
|
+
<%= link_to "← Back to editor".html_safe, edit_cp_content_type_entry_path(@content_type, @entry),
|
|
9
|
+
class: "text-sm font-medium text-gray-500 hover:text-gray-900" %>
|
|
10
|
+
<div class="flex items-center gap-4">
|
|
11
|
+
<span class="plum-write-save-state" data-plum--write-target="status" data-state="idle">
|
|
12
|
+
<span data-plum--write-target="statusText"><%= @entry.has_draft? ? "Draft saved" : "Saved" %></span>
|
|
13
|
+
</span>
|
|
14
|
+
<% if drafting %>
|
|
15
|
+
<span class="text-xs text-gray-400">Published — edits save as a draft</span>
|
|
16
|
+
<%= link_to "Review changes", diff_cp_content_type_entry_path(@content_type, @entry),
|
|
17
|
+
class: "text-sm font-medium text-gray-500 hover:text-gray-900" %>
|
|
18
|
+
<button type="button" data-action="plum--write#save"
|
|
19
|
+
class="px-4 py-1.5 rounded-md text-sm font-medium text-gray-700 border border-gray-300 bg-white hover:bg-gray-50 cursor-pointer">
|
|
20
|
+
Save draft
|
|
21
|
+
</button>
|
|
22
|
+
<button type="button" data-action="plum--write#publishDraft"
|
|
23
|
+
class="px-4 py-1.5 rounded-md text-sm font-medium text-white plum-btn-primary cursor-pointer">
|
|
24
|
+
Publish changes
|
|
25
|
+
</button>
|
|
26
|
+
<% else %>
|
|
27
|
+
<span class="text-xs text-gray-400"><%= @entry.status.titleize %></span>
|
|
28
|
+
<button type="button" data-action="plum--write#save"
|
|
29
|
+
class="px-4 py-1.5 rounded-md text-sm font-medium text-white plum-btn-primary cursor-pointer">
|
|
30
|
+
Save
|
|
31
|
+
</button>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
</header>
|
|
35
|
+
|
|
36
|
+
<main class="plum-write-page">
|
|
37
|
+
<%= form_with model: [:cp, @content_type, @entry], id: "write-form", data: { "plum--write-target": "form" } do |f| %>
|
|
38
|
+
<%= hidden_field_tag "entry[write_mode]", "1" %>
|
|
39
|
+
<%= f.text_area :title, rows: 1, placeholder: "Untitled", autocomplete: "off",
|
|
40
|
+
value: @entry.draft_title,
|
|
41
|
+
class: "plum-write-title",
|
|
42
|
+
data: { "plum--write-target": "title", action: "input->plum--write#titleChanged" } %>
|
|
43
|
+
|
|
44
|
+
<div class="plum-write-body">
|
|
45
|
+
<%= hidden_field_tag "entry[data][#{field_handle}]", field_value, id: "write_field_hidden" %>
|
|
46
|
+
<lexxy-editor value="<%= field_value %>"
|
|
47
|
+
data-direct-upload-url="<%= main_app.rails_direct_uploads_url %>"
|
|
48
|
+
data-blob-url-template="<%= main_app.rails_service_blob_url(":signed_id", ":filename") %>"
|
|
49
|
+
data-hidden-field="write_field_hidden"
|
|
50
|
+
data-plum--write-target="editor"
|
|
51
|
+
class="lexxy-content"
|
|
52
|
+
placeholder="Write something…"></lexxy-editor>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
</main>
|
|
56
|
+
|
|
57
|
+
<div class="plum-write-status">
|
|
58
|
+
<span data-plum--write-target="words"></span>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
data/config/plum_routes.rb
CHANGED
|
@@ -9,6 +9,10 @@ Plum::Engine.routes.draw do
|
|
|
9
9
|
post :apply_fieldset, on: :member
|
|
10
10
|
resources :entries do
|
|
11
11
|
patch :image_field, on: :member
|
|
12
|
+
get :write, on: :member
|
|
13
|
+
get :diff, on: :member
|
|
14
|
+
post :publish_draft, on: :member
|
|
15
|
+
delete :discard_draft, on: :member
|
|
12
16
|
post :translate, on: :member
|
|
13
17
|
resources :revisions, controller: "entry_revisions", only: [ :index ] do
|
|
14
18
|
post :restore, on: :member
|
|
@@ -27,6 +31,7 @@ Plum::Engine.routes.draw do
|
|
|
27
31
|
resource :site_settings, only: [ :show, :edit, :update ]
|
|
28
32
|
patch "site_settings/image_field", to: "site_settings#image_field", as: :site_settings_image_field
|
|
29
33
|
resources :themes, only: [ :index, :create, :update ]
|
|
34
|
+
delete "static_cache", to: "static_cache#destroy", as: :static_cache
|
|
30
35
|
resources :taxonomies do
|
|
31
36
|
resources :terms, except: [ :index, :show ]
|
|
32
37
|
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Proposal: Config as Code
|
|
2
|
+
|
|
3
|
+
Status: phase 1 implemented 2026-08-12 (`Plum::ConfigSync` + `plum:config:export|sync|check`
|
|
4
|
+
rake tasks, content types + fieldsets, one-way). Phases 2-3 (two-way CP
|
|
5
|
+
write-back, dev file watcher, taxonomies/forms) remain as proposed below.
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
The content *model* (content type blueprints, taxonomies, form definitions)
|
|
10
|
+
lives only in the database, created by clicking in the control panel. That
|
|
11
|
+
means no git history, no code review, and no repeatable path from development
|
|
12
|
+
to production — adding a field to a live site means re-clicking it in the
|
|
13
|
+
production CP. This is the classic CMS pain (WordPress/ACF sync, Contentful
|
|
14
|
+
migrations, Drupal config management all exist to solve it).
|
|
15
|
+
|
|
16
|
+
## Principle
|
|
17
|
+
|
|
18
|
+
Split the two kinds of data a CMS holds:
|
|
19
|
+
|
|
20
|
+
- **Content** (entries, terms, nav items, assets, submissions): editor-owned,
|
|
21
|
+
changes constantly, stays in the database. Untouched by this proposal.
|
|
22
|
+
- **Config** (content types + blueprints, taxonomies, forms, fieldsets):
|
|
23
|
+
developer-owned, changes rarely, belongs in version-controlled files.
|
|
24
|
+
|
|
25
|
+
Files are the source of truth; the DB holds a synced copy so rendering and
|
|
26
|
+
the CP keep working exactly as today.
|
|
27
|
+
|
|
28
|
+
## File layout (host app repo)
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
plum/
|
|
32
|
+
content_types/posts.yml
|
|
33
|
+
content_types/pages.yml
|
|
34
|
+
taxonomies/topics.yml
|
|
35
|
+
forms/contact.yml
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
# plum/content_types/posts.yml
|
|
40
|
+
name: Blog Posts
|
|
41
|
+
handle: posts
|
|
42
|
+
icon: document
|
|
43
|
+
route_prefix: blog
|
|
44
|
+
fields:
|
|
45
|
+
- handle: body
|
|
46
|
+
type: rich_text
|
|
47
|
+
label: Body
|
|
48
|
+
- handle: reading_time
|
|
49
|
+
type: number
|
|
50
|
+
unit: min
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Mechanics
|
|
54
|
+
|
|
55
|
+
- `Plum::ConfigSync.apply(site:, dir:)` — files → DB. Upsert by handle,
|
|
56
|
+
transactional, flushes the static cache. Runs via `rails plum:sync` and
|
|
57
|
+
optionally on boot.
|
|
58
|
+
- `Plum::ConfigSync.export(site:, dir:)` — DB → files. Bootstraps existing
|
|
59
|
+
sites onto the workflow; also called by the CP after visual blueprint edits
|
|
60
|
+
so the clicky builder and git never diverge (**two-way sync** — the
|
|
61
|
+
Statamic trick that makes both editors and developers happy).
|
|
62
|
+
- Dev: `ActiveSupport::FileUpdateChecker` re-applies changed files on reload,
|
|
63
|
+
same feel as theme editing.
|
|
64
|
+
- CI: `rails plum:sync --check` fails when the DB has drifted from the files
|
|
65
|
+
(hand-edits in prod that were never exported).
|
|
66
|
+
|
|
67
|
+
## Safety rails
|
|
68
|
+
|
|
69
|
+
- Removing a field from YAML never deletes entry data — entry data is a JSON
|
|
70
|
+
blob, so orphaned keys simply stop rendering (already true today).
|
|
71
|
+
- Renaming a handle is treated as remove+add unless a `renamed_from:` hint is
|
|
72
|
+
given.
|
|
73
|
+
- Deleting a content type that still has entries requires `--force`.
|
|
74
|
+
|
|
75
|
+
## Multi-site / embedded mode
|
|
76
|
+
|
|
77
|
+
Standalone mode: sync targets the single site; two-way write-back (phase 2)
|
|
78
|
+
is appropriate because the developer owns both the repo and the CP.
|
|
79
|
+
|
|
80
|
+
Embedded/SaaS mode (Table Needs) inverts the flow: the platform owns the
|
|
81
|
+
content model, customer sites share it, and customers must not write YAML
|
|
82
|
+
into the platform repo. So in host mode:
|
|
83
|
+
|
|
84
|
+
- files are authoritative **one-way** (no CP write-back),
|
|
85
|
+
- `plum:config:sync` needs an `ALL_SITES=1` mode that migrates every site's
|
|
86
|
+
model on deploy, like db:migrate for blueprints (not yet implemented),
|
|
87
|
+
- the same files seed newly created sites.
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
Plum.configure do |config|
|
|
93
|
+
config.config_path = Rails.root.join("plum") # nil disables the feature
|
|
94
|
+
config.config_sync = :two_way # :files_authoritative, :off
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Phasing & estimate
|
|
99
|
+
|
|
100
|
+
1. One-way `apply` + `export` + rake tasks, content types + fieldsets only
|
|
101
|
+
(~2–3 days).
|
|
102
|
+
2. Two-way CP write-back + dev file watcher (~2 days).
|
|
103
|
+
3. Taxonomies, forms, CI check, docs (~1 day).
|