action_mosaico 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +6 -0
  3. data/.ruby-gemset +1 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +84 -0
  7. data/Gemfile +19 -0
  8. data/Gemfile.lock +114 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +45 -0
  11. data/Rakefile +16 -0
  12. data/action_mosaico.gemspec +45 -0
  13. data/app/assets/javascripts/action_mosaico.js +0 -0
  14. data/app/assets/stylesheets/action_mosaico.css +0 -0
  15. data/app/helpers/action_mosaico/content_helper.rb +51 -0
  16. data/app/helpers/action_mosaico/tag_helper.rb +95 -0
  17. data/app/javascript/action_mosaico/attachment_upload.js +45 -0
  18. data/app/javascript/action_mosaico/index.js +10 -0
  19. data/app/models/action_mosaico/encrypted_rich_text.rb +9 -0
  20. data/app/models/action_mosaico/record.rb +9 -0
  21. data/app/models/action_mosaico/rich_text.rb +33 -0
  22. data/app/views/action_text/attachables/_missing_attachable.html.erb +1 -0
  23. data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
  24. data/app/views/action_text/attachment_galleries/_attachment_gallery.html.erb +3 -0
  25. data/app/views/action_text/contents/_content.html.erb +1 -0
  26. data/app/views/active_storage/blobs/_blob.html.erb +14 -0
  27. data/app/views/layouts/action_text/contents/_content.html.erb +3 -0
  28. data/bin/console +15 -0
  29. data/bin/setup +8 -0
  30. data/db/migrate/20180528164100_create_action_mosaico_tables.rb +28 -0
  31. data/lib/action_mosaico/attachable.rb +91 -0
  32. data/lib/action_mosaico/attachables/content_attachment.rb +37 -0
  33. data/lib/action_mosaico/attachables/missing_attachable.rb +13 -0
  34. data/lib/action_mosaico/attachables/remote_image.rb +45 -0
  35. data/lib/action_mosaico/attachment.rb +109 -0
  36. data/lib/action_mosaico/attachment_gallery.rb +70 -0
  37. data/lib/action_mosaico/attachments/caching.rb +17 -0
  38. data/lib/action_mosaico/attachments/minification.rb +17 -0
  39. data/lib/action_mosaico/attachments/mosaico_conversion.rb +37 -0
  40. data/lib/action_mosaico/attribute.rb +66 -0
  41. data/lib/action_mosaico/content.rb +132 -0
  42. data/lib/action_mosaico/encryption.rb +39 -0
  43. data/lib/action_mosaico/engine.rb +86 -0
  44. data/lib/action_mosaico/fixture_set.rb +61 -0
  45. data/lib/action_mosaico/fragment.rb +57 -0
  46. data/lib/action_mosaico/html_conversion.rb +25 -0
  47. data/lib/action_mosaico/mosaico_attachment.rb +95 -0
  48. data/lib/action_mosaico/plain_text_conversion.rb +84 -0
  49. data/lib/action_mosaico/rendering.rb +30 -0
  50. data/lib/action_mosaico/serialization.rb +36 -0
  51. data/lib/action_mosaico/system_test_helper.rb +55 -0
  52. data/lib/action_mosaico/version.rb +5 -0
  53. data/lib/action_mosaico.rb +40 -0
  54. data/lib/generators/action_mosaico/install/install_generator.rb +60 -0
  55. data/lib/generators/action_mosaico/install/templates/actiontext.css +35 -0
  56. data/lib/tasks/action_mosaico.rake +6 -0
  57. data/package.json +32 -0
  58. metadata +172 -0
