plum-cms 0.2.0 → 0.2.1
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 +18 -0
- data/README.md +15 -0
- data/app/services/plum/site_archive.rb +367 -0
- data/docs/portability.md +40 -0
- data/docs/roadmap.md +71 -69
- data/lib/plum/engine.rb +3 -1
- data/lib/plum/version.rb +1 -1
- data/lib/tasks/plum_portability.rake +37 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 277d5241551a05b136eebf29f83e6347c7fc15a9d600054c524a245ab18010b4
|
|
4
|
+
data.tar.gz: 336177aba310b7b33b4fddab22190b0ac3a1fec3ad330fb1c09552459ffc8def
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ad29dfd4e8927c9259276e8c3d0cd916eb3e3b2ae74327ee32f511c67db9779613190a637b92ef4903cc0f6d440d620a6212103fad74d268202007e54b9e84c
|
|
7
|
+
data.tar.gz: e01f9d033d20d606da90e74078155e5d825c727b89162b7e750eeac5c9f3aabc048a7c3c8c29ddb241ba7d3aa4a727458f1150b04475a898c44ba8a1d80f1316
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 — 2026-08-07
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Added versioned, checksum-verified site archives covering content schemas,
|
|
8
|
+
entries, relationships, taxonomies, navigation, globals, forms, settings,
|
|
9
|
+
revisions, and original asset files.
|
|
10
|
+
- Added export/import and timestamped backup/restore Rake tasks with safe
|
|
11
|
+
new-site restoration and identifier remapping.
|
|
12
|
+
- Reframed the roadmap around a production-readiness 0.3.0 milestone.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Corrected the packaged Propshaft search path so engine Stimulus controllers
|
|
17
|
+
resolve under their `plum/` import-map namespace in external applications.
|
|
18
|
+
- Upgraded Lexxy so pasted plain text preserves paragraph boundaries and
|
|
19
|
+
block formatting only changes the active paragraph.
|
|
20
|
+
|
|
3
21
|
## 0.2.0 — 2026-08-07
|
|
4
22
|
|
|
5
23
|
### Added
|
data/README.md
CHANGED
|
@@ -228,6 +228,21 @@ Pagination variables: `pagination.current_page`, `pagination.total_pages`,
|
|
|
228
228
|
`/search?q=term` searches entry titles and slugs. Themes provide a
|
|
229
229
|
`search.liquid` template with a search form and results.
|
|
230
230
|
|
|
231
|
+
## Portability and backups
|
|
232
|
+
|
|
233
|
+
Plum can move or recover an entire site—including assets and remapped content
|
|
234
|
+
relationships—with versioned archives:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
bin/rails plum:site:export ARCHIVE=site.plum.zip
|
|
238
|
+
bin/rails plum:site:import ARCHIVE=site.plum.zip
|
|
239
|
+
bin/rails plum:backup:create DIRECTORY=/var/backups/plum
|
|
240
|
+
bin/rails plum:backup:restore ARCHIVE=/var/backups/plum/site.plum.zip
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
See [the portability guide](docs/portability.md) for archive guarantees and
|
|
244
|
+
production backup guidance.
|
|
245
|
+
|
|
231
246
|
## Content API
|
|
232
247
|
|
|
233
248
|
Published entries are available through a read-only, site-scoped JSON API:
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "zip"
|
|
3
|
+
require "digest"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Plum
|
|
7
|
+
module SiteArchive
|
|
8
|
+
FORMAT = "plum-site"
|
|
9
|
+
VERSION = 1
|
|
10
|
+
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class InvalidArchive < Error; end
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def dump(site:, path:)
|
|
17
|
+
Exporter.new(site).write(path)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def load(path:, name: nil, domain: nil)
|
|
21
|
+
Importer.new(path).import(name: name, domain: domain)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class Exporter
|
|
25
|
+
def initialize(site)
|
|
26
|
+
@site = site
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(path)
|
|
30
|
+
destination = Pathname(path).expand_path
|
|
31
|
+
destination.dirname.mkpath
|
|
32
|
+
FileUtils.rm_f(destination)
|
|
33
|
+
|
|
34
|
+
Zip::File.open(destination, create: true) do |zip|
|
|
35
|
+
zip.get_output_stream("manifest.json") { |stream| stream.write(JSON.pretty_generate(manifest)) }
|
|
36
|
+
site.assets.with_attached_file.find_each do |asset|
|
|
37
|
+
next unless asset.file.attached?
|
|
38
|
+
|
|
39
|
+
zip.get_output_stream(asset_path(asset)) do |stream|
|
|
40
|
+
asset.file.blob.open { |file| IO.copy_stream(file, stream) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
destination
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :site
|
|
50
|
+
|
|
51
|
+
def manifest
|
|
52
|
+
{
|
|
53
|
+
"format" => FORMAT,
|
|
54
|
+
"format_version" => VERSION,
|
|
55
|
+
"plum_version" => Plum::VERSION,
|
|
56
|
+
"exported_at" => Time.current.iso8601,
|
|
57
|
+
"site" => record(site, %w[id name domain theme_name settings theme_settings custom_css]),
|
|
58
|
+
"site_setting" => site.site_setting && record(site.site_setting, site_setting_fields),
|
|
59
|
+
"content_types" => records(site.content_types, %w[id name handle singleton blueprint icon]),
|
|
60
|
+
"fieldsets" => records(site.fieldsets, %w[id name handle fields]),
|
|
61
|
+
"taxonomies" => records(site.taxonomies, %w[id name handle slug]),
|
|
62
|
+
"terms" => records(site.terms, %w[id taxonomy_id name slug position]),
|
|
63
|
+
"assets" => site.assets.with_attached_file.order(:id).map { |asset| asset_record(asset) },
|
|
64
|
+
"entries" => site.entries.order(:id).map { |entry| entry_record(entry) },
|
|
65
|
+
"entry_revisions" => revision_records,
|
|
66
|
+
"globals" => records(site.globals, %w[id name handle data]),
|
|
67
|
+
"nav_menus" => records(site.nav_menus, %w[id name handle]),
|
|
68
|
+
"nav_items" => records(site.nav_items.unscoped.where(site: site), %w[id nav_menu_id parent_id entry_id label url position]),
|
|
69
|
+
"form_definitions" => records(site.form_definitions, %w[id name handle fields notification_email]),
|
|
70
|
+
"form_submissions" => records(site.form_submissions, %w[id form_definition_id data created_at updated_at])
|
|
71
|
+
}.compact
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def site_setting_fields
|
|
75
|
+
%w[name tagline logo favicon seo_title seo_description theme_name primary_color support_email]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def entry_record(entry)
|
|
79
|
+
record(entry, %w[id content_type_id title slug status data published_at author_name author_email author_gid locale origin_id]).merge(
|
|
80
|
+
"term_ids" => entry.term_ids
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def revision_records
|
|
85
|
+
site.entries.includes(:revisions).flat_map do |entry|
|
|
86
|
+
entry.revisions.order(:id).map do |revision|
|
|
87
|
+
record(revision, %w[id entry_id editor_name editor_email editor_gid snapshot created_at updated_at])
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def asset_record(asset)
|
|
93
|
+
record(asset, %w[id alt_text caption folder focal_x focal_y]).merge(
|
|
94
|
+
"filename" => asset.filename,
|
|
95
|
+
"content_type" => asset.content_type,
|
|
96
|
+
"byte_size" => asset.file.byte_size,
|
|
97
|
+
"checksum" => asset.file.blob.checksum,
|
|
98
|
+
"path" => asset_path(asset)
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def asset_path(asset)
|
|
103
|
+
"assets/#{asset.id}/#{asset.filename.gsub(/[^A-Za-z0-9._-]/, "_")}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def records(scope, fields)
|
|
107
|
+
scope.order(:id).map { |item| record(item, fields) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def record(item, fields)
|
|
111
|
+
item.attributes.slice(*fields)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class Importer
|
|
116
|
+
def initialize(path)
|
|
117
|
+
@path = Pathname(path).expand_path
|
|
118
|
+
@maps = Hash.new { |hash, key| hash[key] = {} }
|
|
119
|
+
@uploaded_blobs = []
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def import(name: nil, domain: nil)
|
|
123
|
+
raise InvalidArchive, "Archive does not exist: #{path}" unless path.file?
|
|
124
|
+
|
|
125
|
+
Zip::File.open(path) do |zip|
|
|
126
|
+
@zip = zip
|
|
127
|
+
@data = parse_manifest(zip)
|
|
128
|
+
validate_manifest!
|
|
129
|
+
ActiveRecord::Base.transaction { import_site(name:, domain:) }
|
|
130
|
+
end
|
|
131
|
+
rescue StandardError => error
|
|
132
|
+
cleanup_uploaded_files
|
|
133
|
+
raise unless error.is_a?(Zip::Error) || error.is_a?(JSON::ParserError)
|
|
134
|
+
|
|
135
|
+
raise InvalidArchive, error.message
|
|
136
|
+
ensure
|
|
137
|
+
@zip = nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
attr_reader :path, :data, :maps, :zip
|
|
143
|
+
|
|
144
|
+
def parse_manifest(zip_file)
|
|
145
|
+
entry = zip_file.find_entry("manifest.json")
|
|
146
|
+
raise InvalidArchive, "Archive is missing manifest.json" unless entry
|
|
147
|
+
|
|
148
|
+
JSON.parse(entry.get_input_stream.read)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_manifest!
|
|
152
|
+
raise InvalidArchive, "Not a Plum site archive" unless data["format"] == FORMAT
|
|
153
|
+
raise InvalidArchive, "Unsupported archive version #{data['format_version'].inspect}" unless data["format_version"] == VERSION
|
|
154
|
+
raise InvalidArchive, "Archive is missing site data" unless data["site"].is_a?(Hash)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def import_site(name:, domain:)
|
|
158
|
+
source = data.fetch("site")
|
|
159
|
+
@site = Site.create!(
|
|
160
|
+
name: name.presence || source.fetch("name"),
|
|
161
|
+
domain: domain.nil? ? source["domain"] : domain,
|
|
162
|
+
theme_name: source["theme_name"],
|
|
163
|
+
settings: source["settings"] || {},
|
|
164
|
+
theme_settings: source["theme_settings"] || {},
|
|
165
|
+
custom_css: source["custom_css"],
|
|
166
|
+
skip_defaults: true
|
|
167
|
+
)
|
|
168
|
+
maps[:sites][source["id"]] = @site.id
|
|
169
|
+
|
|
170
|
+
import_simple(:content_types, ContentType, %w[name handle singleton blueprint icon])
|
|
171
|
+
import_simple(:fieldsets, Fieldset, %w[name handle fields])
|
|
172
|
+
import_simple(:taxonomies, Taxonomy, %w[name handle slug])
|
|
173
|
+
import_terms
|
|
174
|
+
import_assets
|
|
175
|
+
import_entries
|
|
176
|
+
import_entry_links
|
|
177
|
+
import_globals
|
|
178
|
+
import_navigation
|
|
179
|
+
import_forms
|
|
180
|
+
import_site_setting
|
|
181
|
+
@site
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def import_simple(key, model, fields)
|
|
185
|
+
Array(data[key.to_s]).each do |source|
|
|
186
|
+
item = model.create!(source.slice(*fields).merge("site_id" => @site.id))
|
|
187
|
+
maps[key][source["id"]] = item.id
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def import_terms
|
|
192
|
+
Array(data["terms"]).each do |source|
|
|
193
|
+
term = Term.create!(source.slice("name", "slug", "position").merge(
|
|
194
|
+
"site_id" => @site.id,
|
|
195
|
+
"taxonomy_id" => mapped!(:taxonomies, source["taxonomy_id"])
|
|
196
|
+
))
|
|
197
|
+
maps[:terms][source["id"]] = term.id
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def import_assets
|
|
202
|
+
Array(data["assets"]).each do |source|
|
|
203
|
+
archive_entry = zip.find_entry(source.fetch("path"))
|
|
204
|
+
raise InvalidArchive, "Archive is missing asset #{source['path']}" unless archive_entry
|
|
205
|
+
|
|
206
|
+
asset = import_asset(source, archive_entry)
|
|
207
|
+
maps[:assets][source["id"]] = asset.id
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def import_asset(source, archive_entry)
|
|
212
|
+
Tempfile.create([ "plum-asset", File.extname(source.fetch("filename")) ], binmode: true) do |file|
|
|
213
|
+
digest = Digest::MD5.new
|
|
214
|
+
bytes = 0
|
|
215
|
+
input = archive_entry.get_input_stream
|
|
216
|
+
while (chunk = input.read(64 * 1024))
|
|
217
|
+
file.write(chunk)
|
|
218
|
+
digest.update(chunk)
|
|
219
|
+
bytes += chunk.bytesize
|
|
220
|
+
end
|
|
221
|
+
expected_checksum = source["checksum"].to_s
|
|
222
|
+
actual_checksum = [ digest.digest ].pack("m0")
|
|
223
|
+
raise InvalidArchive, "Asset #{source['path']} has an invalid size" if source["byte_size"].present? && bytes != source["byte_size"].to_i
|
|
224
|
+
raise InvalidArchive, "Asset #{source['path']} failed its checksum" if expected_checksum.present? && actual_checksum != expected_checksum
|
|
225
|
+
|
|
226
|
+
file.rewind
|
|
227
|
+
asset = Asset.new(source.slice("alt_text", "caption", "folder", "focal_x", "focal_y").merge("site_id" => @site.id))
|
|
228
|
+
blob = ActiveStorage::Blob.create_and_upload!(
|
|
229
|
+
io: file,
|
|
230
|
+
filename: source.fetch("filename"),
|
|
231
|
+
content_type: source["content_type"]
|
|
232
|
+
)
|
|
233
|
+
@uploaded_blobs << blob
|
|
234
|
+
asset.file.attach(blob)
|
|
235
|
+
asset.save!
|
|
236
|
+
asset
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def import_entries
|
|
241
|
+
Array(data["entries"]).each do |source|
|
|
242
|
+
entry = Entry.create!(source.slice("title", "slug", "status", "data", "published_at", "author_name", "author_email", "author_gid", "locale").merge(
|
|
243
|
+
"site_id" => @site.id,
|
|
244
|
+
"content_type_id" => mapped!(:content_types, source["content_type_id"])
|
|
245
|
+
))
|
|
246
|
+
maps[:entries][source["id"]] = entry.id
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def import_entry_links
|
|
251
|
+
entries_by_id = Array(data["entries"]).index_by { |item| item["id"] }
|
|
252
|
+
entries_by_id.each do |old_id, source|
|
|
253
|
+
entry = Entry.find(mapped!(:entries, old_id))
|
|
254
|
+
fields = entry.content_type.fields
|
|
255
|
+
entry.update_columns(
|
|
256
|
+
data: remap_field_values(source["data"] || {}, fields),
|
|
257
|
+
origin_id: mapped(:entries, source["origin_id"]),
|
|
258
|
+
updated_at: Time.current
|
|
259
|
+
)
|
|
260
|
+
entry.term_ids = Array(source["term_ids"]).filter_map { |id| mapped(:terms, id) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
Array(data["entry_revisions"]).each do |source|
|
|
264
|
+
snapshot = source["snapshot"].to_h.deep_dup
|
|
265
|
+
entry = Entry.find(mapped!(:entries, source["entry_id"]))
|
|
266
|
+
snapshot["data"] = remap_field_values(snapshot["data"] || {}, entry.content_type.fields)
|
|
267
|
+
snapshot["term_ids"] = Array(snapshot["term_ids"]).filter_map { |id| mapped(:terms, id) }
|
|
268
|
+
EntryRevision.create!(source.slice("editor_name", "editor_email", "editor_gid", "created_at", "updated_at").merge(
|
|
269
|
+
"site_id" => @site.id, "entry_id" => entry.id, "snapshot" => snapshot
|
|
270
|
+
))
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def remap_field_values(values, fields)
|
|
275
|
+
result = values.to_h.deep_dup
|
|
276
|
+
Array(fields).each do |field|
|
|
277
|
+
handle = field["handle"].to_s
|
|
278
|
+
value = result[handle]
|
|
279
|
+
result[handle] = case field["type"]
|
|
280
|
+
when "image" then mapped(:assets, value)
|
|
281
|
+
when "images" then Array(value).filter_map { |id| mapped(:assets, id) }
|
|
282
|
+
when "relationship"
|
|
283
|
+
field["multiple"] ? Array(value).filter_map { |id| mapped(:entries, id) } : mapped(:entries, value)
|
|
284
|
+
when "group" then remap_field_values(value || {}, field["fields"])
|
|
285
|
+
when "repeater" then Array(value).map { |row| remap_field_values(row, field["fields"]) }
|
|
286
|
+
when "blocks" then remap_blocks(value)
|
|
287
|
+
else value
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
result
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def remap_blocks(value)
|
|
294
|
+
library = BlockLibrary.new(@site.theme)
|
|
295
|
+
Array(value).map do |block|
|
|
296
|
+
restored = block.to_h.deep_dup
|
|
297
|
+
definition = library.definition(restored["type"])
|
|
298
|
+
restored["fields"] = remap_field_values(restored["fields"] || {}, definition&.dig("fields") || [])
|
|
299
|
+
restored
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def import_globals
|
|
304
|
+
Array(data["globals"]).each do |source|
|
|
305
|
+
Global.create!(source.slice("name", "handle", "data").merge("site_id" => @site.id))
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def import_navigation
|
|
310
|
+
import_simple(:nav_menus, NavMenu, %w[name handle])
|
|
311
|
+
pending = Array(data["nav_items"]).sort_by { |item| item["parent_id"].present? ? 1 : 0 }
|
|
312
|
+
until pending.empty?
|
|
313
|
+
imported = pending.reject! do |source|
|
|
314
|
+
next false if source["parent_id"].present? && mapped(:nav_items, source["parent_id"]).blank?
|
|
315
|
+
|
|
316
|
+
item = NavItem.create!(source.slice("label", "url", "position").merge(
|
|
317
|
+
"site_id" => @site.id,
|
|
318
|
+
"nav_menu_id" => mapped!(:nav_menus, source["nav_menu_id"]),
|
|
319
|
+
"parent_id" => mapped(:nav_items, source["parent_id"]),
|
|
320
|
+
"entry_id" => mapped(:entries, source["entry_id"])
|
|
321
|
+
))
|
|
322
|
+
maps[:nav_items][source["id"]] = item.id
|
|
323
|
+
true
|
|
324
|
+
end
|
|
325
|
+
raise InvalidArchive, "Navigation contains an invalid parent cycle" unless imported
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def import_forms
|
|
330
|
+
import_simple(:form_definitions, FormDefinition, %w[name handle fields notification_email])
|
|
331
|
+
Array(data["form_submissions"]).each do |source|
|
|
332
|
+
submission = FormSubmission.new(source.slice("data", "created_at", "updated_at").merge(
|
|
333
|
+
"site_id" => @site.id,
|
|
334
|
+
"form_definition_id" => mapped!(:form_definitions, source["form_definition_id"])
|
|
335
|
+
))
|
|
336
|
+
submission.save!(validate: false)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def import_site_setting
|
|
341
|
+
source = data["site_setting"]
|
|
342
|
+
return SiteSetting.instance(@site) unless source
|
|
343
|
+
|
|
344
|
+
attributes = source.except("id")
|
|
345
|
+
attributes["logo"] = mapped(:assets, source["logo"].to_i)&.to_s if source["logo"].present?
|
|
346
|
+
attributes["favicon"] = mapped(:assets, source["favicon"].to_i)&.to_s if source["favicon"].present?
|
|
347
|
+
SiteSetting.create!(attributes.merge("site_id" => @site.id))
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def mapped(type, old_id)
|
|
351
|
+
return if old_id.blank?
|
|
352
|
+
|
|
353
|
+
maps[type][old_id] || maps[type][old_id.to_i]
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def mapped!(type, old_id)
|
|
357
|
+
mapped(type, old_id) || raise(InvalidArchive, "Missing #{type.to_s.singularize} reference #{old_id.inspect}")
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def cleanup_uploaded_files
|
|
361
|
+
@uploaded_blobs.each { |blob| blob.service.delete(blob.key) }
|
|
362
|
+
rescue StandardError
|
|
363
|
+
nil
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
data/docs/portability.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Site export, import, backup, and restore
|
|
2
|
+
|
|
3
|
+
Plum 0.3 archives are versioned ZIP files containing a JSON manifest and the
|
|
4
|
+
original bytes of every attached asset. The manifest carries schemas, entries,
|
|
5
|
+
relationships, translations, taxonomies, navigation, globals, forms, fieldsets,
|
|
6
|
+
settings, revisions, and submissions. Import remaps database identifiers so an
|
|
7
|
+
archive can be restored into a different database without collisions.
|
|
8
|
+
|
|
9
|
+
## Export and import
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bin/rails plum:site:export ARCHIVE=/safe/location/site.plum.zip SITE_ID=1
|
|
13
|
+
bin/rails plum:site:import ARCHIVE=/safe/location/site.plum.zip NAME="Imported site" DOMAIN=example.com
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`SITE_ID` is optional when exporting and defaults to the first site. `NAME` and
|
|
17
|
+
`DOMAIN` are optional import overrides. Import always creates a new site; it
|
|
18
|
+
never overwrites an existing site.
|
|
19
|
+
|
|
20
|
+
## Backup and restore
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bin/rails plum:backup:create SITE_ID=1 DIRECTORY=/var/backups/plum
|
|
24
|
+
bin/rails plum:backup:restore ARCHIVE=/var/backups/plum/plum-site-1-TIMESTAMP.plum.zip
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The backup task writes a timestamped archive. Restore uses the same safe,
|
|
28
|
+
new-site import path. Assets are checked against their recorded byte size and
|
|
29
|
+
checksum before the database transaction commits. Missing, corrupt, or
|
|
30
|
+
unsupported archives fail without leaving a partially imported site.
|
|
31
|
+
|
|
32
|
+
Backups must be copied off the application host and tested regularly. An archive
|
|
33
|
+
does not include application code, environment secrets, users, or the host
|
|
34
|
+
database outside the selected Plum site.
|
|
35
|
+
|
|
36
|
+
## Archive compatibility
|
|
37
|
+
|
|
38
|
+
The manifest currently uses format version `1`. Plum rejects archive versions
|
|
39
|
+
it does not understand instead of guessing. Future format changes will either
|
|
40
|
+
remain readable or ship an explicit migration path.
|
data/docs/roadmap.md
CHANGED
|
@@ -1,76 +1,79 @@
|
|
|
1
1
|
# Roadmap
|
|
2
2
|
|
|
3
|
-
Plum's roadmap is organized by outcomes rather than promised dates.
|
|
4
|
-
as the product is exercised in real
|
|
5
|
-
feedback.
|
|
6
|
-
|
|
7
|
-
## Now: Make Plum Real and Adoptable
|
|
8
|
-
|
|
9
|
-
The current milestone is to build and publish the Plum marketing and
|
|
10
|
-
documentation site as a standalone Rails application powered by Plum and
|
|
11
|
-
SQLite.
|
|
12
|
-
|
|
13
|
-
### Product foundation
|
|
14
|
-
|
|
15
|
-
- Publish a clear vision, product principles, and supported use cases.
|
|
16
|
-
- Build `plumcms.org` with Plum as an external dependency.
|
|
17
|
-
- Define a portable production layout for the SQLite database, uploaded assets,
|
|
18
|
-
and mutable site data.
|
|
19
|
-
- Package the site as an ONCE-compatible Docker application.
|
|
20
|
-
- Exercise installation, initialization, health checks, upgrades, backup, and
|
|
21
|
-
restore on a real VM.
|
|
22
|
-
- Keep SQLite and PostgreSQL verification in CI.
|
|
23
|
-
|
|
24
|
-
### Documentation
|
|
25
|
-
|
|
26
|
-
- Document installation and the first editable page.
|
|
27
|
-
- Document sites, content types, fields, entries, and publishing.
|
|
28
|
-
- Document blocks, assets, relationships, taxonomies, navigation, globals, and
|
|
29
|
-
forms.
|
|
30
|
-
- Document Liquid themes and host content sources.
|
|
31
|
-
- Document embedded authentication, authorization, tenancy, and routing.
|
|
32
|
-
- Document standalone SQLite and embedded PostgreSQL deployments.
|
|
33
|
-
- Publish configuration, troubleshooting, and upgrade references.
|
|
34
|
-
|
|
35
|
-
### Distribution
|
|
36
|
-
|
|
37
|
-
- Publish the `plum` gem.
|
|
38
|
-
- State supported Ruby and Rails versions.
|
|
39
|
-
- Establish semantic versioning and an upgrade policy.
|
|
40
|
-
- Test fresh external installations and upgrades in CI.
|
|
41
|
-
- Provide a production-ready container contract and example application.
|
|
42
|
-
|
|
43
|
-
## Next: Editorial Confidence and Portability
|
|
44
|
-
|
|
45
|
-
Once the public site proves the basic workflow, focus on the features editors
|
|
46
|
-
and agencies need to trust Plum in production.
|
|
47
|
-
|
|
48
|
-
- Draft preview.
|
|
49
|
-
- Revisions and rollback.
|
|
50
|
-
- Scheduled publishing.
|
|
51
|
-
- Multi-entry relationships.
|
|
52
|
-
- Reusable blocks and sections.
|
|
53
|
-
- Content, schema, and asset export/import.
|
|
54
|
-
- Document hierarchy, tables of contents, and site search.
|
|
55
|
-
- SEO metadata, canonical URLs, sitemaps, feeds, and redirect management.
|
|
56
|
-
- Stronger asset organization and image editing.
|
|
57
|
-
- Form spam protection and improved submission workflows.
|
|
58
|
-
- Tested backup and restore commands.
|
|
3
|
+
Plum's roadmap is organized by outcomes rather than promised dates. Priorities
|
|
4
|
+
move as the product is exercised in real Rails applications.
|
|
59
5
|
|
|
60
|
-
##
|
|
6
|
+
## Shipped: Plum 0.2.0 — Authoring Foundation
|
|
61
7
|
|
|
62
|
-
|
|
63
|
-
adopt repeatedly and extend publicly.
|
|
8
|
+
Plum 0.2.0 established the practical authoring surface:
|
|
64
9
|
|
|
65
|
-
-
|
|
66
|
-
and
|
|
67
|
-
-
|
|
68
|
-
|
|
69
|
-
-
|
|
10
|
+
- a visual blueprint builder with 21 field types, reusable fieldsets, nested
|
|
11
|
+
structures, validation, field widths, sections, and conditions;
|
|
12
|
+
- assets with single and multiple image fields, metadata, focal points, and
|
|
13
|
+
responsive variants;
|
|
14
|
+
- relationships, taxonomies, navigation, globals, forms, and Liquid themes;
|
|
15
|
+
- drafts, scheduled publishing, immutable revisions, and rollback;
|
|
16
|
+
- localized entries, locale-aware public routes, and a live-only content API;
|
|
17
|
+
- registry-backed custom field types and packaged Tailwind control-panel styles.
|
|
18
|
+
|
|
19
|
+
See [the Statamic parity matrix](statamic-parity.md) for the supported surface.
|
|
20
|
+
|
|
21
|
+
## Now: Plum 0.3.0 — Production Readiness
|
|
22
|
+
|
|
23
|
+
The 0.3.0 milestone makes Plum safe to adopt, move, recover, and operate outside
|
|
24
|
+
its development repository.
|
|
25
|
+
|
|
26
|
+
### 1. Portability and recovery
|
|
27
|
+
|
|
28
|
+
- Versioned site export/import covering schemas, entries, relationships,
|
|
29
|
+
taxonomies, navigation, globals, forms, settings, and assets.
|
|
30
|
+
- Tested backup and restore commands with integrity checks and clear failure
|
|
31
|
+
behavior.
|
|
32
|
+
- A documented archive format that remains readable across compatible releases.
|
|
33
|
+
|
|
34
|
+
### 2. Real-world installation
|
|
35
|
+
|
|
36
|
+
- Build `plumcms.org` as a standalone Rails application using the released gem.
|
|
37
|
+
- Test fresh external installations and 0.2.x upgrades in CI.
|
|
38
|
+
- Publish a production Docker contract and SQLite deployment example.
|
|
39
|
+
- Exercise health checks, upgrades, persistence, backup, and restore on a VM.
|
|
40
|
+
|
|
41
|
+
### 3. Publishing and discovery
|
|
42
|
+
|
|
43
|
+
- SEO metadata, canonical URLs, redirects, XML sitemaps, and feeds.
|
|
44
|
+
- Document hierarchy, breadcrumbs, generated tables of contents, and site search.
|
|
45
|
+
- Secure, shareable preview links for draft and scheduled content.
|
|
46
|
+
|
|
47
|
+
### 4. Editorial operations
|
|
48
|
+
|
|
49
|
+
- Asset folders, search, replacement, cropping, and richer transformations.
|
|
50
|
+
- Granular editorial roles, approvals, and publish permissions.
|
|
51
|
+
- Form spam protection and improved submission review/export workflows.
|
|
52
|
+
|
|
53
|
+
### 5. Documentation
|
|
54
|
+
|
|
55
|
+
- Complete installation and first-page guides.
|
|
56
|
+
- Document every supported field and content primitive.
|
|
57
|
+
- Document standalone SQLite and embedded PostgreSQL operation.
|
|
58
|
+
- Publish configuration, troubleshooting, extension, upgrade, backup, and
|
|
59
|
+
restore references.
|
|
60
|
+
|
|
61
|
+
## Next: Plum 0.4.0 — Repeatable Adoption
|
|
62
|
+
|
|
63
|
+
- A thin `plum` CLI for diagnostics, export/import, backup/restore, upgrades,
|
|
64
|
+
themes, and packaging.
|
|
65
|
+
- Starter applications for common publishing use cases.
|
|
70
66
|
- Theme scaffolding, validation, packaging, and distribution tools.
|
|
67
|
+
- A collection of high-quality open themes and blocks.
|
|
71
68
|
- Importers for common CMS and structured-data formats.
|
|
72
|
-
-
|
|
69
|
+
- Addon discovery and a documented compatibility contract.
|
|
70
|
+
|
|
71
|
+
## Later: The Rails Content Ecosystem
|
|
72
|
+
|
|
73
|
+
- Translation-service integrations and richer localization workflows.
|
|
74
|
+
- Agency-oriented multisite operations and reusable project recipes.
|
|
73
75
|
- Community examples, case studies, talks, and contribution programs.
|
|
76
|
+
- Broader content APIs where real applications demonstrate the need.
|
|
74
77
|
|
|
75
78
|
## Plum 1.0
|
|
76
79
|
|
|
@@ -92,7 +95,6 @@ It requires:
|
|
|
92
95
|
|
|
93
96
|
## Not on the Near-Term Roadmap
|
|
94
97
|
|
|
95
|
-
Plum is not
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
belongs inside Rails.
|
|
98
|
+
Plum is not trying to become a general-purpose admin framework, a freeform
|
|
99
|
+
visual design tool, an ecommerce platform, or a hosted headless CMS. Plum's
|
|
100
|
+
focus is managed content that belongs inside Rails.
|
data/lib/plum/engine.rb
CHANGED
|
@@ -8,7 +8,9 @@ module Plum
|
|
|
8
8
|
|
|
9
9
|
initializer "plum.assets" do |app|
|
|
10
10
|
app.config.assets.paths << root.join("app/assets/javascripts")
|
|
11
|
-
|
|
11
|
+
# Importmap exposes these files as `plum/*`, so Propshaft must resolve
|
|
12
|
+
# them relative to the parent controllers directory.
|
|
13
|
+
app.config.assets.paths << root.join("app/javascript/controllers")
|
|
12
14
|
app.config.assets.paths << root.join("vendor/javascript")
|
|
13
15
|
|
|
14
16
|
lexxy_spec = Gem.loaded_specs["lexxy"]
|
data/lib/plum/version.rb
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
namespace :plum do
|
|
2
|
+
namespace :site do
|
|
3
|
+
desc "Export a complete Plum site archive (SITE_ID or first site; ARCHIVE required)"
|
|
4
|
+
task export: :environment do
|
|
5
|
+
path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
|
|
6
|
+
site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
|
|
7
|
+
abort "No Plum site exists" unless site
|
|
8
|
+
|
|
9
|
+
archive = Plum::SiteArchive.dump(site: site, path: path)
|
|
10
|
+
puts "Exported #{site.name} to #{archive}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "Import a Plum site archive as a new site (ARCHIVE required; NAME and DOMAIN optional)"
|
|
14
|
+
task import: :environment do
|
|
15
|
+
path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
|
|
16
|
+
site = Plum::SiteArchive.load(path: path, name: ENV["NAME"], domain: ENV["DOMAIN"])
|
|
17
|
+
puts "Imported #{site.name} as site #{site.id}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
namespace :backup do
|
|
22
|
+
desc "Create a timestamped Plum site backup (SITE_ID optional; DIRECTORY defaults to backups/plum)"
|
|
23
|
+
task create: :environment do
|
|
24
|
+
site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
|
|
25
|
+
abort "No Plum site exists" unless site
|
|
26
|
+
directory = Rails.root.join(ENV.fetch("DIRECTORY", "backups/plum"))
|
|
27
|
+
filename = "plum-site-#{site.id}-#{Time.current.utc.strftime('%Y%m%d%H%M%S')}.plum.zip"
|
|
28
|
+
archive = Plum::SiteArchive.dump(site: site, path: directory.join(filename))
|
|
29
|
+
puts "Backed up #{site.name} to #{archive}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc "Restore a Plum backup as a new site (ARCHIVE required; NAME and DOMAIN optional)"
|
|
33
|
+
task restore: :environment do
|
|
34
|
+
Rake::Task["plum:site:import"].invoke
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: plum-cms
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ben Simmons
|
|
@@ -167,14 +167,14 @@ dependencies:
|
|
|
167
167
|
requirements:
|
|
168
168
|
- - "~>"
|
|
169
169
|
- !ruby/object:Gem::Version
|
|
170
|
-
version: '0.
|
|
170
|
+
version: '0.9'
|
|
171
171
|
type: :runtime
|
|
172
172
|
prerelease: false
|
|
173
173
|
version_requirements: !ruby/object:Gem::Requirement
|
|
174
174
|
requirements:
|
|
175
175
|
- - "~>"
|
|
176
176
|
- !ruby/object:Gem::Version
|
|
177
|
-
version: '0.
|
|
177
|
+
version: '0.9'
|
|
178
178
|
- !ruby/object:Gem::Dependency
|
|
179
179
|
name: image_processing
|
|
180
180
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -276,6 +276,7 @@ files:
|
|
|
276
276
|
- app/services/plum/liquid_context.rb
|
|
277
277
|
- app/services/plum/liquid_filters.rb
|
|
278
278
|
- app/services/plum/liquid_renderer.rb
|
|
279
|
+
- app/services/plum/site_archive.rb
|
|
279
280
|
- app/services/plum/theme.rb
|
|
280
281
|
- app/services/plum/theme_asset_path.rb
|
|
281
282
|
- app/services/plum/theme_package_installer.rb
|
|
@@ -383,6 +384,7 @@ files:
|
|
|
383
384
|
- db/engine_migrate/20260807160000_add_localization_to_plum_entries.rb
|
|
384
385
|
- docs/blueprint-fields.md
|
|
385
386
|
- docs/extensions.md
|
|
387
|
+
- docs/portability.md
|
|
386
388
|
- docs/product-principles.md
|
|
387
389
|
- docs/roadmap.md
|
|
388
390
|
- docs/site/homepage.md
|
|
@@ -399,6 +401,7 @@ files:
|
|
|
399
401
|
- lib/plum/engine.rb
|
|
400
402
|
- lib/plum/liquid_tags/form_tag.rb
|
|
401
403
|
- lib/plum/version.rb
|
|
404
|
+
- lib/tasks/plum_portability.rake
|
|
402
405
|
- lib/tasks/plum_styles.rake
|
|
403
406
|
- vendor/javascript/lexxy.js
|
|
404
407
|
homepage: https://plumcms.org
|