macron-cms 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +338 -25
  3. data/app/assets/stylesheets/macron/cms/application.css +1 -1
  4. data/app/controllers/concerns/macron/cms/api_serialization.rb +79 -0
  5. data/app/controllers/concerns/macron/cms/slug_redirectable.rb +27 -0
  6. data/app/controllers/macron/cms/api/v1/article_contents_controller.rb +221 -0
  7. data/app/controllers/macron/cms/api/v1/articles_controller.rb +140 -66
  8. data/app/controllers/macron/cms/api/v1/content_types_controller.rb +3 -1
  9. data/app/controllers/macron/cms/article_contents_controller.rb +44 -10
  10. data/app/controllers/macron/cms/articles_controller.rb +4 -3
  11. data/app/controllers/macron/cms/content_types_controller.rb +2 -0
  12. data/app/controllers/macron/cms/dashboard_controller.rb +1 -1
  13. data/app/helpers/macron/cms/content_helper.rb +1 -1
  14. data/app/helpers/macron/cms/fields_helper.rb +14 -0
  15. data/app/models/macron/cms/article.rb +45 -15
  16. data/app/models/macron/cms/article_content.rb +24 -0
  17. data/app/models/macron/cms/content.rb +27 -0
  18. data/app/services/macron/cms/inline_asset_attacher.rb +70 -0
  19. data/app/services/macron/cms/remote_asset_attacher.rb +16 -6
  20. data/app/views/macron/cms/articles/_content_blocks_section.html.erb +494 -199
  21. data/app/views/macron/cms/articles/_form.html.erb +10 -2
  22. data/app/views/macron/cms/articles/_table.html.erb +3 -3
  23. data/app/views/macron/cms/articles/show.html.erb +3 -1
  24. data/app/views/macron/cms/content_types/index.html.erb +30 -0
  25. data/app/views/macron/cms/content_types/show.html.erb +34 -0
  26. data/config/routes.rb +12 -1
  27. data/db/migrate/20261029000001_add_convertible_to_to_macron_cms_contents.rb +5 -0
  28. data/db/migrate/20261029120001_add_featured_image_to_macron_cms_contents.rb +5 -0
  29. data/db/migrate/20261029120002_add_featured_image_blob_to_macron_cms_articles.rb +8 -0
  30. data/db/migrate/20261029140000_create_friendly_id_slugs.rb +26 -0
  31. data/db/migrate/20261029150000_remove_name_from_macron_cms_articles.rb +6 -0
  32. data/db/migrate/20261029160000_add_published_at_to_macron_cms_articles.rb +14 -0
  33. data/db/seeds.rb +3 -2
  34. data/lib/macron/cms/version.rb +1 -1
  35. data/lib/tasks/macron/cms_tasks.rake +6 -0
  36. metadata +11 -1
@@ -6,6 +6,8 @@ module Macron
6
6
  has_many :article_contents, class_name: "Macron::Cms::ArticleContent", dependent: :destroy
7
7
  has_many :articles, through: :article_contents
8
8
 
9
+ scope :featured_image_capable, -> { where(featured_image: true) }
10
+
9
11
  def normalized_fields
10
12
  raw = if fields.is_a?(Array)
11
13
  fields
@@ -29,6 +31,31 @@ module Macron
29
31
  def field_names
30
32
  normalized_fields.map { |f| f["name"] }
31
33
  end
34
+
35
+ def normalized_convertible_to
36
+ Array(convertible_to).filter_map do |entry|
37
+ target_name = entry.to_s.strip
38
+ target_name if target_name.present? && target_name != name
39
+ end.uniq
40
+ end
41
+
42
+ def convertible_to?(target)
43
+ target_name = target.is_a?(Content) ? target.name : target.to_s.strip
44
+ normalized_convertible_to.include?(target_name)
45
+ end
46
+
47
+ def convertible_targets(available_contents = nil)
48
+ names = normalized_convertible_to
49
+ return [] if names.empty?
50
+
51
+ indexed = if available_contents
52
+ available_contents.index_by(&:name)
53
+ else
54
+ Content.where(name: names).index_by(&:name)
55
+ end
56
+
57
+ names.filter_map { |n| indexed[n] }
58
+ end
32
59
  end
33
60
  end
34
61
  end
