plum-cms 0.2.1 → 0.2.3
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 +249 -0
- data/README.md +68 -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/cp.html.erb +1 -1
- data/app/views/layouts/plum/session.html.erb +1 -1
- 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 +422 -0
- data/docs/static-caching.md +163 -0
- data/docs/zero-to-agency.md +172 -0
- data/lib/generators/plum/install/install_generator.rb +13 -1
- data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
- data/lib/plum/configuration.rb +12 -1
- data/lib/plum/engine.rb +18 -4
- 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/plum.rb +7 -0
- 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 +30 -5
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Plum
|
|
4
|
+
# Config-as-code (phase 1): the content model lives as YAML files in the
|
|
5
|
+
# host repo and syncs into the database, like db:migrate for content types.
|
|
6
|
+
#
|
|
7
|
+
# plum/content_types/posts.yml -> Plum::ContentType (blueprint)
|
|
8
|
+
# plum/fieldsets/seo.yml -> Plum::Fieldset
|
|
9
|
+
#
|
|
10
|
+
# Files are the source of truth; `apply` upserts the DB from them, `export`
|
|
11
|
+
# writes the DB back out (bootstrap + CP write-back later), and `check`
|
|
12
|
+
# reports drift for CI. Content (entries, terms, assets) is never touched.
|
|
13
|
+
class ConfigSync
|
|
14
|
+
Result = Struct.new(:created, :updated, :unchanged, :deleted, keyword_init: true) do
|
|
15
|
+
def summary
|
|
16
|
+
"#{created.length} created, #{updated.length} updated, #{unchanged.length} unchanged, #{deleted.length} deleted"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class DriftError < StandardError; end
|
|
21
|
+
class UnsafePruneError < StandardError; end
|
|
22
|
+
|
|
23
|
+
CONTENT_TYPE_DIR = "content_types".freeze
|
|
24
|
+
FIELDSET_DIR = "fieldsets".freeze
|
|
25
|
+
|
|
26
|
+
def self.export(site:, dir:)
|
|
27
|
+
new(site: site, dir: dir).export
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.apply(site:, dir:, prune: false, force: false)
|
|
31
|
+
new(site: site, dir: dir).apply(prune: prune, force: force)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.check(site:, dir:)
|
|
35
|
+
new(site: site, dir: dir).check
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(site:, dir:)
|
|
39
|
+
@site = site
|
|
40
|
+
@dir = Pathname(dir)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# DB -> files. Mirrors the database exactly: stale files for handles that
|
|
44
|
+
# no longer exist are removed.
|
|
45
|
+
def export
|
|
46
|
+
written = []
|
|
47
|
+
written += export_kind(CONTENT_TYPE_DIR, content_types.order(:handle)) { |record| content_type_config(record) }
|
|
48
|
+
written += export_kind(FIELDSET_DIR, fieldsets.order(:handle)) { |record| fieldset_config(record) }
|
|
49
|
+
written
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Files -> DB. Upserts by handle inside a transaction. Deleting requires
|
|
53
|
+
# prune: true, and deleting a content type that still has entries
|
|
54
|
+
# additionally requires force: true.
|
|
55
|
+
def apply(prune: false, force: false)
|
|
56
|
+
result = Result.new(created: [], updated: [], unchanged: [], deleted: [])
|
|
57
|
+
|
|
58
|
+
site.transaction do
|
|
59
|
+
apply_content_types(result)
|
|
60
|
+
apply_fieldsets(result)
|
|
61
|
+
prune_missing(result, force: force) if prune
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns human-readable drift lines; empty means files and DB agree.
|
|
68
|
+
def check
|
|
69
|
+
drift = []
|
|
70
|
+
drift += check_kind(CONTENT_TYPE_DIR, content_types)
|
|
71
|
+
drift += check_kind(FIELDSET_DIR, fieldsets)
|
|
72
|
+
drift
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
attr_reader :site, :dir
|
|
78
|
+
|
|
79
|
+
# Fresh relations every time — going through site.content_types would
|
|
80
|
+
# cache the association on the site instance and hide later DB changes
|
|
81
|
+
# from repeated check/apply calls.
|
|
82
|
+
def content_types
|
|
83
|
+
ContentType.for_site(site)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def fieldsets
|
|
87
|
+
Fieldset.for_site(site)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def export_kind(subdir, records)
|
|
91
|
+
path = dir.join(subdir)
|
|
92
|
+
path.mkpath
|
|
93
|
+
keep = []
|
|
94
|
+
|
|
95
|
+
records.each do |record|
|
|
96
|
+
file = path.join("#{record.handle}.yml")
|
|
97
|
+
file.write(yaml_for(yield(record)))
|
|
98
|
+
keep << file
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
path.glob("*.yml").each { |file| file.delete unless keep.include?(file) }
|
|
102
|
+
keep
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def apply_content_types(result)
|
|
106
|
+
each_config(CONTENT_TYPE_DIR) do |config, file|
|
|
107
|
+
record = content_types.find_or_initialize_by(handle: config.fetch("handle"))
|
|
108
|
+
record.site = site if record.new_record?
|
|
109
|
+
record.name = config["name"]
|
|
110
|
+
record.icon = config["icon"]
|
|
111
|
+
record.singleton = config["singleton"] unless config["singleton"].nil?
|
|
112
|
+
record.blueprint = merged_blueprint(record, config)
|
|
113
|
+
track(result, record, file)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def apply_fieldsets(result)
|
|
118
|
+
each_config(FIELDSET_DIR) do |config, file|
|
|
119
|
+
record = fieldsets.find_or_initialize_by(handle: config.fetch("handle"))
|
|
120
|
+
record.site = site if record.new_record?
|
|
121
|
+
record.name = config["name"]
|
|
122
|
+
record.fields = config["fields"] || []
|
|
123
|
+
track(result, record, file)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Blueprint keys the files don't know about are preserved so config
|
|
128
|
+
# written by newer Plum versions survives a sync from older files.
|
|
129
|
+
def merged_blueprint(record, config)
|
|
130
|
+
blueprint = (record.blueprint || {}).deep_dup
|
|
131
|
+
blueprint["fields"] = config["fields"] || []
|
|
132
|
+
if config["route_prefix"].present?
|
|
133
|
+
blueprint["route_prefix"] = config["route_prefix"]
|
|
134
|
+
else
|
|
135
|
+
blueprint.delete("route_prefix")
|
|
136
|
+
end
|
|
137
|
+
blueprint
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def track(result, record, file)
|
|
141
|
+
if record.new_record?
|
|
142
|
+
record.save!
|
|
143
|
+
result.created << record.handle
|
|
144
|
+
elsif record.changed?
|
|
145
|
+
record.save!
|
|
146
|
+
result.updated << record.handle
|
|
147
|
+
else
|
|
148
|
+
result.unchanged << record.handle
|
|
149
|
+
end
|
|
150
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
151
|
+
raise ActiveRecord::RecordInvalid.new(e.record), "#{file.basename}: #{e.message}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def prune_missing(result, force:)
|
|
155
|
+
file_handles = handles_in(CONTENT_TYPE_DIR)
|
|
156
|
+
content_types.where.not(handle: file_handles).find_each do |record|
|
|
157
|
+
if record.entries.exists? && !force
|
|
158
|
+
raise UnsafePruneError,
|
|
159
|
+
"Content type '#{record.handle}' has #{record.entries.count} entries; " \
|
|
160
|
+
"re-run with FORCE=1 to delete them"
|
|
161
|
+
end
|
|
162
|
+
record.destroy!
|
|
163
|
+
result.deleted << record.handle
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
fieldsets.where.not(handle: handles_in(FIELDSET_DIR)).find_each do |record|
|
|
167
|
+
record.destroy!
|
|
168
|
+
result.deleted << record.handle
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def handles_in(subdir)
|
|
173
|
+
handles = []
|
|
174
|
+
each_config(subdir) { |config, _file| handles << config.fetch("handle") }
|
|
175
|
+
handles
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def check_kind(subdir, records)
|
|
179
|
+
drift = []
|
|
180
|
+
configs = {}
|
|
181
|
+
each_config(subdir) { |config, _file| configs[config.fetch("handle")] = config }
|
|
182
|
+
|
|
183
|
+
db = records.index_by(&:handle)
|
|
184
|
+
configs.each do |handle, config|
|
|
185
|
+
record = db[handle]
|
|
186
|
+
if record.nil?
|
|
187
|
+
drift << "#{subdir}/#{handle}: in files but not in the database"
|
|
188
|
+
elsif normalize(subdir, config_for(subdir, record)) != normalize(subdir, config)
|
|
189
|
+
drift << "#{subdir}/#{handle}: files and database differ"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
(db.keys - configs.keys).each do |handle|
|
|
193
|
+
drift << "#{subdir}/#{handle}: in the database but not in files"
|
|
194
|
+
end
|
|
195
|
+
drift
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def config_for(subdir, record)
|
|
199
|
+
subdir == CONTENT_TYPE_DIR ? content_type_config(record) : fieldset_config(record)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Key order and empty-vs-absent values must not register as drift.
|
|
203
|
+
def normalize(subdir, config)
|
|
204
|
+
if subdir == CONTENT_TYPE_DIR
|
|
205
|
+
{
|
|
206
|
+
"name" => config["name"].to_s,
|
|
207
|
+
"handle" => config["handle"].to_s,
|
|
208
|
+
"icon" => config["icon"].presence,
|
|
209
|
+
"singleton" => !!config["singleton"],
|
|
210
|
+
"route_prefix" => config["route_prefix"].presence,
|
|
211
|
+
"fields" => config["fields"] || []
|
|
212
|
+
}
|
|
213
|
+
else
|
|
214
|
+
{
|
|
215
|
+
"name" => config["name"].to_s,
|
|
216
|
+
"handle" => config["handle"].to_s,
|
|
217
|
+
"fields" => config["fields"] || []
|
|
218
|
+
}
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def content_type_config(record)
|
|
223
|
+
config = {
|
|
224
|
+
"name" => record.name,
|
|
225
|
+
"handle" => record.handle,
|
|
226
|
+
"icon" => record.icon,
|
|
227
|
+
"singleton" => record.singleton,
|
|
228
|
+
"route_prefix" => record.route_prefix,
|
|
229
|
+
"fields" => record.fields
|
|
230
|
+
}
|
|
231
|
+
config.reject { |_key, value| value.nil? }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def fieldset_config(record)
|
|
235
|
+
{ "name" => record.name, "handle" => record.handle, "fields" => record.fields || [] }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def each_config(subdir)
|
|
239
|
+
dir.join(subdir).glob("*.yml").sort.each do |file|
|
|
240
|
+
config = YAML.safe_load(file.read, aliases: true)
|
|
241
|
+
raise DriftError, "#{file} is not a YAML mapping" unless config.is_a?(Hash)
|
|
242
|
+
|
|
243
|
+
config["handle"] ||= file.basename(".yml").to_s
|
|
244
|
+
yield config, file
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def yaml_for(config)
|
|
249
|
+
config.to_yaml
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -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
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<%= favicon_link_tag "plum-mark.svg", type: "image/svg+xml" %>
|
|
9
9
|
<%= stylesheet_link_tag "plum/control_panel", "data-turbo-track": "reload" %>
|
|
10
10
|
<%= stylesheet_link_tag "lexxy", "data-turbo-track": "reload" %>
|
|
11
|
-
<%= javascript_importmap_tags %>
|
|
11
|
+
<%= javascript_importmap_tags(importmap: Plum.importmap || Rails.application.importmap) %>
|
|
12
12
|
<script type="module">import "plum/application"</script>
|
|
13
13
|
<style>
|
|
14
14
|
:root {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<%= csp_meta_tag %>
|
|
8
8
|
<%= favicon_link_tag "plum-mark.svg", type: "image/svg+xml" %>
|
|
9
9
|
<%= stylesheet_link_tag "plum/control_panel", "data-turbo-track": "reload" %>
|
|
10
|
-
<%= javascript_importmap_tags %>
|
|
10
|
+
<%= javascript_importmap_tags(importmap: Plum.importmap || Rails.application.importmap) %>
|
|
11
11
|
<style>
|
|
12
12
|
:root { --plum-accent: <%= Plum.configuration.cp_accent_color %>; }
|
|
13
13
|
.bg-purple-600 { background-color: var(--plum-accent) !important; }
|
|
@@ -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(importmap: Plum.importmap || Rails.application.importmap) %>
|
|
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 %>>
|