From 68d350ddacedf604717f0d1074d7624fa57757c2 Mon Sep 17 00:00:00 2001 From: Sam Stephenson Date: Wed, 7 Feb 2018 18:26:19 -0600 Subject: Initial import from BC3 RichText --- lib/active_text.rb | 27 ++++++ lib/active_text/attachable.rb | 24 +++++ lib/active_text/attachables/content_attachment.rb | 36 ++++++++ lib/active_text/attachables/missing_attachable.rb | 11 +++ lib/active_text/attachables/remote_image.rb | 44 ++++++++++ lib/active_text/attachment.rb | 101 ++++++++++++++++++++++ lib/active_text/attachments/caching.rb | 14 +++ lib/active_text/attachments/minification.rb | 15 ++++ lib/active_text/attachments/trix_conversion.rb | 32 +++++++ lib/active_text/content.rb | 80 +++++++++++++++++ lib/active_text/fragment.rb | 55 ++++++++++++ lib/active_text/html_conversion.rb | 22 +++++ lib/active_text/plain_text_conversion.rb | 79 +++++++++++++++++ lib/active_text/serialization.rb | 32 +++++++ lib/active_text/trix_attachment.rb | 89 +++++++++++++++++++ 15 files changed, 661 insertions(+) create mode 100644 lib/active_text/attachable.rb create mode 100644 lib/active_text/attachables/content_attachment.rb create mode 100644 lib/active_text/attachables/missing_attachable.rb create mode 100644 lib/active_text/attachables/remote_image.rb create mode 100644 lib/active_text/attachment.rb create mode 100644 lib/active_text/attachments/caching.rb create mode 100644 lib/active_text/attachments/minification.rb create mode 100644 lib/active_text/attachments/trix_conversion.rb create mode 100644 lib/active_text/content.rb create mode 100644 lib/active_text/fragment.rb create mode 100644 lib/active_text/html_conversion.rb create mode 100644 lib/active_text/plain_text_conversion.rb create mode 100644 lib/active_text/serialization.rb create mode 100644 lib/active_text/trix_attachment.rb (limited to 'lib') 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 -- cgit v1.2.3