Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
theozaurus committed Jun 20, 2016
0 parents commit 6e493da
Show file tree
Hide file tree
Showing 18 changed files with 490 additions and 0 deletions.
11 changes: 11 additions & 0 deletions .editorconfig
@@ -0,0 +1,11 @@
# This file is for unifying the coding style for different editors and IDEs
# editorconfig.org

root = true

[*]
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 2
14 changes: 14 additions & 0 deletions .gitignore
@@ -0,0 +1,14 @@
# Ignore built gems
*.gem

# Ignore Rubymine
/.idea

# Ignore bundler settings
/.bundle

# Ignore Gemfile.lock as the Gemspec dictates our requirements
/Gemfile.lock

# Ignore OSX Files
.DS_Store
14 changes: 14 additions & 0 deletions .rubocop.yml
@@ -0,0 +1,14 @@
AllCops:
TargetRubyVersion: 2.1

Style/Documentation:
Enabled: false

BlockDelimiters:
EnforcedStyle: semantic

Metrics/LineLength:
Max: 110

Style/MultilineBlockChain:
Enabled: false
1 change: 1 addition & 0 deletions .ruby-version
@@ -0,0 +1 @@
2.1.9
4 changes: 4 additions & 0 deletions Gemfile
@@ -0,0 +1,4 @@
# frozen_string_literal: true
source 'https://rubygems.org'

gemspec
7 changes: 7 additions & 0 deletions LICENSE.md
@@ -0,0 +1,7 @@
Copyright (c) 2016 Ignition Works Limited

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
83 changes: 83 additions & 0 deletions README.md
@@ -0,0 +1,83 @@
# Draft.js Exporter