@@ -0,0 +1,70 @@
1
+ require "base64"
2
+ require "stringio"
3
+ require "securerandom"
4
+
5
+ module Macron
6
+ module Cms
7
+ class InlineAssetAttacher
8
+ MAX_FILE_SIZE = 25.megabytes
9
+
10
+ def self.attach(article_content:, upload_data:, replace: true)
11
+ new(record: article_content, association: :files, upload_data: upload_data, replace: replace).call
12
+ end
13
+
14
+ def self.attach_background(article:, upload_data:, replace: true)
15
+ new(record: article, association: :background_image, upload_data: upload_data, replace: replace).call
16
+ end
17
+
18
+ def initialize(record:, association:, upload_data:, replace: true)
19
+ @record = record
20
+ @association = association
21
+ @upload_data = upload_data.respond_to?(:to_unsafe_h) ? upload_data.to_unsafe_h : upload_data.to_h
22
+ @replace = replace
23
+ end
24
+
25
+ def call
26
+ return false if @upload_data.blank?
27
+
28
+ data_str = (@upload_data["data_base64"] || @upload_data[:data_base64] || @upload_data["data"] || @upload_data[:data]).to_s.strip
29
+ return false if data_str.blank?
30
+
31
+ # Strip optional data URI scheme prefix: data:image/png;base64,...
32
+ raw_base64 = data_str.sub(/\Adata:[^;]+;base64,/, "")
33
+ binary_data = Base64.decode64(raw_base64)
34
+
35
+ if binary_data.bytesize > MAX_FILE_SIZE
36
+ raise ArgumentError, "Uploaded file exceeds maximum size of #{MAX_FILE_SIZE} bytes"
37
+ end
38
+
39
+ filename = (@upload_data["filename"] || @upload_data[:filename]).to_s.strip
40
+ content_type = (@upload_data["content_type"] || @upload_data[:content_type]).to_s.strip
41
+
42
+ if filename.blank?
43
+ ext = Rack::Mime::MIME_TYPES.invert[content_type] || ".bin"
44
+ filename = "upload_#{SecureRandom.hex(6)}#{ext}"
45
+ end
46
+ content_type = "application/octet-stream" if content_type.blank?
47
+
48
+ io = StringIO.new(binary_data)
49
+
50
+ target_assoc = @record.public_send(@association)
51
+
52
+ if @replace
53
+ if target_assoc.respond_to?(:purge) && target_assoc.attached?
54
+ target_assoc.purge
55
+ elsif target_assoc.respond_to?(:attached?) && target_assoc.attached?
56
+ target_assoc.purge
57
+ end
58
+ end
59
+
60
+ target_assoc.attach(
61
+ io: io,
62
+ filename: filename,
63
+ content_type: content_type
64
+ )
65
+
66
+ true
67
+ end
68
+ end
69
+ end
70
+ end
@@ -9,11 +9,17 @@ module Macron
9
9
  OPEN_TIMEOUT = 5
10
10
 
11
11
  def self.attach(article_content:, url:, replace: true)
12
- new(article_content: article_content, url: url, replace: replace).call
12
+ new(record: article_content, association: :files, url: url, replace: replace).call
13
13
  end
14
14
 
15
- def initialize(article_content:, url:, replace: true)
16
- @article_content = article_content
15
+ def self.attach_background(article:, url:, replace: true)
16
+ new(record: article, association: :background_image, url: url, replace: replace).call
17
+ end
18
+
19
+ def initialize(record: nil, association: :files, article_content: nil, url:, replace: true)
20
+ @record = record || article_content
21
+ @association = association
22
+ @article_content = article_content || (@record.is_a?(ArticleContent) ? @record : nil)
17
23
  @url = url.to_s.strip
18
24
  @replace = replace
19
25
  end
@@ -51,13 +57,15 @@ module Macron
51
57
  filename = derive_filename(uri, downloaded_io)
52
58
  content_type = downloaded_io.content_type || "application/octet-stream"
53
59
 
54
- if @replace && @article_content.files.attached?
55
- @article_content.files.purge
60
+ target_assoc = @record.public_send(@association)
61
+
62
+ if @replace && target_assoc.respond_to?(:attached?) && target_assoc.attached?
63
+ target_assoc.purge
56
64
  end
57
65
 
58
66
  downloaded_io.rewind if downloaded_io.respond_to?(:rewind)
59
67
 
60
- @article_content.files.attach(
68
+ target_assoc.attach(
61
69
  io: downloaded_io,
62
70
  filename: filename,
63
71
  content_type: content_type
@@ -77,6 +85,8 @@ module Macron
77
85
  end
78
86
 
79
87
  def fallback_to_payload_url
88
+ return unless @article_content
89
+
80
90
  current_payload = (@article_content.payload || {}).dup
81
91
  current_payload["upload"] = @url
82
92
  @article_content.payload = current_payload