diff options
author | David Heinemeier Hansson <david@loudthinking.com> | 2018-04-13 16:23:04 -0700 |
---|---|---|
committer | David Heinemeier Hansson <david@loudthinking.com> | 2018-04-13 16:23:04 -0700 |
commit | f1d74871e7f00e8bbde3501a759487ac8cc4c3fc (patch) | |
tree | 2f1fa680bd1e6a356256fdc59cab5a6af8d74da7 /lib/action_text | |
parent | 3bc244abc1800c7617cbfbbe1dd2597053a638c9 (diff) | |
download | rails-f1d74871e7f00e8bbde3501a759487ac8cc4c3fc.tar.gz rails-f1d74871e7f00e8bbde3501a759487ac8cc4c3fc.tar.bz2 rails-f1d74871e7f00e8bbde3501a759487ac8cc4c3fc.zip |
Rename from Active Text to Action Text
This is more like Action View than Active Model.
Diffstat (limited to 'lib/action_text')
-rw-r--r-- | lib/action_text/attachable.rb | 64 | ||||
-rw-r--r-- | lib/action_text/attachables/content_attachment.rb | 36 | ||||
-rw-r--r-- | lib/action_text/attachables/missing_attachable.rb | 11 | ||||
-rw-r--r-- | lib/action_text/attachables/remote_image.rb | 44 | ||||
-rw-r--r-- | lib/action_text/attachment.rb | 101 | ||||
-rw-r--r-- | lib/action_text/attachments/caching.rb | 14 | ||||
-rw-r--r-- | lib/action_text/attachments/minification.rb | 15 | ||||
-rw-r--r-- | lib/action_text/attachments/trix_conversion.rb | 32 | ||||
-rw-r--r-- | lib/action_text/attribute.rb | 18 | ||||
-rw-r--r-- | lib/action_text/content.rb | 84 | ||||
-rw-r--r-- | lib/action_text/engine.rb | 45 | ||||
-rw-r--r-- | lib/action_text/fragment.rb | 55 | ||||
-rw-r--r-- | lib/action_text/html_conversion.rb | 22 | ||||
-rw-r--r-- | lib/action_text/plain_text_conversion.rb | 79 | ||||
-rw-r--r-- | lib/action_text/serialization.rb | 32 | ||||
-rw-r--r-- | lib/action_text/trix_attachment.rb | 89 | ||||
-rw-r--r-- | lib/action_text/version.rb | 3 |
17 files changed, 744 insertions, 0 deletions
diff --git a/lib/action_text/attachable.rb b/lib/action_text/attachable.rb new file mode 100644 index 0000000000..83a6cf0c17 --- /dev/null +++ b/lib/action_text/attachable.rb @@ -0,0 +1,64 @@ +module ActionText + module Attachable + extend ActiveSupport::Concern + + LOCATOR_NAME = "attachable" + + class << self + def from_node(node) + if attachable = attachable_from_sgid(node["sgid"]) + attachable + elsif attachable = ActionText::Attachables::ContentAttachment.from_node(node) + attachable + elsif attachable = ActionText::Attachables::RemoteImage.from_node(node) + attachable + else + ActionText::Attachables::MissingAttachable + end + end + + def from_attachable_sgid(sgid, options = {}) + method = sgid.is_a?(Array) ? :locate_many_signed : :locate_signed + record = GlobalID::Locator.public_send(method, sgid, options.merge(for: LOCATOR_NAME)) + record or raise ActiveRecord::RecordNotFound + end + + private + def attachable_from_sgid(sgid) + from_attachable_sgid(sgid) + rescue ActiveRecord::RecordNotFound + nil + end + end + + class_methods do + def from_attachable_sgid(sgid) + ActionText::Attachable.from_attachable_sgid(sgid, only: self) + end + end + + def attachable_sgid + to_sgid(expires_in: nil, for: LOCATOR_NAME).to_s + end + + def attachable_content_type + try(:content_type) || "application/octet-stream" + end + + def previewable_attachable? + false + end + + def as_json(*) + super.merge(attachable_sgid: attachable_sgid) + end + + def to_rich_text_attributes(attributes = {}) + attributes.dup.tap do |attributes| + attributes[:sgid] = attachable_sgid + attributes[:content_type] = attachable_content_type + attributes[:previewable] = true if previewable_attachable? + end + end + end +end diff --git a/lib/action_text/attachables/content_attachment.rb b/lib/action_text/attachables/content_attachment.rb new file mode 100644 index 0000000000..3ebd734786 --- /dev/null +++ b/lib/action_text/attachables/content_attachment.rb @@ -0,0 +1,36 @@ +module ActionText + 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 + "action_text/attachables/content_attachment" + end + + def to_trix_content_attachment_partial_path + "action_text/attachables/content_attachments/#{name.underscore}" + end + end + end +end diff --git a/lib/action_text/attachables/missing_attachable.rb b/lib/action_text/attachables/missing_attachable.rb new file mode 100644 index 0000000000..54b36d3cce --- /dev/null +++ b/lib/action_text/attachables/missing_attachable.rb @@ -0,0 +1,11 @@ +module ActionText + module Attachables + module MissingAttachable + extend ActiveModel::Naming + + def self.to_partial_path + "action_text/attachables/missing_attachable" + end + end + end +end diff --git a/lib/action_text/attachables/remote_image.rb b/lib/action_text/attachables/remote_image.rb new file mode 100644 index 0000000000..2333427371 --- /dev/null +++ b/lib/action_text/attachables/remote_image.rb @@ -0,0 +1,44 @@ +module ActionText + 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 + "action_text/attachables/remote_image" + end + end + end +end diff --git a/lib/action_text/attachment.rb b/lib/action_text/attachment.rb new file mode 100644 index 0000000000..2129a3985b --- /dev/null +++ b/lib/action_text/attachment.rb @@ -0,0 +1,101 @@ +module ActionText + 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 || ActionText::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_rich_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 + ActionText::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_rich_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/action_text/attachments/caching.rb b/lib/action_text/attachments/caching.rb new file mode 100644 index 0000000000..b867e2ff91 --- /dev/null +++ b/lib/action_text/attachments/caching.rb @@ -0,0 +1,14 @@ +module ActionText + 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/action_text/attachments/minification.rb b/lib/action_text/attachments/minification.rb new file mode 100644 index 0000000000..b1ca43b7b8 --- /dev/null +++ b/lib/action_text/attachments/minification.rb @@ -0,0 +1,15 @@ +module ActionText + module Attachments + module Minification + extend ActiveSupport::Concern + + class_methods do + def fragment_by_minifying_attachments(content) + Fragment.wrap(content).replace(ActionText::Attachment::SELECTOR) do |node| + node.tap { |n| n.inner_html = "" } + end + end + end + end + end +end diff --git a/lib/action_text/attachments/trix_conversion.rb b/lib/action_text/attachments/trix_conversion.rb new file mode 100644 index 0000000000..39989ce644 --- /dev/null +++ b/lib/action_text/attachments/trix_conversion.rb @@ -0,0 +1,32 @@ +module ActionText + 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) + ActionText.renderer.render(partial: partial_path, object: self, as: model_name.element) + end + end + end + end +end diff --git a/lib/action_text/attribute.rb b/lib/action_text/attribute.rb new file mode 100644 index 0000000000..32dd1d0e3f --- /dev/null +++ b/lib/action_text/attribute.rb @@ -0,0 +1,18 @@ +module ActionText + module Attribute + extend ActiveSupport::Concern + + class_methods do + def has_rich_text(attribute_name) + serialize(attribute_name, ActionText::Content) + + has_many_attached "#{attribute_name}_attachments" + + after_save do + blobs = public_send(attribute_name).attachments.map(&:attachable) + public_send("#{attribute_name}_attachments_blobs=", blobs) + end + end + end + end +end diff --git a/lib/action_text/content.rb b/lib/action_text/content.rb new file mode 100644 index 0000000000..c63e0c3525 --- /dev/null +++ b/lib/action_text/content.rb @@ -0,0 +1,84 @@ +module ActionText + class Content + include Serialization + + attr_reader :fragment + + delegate :blank?, :empty?, :html_safe, :present?, to: :to_s + + def initialize(content = nil) + @fragment = ActionText::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| + ActionText::Attachable.from_node(node) + end + end + + def append_attachables(attachables) + attachments = ActionText::Attachment.from_attachables(attachables) + self.class.new([self.to_s.presence, *attachments].compact.join("\n")) + end + + def render_attachments(**options, &block) + fragment.replace(ActionText::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 + render_attachments do |attachment| + attachment.node.tap do |node| + node.inner_html = ActionText.renderer.render(attachment) + end + end.to_html + end + + def to_s + to_html.html_safe + 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(ActionText::Attachment::SELECTOR) + end + + def attachment_for_node(node, with_full_attributes: true) + attachment = ActionText::Attachment.from_node(node) + with_full_attributes ? attachment.with_full_attributes : attachment + end + end +end diff --git a/lib/action_text/engine.rb b/lib/action_text/engine.rb new file mode 100644 index 0000000000..71db6d6a26 --- /dev/null +++ b/lib/action_text/engine.rb @@ -0,0 +1,45 @@ +require "rails/engine" + +module ActionText + class Engine < Rails::Engine + isolate_namespace ActionText + config.eager_load_namespaces << ActionText + + initializer "action_text.attribute" do + ActiveSupport.on_load(:active_record) do + include ActionText::Attribute + end + end + + initializer "action_text.active_storage_extension" do + require "active_storage/blob" + + class ActiveStorage::Blob + include ActionText::Attachable + + def previewable_attachable? + representable? + end + end + end + + initializer "action_text.helper" do + ActiveSupport.on_load(:action_controller_base) do + helper ActionText::TagHelper + end + end + + initializer "action_text.config" do + config.after_initialize do |app| + ActionText.renderer ||= ApplicationController.renderer + + # FIXME: ApplicationController should have a per-request specific renderer + # that's been set with the request.env env, and ActionText should just piggyback off + # that by default rather than doing this work directly. + ApplicationController.before_action do + ActionText.renderer = ActionText.renderer.new(request.env) + end + end + end + end +end diff --git a/lib/action_text/fragment.rb b/lib/action_text/fragment.rb new file mode 100644 index 0000000000..63b088f3e1 --- /dev/null +++ b/lib/action_text/fragment.rb @@ -0,0 +1,55 @@ +module ActionText + 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(ActionText::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/action_text/html_conversion.rb b/lib/action_text/html_conversion.rb new file mode 100644 index 0000000000..1c70504fb7 --- /dev/null +++ b/lib/action_text/html_conversion.rb @@ -0,0 +1,22 @@ +module ActionText + 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/action_text/plain_text_conversion.rb b/lib/action_text/plain_text_conversion.rb new file mode 100644 index 0000000000..c9867bd8c9 --- /dev/null +++ b/lib/action_text/plain_text_conversion.rb @@ -0,0 +1,79 @@ +module ActionText + 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 |child, index| + plain_text_for_node(child, 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/action_text/serialization.rb b/lib/action_text/serialization.rb new file mode 100644 index 0000000000..ac2b0602d5 --- /dev/null +++ b/lib/action_text/serialization.rb @@ -0,0 +1,32 @@ +module ActionText + 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/action_text/trix_attachment.rb b/lib/action_text/trix_attachment.rb new file mode 100644 index 0000000000..717d130d12 --- /dev/null +++ b/lib/action_text/trix_attachment.rb @@ -0,0 +1,89 @@ +module ActionText + 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 = ActionText::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 + ActionText::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 diff --git a/lib/action_text/version.rb b/lib/action_text/version.rb new file mode 100644 index 0000000000..bc34656697 --- /dev/null +++ b/lib/action_text/version.rb @@ -0,0 +1,3 @@ +module ActionText + VERSION = '0.1.0' +end |