action_mosaico 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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