loading
Generated 2020-08-25T23:32:11-04:00

All Files ( 81.33% covered at 30.16 hits/line )

26 files in total.
675 relevant lines, 549 lines covered and 126 lines missed. ( 81.33% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/helpers/action_text/content_helper.rb 100.00 % 37 20 20 0 9.35
app/helpers/action_text/tag_helper.rb 100.00 % 80 32 32 0 3.53
app/models/action_text/rich_text.rb 100.00 % 29 13 13 0 2.92
lib/action_text.rb 100.00 % 37 25 25 0 1.00
lib/action_text/attachable.rb 93.88 % 86 49 46 3 12.53
lib/action_text/attachables/content_attachment.rb 63.16 % 38 19 12 7 2.79
lib/action_text/attachables/missing_attachable.rb 100.00 % 13 6 6 0 3.50
lib/action_text/attachables/remote_image.rb 100.00 % 46 23 23 0 4.30
lib/action_text/attachment.rb 96.36 % 105 55 53 2 27.05
lib/action_text/attachment_gallery.rb 96.97 % 65 33 32 1 17.09
lib/action_text/attachments/caching.rb 75.00 % 16 8 6 2 0.75
lib/action_text/attachments/minification.rb 100.00 % 17 8 8 0 18.25
lib/action_text/attachments/trix_conversion.rb 100.00 % 36 19 19 0 5.37
lib/action_text/attribute.rb 100.00 % 50 9 9 0 3.89
lib/action_text/content.rb 95.65 % 132 69 66 3 21.09
lib/action_text/engine.rb 94.12 % 65 34 32 2 1.62
lib/action_text/fragment.rb 96.43 % 57 28 27 1 109.82
lib/action_text/gem_version.rb 0.00 % 17 12 0 12 0.00
lib/action_text/html_conversion.rb 100.00 % 24 12 12 0 41.42
lib/action_text/plain_text_conversion.rb 100.00 % 83 44 44 0 251.84
lib/action_text/serialization.rb 100.00 % 34 14 14 0 16.43
lib/action_text/system_test_helper.rb 0.00 % 48 22 0 22 0.00
lib/action_text/trix_attachment.rb 92.59 % 92 54 50 4 8.72
lib/action_text/version.rb 0.00 % 10 6 0 6 0.00
lib/generators/action_text/install/install_generator.rb 0.00 % 67 51 0 51 0.00
lib/rails/generators/test_unit/install_generator.rb 0.00 % 13 10 0 10 0.00

app/helpers/action_text/content_helper.rb

100.0% lines covered

20 relevant lines. 20 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "rails-html-sanitizer"
  3. 1 module ActionText
  4. 1 module ContentHelper
  5. 2 mattr_accessor(:sanitizer) { Rails::Html::Sanitizer.safe_list_sanitizer.new }
  6. 2 mattr_accessor(:allowed_tags) { sanitizer.class.allowed_tags + [ ActionText::Attachment::TAG_NAME, "figure", "figcaption" ] }
  7. 2 mattr_accessor(:allowed_attributes) { sanitizer.class.allowed_attributes + ActionText::Attachment::ATTRIBUTES }
  8. 1 mattr_accessor(:scrubber)
  9. 1 def render_action_text_content(content)
  10. 39 sanitize_action_text_content(render_action_text_attachments(content))
  11. end
  12. 1 def sanitize_action_text_content(content)
  13. 39 sanitizer.sanitize(content.to_html, tags: allowed_tags, attributes: allowed_attributes, scrubber: scrubber).html_safe
  14. end
  15. 1 def render_action_text_attachments(content)
  16. content.render_attachments do |attachment|
  17. 13 unless attachment.in?(content.gallery_attachments)
  18. 13 attachment.node.tap do |node|
  19. 13 node.inner_html = render(attachment, in_gallery: false).chomp
  20. end
  21. end
  22. 39 end.render_attachment_galleries do |attachment_gallery|
  23. render(layout: attachment_gallery, object: attachment_gallery) do
  24. attachment_gallery.attachments.map do |attachment|
  25. 6 attachment.node.inner_html = render(attachment, in_gallery: true).chomp
  26. 6 attachment.to_html
  27. 3 end.join.html_safe
  28. 3 end.chomp
  29. end
  30. end
  31. end
  32. end

app/helpers/action_text/tag_helper.rb

100.0% lines covered

32 relevant lines. 32 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/core_ext/object/try"
  3. 1 require "action_view/helpers/tags/placeholderable"
  4. 1 module ActionText
  5. 1 module TagHelper
  6. 2 cattr_accessor(:id, instance_accessor: false) { 0 }
  7. # Returns a +trix-editor+ tag that instantiates the Trix JavaScript editor as well as a hidden field
  8. # that Trix will write to on changes, so the content will be sent on form submissions.
  9. #
  10. # ==== Options
  11. # * <tt>:class</tt> - Defaults to "trix-content" which ensures default styling is applied.
  12. #
  13. # ==== Example
  14. #
  15. # rich_text_area_tag "content", message.content
  16. # # <input type="hidden" name="content" id="trix_input_post_1">
  17. # # <trix-editor id="content" input="trix_input_post_1" class="trix-content" ...></trix-editor>
  18. 1 def rich_text_area_tag(name, value = nil, options = {})
  19. 6 options = options.symbolize_keys
  20. 6 options[:input] ||= "trix_input_#{ActionText::TagHelper.id += 1}"
  21. 6 options[:class] ||= "trix-content"
  22. 6 options[:data] ||= {}
  23. 6 options[:data][:direct_upload_url] = main_app.rails_direct_uploads_url
  24. 6 options[:data][:blob_url_template] = main_app.rails_service_blob_url(":signed_id", ":filename")
  25. 6 editor_tag = content_tag("trix-editor", "", options)
  26. 6 input_tag = hidden_field_tag(name, value, id: options[:input])
  27. 6 input_tag + editor_tag
  28. end
  29. end
  30. end
  31. 1 module ActionView::Helpers
  32. 1 class Tags::ActionText < Tags::Base
  33. 1 include Tags::Placeholderable
  34. 1 delegate :dom_id, to: ActionView::RecordIdentifier
  35. 1 def render
  36. 6 options = @options.stringify_keys
  37. 6 add_default_name_and_id(options)
  38. 6 options["input"] ||= dom_id(object, [options["id"], :trix_input].compact.join("_")) if object
  39. 6 @template_object.rich_text_area_tag(options.delete("name"), editable_value, options)
  40. end
  41. 1 def editable_value
  42. 6 value&.body.try(:to_trix_html)
  43. end
  44. end
  45. 1 module FormHelper
  46. # Returns a +trix-editor+ tag that instantiates the Trix JavaScript editor as well as a hidden field
  47. # that Trix will write to on changes, so the content will be sent on form submissions.
  48. #
  49. # ==== Options
  50. # * <tt>:class</tt> - Defaults to "trix-content" which ensures default styling is applied.
  51. #
  52. # ==== Example
  53. # form_with(model: @message) do |form|
  54. # form.rich_text_area :content
  55. # end
  56. # # <input type="hidden" name="message[content]" id="message_content_trix_input_message_1">
  57. # # <trix-editor id="content" input="message_content_trix_input_message_1" class="trix-content" ...></trix-editor>
  58. 1 def rich_text_area(object_name, method, options = {})
  59. 6 Tags::ActionText.new(object_name, method, self, options).render
  60. end
  61. end
  62. 1 class FormBuilder
  63. 1 def rich_text_area(method, options = {})
  64. 6 @template.rich_text_area(@object_name, method, objectify_options(options))
  65. end
  66. end
  67. end

app/models/action_text/rich_text.rb

100.0% lines covered

13 relevant lines. 13 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. # The RichText record holds the content produced by the Trix editor in a serialized +body+ attribute.
  4. # It also holds all the references to the embedded files, which are stored using Active Storage.
  5. # This record is then associated with the Active Record model the application desires to have
  6. # rich text content using the +has_rich_text+ class method.
  7. 1 class RichText < ActiveRecord::Base
  8. 1 self.table_name = "action_text_rich_texts"
  9. 1 serialize :body, ActionText::Content
  10. 1 delegate :to_s, :nil?, to: :body
  11. 1 belongs_to :record, polymorphic: true, touch: true
  12. 1 has_many_attached :embeds
  13. 1 before_save do
  14. 11 self.embeds = body.attachables.grep(ActiveStorage::Blob).uniq if body.present?
  15. end
  16. 1 def to_plain_text
  17. 16 body&.to_plain_text.to_s
  18. end
  19. 1 delegate :blank?, :empty?, :present?, to: :to_plain_text
  20. end
  21. end
  22. 1 ActiveSupport.run_load_hooks :action_text_rich_text, ActionText::RichText

lib/action_text.rb

100.0% lines covered

25 relevant lines. 25 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support"
  3. 1 require "active_support/rails"
  4. 1 require "nokogiri"
  5. 1 module ActionText
  6. 1 extend ActiveSupport::Autoload
  7. 1 autoload :Attachable
  8. 1 autoload :AttachmentGallery
  9. 1 autoload :Attachment
  10. 1 autoload :Attribute
  11. 1 autoload :Content
  12. 1 autoload :Fragment
  13. 1 autoload :HtmlConversion
  14. 1 autoload :PlainTextConversion
  15. 1 autoload :Serialization
  16. 1 autoload :TrixAttachment
  17. 1 module Attachables
  18. 1 extend ActiveSupport::Autoload
  19. 1 autoload :ContentAttachment
  20. 1 autoload :MissingAttachable
  21. 1 autoload :RemoteImage
  22. end
  23. 1 module Attachments
  24. 1 extend ActiveSupport::Autoload
  25. 1 autoload :Caching
  26. 1 autoload :Minification
  27. 1 autoload :TrixConversion
  28. end
  29. end

lib/action_text/attachable.rb

93.88% lines covered

49 relevant lines. 46 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 module Attachable
  4. 1 extend ActiveSupport::Concern
  5. 1 LOCATOR_NAME = "attachable"
  6. 1 class << self
  7. 1 def from_node(node)
  8. 46 if attachable = attachable_from_sgid(node["sgid"])
  9. 13 attachable
  10. 33 elsif attachable = ActionText::Attachables::ContentAttachment.from_node(node)
  11. attachable
  12. 33 elsif attachable = ActionText::Attachables::RemoteImage.from_node(node)
  13. 7 attachable
  14. else
  15. 26 ActionText::Attachables::MissingAttachable
  16. end
  17. end
  18. 1 def from_attachable_sgid(sgid, options = {})
  19. 46 method = sgid.is_a?(Array) ? :locate_many_signed : :locate_signed
  20. 46 record = GlobalID::Locator.public_send(method, sgid, options.merge(for: LOCATOR_NAME))
  21. 44 record || raise(ActiveRecord::RecordNotFound)
  22. end
  23. 1 private
  24. 1 def attachable_from_sgid(sgid)
  25. 46 from_attachable_sgid(sgid)
  26. rescue ActiveRecord::RecordNotFound
  27. 33 nil
  28. end
  29. end
  30. 1 class_methods do
  31. 1 def from_attachable_sgid(sgid)
  32. ActionText::Attachable.from_attachable_sgid(sgid, only: self)
  33. end
  34. end
  35. 1 def attachable_sgid
  36. 21 to_sgid(expires_in: nil, for: LOCATOR_NAME).to_s
  37. end
  38. 1 def attachable_content_type
  39. 17 try(:content_type) || "application/octet-stream"
  40. end
  41. 1 def attachable_filename
  42. 15 filename.to_s if respond_to?(:filename)
  43. end
  44. 1 def attachable_filesize
  45. 15 try(:byte_size) || try(:filesize)
  46. end
  47. 1 def attachable_metadata
  48. 30 try(:metadata) || {}
  49. end
  50. 1 def previewable_attachable?
  51. 2 false
  52. end
  53. 1 def as_json(*)
  54. super.merge(attachable_sgid: attachable_sgid)
  55. end
  56. 1 def to_trix_content_attachment_partial_path
  57. 1 to_partial_path
  58. end
  59. 1 def to_rich_text_attributes(attributes = {})
  60. attributes.dup.tap do |attrs|
  61. 15 attrs[:sgid] = attachable_sgid
  62. 15 attrs[:content_type] = attachable_content_type
  63. 15 attrs[:previewable] = true if previewable_attachable?
  64. 15 attrs[:filename] = attachable_filename
  65. 15 attrs[:filesize] = attachable_filesize
  66. 15 attrs[:width] = attachable_metadata[:width]
  67. 15 attrs[:height] = attachable_metadata[:height]
  68. 15 end.compact
  69. end
  70. end
  71. end

lib/action_text/attachables/content_attachment.rb

63.16% lines covered

19 relevant lines. 12 lines covered and 7 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 module Attachables
  4. 1 class ContentAttachment
  5. 1 include ActiveModel::Model
  6. 1 def self.from_node(node)
  7. 33 if node["content-type"]
  8. 10 if matches = node["content-type"].match(/vnd\.rubyonrails\.(.+)\.html/)
  9. attachment = new(name: matches[1])
  10. attachment if attachment.valid?
  11. end
  12. end
  13. end
  14. 1 attr_accessor :name
  15. 1 validates_inclusion_of :name, in: %w( horizontal-rule )
  16. 1 def attachable_plain_text_representation(caption)
  17. case name
  18. when "horizontal-rule"
  19. " ┄ "
  20. else
  21. " "
  22. end
  23. end
  24. 1 def to_partial_path
  25. "action_text/attachables/content_attachment"
  26. end
  27. 1 def to_trix_content_attachment_partial_path
  28. "action_text/attachables/content_attachments/#{name.underscore}"
  29. end
  30. end
  31. end
  32. end

lib/action_text/attachables/missing_attachable.rb

100.0% lines covered

6 relevant lines. 6 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 module Attachables
  4. 1 module MissingAttachable
  5. 1 extend ActiveModel::Naming
  6. 1 def self.to_partial_path
  7. 16 "action_text/attachables/missing_attachable"
  8. end
  9. end
  10. end
  11. end

lib/action_text/attachables/remote_image.rb

100.0% lines covered

23 relevant lines. 23 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 module Attachables
  4. 1 class RemoteImage
  5. 1 extend ActiveModel::Naming
  6. 1 class << self
  7. 1 def from_node(node)
  8. 33 if node["url"] && content_type_is_image?(node["content-type"])
  9. 7 new(attributes_from_node(node))
  10. end
  11. end
  12. 1 private
  13. 1 def content_type_is_image?(content_type)
  14. 7 content_type.to_s.match?(/^image(\/.+|$)/)
  15. end
  16. 1 def attributes_from_node(node)
  17. 7 { url: node["url"],
  18. content_type: node["content-type"],
  19. width: node["width"],
  20. height: node["height"] }
  21. end
  22. end
  23. 1 attr_reader :url, :content_type, :width, :height
  24. 1 def initialize(attributes = {})
  25. 7 @url = attributes[:url]
  26. 7 @content_type = attributes[:content_type]
  27. 7 @width = attributes[:width]
  28. 7 @height = attributes[:height]
  29. end
  30. 1 def attachable_plain_text_representation(caption)
  31. 2 "[#{caption || "Image"}]"
  32. end
  33. 1 def to_partial_path
  34. 2 "action_text/attachables/remote_image"
  35. end
  36. end
  37. end
  38. end

lib/action_text/attachment.rb

96.36% lines covered

55 relevant lines. 53 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/core_ext/object/try"
  3. 1 module ActionText
  4. 1 class Attachment
  5. 1 include Attachments::TrixConversion, Attachments::Minification, Attachments::Caching
  6. 1 TAG_NAME = "action-text-attachment"
  7. 1 SELECTOR = TAG_NAME
  8. 1 ATTRIBUTES = %w( sgid content-type url href filename filesize width height previewable presentation caption )
  9. 1 class << self
  10. 1 def fragment_by_canonicalizing_attachments(content)
  11. 78 fragment_by_minifying_attachments(fragment_by_converting_trix_attachments(content))
  12. end
  13. 1 def from_node(node, attachable = nil)
  14. 71 new(node, attachable || ActionText::Attachable.from_node(node))
  15. end
  16. 1 def from_attachables(attachables)
  17. 7 Array(attachables).map { |attachable| from_attachable(attachable) }.compact
  18. end
  19. 1 def from_attachable(attachable, attributes = {})
  20. 9 if node = node_from_attributes(attachable.to_rich_text_attributes(attributes))
  21. 9 new(node, attachable)
  22. end
  23. end
  24. 1 def from_attributes(attributes, attachable = nil)
  25. 34 if node = node_from_attributes(attributes)
  26. 33 from_node(node, attachable)
  27. end
  28. end
  29. 1 private
  30. 1 def node_from_attributes(attributes)
  31. 43 if attributes = process_attributes(attributes).presence
  32. 42 ActionText::HtmlConversion.create_element(TAG_NAME, attributes)
  33. end
  34. end
  35. 1 def process_attributes(attributes)
  36. 178 attributes.transform_keys { |key| key.to_s.underscore.dasherize }.slice(*ATTRIBUTES)
  37. end
  38. end
  39. 1 attr_reader :node, :attachable
  40. 1 delegate :to_param, to: :attachable
  41. 1 delegate_missing_to :attachable
  42. 1 def initialize(node, attachable)
  43. 80 @node = node
  44. 80 @attachable = attachable
  45. end
  46. 1 def caption
  47. 14 node_attributes["caption"].presence
  48. end
  49. 1 def full_attributes
  50. 34 node_attributes.merge(attachable_attributes).merge(sgid_attributes)
  51. end
  52. 1 def with_full_attributes
  53. 32 self.class.from_attributes(full_attributes, attachable)
  54. end
  55. 1 def to_plain_text
  56. 8 if respond_to?(:attachable_plain_text_representation)
  57. 8 attachable_plain_text_representation(caption)
  58. else
  59. caption.to_s
  60. end
  61. end
  62. 1 def to_html
  63. 11 HtmlConversion.node_to_html(node)
  64. end
  65. 1 def to_s
  66. 5 to_html
  67. end
  68. 1 def inspect
  69. "#<#{self.class.name} attachable=#{attachable.inspect}>"
  70. end
  71. 1 private
  72. 1 def node_attributes
  73. 610 @node_attributes ||= ATTRIBUTES.map { |name| [ name.underscore, node[name] ] }.to_h.compact
  74. end
  75. 1 def attachable_attributes
  76. 37 @attachable_attributes ||= (attachable.try(:to_rich_text_attributes) || {}).stringify_keys
  77. end
  78. 1 def sgid_attributes
  79. 34 @sgid_attributes ||= node_attributes.slice("sgid").presence || attachable_attributes.slice("sgid")
  80. end
  81. end
  82. end

lib/action_text/attachment_gallery.rb

96.97% lines covered

33 relevant lines. 32 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 class AttachmentGallery
  4. 1 include ActiveModel::Model
  5. 1 class << self
  6. 1 def fragment_by_canonicalizing_attachment_galleries(content)
  7. 78 fragment_by_replacing_attachment_gallery_nodes(content) do |node|
  8. 3 "<#{TAG_NAME}>#{node.inner_html}</#{TAG_NAME}>"
  9. end
  10. end
  11. 1 def fragment_by_replacing_attachment_gallery_nodes(content)
  12. 117 Fragment.wrap(content).update do |source|
  13. 117 find_attachment_gallery_nodes(source).each do |node|
  14. 6 node.replace(yield(node).to_s)
  15. end
  16. end
  17. end
  18. 1 def find_attachment_gallery_nodes(content)
  19. 127 Fragment.wrap(content).find_all(SELECTOR).select do |node|
  20. 9 node.children.all? do |child|
  21. 27 if child.text?
  22. 9 /\A(\n|\ )*\z/.match?(child.text)
  23. else
  24. 18 child.matches? ATTACHMENT_SELECTOR
  25. end
  26. end
  27. end
  28. end
  29. 1 def from_node(node)
  30. 6 new(node)
  31. end
  32. end
  33. 1 attr_reader :node
  34. 1 def initialize(node)
  35. 6 @node = node
  36. end
  37. 1 def attachments
  38. 9 @attachments ||= node.css(ATTACHMENT_SELECTOR).map do |node|
  39. 12 ActionText::Attachment.from_node(node).with_full_attributes
  40. end
  41. end
  42. 1 def size
  43. 3 attachments.size
  44. end
  45. 1 def inspect
  46. "#<#{self.class.name} size=#{size.inspect}>"
  47. end
  48. 1 TAG_NAME = "div"
  49. 1 ATTACHMENT_SELECTOR = "#{ActionText::Attachment::SELECTOR}[presentation=gallery]"
  50. 1 SELECTOR = "#{TAG_NAME}:has(#{ATTACHMENT_SELECTOR} + #{ATTACHMENT_SELECTOR})"
  51. 1 private_constant :TAG_NAME, :ATTACHMENT_SELECTOR, :SELECTOR
  52. end
  53. end

lib/action_text/attachments/caching.rb

75.0% lines covered

8 relevant lines. 6 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 module Attachments
  4. 1 module Caching
  5. 1 def cache_key(*args)
  6. [self.class.name, cache_digest, *attachable.cache_key(*args)].join("/")
  7. end
  8. 1 private
  9. 1 def cache_digest
  10. Digest::SHA256.hexdigest(node.to_s)
  11. end
  12. end
  13. end
  14. end

lib/action_text/attachments/minification.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 module Attachments
  4. 1 module Minification
  5. 1 extend ActiveSupport::Concern
  6. 1 class_methods do
  7. 1 def fragment_by_minifying_attachments(content)
  8. 78 Fragment.wrap(content).replace(ActionText::Attachment::SELECTOR) do |node|
  9. 62 node.tap { |n| n.inner_html = "" }
  10. end
  11. end
  12. end
  13. end
  14. end
  15. end

lib/action_text/attachments/trix_conversion.rb

100.0% lines covered

19 relevant lines. 19 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/core_ext/object/try"
  3. 1 module ActionText
  4. 1 module Attachments
  5. 1 module TrixConversion
  6. 1 extend ActiveSupport::Concern
  7. 1 class_methods do
  8. 1 def fragment_by_converting_trix_attachments(content)
  9. 78 Fragment.wrap(content).replace(TrixAttachment::SELECTOR) do |node|
  10. 2 from_trix_attachment(TrixAttachment.new(node))
  11. end
  12. end
  13. 1 def from_trix_attachment(trix_attachment)
  14. 2 from_attributes(trix_attachment.attributes)
  15. end
  16. end
  17. 1 def to_trix_attachment(content = trix_attachment_content)
  18. 2 attributes = full_attributes.dup
  19. 2 attributes["content"] = content if content
  20. 2 TrixAttachment.from_attributes(attributes)
  21. end
  22. 1 private
  23. 1 def trix_attachment_content
  24. 2 if partial_path = attachable.try(:to_trix_content_attachment_partial_path)
  25. 1 ActionText::Content.renderer.render(partial: partial_path, object: self, as: model_name.element)
  26. end
  27. end
  28. end
  29. end
  30. end

lib/action_text/attribute.rb

100.0% lines covered

9 relevant lines. 9 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 module Attribute
  4. 1 extend ActiveSupport::Concern
  5. 1 class_methods do
  6. # Provides access to a dependent RichText model that holds the body and attachments for a single named rich text attribute.
  7. # This dependent attribute is lazily instantiated and will be auto-saved when it's been changed. Example:
  8. #
  9. # class Message < ActiveRecord::Base
  10. # has_rich_text :content
  11. # end
  12. #
  13. # message = Message.create!(content: "<h1>Funny times!</h1>")
  14. # message.content? #=> true
  15. # message.content.to_s # => "<h1>Funny times!</h1>"
  16. # message.content.to_plain_text # => "Funny times!"
  17. #
  18. # The dependent RichText model will also automatically process attachments links as sent via the Trix-powered editor.
  19. # These attachments are associated with the RichText model using Active Storage.
  20. #
  21. # If you wish to preload the dependent RichText model, you can use the named scope:
  22. #
  23. # Message.all.with_rich_text_content # Avoids N+1 queries when you just want the body, not the attachments.
  24. # Message.all.with_rich_text_content_and_embeds # Avoids N+1 queries when you just want the body and attachments.
  25. 1 def has_rich_text(name)
  26. 3 class_eval <<-CODE, __FILE__, __LINE__ + 1
  27. def #{name}
  28. rich_text_#{name} || build_rich_text_#{name}
  29. end
  30. def #{name}?
  31. rich_text_#{name}.present?
  32. end
  33. def #{name}=(body)
  34. self.#{name}.body = body
  35. end
  36. CODE
  37. 21 has_one :"rich_text_#{name}", -> { where(name: name) },
  38. class_name: "ActionText::RichText", as: :record, inverse_of: :record, autosave: true, dependent: :destroy
  39. 3 scope :"with_rich_text_#{name}", -> { includes("rich_text_#{name}") }
  40. 3 scope :"with_rich_text_#{name}_and_embeds", -> { includes("rich_text_#{name}": { embeds_attachments: :blob }) }
  41. end
  42. end
  43. end
  44. end

lib/action_text/content.rb

95.65% lines covered

69 relevant lines. 66 lines covered and 3 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "active_support/core_ext/module/attribute_accessors_per_thread"
  3. 1 module ActionText
  4. 1 class Content
  5. 1 include Serialization
  6. 1 thread_cattr_accessor :renderer
  7. 1 attr_reader :fragment
  8. 1 delegate :blank?, :empty?, :html_safe, :present?, to: :to_html # Delegating to to_html to avoid including the layout
  9. 1 class << self
  10. 1 def fragment_by_canonicalizing_content(content)
  11. 78 fragment = ActionText::Attachment.fragment_by_canonicalizing_attachments(content)
  12. 78 fragment = ActionText::AttachmentGallery.fragment_by_canonicalizing_attachment_galleries(fragment)
  13. 78 fragment
  14. end
  15. end
  16. 1 def initialize(content = nil, options = {})
  17. 182 options.with_defaults! canonicalize: true
  18. 182 if options[:canonicalize]
  19. 78 @fragment = self.class.fragment_by_canonicalizing_content(content)
  20. else
  21. 104 @fragment = ActionText::Fragment.wrap(content)
  22. end
  23. end
  24. 1 def links
  25. 3 @links ||= fragment.find_all("a[href]").map { |a| a["href"] }.uniq
  26. end
  27. 1 def attachments
  28. 12 @attachments ||= attachment_nodes.map do |node|
  29. 7 attachment_for_node(node)
  30. end
  31. end
  32. 1 def attachment_galleries
  33. 10 @attachment_galleries ||= attachment_gallery_nodes.map do |node|
  34. 3 attachment_gallery_for_node(node)
  35. end
  36. end
  37. 1 def gallery_attachments
  38. 13 @gallery_attachments ||= attachment_galleries.flat_map(&:attachments)
  39. end
  40. 1 def attachables
  41. 11 @attachables ||= attachment_nodes.map do |node|
  42. 7 ActionText::Attachable.from_node(node)
  43. end
  44. end
  45. 1 def append_attachables(attachables)
  46. 3 attachments = ActionText::Attachment.from_attachables(attachables)
  47. 3 self.class.new([self.to_s.presence, *attachments].compact.join("\n"))
  48. end
  49. 1 def render_attachments(**options, &block)
  50. 65 content = fragment.replace(ActionText::Attachment::SELECTOR) do |node|
  51. 19 block.call(attachment_for_node(node, **options))
  52. end
  53. 65 self.class.new(content, canonicalize: false)
  54. end
  55. 1 def render_attachment_galleries(&block)
  56. 39 content = ActionText::AttachmentGallery.fragment_by_replacing_attachment_gallery_nodes(fragment) do |node|
  57. 3 block.call(attachment_gallery_for_node(node))
  58. end
  59. 39 self.class.new(content, canonicalize: false)
  60. end
  61. 1 def to_plain_text
  62. 26 render_attachments(with_full_attributes: false, &:to_plain_text).fragment.to_plain_text
  63. end
  64. 1 def to_trix_html
  65. render_attachments(&:to_trix_attachment).to_html
  66. end
  67. 1 def to_html
  68. 98 fragment.to_html
  69. end
  70. 1 def to_rendered_html_with_layout
  71. 39 renderer.render(partial: "action_text/content/layout", locals: { content: self })
  72. end
  73. 1 def to_s
  74. 39 to_rendered_html_with_layout
  75. end
  76. 1 def as_json(*)
  77. to_html
  78. end
  79. 1 def inspect
  80. "#<#{self.class.name} #{to_s.truncate(25).inspect}>"
  81. end
  82. 1 def ==(other)
  83. 42 if other.is_a?(self.class)
  84. 10 to_s == other.to_s
  85. end
  86. end
  87. 1 private
  88. 1 def attachment_nodes
  89. 19 @attachment_nodes ||= fragment.find_all(ActionText::Attachment::SELECTOR)
  90. end
  91. 1 def attachment_gallery_nodes
  92. 10 @attachment_gallery_nodes ||= ActionText::AttachmentGallery.find_attachment_gallery_nodes(fragment)
  93. end
  94. 1 def attachment_for_node(node, with_full_attributes: true)
  95. 26 attachment = ActionText::Attachment.from_node(node)
  96. 26 with_full_attributes ? attachment.with_full_attributes : attachment
  97. end
  98. 1 def attachment_gallery_for_node(node)
  99. 6 ActionText::AttachmentGallery.from_node(node)
  100. end
  101. end
  102. end
  103. 1 ActiveSupport.run_load_hooks :action_text_content, ActionText::Content

lib/action_text/engine.rb

94.12% lines covered

34 relevant lines. 32 lines covered and 2 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 require "rails"
  3. 1 require "action_controller/railtie"
  4. 1 require "active_record/railtie"
  5. 1 require "active_storage/engine"
  6. 1 require "action_text"
  7. 1 module ActionText
  8. 1 class Engine < Rails::Engine
  9. 1 isolate_namespace ActionText
  10. 1 config.eager_load_namespaces << ActionText
  11. 1 initializer "action_text.attribute" do
  12. 1 ActiveSupport.on_load(:active_record) do
  13. 1 include ActionText::Attribute
  14. end
  15. end
  16. 1 initializer "action_text.attachable" do
  17. 1 ActiveSupport.on_load(:active_storage_blob) do
  18. 1 include ActionText::Attachable
  19. 1 def previewable_attachable?
  20. 13 representable?
  21. end
  22. 1 def attachable_plain_text_representation(caption = nil)
  23. 6 "[#{caption || filename}]"
  24. end
  25. 1 def to_trix_content_attachment_partial_path
  26. nil
  27. end
  28. end
  29. end
  30. 1 initializer "action_text.helper" do
  31. 1 ActiveSupport.on_load(:action_controller_base) do
  32. 1 helper ActionText::Engine.helpers
  33. end
  34. end
  35. 1 initializer "action_text.renderer" do |app|
  36. 4 app.executor.to_run { ActionText::Content.renderer = ApplicationController.renderer }
  37. 4 app.executor.to_complete { ActionText::Content.renderer = ApplicationController.renderer }
  38. 1 ActiveSupport.on_load(:action_text_content) do
  39. 1 self.renderer = ApplicationController.renderer
  40. end
  41. 1 ActiveSupport.on_load(:action_controller_base) do
  42. 1 before_action { ActionText::Content.renderer = ApplicationController.renderer.new(request.env) }
  43. end
  44. end
  45. 1 initializer "action_text.system_test_helper" do
  46. 1 ActiveSupport.on_load(:action_dispatch_system_test_case) do
  47. require "action_text/system_test_helper"
  48. include ActionText::SystemTestHelper
  49. end
  50. end
  51. end
  52. end

lib/action_text/fragment.rb

96.43% lines covered

28 relevant lines. 27 lines covered and 1 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 class Fragment
  4. 1 class << self
  5. 1 def wrap(fragment_or_html)
  6. 505 case fragment_or_html
  7. when self
  8. 310 fragment_or_html
  9. when Nokogiri::HTML::DocumentFragment
  10. 117 new(fragment_or_html)
  11. else
  12. 78 from_html(fragment_or_html)
  13. end
  14. end
  15. 1 def from_html(html)
  16. 78 new(ActionText::HtmlConversion.fragment_for_html(html.to_s.strip))
  17. end
  18. end
  19. 1 attr_reader :source
  20. 1 def initialize(source)
  21. 533 @source = source
  22. end
  23. 1 def find_all(selector)
  24. 147 source.css(selector)
  25. end
  26. 1 def update
  27. 338 yield source = self.source.clone
  28. 338 self.class.new(source)
  29. end
  30. 1 def replace(selector)
  31. 221 update do |source|
  32. 221 source.css(selector).each do |node|
  33. 52 node.replace(yield(node).to_s)
  34. end
  35. end
  36. end
  37. 1 def to_plain_text
  38. 26 @plain_text ||= PlainTextConversion.node_to_plain_text(source)
  39. end
  40. 1 def to_html
  41. 98 @html ||= HtmlConversion.node_to_html(source)
  42. end
  43. 1 def to_s
  44. to_html
  45. end
  46. end
  47. end

lib/action_text/gem_version.rb

0.0% lines covered

12 relevant lines. 0 lines covered and 12 lines missed.
    
  1. # frozen_string_literal: true
  2. module ActionText
  3. # Returns the currently-loaded version of Action Text as a <tt>Gem::Version</tt>.
  4. def self.gem_version
  5. Gem::Version.new VERSION::STRING
  6. end
  7. module VERSION
  8. MAJOR = 6
  9. MINOR = 1
  10. TINY = 0
  11. PRE = "alpha"
  12. STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
  13. end
  14. end

lib/action_text/html_conversion.rb

100.0% lines covered

12 relevant lines. 12 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 module HtmlConversion
  4. 1 extend self
  5. 1 def node_to_html(node)
  6. 84 node.to_html(save_with: Nokogiri::XML::Node::SaveOptions::AS_HTML)
  7. end
  8. 1 def fragment_for_html(html)
  9. 78 document.fragment(html)
  10. end
  11. 1 def create_element(tag_name, attributes = {})
  12. 57 document.create_element(tag_name, attributes)
  13. end
  14. 1 private
  15. 1 def document
  16. 270 Nokogiri::HTML::Document.new.tap { |doc| doc.encoding = "UTF-8" }
  17. end
  18. end
  19. end

lib/action_text/plain_text_conversion.rb

100.0% lines covered

44 relevant lines. 44 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 module PlainTextConversion
  4. 1 extend self
  5. 1 def node_to_plain_text(node)
  6. 26 remove_trailing_newlines(plain_text_for_node(node))
  7. end
  8. 1 private
  9. 1 def plain_text_for_node(node, index = 0)
  10. 1124 if respond_to?(plain_text_method_for_node(node), true)
  11. 1096 send(plain_text_method_for_node(node), node, index)
  12. else
  13. 28 plain_text_for_node_children(node)
  14. end
  15. end
  16. 1 def plain_text_for_node_children(node)
  17. 1066 texts = []
  18. 1066 node.children.each_with_index do |child, index|
  19. 1098 texts << plain_text_for_node(child, index)
  20. end
  21. 1066 texts.join
  22. end
  23. 1 def plain_text_method_for_node(node)
  24. 2220 :"plain_text_for_#{node.name}_node"
  25. end
  26. 1 def plain_text_for_block(node, index = 0)
  27. 18 "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n\n"
  28. end
  29. 1 %i[ h1 p ul ol ].each do |element|
  30. 4 alias_method :"plain_text_for_#{element}_node", :plain_text_for_block
  31. end
  32. 1 def plain_text_for_br_node(node, index)
  33. 4 "\n"
  34. end
  35. 1 def plain_text_for_text_node(node, index)
  36. 54 remove_trailing_newlines(node.text)
  37. end
  38. 1 def plain_text_for_div_node(node, index)
  39. 1009 "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n"
  40. end
  41. 1 def plain_text_for_figcaption_node(node, index)
  42. 1 "[#{remove_trailing_newlines(plain_text_for_node_children(node))}]"
  43. end
  44. 1 def plain_text_for_blockquote_node(node, index)
  45. 2 text = plain_text_for_block(node)
  46. 2 text.sub(/\A(\s*)(.+?)(\s*)\Z/m, '\1“\2”\3')
  47. end
  48. 1 def plain_text_for_li_node(node, index)
  49. 10 bullet = bullet_for_li_node(node, index)
  50. 10 text = remove_trailing_newlines(plain_text_for_node_children(node))
  51. 10 "#{bullet} #{text}\n"
  52. end
  53. 1 def remove_trailing_newlines(text)
  54. 1118 text.chomp("")
  55. end
  56. 1 def bullet_for_li_node(node, index)
  57. 10 if list_node_name_for_li_node(node) == "ol"
  58. 2 "#{index + 1}."
  59. else
  60. 8 "•"
  61. end
  62. end
  63. 1 def list_node_name_for_li_node(node)
  64. 10 node.ancestors.lazy.map(&:name).grep(/^[uo]l$/).first
  65. end
  66. end
  67. end

lib/action_text/serialization.rb

100.0% lines covered

14 relevant lines. 14 lines covered and 0 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 module Serialization
  4. 1 extend ActiveSupport::Concern
  5. 1 class_methods do
  6. 1 def load(content)
  7. 136 new(content) if content
  8. end
  9. 1 def dump(content)
  10. 42 case content
  11. when nil
  12. nil
  13. when self
  14. 32 content.to_html
  15. else
  16. 10 new(content).to_html
  17. end
  18. end
  19. end
  20. # Marshal compatibility
  21. 1 class_methods do
  22. 1 alias_method :_load, :load
  23. end
  24. 1 def _dump(*)
  25. 1 self.class.dump(self)
  26. end
  27. end
  28. end

lib/action_text/system_test_helper.rb

0.0% lines covered

22 relevant lines. 0 lines covered and 22 lines missed.
    
  1. # frozen_string_literal: true
  2. module ActionText
  3. module SystemTestHelper
  4. # Locates a Trix editor and fills it in with the given HTML.
  5. #
  6. # The editor can be found by:
  7. # * its +id+
  8. # * its +placeholder+
  9. # * its +aria-label+
  10. # * the +name+ of its input
  11. #
  12. # Examples:
  13. #
  14. # # <trix-editor id="message_content" ...></trix-editor>
  15. # fill_in_rich_text_area "message_content", with: "Hello <em>world!</em>"
  16. #
  17. # # <trix-editor placeholder="Your message here" ...></trix-editor>
  18. # fill_in_rich_text_area "Your message here", with: "Hello <em>world!</em>"
  19. #
  20. # # <trix-editor aria-label="Message content" ...></trix-editor>
  21. # fill_in_rich_text_area "Message content", with: "Hello <em>world!</em>"
  22. #
  23. # # <input id="trix_input_1" name="message[content]" type="hidden">
  24. # # <trix-editor input="trix_input_1"></trix-editor>
  25. # fill_in_rich_text_area "message[content]", with: "Hello <em>world!</em>"
  26. def fill_in_rich_text_area(locator = nil, with:)
  27. find(:rich_text_area, locator).execute_script("this.editor.loadHTML(arguments[0])", with.to_s)
  28. end
  29. end
  30. end
  31. Capybara.add_selector :rich_text_area do
  32. label "rich-text area"
  33. xpath do |locator|
  34. if locator.nil?
  35. XPath.descendant(:"trix-editor")
  36. else
  37. input_located_by_name = XPath.anywhere(:input).where(XPath.attr(:name) == locator).attr(:id)
  38. XPath.descendant(:"trix-editor").where \
  39. XPath.attr(:id).equals(locator) |
  40. XPath.attr(:placeholder).equals(locator) |
  41. XPath.attr(:"aria-label").equals(locator) |
  42. XPath.attr(:input).equals(input_located_by_name)
  43. end
  44. end
  45. end

lib/action_text/trix_attachment.rb

92.59% lines covered

54 relevant lines. 50 lines covered and 4 lines missed.
    
  1. # frozen_string_literal: true
  2. 1 module ActionText
  3. 1 class TrixAttachment
  4. 1 TAG_NAME = "figure"
  5. 1 SELECTOR = "[data-trix-attachment]"
  6. 1 COMPOSED_ATTRIBUTES = %w( caption presentation )
  7. 1 ATTRIBUTES = %w( sgid contentType url href filename filesize width height previewable content ) + COMPOSED_ATTRIBUTES
  8. 1 ATTRIBUTE_TYPES = {
  9. 8 "previewable" => ->(value) { value.to_s == "true" },
  10. 7 "filesize" => ->(value) { Integer(value.to_s) rescue value },
  11. "width" => ->(value) { Integer(value.to_s) rescue nil },
  12. "height" => ->(value) { Integer(value.to_s) rescue nil },
  13. 15 :default => ->(value) { value.to_s }
  14. }
  15. 1 class << self
  16. 1 def from_attributes(attributes)
  17. 15 attributes = process_attributes(attributes)
  18. 15 trix_attachment_attributes = attributes.except(*COMPOSED_ATTRIBUTES)
  19. 15 trix_attributes = attributes.slice(*COMPOSED_ATTRIBUTES)
  20. 15 node = ActionText::HtmlConversion.create_element(TAG_NAME)
  21. 15 node["data-trix-attachment"] = JSON.generate(trix_attachment_attributes)
  22. 15 node["data-trix-attributes"] = JSON.generate(trix_attributes) if trix_attributes.any?
  23. 15 new(node)
  24. end
  25. 1 private
  26. 1 def process_attributes(attributes)
  27. 15 typecast_attribute_values(transform_attribute_keys(attributes))
  28. end
  29. 1 def transform_attribute_keys(attributes)
  30. 45 attributes.transform_keys { |key| key.to_s.underscore.camelize(:lower) }
  31. end
  32. 1 def typecast_attribute_values(attributes)
  33. attributes.map do |key, value|
  34. 30 typecast = ATTRIBUTE_TYPES[key] || ATTRIBUTE_TYPES[:default]
  35. 30 [key, typecast.call(value)]
  36. 15 end.to_h
  37. end
  38. end
  39. 1 attr_reader :node
  40. 1 def initialize(node)
  41. 17 @node = node
  42. end
  43. 1 def attributes
  44. 23 @attributes ||= attachment_attributes.merge(composed_attributes).slice(*ATTRIBUTES)
  45. end
  46. 1 def to_html
  47. ActionText::HtmlConversion.node_to_html(node)
  48. end
  49. 1 def to_s
  50. to_html
  51. end
  52. 1 private
  53. 1 def attachment_attributes
  54. 16 read_json_object_attribute("data-trix-attachment")
  55. end
  56. 1 def composed_attributes
  57. 16 read_json_object_attribute("data-trix-attributes")
  58. end
  59. 1 def read_json_object_attribute(name)
  60. 32 read_json_attribute(name) || {}
  61. end
  62. 1 def read_json_attribute(name)
  63. 32 if value = node[name]
  64. 19 begin
  65. 19 JSON.parse(value)
  66. 1 rescue => e
  67. 1 Rails.logger.error "[#{self.class.name}] Couldn't parse JSON #{value} from NODE #{node.inspect}"
  68. 1 Rails.logger.error "[#{self.class.name}] Failed with #{e.class}: #{e.backtrace}"
  69. 1 nil
  70. end
  71. end
  72. end
  73. end
  74. end

lib/action_text/version.rb

0.0% lines covered

6 relevant lines. 0 lines covered and 6 lines missed.
    
  1. # frozen_string_literal: true
  2. require_relative "gem_version"
  3. module ActionText
  4. # Returns the currently-loaded version of Action Text as a <tt>Gem::Version</tt>.
  5. def self.version
  6. gem_version
  7. end
  8. end

lib/generators/action_text/install/install_generator.rb

0.0% lines covered

51 relevant lines. 0 lines covered and 51 lines missed.
    
  1. # frozen_string_literal: true
  2. require "pathname"
  3. require "json"
  4. module ActionText
  5. module Generators
  6. class InstallGenerator < ::Rails::Generators::Base
  7. source_root File.expand_path("templates", __dir__)
  8. def install_javascript_dependencies
  9. rails_command "app:binstub:yarn", inline: true
  10. say "Installing JavaScript dependencies", :green
  11. run "bin/yarn add #{js_dependencies.map { |name, version| "#{name}@#{version}" }.join(" ")}",
  12. abort_on_failure: true, capture: true
  13. end
  14. def append_dependencies_to_package_file
  15. if (app_javascript_pack_path = Pathname.new("app/javascript/packs/application.js")).exist?
  16. js_dependencies.each_key do |dependency|
  17. line = %[require("#{dependency}")]
  18. unless app_javascript_pack_path.read.include? line
  19. say "Adding #{dependency} to #{app_javascript_pack_path}", :green
  20. append_to_file app_javascript_pack_path, "\n#{line}"
  21. end
  22. end
  23. else
  24. say <<~WARNING, :red
  25. WARNING: Action Text can't locate your JavaScript bundle to add its package dependencies.
  26. Add these lines to any bundles:
  27. require("trix")
  28. require("@rails/actiontext")
  29. Alternatively, install and setup the webpacker gem then rerun `bin/rails action_text:install`
  30. to have these dependencies added automatically.
  31. WARNING
  32. end
  33. end
  34. def create_actiontext_files
  35. template "actiontext.scss", "app/assets/stylesheets/actiontext.scss"
  36. copy_file "#{GEM_ROOT}/app/views/active_storage/blobs/_blob.html.erb",
  37. "app/views/active_storage/blobs/_blob.html.erb"
  38. end
  39. def create_migrations
  40. rails_command "railties:install:migrations FROM=active_storage,action_text", inline: true
  41. end
  42. hook_for :test_framework
  43. private
  44. GEM_ROOT = "#{__dir__}/../../../.."
  45. def js_dependencies
  46. js_package = JSON.load(Pathname.new("#{GEM_ROOT}/package.json"))
  47. js_package["peerDependencies"].merge \
  48. js_package["name"] => "^#{js_package["version"]}"
  49. end
  50. end
  51. end
  52. end

lib/rails/generators/test_unit/install_generator.rb

0.0% lines covered

10 relevant lines. 0 lines covered and 10 lines missed.
    
  1. # frozen_string_literal: true
  2. module TestUnit
  3. module Generators
  4. class InstallGenerator < ::Rails::Generators::Base
  5. source_root File.expand_path("templates", __dir__)
  6. def create_test_files
  7. template "fixtures.yml", "test/fixtures/action_text/rich_texts.yml"
  8. end
  9. end
  10. end
  11. end