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.
- checksums.yaml +7 -0
- data/.rubocop.yml +6 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +19 -0
- data/Gemfile.lock +114 -0
- data/LICENSE.txt +21 -0
- data/README.md +45 -0
- data/Rakefile +16 -0
- data/action_mosaico.gemspec +45 -0
- data/app/assets/javascripts/action_mosaico.js +0 -0
- data/app/assets/stylesheets/action_mosaico.css +0 -0
- data/app/helpers/action_mosaico/content_helper.rb +51 -0
- data/app/helpers/action_mosaico/tag_helper.rb +95 -0
- data/app/javascript/action_mosaico/attachment_upload.js +45 -0
- data/app/javascript/action_mosaico/index.js +10 -0
- data/app/models/action_mosaico/encrypted_rich_text.rb +9 -0
- data/app/models/action_mosaico/record.rb +9 -0
- data/app/models/action_mosaico/rich_text.rb +33 -0
- data/app/views/action_text/attachables/_missing_attachable.html.erb +1 -0
- data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
- data/app/views/action_text/attachment_galleries/_attachment_gallery.html.erb +3 -0
- data/app/views/action_text/contents/_content.html.erb +1 -0
- data/app/views/active_storage/blobs/_blob.html.erb +14 -0
- data/app/views/layouts/action_text/contents/_content.html.erb +3 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/db/migrate/20180528164100_create_action_mosaico_tables.rb +28 -0
- data/lib/action_mosaico/attachable.rb +91 -0
- data/lib/action_mosaico/attachables/content_attachment.rb +37 -0
- data/lib/action_mosaico/attachables/missing_attachable.rb +13 -0
- data/lib/action_mosaico/attachables/remote_image.rb +45 -0
- data/lib/action_mosaico/attachment.rb +109 -0
- data/lib/action_mosaico/attachment_gallery.rb +70 -0
- data/lib/action_mosaico/attachments/caching.rb +17 -0
- data/lib/action_mosaico/attachments/minification.rb +17 -0
- data/lib/action_mosaico/attachments/mosaico_conversion.rb +37 -0
- data/lib/action_mosaico/attribute.rb +66 -0
- data/lib/action_mosaico/content.rb +132 -0
- data/lib/action_mosaico/encryption.rb +39 -0
- data/lib/action_mosaico/engine.rb +86 -0
- data/lib/action_mosaico/fixture_set.rb +61 -0
- data/lib/action_mosaico/fragment.rb +57 -0
- data/lib/action_mosaico/html_conversion.rb +25 -0
- data/lib/action_mosaico/mosaico_attachment.rb +95 -0
- data/lib/action_mosaico/plain_text_conversion.rb +84 -0
- data/lib/action_mosaico/rendering.rb +30 -0
- data/lib/action_mosaico/serialization.rb +36 -0
- data/lib/action_mosaico/system_test_helper.rb +55 -0
- data/lib/action_mosaico/version.rb +5 -0
- data/lib/action_mosaico.rb +40 -0
- data/lib/generators/action_mosaico/install/install_generator.rb +60 -0
- data/lib/generators/action_mosaico/install/templates/actiontext.css +35 -0
- data/lib/tasks/action_mosaico.rake +6 -0
- data/package.json +32 -0
- metadata +172 -0
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMosaico
|
4
|
+
class Content
|
5
|
+
include Serialization
|
6
|
+
include Rendering
|
7
|
+
|
8
|
+
attr_reader :fragment
|
9
|
+
|
10
|
+
delegate :blank?, :empty?, :html_safe, :present?, to: :to_html # Delegating to to_html to avoid including the layout
|
11
|
+
|
12
|
+
class << self
|
13
|
+
def fragment_by_canonicalizing_content(content)
|
14
|
+
fragment = ActionMosaico::Attachment.fragment_by_canonicalizing_attachments(content)
|
15
|
+
ActionMosaico::AttachmentGallery.fragment_by_canonicalizing_attachment_galleries(fragment)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(content = nil, options = {})
|
20
|
+
options.with_defaults! canonicalize: true
|
21
|
+
|
22
|
+
@fragment = if options[:canonicalize]
|
23
|
+
self.class.fragment_by_canonicalizing_content(content)
|
24
|
+
else
|
25
|
+
ActionMosaico::Fragment.wrap(content)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def links
|
30
|
+
@links ||= fragment.find_all('a[href]').map { |a| a['href'] }.uniq
|
31
|
+
end
|
32
|
+
|
33
|
+
def attachments
|
34
|
+
@attachments ||= attachment_nodes.map do |node|
|
35
|
+
attachment_for_node(node)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def attachment_galleries
|
40
|
+
@attachment_galleries ||= attachment_gallery_nodes.map do |node|
|
41
|
+
attachment_gallery_for_node(node)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def gallery_attachments
|
46
|
+
@gallery_attachments ||= attachment_galleries.flat_map(&:attachments)
|
47
|
+
end
|
48
|
+
|
49
|
+
def attachables
|
50
|
+
@attachables ||= attachment_nodes.map do |node|
|
51
|
+
ActionMosaico::Attachable.from_node(node)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def append_attachables(attachables)
|
56
|
+
attachments = ActionMosaico::Attachment.from_attachables(attachables)
|
57
|
+
self.class.new([to_s.presence, *attachments].compact.join("\n"))
|
58
|
+
end
|
59
|
+
|
60
|
+
def render_attachments(**options, &block)
|
61
|
+
content = fragment.replace(ActionMosaico::Attachment.tag_name) do |node|
|
62
|
+
block.call(attachment_for_node(node, **options))
|
63
|
+
end
|
64
|
+
self.class.new(content, canonicalize: false)
|
65
|
+
end
|
66
|
+
|
67
|
+
def render_attachment_galleries(&block)
|
68
|
+
content = ActionMosaico::AttachmentGallery.fragment_by_replacing_attachment_gallery_nodes(fragment) do |node|
|
69
|
+
block.call(attachment_gallery_for_node(node))
|
70
|
+
end
|
71
|
+
self.class.new(content, canonicalize: false)
|
72
|
+
end
|
73
|
+
|
74
|
+
def to_plain_text
|
75
|
+
render_attachments(with_full_attributes: false, &:to_plain_text).fragment.to_plain_text
|
76
|
+
end
|
77
|
+
|
78
|
+
def to_mosaico_html
|
79
|
+
render_attachments(&:to_mosaico_attachment).to_html
|
80
|
+
end
|
81
|
+
|
82
|
+
def to_html
|
83
|
+
fragment.to_html
|
84
|
+
end
|
85
|
+
|
86
|
+
def to_rendered_html_with_layout
|
87
|
+
render layout: 'action_mosaico/contents/content', partial: to_partial_path, formats: :html,
|
88
|
+
locals: { content: self }
|
89
|
+
end
|
90
|
+
|
91
|
+
def to_partial_path
|
92
|
+
'action_mosaico/contents/content'
|
93
|
+
end
|
94
|
+
|
95
|
+
def to_s
|
96
|
+
to_rendered_html_with_layout
|
97
|
+
end
|
98
|
+
|
99
|
+
def as_json(*)
|
100
|
+
to_html
|
101
|
+
end
|
102
|
+
|
103
|
+
def inspect
|
104
|
+
"#<#{self.class.name} #{to_s.truncate(25).inspect}>"
|
105
|
+
end
|
106
|
+
|
107
|
+
def ==(other)
|
108
|
+
to_s == other.to_s if other.is_a?(self.class)
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
def attachment_nodes
|
114
|
+
@attachment_nodes ||= fragment.find_all(ActionMosaico::Attachment.tag_name)
|
115
|
+
end
|
116
|
+
|
117
|
+
def attachment_gallery_nodes
|
118
|
+
@attachment_gallery_nodes ||= ActionMosaico::AttachmentGallery.find_attachment_gallery_nodes(fragment)
|
119
|
+
end
|
120
|
+
|
121
|
+
def attachment_for_node(node, with_full_attributes: true)
|
122
|
+
attachment = ActionMosaico::Attachment.from_node(node)
|
123
|
+
with_full_attributes ? attachment.with_full_attributes : attachment
|
124
|
+
end
|
125
|
+
|
126
|
+
def attachment_gallery_for_node(node)
|
127
|
+
ActionMosaico::AttachmentGallery.from_node(node)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
ActiveSupport.run_load_hooks :action_mosaico_content, ActionMosaico::Content
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMosaico
|
4
|
+
module Encryption
|
5
|
+
def encrypt
|
6
|
+
transaction do
|
7
|
+
super
|
8
|
+
encrypt_rich_texts if has_encrypted_rich_texts?
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def decrypt
|
13
|
+
transaction do
|
14
|
+
super
|
15
|
+
decrypt_rich_texts if has_encrypted_rich_texts?
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def encrypt_rich_texts
|
22
|
+
encryptable_rich_texts.each(&:encrypt)
|
23
|
+
end
|
24
|
+
|
25
|
+
def decrypt_rich_texts
|
26
|
+
encryptable_rich_texts.each(&:decrypt)
|
27
|
+
end
|
28
|
+
|
29
|
+
def has_encrypted_rich_texts?
|
30
|
+
encryptable_rich_texts.present?
|
31
|
+
end
|
32
|
+
|
33
|
+
def encryptable_rich_texts
|
34
|
+
@encryptable_rich_texts ||= self.class.rich_text_association_names
|
35
|
+
.filter_map { |attribute_name| send(attribute_name) }
|
36
|
+
.find_all { |record| record.is_a?(ActionMosaico::EncryptedRichText) }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails'
|
4
|
+
require 'action_controller/railtie'
|
5
|
+
require 'active_record/railtie'
|
6
|
+
require 'active_storage/engine'
|
7
|
+
|
8
|
+
require 'action_mosaico'
|
9
|
+
|
10
|
+
module ActionMosaico
|
11
|
+
class Engine < Rails::Engine
|
12
|
+
isolate_namespace ActionMosaico
|
13
|
+
config.eager_load_namespaces << ActionMosaico
|
14
|
+
|
15
|
+
config.action_mosaico = ActiveSupport::OrderedOptions.new
|
16
|
+
config.action_mosaico.attachment_tag_name = 'action-text-attachment'
|
17
|
+
config.autoload_once_paths = %W[
|
18
|
+
#{root}/app/helpers
|
19
|
+
#{root}/app/models
|
20
|
+
]
|
21
|
+
|
22
|
+
initializer 'action_mosaico.attribute' do
|
23
|
+
ActiveSupport.on_load(:active_record) do
|
24
|
+
include ActionMosaico::Attribute
|
25
|
+
prepend ActionMosaico::Encryption
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
initializer 'action_mosaico.asset' do
|
30
|
+
if Rails.application.config.respond_to?(:assets)
|
31
|
+
Rails.application.config.assets.precompile += %w[action_mosaico.js mosaico.js mosaico.css]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
initializer 'action_mosaico.attachable' do
|
36
|
+
ActiveSupport.on_load(:active_storage_blob) do
|
37
|
+
include ActionMosaico::Attachable
|
38
|
+
|
39
|
+
def previewable_attachable?
|
40
|
+
representable?
|
41
|
+
end
|
42
|
+
|
43
|
+
def attachable_plain_text_representation(caption = nil)
|
44
|
+
"[#{caption || filename}]"
|
45
|
+
end
|
46
|
+
|
47
|
+
def to_mosaico_content_attachment_partial_path
|
48
|
+
nil
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
initializer 'action_mosaico.helper' do
|
54
|
+
%i[action_controller_base action_mailer].each do |abstract_controller|
|
55
|
+
ActiveSupport.on_load(abstract_controller) do
|
56
|
+
helper ActionMosaico::Engine.helpers
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
initializer 'action_mosaico.renderer' do
|
62
|
+
ActiveSupport.on_load(:action_mosaico_content) do
|
63
|
+
self.default_renderer = Class.new(ActionController::Base).renderer
|
64
|
+
end
|
65
|
+
|
66
|
+
%i[action_controller_base action_mailer].each do |abstract_controller|
|
67
|
+
ActiveSupport.on_load(abstract_controller) do
|
68
|
+
around_action do |controller, action|
|
69
|
+
ActionMosaico::Content.with_renderer(controller, &action)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
initializer 'action_mosaico.system_test_helper' do
|
76
|
+
ActiveSupport.on_load(:action_dispatch_system_test_case) do
|
77
|
+
require 'action_mosaico/system_test_helper'
|
78
|
+
include ActionMosaico::SystemTestHelper
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
initializer 'action_mosaico.configure' do |app|
|
83
|
+
ActionMosaico::Attachment.tag_name = app.config.action_mosaico.attachment_tag_name
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMosaico
|
4
|
+
# Fixtures are a way of organizing data that you want to test against; in
|
5
|
+
# short, sample data.
|
6
|
+
#
|
7
|
+
# To learn more about fixtures, read the
|
8
|
+
# {ActiveRecord::FixtureSet}[rdoc-ref:ActiveRecord::FixtureSet] documentation.
|
9
|
+
#
|
10
|
+
# === YAML
|
11
|
+
#
|
12
|
+
# Like other Active Record-backed models, ActionMosaico::RichText records inherit
|
13
|
+
# from ActiveRecord::Base instances and therefore can be populated by
|
14
|
+
# fixtures.
|
15
|
+
#
|
16
|
+
# Consider a hypothetical <tt>Article</tt> model class, its related fixture
|
17
|
+
# data, as well as fixture data for related ActionMosaico::RichText records:
|
18
|
+
#
|
19
|
+
# # app/models/article.rb
|
20
|
+
# class Article < ApplicationRecord
|
21
|
+
# has_rich_text :content
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# # tests/fixtures/articles.yml
|
25
|
+
# first:
|
26
|
+
# title: An Article
|
27
|
+
#
|
28
|
+
# # tests/fixtures/action_mosaico/rich_texts.yml
|
29
|
+
# first_content:
|
30
|
+
# record: first (Article)
|
31
|
+
# name: content
|
32
|
+
# body: <div>Hello, world.</div>
|
33
|
+
#
|
34
|
+
# When processed, Active Record will insert database records for each fixture
|
35
|
+
# entry and will ensure the Action Mosaico relationship is intact.
|
36
|
+
class FixtureSet
|
37
|
+
# Fixtures support Action Mosaico attachments as part of their <tt>body</tt>
|
38
|
+
# HTML.
|
39
|
+
#
|
40
|
+
# === Examples
|
41
|
+
#
|
42
|
+
# For example, consider a second <tt>Article</tt> record that mentions the
|
43
|
+
# first as part of its <tt>content</tt> HTML:
|
44
|
+
#
|
45
|
+
# # tests/fixtures/articles.yml
|
46
|
+
# second:
|
47
|
+
# title: Another Article
|
48
|
+
#
|
49
|
+
# # tests/fixtures/action_mosaico/rich_texts.yml
|
50
|
+
# second_content:
|
51
|
+
# record: second (Article)
|
52
|
+
# name: content
|
53
|
+
# body: <div>Hello, <%= ActionMosaico::FixtureSet.attachment("articles", :first) %></div>
|
54
|
+
def self.attachment(fixture_set_name, label, column_type: :integer)
|
55
|
+
signed_global_id = ActiveRecord::FixtureSet.signed_global_id fixture_set_name, label,
|
56
|
+
column_type: column_type, for: ActionMosaico::Attachable::LOCATOR_NAME
|
57
|
+
|
58
|
+
%(<action-text-attachment sgid="#{signed_global_id}"></action-text-attachment>)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMosaico
|
4
|
+
class Fragment
|
5
|
+
class << self
|
6
|
+
def wrap(fragment_or_html)
|
7
|
+
case fragment_or_html
|
8
|
+
when self
|
9
|
+
fragment_or_html
|
10
|
+
when Nokogiri::HTML::DocumentFragment
|
11
|
+
new(fragment_or_html)
|
12
|
+
else
|
13
|
+
from_html(fragment_or_html)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def from_html(html)
|
18
|
+
new(ActionMosaico::HtmlConversion.fragment_for_html(html.to_s.strip))
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_reader :source
|
23
|
+
|
24
|
+
def initialize(source)
|
25
|
+
@source = source
|
26
|
+
end
|
27
|
+
|
28
|
+
def find_all(selector)
|
29
|
+
source.css(selector)
|
30
|
+
end
|
31
|
+
|
32
|
+
def update
|
33
|
+
yield source = self.source.clone
|
34
|
+
self.class.new(source)
|
35
|
+
end
|
36
|
+
|
37
|
+
def replace(selector)
|
38
|
+
update do |source|
|
39
|
+
source.css(selector).each do |node|
|
40
|
+
node.replace(yield(node).to_s)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_plain_text
|
46
|
+
@plain_text ||= PlainTextConversion.node_to_plain_text(source)
|
47
|
+
end
|
48
|
+
|
49
|
+
def to_html
|
50
|
+
@html ||= HtmlConversion.node_to_html(source)
|
51
|
+
end
|
52
|
+
|
53
|
+
def to_s
|
54
|
+
to_html
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMosaico
|
4
|
+
module HtmlConversion
|
5
|
+
extend self
|
6
|
+
|
7
|
+
def node_to_html(node)
|
8
|
+
node.to_html(save_with: Nokogiri::XML::Node::SaveOptions::AS_HTML)
|
9
|
+
end
|
10
|
+
|
11
|
+
def fragment_for_html(html)
|
12
|
+
document.fragment(html)
|
13
|
+
end
|
14
|
+
|
15
|
+
def create_element(tag_name, attributes = {})
|
16
|
+
document.create_element(tag_name, attributes)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def document
|
22
|
+
Nokogiri::HTML::Document.new.tap { |doc| doc.encoding = 'UTF-8' }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMosaico
|
4
|
+
class TrixAttachment
|
5
|
+
TAG_NAME = 'figure'
|
6
|
+
SELECTOR = '[data-mosaico-attachment]'
|
7
|
+
|
8
|
+
COMPOSED_ATTRIBUTES = %w[caption presentation].freeze
|
9
|
+
ATTRIBUTES = %w[sgid contentType url href filename filesize width height previewable
|
10
|
+
content] + COMPOSED_ATTRIBUTES
|
11
|
+
ATTRIBUTE_TYPES = {
|
12
|
+
'previewable' => ->(value) { value.to_s == 'true' },
|
13
|
+
'filesize' => ->(value) { Integer(value.to_s, exception: false) || value },
|
14
|
+
'width' => ->(value) { Integer(value.to_s, exception: false) },
|
15
|
+
'height' => ->(value) { Integer(value.to_s, exception: false) },
|
16
|
+
:default => ->(value) { value.to_s }
|
17
|
+
}.freeze
|
18
|
+
|
19
|
+
class << self
|
20
|
+
def from_attributes(attributes)
|
21
|
+
attributes = process_attributes(attributes)
|
22
|
+
|
23
|
+
mosaico_attachment_attributes = attributes.except(*COMPOSED_ATTRIBUTES)
|
24
|
+
mosaico_attributes = attributes.slice(*COMPOSED_ATTRIBUTES)
|
25
|
+
|
26
|
+
node = ActionMosaico::HtmlConversion.create_element(TAG_NAME)
|
27
|
+
node['data-mosaico-attachment'] = JSON.generate(mosaico_attachment_attributes)
|
28
|
+
node['data-mosaico-attributes'] = JSON.generate(mosaico_attributes) if mosaico_attributes.any?
|
29
|
+
|
30
|
+
new(node)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def process_attributes(attributes)
|
36
|
+
typecast_attribute_values(transform_attribute_keys(attributes))
|
37
|
+
end
|
38
|
+
|
39
|
+
def transform_attribute_keys(attributes)
|
40
|
+
attributes.transform_keys { |key| key.to_s.underscore.camelize(:lower) }
|
41
|
+
end
|
42
|
+
|
43
|
+
def typecast_attribute_values(attributes)
|
44
|
+
attributes.map do |key, value|
|
45
|
+
typecast = ATTRIBUTE_TYPES[key] || ATTRIBUTE_TYPES[:default]
|
46
|
+
[key, typecast.call(value)]
|
47
|
+
end.to_h
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
attr_reader :node
|
52
|
+
|
53
|
+
def initialize(node)
|
54
|
+
@node = node
|
55
|
+
end
|
56
|
+
|
57
|
+
def attributes
|
58
|
+
@attributes ||= attachment_attributes.merge(composed_attributes).slice(*ATTRIBUTES)
|
59
|
+
end
|
60
|
+
|
61
|
+
def to_html
|
62
|
+
ActionMosaico::HtmlConversion.node_to_html(node)
|
63
|
+
end
|
64
|
+
|
65
|
+
def to_s
|
66
|
+
to_html
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def attachment_attributes
|
72
|
+
read_json_object_attribute('data-mosaico-attachment')
|
73
|
+
end
|
74
|
+
|
75
|
+
def composed_attributes
|
76
|
+
read_json_object_attribute('data-mosaico-attributes')
|
77
|
+
end
|
78
|
+
|
79
|
+
def read_json_object_attribute(name)
|
80
|
+
read_json_attribute(name) || {}
|
81
|
+
end
|
82
|
+
|
83
|
+
def read_json_attribute(name)
|
84
|
+
if value = node[name]
|
85
|
+
begin
|
86
|
+
JSON.parse(value)
|
87
|
+
rescue StandardError => e
|
88
|
+
Rails.logger.error "[#{self.class.name}] Couldn't parse JSON #{value} from NODE #{node.inspect}"
|
89
|
+
Rails.logger.error "[#{self.class.name}] Failed with #{e.class}: #{e.backtrace}"
|
90
|
+
nil
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMosaico
|
4
|
+
module PlainTextConversion
|
5
|
+
extend self
|
6
|
+
|
7
|
+
def node_to_plain_text(node)
|
8
|
+
remove_trailing_newlines(plain_text_for_node(node))
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def plain_text_for_node(node, index = 0)
|
14
|
+
if respond_to?(plain_text_method_for_node(node), true)
|
15
|
+
send(plain_text_method_for_node(node), node, index)
|
16
|
+
else
|
17
|
+
plain_text_for_node_children(node)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def plain_text_for_node_children(node)
|
22
|
+
texts = []
|
23
|
+
node.children.each_with_index do |child, index|
|
24
|
+
texts << plain_text_for_node(child, index)
|
25
|
+
end
|
26
|
+
texts.join
|
27
|
+
end
|
28
|
+
|
29
|
+
def plain_text_method_for_node(node)
|
30
|
+
:"plain_text_for_#{node.name}_node"
|
31
|
+
end
|
32
|
+
|
33
|
+
def plain_text_for_block(node, _index = 0)
|
34
|
+
"#{remove_trailing_newlines(plain_text_for_node_children(node))}\n\n"
|
35
|
+
end
|
36
|
+
|
37
|
+
%i[h1 p ul ol].each do |element|
|
38
|
+
alias_method :"plain_text_for_#{element}_node", :plain_text_for_block
|
39
|
+
end
|
40
|
+
|
41
|
+
def plain_text_for_br_node(_node, _index)
|
42
|
+
"\n"
|
43
|
+
end
|
44
|
+
|
45
|
+
def plain_text_for_text_node(node, _index)
|
46
|
+
remove_trailing_newlines(node.text)
|
47
|
+
end
|
48
|
+
|
49
|
+
def plain_text_for_div_node(node, _index)
|
50
|
+
"#{remove_trailing_newlines(plain_text_for_node_children(node))}\n"
|
51
|
+
end
|
52
|
+
|
53
|
+
def plain_text_for_figcaption_node(node, _index)
|
54
|
+
"[#{remove_trailing_newlines(plain_text_for_node_children(node))}]"
|
55
|
+
end
|
56
|
+
|
57
|
+
def plain_text_for_blockquote_node(node, _index)
|
58
|
+
text = plain_text_for_block(node)
|
59
|
+
text.sub(/\A(\s*)(.+?)(\s*)\Z/m, '\1“\2”\3')
|
60
|
+
end
|
61
|
+
|
62
|
+
def plain_text_for_li_node(node, index)
|
63
|
+
bullet = bullet_for_li_node(node, index)
|
64
|
+
text = remove_trailing_newlines(plain_text_for_node_children(node))
|
65
|
+
"#{bullet} #{text}\n"
|
66
|
+
end
|
67
|
+
|
68
|
+
def remove_trailing_newlines(text)
|
69
|
+
text.chomp('')
|
70
|
+
end
|
71
|
+
|
72
|
+
def bullet_for_li_node(node, index)
|
73
|
+
if list_node_name_for_li_node(node) == 'ol'
|
74
|
+
"#{index + 1}."
|
75
|
+
else
|
76
|
+
'•'
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def list_node_name_for_li_node(node)
|
81
|
+
node.ancestors.lazy.map(&:name).grep(/^[uo]l$/).first
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/concern'
|
4
|
+
require 'active_support/core_ext/module/attribute_accessors_per_thread'
|
5
|
+
|
6
|
+
module ActionMosaico
|
7
|
+
module Rendering # :nodoc:
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
included do
|
11
|
+
cattr_accessor :default_renderer, instance_accessor: false
|
12
|
+
thread_cattr_accessor :renderer, instance_accessor: false
|
13
|
+
delegate :render, to: :class
|
14
|
+
end
|
15
|
+
|
16
|
+
class_methods do
|
17
|
+
def with_renderer(renderer)
|
18
|
+
previous_renderer = self.renderer
|
19
|
+
self.renderer = renderer
|
20
|
+
yield
|
21
|
+
ensure
|
22
|
+
self.renderer = previous_renderer
|
23
|
+
end
|
24
|
+
|
25
|
+
def render(...)
|
26
|
+
(renderer || default_renderer).render_to_string(...)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMosaico
|
4
|
+
module Serialization
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
class_methods do
|
8
|
+
def load(content)
|
9
|
+
new(content) if content
|
10
|
+
end
|
11
|
+
|
12
|
+
def dump(content)
|
13
|
+
case content
|
14
|
+
when nil
|
15
|
+
nil
|
16
|
+
when self
|
17
|
+
content.to_html
|
18
|
+
when ActionMosaico::RichText
|
19
|
+
content.body.to_html
|
20
|
+
else
|
21
|
+
new(content).to_html
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Marshal compatibility
|
27
|
+
|
28
|
+
class_methods do
|
29
|
+
alias_method :_load, :load
|
30
|
+
end
|
31
|
+
|
32
|
+
def _dump(*)
|
33
|
+
self.class.dump(self)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMosaico
|
4
|
+
module SystemTestHelper
|
5
|
+
# Locates a Trix editor and fills it in with the given HTML.
|
6
|
+
#
|
7
|
+
# The editor can be found by:
|
8
|
+
# * its +id+
|
9
|
+
# * its +placeholder+
|
10
|
+
# * the text from its +label+ element
|
11
|
+
# * its +aria-label+
|
12
|
+
# * the +name+ of its input
|
13
|
+
#
|
14
|
+
# Examples:
|
15
|
+
#
|
16
|
+
# # <mosaico-editor id="message_content" ...></mosaico-editor>
|
17
|
+
# fill_in_rich_text_area "message_content", with: "Hello <em>world!</em>"
|
18
|
+
#
|
19
|
+
# # <mosaico-editor placeholder="Your message here" ...></mosaico-editor>
|
20
|
+
# fill_in_rich_text_area "Your message here", with: "Hello <em>world!</em>"
|
21
|
+
#
|
22
|
+
# # <label for="message_content">Message content</label>
|
23
|
+
# # <mosaico-editor id="message_content" ...></mosaico-editor>
|
24
|
+
# fill_in_rich_text_area "Message content", with: "Hello <em>world!</em>"
|
25
|
+
#
|
26
|
+
# # <mosaico-editor aria-label="Message content" ...></mosaico-editor>
|
27
|
+
# fill_in_rich_text_area "Message content", with: "Hello <em>world!</em>"
|
28
|
+
#
|
29
|
+
# # <input id="mosaico_input_1" name="message[content]" type="hidden">
|
30
|
+
# # <mosaico-editor input="mosaico_input_1"></mosaico-editor>
|
31
|
+
# fill_in_rich_text_area "message[content]", with: "Hello <em>world!</em>"
|
32
|
+
def fill_in_rich_text_area(locator = nil, with:)
|
33
|
+
find(:rich_text_area, locator).execute_script('this.editor.loadHTML(arguments[0])', with.to_s)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
Capybara.add_selector :rich_text_area do
|
39
|
+
label 'rich-text area'
|
40
|
+
xpath do |locator|
|
41
|
+
if locator.nil?
|
42
|
+
XPath.descendant(:'mosaico-editor')
|
43
|
+
else
|
44
|
+
input_located_by_name = XPath.anywhere(:input).where(XPath.attr(:name) == locator).attr(:id)
|
45
|
+
input_located_by_label = XPath.anywhere(:label).where(XPath.string.n.is(locator)).attr(:for)
|
46
|
+
|
47
|
+
XPath.descendant(:'mosaico-editor').where \
|
48
|
+
XPath.attr(:id).equals(locator) |
|
49
|
+
XPath.attr(:placeholder).equals(locator) |
|
50
|
+
XPath.attr(:'aria-label').equals(locator) |
|
51
|
+
XPath.attr(:input).equals(input_located_by_name) |
|
52
|
+
XPath.attr(:id).equals(input_located_by_label)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|