Initial import from BC3 RichText

This commit is contained in:
Sam Stephenson 2018-02-07 18:26:19 -06:00
parent e22ba227a6
commit 68d350ddac
17 changed files with 663 additions and 0 deletions

View File

@ -69,6 +69,7 @@ PATH
activetext (0.1)
activerecord (>= 5.2.0)
activestorage (>= 5.2.0)
nokogiri
rails (>= 5.2.0)
GEM

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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