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