Initial import from BC3 RichText
This commit is contained in:
parent
e22ba227a6
commit
68d350ddac
|
@ -69,6 +69,7 @@ PATH
|
|||
activetext (0.1)
|
||||
activerecord (>= 5.2.0)
|
||||
activestorage (>= 5.2.0)
|
||||
nokogiri
|
||||
rails (>= 5.2.0)
|
||||
|
||||
GEM
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue