module DraftjsHtml class ToHtml BLOCK_TYPE_TO_HTML = { 'unstyled' => 'p', 'paragraph' => 'p', 'header-one' => 'h1', 'header-two' => 'h2', 'header-three' => 'h3', 'header-four' => 'h4', 'header-five' => 'h5', 'header-six' => 'h6', 'blockquote' => 'blockquote', 'code-block' => 'code', 'ordered-list-item' => 'li', 'unordered-list-item' => 'li', 'atomic' => 'figure', }.freeze BLOCK_TYPE_TO_HTML_WRAPPER = { 'code-block' => 'pre', 'ordered-list-item' => 'ol', 'unordered-list-item' => 'ul', }.freeze STYLE_MAP = { 'BOLD' => 'b', 'ITALIC' => 'i', 'STRIKETHROUGH' => 'del', 'UNDERLINE' => 'u', }.freeze DEFAULT_ENTITY_STYLE_FN = ->(_entity, chars) { chars } ENTITY_ATTRIBUTE_NAME_MAP = { 'className' => 'class', 'url' => 'href', }.freeze ENTITY_CONVERSION_MAP = { 'LINK' => ->(entity, content) { node = Nokogiri::HTML::DocumentFragment.parse('').children.first node.content = content entity.data.slice('url', 'rel', 'target', 'title', 'className').each do |attr, value| node[ENTITY_ATTRIBUTE_NAME_MAP.fetch(attr, attr)] = value end node }, 'IMAGE' => ->(entity, _content) { node = Nokogiri::HTML::DocumentFragment.parse('').children.first entity.data.slice('src', 'alt', 'className', 'width', 'height').each do |attr, value| node[ENTITY_ATTRIBUTE_NAME_MAP.fetch(attr, attr)] = value end node } }.freeze def initialize(options) @document = Nokogiri::HTML::Builder.new @options = ensure_options!(options) end def convert(raw_draftjs) draftjs = Draftjs.parse(raw_draftjs) @document.html do |html| html.body do |body| @previous_parent = body.parent draftjs.blocks.each do |block| ensure_nesting_depth(block, body) body.public_send(block_element_for(block)) do |block_body| block.each_range do |char_range| content = try_apply_entity_to(draftjs, char_range) apply_styles_to(block_body, char_range.style_names, content) end end end end end @document.doc.css('body').first.children.to_html.strip end private def ensure_nesting_depth(block, body) new_wrapper_tag = BLOCK_TYPE_TO_HTML_WRAPPER[block.type] if body.parent.name != new_wrapper_tag if new_wrapper_tag push_nesting(body, new_wrapper_tag) else pop_nesting(body) end end end def apply_styles_to(html, style_names, text) return html.parent << text if style_names.empty? custom_render_content = @options[:inline_style_renderer].call(style_names, text) return html.parent << custom_render_content if custom_render_content style, *rest = style_names html.public_send(style_element_for(style)) do apply_styles_to(html, rest, text) end end def block_element_for(block) return 'br' if block.blank? @options[:block_type_mapping].fetch(block.type) end def style_element_for(style) @options[:inline_style_mapping][style] end def try_apply_entity_to(draftjs, char_range) entity = draftjs.find_entity(char_range.entity_key) content = char_range.text content = (@options[:entity_style_mappings][entity.type] || DEFAULT_ENTITY_STYLE_FN).call(entity, content) if entity content end def push_nesting(builder, tagname) node = create_child(builder, tagname) @previous_parent = builder.parent builder.parent = node end def pop_nesting(builder) builder.parent = @previous_parent end def create_child(builder, tagname) builder.parent.add_child(builder.doc.create_element(tagname)) end def ensure_options!(opts) opts[:entity_style_mappings] = ENTITY_CONVERSION_MAP.merge(opts[:entity_style_mappings] || {}).transform_keys(&:to_s) opts[:block_type_mapping] = BLOCK_TYPE_TO_HTML.merge(opts[:block_type_mapping] || {}) opts[:inline_style_mapping] = STYLE_MAP.merge(opts[:inline_style_mapping] || {}).transform_keys(&:to_s) opts[:inline_style_renderer] ||= ->(*) { nil } opts end end end