aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSam Stephenson <sam@37signals.com>2018-02-07 18:26:19 -0600
committerSam Stephenson <sam@37signals.com>2018-02-07 18:26:19 -0600
commit68d350ddacedf604717f0d1074d7624fa57757c2 (patch)
treea01852130448e37368ed2fd4af7e930e6c2cd0f2
parente22ba227a694b8426e69dbce640c5b0e4f39f574 (diff)
downloadrails-68d350ddacedf604717f0d1074d7624fa57757c2.tar.gz
rails-68d350ddacedf604717f0d1074d7624fa57757c2.tar.bz2
rails-68d350ddacedf604717f0d1074d7624fa57757c2.zip
Initial import from BC3 RichText
-rw-r--r--Gemfile.lock1
-rw-r--r--activetext.gemspec1
-rw-r--r--lib/active_text.rb27
-rw-r--r--lib/active_text/attachable.rb24
-rw-r--r--lib/active_text/attachables/content_attachment.rb36
-rw-r--r--lib/active_text/attachables/missing_attachable.rb11
-rw-r--r--lib/active_text/attachables/remote_image.rb44
-rw-r--r--lib/active_text/attachment.rb101
-rw-r--r--lib/active_text/attachments/caching.rb14
-rw-r--r--lib/active_text/attachments/minification.rb15
-rw-r--r--lib/active_text/attachments/trix_conversion.rb32
-rw-r--r--lib/active_text/content.rb80
-rw-r--r--lib/active_text/fragment.rb55
-rw-r--r--lib/active_text/html_conversion.rb22
-rw-r--r--lib/active_text/plain_text_conversion.rb79
-rw-r--r--lib/active_text/serialization.rb32
-rw-r--r--lib/active_text/trix_attachment.rb89
17 files changed, 663 insertions, 0 deletions
diff --git a/Gemfile.lock b/Gemfile.lock
index d6f040c0e8..ce010a91c0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -69,6 +69,7 @@ PATH
activetext (0.1)
activerecord (>= 5.2.0)
activestorage (>= 5.2.0)
+ nokogiri
rails (>= 5.2.0)
GEM
diff --git a/activetext.gemspec b/activetext.gemspec
index 9f5e611f2f..1a43fdb688 100644
--- a/activetext.gemspec
+++ b/activetext.gemspec
@@ -12,6 +12,7 @@ Gem::Specification.new do |s|
s.add_dependency "rails", ">= 5.2.0"
s.add_dependency "activerecord", ">= 5.2.0"
s.add_dependency "activestorage", ">= 5.2.0"
+ s.add_dependency "nokogiri"
s.add_development_dependency "bundler", "~> 1.15"
diff --git a/lib/active_text.rb b/lib/active_text.rb
index e6384020f1..e9dfaeefe4 100644
--- a/lib/active_text.rb
+++ b/lib/active_text.rb
@@ -1,5 +1,32 @@
require "active_record"
require "active_text/engine"
+require "nokogiri"
module ActiveText
+ extend ActiveSupport::Autoload
+
+ autoload :Attachable
+ autoload :Attachment
+ autoload :Content
+ autoload :Fragment
+ autoload :HtmlConversion
+ autoload :PlainTextConversion
+ autoload :Serialization
+ autoload :TrixAttachment
+
+ module Attachables
+ extend ActiveSupport::Autoload
+
+ autoload :ContentAttachment
+ autoload :MissingAttachable
+ autoload :RemoteImage
+ end
+
+ module Attachments
+ extend ActiveSupport::Autoload
+
+ autoload :Caching
+ autoload :Minification
+ autoload :TrixConversion
+ end
end
diff --git a/lib/active_text/attachable.rb b/lib/active_text/attachable.rb
new file mode 100644
index 0000000000..445ee30605
--- /dev/null
+++ b/lib/active_text/attachable.rb
@@ -0,0 +1,24 @@
+module ActiveText
+ module Attachable
+ class << self
+ def from_node(node)
+ if attachable = attachable_from_sgid(node["sgid"])
+ attachable
+ elsif attachable = ActiveText::Attachables::ContentAttachment.from_node(node)
+ attachable
+ elsif attachable = ActiveText::Attachables::RemoteImage.from_node(node)
+ attachable
+ else
+ ActiveText::Attachables::MissingAttachable
+ end
+ end
+
+ private
+ def attachable_from_sgid(sgid)
+ ::Attachable.from_attachable_sgid(sgid)
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
+ end
+ end
+end
diff --git a/lib/active_text/attachables/content_attachment.rb b/lib/active_text/attachables/content_attachment.rb
new file mode 100644
index 0000000000..316d7e304a
--- /dev/null
+++ b/lib/active_text/attachables/content_attachment.rb
@@ -0,0 +1,36 @@
+module ActiveText
+ module Attachables
+ class ContentAttachment
+ include ActiveModel::Model
+
+ def self.from_node(node)
+ if node["content-type"]
+ if matches = node["content-type"].match(/vnd\.rubyonrails\.(.+)\.html/)
+ attachment = new(name: matches[1])
+ attachment if attachment.valid?
+ end
+ end
+ end
+
+ attr_accessor :name
+ validates_inclusion_of :name, in: %w( horizontal-rule )
+
+ def attachable_plain_text_representation(caption)
+ case name
+ when "horizontal-rule"
+ " ┄ "
+ else
+ " "
+ end
+ end
+
+ def to_partial_path
+ "active_text/attachables/content_attachment"
+ end
+
+ def to_trix_content_attachment_partial_path
+ "active_text/attachables/content_attachments/#{name.underscore}"
+ end
+ end
+ end
+end
diff --git a/lib/active_text/attachables/missing_attachable.rb b/lib/active_text/attachables/missing_attachable.rb
new file mode 100644
index 0000000000..94a095ce09
--- /dev/null
+++ b/lib/active_text/attachables/missing_attachable.rb
@@ -0,0 +1,11 @@
+module ActiveText
+ module Attachables
+ module MissingAttachable
+ extend ActiveModel::Naming
+
+ def self.to_partial_path
+ "active_text/attachables/missing_attachable"
+ end
+ end
+ end
+end
diff --git a/lib/active_text/attachables/remote_image.rb b/lib/active_text/attachables/remote_image.rb
new file mode 100644
index 0000000000..0624b5564d
--- /dev/null
+++ b/lib/active_text/attachables/remote_image.rb
@@ -0,0 +1,44 @@
+module ActiveText
+ module Attachables
+ class RemoteImage
+ extend ActiveModel::Naming
+
+ class << self
+ def from_node(node)
+ if node["url"] && content_type_is_image?(node["content-type"])
+ new(attributes_from_node(node))
+ end
+ end
+
+ private
+ def content_type_is_image?(content_type)
+ content_type.to_s =~ /^image(\/.+|$)/
+ end
+
+ def attributes_from_node(node)
+ { url: node["url"],
+ content_type: node["content-type"],
+ width: node["width"],
+ height: node["height"] }
+ end
+ end
+
+ attr_reader :url, :content_type, :width, :height
+
+ def initialize(attributes = {})
+ @url = attributes[:url]
+ @content_type = attributes[:content_type]
+ @width = attributes[:width]
+ @height = attributes[:height]
+ end
+
+ def attachable_plain_text_representation(caption)
+ "[#{caption || "Image"}]"
+ end
+
+ def to_partial_path
+ "active_text/attachables/remote_image"
+ end
+ end
+ end
+end
diff --git a/lib/active_text/attachment.rb b/lib/active_text/attachment.rb
new file mode 100644
index 0000000000..870390f63e
--- /dev/null
+++ b/lib/active_text/attachment.rb
@@ -0,0 +1,101 @@
+module ActiveText
+ class Attachment
+ include Attachments::TrixConversion, Attachments::Minification, Attachments::Caching
+
+ TAG_NAME = "active-text-attachment"
+ SELECTOR = TAG_NAME
+ ATTRIBUTES = %w( sgid content-type url href filename filesize width height previewable caption )
+
+ class << self
+ def fragment_by_canonicalizing_attachments(content)
+ fragment_by_minifying_attachments(fragment_by_converting_trix_attachments(content))
+ end
+
+ def from_node(node, attachable = nil)
+ new(node, attachable || ActiveText::Attachable.from_node(node))
+ end
+
+ def from_attachables(attachables)
+ Array(attachables).map { |attachable| from_attachable(attachable) }.compact
+ end
+
+ def from_attachable(attachable, attributes = {})
+ if node = node_from_attributes(attachable.to_active_text_attributes(attributes))
+ new(node, attachable)
+ end
+ end
+
+ def from_attributes(attributes, attachable = nil)
+ if node = node_from_attributes(attributes)
+ from_node(node, attachable)
+ end
+ end
+
+ private
+ def node_from_attributes(attributes)
+ if attributes = process_attributes(attributes).presence
+ ActiveText::HtmlConversion.create_element(TAG_NAME, attributes)
+ end
+ end
+
+ def process_attributes(attributes)
+ attributes.transform_keys { |key| key.to_s.underscore.dasherize }.slice(*ATTRIBUTES)
+ end
+ end
+
+ attr_reader :node, :attachable
+
+ delegate :to_param, to: :attachable
+ delegate_missing_to :attachable
+
+ def initialize(node, attachable)
+ @node = node
+ @attachable = attachable
+ end
+
+ def caption
+ node_attributes["caption"].presence
+ end
+
+ def full_attributes
+ node_attributes.merge(attachable_attributes).merge(sgid_attributes)
+ end
+
+ def with_full_attributes
+ self.class.from_attributes(full_attributes, attachable)
+ end
+
+ def to_plain_text
+ if respond_to?(:attachable_plain_text_representation)
+ attachable_plain_text_representation(caption)
+ else
+ caption.to_s
+ end
+ end
+
+ def to_html
+ HtmlConversion.node_to_html(node)
+ end
+
+ def to_s
+ to_html
+ end
+
+ def inspect
+ "#<#{self.class.name} attachable=#{attachable.inspect}>"
+ end
+
+ private
+ def node_attributes
+ @node_attributes ||= ATTRIBUTES.map { |name| [ name.underscore, node[name] ] }.to_h.compact
+ end
+
+ def attachable_attributes
+ @attachable_attributes ||= (attachable.try(:to_active_text_attributes) || {}).stringify_keys
+ end
+
+ def sgid_attributes
+ @sgid_attributes ||= node_attributes.slice("sgid").presence || attachable_attributes.slice("sgid")
+ end
+ end
+end
diff --git a/lib/active_text/attachments/caching.rb b/lib/active_text/attachments/caching.rb
new file mode 100644
index 0000000000..b0194170e1
--- /dev/null
+++ b/lib/active_text/attachments/caching.rb
@@ -0,0 +1,14 @@
+module ActiveText
+ module Attachments
+ module Caching
+ def cache_key(*args)
+ [self.class.name, cache_digest, *attachable.cache_key(*args)].join("/")
+ end
+
+ private
+ def cache_digest
+ Digest::SHA256.hexdigest(node.to_s)
+ end
+ end
+ end
+end
diff --git a/lib/active_text/attachments/minification.rb b/lib/active_text/attachments/minification.rb
new file mode 100644
index 0000000000..b33822862f
--- /dev/null
+++ b/lib/active_text/attachments/minification.rb
@@ -0,0 +1,15 @@
+module ActiveText
+ module Attachments
+ module Minification
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def fragment_by_minifying_attachments(content)
+ Fragment.wrap(content).replace(ActiveText::Attachment::SELECTOR) do |node|
+ node.tap { |node| node.inner_html = "" }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/active_text/attachments/trix_conversion.rb b/lib/active_text/attachments/trix_conversion.rb
new file mode 100644
index 0000000000..25eada6709
--- /dev/null
+++ b/lib/active_text/attachments/trix_conversion.rb
@@ -0,0 +1,32 @@
+module ActiveText
+ module Attachments
+ module TrixConversion
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def fragment_by_converting_trix_attachments(content)
+ Fragment.wrap(content).replace(TrixAttachment::SELECTOR) do |node|
+ from_trix_attachment(TrixAttachment.new(node))
+ end
+ end
+
+ def from_trix_attachment(trix_attachment)
+ from_attributes(trix_attachment.attributes)
+ end
+ end
+
+ def to_trix_attachment(content = trix_attachment_content)
+ attributes = full_attributes.dup
+ attributes["content"] = content if content
+ TrixAttachment.from_attributes(attributes)
+ end
+
+ private
+ def trix_attachment_content
+ if partial_path = attachable.try(:to_trix_content_attachment_partial_path)
+ ApplicationRenderer.render(partial: partial_path, object: self, as: model_name.element)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/active_text/content.rb b/lib/active_text/content.rb
new file mode 100644
index 0000000000..0154cea17e
--- /dev/null
+++ b/lib/active_text/content.rb
@@ -0,0 +1,80 @@
+module ActiveText
+ class Content
+ include Serialization
+
+ attr_reader :fragment
+
+ delegate :blank?, :empty?, :present?, to: :to_s
+
+ def initialize(content = nil)
+ @fragment = ActiveText::Attachment.fragment_by_canonicalizing_attachments(content)
+ end
+
+ def links
+ @links ||= fragment.find_all("a[href]").map { |a| a["href"] }.uniq
+ end
+
+ def attachments
+ @attachments ||= attachment_nodes.map do |node|
+ attachment_for_node(node)
+ end
+ end
+
+ def attachables
+ @attachables ||= attachment_nodes.map do |node|
+ ActiveText::Attachable.from_node(node)
+ end
+ end
+
+ def append_attachables(attachables)
+ attachments = ActiveText::Attachment.from_attachables(attachables)
+ self.class.new([self.to_s.presence, *attachments].compact.join("\n"))
+ end
+
+ def render_attachments(**options, &block)
+ fragment.replace(ActiveText::Attachment::SELECTOR) do |node|
+ block.call(attachment_for_node(node, **options))
+ end
+ end
+
+ def to_plain_text
+ render_attachments(with_full_attributes: false, &:to_plain_text).to_plain_text
+ end
+
+ def to_trix_html
+ render_attachments(&:to_trix_attachment).to_html
+ end
+
+ def to_html
+ fragment.to_html
+ end
+
+ def to_s
+ to_html
+ end
+
+ def as_json(*)
+ to_html
+ end
+
+ def inspect
+ "#<#{self.class.name} #{to_s.truncate(25).inspect}>"
+ end
+
+ def ==(other)
+ if other.is_a?(self.class)
+ to_s == other.to_s
+ end
+ end
+
+ private
+ def attachment_nodes
+ @attachment_nodes ||= fragment.find_all(ActiveText::Attachment::SELECTOR)
+ end
+
+ def attachment_for_node(node, with_full_attributes: true)
+ attachment = ActiveText::Attachment.from_node(node)
+ with_full_attributes ? attachment.with_full_attributes : attachment
+ end
+ end
+end
diff --git a/lib/active_text/fragment.rb b/lib/active_text/fragment.rb
new file mode 100644
index 0000000000..9e0af6f57a
--- /dev/null
+++ b/lib/active_text/fragment.rb
@@ -0,0 +1,55 @@
+module ActiveText
+ class Fragment
+ class << self
+ def wrap(fragment_or_html)
+ case fragment_or_html
+ when self
+ fragment_or_html
+ when Nokogiri::HTML::DocumentFragment
+ new(fragment_or_html)
+ else
+ from_html(fragment_or_html)
+ end
+ end
+
+ def from_html(html)
+ new(ActiveText::HtmlConversion.fragment_for_html(html.to_s.strip))
+ end
+ end
+
+ attr_reader :source
+
+ def initialize(source)
+ @source = source
+ end
+
+ def find_all(selector)
+ source.css(selector)
+ end
+
+ def update
+ yield source = self.source.clone
+ self.class.new(source)
+ end
+
+ def replace(selector)
+ update do |source|
+ source.css(selector).each do |node|
+ node.replace(yield(node).to_s)
+ end
+ end
+ end
+
+ def to_plain_text
+ @plain_text ||= PlainTextConversion.node_to_plain_text(source)
+ end
+
+ def to_html
+ @html ||= HtmlConversion.node_to_html(source)
+ end
+
+ def to_s
+ to_html
+ end
+ end
+end
diff --git a/lib/active_text/html_conversion.rb b/lib/active_text/html_conversion.rb
new file mode 100644
index 0000000000..7c35e5cf94
--- /dev/null
+++ b/lib/active_text/html_conversion.rb
@@ -0,0 +1,22 @@
+module ActiveText
+ module HtmlConversion
+ extend self
+
+ def node_to_html(node)
+ node.to_html(save_with: Nokogiri::XML::Node::SaveOptions::AS_HTML)
+ end
+
+ def fragment_for_html(html)
+ document.fragment(html)
+ end
+
+ def create_element(tag_name, attributes = {})
+ document.create_element(tag_name, attributes)
+ end
+
+ private
+ def document
+ Nokogiri::HTML::Document.new.tap { |doc| doc.encoding = "UTF-8" }
+ end
+ end
+end
diff --git a/lib/active_text/plain_text_conversion.rb b/lib/active_text/plain_text_conversion.rb
new file mode 100644
index 0000000000..3e5f3b8654
--- /dev/null
+++ b/lib/active_text/plain_text_conversion.rb
@@ -0,0 +1,79 @@
+module ActiveText
+ module PlainTextConversion
+ extend self
+
+ def node_to_plain_text(node)
+ remove_trailing_newlines(plain_text_for_node(node))
+ end
+
+ private
+ def plain_text_for_node(node, index = 0)
+ if respond_to?(plain_text_method_for_node(node), true)
+ send(plain_text_method_for_node(node), node, index)
+ else
+ plain_text_for_node_children(node)
+ end
+ end
+
+ def plain_text_for_node_children(node)
+ node.children.each_with_index.map do |node, index|
+ plain_text_for_node(node, index)
+ end.compact.join("")
+ end
+
+ def plain_text_method_for_node(node)
+ :"plain_text_for_#{node.name}_node"
+ end
+
+ def plain_text_for_block(node, index = 0)
+ "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n\n"
+ end
+
+ %i[ p ul ol ].each do |element|
+ alias_method :"plain_text_for_#{element}_node", :plain_text_for_block
+ end
+
+ def plain_text_for_br_node(node, index)
+ "\n"
+ end
+
+ def plain_text_for_text_node(node, index)
+ remove_trailing_newlines(node.text)
+ end
+
+ def plain_text_for_div_node(node, index)
+ "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n"
+ end
+
+ def plain_text_for_figcaption_node(node, index)
+ "[#{remove_trailing_newlines(plain_text_for_node_children(node))}]"
+ end
+
+ def plain_text_for_blockquote_node(node, index)
+ text = plain_text_for_block(node)
+ text.sub(/\A(\s*)(.+?)(\s*)\Z/m, '\1“\2”\3')
+ end
+
+ def plain_text_for_li_node(node, index)
+ bullet = bullet_for_li_node(node, index)
+ text = remove_trailing_newlines(plain_text_for_node_children(node))
+ "#{bullet} #{text}\n"
+ end
+
+ def remove_trailing_newlines(text)
+ text.chomp("")
+ end
+
+ def bullet_for_li_node(node, index)
+ if list_node_name_for_li_node(node) == "ol"
+ "#{index + 1}."
+ else
+ "•"
+ end
+ end
+
+ def list_node_name_for_li_node(node)
+ node.ancestors.lazy.map(&:name).grep(/^[uo]l$/).first
+ end
+ end
+end
diff --git a/lib/active_text/serialization.rb b/lib/active_text/serialization.rb
new file mode 100644
index 0000000000..46ebeba1aa
--- /dev/null
+++ b/lib/active_text/serialization.rb
@@ -0,0 +1,32 @@
+module ActiveText
+ module Serialization
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def load(content)
+ new(content) if content
+ end
+
+ def dump(content)
+ case content
+ when nil
+ nil
+ when self
+ content.to_html
+ else
+ new(content).to_html
+ end
+ end
+ end
+
+ # Marshal compatibility
+
+ class_methods do
+ alias_method :_load, :load
+ end
+
+ def _dump(*)
+ self.class.dump(self)
+ end
+ end
+end
diff --git a/lib/active_text/trix_attachment.rb b/lib/active_text/trix_attachment.rb
new file mode 100644
index 0000000000..19add53414
--- /dev/null
+++ b/lib/active_text/trix_attachment.rb
@@ -0,0 +1,89 @@
+module ActiveText
+ class TrixAttachment
+ TAG_NAME = "figure"
+ SELECTOR = "[data-trix-attachment]"
+
+ ATTRIBUTES = %w( sgid contentType url href filename filesize width height previewable content caption )
+ ATTRIBUTE_TYPES = {
+ "previewable" => ->(value) { value.to_s == "true" },
+ "filesize" => ->(value) { Integer(value.to_s) rescue value },
+ "width" => ->(value) { Integer(value.to_s) rescue nil },
+ "height" => ->(value) { Integer(value.to_s) rescue nil },
+ :default => ->(value) { value.to_s }
+ }
+
+ class << self
+ def from_attributes(attributes)
+ attributes = process_attributes(attributes)
+
+ trix_attachment_attributes = attributes.except("caption")
+ trix_attributes = attributes.slice("caption")
+
+ node = ActiveText::HtmlConversion.create_element(TAG_NAME)
+ node["data-trix-attachment"] = JSON.generate(trix_attachment_attributes)
+ node["data-trix-attributes"] = JSON.generate(trix_attributes) if trix_attributes.any?
+
+ new(node)
+ end
+
+ private
+ def process_attributes(attributes)
+ typecast_attribute_values(transform_attribute_keys(attributes))
+ end
+
+ def transform_attribute_keys(attributes)
+ attributes.transform_keys { |key| key.to_s.underscore.camelize(:lower) }
+ end
+
+ def typecast_attribute_values(attributes)
+ attributes.map do |key, value|
+ typecast = ATTRIBUTE_TYPES[key] || ATTRIBUTE_TYPES[:default]
+ [key, typecast.call(value)]
+ end.to_h
+ end
+ end
+
+ attr_reader :node
+
+ def initialize(node)
+ @node = node
+ end
+
+ def attributes
+ @attributes ||= attachment_attributes.merge(composed_attributes).slice(*ATTRIBUTES)
+ end
+
+ def to_html
+ ActiveText::HtmlConversion.node_to_html(node)
+ end
+
+ def to_s
+ to_html
+ end
+
+ private
+ def attachment_attributes
+ read_json_object_attribute("data-trix-attachment")
+ end
+
+ def composed_attributes
+ read_json_object_attribute("data-trix-attributes")
+ end
+
+ def read_json_object_attribute(name)
+ read_json_attribute(name) || {}
+ end
+
+ def read_json_attribute(name)
+ if value = node[name]
+ begin
+ JSON.parse(value)
+ rescue => e
+ Rails.logger.error "[#{self.class.name}] Couldn't parse JSON #{value} from NODE #{node.inspect}"
+ Rails.logger.error "[#{self.class.name}] Failed with #{e.class}: #{e.backtrace}"
+ nil
+ end
+ end
+ end
+ end
+end