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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0c045923ca6053faa960068411e9c23cb65653738a29644caae57f592700eb0
4
- data.tar.gz: 3bdab444dc9ca623c1207f9736e4a65d5e34b43a8a3d8d39bf1f67472884b0ac
3
+ metadata.gz: 277d5241551a05b136eebf29f83e6347c7fc15a9d600054c524a245ab18010b4
4
+ data.tar.gz: 336177aba310b7b33b4fddab22190b0ac3a1fec3ad330fb1c09552459ffc8def
5
5
  SHA512:
6
- metadata.gz: 3b12a09d3b0af6237fd8fe397101d30e35aaa92fcc48279b140b1ab37fa1279c4cb8c6f234a190fc08078be6d534257677de5d7d774507bf42cc403de78e3b25
7
- data.tar.gz: 83f896aa5c05d595d80b97ea9ca17b445dbb51d82ab7925efcbdd2f43f3a93ae4169e7072592255f5d57e6640a6dfc4886e3c2a2773cb01ded724bdd4bb8e1c6
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
@@ -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. Items move
4
- as the product is exercised in real applications and the Rails community gives
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
- ## Later: The Rails Content Ecosystem
6
+ ## Shipped: Plum 0.2.0 Authoring Foundation
61
7
 
62
- After installation and production operation are dependable, make Plum easier to
63
- adopt repeatedly and extend publicly.
8
+ Plum 0.2.0 established the practical authoring surface:
64
9
 
65
- - A thin `plum` CLI for new sites, diagnostics, themes, export/import, upgrades,
66
- and packaging.
67
- - Starter applications for common content-site use cases.
68
- - A documented extension contract for fields, blocks, and content sources.
69
- - A collection of high-quality open themes and blocks.
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
- - Optional content APIs for applications that genuinely need them.
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 currently trying to become a general-purpose admin framework, a
96
- freeform visual design tool, an ecommerce platform, or a hosted headless CMS.
97
- Those products solve different problems. Plum's focus is managed content that
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
- app.config.assets.paths << root.join("app/javascript/controllers/plum")
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
@@ -1,6 +1,6 @@
1
1
  module Plum
2
2
  module Version
3
- STRING = "0.2.0"
3
+ STRING = "0.2.1"
4
4
  end
5
5
 
6
6
  VERSION = Version::STRING
@@ -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.0
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.1'
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.1'
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