@@ -0,0 +1,14 @@
1
+ <figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
2
+ <% if blob.representable? %>
3
+ <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
4
+ <% end %>
5
+
6
+ <figcaption class="attachment__caption">
7
+ <% if caption = blob.try(:caption) %>
8
+ <%= caption %>
9
+ <% else %>
10
+ <span class="attachment__name"><%= blob.filename %></span>
11
+ <span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
12
+ <% end %>
13
+ </figcaption>
14
+ </figure>
@@ -0,0 +1,3 @@
1
+ <div class="mosaico-content">
2
+ <%= yield -%>
3
+ </div>
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'action_mosaico'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateActionMosaicoTables < ActiveRecord::Migration[7.0]
4
+ def change
5
+ # Use Active Record's configured type for primary and foreign keys
6
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
7
+
8
+ create_table :action_mosaico_rich_texts, id: primary_key_type do |t|
9
+ t.string :name, null: false
10
+ t.text :body, size: :long
11
+ t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
12
+
13
+ t.timestamps
14
+
15
+ t.index %i[record_type record_id name], name: 'index_action_mosaico_rich_texts_uniqueness', unique: true
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def primary_and_foreign_key_types
22
+ config = Rails.configuration.generators
23
+ setting = config.options[config.orm][:primary_key_type]
24
+ primary_key_type = setting || :primary_key
25
+ foreign_key_type = setting || :bigint
26
+ [primary_key_type, foreign_key_type]
27
+ end
28
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMosaico
4
+ module Attachable
5
+ extend ActiveSupport::Concern
6
+
7
+ LOCATOR_NAME = 'attachable'
8
+
9
+ class << self
10
+ def from_node(node)
11
+ if attachable = attachable_from_sgid(node['sgid'])
12
+ attachable
13
+ elsif attachable = ActionMosaico::Attachables::ContentAttachment.from_node(node)
14
+ attachable
15
+ elsif attachable = ActionMosaico::Attachables::RemoteImage.from_node(node)
16
+ attachable
17
+ else
18
+ ActionMosaico::Attachables::MissingAttachable
19
+ end
20
+ end
21
+
22
+ def from_attachable_sgid(sgid, options = {})
23
+ method = sgid.is_a?(Array) ? :locate_many_signed : :locate_signed
24
+ record = GlobalID::Locator.public_send(method, sgid, options.merge(for: LOCATOR_NAME))
25
+ record || raise(ActiveRecord::RecordNotFound)
26
+ end
27
+
28
+ private
29
+
30
+ def attachable_from_sgid(sgid)
31
+ from_attachable_sgid(sgid)
32
+ rescue ActiveRecord::RecordNotFound
33
+ nil
34
+ end
35
+ end
36
+
37
+ class_methods do
38
+ def from_attachable_sgid(sgid)
39
+ ActionMosaico::Attachable.from_attachable_sgid(sgid, only: self)
40
+ end
41
+ end
42
+
43
+ def attachable_sgid
44
+ to_sgid(expires_in: nil, for: LOCATOR_NAME).to_s
45
+ end
46
+
47
+ def attachable_content_type
48
+ try(:content_type) || 'application/octet-stream'
49
+ end
50
+
51
+ def attachable_filename
52
+ filename.to_s if respond_to?(:filename)
53
+ end
54
+
55
+ def attachable_filesize
56
+ try(:byte_size) || try(:filesize)
57
+ end
58
+
59
+ def attachable_metadata
60
+ try(:metadata) || {}
61
+ end
62
+
63
+ def previewable_attachable?
64
+ false
65
+ end
66
+
67
+ def as_json(*)
68
+ super.merge(attachable_sgid: attachable_sgid)
69
+ end
70
+
71
+ def to_mosaico_content_attachment_partial_path
72
+ to_partial_path
73
+ end
74
+
75
+ def to_attachable_partial_path
76
+ to_partial_path
77
+ end
78
+
79
+ def to_rich_text_attributes(attributes = {})
80
+ attributes.dup.tap do |attrs|
81
+ attrs[:sgid] = attachable_sgid
82
+ attrs[:content_type] = attachable_content_type
83
+ attrs[:previewable] = true if previewable_attachable?
84
+ attrs[:filename] = attachable_filename
85
+ attrs[:filesize] = attachable_filesize
86
+ attrs[:width] = attachable_metadata[:width]
87
+ attrs[:height] = attachable_metadata[:height]
88
+ end.compact
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMosaico
4
+ module Attachables
5
+ class ContentAttachment
6
+ include ActiveModel::Model
7
+
8
+ def self.from_node(node)
9
+ if node['content-type'] && matches = node['content-type'].match(/vnd\.rubyonrails\.(.+)\.html/)
10
+ attachment = new(name: matches[1])
11
+ attachment if attachment.valid?
12
+ end
13
+ end
14
+
15
+ attr_accessor :name
16
+
17
+ validates_inclusion_of :name, in: %w[horizontal-rule]
18
+
19
+ def attachable_plain_text_representation(_caption)
20
+ case name
21
+ when 'horizontal-rule'
22
+ ' ┄ '
23
+ else
24
+ ' '
25
+ end
26
+ end
27
+
28
+ def to_partial_path
29
+ 'action_mosaico/attachables/content_attachment'
30
+ end
31
+
32
+ def to_mosaico_content_attachment_partial_path
33
+ "action_mosaico/attachables/content_attachments/#{name.underscore}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMosaico
4
+ module Attachables
5
+ module MissingAttachable
6
+ extend ActiveModel::Naming
7
+
8
+ def self.to_partial_path
9
+ 'action_mosaico/attachables/missing_attachable'
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMosaico
4
+ module Attachables
5
+ class RemoteImage
6
+ extend ActiveModel::Naming
7
+
8
+ class << self
9
+ def from_node(node)
10
+ new(attributes_from_node(node)) if node['url'] && content_type_is_image?(node['content-type'])
11
+ end
12
+
13
+ private
14
+
15
+ def content_type_is_image?(content_type)
16
+ content_type.to_s.match?(%r{^image(/.+|$)})
17
+ end
18
+
19
+ def attributes_from_node(node)
20
+ { url: node['url'],
21
+ content_type: node['content-type'],
22
+ width: node['width'],
23
+ height: node['height'] }
24
+ end
25
+ end
26
+
27
+ attr_reader :url, :content_type, :width, :height
28
+
29
+ def initialize(attributes = {})
30
+ @url = attributes[:url]
31
+ @content_type = attributes[:content_type]
32
+ @width = attributes[:width]
33
+ @height = attributes[:height]
34
+ end
35
+
36
+ def attachable_plain_text_representation(caption)
37
+ "[#{caption || 'Image'}]"
38
+ end
39
+
40
+ def to_partial_path
41
+ 'action_mosaico/attachables/remote_image'
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/try'
4
+
5
+ module ActionMosaico
6
+ class Attachment
7
+ include Attachments::Caching
8
+ include Attachments::Minification
9
+ include Attachments::TrixConversion
10
+
11
+ mattr_accessor :tag_name, default: 'action-text-attachment'
12
+
13
+ ATTRIBUTES = %w[sgid content-type url href filename filesize width height previewable presentation caption].freeze
14
+
15
+ class << self
16
+ def fragment_by_canonicalizing_attachments(content)
17
+ fragment_by_minifying_attachments(fragment_by_converting_mosaico_attachments(content))
18
+ end
19
+
20
+ def from_node(node, attachable = nil)
21
+ new(node, attachable || ActionMosaico::Attachable.from_node(node))
22
+ end
23
+
24
+ def from_attachables(attachables)
25
+ Array(attachables).filter_map { |attachable| from_attachable(attachable) }
26
+ end
27
+
28
+ def from_attachable(attachable, attributes = {})
29
+ if node = node_from_attributes(attachable.to_rich_text_attributes(attributes))
30
+ new(node, attachable)
31
+ end
32
+ end
33
+
34
+ def from_attributes(attributes, attachable = nil)
35
+ if node = node_from_attributes(attributes)
36
+ from_node(node, attachable)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def node_from_attributes(attributes)
43
+ if attributes = process_attributes(attributes).presence
44
+ ActionMosaico::HtmlConversion.create_element(tag_name, attributes)
45
+ end
46
+ end
47
+
48
+ def process_attributes(attributes)
49
+ attributes.transform_keys { |key| key.to_s.underscore.dasherize }.slice(*ATTRIBUTES)
50
+ end
51
+ end
52
+
53
+ attr_reader :node, :attachable
54
+
55
+ delegate :to_param, to: :attachable
56
+ delegate_missing_to :attachable
57
+
58
+ def initialize(node, attachable)
59
+ @node = node
60
+ @attachable = attachable
61
+ end
62
+
63
+ def caption
64
+ node_attributes['caption'].presence
65
+ end
66
+
67
+ def full_attributes
68
+ node_attributes.merge(attachable_attributes).merge(sgid_attributes)
69
+ end
70
+
71
+ def with_full_attributes
72
+ self.class.from_attributes(full_attributes, attachable)
73
+ end
74
+
75
+ def to_plain_text
76
+ if respond_to?(:attachable_plain_text_representation)
77
+ attachable_plain_text_representation(caption)
78
+ else
79
+ caption.to_s
80
+ end
81
+ end
82
+
83
+ def to_html
84
+ HtmlConversion.node_to_html(node)
85
+ end
86
+
87
+ def to_s
88
+ to_html
89
+ end
90
+
91
+ def inspect
92
+ "#<#{self.class.name} attachable=#{attachable.inspect}>"
93
+ end
94
+
95
+ private
96
+
97
+ def node_attributes
98
+ @node_attributes ||= ATTRIBUTES.map { |name| [name.underscore, node[name]] }.to_h.compact
99
+ end
100
+
101
+ def attachable_attributes
102
+ @attachable_attributes ||= (attachable.try(:to_rich_text_attributes) || {}).stringify_keys
103
+ end
104
+
105
+ def sgid_attributes
106
+ @sgid_attributes ||= node_attributes.slice('sgid').presence || attachable_attributes.slice('sgid')
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMosaico
4
+ class AttachmentGallery
5
+ include ActiveModel::Model
6
+
7
+ TAG_NAME = 'div'
8
+ private_constant :TAG_NAME
9
+
10
+ class << self
11
+ def fragment_by_canonicalizing_attachment_galleries(content)
12
+ fragment_by_replacing_attachment_gallery_nodes(content) do |node|
13
+ "<#{TAG_NAME}>#{node.inner_html}</#{TAG_NAME}>"
14
+ end
15
+ end
16
+
17
+ def fragment_by_replacing_attachment_gallery_nodes(content)
18
+ Fragment.wrap(content).update do |source|
19
+ find_attachment_gallery_nodes(source).each do |node|
20
+ node.replace(yield(node).to_s)
21
+ end
22
+ end
23
+ end
24
+
25
+ def find_attachment_gallery_nodes(content)
26
+ Fragment.wrap(content).find_all(selector).select do |node|
27
+ node.children.all? do |child|
28
+ if child.text?
29
+ /\A(\n|\ )*\z/.match?(child.text)
30
+ else
31
+ child.matches? attachment_selector
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ def from_node(node)
38
+ new(node)
39
+ end
40
+
41
+ def attachment_selector
42
+ "#{ActionMosaico::Attachment.tag_name}[presentation=gallery]"
43
+ end
44
+
45
+ def selector
46
+ "#{TAG_NAME}:has(#{attachment_selector} + #{attachment_selector})"
47
+ end
48
+ end
49
+
50
+ attr_reader :node
51
+
52
+ def initialize(node)
53
+ @node = node
54
+ end
55
+
56
+ def attachments
57
+ @attachments ||= node.css(ActionMosaico::AttachmentGallery.attachment_selector).map do |node|
58
+ ActionMosaico::Attachment.from_node(node).with_full_attributes
59
+ end
60
+ end
61
+
62
+ def size
63
+ attachments.size
64
+ end
65
+
66
+ def inspect
67
+ "#<#{self.class.name} size=#{size.inspect}>"
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMosaico
4
+ module Attachments
5
+ module Caching
6
+ def cache_key(*args)
7
+ [self.class.name, cache_digest, *attachable.cache_key(*args)].join('/')
8
+ end
9
+
10
+ private
11
+
12
+ def cache_digest
13
+ OpenSSL::Digest::SHA256.hexdigest(node.to_s)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMosaico
4
+ module Attachments
5
+ module Minification
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def fragment_by_minifying_attachments(content)
10
+ Fragment.wrap(content).replace(ActionMosaico::Attachment.tag_name) do |node|
11
+ node.tap { |n| n.inner_html = '' }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/try'
4
+
5
+ module ActionMosaico
6
+ module Attachments
7
+ module TrixConversion
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ def fragment_by_converting_mosaico_attachments(content)
12
+ Fragment.wrap(content).replace(TrixAttachment::SELECTOR) do |node|
13
+ from_mosaico_attachment(TrixAttachment.new(node))
14
+ end
15
+ end
16
+
17
+ def from_mosaico_attachment(mosaico_attachment)
18
+ from_attributes(mosaico_attachment.attributes)
19
+ end
20
+ end
21
+
22
+ def to_mosaico_attachment(content = mosaico_attachment_content)
23
+ attributes = full_attributes.dup
24
+ attributes['content'] = content if content
25
+ TrixAttachment.from_attributes(attributes)
26
+ end
27
+
28
+ private
29
+
30
+ def mosaico_attachment_content
31
+ if partial_path = attachable.try(:to_mosaico_content_attachment_partial_path)
32
+ ActionMosaico::Content.render(partial: partial_path, formats: :html, object: self, as: model_name.element)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMosaico
4
+ module Attribute
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ # Provides access to a dependent RichText model that holds the body and attachments for a single named rich text attribute.
9
+ # This dependent attribute is lazily instantiated and will be auto-saved when it's been changed. Example:
10
+ #
11
+ # class Message < ActiveRecord::Base
12
+ # has_rich_text :content
13
+ # end
14
+ #
15
+ # message = Message.create!(content: "<h1>Funny times!</h1>")
16
+ # message.content? #=> true
17
+ # message.content.to_s # => "<h1>Funny times!</h1>"
18
+ # message.content.to_plain_text # => "Funny times!"
19
+ #
20
+ # The dependent RichText model will also automatically process attachments links as sent via the Trix-powered editor.
21
+ # These attachments are associated with the RichText model using Active Storage.
22
+ #
23
+ # If you wish to preload the dependent RichText model, you can use the named scope:
24
+ #
25
+ # Message.all.with_rich_text_content # Avoids N+1 queries when you just want the body, not the attachments.
26
+ # Message.all.with_rich_text_content_and_embeds # Avoids N+1 queries when you just want the body and attachments.
27
+ # Message.all.with_all_rich_text # Loads all rich text associations.
28
+ #
29
+ # === Options
30
+ #
31
+ # * <tt>:encrypted</tt> - Pass true to encrypt the rich text attribute. The encryption will be non-deterministic. See
32
+ # +ActiveRecord::Encryption::EncryptableRecord.encrypts+. Default: false.
33
+ def has_rich_text(name, encrypted: false)
34
+ class_eval <<-CODE, __FILE__, __LINE__ + 1
35
+ def #{name}
36
+ rich_text_#{name} || build_rich_text_#{name}
37
+ end
38
+
39
+ def #{name}?
40
+ rich_text_#{name}.present?
41
+ end
42
+
43
+ def #{name}=(body)
44
+ self.#{name}.body = body
45
+ end
46
+ CODE
47
+
48
+ rich_text_class_name = encrypted ? 'ActionMosaico::EncryptedRichText' : 'ActionMosaico::RichText'
49
+ has_one :"rich_text_#{name}", -> { where(name: name) },
50
+ class_name: rich_text_class_name, as: :record, inverse_of: :record, autosave: true, dependent: :destroy
51
+
52
+ scope :"with_rich_text_#{name}", -> { includes("rich_text_#{name}") }
53
+ scope :"with_rich_text_#{name}_and_embeds", -> { includes("rich_text_#{name}": { embeds_attachments: :blob }) }
54
+ end
55
+
56
+ # Eager load all dependent RichText models in bulk.
57
+ def with_all_rich_text
58
+ eager_load(rich_text_association_names)
59
+ end
60
+
61
+ def rich_text_association_names
62
+ reflect_on_all_associations(:has_one).collect(&:name).select { |n| n.start_with?('rich_text_') }
63
+ end
64
+ end
65
+ end
66
+ end