[Draft.js](https://facebook.github.io/draft-js/) is a framework for
building rich text editors. However, it does not support exporting
documents at HTML. This gem is designed to take the raw `ContentState`
(output of [`convertToRaw`](https://facebook.github.io/draft-js/docs/api-reference-data-conversion.html#converttoraw))
from Draft.js and convert it to HTML using Ruby.

## Usage

```ruby
# Create configuration for entities and styles
config = {
entity_decorators: {
'LINK' => DraftjsExporter::Entities::Link.new
},
block_map: {
'header-one' => { element: 'h1' },
'unordered-list-item' => {
element: 'li',
wrapper: ['ul', { className: 'public-DraftStyleDefault-ul' }]
},
'unstyled' => { element: 'div' }
},
style_map: {
'ITALIC' => { fontStyle: 'italic' }
}
}

# New up the exporter
exporter = DraftjsExporter::HTML.new(config)

# Provide raw content state
exporter.call({
entityMap: {
'0' => {
type: 'LINK',
mutability: 'MUTABLE',
data: {
url: 'http://example.com'
}
}
},
blocks: [
{
key: '5s7g9',
text: 'Header',
type: 'header-one',
depth: 0,
inlineStyleRanges: [],
entityRanges: []
},
{
key: 'dem5p',
text: 'some paragraph text',
type: 'unstyled',
depth: 0,
inlineStyleRanges: [
{
offset: 0,
length: 4,
style: 'ITALIC'
}
],
entityRanges: [
{
offset: 5,
length: 9,
key: 0
}
]
}
]
})
# => "<h1>Header</h1><div>\n<span style=\"fontStyle: italic;\">some</span> <a href=\"http://example.com\">paragraph</a> text</div>"
```

## Tests

```bash
$ rspec
```

32 changes: 32 additions & 0 deletions draftjs_exporter.gemspec
@@ -0,0 +1,32 @@
# frozen_string_literal: true
require 'pathname'

ROOT_DIR = Pathname.new('.').expand_path(__dir__)
LIB_DIR = ROOT_DIR.join('lib')
SPEC_DIR = ROOT_DIR.join('spec')

$LOAD_PATH.push(LIB_DIR)
require 'draftjs_exporter/version'

Gem::Specification.new do |s|
s.name = 'draftjs_exporter'
s.version = DraftjsExporter::VERSION
s.licenses = ['MIT']
s.summary = 'Export Draft.js content state into HTML'
s.description = File.read(ROOT_DIR.join('README.md'))
s.authors = ['Theo Cushion']
s.email = 'theo@ignition.works'
s.homepage = 'https://github.com/ignitionworks/draftjs_exporter'
s.required_ruby_version = '>= 2.1.0'

s.files = [
ROOT_DIR.join('*.md'),
LIB_DIR.join('**/*.rb'),
SPEC_DIR.join('**/*')
].flat_map { |p| Pathname.glob(p) }.map { |p| p.relative_path_from(ROOT_DIR).to_s }

s.add_runtime_dependency 'nokogiri', '~> 1.6'

s.add_development_dependency 'rspec', '~> 3.4'
s.add_development_dependency 'rubocop', '~> 0.40'
end
3 changes: 3 additions & 0 deletions lib/draftjs_exporter.rb
@@ -0,0 +1,3 @@
# frozen_string_literal: true
require 'draftjs_exporter/version'
require 'draftjs_exporter/html'
4 changes: 4 additions & 0 deletions lib/draftjs_exporter/command.rb
@@ -0,0 +1,4 @@
# frozen_string_literal: true
module DraftjsExporter
Command = Struct.new(:name, :index, :data)
end
12 changes: 12 additions & 0 deletions lib/draftjs_exporter/entities/link.rb
@@ -0,0 +1,12 @@
# frozen_string_literal: true
module DraftjsExporter
module Entities
class Link
def call(parent_element, data)
element = parent_element.document.create_element('a', href: data.fetch(:data, {}).fetch(:url))
parent_element.add_child(element)
element
end
end
end
end
10 changes: 10 additions & 0 deletions lib/draftjs_exporter/entities/null.rb
@@ -0,0 +1,10 @@
# frozen_string_literal: true
module DraftjsExporter
module Entities
class Null
def call(parent_element, _data)
parent_element
end
end
end
end
49 changes: 49 additions & 0 deletions lib/draftjs_exporter/entity_state.rb
@@ -0,0 +1,49 @@
# frozen_string_literal: true
require 'draftjs_exporter/entities/null'

module DraftjsExporter
class EntityState
attr_reader :entity_decorators, :entity_map, :entity_stack, :root_element

def initialize(root_element, entity_decorators, entity_map)
@entity_decorators = entity_decorators
@entity_map = entity_map
@entity_stack = [[Entities::Null.new.call(root_element, nil), nil]]
end

def apply(command)
case command.name
when :start_entity
start_command(command)
when :stop_entity
stop_command(command)
end
end

def current_parent
element, _data = entity_stack.last
element
end

private

def start_command(command)
entity_details = entity_map.fetch(command.data.to_s)
decorator = entity_decorators.fetch(entity_details.fetch(:type))
parent_element = entity_stack.last.first
new_element = decorator.call(parent_element, entity_details)
entity_stack.push([new_element, entity_details])
end

def stop_command(command)
entity_details = entity_map.fetch(command.data.to_s)
_element, expected_entity_details = entity_stack.last

if expected_entity_details != entity_details
raise "Invalid entity. Expected #{expected_entity_details.inspect} got #{entity_details.inspect}"
end

entity_stack.pop
end
end
end
114 changes: 114 additions & 0 deletions lib/draftjs_exporter/html.rb
@@ -0,0 +1,114 @@
# frozen_string_literal: true
require 'nokogiri'
require 'draftjs_exporter/entity_state'
require 'draftjs_exporter/style_state'
require 'draftjs_exporter/command'

module DraftjsExporter
class HTML
attr_reader :block_map, :style_map, :entity_decorators

def initialize(block_map:, style_map:, entity_decorators:)
@block_map = block_map
@style_map = style_map
@entity_decorators = entity_decorators
end

def call(content_state)
content_state.fetch(:blocks, []).map { |block|
content_state_block(block, content_state.fetch(:entityMap, {}))
}.inject(:+)
end

private

def content_state_block(block, entity_map)
document = Nokogiri::HTML::Document.new
fragment = Nokogiri::HTML::DocumentFragment.new(document)
type = block.fetch(:type, 'unstyled')
element = document.create_element(*block_options(type)) { |e|
block_contents(e, block, entity_map)
}
fragment.add_child(element).to_s
end

def block_contents(element, block, entity_map)
style_state = StyleState.new(style_map)
entity_state = EntityState.new(element, entity_decorators, entity_map)
build_command_groups(block).each do |text, commands|
commands.each do |command|
entity_state.apply(command)
style_state.apply(command)
end

add_node(entity_state.current_parent, text, style_state)
end
end

def block_options(type)
options = block_map.fetch(type)
return [options.fetch(:element)] unless options.key?(:wrapper)

wrapper = options.fetch(:wrapper)
name = wrapper[0]
config = wrapper[1] || {}
options = {}
options[:class] = config.fetch(:className) if config.key?(:className)
[name, options]
end

def add_node(element, text, state)
document = element.document
node = if state.text?
document.create_text_node(text)
else
document.create_element('span', text, state.element_attributes)
end
element.add_child(node)
end

def build_command_groups(block)
text = block.fetch(:text)
grouped = build_commands(block).group_by(&:index).sort
grouped.map.with_index { |(index, commands), command_index|
start_index = index
next_group = grouped[command_index + 1]
stop_index = (next_group && next_group.first || 0) - 1
[text.slice(start_index..stop_index), commands]
}
end

def build_commands(block)
[
Command.new(:start_text, 0),
Command.new(:stop_text, block.fetch(:text).size)
] +
build_inline_style_commands(block.fetch(:inlineStyleRanges)) +
build_entity_commands(block.fetch(:entityRanges))
end

def build_inline_style_commands(inline_style_ranges)
inline_style_ranges.flat_map { |style|
data = style.fetch(:style)
start = style.fetch(:offset)
stop = start + style.fetch(:length)
[
Command.new(:start_inline_style, start, data),
Command.new(:stop_inline_style, stop, data)
]
}
end

def build_entity_commands(entity_ranges)
entity_ranges.flat_map { |entity|
data = entity.fetch(:key)
start = entity.fetch(:offset)
stop = start + entity.fetch(:length)
[
Command.new(:start_entity, start, data),
Command.new(:stop_entity, stop, data)
]
}
end
end
end

0 comments on commit 6e493da

Please sign in to comment.